From 84cca71befef13616c0dcb6620cfd1f34183abb8 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 4 Jun 2026 03:51:57 +0800 Subject: [PATCH] feat(cloud-api): v0.2 OIDC login/callback (state, nonce, user upsert) --- internal/cloud/access-control.test.ts | 81 ++++++++++++ internal/cloud/access-control.ts | 181 +++++++++++++++++++++++++- internal/cloud/server-http-utils.ts | 9 ++ 3 files changed, 270 insertions(+), 1 deletion(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index a2402ab6..bb958aa2 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -184,6 +184,87 @@ test("workbench workspace is account-scoped, persistent, and permits independent } }); +test("cloud api /auth/oidc/login returns 302 to Keycloak when issuer and client are configured", async () => { + const server = createCloudApiServer({ + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: "https://auth.74-48-78-17.nip.io/realms/hwlab", HWLAB_KEYCLOAK_CLIENT_ID: "hwlab-cloud-web", HWLAB_PUBLIC_ENDPOINT: "http://74.48.78.17:19667" }, + now: () => "2026-06-03T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/auth/oidc/login?returnTo=/workbench`, { redirect: "manual" }); + assert.equal(response.status, 302); + const location = response.headers.get("location"); + assert.ok(location?.startsWith("https://auth.74-48-78-17.nip.io/realms/hwlab/protocol/openid-connect/auth"), `unexpected location: ${location}`); + assert.ok(location?.includes("client_id=hwlab-cloud-web")); + assert.ok(location?.includes("response_type=code")); + assert.ok(location?.includes("state=")); + assert.ok(location?.includes("nonce=")); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + +test("cloud api /auth/oidc/callback upserts user by issuer+sub and issues 24h web session", async () => { + const accessController = createAccessController({ + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: "https://auth.74-48-78-17.nip.io/realms/hwlab", HWLAB_KEYCLOAK_CLIENT_ID: "hwlab-cloud-web", HWLAB_KEYCLOAK_CLIENT_SECRET: "secret", HWLAB_PUBLIC_ENDPOINT: "http://74.48.78.17:19667" }, + fetchImpl: makeOidcFetchMock(), + now: () => "2026-06-03T00:00:00.000Z" + }); + const state = "test-state"; + await accessController.store.upsertOidcState({ state, nonce: "test-nonce", returnTo: "/workbench", createdAt: "2026-06-03T00:00:00.000Z", expiresAt: "2026-06-03T00:10:00.000Z" }); + const server = createCloudApiServer({ + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, + accessController, + now: () => "2026-06-03T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/auth/oidc/callback?code=oidc-code&state=${state}`, { redirect: "manual" }); + assert.equal(response.status, 302); + const cookie = response.headers.get("set-cookie") ?? ""; + assert.match(cookie, /hwlab_session=[A-Za-z0-9_-]+;/); + assert.match(cookie, /HttpOnly/); + assert.match(cookie, /Secure/); + assert.match(cookie, /Max-Age=86400/); + const sessionResponse = await fetch(`http://127.0.0.1:${port}/v1/auth/session`, { headers: { cookie: cookie.split(";")[0] } }); + assert.equal(sessionResponse.status, 200); + const sessionBody = await sessionResponse.json(); + assert.equal(sessionBody.authenticated, true); + assert.equal(sessionBody.actor.username, "oidc-user"); + assert.equal(sessionBody.actor.role, "user"); + assert.equal(sessionBody.authMethod, "web-session"); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + +function makeOidcFetchMock() { + return async (url) => { + const u = new URL(String(url)); + if (u.pathname.endsWith("/protocol/openid-connect/token")) { + return new Response(JSON.stringify({ + access_token: "oidc-access-token", + refresh_token: "oidc-refresh-token", + id_token: "oidc-id-token", + token_type: "Bearer", + expires_in: 300 + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (u.pathname.endsWith("/protocol/openid-connect/userinfo")) { + return new Response(JSON.stringify({ + sub: "keycloak-sub-1234", + preferred_username: "oidc-user", + email: "oidc-user@hwlab.local", + name: "OIDC User", + email_verified: true + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }; +} + test("workbench workspace permits a new turn after AgentRun active trace reaches terminal status", async () => { const agentRunCalls = []; const agentSessions = new Map(); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index db395fc1..b1450b80 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -7,7 +7,7 @@ import { syncAgentRunChatResult } from "./code-agent-agentrun-adapter.ts"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; -import { getHeader, readBody, sendJson, truthyFlag } from "./server-http-utils.ts"; +import { getHeader, readBody, sendJson, sendRedirect, truthyFlag } from "./server-http-utils.ts"; const SESSION_COOKIE = "hwlab_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; @@ -83,6 +83,14 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ )`, `CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id, status, created_at DESC)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix)`, + `CREATE TABLE IF NOT EXISTS oidc_states ( + state TEXT PRIMARY KEY, + nonce TEXT NOT NULL, + return_to TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)`, `CREATE TABLE IF NOT EXISTS user_sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), @@ -240,6 +248,14 @@ class AccessController { async handleAuthRoute(request, response, url) { try { await this.ensureBootstrap(); + if (url.pathname === "/auth/oidc/login" && (request.method === "GET" || request.method === "POST")) { + await this.handleOidcLogin(request, response, url); + return; + } + if (url.pathname === "/auth/oidc/callback" && request.method === "GET") { + await this.handleOidcCallback(request, response, url); + return; + } if (request.method === "GET" && url.pathname === "/auth/session") { sendJson(response, 200, await this.sessionPayload(request)); return; @@ -623,6 +639,158 @@ class AccessController { }; } + async handleOidcLogin(request, response, url) { + const issuer = textOr(this.env.HWLAB_KEYCLOAK_ISSUER, ""); + const clientId = textOr(this.env.HWLAB_KEYCLOAK_CLIENT_ID, ""); + if (!issuer || !clientId) { + return sendJson(response, 503, errorPayload("oidc_not_configured", "OIDC issuer / client are not configured on this cloud-api", 503)); + } + const returnTo = textOr(url.searchParams.get("returnTo"), "/"); + const state = randomBytes(24).toString("base64url"); + const nonce = randomBytes(24).toString("base64url"); + const authorizeUrl = new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/auth`); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("client_id", clientId); + authorizeUrl.searchParams.set("redirect_uri", this.oidcRedirectUri(request)); + authorizeUrl.searchParams.set("scope", "openid profile email"); + authorizeUrl.searchParams.set("state", state); + authorizeUrl.searchParams.set("nonce", nonce); + await this.store.upsertOidcState?.({ + state, + nonce, + returnTo, + createdAt: this.now(), + expiresAt: new Date(Date.parse(this.now()) + 600 * 1000).toISOString() + }); + sendRedirect(response, authorizeUrl.toString(), { contractVersion: "oidc-v1" }); + } + + async handleOidcCallback(request, response, url) { + const issuer = textOr(this.env.HWLAB_KEYCLOAK_ISSUER, ""); + const clientId = textOr(this.env.HWLAB_KEYCLOAK_CLIENT_ID, ""); + const clientSecret = textOr(this.env.HWLAB_KEYCLOAK_CLIENT_SECRET, ""); + if (!issuer || !clientId || !clientSecret) { + return sendJson(response, 503, errorPayload("oidc_not_configured", "OIDC issuer / client are not configured on this cloud-api", 503)); + } + const code = textOr(url.searchParams.get("code"), ""); + const state = textOr(url.searchParams.get("state"), ""); + const error = textOr(url.searchParams.get("error"), ""); + if (error) { + return sendJson(response, 400, errorPayload("oidc_error", `OIDC provider returned error: ${error}`, 400)); + } + if (!code || !state) { + return sendJson(response, 400, errorPayload("oidc_missing_params", "code and state are required", 400)); + } + const stored = await this.store.consumeOidcState?.(state) ?? null; + if (!stored) { + return sendJson(response, 400, errorPayload("oidc_invalid_state", "OIDC state not found or already consumed", 400)); + } + if (new Date(stored.expiresAt).getTime() < Date.parse(this.now())) { + return sendJson(response, 400, errorPayload("oidc_state_expired", "OIDC state expired; retry login", 400)); + } + const tokenResponse = await this.exchangeOidcCode({ issuer, clientId, clientSecret, code, request }); + if (!tokenResponse.ok) { + return sendJson(response, tokenResponse.status, errorPayload(tokenResponse.code, tokenResponse.message, tokenResponse.status)); + } + const userinfo = await this.fetchOidcUserinfo({ issuer, accessToken: tokenResponse.accessToken }); + if (!userinfo.ok) { + return sendJson(response, userinfo.status, errorPayload(userinfo.code, userinfo.message, userinfo.status)); + } + if (textOr(userinfo.claims.nonce, "") && textOr(userinfo.claims.nonce, "") !== textOr(stored.nonce, "")) { + return sendJson(response, 400, errorPayload("oidc_nonce_mismatch", "OIDC nonce mismatch", 400)); + } + const actor = await this.upsertOidcUser({ issuer, claims: userinfo.claims, accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken }); + if (!actor) { + return sendJson(response, 500, errorPayload("oidc_user_upsert_failed", "Failed to upsert OIDC user", 500)); + } + const session = await this.issueWebSessionForActor(actor); + setSessionCookie(response, session.token, SESSION_MAX_AGE_SECONDS); + sendRedirect(response, stored.returnTo || "/", { contractVersion: "oidc-v1" }); + } + + oidcRedirectUri(request) { + const publicBase = textOr(this.env.HWLAB_PUBLIC_ENDPOINT, ""); + if (publicBase) return `${publicBase.replace(/\/$/, "")}/auth/oidc/callback`; + return `${textOr(this.env.HWLAB_RUNTIME_API_URL, "http://127.0.0.1:6667")}/auth/oidc/callback`; + } + + async exchangeOidcCode({ issuer, clientId, clientSecret, code, request }) { + const tokenUrl = `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`; + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: this.oidcRedirectUri(request) + }); + try { + const response = await this.fetchImpl(tokenUrl, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, + body: body.toString() + }); + const text = await response.text(); + const json = parseJson(text, {}); + if (response.status >= 400 || !json.access_token) { + return { ok: false, status: response.status, code: "oidc_token_exchange_failed", message: textOr(json.error_description, text) }; + } + return { ok: true, accessToken: json.access_token, refreshToken: json.refresh_token ?? null, idTokenClaims: json }; + } catch (error) { + return { ok: false, status: 500, code: "oidc_token_exchange_error", message: error?.message ?? String(error) }; + } + } + + async fetchOidcUserinfo({ issuer, accessToken }) { + try { + const userinfoUrl = `${issuer.replace(/\/$/, "")}/protocol/openid-connect/userinfo`; + const response = await this.fetchImpl(userinfoUrl, { headers: { accept: "application/json", authorization: `Bearer ${accessToken}` } }); + const text = await response.text(); + const claims = parseJson(text, {}); + if (response.status >= 400 || !claims.sub) { + return { ok: false, status: response.status, code: "oidc_userinfo_failed", message: textOr(claims.error_description, text) }; + } + return { ok: true, claims }; + } catch (error) { + return { ok: false, status: 500, code: "oidc_userinfo_error", message: error?.message ?? String(error) }; + } + } + + async upsertOidcUser({ issuer, claims }) { + const sub = textOr(claims.sub, ""); + if (!sub) return null; + const username = textOr(claims.preferred_username, claims.email ?? sub); + const email = textOr(claims.email, ""); + const displayName = textOr(claims.name, username); + const existing = await this.store.findUserByKeycloakSubject?.(issuer, sub) ?? null; + if (existing) { + await this.store.updateUserOidcLogin?.({ userId: existing.id, email, lastLoginAt: this.now() }); + return existing; + } + return await this.store.createUserOidc?.({ + username, + displayName, + email, + keycloakIssuer: issuer, + keycloakSub: sub, + role: "user", + status: "active", + now: this.now() + }); + } + + async issueWebSessionForActor(actor) { + const token = randomBytes(32).toString("base64url"); + const now = this.now(); + const session = await this.store.createSession({ + userId: actor.id, + tokenHash: sha256(token), + now, + expiresAt: new Date(Date.parse(now) + SESSION_MAX_AGE_SECONDS * 1000).toISOString() + }); + await this.store.ensureDefaultApiKeyForUser?.({ userId: actor.id, now }); + return { token, session }; + } + async handleLogin(request, response) { const body = await jsonBody(request); const user = await this.store.findUserByUsername(requiredText(body.username, "username")); @@ -1562,6 +1730,7 @@ class MemoryAccessStore { this.users = new Map(); this.sessions = new Map(); this.apiKeys = new Map(); + this.oidcStates = new Map(); this.devicePods = new Map(); this.grants = new Set(); this.jobs = new Map(); @@ -1616,6 +1785,11 @@ class MemoryAccessStore { async touchApiKey(id, now) { const key = this.apiKeys.get(id); if (key) key.lastUsedAt = now; } async revokeApiKey(id, now) { const key = this.apiKeys.get(id); if (key) { key.status = "revoked"; key.revokedAt = now; } return key ?? null; } async revokeApiKeysForUser(userId, now) { let count = 0; for (const key of this.apiKeys.values()) { if (key.userId === userId && key.status === "active") { key.status = "revoked"; key.revokedAt = now; count += 1; } } return count; } + async upsertOidcState(input) { this.oidcStates.set(input.state, { state: input.state, nonce: input.nonce, returnTo: input.returnTo, createdAt: input.createdAt, expiresAt: input.expiresAt }); return { ok: true }; } + async consumeOidcState(state) { const entry = this.oidcStates.get(state) ?? null; if (entry) this.oidcStates.delete(state); return entry; } + async findUserByKeycloakSubject(issuer, sub) { for (const user of this.users.values()) { if (user.authProvider === "keycloak" && user.keycloakIssuer === issuer && user.keycloakSub === sub) return user; } return null; } + async createUserOidc(input) { const id = `usr_${randomUUID()}`; const now = input.now ?? this.now(); const user = { id, username: input.username, displayName: input.displayName ?? input.username, role: input.role ?? "user", status: input.status ?? "active", passwordHash: null, authProvider: "keycloak", keycloakIssuer: input.keycloakIssuer, keycloakSub: input.keycloakSub, email: input.email, lastLoginAt: now, createdAt: now, updatedAt: now }; this.users.set(id, user); return user; } + async updateUserOidcLogin(input) { const user = this.users.get(input.userId); if (!user) return null; this.users.set(input.userId, { ...user, email: input.email, lastLoginAt: input.lastLoginAt, updatedAt: this.now() }); return this.users.get(input.userId); } async ensureDefaultApiKeyForUser(input = {}) { const existing = await this.findActiveDefaultApiKeyForUser(input.userId); if (existing) return { key: existing, created: false }; @@ -1770,6 +1944,11 @@ class PostgresAccessStore extends MemoryAccessStore { async touchApiKey(id, now) { await this.ensureSchema(); await this.query("UPDATE api_keys SET last_used_at = $2 WHERE id = $1", [id, now]); } async revokeApiKey(id, now) { await this.ensureSchema(); const result = await this.query("UPDATE api_keys SET status = 'revoked', revoked_at = $2 WHERE id = $1 RETURNING id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at", [id, now]); return pgApiKey(result.rows?.[0]); } async revokeApiKeysForUser(userId, now) { await this.ensureSchema(); const result = await this.query("UPDATE api_keys SET status = 'revoked', revoked_at = $2 WHERE user_id = $1 AND status = 'active' RETURNING id", [userId, now]); return result.rows?.length ?? 0; } + async upsertOidcState(input) { await this.ensureSchema(); await this.query("INSERT INTO oidc_states (state, nonce, return_to, created_at, expires_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (state) DO UPDATE SET nonce = EXCLUDED.nonce, return_to = EXCLUDED.return_to, created_at = EXCLUDED.created_at, expires_at = EXCLUDED.expires_at", [input.state, input.nonce, input.returnTo ?? "", input.createdAt, input.expiresAt]); return { ok: true }; } + async consumeOidcState(state) { await this.ensureSchema(); const result = await this.query("DELETE FROM oidc_states WHERE state = $1 RETURNING state, nonce, return_to, created_at, expires_at", [state]); const row = result.rows?.[0]; if (!row) return null; return { state: row.state, nonce: row.nonce, returnTo: row.return_to, createdAt: row.created_at, expiresAt: row.expires_at }; } + async findUserByKeycloakSubject(issuer, sub) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at FROM users WHERE keycloak_issuer = $1 AND keycloak_sub = $2 LIMIT 1", [issuer, sub]); return pgUserWithOIDC(result.rows?.[0]); } + async createUserOidc(input) { await this.ensureSchema(); const id = `usr_${randomUUID()}`; const now = input.now ?? this.now(); const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, auth_provider = EXCLUDED.auth_provider, keycloak_issuer = EXCLUDED.keycloak_issuer, keycloak_sub = EXCLUDED.keycloak_sub, email = EXCLUDED.email, last_login_at = EXCLUDED.last_login_at, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [id, input.username, input.displayName ?? input.username, input.role ?? "user", input.status ?? "active", null, "keycloak", input.keycloakIssuer, input.keycloakSub, input.email ?? null, now, now, now]); return pgUserWithOIDC(result.rows?.[0]); } + async updateUserOidcLogin(input) { await this.ensureSchema(); const result = await this.query("UPDATE users SET email = COALESCE($2, email), last_login_at = $3, updated_at = $3 WHERE id = $1 RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [input.userId, input.email ?? null, input.lastLoginAt]); return pgUserWithOIDC(result.rows?.[0]); } async upsertDevicePod(input) { await this.ensureSchema(); const pod = await super.upsertDevicePod(input); const result = await this.query("INSERT INTO device_pods (id, name, status, profile_json, profile_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, status = EXCLUDED.status, profile_json = EXCLUDED.profile_json, profile_hash = EXCLUDED.profile_hash, updated_at = EXCLUDED.updated_at RETURNING *", [pod.id, pod.name, pod.status, stableJson(pod.profile), pod.profileHash, pod.createdAt, pod.updatedAt]); return pgDevicePod(result.rows?.[0]); } async grantDevicePod(input) { await this.ensureSchema(); const grant = await super.grantDevicePod(input); await this.query("INSERT INTO device_pod_grants (device_pod_id, user_id, created_by_admin_id, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (device_pod_id, user_id) DO UPDATE SET created_by_admin_id = EXCLUDED.created_by_admin_id, created_at = EXCLUDED.created_at", [grant.devicePodId, grant.userId, grant.createdByAdminId, grant.createdAt]); return grant; } async revokeDevicePodGrant(input) { await this.ensureSchema(); await this.query("DELETE FROM device_pod_grants WHERE device_pod_id = $1 AND user_id = $2", [input.devicePodId, input.userId]); } diff --git a/internal/cloud/server-http-utils.ts b/internal/cloud/server-http-utils.ts index fba9fcf2..56da0e71 100644 --- a/internal/cloud/server-http-utils.ts +++ b/internal/cloud/server-http-utils.ts @@ -28,6 +28,15 @@ export function sendJson(response, statusCode, body) { response.end(JSON.stringify(body) + "\n"); } +export function sendRedirect(response, location, body = {}) { + response.writeHead(302, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + location + }); + response.end(JSON.stringify({ redirectTo: location, ...body }) + "\n"); +} + export function getHeader(request, name) { const value = request.headers[name.toLowerCase()]; if (Array.isArray(value)) return value[0];