feat(cloud-api): v0.2 OIDC login/callback (state, nonce, user upsert)

This commit is contained in:
Codex
2026-06-04 03:51:57 +08:00
committed by Codex Agent
parent 1ba5103d33
commit 84cca71bef
3 changed files with 270 additions and 1 deletions
+81
View File
@@ -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();
+180 -1
View File
@@ -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]); }
+9
View File
@@ -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];