From ffff80029573d63666652105de74309619c59be9 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Sat, 18 Jul 2026 14:05:35 +0700 Subject: [PATCH] feat(control-panel): add oidc account switching --- infra/local-prod/idp/mock_idp.py | 77 ++++++++++++++++--- .../backend/src/app.module.ts | 3 +- .../backend/src/session/session.controller.ts | 11 +++ .../frontend/src/components/layout/Header.tsx | 13 +++- .../frontend/src/lib/api.ts | 1 + 5 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 packages/casan-control-panel/backend/src/session/session.controller.ts diff --git a/infra/local-prod/idp/mock_idp.py b/infra/local-prod/idp/mock_idp.py index 403076e..9280e3b 100644 --- a/infra/local-prod/idp/mock_idp.py +++ b/infra/local-prod/idp/mock_idp.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import json import hmac +import html import os import time import uuid @@ -16,6 +17,20 @@ ISSUER = os.environ.get("CASAN_IDP_ISSUER", f"http://127.0.0.1:{PORT}") DEFAULT_SUB = os.environ.get("CASAN_IDP_SUB", "oidc-ops") DEFAULT_EMAIL = os.environ.get("CASAN_IDP_EMAIL", "oidc-ops@example.com") DEFAULT_GROUPS = [g for g in os.environ.get("CASAN_IDP_GROUPS", "casan-org-admin,casan-approver").split(",") if g] +USERS = { + DEFAULT_SUB: { + "name": "Operations Owner", + "email": DEFAULT_EMAIL, + "groups": DEFAULT_GROUPS, + "description": "Creates and operates governed runs", + }, + "oidc-reviewer": { + "name": "Independent Reviewer", + "email": "oidc-reviewer@example.com", + "groups": ["casan-approver"], + "description": "Reviews and approves another operator's proposal", + }, +} KID = "casan-local-prod-idp" KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) CODES = {} @@ -41,14 +56,15 @@ def jwk(): } -def sign_claims(client_id="casan-control-panel", nonce="", ttl_s=300): +def sign_claims(identity=None, client_id="casan-control-panel", nonce="", ttl_s=300): + identity = identity or USERS[DEFAULT_SUB] now = int(time.time()) claims = { "iss": ISSUER, - "sub": DEFAULT_SUB, + "sub": identity.get("sub", DEFAULT_SUB), "aud": client_id, - "email": DEFAULT_EMAIL, - "groups": DEFAULT_GROUPS, + "email": identity.get("email", DEFAULT_EMAIL), + "groups": identity.get("groups", DEFAULT_GROUPS), "iat": now, "exp": now + int(ttl_s), } @@ -67,6 +83,37 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def send_html(self, code, body): + raw = body.encode() + self.send_response(code) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(raw) + + def login_page(self, query): + cards = [] + base = {key: values[0] for key, values in query.items() if key != "user" and values} + for user_id, identity in USERS.items(): + params = {**base, "user": user_id} + cards.append( + '' + '{}{}' + '{}{}'.format( + html.escape(urlencode(params), quote=True), + html.escape(identity["name"][0]), + html.escape(identity["name"]), + html.escape(user_id), + html.escape(identity["description"]), + ) + ) + template = """ +Sign in to CASAN +
CASAN LOCAL OIDC

Choose an identity

Use separate operator and reviewer identities to preserve separation of duties.

__IDENTITY_CARDS__
""" + return template.replace("__IDENTITY_CARDS__", "".join(cards)) + def do_GET(self): # noqa: N802 parsed = urlparse(self.path) path = parsed.path @@ -96,13 +143,18 @@ class Handler(BaseHTTPRequestHandler): if not redirect_uri: self.send_json(400, {"error": "missing_redirect_uri"}) return + user_id = q.get("user", [""])[0] + if user_id not in USERS: + self.send_html(200, self.login_page(q)) + return + identity = USERS[user_id] code = uuid.uuid4().hex CODES[code] = { "client_id": q.get("client_id", ["casan-control-panel"])[0], "nonce": q.get("nonce", [""])[0], - "sub": DEFAULT_SUB, - "email": DEFAULT_EMAIL, - "groups": DEFAULT_GROUPS, + "sub": user_id, + "email": identity["email"], + "groups": identity["groups"], } params = {"code": code} if q.get("state"): @@ -112,7 +164,13 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Location", redirect_uri + sep + urlencode(params)) self.end_headers() elif path == "/userinfo": - self.send_json(200, {"sub": DEFAULT_SUB, "email": DEFAULT_EMAIL, "groups": DEFAULT_GROUPS}) + authorization = self.headers.get("Authorization", "") + token = authorization.removeprefix("Bearer ").strip() + try: + claims = jwt.decode(token, KEY.public_key(), algorithms=["RS256"], options={"verify_aud": False}) + self.send_json(200, {"sub": claims["sub"], "email": claims["email"], "groups": claims["groups"]}) + except Exception: + self.send_json(401, {"error": "invalid_token"}) else: self.send_json(404, {"error": "not_found"}) @@ -156,7 +214,8 @@ class Handler(BaseHTTPRequestHandler): if not rec: self.send_json(400, {"error": "invalid_grant"}) return - token, claims = sign_claims(client_id=rec.get("client_id") or form.get("client_id", ["casan-control-panel"])[0], + token, claims = sign_claims(identity=rec, + client_id=rec.get("client_id") or form.get("client_id", ["casan-control-panel"])[0], nonce=rec.get("nonce", "")) self.send_json(200, { "access_token": token, diff --git a/packages/casan-control-panel/backend/src/app.module.ts b/packages/casan-control-panel/backend/src/app.module.ts index b028ae5..5ceb6df 100644 --- a/packages/casan-control-panel/backend/src/app.module.ts +++ b/packages/casan-control-panel/backend/src/app.module.ts @@ -8,9 +8,10 @@ import { ChatModule } from './chat/chat.module.js'; import { ProviderAuthModule } from './provider-auth/provider-auth.module.js'; import { GoalsModule } from './goals/goals.module.js'; import { EvidenceModule } from './evidence/evidence.module.js'; +import { SessionController } from './session/session.controller.js'; @Module({ imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule, GoalsModule, EvidenceModule], - controllers: [HealthController], + controllers: [HealthController, SessionController], }) export class AppModule {} diff --git a/packages/casan-control-panel/backend/src/session/session.controller.ts b/packages/casan-control-panel/backend/src/session/session.controller.ts new file mode 100644 index 0000000..41d931b --- /dev/null +++ b/packages/casan-control-panel/backend/src/session/session.controller.ts @@ -0,0 +1,11 @@ +import { Controller, Get, Headers } from '@nestjs/common'; +import { ok } from '../common/api-response.js'; +import { actorFromHeaders } from '../common/auth-context.js'; + +@Controller('api/v1/session') +export class SessionController { + @Get() + current(@Headers() headers: Record) { + return ok(actorFromHeaders(headers)); + } +} diff --git a/packages/casan-control-panel/frontend/src/components/layout/Header.tsx b/packages/casan-control-panel/frontend/src/components/layout/Header.tsx index 8068bb8..1ce3d2b 100644 --- a/packages/casan-control-panel/frontend/src/components/layout/Header.tsx +++ b/packages/casan-control-panel/frontend/src/components/layout/Header.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useLocation } from 'react-router-dom'; -import { health } from '../../lib/api'; +import { api, health } from '../../lib/api'; const PAGE_COPY: Record = { '/': { title: 'Operations overview', eyebrow: 'System posture' }, @@ -18,6 +18,7 @@ const PAGE_COPY: Record = { export function Header() { const { data } = useQuery({ queryKey: ['health'], queryFn: health }); + const { data: session } = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: 60_000 }); const { pathname } = useLocation(); const stale = data ? !data.ok : true; const page = PAGE_COPY[pathname] ?? PAGE_COPY['/']; @@ -36,6 +37,16 @@ export function Header() { {stale ? `STALE${data?.metrics_age_s != null ? ` · ${data.metrics_age_s}s` : ''}` : 'LIVE TRUST'} +
+
{session?.actor ?? 'Loading identity…'}
+
{session?.role ?? 'OIDC'}
+
+ + Sign out / switch + diff --git a/packages/casan-control-panel/frontend/src/lib/api.ts b/packages/casan-control-panel/frontend/src/lib/api.ts index f78a27d..65f64f8 100644 --- a/packages/casan-control-panel/frontend/src/lib/api.ts +++ b/packages/casan-control-panel/frontend/src/lib/api.ts @@ -424,6 +424,7 @@ function actorHeaders(actor: SettingsActor): Record { } export const api = { + session: () => get('session'), overview: () => get('overview'), runs: (limit = 50) => get(`runs?limit=${limit}`), traceGraph: (traceId: string) => get(`runs/${encodeURIComponent(traceId)}/graph`),