Kiro RefreshToken/ClientID/ClientSecret 网页获取工具
地址 https://kiro.endpoint.cc.cd
使用 Cloudflare Worker 绕过 CORS 限制,实现生成 Device Flow 登录链接
没有做信息存储!!
源代码:
/**
* worker.js — Kiro Manual Auth (Device Flow) Web Tool
* Full UI (no cuts): Vercel/shadcn/Inspira-ish + Acrylic hover actions
*
* ✅ Flow:
* Login -> get CID/CS -> get Device Flow link (hover: Copy | Open Link) -> background polling
* -> success modal pops with Tabs: Viewer / JSON
*
* ✅ Viewer tab:
* - Shows CID / CS + ALL keys from last successful /api/poll JSON
* - Each row hover shows acrylic blur + "Copy" (same logic as URL hover, no OpenLink)
*
* ✅ Poll logic:
* - No overlapping polls: next poll only after previous finished
* - If backend returns 400 {"error":"authorization_pending"...}, treat as Pending (yellow) not error
*
* ✅ NEW (your request):
* - Modal content is scrollable (so long tokens won't squeeze/overflow the screen)
* - Each KV value area is also scrollable (does not cut value; copy still copies full value)
*/
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname;
// ===== Config =====
const OIDC = "https://oidc.us-east-1.amazonaws.com";
const PORTAL = "https://view.awsapps.com";
const START_URL = `${PORTAL}/start`;
// ===== CORS =====
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
// ===== UI =====
if (request.method === "GET" && path === "/") {
return new Response(renderHTML(), {
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
},
});
}
// ===== Helpers =====
const json = (obj, status = 200, extraHeaders = {}) =>
new Response(JSON.stringify(obj), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
...corsHeaders,
...extraHeaders,
},
});
const proxyJson = async (targetUrl, bodyObj) => {
const r = await fetch(targetUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(bodyObj),
});
const text = await r.text();
return new Response(text, {
status: r.status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
...corsHeaders,
},
});
};
// ===== API: register =====
if (request.method === "POST" && path === "/api/register") {
return proxyJson(`${OIDC}/client/register`, {
clientName: "Amazon Q Developer for command line",
clientType: "public",
scopes: [
"codewhisperer:completions",
"codewhisperer:analysis",
"codewhisperer:conversations",
],
});
}
// ===== API: device authorization =====
if (request.method === "POST" && path === "/api/device") {
let req;
try {
req = await request.json();
} catch {
req = {};
}
const { clientId, clientSecret } = req || {};
if (!clientId || !clientSecret) {
return json({ error: "missing clientId/clientSecret" }, 400);
}
return proxyJson(`${OIDC}/device_authorization`, {
clientId,
clientSecret,
startUrl: START_URL,
});
}
// ===== API: poll token (device_code) =====
// UI handles 400 authorization_pending as "Pending"
if (request.method === "POST" && path === "/api/poll") {
let req;
try {
req = await request.json();
} catch {
req = {};
}
const { clientId, clientSecret, deviceCode } = req || {};
if (!clientId || !clientSecret || !deviceCode) {
return json({ error: "missing clientId/clientSecret/deviceCode" }, 400);
}
return proxyJson(`${OIDC}/token`, {
clientId,
clientSecret,
deviceCode,
grantType: "urn:ietf:params:oauth:grant-type:device_code",
});
}
// ===== API: refresh (optional) =====
if (request.method === "POST" && path === "/api/refresh") {
let req;
try {
req = await request.json();
} catch {
req = {};
}
const { clientId, clientSecret, refreshToken } = req || {};
if (!clientId || !clientSecret || !refreshToken) {
return json({ error: "missing clientId/clientSecret/refreshToken" }, 400);
}
return proxyJson(`${OIDC}/token`, {
clientId,
clientSecret,
refreshToken,
grantType: "refresh_token",
});
}
return new Response("Not Found", { status: 404 });
},
};
function renderHTML() {
const AWS_SVG = `<svg fill="currentColor" fill-rule="evenodd" height="18" viewBox="0 0 24 24" width="18" xmlns="http://www.w3.org/2000/svg" style="flex: 0 0 auto; line-height: 1;"><title>AWS</title><path d="M6.763 11.212c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 01-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 01-.287-.375 6.18 6.18 0 01-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272a2.4 2.4 0 01-.28.104.488.488 0 01-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 01.224-.167 4.577 4.577 0 011.005-.36 4.84 4.84 0 011.246-.151c.95 0 1.644.216 2.091.647.44.43.662 1.085.662 1.963v2.586h.016zm-3.24 1.214c.263 0 .534-.048.822-.144a1.78 1.78 0 00.758-.51 1.27 1.27 0 00.272-.512c.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 00-.735-.136 6.02 6.02 0 00-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.398 1.398 0 01-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 01.32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 01.311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 01-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 01-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08l-.686.001zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 01-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 00.415-.758.777.777 0 00-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 01-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 01.24.2.43.43 0 01.071.263v.375c0 .168-.064.256-.184.256a.83.83 0 01-.303-.096 3.652 3.652 0 00-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926a2.157 2.157 0 01-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167z"></path><path d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351zm23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399z" fill="#F90"></path></svg>`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Kiro Manual Auth (Worker)</title>
<style>
:root{
--bg: 10 10 12;
--card: 18 18 22;
--muted: 160 160 175;
--text: 240 240 245;
--border: 255 255 255;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--ring: 99 102 241;
--ok: 16 185 129;
--bad: 239 68 68;
--warn: 245 158 11;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
color: rgb(var(--text));
background:
radial-gradient(1200px 600px at 20% 10%, rgba(99,102,241,.22), transparent 60%),
radial-gradient(900px 520px at 80% 30%, rgba(16,185,129,.18), transparent 55%),
radial-gradient(900px 520px at 40% 90%, rgba(236,72,153,.14), transparent 55%),
linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.35) 55%, rgba(0,0,0,.75) 100%),
rgb(var(--bg));
overflow-x:hidden;
}
.container{max-width:980px;margin:0 auto;padding:40px 16px 60px}
.header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}
.title{font-size:22px;font-weight:650;letter-spacing:-.02em;margin:0}
.subtitle{margin:8px 0 0;color:rgba(var(--muted),.9);font-size:13px;line-height:1.5}
.card{
border:1px solid rgba(var(--border),.10);
background: rgba(var(--card), .55);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 18px;
box-shadow: var(--shadow);
overflow:hidden;
position:relative;
}
.card-inner{padding:18px}
.row{display:flex;gap:12px;flex-wrap:wrap;align-items:center;justify-content:space-between}
.muted{color:rgba(var(--muted),.9);font-size:12px;line-height:1.5}
.sep{height:1px;background:rgba(var(--border),.10);margin:16px 0}
.btn{
display:inline-flex;align-items:center;gap:10px;
padding:10px 14px;
border-radius:14px;
border:1px solid rgba(var(--border),.14);
background: rgba(255,255,255,.06);
color: rgb(var(--text));
cursor:pointer;
font-size:13px;font-weight:600;
transition: transform .12s ease, background .12s ease, border-color .12s ease, box-shadow .12s ease;
user-select:none;
}
.btn:hover{background: rgba(255,255,255,.09);border-color: rgba(var(--border),.20);transform: translateY(-1px)}
.btn:active{transform: translateY(0px)}
.btn:focus{outline:none;box-shadow: 0 0 0 4px rgba(var(--ring), .25)}
.btn[disabled]{opacity:.55;cursor:not-allowed;transform:none}
.btn-primary{background: rgba(255,255,255,.10);border-color: rgba(255,255,255,.16)}
.btn-ghost{background: transparent;border-color: rgba(255,255,255,.10)}
.btn-secondary{background: rgba(255,255,255,.07)}
.btn-xs{padding:7px 10px;border-radius:12px;font-size:12px}
.grid{display:grid;grid-template-columns:1fr;gap:12px}
@media (min-width: 860px){ .grid{grid-template-columns:1fr 1fr} }
.field{
border:1px solid rgba(var(--border),.10);
background: rgba(0,0,0,.22);
border-radius: 16px;
padding:12px 14px;
}
.label{font-size:11px;color:rgba(var(--muted),.9);margin-bottom:6px}
.value{font-size:13px;word-break:break-all}
.mono{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px}
.link-panel{
position:relative;
border:1px solid rgba(var(--border),.10);
background: rgba(0,0,0,.20);
border-radius: 18px;
padding:14px;
overflow:hidden;
transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease;
}
.link-panel:hover{box-shadow: 0 12px 30px rgba(0,0,0,.35);border-color: rgba(255,255,255,.16);transform: translateY(-1px)}
.link-text{margin-top:8px;font-size:13px;opacity:.92;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* Acrylic hover overlay */
.acrylic{position:absolute;inset:0;opacity:0;transition: opacity .16s ease;pointer-events:none;}
.link-panel:hover .acrylic{opacity:1}
.acrylic::before{
content:"";position:absolute;inset:0;
background: rgba(255,255,255,.10);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.acrylic::after{
content:"";position:absolute;inset:-40px;
background: radial-gradient(420px 220px at 20% 20%, rgba(255,255,255,.12), transparent 55%),
radial-gradient(420px 220px at 80% 40%, rgba(255,255,255,.08), transparent 55%);
opacity:.9;mix-blend-mode: overlay;
}
.link-actions{
position:absolute; inset:0;
display:flex;align-items:center;justify-content:center;gap:10px;
opacity:0;transition: opacity .16s ease;
pointer-events:none;
}
.link-panel:hover .link-actions{opacity:1}
.link-actions .btn{pointer-events:auto}
.status{
border:1px solid rgba(var(--border),.10);
background: rgba(0,0,0,.20);
border-radius: 16px;
padding:12px 14px;
display:flex;gap:12px;align-items:flex-start;justify-content:space-between;
}
.badge{
display:inline-flex;align-items:center;gap:8px;
font-size:11px;color:rgba(var(--muted),.95);
padding:6px 10px;border-radius:999px;
border:1px solid rgba(var(--border),.12);
background: rgba(255,255,255,.05);
white-space:nowrap;
}
.dot{width:8px;height:8px;border-radius:999px;background: rgba(255,255,255,.55);position:relative;}
.dot.ping::after{
content:"";position:absolute;inset:-6px;border-radius:999px;
border:1px solid rgba(255,255,255,.35);
animation: ping 1.2s ease-out infinite;opacity:.8;
}
@keyframes ping{0%{transform:scale(.4);opacity:.8} 100%{transform:scale(1.5);opacity:0}}
.status.ok{border-color: rgba(var(--ok), .35); background: rgba(var(--ok), .10)}
.status.bad{border-color: rgba(var(--bad), .35); background: rgba(var(--bad), .10)}
.status.warn{border-color: rgba(var(--warn), .35); background: rgba(var(--warn), .08)}
/* Modal */
.modal-backdrop{
position:fixed;inset:0;
background: rgba(0,0,0,.60);
display:none;
align-items:center;justify-content:center;
padding:18px;
z-index:50;
}
.modal-backdrop.show{display:flex}
.modal{
width:min(900px, 100%);
max-height: 88vh; /* NEW: keep modal within viewport */
border-radius: 18px;
border:1px solid rgba(var(--border),.14);
background: rgba(var(--card), .86);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: var(--shadow);
overflow:hidden;
display:flex;
flex-direction:column; /* NEW: allow internal scroll areas */
}
.modal-head{padding:16px 18px;border-bottom:1px solid rgba(var(--border),.10);flex:0 0 auto}
.modal-title{margin:0;font-size:16px;font-weight:750}
.modal-desc{margin:6px 0 0;color:rgba(var(--muted),.9);font-size:12px;line-height:1.45}
.modal-body{
padding:16px 18px;
flex: 1 1 auto; /* NEW */
min-height: 0; /* NEW: critical for flex scroll children */
display:flex; /* NEW */
flex-direction:column; /* NEW */
gap:12px; /* NEW */
}
/* NEW: scrollable content area inside modal body */
.modal-scroll{
flex: 1 1 auto;
min-height: 0;
overflow:auto;
padding-right: 4px;
border-radius: 14px;
}
/* nicer scrollbar (webkit only) */
.modal-scroll::-webkit-scrollbar{width:10px}
.modal-scroll::-webkit-scrollbar-thumb{background: rgba(255,255,255,.12); border-radius: 999px; border:2px solid rgba(0,0,0,.15)}
.modal-scroll::-webkit-scrollbar-track{background: rgba(0,0,0,.10); border-radius: 999px}
pre{
margin:0;border-radius: 14px;border:1px solid rgba(var(--border),.10);
background: rgba(0,0,0,.30);
padding:14px;
overflow:auto;
font-size:12px; line-height:1.5;
}
/* Footer pinned inside modal */
.footer-actions{
display:flex;gap:10px;flex-wrap:wrap;
padding-top: 12px;
margin-top: 0;
position: sticky; /* NEW */
bottom: 0; /* NEW */
background: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,.20) 30%, rgba(0,0,0,.28));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-top: 1px solid rgba(255,255,255,.08);
}
.right{margin-left:auto}
/* Tabs */
.tabs{display:flex;gap:8px;align-items:center}
.tab{
padding:8px 10px;
border-radius: 12px;
border:1px solid rgba(var(--border),.12);
background: rgba(255,255,255,.05);
color: rgba(var(--text), .88);
font-size:12px;font-weight:650;
cursor:pointer;
transition: background .12s ease, border-color .12s ease, transform .12s ease;
user-select:none;
}
.tab:hover{background: rgba(255,255,255,.08);border-color: rgba(255,255,255,.18);transform: translateY(-1px)}
.tab.active{
background: rgba(255,255,255,.12);
border-color: rgba(255,255,255,.22);
color: rgb(var(--text));
}
/* Viewer list */
.viewer{display:flex;flex-direction:column;gap:10px;}
.kv-row{
position:relative;
border:1px solid rgba(var(--border),.10);
background: rgba(0,0,0,.22);
border-radius: 16px;
padding:12px 14px;
overflow:hidden;
}
.kv-row:hover{border-color: rgba(255,255,255,.16)}
.kv-key{font-size:11px;color:rgba(var(--muted),.92)}
.kv-val{
margin-top:6px;
font-size:12px;
word-break: break-all;
opacity:.92;
/* NEW: prevent a single token from taking the entire screen */
max-height: 140px;
overflow:auto;
padding-right: 6px;
}
.kv-val::-webkit-scrollbar{width:10px}
.kv-val::-webkit-scrollbar-thumb{background: rgba(255,255,255,.10); border-radius: 999px; border:2px solid rgba(0,0,0,.15)}
.kv-val::-webkit-scrollbar-track{background: rgba(0,0,0,.10); border-radius: 999px}
.kv-actions{
position:absolute;inset:0;
display:flex;align-items:center;justify-content:center;
opacity:0;transition: opacity .16s ease;
pointer-events:none;
}
.kv-row:hover .kv-actions{opacity:1}
.kv-actions .btn{pointer-events:auto}
.kv-acrylic{position:absolute;inset:0;opacity:0;transition: opacity .16s ease;pointer-events:none;}
.kv-row:hover .kv-acrylic{opacity:1}
.kv-acrylic::before{
content:"";position:absolute;inset:0;
background: rgba(255,255,255,.10);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.kv-acrylic::after{
content:"";position:absolute;inset:-40px;
background: radial-gradient(380px 200px at 25% 30%, rgba(255,255,255,.12), transparent 58%),
radial-gradient(380px 200px at 75% 45%, rgba(255,255,255,.08), transparent 60%);
opacity:.9;mix-blend-mode: overlay;
}
.small-note{margin-top:12px;color:rgba(var(--muted),.85);font-size:12px;line-height:1.5}
.glow{
position:absolute; inset:-200px;
background: radial-gradient(600px 260px at 20% 10%, rgba(99,102,241,.20), transparent 55%),
radial-gradient(500px 240px at 80% 20%, rgba(16,185,129,.14), transparent 60%);
pointer-events:none;
opacity:.9;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1 class="title">Kiro Manual Auth</h1>
<p class="subtitle">
Click <b>Login</b> → get CID/CS → device flow link. Hover link for <b>Copy | Open Link</b>.
We poll automatically and pop credentials when authorized.
</p>
</div>
<button id="btnLogin" class="btn btn-primary">
${AWS_SVG}
<span>Login</span>
</button>
</div>
<div class="card">
<div class="glow"></div>
<div class="card-inner">
<div class="row" style="gap:10px">
<div class="muted">
OIDC requests are proxied server-side to bypass browser CORS.
<span style="display:block;opacity:.85">Treat Refresh Token / Client Secret as secrets.</span>
</div>
<div class="row" style="justify-content:flex-end">
<button id="btnClear" class="btn btn-ghost">Clear</button>
</div>
</div>
<div class="sep"></div>
<div class="link-panel" id="linkPanel">
<div class="label">Device Flow Link</div>
<div class="link-text" id="verifyUrl" title="">—</div>
<div class="acrylic"></div>
<div class="link-actions" id="linkActions">
<button id="btnCopyLink" class="btn btn-secondary">Copy</button>
<button id="btnOpenLink" class="btn btn-secondary">Open Link</button>
</div>
</div>
<div class="sep"></div>
<div class="grid">
<div class="field">
<div class="label">User Code</div>
<div class="value" id="userCode">—</div>
</div>
<div class="field">
<div class="label">Device Code</div>
<div class="value mono" id="deviceCode">—</div>
</div>
</div>
<div class="sep"></div>
<div class="status" id="statusBox">
<div>
<div style="font-weight:700;font-size:13px">Status</div>
<div class="muted" id="statusText" style="margin-top:4px">Click Login to start.</div>
</div>
<div class="badge" id="statusBadge">
<span class="dot" id="statusDot"></span>
<span id="statusLabel">IDLE</span>
</div>
</div>
<div class="small-note">
Tip: hover the link panel to copy/open. Polling starts immediately after link generation.
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal-backdrop" id="modalBackdrop" role="dialog" aria-modal="true">
<div class="modal">
<div class="modal-head">
<div class="row" style="align-items:flex-start">
<div>
<h2 class="modal-title">Authorized ✅</h2>
<p class="modal-desc">Viewer: copy per key (CID/CS included). JSON: raw token.json.</p>
</div>
<div class="tabs" aria-label="result tabs">
<div id="tabViewer" class="tab active">Viewer</div>
<div id="tabJson" class="tab">JSON</div>
</div>
</div>
</div>
<div class="modal-body">
<!-- NEW: scroll container -->
<div class="modal-scroll" id="modalScroll">
<div id="panelViewer" class="viewer"></div>
<div id="panelJson" style="display:none">
<pre id="resultPre">{}</pre>
</div>
</div>
<div class="footer-actions">
<button id="btnCopyJson" class="btn btn-secondary">Copy JSON</button>
<button id="btnDownload" class="btn btn-secondary">Download token.json</button>
<button id="btnCloseModal" class="btn btn-ghost right">Close</button>
</div>
</div>
</div>
</div>
<script>
(() => {
const $ = (id) => document.getElementById(id);
const state = {
loading: false,
clientId: "",
clientSecret: "",
verifyUrl: "",
userCode: "",
deviceCode: "",
// polling control: no overlap; next only after last finished
pollTimer: null,
pollIntervalMs: 2000,
pollInFlight: false,
pollingEnabled: false,
lastPollOk: null, // last successful poll JSON (accessToken present)
resultJson: "", // token.json content shown in JSON tab
};
const ui = {
btnLogin: $("btnLogin"),
btnClear: $("btnClear"),
verifyUrl: $("verifyUrl"),
userCode: $("userCode"),
deviceCode: $("deviceCode"),
btnCopyLink: $("btnCopyLink"),
btnOpenLink: $("btnOpenLink"),
statusBox: $("statusBox"),
statusText: $("statusText"),
statusDot: $("statusDot"),
statusLabel: $("statusLabel"),
modalBackdrop: $("modalBackdrop"),
modalScroll: $("modalScroll"),
panelViewer: $("panelViewer"),
panelJson: $("panelJson"),
resultPre: $("resultPre"),
btnCopyJson: $("btnCopyJson"),
btnDownload: $("btnDownload"),
btnCloseModal: $("btnCloseModal"),
tabViewer: $("tabViewer"),
tabJson: $("tabJson"),
};
function escapeHtml(str) {
return String(str)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function setBusy(b) {
state.loading = b;
ui.btnLogin.disabled = b;
ui.btnClear.disabled = b;
}
function setStatus(kind, text, label) {
ui.statusText.textContent = text;
ui.statusLabel.textContent = label;
ui.statusBox.classList.remove("ok", "bad", "warn");
ui.statusDot.classList.remove("ping");
ui.statusDot.style.background = "rgba(255,255,255,.55)";
if (kind === "pending" || kind === "polling") {
ui.statusBox.classList.add("warn");
ui.statusDot.classList.add("ping");
ui.statusDot.style.background = "rgba(245,158,11,.9)";
} else if (kind === "ok") {
ui.statusBox.classList.add("ok");
ui.statusDot.style.background = "rgba(16,185,129,.95)";
} else if (kind === "error") {
ui.statusBox.classList.add("bad");
ui.statusDot.style.background = "rgba(239,68,68,.95)";
}
}
function stopPolling() {
state.pollingEnabled = false;
if (state.pollTimer) clearTimeout(state.pollTimer);
state.pollTimer = null;
state.pollInFlight = false;
}
function scheduleNextPoll() {
if (!state.pollingEnabled) return;
if (state.pollTimer) clearTimeout(state.pollTimer);
state.pollTimer = setTimeout(async () => {
await pollOnce();
scheduleNextPoll();
}, state.pollIntervalMs);
}
async function apiStrict(path, body) {
const r = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: body ? JSON.stringify(body) : undefined
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(typeof data === "string" ? data : JSON.stringify(data));
return data;
}
async function apiAllowNon200(path, body) {
const r = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: body ? JSON.stringify(body) : undefined
});
const data = await r.json().catch(() => ({}));
return { ok: r.ok, status: r.status, data };
}
function setLink(url) {
state.verifyUrl = url || "";
ui.verifyUrl.textContent = url || "—";
ui.verifyUrl.title = url || "";
ui.btnCopyLink.disabled = !url;
ui.btnOpenLink.disabled = !url;
}
function setCodes({ userCode, deviceCode }) {
state.userCode = userCode || "";
state.deviceCode = deviceCode || "";
ui.userCode.textContent = userCode || "—";
ui.deviceCode.textContent = deviceCode || "—";
}
async function copy(text) {
if (!text) return;
await navigator.clipboard.writeText(text);
}
function showModal() {
ui.modalBackdrop.classList.add("show");
// NEW: reset scroll to top when opening (optional but nice)
if (ui.modalScroll) ui.modalScroll.scrollTop = 0;
}
function hideModal() {
ui.modalBackdrop.classList.remove("show");
}
function setTab(which) {
const viewer = which === "viewer";
ui.tabViewer.classList.toggle("active", viewer);
ui.tabJson.classList.toggle("active", !viewer);
ui.panelViewer.style.display = viewer ? "flex" : "none";
ui.panelJson.style.display = viewer ? "none" : "block";
// keep scroll at top when switching
if (ui.modalScroll) ui.modalScroll.scrollTop = 0;
}
function kvRow(key, val, onCopy) {
const row = document.createElement("div");
row.className = "kv-row";
row.innerHTML = \`
<div class="kv-key">\${escapeHtml(key)}</div>
<div class="kv-val mono">\${escapeHtml(val)}</div>
<div class="kv-acrylic"></div>
<div class="kv-actions">
<button class="btn btn-secondary btn-xs">Copy</button>
</div>
\`;
const btn = row.querySelector("button");
btn.addEventListener("click", onCopy);
return row;
}
function normalizeValue(v) {
if (v === null || v === undefined) return "null";
if (typeof v === "string") return v;
return JSON.stringify(v);
}
function renderViewer({ cid, cs, pollOk }) {
ui.panelViewer.innerHTML = "";
const cidVal = cid || "";
const csVal = cs || "";
ui.panelViewer.appendChild(
kvRow("clientId", cidVal || "—", async () => {
await copy(cidVal);
setStatus("ok", "Copied: clientId", "SUCCESS");
})
);
ui.panelViewer.appendChild(
kvRow("clientSecret", csVal || "—", async () => {
await copy(csVal);
setStatus("ok", "Copied: clientSecret", "SUCCESS");
})
);
if (!pollOk || typeof pollOk !== "object") {
const msg = document.createElement("div");
msg.className = "muted";
msg.textContent = "No poll response data.";
ui.panelViewer.appendChild(msg);
return;
}
const entries = Object.entries(pollOk);
for (const [k, v] of entries) {
const val = normalizeValue(v);
ui.panelViewer.appendChild(
kvRow(k, val, async () => {
await copy(val);
setStatus("ok", "Copied: " + k, "SUCCESS");
})
);
}
}
function buildTokenJson(cid, cs, pollOk) {
const out = {
client_id: cid,
client_secret: cs,
refresh_token: pollOk && pollOk.refreshToken ? pollOk.refreshToken : null,
poll_response: pollOk || null
};
return JSON.stringify(out, null, 2);
}
async function pollOnce() {
if (!state.pollingEnabled) return;
if (state.pollInFlight) return;
state.pollInFlight = true;
try {
const { ok, status, data } = await apiAllowNon200("/api/poll", {
clientId: state.clientId,
clientSecret: state.clientSecret,
deviceCode: state.deviceCode,
});
if (data && data.accessToken) {
state.lastPollOk = data;
stopPolling();
setStatus("ok", "Authorized! Refresh token received.", "SUCCESS");
state.resultJson = buildTokenJson(state.clientId, state.clientSecret, data);
ui.resultPre.textContent = state.resultJson;
renderViewer({ cid: state.clientId, cs: state.clientSecret, pollOk: data });
setTab("viewer");
showModal();
return;
}
// PENDING (even if backend returns 400)
if (data && data.error === "authorization_pending") {
setStatus("pending", "Pending authorization... (complete it in the opened page)", "PENDING");
return;
}
if (data && data.error === "slow_down") {
state.pollIntervalMs = Math.min(state.pollIntervalMs + 2000, 10000);
setStatus(
"pending",
"Slow down requested. Polling every " + (state.pollIntervalMs / 1000).toFixed(1) + "s",
"PENDING"
);
return;
}
if (data && data.error === "expired_token") {
stopPolling();
setStatus("error", "Device code expired. Click Login again.", "EXPIRED");
return;
}
if (!ok) {
stopPolling();
setStatus("error", "Error: " + JSON.stringify(data), "ERROR");
return;
}
setStatus("pending", "Waiting... " + JSON.stringify(data), "PENDING");
} catch (e) {
stopPolling();
setStatus("error", "Polling error: " + (e && e.message ? e.message : String(e)), "ERROR");
} finally {
state.pollInFlight = false;
}
}
function startPolling() {
stopPolling();
state.pollingEnabled = true;
setStatus("pending", "Polling started... authorize in the verification page.", "PENDING");
pollOnce().finally(() => {
scheduleNextPoll();
});
}
async function loginFlow() {
stopPolling();
setBusy(true);
setLink("");
setCodes({ userCode: "", deviceCode: "" });
state.clientId = "";
state.clientSecret = "";
state.lastPollOk = null;
state.resultJson = "";
setStatus("polling", "Registering OIDC client...", "WORKING");
try {
const reg = await apiStrict("/api/register");
state.clientId = reg.clientId;
state.clientSecret = reg.clientSecret;
setStatus("polling", "Generating device flow link...", "WORKING");
const dev = await apiStrict("/api/device", {
clientId: state.clientId,
clientSecret: state.clientSecret
});
const link = dev.verificationUriComplete || dev.verificationUri || "";
setLink(link);
setCodes({ userCode: dev.userCode, deviceCode: dev.deviceCode });
state.pollIntervalMs = Math.max(2000, (dev.interval ? dev.interval * 1000 : 2000));
setStatus("pending", "Link ready. Hover to Copy/Open. Polling in background...", "PENDING");
startPolling();
} catch (e) {
stopPolling();
setStatus("error", "Error: " + (e && e.message ? e.message : String(e)), "ERROR");
} finally {
setBusy(false);
}
}
function clearAll() {
stopPolling();
state.clientId = "";
state.clientSecret = "";
state.verifyUrl = "";
state.userCode = "";
state.deviceCode = "";
state.lastPollOk = null;
state.resultJson = "";
state.pollIntervalMs = 2000;
setLink("");
setCodes({ userCode: "", deviceCode: "" });
setStatus("idle", "Cleared. Click Login to start.", "IDLE");
hideModal();
}
// ===== Bindings =====
ui.btnLogin.addEventListener("click", loginFlow);
ui.btnClear.addEventListener("click", clearAll);
ui.btnCopyLink.addEventListener("click", async () => {
if (!state.verifyUrl) return;
await copy(state.verifyUrl);
setStatus("pending", "Link copied. Continue authorization in the opened page.", "PENDING");
});
ui.btnOpenLink.addEventListener("click", () => {
if (!state.verifyUrl) return;
window.open(state.verifyUrl, "_blank", "noopener,noreferrer");
setStatus("pending", "Link opened. Complete authorization, we are polling...", "PENDING");
});
ui.btnCopyJson.addEventListener("click", async () => {
if (!state.resultJson) return;
await copy(state.resultJson);
setStatus("ok", "token.json copied.", "SUCCESS");
});
ui.btnDownload.addEventListener("click", () => {
if (!state.resultJson) return;
const blob = new Blob([state.resultJson], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "token.json";
a.click();
URL.revokeObjectURL(a.href);
setStatus("ok", "Downloaded token.json", "SUCCESS");
});
ui.btnCloseModal.addEventListener("click", hideModal);
ui.modalBackdrop.addEventListener("click", (e) => {
if (e.target === ui.modalBackdrop) hideModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") hideModal();
});
ui.tabViewer.addEventListener("click", () => setTab("viewer"));
ui.tabJson.addEventListener("click", () => setTab("json"));
// Initial state
setLink("");
setCodes({ userCode: "", deviceCode: "" });
setStatus("idle", "Click Login to start.", "IDLE");
setTab("viewer");
})();
</script>
</body>
</html>`;
}
使用方法:
