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`),