948 lines
31 KiB
HTML
948 lines
31 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>OAuth2/OIDC 联调客户端</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f4f7fb;
|
||
--card: #ffffff;
|
||
--text: #0f172a;
|
||
--muted: #475569;
|
||
--line: #dbe2ea;
|
||
--brand: #0f766e;
|
||
--brand-soft: #e6f4f2;
|
||
--danger: #b91c1c;
|
||
--warn: #92400e;
|
||
--ok: #166534;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
background: radial-gradient(circle at top right, #d9efe8 0%, var(--bg) 35%);
|
||
color: var(--text);
|
||
}
|
||
|
||
.container {
|
||
max-width: 1200px;
|
||
margin: 24px auto;
|
||
padding: 0 16px 24px;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
h1 {
|
||
margin: 0;
|
||
font-size: 24px;
|
||
}
|
||
|
||
.subtitle {
|
||
margin: 4px 0 0;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(12, 1fr);
|
||
gap: 14px;
|
||
}
|
||
|
||
.card {
|
||
grid-column: span 12;
|
||
background: var(--card);
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
padding: 14px;
|
||
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.card h2 {
|
||
margin: 0 0 10px;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.card .hint {
|
||
margin-top: -4px;
|
||
margin-bottom: 10px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.fields {
|
||
display: grid;
|
||
grid-template-columns: repeat(12, 1fr);
|
||
gap: 10px;
|
||
}
|
||
|
||
.field {
|
||
grid-column: span 12;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.field.s6 { grid-column: span 6; }
|
||
.field.s4 { grid-column: span 4; }
|
||
.field.s3 { grid-column: span 3; }
|
||
|
||
label {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
font-weight: 600;
|
||
}
|
||
|
||
input, select, textarea {
|
||
width: 100%;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 9px 10px;
|
||
font-size: 14px;
|
||
outline: none;
|
||
background: #fff;
|
||
color: var(--text);
|
||
}
|
||
|
||
input:focus, select:focus, textarea:focus {
|
||
border-color: var(--brand);
|
||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||
}
|
||
|
||
textarea {
|
||
min-height: 120px;
|
||
resize: vertical;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.btns {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
button {
|
||
border: 1px solid var(--line);
|
||
background: #fff;
|
||
color: var(--text);
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
button:hover { background: #f8fafc; }
|
||
|
||
button.primary {
|
||
background: var(--brand);
|
||
border-color: var(--brand);
|
||
color: #fff;
|
||
}
|
||
|
||
button.primary:hover { filter: brightness(0.95); }
|
||
|
||
button.soft {
|
||
border-color: #bfe4dd;
|
||
background: var(--brand-soft);
|
||
color: #0f5f58;
|
||
}
|
||
|
||
.status {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
border: 1px solid var(--line);
|
||
background: #fff;
|
||
}
|
||
|
||
.status.ok { color: var(--ok); border-color: #bbf7d0; background: #f0fdf4; }
|
||
.status.warn { color: var(--warn); border-color: #fde68a; background: #fffbeb; }
|
||
.status.err { color: var(--danger); border-color: #fecaca; background: #fef2f2; }
|
||
|
||
.split {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
}
|
||
|
||
.kv {
|
||
display: grid;
|
||
grid-template-columns: 150px 1fr;
|
||
gap: 8px;
|
||
align-items: center;
|
||
font-size: 13px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.kv .key { color: var(--muted); }
|
||
|
||
.mono {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.logs {
|
||
min-height: 180px;
|
||
max-height: 340px;
|
||
overflow: auto;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: #0b1220;
|
||
color: #d2deff;
|
||
padding: 10px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
.split { grid-template-columns: 1fr; }
|
||
.field.s6, .field.s4, .field.s3 { grid-column: span 12; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<div>
|
||
<h1>OAuth2/OIDC 联调客户端</h1>
|
||
<p class="subtitle">独立静态页,不依赖现有系统前端。用于授权码流程联调、排错和回归验证。</p>
|
||
</div>
|
||
<div id="status" class="status">未执行</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<section class="card">
|
||
<h2>服务器配置</h2>
|
||
<div class="fields">
|
||
<div class="field s6"><label for="issuer">issuer</label><input id="issuer" placeholder="http://localhost" /></div>
|
||
<div class="field s6"><label for="discovery_endpoint">discovery_endpoint</label><input id="discovery_endpoint" placeholder="http://localhost/.well-known/openid-configuration" /></div>
|
||
<div class="field s6"><label for="authorize_endpoint">authorize_endpoint</label><input id="authorize_endpoint" placeholder="http://localhost/oauth/authorize" /></div>
|
||
<div class="field s6"><label for="token_endpoint">token_endpoint</label><input id="token_endpoint" placeholder="http://localhost/oauth/token" /></div>
|
||
<div class="field s6"><label for="userinfo_endpoint">userinfo_endpoint</label><input id="userinfo_endpoint" placeholder="http://localhost/oauth/userinfo" /></div>
|
||
<div class="field s6"><label for="revoke_endpoint">revoke_endpoint</label><input id="revoke_endpoint" placeholder="http://localhost/oauth/revoke" /></div>
|
||
</div>
|
||
<div class="btns" style="margin-top: 10px;">
|
||
<button class="soft" id="btn-discovery">发现配置</button>
|
||
<button id="btn-fill-defaults">按 issuer 自动填充端点</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>客户端配置</h2>
|
||
<div class="fields">
|
||
<div class="field s4"><label for="client_id">client_id</label><input id="client_id" /></div>
|
||
<div class="field s4"><label for="client_secret">client_secret</label><input id="client_secret" type="password" /></div>
|
||
<div class="field s4"><label for="redirect_uri">redirect_uri</label><input id="redirect_uri" /></div>
|
||
<div class="field s4"><label for="scope">scope</label><input id="scope" placeholder="openid profile email phone" /></div>
|
||
<div class="field s4"><label for="state">state</label><input id="state" /></div>
|
||
<div class="field s4"><label for="nonce">nonce</label><input id="nonce" /></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>高级选项</h2>
|
||
<div class="fields">
|
||
<div class="field s4">
|
||
<label for="auth_mode">token/revoke 客户端认证</label>
|
||
<select id="auth_mode">
|
||
<option value="basic_first">Basic 优先(失败可切 post)</option>
|
||
<option value="basic_only">仅 Basic</option>
|
||
<option value="post_only">仅 client_secret_post</option>
|
||
</select>
|
||
</div>
|
||
<div class="field s4">
|
||
<label for="pkce_enabled">PKCE</label>
|
||
<select id="pkce_enabled">
|
||
<option value="false">关闭</option>
|
||
<option value="true">开启(S256)</option>
|
||
</select>
|
||
</div>
|
||
<div class="field s4">
|
||
<label for="token_type_hint">revoke token_type_hint</label>
|
||
<select id="token_type_hint">
|
||
<option value="">不传</option>
|
||
<option value="access_token">access_token</option>
|
||
<option value="refresh_token">refresh_token</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<p class="hint">说明:PKCE 开启后会自动生成 code_verifier/code_challenge,并在授权与换 token 时自动带上。</p>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>操作</h2>
|
||
<div class="btns">
|
||
<button class="primary" id="btn-authorize">发起授权</button>
|
||
<button id="btn-parse-callback">解析回调</button>
|
||
<button id="btn-exchange-code">换取 Token</button>
|
||
<button id="btn-userinfo">拉取 UserInfo</button>
|
||
<button id="btn-refresh">刷新 Token</button>
|
||
<button id="btn-revoke-access">撤销 Access Token</button>
|
||
<button id="btn-revoke-refresh">撤销 Refresh Token</button>
|
||
<button id="btn-clear-state">清空状态</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>结果与状态</h2>
|
||
<div class="split">
|
||
<div>
|
||
<div class="kv"><div class="key">授权 code</div><div id="out-code" class="mono">-</div></div>
|
||
<div class="kv"><div class="key">state 校验</div><div id="out-state-check" class="mono">-</div></div>
|
||
<div class="kv"><div class="key">access_token</div><div id="out-access" class="mono">-</div></div>
|
||
<div class="kv"><div class="key">refresh_token</div><div id="out-refresh" class="mono">-</div></div>
|
||
<div class="kv"><div class="key">id_token</div><div id="out-idtoken" class="mono">-</div></div>
|
||
</div>
|
||
<div>
|
||
<label for="response_json">最近响应 JSON</label>
|
||
<textarea id="response_json" readonly></textarea>
|
||
<div class="btns" style="margin-top: 8px;">
|
||
<button id="btn-copy-response">复制响应 JSON</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>日志</h2>
|
||
<div class="btns" style="margin-bottom: 8px;">
|
||
<button id="btn-copy-log">复制日志</button>
|
||
<button id="btn-clear-log">清空日志</button>
|
||
</div>
|
||
<div id="logs" class="logs"></div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(() => {
|
||
const STORAGE_KEY = 'oauth_test_config_v1';
|
||
const TOKEN_KEY = 'oauth_test_token_state_v1';
|
||
|
||
const fields = {
|
||
issuer: document.getElementById('issuer'),
|
||
discovery_endpoint: document.getElementById('discovery_endpoint'),
|
||
authorize_endpoint: document.getElementById('authorize_endpoint'),
|
||
token_endpoint: document.getElementById('token_endpoint'),
|
||
userinfo_endpoint: document.getElementById('userinfo_endpoint'),
|
||
revoke_endpoint: document.getElementById('revoke_endpoint'),
|
||
client_id: document.getElementById('client_id'),
|
||
client_secret: document.getElementById('client_secret'),
|
||
redirect_uri: document.getElementById('redirect_uri'),
|
||
scope: document.getElementById('scope'),
|
||
state: document.getElementById('state'),
|
||
nonce: document.getElementById('nonce'),
|
||
auth_mode: document.getElementById('auth_mode'),
|
||
pkce_enabled: document.getElementById('pkce_enabled'),
|
||
token_type_hint: document.getElementById('token_type_hint'),
|
||
response_json: document.getElementById('response_json')
|
||
};
|
||
|
||
const outputs = {
|
||
status: document.getElementById('status'),
|
||
code: document.getElementById('out-code'),
|
||
stateCheck: document.getElementById('out-state-check'),
|
||
access: document.getElementById('out-access'),
|
||
refresh: document.getElementById('out-refresh'),
|
||
idtoken: document.getElementById('out-idtoken'),
|
||
logs: document.getElementById('logs')
|
||
};
|
||
|
||
const tokenState = {
|
||
code: '',
|
||
stateFromCallback: '',
|
||
access_token: '',
|
||
refresh_token: '',
|
||
id_token: '',
|
||
token_type: 'Bearer',
|
||
expires_in: 0,
|
||
code_verifier: ''
|
||
};
|
||
|
||
function now() {
|
||
return new Date().toLocaleString();
|
||
}
|
||
|
||
function log(message, level = 'info') {
|
||
const prefix = level === 'error' ? '[ERR]' : level === 'warn' ? '[WRN]' : '[INF]';
|
||
outputs.logs.textContent += `${now()} ${prefix} ${message}\n`;
|
||
outputs.logs.scrollTop = outputs.logs.scrollHeight;
|
||
}
|
||
|
||
function setStatus(text, kind = '') {
|
||
outputs.status.textContent = text;
|
||
outputs.status.className = `status${kind ? ` ${kind}` : ''}`;
|
||
}
|
||
|
||
function randomString(len = 24) {
|
||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||
let result = '';
|
||
const array = new Uint8Array(len);
|
||
crypto.getRandomValues(array);
|
||
for (let i = 0; i < len; i += 1) {
|
||
result += chars[array[i] % chars.length];
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function base64UrlEncode(bytes) {
|
||
const bin = String.fromCharCode(...bytes);
|
||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||
}
|
||
|
||
async function sha256Base64Url(str) {
|
||
const encoded = new TextEncoder().encode(str);
|
||
const digest = await crypto.subtle.digest('SHA-256', encoded);
|
||
return base64UrlEncode(new Uint8Array(digest));
|
||
}
|
||
|
||
function getConfig() {
|
||
return {
|
||
issuer: fields.issuer.value.trim(),
|
||
discovery_endpoint: fields.discovery_endpoint.value.trim(),
|
||
authorize_endpoint: fields.authorize_endpoint.value.trim(),
|
||
token_endpoint: fields.token_endpoint.value.trim(),
|
||
userinfo_endpoint: fields.userinfo_endpoint.value.trim(),
|
||
revoke_endpoint: fields.revoke_endpoint.value.trim(),
|
||
client_id: fields.client_id.value.trim(),
|
||
client_secret: fields.client_secret.value,
|
||
redirect_uri: fields.redirect_uri.value.trim(),
|
||
scope: fields.scope.value.trim(),
|
||
state: fields.state.value.trim(),
|
||
nonce: fields.nonce.value.trim(),
|
||
auth_mode: fields.auth_mode.value,
|
||
pkce_enabled: fields.pkce_enabled.value === 'true',
|
||
token_type_hint: fields.token_type_hint.value
|
||
};
|
||
}
|
||
|
||
function saveConfig() {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(getConfig()));
|
||
}
|
||
|
||
function saveTokenState() {
|
||
localStorage.setItem(TOKEN_KEY, JSON.stringify(tokenState));
|
||
}
|
||
|
||
function loadConfig() {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (!raw) {
|
||
const origin = location.origin.startsWith('http') ? location.origin : 'http://localhost';
|
||
fields.issuer.value = origin;
|
||
fillEndpointsByIssuer();
|
||
fields.redirect_uri.value = `${origin}${location.pathname}`;
|
||
fields.scope.value = 'openid profile email phone';
|
||
fields.state.value = randomString(16);
|
||
fields.nonce.value = randomString(16);
|
||
fields.auth_mode.value = 'basic_first';
|
||
fields.pkce_enabled.value = 'false';
|
||
fields.token_type_hint.value = '';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const cfg = JSON.parse(raw);
|
||
Object.keys(fields).forEach((k) => {
|
||
if (cfg[k] !== undefined && fields[k]) {
|
||
fields[k].value = String(cfg[k]);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
log(`读取本地配置失败: ${String(e)}`, 'warn');
|
||
}
|
||
}
|
||
|
||
function loadTokenState() {
|
||
const raw = localStorage.getItem(TOKEN_KEY);
|
||
if (!raw) return;
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
Object.assign(tokenState, parsed || {});
|
||
renderState();
|
||
} catch (e) {
|
||
log(`读取 token 状态失败: ${String(e)}`, 'warn');
|
||
}
|
||
}
|
||
|
||
function fillEndpointsByIssuer() {
|
||
const issuer = fields.issuer.value.trim().replace(/\/$/, '');
|
||
if (!issuer) {
|
||
throw new Error('请先填写 issuer');
|
||
}
|
||
fields.discovery_endpoint.value = `${issuer}/.well-known/openid-configuration`;
|
||
fields.authorize_endpoint.value = `${issuer}/oauth/authorize`;
|
||
fields.token_endpoint.value = `${issuer}/oauth/token`;
|
||
fields.userinfo_endpoint.value = `${issuer}/oauth/userinfo`;
|
||
fields.revoke_endpoint.value = `${issuer}/oauth/revoke`;
|
||
log('已按 issuer 自动填充端点。');
|
||
saveConfig();
|
||
}
|
||
|
||
async function parseJsonResponse(resp) {
|
||
const text = await resp.text();
|
||
let json = null;
|
||
if (text) {
|
||
try {
|
||
json = JSON.parse(text);
|
||
} catch (_e) {
|
||
json = { raw: text };
|
||
}
|
||
}
|
||
return { status: resp.status, ok: resp.ok, json };
|
||
}
|
||
|
||
function summarizeRequest(method, url, headers, body) {
|
||
const sanitizedHeaders = { ...headers };
|
||
if (sanitizedHeaders.Authorization) {
|
||
sanitizedHeaders.Authorization = '[REDACTED]';
|
||
}
|
||
return {
|
||
method,
|
||
url,
|
||
headers: sanitizedHeaders,
|
||
body
|
||
};
|
||
}
|
||
|
||
function showResponse(data) {
|
||
fields.response_json.value = JSON.stringify(data, null, 2);
|
||
}
|
||
|
||
function renderState() {
|
||
outputs.code.textContent = tokenState.code || '-';
|
||
outputs.access.textContent = tokenState.access_token || '-';
|
||
outputs.refresh.textContent = tokenState.refresh_token || '-';
|
||
outputs.idtoken.textContent = tokenState.id_token || '-';
|
||
}
|
||
|
||
function ensure(v, msg) {
|
||
if (!v) throw new Error(msg);
|
||
}
|
||
|
||
async function discover() {
|
||
const cfg = getConfig();
|
||
ensure(cfg.discovery_endpoint, '请填写 discovery_endpoint');
|
||
const url = cfg.discovery_endpoint;
|
||
|
||
log(`发起 discovery 请求: ${url}`);
|
||
setStatus('正在发现配置...', 'warn');
|
||
|
||
const reqSummary = summarizeRequest('GET', url, { Accept: 'application/json' }, null);
|
||
try {
|
||
const resp = await fetch(url, { headers: { Accept: 'application/json' } });
|
||
const parsed = await parseJsonResponse(resp);
|
||
const result = { request: reqSummary, response: parsed };
|
||
showResponse(result);
|
||
|
||
if (!parsed.ok) {
|
||
setStatus(`Discovery 失败 (${parsed.status})`, 'err');
|
||
log(`Discovery 失败: HTTP ${parsed.status}`, 'error');
|
||
return;
|
||
}
|
||
|
||
const d = parsed.json || {};
|
||
fields.issuer.value = d.issuer || fields.issuer.value;
|
||
fields.authorize_endpoint.value = d.authorization_endpoint || fields.authorize_endpoint.value;
|
||
fields.token_endpoint.value = d.token_endpoint || fields.token_endpoint.value;
|
||
fields.userinfo_endpoint.value = d.userinfo_endpoint || fields.userinfo_endpoint.value;
|
||
fields.revoke_endpoint.value = d.revocation_endpoint || fields.revoke_endpoint.value;
|
||
|
||
saveConfig();
|
||
setStatus('Discovery 成功', 'ok');
|
||
log('Discovery 成功并已回填端点。');
|
||
} catch (e) {
|
||
handleFetchError(e, 'Discovery');
|
||
}
|
||
}
|
||
|
||
async function beginAuthorize() {
|
||
const cfg = getConfig();
|
||
ensure(cfg.authorize_endpoint, '请填写 authorize_endpoint');
|
||
ensure(cfg.client_id, '请填写 client_id');
|
||
ensure(cfg.redirect_uri, '请填写 redirect_uri');
|
||
|
||
if (!cfg.state) {
|
||
cfg.state = randomString(16);
|
||
fields.state.value = cfg.state;
|
||
}
|
||
|
||
if (!cfg.nonce) {
|
||
cfg.nonce = randomString(16);
|
||
fields.nonce.value = cfg.nonce;
|
||
}
|
||
|
||
const params = new URLSearchParams({
|
||
response_type: 'code',
|
||
client_id: cfg.client_id,
|
||
redirect_uri: cfg.redirect_uri,
|
||
scope: cfg.scope || '',
|
||
state: cfg.state,
|
||
nonce: cfg.nonce
|
||
});
|
||
|
||
if (cfg.pkce_enabled) {
|
||
tokenState.code_verifier = randomString(64);
|
||
const challenge = await sha256Base64Url(tokenState.code_verifier);
|
||
params.set('code_challenge', challenge);
|
||
params.set('code_challenge_method', 'S256');
|
||
log('PKCE 已开启,已生成 code_verifier/code_challenge。');
|
||
} else {
|
||
tokenState.code_verifier = '';
|
||
}
|
||
|
||
saveConfig();
|
||
saveTokenState();
|
||
|
||
const url = `${cfg.authorize_endpoint}${cfg.authorize_endpoint.includes('?') ? '&' : '?'}${params.toString()}`;
|
||
log(`即将跳转授权: ${url}`);
|
||
setStatus('跳转授权中...', 'warn');
|
||
location.href = url;
|
||
}
|
||
|
||
function parseCallback() {
|
||
const query = new URLSearchParams(location.search || '');
|
||
const hashIndex = location.hash.indexOf('?');
|
||
if (hashIndex >= 0) {
|
||
const hashQuery = new URLSearchParams(location.hash.slice(hashIndex + 1));
|
||
hashQuery.forEach((v, k) => query.set(k, v));
|
||
}
|
||
|
||
const code = query.get('code') || '';
|
||
const error = query.get('error') || '';
|
||
const errorDescription = query.get('error_description') || '';
|
||
const state = query.get('state') || '';
|
||
|
||
tokenState.code = code;
|
||
tokenState.stateFromCallback = state;
|
||
|
||
const expectedState = fields.state.value.trim();
|
||
if (state && expectedState) {
|
||
outputs.stateCheck.textContent = state === expectedState ? '通过' : `失败(expected=${expectedState}, actual=${state})`;
|
||
} else {
|
||
outputs.stateCheck.textContent = '-';
|
||
}
|
||
|
||
saveTokenState();
|
||
renderState();
|
||
|
||
const result = {
|
||
callback_url: location.href,
|
||
code,
|
||
state,
|
||
error,
|
||
error_description: errorDescription
|
||
};
|
||
showResponse(result);
|
||
|
||
if (error) {
|
||
setStatus(`回调错误: ${error}`, 'err');
|
||
log(`授权回调错误: ${error} ${errorDescription}`, 'error');
|
||
} else if (code) {
|
||
setStatus('已获取授权码', 'ok');
|
||
log('授权回调成功,已获取 code。');
|
||
} else {
|
||
setStatus('未发现授权参数', 'warn');
|
||
log('当前 URL 未包含 code/error 参数。', 'warn');
|
||
}
|
||
}
|
||
|
||
function buildBasicAuth(clientId, clientSecret) {
|
||
return btoa(`${clientId}:${clientSecret}`);
|
||
}
|
||
|
||
async function tokenRequest(form, endpoint, authMode, clientId, clientSecret) {
|
||
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||
const body = new URLSearchParams(form);
|
||
|
||
const tryBasic = authMode === 'basic_first' || authMode === 'basic_only';
|
||
const allowPostFallback = authMode === 'basic_first';
|
||
const postOnly = authMode === 'post_only';
|
||
|
||
if (postOnly) {
|
||
body.set('client_id', clientId);
|
||
body.set('client_secret', clientSecret);
|
||
const reqSummary = summarizeRequest('POST', endpoint, headers, Object.fromEntries(body.entries()));
|
||
const resp = await fetch(endpoint, { method: 'POST', headers, body: body.toString() });
|
||
return { parsed: await parseJsonResponse(resp), reqSummary };
|
||
}
|
||
|
||
if (tryBasic) {
|
||
const basicHeaders = { ...headers, Authorization: `Basic ${buildBasicAuth(clientId, clientSecret)}` };
|
||
const reqSummary = summarizeRequest('POST', endpoint, basicHeaders, Object.fromEntries(body.entries()));
|
||
const resp = await fetch(endpoint, { method: 'POST', headers: basicHeaders, body: body.toString() });
|
||
const parsed = await parseJsonResponse(resp);
|
||
if (parsed.ok || !allowPostFallback) {
|
||
return { parsed, reqSummary };
|
||
}
|
||
|
||
log(`Basic 方式失败(HTTP ${parsed.status}),尝试 client_secret_post...`, 'warn');
|
||
body.set('client_id', clientId);
|
||
body.set('client_secret', clientSecret);
|
||
const fallbackHeaders = { ...headers };
|
||
const fallbackSummary = summarizeRequest('POST', endpoint, fallbackHeaders, Object.fromEntries(body.entries()));
|
||
const fallbackResp = await fetch(endpoint, { method: 'POST', headers: fallbackHeaders, body: body.toString() });
|
||
return { parsed: await parseJsonResponse(fallbackResp), reqSummary: fallbackSummary };
|
||
}
|
||
|
||
throw new Error('未知认证模式');
|
||
}
|
||
|
||
async function exchangeCode() {
|
||
const cfg = getConfig();
|
||
ensure(cfg.token_endpoint, '请填写 token_endpoint');
|
||
ensure(cfg.client_id, '请填写 client_id');
|
||
ensure(cfg.client_secret, '请填写 client_secret');
|
||
ensure(cfg.redirect_uri, '请填写 redirect_uri');
|
||
ensure(tokenState.code, '未检测到授权 code,请先点击“解析回调”');
|
||
|
||
const form = {
|
||
grant_type: 'authorization_code',
|
||
code: tokenState.code,
|
||
redirect_uri: cfg.redirect_uri
|
||
};
|
||
|
||
if (cfg.pkce_enabled) {
|
||
ensure(tokenState.code_verifier, 'PKCE 已开启但缺少 code_verifier,请重新发起授权');
|
||
form.code_verifier = tokenState.code_verifier;
|
||
}
|
||
|
||
setStatus('换取 Token 中...', 'warn');
|
||
log(`请求 token endpoint: ${cfg.token_endpoint}`);
|
||
|
||
try {
|
||
const { parsed, reqSummary } = await tokenRequest(form, cfg.token_endpoint, cfg.auth_mode, cfg.client_id, cfg.client_secret);
|
||
showResponse({ request: reqSummary, response: parsed });
|
||
|
||
if (!parsed.ok) {
|
||
setStatus(`换 token 失败 (${parsed.status})`, 'err');
|
||
log(`换 token 失败: HTTP ${parsed.status}`, 'error');
|
||
return;
|
||
}
|
||
|
||
const payload = parsed.json || {};
|
||
tokenState.access_token = payload.access_token || '';
|
||
tokenState.refresh_token = payload.refresh_token || '';
|
||
tokenState.id_token = payload.id_token || '';
|
||
tokenState.token_type = payload.token_type || 'Bearer';
|
||
tokenState.expires_in = Number(payload.expires_in || 0);
|
||
saveTokenState();
|
||
renderState();
|
||
|
||
setStatus('换 token 成功', 'ok');
|
||
log('换 token 成功。');
|
||
} catch (e) {
|
||
handleFetchError(e, '换 token');
|
||
}
|
||
}
|
||
|
||
async function fetchUserinfo() {
|
||
const cfg = getConfig();
|
||
ensure(cfg.userinfo_endpoint, '请填写 userinfo_endpoint');
|
||
ensure(tokenState.access_token, '请先获取 access_token');
|
||
|
||
const headers = {
|
||
Authorization: `${tokenState.token_type || 'Bearer'} ${tokenState.access_token}`,
|
||
Accept: 'application/json'
|
||
};
|
||
|
||
const reqSummary = summarizeRequest('GET', cfg.userinfo_endpoint, headers, null);
|
||
|
||
setStatus('请求 UserInfo 中...', 'warn');
|
||
log(`请求 userinfo endpoint: ${cfg.userinfo_endpoint}`);
|
||
|
||
try {
|
||
const resp = await fetch(cfg.userinfo_endpoint, { headers });
|
||
const parsed = await parseJsonResponse(resp);
|
||
showResponse({ request: reqSummary, response: parsed });
|
||
|
||
if (!parsed.ok) {
|
||
setStatus(`UserInfo 失败 (${parsed.status})`, 'err');
|
||
log(`UserInfo 失败: HTTP ${parsed.status}`, 'error');
|
||
return;
|
||
}
|
||
|
||
setStatus('UserInfo 成功', 'ok');
|
||
log('UserInfo 获取成功。');
|
||
} catch (e) {
|
||
handleFetchError(e, 'UserInfo');
|
||
}
|
||
}
|
||
|
||
async function refreshToken() {
|
||
const cfg = getConfig();
|
||
ensure(cfg.token_endpoint, '请填写 token_endpoint');
|
||
ensure(cfg.client_id, '请填写 client_id');
|
||
ensure(cfg.client_secret, '请填写 client_secret');
|
||
ensure(tokenState.refresh_token, '请先获取 refresh_token');
|
||
|
||
const form = {
|
||
grant_type: 'refresh_token',
|
||
refresh_token: tokenState.refresh_token
|
||
};
|
||
|
||
setStatus('刷新 Token 中...', 'warn');
|
||
log(`请求 refresh token: ${cfg.token_endpoint}`);
|
||
|
||
try {
|
||
const { parsed, reqSummary } = await tokenRequest(form, cfg.token_endpoint, cfg.auth_mode, cfg.client_id, cfg.client_secret);
|
||
showResponse({ request: reqSummary, response: parsed });
|
||
|
||
if (!parsed.ok) {
|
||
setStatus(`刷新失败 (${parsed.status})`, 'err');
|
||
log(`刷新 token 失败: HTTP ${parsed.status}`, 'error');
|
||
return;
|
||
}
|
||
|
||
const payload = parsed.json || {};
|
||
tokenState.access_token = payload.access_token || tokenState.access_token;
|
||
tokenState.refresh_token = payload.refresh_token || tokenState.refresh_token;
|
||
tokenState.id_token = payload.id_token || tokenState.id_token;
|
||
tokenState.token_type = payload.token_type || tokenState.token_type || 'Bearer';
|
||
tokenState.expires_in = Number(payload.expires_in || tokenState.expires_in || 0);
|
||
saveTokenState();
|
||
renderState();
|
||
|
||
setStatus('刷新成功', 'ok');
|
||
log('刷新 token 成功。');
|
||
} catch (e) {
|
||
handleFetchError(e, '刷新 token');
|
||
}
|
||
}
|
||
|
||
async function revokeToken(token, hint) {
|
||
const cfg = getConfig();
|
||
ensure(cfg.revoke_endpoint, '请填写 revoke_endpoint');
|
||
ensure(cfg.client_id, '请填写 client_id');
|
||
ensure(cfg.client_secret, '请填写 client_secret');
|
||
ensure(token, '没有可撤销的 token');
|
||
|
||
const form = { token };
|
||
if (hint) form.token_type_hint = hint;
|
||
|
||
setStatus('撤销 Token 中...', 'warn');
|
||
log(`请求 revoke endpoint: ${cfg.revoke_endpoint}`);
|
||
|
||
try {
|
||
const { parsed, reqSummary } = await tokenRequest(form, cfg.revoke_endpoint, cfg.auth_mode, cfg.client_id, cfg.client_secret);
|
||
showResponse({ request: reqSummary, response: parsed });
|
||
|
||
if (!parsed.ok) {
|
||
setStatus(`撤销失败 (${parsed.status})`, 'err');
|
||
log(`撤销失败: HTTP ${parsed.status}`, 'error');
|
||
return;
|
||
}
|
||
|
||
if (hint === 'refresh_token') {
|
||
tokenState.refresh_token = '';
|
||
} else if (hint === 'access_token') {
|
||
tokenState.access_token = '';
|
||
}
|
||
saveTokenState();
|
||
renderState();
|
||
|
||
setStatus('撤销成功', 'ok');
|
||
log('撤销成功。');
|
||
} catch (e) {
|
||
handleFetchError(e, '撤销 token');
|
||
}
|
||
}
|
||
|
||
function clearState() {
|
||
tokenState.code = '';
|
||
tokenState.stateFromCallback = '';
|
||
tokenState.access_token = '';
|
||
tokenState.refresh_token = '';
|
||
tokenState.id_token = '';
|
||
tokenState.token_type = 'Bearer';
|
||
tokenState.expires_in = 0;
|
||
tokenState.code_verifier = '';
|
||
fields.response_json.value = '';
|
||
outputs.stateCheck.textContent = '-';
|
||
saveTokenState();
|
||
renderState();
|
||
setStatus('已清空状态', 'warn');
|
||
log('已清空 OAuth 会话状态。');
|
||
}
|
||
|
||
function handleFetchError(err, actionName) {
|
||
const msg = String(err && err.message ? err.message : err);
|
||
const isCors = /Failed to fetch|NetworkError|Load failed/i.test(msg);
|
||
setStatus(`${actionName}异常`, 'err');
|
||
log(`${actionName}异常: ${msg}`, 'error');
|
||
if (isCors) {
|
||
log('疑似 CORS 或网络策略问题:请检查服务端是否放行当前 Origin 及 Authorization/Content-Type 头。', 'warn');
|
||
}
|
||
showResponse({ error: msg, action: actionName });
|
||
}
|
||
|
||
function bind() {
|
||
document.getElementById('btn-fill-defaults').addEventListener('click', () => {
|
||
try {
|
||
fillEndpointsByIssuer();
|
||
} catch (e) {
|
||
handleFetchError(e, '自动填充端点');
|
||
}
|
||
});
|
||
document.getElementById('btn-discovery').addEventListener('click', discover);
|
||
document.getElementById('btn-authorize').addEventListener('click', () => {
|
||
beginAuthorize().catch((e) => handleFetchError(e, '发起授权'));
|
||
});
|
||
document.getElementById('btn-parse-callback').addEventListener('click', parseCallback);
|
||
document.getElementById('btn-exchange-code').addEventListener('click', exchangeCode);
|
||
document.getElementById('btn-userinfo').addEventListener('click', fetchUserinfo);
|
||
document.getElementById('btn-refresh').addEventListener('click', refreshToken);
|
||
document.getElementById('btn-revoke-access').addEventListener('click', () => revokeToken(tokenState.access_token, fields.token_type_hint.value || 'access_token'));
|
||
document.getElementById('btn-revoke-refresh').addEventListener('click', () => revokeToken(tokenState.refresh_token, fields.token_type_hint.value || 'refresh_token'));
|
||
document.getElementById('btn-clear-state').addEventListener('click', clearState);
|
||
document.getElementById('btn-copy-response').addEventListener('click', async () => {
|
||
await navigator.clipboard.writeText(fields.response_json.value || '');
|
||
log('已复制响应 JSON。');
|
||
});
|
||
document.getElementById('btn-copy-log').addEventListener('click', async () => {
|
||
await navigator.clipboard.writeText(outputs.logs.textContent || '');
|
||
log('已复制日志。');
|
||
});
|
||
document.getElementById('btn-clear-log').addEventListener('click', () => {
|
||
outputs.logs.textContent = '';
|
||
log('日志已清空。');
|
||
});
|
||
|
||
Object.values(fields).forEach((el) => {
|
||
el.addEventListener('change', saveConfig);
|
||
el.addEventListener('blur', saveConfig);
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
loadConfig();
|
||
loadTokenState();
|
||
bind();
|
||
parseCallback();
|
||
setStatus('就绪', 'ok');
|
||
log('测试客户端已就绪。');
|
||
}
|
||
|
||
init();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|