oauth-demo.html 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. <!-- This file is a copy of examples/oauth-demo.html for direct serving under /oauth-demo.html -->
  2. <!doctype html>
  3. <html lang="zh-CN">
  4. <head>
  5. <meta charset="utf-8" />
  6. <meta name="viewport" content="width=device-width, initial-scale=1" />
  7. <title>OAuth2/OIDC 授权码 + PKCE 前端演示</title>
  8. <style>
  9. :root {
  10. --bg: #0b0c10;
  11. --panel: #111317;
  12. --muted: #aab2bf;
  13. --accent: #3b82f6;
  14. --ok: #16a34a;
  15. --warn: #f59e0b;
  16. --err: #ef4444;
  17. --border: #1f2430;
  18. }
  19. body {
  20. margin: 0;
  21. font-family:
  22. ui-sans-serif,
  23. system-ui,
  24. -apple-system,
  25. Segoe UI,
  26. Roboto,
  27. Helvetica,
  28. Arial;
  29. background: var(--bg);
  30. color: #e5e7eb;
  31. }
  32. .wrap {
  33. max-width: 980px;
  34. margin: 32px auto;
  35. padding: 0 16px;
  36. }
  37. h1 {
  38. font-size: 22px;
  39. margin: 0 0 16px;
  40. }
  41. .card {
  42. background: var(--panel);
  43. border: 1px solid var(--border);
  44. border-radius: 10px;
  45. padding: 16px;
  46. margin: 12px 0;
  47. }
  48. .row {
  49. display: flex;
  50. gap: 12px;
  51. flex-wrap: wrap;
  52. }
  53. .col {
  54. flex: 1 1 280px;
  55. display: flex;
  56. flex-direction: column;
  57. }
  58. label {
  59. font-size: 12px;
  60. color: var(--muted);
  61. margin-bottom: 6px;
  62. }
  63. input,
  64. textarea,
  65. select {
  66. background: #0f1115;
  67. color: #e5e7eb;
  68. border: 1px solid var(--border);
  69. padding: 10px 12px;
  70. border-radius: 8px;
  71. outline: none;
  72. }
  73. textarea {
  74. min-height: 100px;
  75. resize: vertical;
  76. }
  77. .btns {
  78. display: flex;
  79. gap: 8px;
  80. flex-wrap: wrap;
  81. margin-top: 8px;
  82. }
  83. button {
  84. background: #1a1f2b;
  85. color: #e5e7eb;
  86. border: 1px solid var(--border);
  87. padding: 8px 12px;
  88. border-radius: 8px;
  89. cursor: pointer;
  90. }
  91. button.primary {
  92. background: var(--accent);
  93. border-color: var(--accent);
  94. color: white;
  95. }
  96. button.ok {
  97. background: var(--ok);
  98. border-color: var(--ok);
  99. color: white;
  100. }
  101. button.warn {
  102. background: var(--warn);
  103. border-color: var(--warn);
  104. color: black;
  105. }
  106. button.ghost {
  107. background: transparent;
  108. }
  109. .muted {
  110. color: var(--muted);
  111. font-size: 12px;
  112. }
  113. .mono {
  114. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
  115. 'Liberation Mono', 'Courier New', monospace;
  116. }
  117. .grid2 {
  118. display: grid;
  119. grid-template-columns: 1fr 1fr;
  120. gap: 12px;
  121. }
  122. @media (max-width: 880px) {
  123. .grid2 {
  124. grid-template-columns: 1fr;
  125. }
  126. }
  127. .ok {
  128. color: #10b981;
  129. }
  130. .err {
  131. color: #ef4444;
  132. }
  133. .sep {
  134. height: 1px;
  135. background: var(--border);
  136. margin: 12px 0;
  137. }
  138. </style>
  139. </head>
  140. <body>
  141. <div class="wrap">
  142. <h1>OAuth2/OIDC 授权码 + PKCE 前端演示</h1>
  143. <div class="card">
  144. <div class="row">
  145. <div class="col">
  146. <label
  147. >Issuer(可选,用于自动发现
  148. /.well-known/openid-configuration)</label
  149. >
  150. <input id="issuer" placeholder="https://your-domain" />
  151. <div class="btns">
  152. <button class="" id="btnDiscover">自动发现端点</button>
  153. </div>
  154. <div class="muted">提示:若未配置 Issuer,可直接填写下方端点。</div>
  155. </div>
  156. </div>
  157. <div class="row">
  158. <div class="col">
  159. <label>Response Type</label>
  160. <select id="response_type">
  161. <option value="code" selected>code</option>
  162. <option value="token">token</option>
  163. </select>
  164. </div>
  165. <div class="col">
  166. <label>Authorization Endpoint</label
  167. ><input
  168. id="authorization_endpoint"
  169. placeholder="https://domain/api/oauth/authorize"
  170. />
  171. </div>
  172. <div class="col">
  173. <label>Token Endpoint</label
  174. ><input
  175. id="token_endpoint"
  176. placeholder="https://domain/api/oauth/token"
  177. />
  178. </div>
  179. </div>
  180. <div class="row">
  181. <div class="col">
  182. <label>UserInfo Endpoint(可选)</label
  183. ><input
  184. id="userinfo_endpoint"
  185. placeholder="https://domain/api/oauth/userinfo"
  186. />
  187. </div>
  188. <div class="col">
  189. <label>Client ID</label
  190. ><input id="client_id" placeholder="your-public-client-id" />
  191. </div>
  192. </div>
  193. <div class="row">
  194. <div class="col">
  195. <label>Client Secret(可选,机密客户端)</label
  196. ><input id="client_secret" placeholder="留空表示公开客户端" />
  197. </div>
  198. </div>
  199. <div class="row">
  200. <div class="col">
  201. <label>Redirect URI(当前页地址或你的回调)</label
  202. ><input id="redirect_uri" />
  203. </div>
  204. <div class="col">
  205. <label>Scope</label
  206. ><input id="scope" value="openid profile email" />
  207. </div>
  208. </div>
  209. <div class="row">
  210. <div class="col"><label>State</label><input id="state" /></div>
  211. <div class="col"><label>Nonce</label><input id="nonce" /></div>
  212. </div>
  213. <div class="row">
  214. <div class="col">
  215. <label>Code Verifier(自动生成,不会上送)</label
  216. ><input id="code_verifier" class="mono" readonly />
  217. </div>
  218. <div class="col">
  219. <label>Code Challenge(S256)</label
  220. ><input id="code_challenge" class="mono" readonly />
  221. </div>
  222. </div>
  223. <div class="btns">
  224. <button id="btnGenPkce">生成 PKCE</button>
  225. <button id="btnRandomState">随机 State</button>
  226. <button id="btnRandomNonce">随机 Nonce</button>
  227. <button id="btnMakeAuthURL">生成授权链接</button>
  228. <button id="btnAuthorize" class="primary">跳转授权</button>
  229. </div>
  230. <div class="row" style="margin-top: 8px">
  231. <div class="col">
  232. <label>授权链接(只生成不跳转)</label>
  233. <textarea
  234. id="authorize_url"
  235. class="mono"
  236. placeholder="(空)"
  237. ></textarea>
  238. <div class="btns">
  239. <button id="btnCopyAuthURL">复制链接</button>
  240. </div>
  241. </div>
  242. </div>
  243. <div class="sep"></div>
  244. <div class="muted">
  245. 说明:
  246. <ul>
  247. <li>
  248. 本页为纯前端演示,适用于公开客户端(不需要 client_secret)。
  249. </li>
  250. <li>
  251. 如跨域调用 Token/UserInfo,需要服务端正确设置 CORS;建议将此 demo
  252. 部署到同源域名下。
  253. </li>
  254. </ul>
  255. </div>
  256. <div class="sep"></div>
  257. <div class="row">
  258. <div class="col">
  259. <label
  260. >粘贴 OIDC Discovery
  261. JSON(/.well-known/openid-configuration)</label
  262. >
  263. <textarea
  264. id="conf_json"
  265. class="mono"
  266. placeholder='{"issuer":"https://...","authorization_endpoint":"...","token_endpoint":"...","userinfo_endpoint":"..."}'
  267. ></textarea>
  268. <div class="btns">
  269. <button id="btnParseConf">解析并填充端点</button>
  270. <button id="btnGenConf">用当前端点生成 JSON</button>
  271. </div>
  272. <div class="muted">
  273. 可将服务端返回的 OIDC Discovery JSON
  274. 粘贴到此处,点击“解析并填充端点”。
  275. </div>
  276. </div>
  277. </div>
  278. </div>
  279. <div class="card">
  280. <div class="row">
  281. <div class="col">
  282. <label>授权结果</label>
  283. <div id="authResult" class="muted">等待授权...</div>
  284. </div>
  285. </div>
  286. <div class="grid2" style="margin-top: 12px">
  287. <div>
  288. <label>Access Token</label>
  289. <textarea
  290. id="access_token"
  291. class="mono"
  292. placeholder="(空)"
  293. ></textarea>
  294. <div class="btns">
  295. <button id="btnCopyAT">复制</button
  296. ><button id="btnCallUserInfo" class="ok">调用 UserInfo</button>
  297. </div>
  298. <div id="userinfoOut" class="muted" style="margin-top: 6px"></div>
  299. </div>
  300. <div>
  301. <label>ID Token(JWT)</label>
  302. <textarea id="id_token" class="mono" placeholder="(空)"></textarea>
  303. <div class="btns">
  304. <button id="btnDecodeJWT">解码显示 Claims</button>
  305. </div>
  306. <pre
  307. id="jwtClaims"
  308. class="mono"
  309. style="
  310. white-space: pre-wrap;
  311. word-break: break-all;
  312. margin-top: 6px;
  313. "
  314. ></pre>
  315. </div>
  316. </div>
  317. <div class="grid2" style="margin-top: 12px">
  318. <div>
  319. <label>Refresh Token</label>
  320. <textarea
  321. id="refresh_token"
  322. class="mono"
  323. placeholder="(空)"
  324. ></textarea>
  325. <div class="btns">
  326. <button id="btnRefreshToken">使用 Refresh Token 刷新</button>
  327. </div>
  328. </div>
  329. <div>
  330. <label>原始 Token 响应</label>
  331. <textarea id="token_raw" class="mono" placeholder="(空)"></textarea>
  332. </div>
  333. </div>
  334. </div>
  335. </div>
  336. <script>
  337. const $ = (id) => document.getElementById(id);
  338. const toB64Url = (buf) =>
  339. btoa(String.fromCharCode(...new Uint8Array(buf)))
  340. .replace(/\+/g, '-')
  341. .replace(/\//g, '_')
  342. .replace(/=+$/, '');
  343. async function sha256B64Url(str) {
  344. const data = new TextEncoder().encode(str);
  345. const digest = await crypto.subtle.digest('SHA-256', data);
  346. return toB64Url(digest);
  347. }
  348. function randStr(len = 64) {
  349. const chars =
  350. 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  351. const arr = new Uint8Array(len);
  352. crypto.getRandomValues(arr);
  353. return Array.from(arr, (v) => chars[v % chars.length]).join('');
  354. }
  355. function setAuthInfo(msg, ok = true) {
  356. const el = $('authResult');
  357. el.textContent = msg;
  358. el.className = ok ? 'ok' : 'err';
  359. }
  360. function qs(name) {
  361. const u = new URL(location.href);
  362. return u.searchParams.get(name);
  363. }
  364. function persist(k, v) {
  365. sessionStorage.setItem('demo_' + k, v);
  366. }
  367. function load(k) {
  368. return sessionStorage.getItem('demo_' + k) || '';
  369. }
  370. (function init() {
  371. $('redirect_uri').value =
  372. window.location.origin + window.location.pathname;
  373. const iss = load('issuer');
  374. if (iss) $('issuer').value = iss;
  375. const cid = load('client_id');
  376. if (cid) $('client_id').value = cid;
  377. const scp = load('scope');
  378. if (scp) $('scope').value = scp;
  379. })();
  380. $('btnDiscover').onclick = async () => {
  381. const iss = $('issuer').value.trim();
  382. if (!iss) {
  383. alert('请填写 Issuer');
  384. return;
  385. }
  386. try {
  387. persist('issuer', iss);
  388. const res = await fetch(
  389. iss.replace(/\/$/, '') + '/api/.well-known/openid-configuration',
  390. );
  391. const d = await res.json();
  392. $('authorization_endpoint').value = d.authorization_endpoint || '';
  393. $('token_endpoint').value = d.token_endpoint || '';
  394. $('userinfo_endpoint').value = d.userinfo_endpoint || '';
  395. if (d.issuer) {
  396. $('issuer').value = d.issuer;
  397. persist('issuer', d.issuer);
  398. }
  399. $('conf_json').value = JSON.stringify(d, null, 2);
  400. setAuthInfo('已从发现文档加载端点', true);
  401. } catch (e) {
  402. setAuthInfo('自动发现失败:' + e, false);
  403. }
  404. };
  405. $('btnGenPkce').onclick = async () => {
  406. const v = randStr(64);
  407. const c = await sha256B64Url(v);
  408. $('code_verifier').value = v;
  409. $('code_challenge').value = c;
  410. persist('code_verifier', v);
  411. persist('code_challenge', c);
  412. setAuthInfo('已生成 PKCE 参数', true);
  413. };
  414. $('btnRandomState').onclick = () => {
  415. $('state').value = randStr(16);
  416. persist('state', $('state').value);
  417. };
  418. $('btnRandomNonce').onclick = () => {
  419. $('nonce').value = randStr(16);
  420. persist('nonce', $('nonce').value);
  421. };
  422. function buildAuthorizeURLFromFields() {
  423. const auth = $('authorization_endpoint').value.trim();
  424. const token = $('token_endpoint').value.trim();
  425. const cid = $('client_id').value.trim();
  426. const red = $('redirect_uri').value.trim();
  427. const scp = $('scope').value.trim() || 'openid profile email';
  428. const rt = $('response_type').value;
  429. const st = $('state').value.trim() || randStr(16);
  430. const no = $('nonce').value.trim() || randStr(16);
  431. const cc = $('code_challenge').value.trim();
  432. const cv = $('code_verifier').value.trim();
  433. if (!auth || !cid || !red) {
  434. throw new Error('请先完善端点/ClientID/RedirectURI');
  435. }
  436. if (rt === 'code' && (!cc || !cv)) {
  437. throw new Error('请先生成 PKCE');
  438. }
  439. persist('authorization_endpoint', auth);
  440. persist('token_endpoint', token);
  441. persist('client_id', cid);
  442. persist('redirect_uri', red);
  443. persist('scope', scp);
  444. persist('state', st);
  445. persist('nonce', no);
  446. persist('code_verifier', cv);
  447. const u = new URL(auth);
  448. u.searchParams.set('response_type', rt);
  449. u.searchParams.set('client_id', cid);
  450. u.searchParams.set('redirect_uri', red);
  451. u.searchParams.set('scope', scp);
  452. u.searchParams.set('state', st);
  453. if (no) u.searchParams.set('nonce', no);
  454. if (rt === 'code') {
  455. u.searchParams.set('code_challenge', cc);
  456. u.searchParams.set('code_challenge_method', 'S256');
  457. }
  458. return u.toString();
  459. }
  460. $('btnMakeAuthURL').onclick = () => {
  461. try {
  462. const url = buildAuthorizeURLFromFields();
  463. $('authorize_url').value = url;
  464. setAuthInfo('已生成授权链接', true);
  465. } catch (e) {
  466. setAuthInfo(e.message, false);
  467. }
  468. };
  469. $('btnAuthorize').onclick = () => {
  470. try {
  471. const url = buildAuthorizeURLFromFields();
  472. location.href = url;
  473. } catch (e) {
  474. setAuthInfo(e.message, false);
  475. }
  476. };
  477. $('btnCopyAuthURL').onclick = async () => {
  478. try {
  479. await navigator.clipboard.writeText($('authorize_url').value);
  480. } catch {}
  481. };
  482. async function postForm(url, data, basic) {
  483. const body = Object.entries(data)
  484. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  485. .join('&');
  486. const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
  487. if (basic && basic.id && basic.secret) {
  488. headers['Authorization'] =
  489. 'Basic ' + btoa(`${basic.id}:${basic.secret}`);
  490. }
  491. const res = await fetch(url, { method: 'POST', headers, body });
  492. if (!res.ok) {
  493. const t = await res.text();
  494. throw new Error(`HTTP ${res.status} ${t}`);
  495. }
  496. return res.json();
  497. }
  498. async function handleCallback() {
  499. const frag =
  500. location.hash && location.hash.startsWith('#')
  501. ? new URLSearchParams(location.hash.slice(1))
  502. : null;
  503. const at = frag ? frag.get('access_token') : null;
  504. const err = qs('error') || (frag ? frag.get('error') : null);
  505. const state = qs('state') || (frag ? frag.get('state') : null);
  506. if (err) {
  507. setAuthInfo('授权失败:' + err, false);
  508. return;
  509. }
  510. if (at) {
  511. $('access_token').value = at || '';
  512. $('token_raw').value = JSON.stringify(
  513. {
  514. access_token: at,
  515. token_type: frag.get('token_type'),
  516. expires_in: frag.get('expires_in'),
  517. scope: frag.get('scope'),
  518. state,
  519. },
  520. null,
  521. 2,
  522. );
  523. setAuthInfo('隐式模式已获取 Access Token', true);
  524. return;
  525. }
  526. const code = qs('code');
  527. if (!code) {
  528. setAuthInfo('等待授权...', true);
  529. return;
  530. }
  531. if (state && load('state') && state !== load('state')) {
  532. setAuthInfo('state 不匹配,已拒绝', false);
  533. return;
  534. }
  535. try {
  536. const tokenEp = load('token_endpoint');
  537. const cid = load('client_id');
  538. const csec = $('client_secret').value.trim();
  539. const basic = csec ? { id: cid, secret: csec } : null;
  540. const data = await postForm(
  541. tokenEp,
  542. {
  543. grant_type: 'authorization_code',
  544. code,
  545. client_id: cid,
  546. redirect_uri: load('redirect_uri'),
  547. code_verifier: load('code_verifier'),
  548. },
  549. basic,
  550. );
  551. $('access_token').value = data.access_token || '';
  552. $('id_token').value = data.id_token || '';
  553. $('refresh_token').value = data.refresh_token || '';
  554. $('token_raw').value = JSON.stringify(data, null, 2);
  555. setAuthInfo('授权成功,已获取令牌', true);
  556. } catch (e) {
  557. setAuthInfo('交换令牌失败:' + e.message, false);
  558. }
  559. }
  560. handleCallback();
  561. $('btnCopyAT').onclick = async () => {
  562. try {
  563. await navigator.clipboard.writeText($('access_token').value);
  564. } catch {}
  565. };
  566. $('btnDecodeJWT').onclick = () => {
  567. const t = $('id_token').value.trim();
  568. if (!t) {
  569. $('jwtClaims').textContent = '(空)';
  570. return;
  571. }
  572. const parts = t.split('.');
  573. if (parts.length < 2) {
  574. $('jwtClaims').textContent = '格式错误';
  575. return;
  576. }
  577. try {
  578. const json = JSON.parse(
  579. atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')),
  580. );
  581. $('jwtClaims').textContent = JSON.stringify(json, null, 2);
  582. } catch (e) {
  583. $('jwtClaims').textContent = '解码失败:' + e;
  584. }
  585. };
  586. $('btnCallUserInfo').onclick = async () => {
  587. const at = $('access_token').value.trim();
  588. const ep = $('userinfo_endpoint').value.trim();
  589. if (!at || !ep) {
  590. alert('请填写UserInfo端点并获取AccessToken');
  591. return;
  592. }
  593. try {
  594. const res = await fetch(ep, {
  595. headers: { Authorization: 'Bearer ' + at },
  596. });
  597. const data = await res.json();
  598. $('userinfoOut').textContent = JSON.stringify(data, null, 2);
  599. } catch (e) {
  600. $('userinfoOut').textContent = '调用失败:' + e;
  601. }
  602. };
  603. $('btnRefreshToken').onclick = async () => {
  604. const rt = $('refresh_token').value.trim();
  605. if (!rt) {
  606. alert('没有刷新令牌');
  607. return;
  608. }
  609. try {
  610. const tokenEp = load('token_endpoint');
  611. const cid = load('client_id');
  612. const csec = $('client_secret').value.trim();
  613. const basic = csec ? { id: cid, secret: csec } : null;
  614. const data = await postForm(
  615. tokenEp,
  616. { grant_type: 'refresh_token', refresh_token: rt, client_id: cid },
  617. basic,
  618. );
  619. $('access_token').value = data.access_token || '';
  620. $('id_token').value = data.id_token || '';
  621. $('refresh_token').value = data.refresh_token || '';
  622. $('token_raw').value = JSON.stringify(data, null, 2);
  623. setAuthInfo('刷新成功', true);
  624. } catch (e) {
  625. setAuthInfo('刷新失败:' + e.message, false);
  626. }
  627. };
  628. $('btnParseConf').onclick = () => {
  629. const txt = $('conf_json').value.trim();
  630. if (!txt) {
  631. alert('请先粘贴 JSON');
  632. return;
  633. }
  634. try {
  635. const d = JSON.parse(txt);
  636. if (d.issuer) {
  637. $('issuer').value = d.issuer;
  638. persist('issuer', d.issuer);
  639. }
  640. if (d.authorization_endpoint)
  641. $('authorization_endpoint').value = d.authorization_endpoint;
  642. if (d.token_endpoint) $('token_endpoint').value = d.token_endpoint;
  643. if (d.userinfo_endpoint)
  644. $('userinfo_endpoint').value = d.userinfo_endpoint;
  645. setAuthInfo('已解析配置并填充端点', true);
  646. } catch (e) {
  647. setAuthInfo('解析失败:' + e, false);
  648. }
  649. };
  650. $('btnGenConf').onclick = () => {
  651. const d = {
  652. issuer: $('issuer').value.trim() || undefined,
  653. authorization_endpoint:
  654. $('authorization_endpoint').value.trim() || undefined,
  655. token_endpoint: $('token_endpoint').value.trim() || undefined,
  656. userinfo_endpoint: $('userinfo_endpoint').value.trim() || undefined,
  657. };
  658. $('conf_json').value = JSON.stringify(d, null, 2);
  659. };
  660. </script>
  661. </body>
  662. </html>