Add device-grant dev sign-in (CRD-driven) for local/agent development
AuthClient dev-console + AuthServer deviceFlow via k8s/auth-dev.yaml; otel server accepts its own URL as an extra audience (OTEL_OIDC_AUDIENCE). Vite dev-only device-flow sign-in, scripts/dev-auth.mjs helper, AGENTS.md notes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0fde8a0f92
commit
bd17837663
10 files changed
+305
-3
No files matched your search
@@ -0,0 +1,94 @@
|
||||
// Development-only sign-in via the issuer's device grant (RFC 8628), so local
|
||||
// runs are not blocked by the redirect login. Imported only when
|
||||
// import.meta.env.DEV; never ships in a production build.
|
||||
//
|
||||
// Flow: POST /dev-auth/device -> show verification link + code -> poll
|
||||
// /dev-auth/device/token. /dev-auth is proxied to the issuer's /api/auth by
|
||||
// vite.config.ts. Tokens land in the normal store so authFetch just works;
|
||||
// refresh goes through /dev-auth/device/refresh.
|
||||
import { hasSession, saveTokens, setRefreshOverride, type TokenResponse } from "../lib/auth";
|
||||
|
||||
const CLIENT_ID =
|
||||
(import.meta.env.VITE_DEV_AUTH_CLIENT_ID as string | undefined) ?? "dev-console";
|
||||
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
interface DeviceStart {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
verification_uri_complete?: string;
|
||||
expires_in: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
const post = (path: string, body: Record<string, string>) =>
|
||||
fetch(`/dev-auth${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
async function refresh(refreshToken: string): Promise<TokenResponse | null> {
|
||||
const res = await post("/device/refresh", { client_id: CLIENT_ID, refresh_token: refreshToken });
|
||||
return res.ok ? ((await res.json()) as TokenResponse) : null;
|
||||
}
|
||||
|
||||
const esc = (v: string) => v.replace(/[<>&"]/g, "");
|
||||
|
||||
function banner() {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("role", "status");
|
||||
el.style.cssText =
|
||||
"position:fixed;right:16px;bottom:16px;z-index:9999;max-width:340px;padding:12px 14px;border-radius:12px;" +
|
||||
"border:1px solid var(--ds-line-strong);background:var(--ds-surface);color:var(--ds-text);" +
|
||||
"font:13px/1.4 var(--font-ui);";
|
||||
document.body.appendChild(el);
|
||||
return {
|
||||
set(html: string | null) {
|
||||
if (html === null) el.remove();
|
||||
else el.innerHTML = html;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function startDevAuth(): Promise<void> {
|
||||
setRefreshOverride(refresh);
|
||||
if (hasSession()) return;
|
||||
|
||||
const ui = banner();
|
||||
try {
|
||||
const res = await post("/device", { client_id: CLIENT_ID });
|
||||
if (!res.ok) throw new Error(`device start failed: HTTP ${res.status}`);
|
||||
const d = (await res.json()) as DeviceStart;
|
||||
const link = esc(d.verification_uri_complete ?? d.verification_uri);
|
||||
ui.set(
|
||||
`<strong>Dev sign-in</strong><br>Approve in your browser: ` +
|
||||
`<a href="${link}" target="_blank" rel="noreferrer" style="color:var(--ds-accent)">${link}</a>` +
|
||||
`<br>Code <code style="font-family:var(--font-data)">${esc(d.user_code)}</code>`,
|
||||
);
|
||||
console.info(`[dev-auth] approve at ${link} (code ${d.user_code})`);
|
||||
|
||||
let interval = (d.interval ?? 5) * 1000;
|
||||
const deadline = Date.now() + d.expires_in * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
const t = await post("/device/token", {
|
||||
grant_type: DEVICE_GRANT,
|
||||
device_code: d.device_code,
|
||||
client_id: CLIENT_ID,
|
||||
});
|
||||
if (t.ok) {
|
||||
saveTokens((await t.json()) as TokenResponse);
|
||||
ui.set(null);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
const err = ((await t.json().catch(() => ({}))) as { error?: string }).error;
|
||||
if (err === "slow_down") interval += 5000;
|
||||
else if (err !== "authorization_pending") throw new Error(err ?? `HTTP ${t.status}`);
|
||||
}
|
||||
throw new Error("device code expired");
|
||||
} catch (e) {
|
||||
ui.set(`<strong>Dev sign-in failed</strong><br>${esc(String(e instanceof Error ? e.message : e))}`);
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -74,8 +74,25 @@ export async function ensureTokens(): Promise<Tokens | null> {
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
// Dev-only hook (see src/dev/device-auth.ts): tokens obtained through the
|
||||
// device grant must be refreshed through the device refresh endpoint.
|
||||
type RefreshOverride = (refreshToken: string) => Promise<TokenResponse | null>;
|
||||
let refreshOverride: RefreshOverride | null = null;
|
||||
export function setRefreshOverride(fn: RefreshOverride | null) {
|
||||
refreshOverride = fn;
|
||||
}
|
||||
|
||||
async function doRefresh(t: Tokens): Promise<Tokens | null> {
|
||||
try {
|
||||
if (refreshOverride) {
|
||||
const body = await refreshOverride(t.refreshToken!);
|
||||
if (!body) {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
return null;
|
||||
}
|
||||
saveTokens(body, t.refreshToken);
|
||||
return readTokens();
|
||||
}
|
||||
const res = await fetch("/api/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
@@ -97,14 +114,14 @@ async function doRefresh(t: Tokens): Promise<Tokens | null> {
|
||||
}
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
id_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
function saveTokens(body: TokenResponse, fallbackRefresh?: string) {
|
||||
export function saveTokens(body: TokenResponse, fallbackRefresh?: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(
|
||||
TOKEN_KEY,
|
||||
|
||||
@@ -18,6 +18,12 @@ rum?.init({
|
||||
endpoint: "/v1/logs",
|
||||
});
|
||||
|
||||
// Local development only: sign in through the issuer's device grant instead
|
||||
// of the redirect flow. Tree-shaken out of production builds.
|
||||
if (import.meta.env.DEV) {
|
||||
void import("./dev/device-auth").then((m) => m.startDevAuth());
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Router>
|
||||
|
||||
Reference in new issue
Block a user