diff --git a/AGENTS.md b/AGENTS.md index 9a0d9d9..d38371a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,3 +171,24 @@ Initial pass is done. Take **one view at a time** to the full screen pattern (FilterBar + card/aside layout, real buttons, fold or restyle the shared chrome for that screen), verify it in the browser in both themes, commit, then move to the next. Suggested order: Traces, Logs, Metrics, Search, Trends, Setup. + +## Local / agent development without login blocking + +Uses the go-auth device grant (RFC 8628); reference: `https://users.k6n.net/dev-auth.md`. +Config is CRD-driven (`https://users.k6n.net/crds.md`), see `k8s/auth-dev.yaml`: +an `AuthClient` `dev-console` with `spec.audience: [https://otel.k6n.net]` +(audience entries must be absolute URLs - `otel-debug` was rejected and briefly +made the AuthServer `READY False`), plus `AuthServer.spec.deviceFlow`. The otel +server accepts that audience via `OTEL_OIDC_AUDIENCE` (`k8s/deployment.yaml`). + +- **Browser (`npm run dev`)**: `src/dev/device-auth.ts` is imported only under + `import.meta.env.DEV`. With no session it shows a "Dev sign-in" link + code, + polls, then reloads. `/dev-auth/*` is proxied by `vite.config.ts` to the + issuer's `/api/auth/*` (override the issuer with `AUTH_DEV_BASE`). +- **Terminal / agents**: `node scripts/dev-auth.mjs login|token|whoami|forget`. + Refresh token lives in `~/.go-auth/dev/` (0700/0600). Use + `curl -H "Authorization: Bearer $(node scripts/dev-auth.mjs token)" ...`. + `GO_AUTH_TOKEN` is an explicit-token escape hatch; never store it. +- Never commit tokens, never persist them in a production build, never log them. +- Apply with `kubectl apply --dry-run=server --server-side --field-manager=otel-dev-auth -f k8s/auth-dev.yaml` + first; check `kubectl get authserver -n ecommerce` stays `READY True`. diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 22cf757..26ffefa 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -78,6 +78,10 @@ type OidcAuth struct { disabled bool ingestKey string agentToken string + // audience is an optional extra accepted token audience: this service's + // own URL, which the issuer emits via AuthClient.spec.audience (e.g. for + // device-grant dev tokens minted for another client). + audience string mu sync.Mutex keys map[string]*rsa.PublicKey @@ -102,6 +106,7 @@ func NewOidcAuth(ingestKey string) *OidcAuth { disabled: disabled, ingestKey: ingestKey, agentToken: os.Getenv("OTEL_AGENT_TOKEN"), + audience: os.Getenv("OTEL_OIDC_AUDIENCE"), } } @@ -239,7 +244,7 @@ func (a *OidcAuth) VerifyToken(ctx context.Context, token string) (*IDClaims, er if claims.Iss != a.issuer { return nil, fmt.Errorf("unexpected issuer %q", claims.Iss) } - if !claims.Aud.Contains(a.client) { + if !claims.Aud.Contains(a.client) && (a.audience == "" || !claims.Aud.Contains(a.audience)) { return nil, errors.New("token not issued for this client") } if claims.Exp != 0 && time.Now().Unix() > claims.Exp { diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go index 907cfba..d19fcb0 100644 --- a/internal/auth/oidc_test.go +++ b/internal/auth/oidc_test.go @@ -103,6 +103,25 @@ func TestVerifyToken(t *testing.T) { t.Fatalf("expected wrong audience to fail") } + // Extra audience: rejected unless configured, accepted once it is. + urlAudToken := mintToken("key-1", IDClaims{ + Iss: "https://test.issuer", + Sub: "dev-user", + Aud: Audience{"dev-console", "https://svc.example"}, + Exp: time.Now().Add(time.Hour).Unix(), + }) + if _, err := auth.VerifyToken(context.Background(), urlAudToken); err == nil { + t.Fatalf("expected unconfigured extra audience to fail") + } + auth.audience = "https://svc.example" + if _, err := auth.VerifyToken(context.Background(), urlAudToken); err != nil { + t.Fatalf("expected configured audience to pass, got: %v", err) + } + if _, err := auth.VerifyToken(context.Background(), wrongAudToken); err == nil { + t.Fatalf("configured audience must not admit other audiences") + } + auth.audience = "" + // Wrong issuer wrongIssToken := mintToken("key-1", IDClaims{ Iss: "https://wrong.issuer", diff --git a/k8s/auth-dev.yaml b/k8s/auth-dev.yaml new file mode 100644 index 0000000..aeb9729 --- /dev/null +++ b/k8s/auth-dev.yaml @@ -0,0 +1,35 @@ +# Local/agent development sign-in via the RFC 8628 device grant. +# Tokens minted for dev-console carry aud=[dev-console, https://otel.k6n.net], so the +# otel server (which requires aud to contain its own URL (OTEL_OIDC_AUDIENCE)) accepts them with no +# server-side change. Approval is still gated by the issuer's normal policy. +apiVersion: auth.k6n.net/v1alpha1 +kind: AuthClient +metadata: + name: dev-console + namespace: ecommerce + labels: + auth.k6n.net/server: auth +spec: + clientID: dev-console + displayName: Dev console (device grant) + description: Public dev client for local and agent development via the device grant + redirectURIs: [] + audience: + - https://otel.k6n.net + scopes: + - openid + - profile + - email + - offline_access +--- +# Partial apply: only deviceFlow is owned by this field manager. +apiVersion: auth.k6n.net/v1alpha1 +kind: AuthServer +metadata: + name: auth + namespace: ecommerce +spec: + deviceFlow: + clientID: dev-console + codeTTL: 10m + pollInterval: 5s diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 4081336..e146602 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -51,6 +51,10 @@ spec: value: "https://users.k6n.net" - name: OTEL_OIDC_CLIENT_ID value: "otel-debug" + # Also accept tokens whose aud carries this service's URL (device-grant + # dev tokens from the dev-console client, see k8s/auth-dev.yaml). + - name: OTEL_OIDC_AUDIENCE + value: "https://otel.k6n.net" # Service credential for non-interactive debugging agents. It grants # access to protected /api debug endpoints, not OTLP ingest. - name: OTEL_AGENT_TOKEN diff --git a/otel-ui/src/dev/device-auth.ts b/otel-ui/src/dev/device-auth.ts new file mode 100644 index 0000000..40bb89a --- /dev/null +++ b/otel-ui/src/dev/device-auth.ts @@ -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) => + fetch(`/dev-auth${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + +async function refresh(refreshToken: string): Promise { + 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 { + 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( + `Dev sign-in
Approve in your browser: ` + + `${link}` + + `
Code ${esc(d.user_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(`Dev sign-in failed
${esc(String(e instanceof Error ? e.message : e))}`); + } +} diff --git a/otel-ui/src/lib/auth.ts b/otel-ui/src/lib/auth.ts index 81770d6..1591a9d 100644 --- a/otel-ui/src/lib/auth.ts +++ b/otel-ui/src/lib/auth.ts @@ -74,8 +74,25 @@ export async function ensureTokens(): Promise { 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; +let refreshOverride: RefreshOverride | null = null; +export function setRefreshOverride(fn: RefreshOverride | null) { + refreshOverride = fn; +} + async function doRefresh(t: Tokens): Promise { 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 { } } -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, diff --git a/otel-ui/src/main.tsx b/otel-ui/src/main.tsx index 4f7310c..a1d1fb4 100644 --- a/otel-ui/src/main.tsx +++ b/otel-ui/src/main.tsx @@ -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( diff --git a/otel-ui/vite.config.ts b/otel-ui/vite.config.ts index d952689..94d1596 100644 --- a/otel-ui/vite.config.ts +++ b/otel-ui/vite.config.ts @@ -3,6 +3,9 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; const target = "https://otel.k6n.net"; +// Device-grant issuer for local/agent development (see src/dev/device-auth.ts). +// Proxied same-origin so the browser needs no CORS and no issuer URL. +const authIssuer = process.env.AUTH_DEV_BASE ?? "https://users.k6n.net"; // https://vite.dev/config/ export default defineConfig({ @@ -18,6 +21,11 @@ export default defineConfig({ target: target, changeOrigin: true, }, + "/dev-auth": { + target: authIssuer, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/dev-auth/, "/api/auth"), + }, "/auth_callback": { target: target, changeOrigin: true, diff --git a/scripts/dev-auth.mjs b/scripts/dev-auth.mjs new file mode 100644 index 0000000..2aacf4a --- /dev/null +++ b/scripts/dev-auth.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// Terminal helper for agent/local development against the protected API. +// Device grant (RFC 8628) against the go-auth issuer. The refresh token is +// kept in ~/.go-auth/dev/.json (0700 dir, 0600 file). Never commit it. +// +// node scripts/dev-auth.mjs login approve once in a browser +// node scripts/dev-auth.mjs token print a fresh access token +// node scripts/dev-auth.mjs whoami print the token claims +// node scripts/dev-auth.mjs forget delete the stored refresh token +// +// Env: AUTH_DEV_BASE (issuer, default https://users.k6n.net), +// AUTH_DEVICE_CLIENT_ID (default dev-console), +// GO_AUTH_TOKEN (explicit token escape hatch; `token` prints it as-is). +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const BASE = (process.env.AUTH_DEV_BASE ?? "https://users.k6n.net").replace(/\/$/, ""); +const CLIENT = process.env.AUTH_DEVICE_CLIENT_ID ?? "dev-console"; +const DIR = join(homedir(), ".go-auth", "dev"); +const FILE = join(DIR, `${CLIENT}.json`); +const GRANT = "urn:ietf:params:oauth:grant-type:device_code"; + +const post = (path, body) => + fetch(`${BASE}/api/auth${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + +async function save(refresh) { + await mkdir(DIR, { recursive: true, mode: 0o700 }); + await chmod(DIR, 0o700); + const tmp = `${FILE}.tmp`; + await writeFile(tmp, JSON.stringify({ refresh_token: refresh }), { mode: 0o600 }); + await rename(tmp, FILE); +} + +async function login() { + const start = await post("/device", { client_id: CLIENT }); + if (!start.ok) throw new Error(`device start failed: HTTP ${start.status}`); + const d = await start.json(); + console.error(`Approve in a browser: ${d.verification_uri_complete ?? d.verification_uri}\nCode: ${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: GRANT, device_code: d.device_code, client_id: CLIENT }); + const body = await t.json().catch(() => ({})); + if (t.ok) { + await save(body.refresh_token); + console.error("Signed in; refresh token stored."); + return; + } + if (body.error === "slow_down") interval += 5000; + else if (body.error !== "authorization_pending") throw new Error(body.error ?? `HTTP ${t.status}`); + } + throw new Error("device code expired"); +} + +async function token() { + if (process.env.GO_AUTH_TOKEN) return process.env.GO_AUTH_TOKEN; + let stored; + try { + stored = JSON.parse(await readFile(FILE, "utf8")); + } catch { + throw new Error("not signed in: run `node scripts/dev-auth.mjs login`"); + } + const res = await post("/device/refresh", { client_id: CLIENT, refresh_token: stored.refresh_token }); + if (!res.ok) throw new Error(`refresh failed (HTTP ${res.status}); run login again`); + const body = await res.json(); + if (body.refresh_token) await save(body.refresh_token); + return body.access_token; +} + +const cmds = { + login, + token: async () => console.log(await token()), + whoami: async () => { + const t = await token(); + console.log(JSON.stringify(JSON.parse(Buffer.from(t.split(".")[1], "base64url").toString()), null, 2)); + }, + forget: () => rm(FILE, { force: true }), +}; +const cmd = cmds[process.argv[2]]; +if (!cmd) { + console.error("usage: dev-auth.mjs login|token|whoami|forget"); + process.exit(2); +} +cmd().catch((e) => { + console.error(e.message); + process.exit(1); +});