BastionSSO/oauth_test/index.html

948 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>