Add device-grant dev sign-in (CRD-driven) for local/agent development
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s

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:
matsandClaude Sonnet 5 committed 2026-09-19 09:08:46 +00:00
1 parent 0fde8a0f92
commit bd17837663
10 files changed
+305 -3

No files matched your search

+21
View File
@@ -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 (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 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. 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`.
+6 -1
View File
@@ -78,6 +78,10 @@ type OidcAuth struct {
disabled bool disabled bool
ingestKey string ingestKey string
agentToken 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 mu sync.Mutex
keys map[string]*rsa.PublicKey keys map[string]*rsa.PublicKey
@@ -102,6 +106,7 @@ func NewOidcAuth(ingestKey string) *OidcAuth {
disabled: disabled, disabled: disabled,
ingestKey: ingestKey, ingestKey: ingestKey,
agentToken: os.Getenv("OTEL_AGENT_TOKEN"), 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 { if claims.Iss != a.issuer {
return nil, fmt.Errorf("unexpected issuer %q", claims.Iss) 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") return nil, errors.New("token not issued for this client")
} }
if claims.Exp != 0 && time.Now().Unix() > claims.Exp { if claims.Exp != 0 && time.Now().Unix() > claims.Exp {
+19
View File
@@ -103,6 +103,25 @@ func TestVerifyToken(t *testing.T) {
t.Fatalf("expected wrong audience to fail") 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 // Wrong issuer
wrongIssToken := mintToken("key-1", IDClaims{ wrongIssToken := mintToken("key-1", IDClaims{
Iss: "https://wrong.issuer", Iss: "https://wrong.issuer",
+35
View File
@@ -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
+4
View File
@@ -51,6 +51,10 @@ spec:
value: "https://users.k6n.net" value: "https://users.k6n.net"
- name: OTEL_OIDC_CLIENT_ID - name: OTEL_OIDC_CLIENT_ID
value: "otel-debug" 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 # Service credential for non-interactive debugging agents. It grants
# access to protected /api debug endpoints, not OTLP ingest. # access to protected /api debug endpoints, not OTLP ingest.
- name: OTEL_AGENT_TOKEN - name: OTEL_AGENT_TOKEN
+94
View File
@@ -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
View File
@@ -74,8 +74,25 @@ export async function ensureTokens(): Promise<Tokens | null> {
return refreshing; 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> { async function doRefresh(t: Tokens): Promise<Tokens | null> {
try { 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", { const res = await fetch("/api/auth/token", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, 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; access_token: string;
id_token: string; id_token: string;
refresh_token?: string; refresh_token?: string;
expires_in: number; expires_in: number;
} }
function saveTokens(body: TokenResponse, fallbackRefresh?: string) { export function saveTokens(body: TokenResponse, fallbackRefresh?: string) {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
localStorage.setItem( localStorage.setItem(
TOKEN_KEY, TOKEN_KEY,
+6
View File
@@ -18,6 +18,12 @@ rum?.init({
endpoint: "/v1/logs", 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( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<Router> <Router>
+8
View File
@@ -3,6 +3,9 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
const target = "https://otel.k6n.net"; 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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
@@ -18,6 +21,11 @@ export default defineConfig({
target: target, target: target,
changeOrigin: true, changeOrigin: true,
}, },
"/dev-auth": {
target: authIssuer,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/dev-auth/, "/api/auth"),
},
"/auth_callback": { "/auth_callback": {
target: target, target: target,
changeOrigin: true, changeOrigin: true,
+93
View File
@@ -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/<client>.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);
});