feat(control-panel): add oidc account switching
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import json
|
import json
|
||||||
import hmac
|
import hmac
|
||||||
|
import html
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
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_SUB = os.environ.get("CASAN_IDP_SUB", "oidc-ops")
|
||||||
DEFAULT_EMAIL = os.environ.get("CASAN_IDP_EMAIL", "oidc-ops@example.com")
|
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]
|
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"
|
KID = "casan-local-prod-idp"
|
||||||
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
CODES = {}
|
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())
|
now = int(time.time())
|
||||||
claims = {
|
claims = {
|
||||||
"iss": ISSUER,
|
"iss": ISSUER,
|
||||||
"sub": DEFAULT_SUB,
|
"sub": identity.get("sub", DEFAULT_SUB),
|
||||||
"aud": client_id,
|
"aud": client_id,
|
||||||
"email": DEFAULT_EMAIL,
|
"email": identity.get("email", DEFAULT_EMAIL),
|
||||||
"groups": DEFAULT_GROUPS,
|
"groups": identity.get("groups", DEFAULT_GROUPS),
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + int(ttl_s),
|
"exp": now + int(ttl_s),
|
||||||
}
|
}
|
||||||
@@ -67,6 +83,37 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
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(
|
||||||
|
'<a class="identity" href="?{}">'
|
||||||
|
'<span class="avatar">{}</span><span><strong>{}</strong>'
|
||||||
|
'<small>{}</small><em>{}</em></span></a>'.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 = """<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Sign in to CASAN</title><style>
|
||||||
|
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at top,#e0e7ff,#f8fafc 48%);font:15px system-ui;color:#0f172a}.panel{width:min(92vw,520px);background:#fff;border:1px solid #e2e8f0;border-radius:24px;padding:32px;box-shadow:0 24px 70px #47556922}.brand{color:#4f46e5;font-size:12px;font-weight:800;letter-spacing:.18em}.panel h1{margin:8px 0}.panel p{color:#64748b;margin:0 0 24px}.identity{display:flex;gap:14px;align-items:center;padding:16px;margin-top:12px;border:1px solid #e2e8f0;border-radius:16px;color:inherit;text-decoration:none;transition:.15s}.identity:hover{border-color:#818cf8;transform:translateY(-1px);box-shadow:0 10px 24px #6366f122}.avatar{display:grid;place-items:center;width:44px;height:44px;border-radius:14px;background:#eef2ff;color:#4f46e5;font-size:18px;font-weight:800}.identity span:last-child{display:grid;gap:2px}.identity small{color:#6366f1;font-weight:700}.identity em{color:#64748b;font-size:12px;font-style:normal}</style></head>
|
||||||
|
<body><main class="panel"><div class="brand">CASAN LOCAL OIDC</div><h1>Choose an identity</h1><p>Use separate operator and reviewer identities to preserve separation of duties.</p>__IDENTITY_CARDS__</main></body></html>"""
|
||||||
|
return template.replace("__IDENTITY_CARDS__", "".join(cards))
|
||||||
|
|
||||||
def do_GET(self): # noqa: N802
|
def do_GET(self): # noqa: N802
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
path = parsed.path
|
path = parsed.path
|
||||||
@@ -96,13 +143,18 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not redirect_uri:
|
if not redirect_uri:
|
||||||
self.send_json(400, {"error": "missing_redirect_uri"})
|
self.send_json(400, {"error": "missing_redirect_uri"})
|
||||||
return
|
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
|
code = uuid.uuid4().hex
|
||||||
CODES[code] = {
|
CODES[code] = {
|
||||||
"client_id": q.get("client_id", ["casan-control-panel"])[0],
|
"client_id": q.get("client_id", ["casan-control-panel"])[0],
|
||||||
"nonce": q.get("nonce", [""])[0],
|
"nonce": q.get("nonce", [""])[0],
|
||||||
"sub": DEFAULT_SUB,
|
"sub": user_id,
|
||||||
"email": DEFAULT_EMAIL,
|
"email": identity["email"],
|
||||||
"groups": DEFAULT_GROUPS,
|
"groups": identity["groups"],
|
||||||
}
|
}
|
||||||
params = {"code": code}
|
params = {"code": code}
|
||||||
if q.get("state"):
|
if q.get("state"):
|
||||||
@@ -112,7 +164,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_header("Location", redirect_uri + sep + urlencode(params))
|
self.send_header("Location", redirect_uri + sep + urlencode(params))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
elif path == "/userinfo":
|
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:
|
else:
|
||||||
self.send_json(404, {"error": "not_found"})
|
self.send_json(404, {"error": "not_found"})
|
||||||
|
|
||||||
@@ -156,7 +214,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not rec:
|
if not rec:
|
||||||
self.send_json(400, {"error": "invalid_grant"})
|
self.send_json(400, {"error": "invalid_grant"})
|
||||||
return
|
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", ""))
|
nonce=rec.get("nonce", ""))
|
||||||
self.send_json(200, {
|
self.send_json(200, {
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { ChatModule } from './chat/chat.module.js';
|
|||||||
import { ProviderAuthModule } from './provider-auth/provider-auth.module.js';
|
import { ProviderAuthModule } from './provider-auth/provider-auth.module.js';
|
||||||
import { GoalsModule } from './goals/goals.module.js';
|
import { GoalsModule } from './goals/goals.module.js';
|
||||||
import { EvidenceModule } from './evidence/evidence.module.js';
|
import { EvidenceModule } from './evidence/evidence.module.js';
|
||||||
|
import { SessionController } from './session/session.controller.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule, GoalsModule, EvidenceModule],
|
imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule, GoalsModule, EvidenceModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController, SessionController],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -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<string, string | string[] | undefined>) {
|
||||||
|
return ok(actorFromHeaders(headers));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { health } from '../../lib/api';
|
import { api, health } from '../../lib/api';
|
||||||
|
|
||||||
const PAGE_COPY: Record<string, { title: string; eyebrow: string }> = {
|
const PAGE_COPY: Record<string, { title: string; eyebrow: string }> = {
|
||||||
'/': { title: 'Operations overview', eyebrow: 'System posture' },
|
'/': { title: 'Operations overview', eyebrow: 'System posture' },
|
||||||
@@ -18,6 +18,7 @@ const PAGE_COPY: Record<string, { title: string; eyebrow: string }> = {
|
|||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { data } = useQuery({ queryKey: ['health'], queryFn: health });
|
const { data } = useQuery({ queryKey: ['health'], queryFn: health });
|
||||||
|
const { data: session } = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: 60_000 });
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const stale = data ? !data.ok : true;
|
const stale = data ? !data.ok : true;
|
||||||
const page = PAGE_COPY[pathname] ?? PAGE_COPY['/'];
|
const page = PAGE_COPY[pathname] ?? PAGE_COPY['/'];
|
||||||
@@ -36,6 +37,16 @@ export function Header() {
|
|||||||
<span className={`h-1.5 w-1.5 rounded-full ${stale ? 'bg-amber-500' : 'bg-emerald-500'}`} />
|
<span className={`h-1.5 w-1.5 rounded-full ${stale ? 'bg-amber-500' : 'bg-emerald-500'}`} />
|
||||||
{stale ? `STALE${data?.metrics_age_s != null ? ` · ${data.metrics_age_s}s` : ''}` : 'LIVE TRUST'}
|
{stale ? `STALE${data?.metrics_age_s != null ? ` · ${data.metrics_age_s}s` : ''}` : 'LIVE TRUST'}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="hidden min-w-0 rounded-xl border border-slate-200 bg-white px-3 py-1.5 sm:block">
|
||||||
|
<div className="max-w-40 truncate font-semibold text-slate-800">{session?.actor ?? 'Loading identity…'}</div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wide text-indigo-600">{session?.role ?? 'OIDC'}</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/oauth2/sign_out?rd=https%3A%2F%2Flocalhost%3A18443%2F"
|
||||||
|
className="rounded-xl border border-slate-300 bg-white px-3 py-2 font-semibold text-slate-700 transition-colors hover:border-indigo-300 hover:bg-indigo-50 hover:text-indigo-700"
|
||||||
|
>
|
||||||
|
Sign out / switch
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ function actorHeaders(actor: SettingsActor): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
session: () => get<SettingsActor>('session'),
|
||||||
overview: () => get<Overview>('overview'),
|
overview: () => get<Overview>('overview'),
|
||||||
runs: (limit = 50) => get<Freshness & { count: number; runs: HarnessRunRecord[] }>(`runs?limit=${limit}`),
|
runs: (limit = 50) => get<Freshness & { count: number; runs: HarnessRunRecord[] }>(`runs?limit=${limit}`),
|
||||||
traceGraph: (traceId: string) => get<HarnessTraceGraph>(`runs/${encodeURIComponent(traceId)}/graph`),
|
traceGraph: (traceId: string) => get<HarnessTraceGraph>(`runs/${encodeURIComponent(traceId)}/graph`),
|
||||||
|
|||||||
Reference in New Issue
Block a user