This commit is contained in:
lovebird 2026-04-08 19:24:10 +02:00
parent f8570411d1
commit 51eeb30a92
3 changed files with 565 additions and 8 deletions

View File

@ -59,6 +59,7 @@ let VariablePlayground: any;
let I18nPlayground: any;
let PlaygroundChat: any;
let PlaygroundVfs: any;
let PlaygroundAuth: any;
let GridSearch: any;
let LocationDetail: any;
let Tetris: any;
@ -84,6 +85,7 @@ if (enablePlaygrounds) {
I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground"));
PlaygroundChat = React.lazy(() => import("./pages/PlaygroundChat"));
PlaygroundVfs = React.lazy(() => import("./pages/PlaygroundVfs"));
PlaygroundAuth = React.lazy(() => import("./playground/auth"));
SupportChat = React.lazy(() => import("./pages/SupportChat"));
}
@ -212,6 +214,7 @@ const AppWrapper = () => {
<Route path="/playground/i18n" element={<React.Suspense fallback={<div>Loading...</div>}><I18nPlayground /></React.Suspense>} />
<Route path="/playground/chat" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundChat /></React.Suspense>} />
<Route path="/playground/vfs" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundVfs /></React.Suspense>} />
<Route path="/playground/auth" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundAuth /></React.Suspense>} />
</>
)}
@ -275,7 +278,8 @@ const App = () => {
authority: "https://auth.polymech.info",
client_id: "367440527605432321",
redirect_uri: window.location.origin + "/authz", // Where Zitadel sends the code back to
post_logout_redirect_uri: window.location.origin,
// Must appear in Zitadel app → Post Logout URIs (same allowlist pattern as redirect_uri; bare origin alone often fails).
post_logout_redirect_uri: window.location.origin + "/authz",
response_type: "code",
scope: "openid profile email",
loadUserInfo: true, // Specifically instruct the client to fetch /userinfo

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Shield, ArrowRight } from 'lucide-react';
import { Shield, LogOut } from 'lucide-react';
import { T, translate } from '@/i18n';
import { useAuth } from 'react-oidc-context';
import { useNavigate } from 'react-router-dom';
@ -10,6 +10,7 @@ const AuthZ = () => {
const auth = useAuth();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [signingOut, setSigningOut] = useState(false);
// Monitor auth state changes and log them for debugging
useEffect(() => {
@ -21,15 +22,11 @@ const AuthZ = () => {
});
if (auth.user) {
// `profile.email` is the same claim the API reads from the Bearer JWT (`getUserCached` → `user.email`).
console.log("🛡️ [AuthZ] Identity Token Profile:", auth.user.profile);
console.log("🛡️ [AuthZ] Access Token:", auth.user.access_token);
}
if (auth.isAuthenticated) {
console.log(`🛡️ [AuthZ] Successfully logged in as: ${auth.user?.profile.email || 'Unknown'}. Redirecting to /...`);
navigate('/');
}
}, [auth.isAuthenticated, auth.isLoading, auth.error, auth.user, navigate]);
}, [auth.isAuthenticated, auth.isLoading, auth.error, auth.user]);
const handleZitadelLogin = async () => {
try {
@ -41,6 +38,52 @@ const AuthZ = () => {
}
};
const handleLogout = async () => {
try {
setSigningOut(true);
await auth.signoutRedirect();
} catch (e) {
console.error(e);
setSigningOut(false);
}
};
if (auth.isAuthenticated && auth.user && !auth.isLoading) {
const email = auth.user.profile.email ?? auth.user.profile.preferred_username ?? '';
return (
<div className="min-h-screen bg-gradient-to-br from-background via-secondary/20 to-accent/20 flex items-center justify-center p-4">
<Card className="w-full max-w-md glass-morphism border-white/20">
<CardHeader className="text-center">
<div className="mx-auto bg-primary/10 w-12 h-12 rounded-full justify-center items-center flex mb-4">
<Shield className="w-6 h-6 text-primary" />
</div>
<CardTitle className="text-2xl font-bold bg-gradient-primary bg-clip-text text-transparent">
<T>Signed in</T>
</CardTitle>
<CardDescription className="break-all">
{email || <T>Session active</T>}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button className="w-full" size="lg" onClick={() => navigate('/')}>
<T>Continue to app</T>
</Button>
<Button
className="w-full"
variant="outline"
size="lg"
onClick={() => void handleLogout()}
disabled={signingOut}
>
<LogOut className="w-4 h-4 mr-2" />
{signingOut ? translate('Signing out…') : translate('Sign out')}
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-background via-secondary/20 to-accent/20 flex items-center justify-center p-4">
<Card className="w-full max-w-md glass-morphism border-white/20">

View File

@ -0,0 +1,510 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from 'react-oidc-context';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Shield, RefreshCw, ExternalLink, Copy, Check } from 'lucide-react';
type AuthzDebugResponse = {
verified: boolean;
sub?: string;
email?: string | null;
appAdmin?: boolean;
resolvedUserId?: string | null;
userSecrets?: unknown;
dbUsersNext?: { poolOk: boolean; database: string; profileCount: number };
error?: string;
hint?: string;
};
function looksLikeCompactJwt(t: string): boolean {
const p = t.split('.');
return p.length === 3 && p.every((x) => x.length > 0);
}
function decodeJwtPayload(t: string): Record<string, unknown> | null {
if (!looksLikeCompactJwt(t)) return null;
try {
const b64 = t.split('.')[1];
if (!b64) return null;
const json = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
}
function tokenFingerprint(t: string | undefined): {
present: boolean;
length: number;
dots: number;
kind: 'missing' | 'opaque' | 'jwt';
preview: string;
} {
if (!t) {
return { present: false, length: 0, dots: 0, kind: 'missing', preview: '—' };
}
const dots = (t.match(/\./g) ?? []).length;
const jwt = looksLikeCompactJwt(t);
const preview =
t.length <= 56 ? t : `${t.slice(0, 28)}${t.slice(-20)}`;
return {
present: true,
length: t.length,
dots,
kind: jwt ? 'jwt' : 'opaque',
preview,
};
}
function formatExp(exp: unknown): string {
if (typeof exp !== 'number') return '—';
const ms = exp * 1000;
const d = new Date(ms);
const left = ms - Date.now();
const rel =
left > 0
? `in ${Math.round(left / 60000)} min`
: `expired ${Math.round(-left / 60000)} min ago`;
return `${d.toISOString()} (${rel})`;
}
/** Zitadel often returns an opaque `access_token`; JWKS verification needs a JWT — prefer `id_token` when AT is opaque. */
function pickBearerJwtForApi(user: { access_token: string; id_token?: string } | undefined): {
token: string;
source: 'access_token' | 'id_token';
} | null {
if (!user?.access_token) return null;
if (looksLikeCompactJwt(user.access_token)) {
return { token: user.access_token, source: 'access_token' };
}
if (user.id_token && looksLikeCompactJwt(user.id_token)) {
return { token: user.id_token, source: 'id_token' };
}
return null;
}
function Row({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<div className="grid grid-cols-[8.5rem_1fr] gap-x-2 gap-y-1 text-xs items-start">
<span className="text-muted-foreground font-medium shrink-0">{label}</span>
<div className="min-w-0 break-all font-mono">{children}</div>
</div>
);
}
/**
* Dev playground: exercises the new Zitadel/JWKS path (`commons/zitadel.ts` + `DATABASE_URL_NEXT` pool).
* Calls like `GET /api/admin/authz/debug` and `GET /api/admin/authz/experiment/users` are for
* integration testing only not production policy.
*/
export default function PlaygroundAuth() {
const auth = useAuth();
const base = import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
const [loading, setLoading] = useState(false);
const [last, setLast] = useState<AuthzDebugResponse | null>(null);
const [fetchError, setFetchError] = useState<string | null>(null);
const [lastHttp, setLastHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null);
const [lastDbNext, setLastDbNext] = useState<AuthzDebugResponse | null>(null);
const [lastDbNextHttp, setLastDbNextHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null);
const [loadingDbNext, setLoadingDbNext] = useState(false);
const [fetchErrorDbNext, setFetchErrorDbNext] = useState<string | null>(null);
/** `/api/admin/authz/experiment/users` — admin-gated user list for new-auth experiments only */
const [loadingExpUsers, setLoadingExpUsers] = useState(false);
const [lastExpUsers, setLastExpUsers] = useState<unknown>(null);
const [lastExpUsersHttp, setLastExpUsersHttp] = useState<{ status: number; ok: boolean; url: string } | null>(
null,
);
const [fetchErrorExpUsers, setFetchErrorExpUsers] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const jwtPick = auth.user ? pickBearerJwtForApi(auth.user) : null;
const atFp = useMemo(
() => tokenFingerprint(auth.user?.access_token),
[auth.user?.access_token],
);
const idFp = useMemo(() => tokenFingerprint(auth.user?.id_token), [auth.user?.id_token]);
const atPayload = useMemo(
() => (auth.user?.access_token ? decodeJwtPayload(auth.user.access_token) : null),
[auth.user?.access_token],
);
const idPayload = useMemo(
() => (auth.user?.id_token ? decodeJwtPayload(auth.user.id_token) : null),
[auth.user?.id_token],
);
const pickedPayload = useMemo(
() => (jwtPick ? decodeJwtPayload(jwtPick.token) : null),
[jwtPick],
);
const oidcUser = auth.user as
| (typeof auth.user & { scope?: string; session_state?: string; expires_at?: number })
| undefined;
const runCheck = useCallback(async () => {
const picked = pickBearerJwtForApi(auth.user ?? undefined);
if (!picked) {
setLastHttp(null);
if (!auth.user?.access_token) {
setLast({ verified: false, error: 'missing_token', hint: 'Sign in via /authz first.' });
} else {
setLast({
verified: false,
error: 'opaque_or_non_jwt_token',
hint:
'Zitadel returned an opaque access_token and no JWT id_token. Ensure scope includes openid and the OIDC app returns id_token.',
});
}
setFetchError(null);
return;
}
setLoading(true);
setFetchError(null);
const url = `${base.replace(/\/$/, '')}/api/admin/authz/debug`;
try {
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: `Bearer ${picked.token}` },
});
setLastHttp({ status: res.status, ok: res.ok, url });
const body = (await res.json()) as AuthzDebugResponse;
setLast(body);
} catch (e) {
setLastHttp(null);
setFetchError(e instanceof Error ? e.message : String(e));
setLast(null);
} finally {
setLoading(false);
}
}, [auth.user, base]);
/** `GET /api/admin/authz/db-next` — same JWT + `authzDbNextPing()` via `pool` (`DATABASE_URL_NEXT`, `db-users-next.ts`). */
const runDbNextCheck = useCallback(async () => {
const picked = pickBearerJwtForApi(auth.user ?? undefined);
if (!picked) {
setLastDbNext({
verified: false,
error: 'opaque_or_non_jwt_token',
hint: 'Need a JWT id_token (or JWT access_token).',
});
setFetchErrorDbNext(null);
setLastDbNextHttp(null);
return;
}
setLoadingDbNext(true);
setFetchErrorDbNext(null);
const url = `${base.replace(/\/$/, '')}/api/admin/authz/db-next`;
try {
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: `Bearer ${picked.token}` },
});
setLastDbNextHttp({ status: res.status, ok: res.ok, url });
const body = (await res.json()) as AuthzDebugResponse;
setLastDbNext(body);
} catch (e) {
setLastDbNextHttp(null);
setFetchErrorDbNext(e instanceof Error ? e.message : String(e));
setLastDbNext(null);
} finally {
setLoadingDbNext(false);
}
}, [auth.user, base]);
/** Experiment route: Zitadel JWT + app admin, then list users (not production `/api/admin/users`). */
const runExperimentUsers = useCallback(async () => {
setFetchErrorExpUsers(null);
setLastExpUsers(null);
setLastExpUsersHttp(null);
if (!base) {
setFetchErrorExpUsers('VITE_SERVER_IMAGE_API_URL is not set');
return;
}
const picked = pickBearerJwtForApi(auth.user);
if (!picked) {
setFetchErrorExpUsers('No JWT-shaped token (use id_token when access_token is opaque)');
return;
}
setLoadingExpUsers(true);
try {
const url = `${base.replace(/\/$/, '')}/api/admin/authz/experiment/users`;
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: `Bearer ${picked.token}` },
});
setLastExpUsersHttp({ status: res.status, ok: res.ok, url });
const body = (await res.json()) as unknown;
setLastExpUsers(body);
if (!res.ok) {
setFetchErrorExpUsers(`HTTP ${res.status}`);
}
} catch (e) {
setLastExpUsersHttp(null);
setFetchErrorExpUsers(e instanceof Error ? e.message : String(e));
setLastExpUsers(null);
} finally {
setLoadingExpUsers(false);
}
}, [auth.user, base]);
const copyPickedJwt = useCallback(async () => {
if (!jwtPick?.token) return;
try {
await navigator.clipboard.writeText(jwtPick.token);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
}
}, [jwtPick?.token]);
return (
<div className="min-h-[60vh] flex items-center justify-center p-4">
<Card className="w-full max-w-3xl">
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<CardTitle>Auth / Zitadel debug</CardTitle>
</div>
<CardDescription>
Same flow as <code className="text-xs">pages/AuthZ.tsx</code>. The API verifies JWTs (JWKS); if Zitadel
gives an <strong>opaque</strong> access token, this page sends the OIDC <strong>id_token</strong> instead.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link to="/authz" className="inline-flex items-center gap-1">
Open AuthZ <ExternalLink className="h-3.5 w-3.5" />
</Link>
</Button>
<Button size="sm" onClick={() => void runCheck()} disabled={loading || !base}>
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />
{loading ? 'Checking…' : 'Authz debug'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void runDbNextCheck()}
disabled={loadingDbNext || !base}
>
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loadingDbNext ? 'animate-spin' : ''}`} />
{loadingDbNext ? 'Pool…' : 'db-users-next (pool)'}
</Button>
{/* New-auth experiment only — see server `authzExperimentUsersRoute` */}
<Button
variant="outline"
size="sm"
onClick={() => void runExperimentUsers()}
disabled={loadingExpUsers || !base}
>
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loadingExpUsers ? 'animate-spin' : ''}`} />
{loadingExpUsers ? 'Users…' : 'Experiment: all users'}
</Button>
{jwtPick && (
<Button variant="secondary" size="sm" type="button" onClick={() => void copyPickedJwt()}>
{copied ? <Check className="h-3.5 w-3.5 mr-1" /> : <Copy className="h-3.5 w-3.5 mr-1" />}
{copied ? 'Copied' : `Copy ${jwtPick.source} (curl)`}
</Button>
)}
</div>
<section className="space-y-2">
<h3 className="text-sm font-semibold">Environment</h3>
<Row label="API base">
{base || <span className="text-amber-600 dark:text-amber-400">VITE_SERVER_IMAGE_API_URL unset</span>}
</Row>
</section>
<Separator />
<section className="space-y-2">
<h3 className="text-sm font-semibold">OIDC client session</h3>
<Row label="isLoading">{String(auth.isLoading)}</Row>
<Row label="isAuthenticated">{String(auth.isAuthenticated)}</Row>
<Row label="activeNavigator">{auth.activeNavigator ?? '—'}</Row>
<Row label="error">{auth.error?.message ?? '—'}</Row>
<Row label="scope">{oidcUser?.scope ?? '—'}</Row>
<Row label="session_state">{oidcUser?.session_state ?? '—'}</Row>
<Row label="expires_at">
{oidcUser?.expires_at != null
? `${new Date(oidcUser.expires_at * 1000).toISOString()} (oidc-client session)`
: '—'}
</Row>
</section>
<Separator />
<section className="space-y-3">
<h3 className="text-sm font-semibold">Tokens (client storage)</h3>
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium">access_token</span>
<Badge variant={atFp.kind === 'jwt' ? 'default' : atFp.kind === 'opaque' ? 'secondary' : 'outline'}>
{atFp.kind}
</Badge>
<span className="text-xs text-muted-foreground">
len {atFp.length} · dots {atFp.dots}
</span>
</div>
<p className="text-[11px] font-mono text-muted-foreground break-all">{atFp.preview}</p>
{atPayload && (
<pre className="text-[11px] bg-background/80 rounded p-2 overflow-x-auto max-h-28">
{JSON.stringify(
{
iss: atPayload.iss,
sub: atPayload.sub,
aud: atPayload.aud,
exp: atPayload.exp,
exp_h: formatExp(atPayload.exp),
},
null,
0,
)}
</pre>
)}
</div>
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium">id_token</span>
<Badge variant={idFp.kind === 'jwt' ? 'default' : idFp.kind === 'opaque' ? 'secondary' : 'outline'}>
{idFp.kind}
</Badge>
<span className="text-xs text-muted-foreground">
len {idFp.length} · dots {idFp.dots}
</span>
</div>
<p className="text-[11px] font-mono text-muted-foreground break-all">{idFp.preview}</p>
{idPayload && (
<pre className="text-[11px] bg-background/80 rounded p-2 overflow-x-auto max-h-28">
{JSON.stringify(
{
iss: idPayload.iss,
sub: idPayload.sub,
aud: idPayload.aud,
exp: idPayload.exp,
exp_h: formatExp(idPayload.exp),
email: idPayload.email,
},
null,
0,
)}
</pre>
)}
</div>
<Row label="Bearer for API">
{jwtPick ? (
<span>
<Badge variant="outline">{jwtPick.source}</Badge>{' '}
<span className="text-muted-foreground">(JWKS verifies this JWT)</span>
</span>
) : (
<span className="text-amber-700 dark:text-amber-400">no JWT cannot call verify endpoint</span>
)}
</Row>
{pickedPayload && (
<pre className="text-[11px] bg-muted rounded-md p-2 overflow-x-auto max-h-32">
{JSON.stringify(pickedPayload, null, 2)}
</pre>
)}
</section>
<Separator />
<section className="space-y-2">
<h3 className="text-sm font-semibold">Profile (userinfo / id_token claims)</h3>
<pre className="text-[11px] bg-muted rounded-md p-3 overflow-x-auto max-h-40">
{auth.user?.profile
? JSON.stringify(auth.user.profile, null, 2)
: '—'}
</pre>
</section>
<Separator />
<section className="space-y-2">
<h3 className="text-sm font-semibold">Last HTTP</h3>
<Row label="request">{lastHttp?.url ?? '—'}</Row>
<Row label="status">
{lastHttp ? (
<>
{lastHttp.status} {lastHttp.ok ? 'OK' : '(not ok)'}
</>
) : (
'—'
)}
</Row>
</section>
{fetchError && (
<pre className="text-xs bg-destructive/10 text-destructive rounded-md p-3 overflow-x-auto">
{fetchError}
</pre>
)}
{last && (
<section className="space-y-2">
<h3 className="text-sm font-semibold">API response (/api/admin/authz/debug)</h3>
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-[360px]">
{JSON.stringify(last, null, 2)}
</pre>
</section>
)}
{fetchErrorDbNext && (
<pre className="text-xs bg-destructive/10 text-destructive rounded-md p-3 overflow-x-auto">
{fetchErrorDbNext}
</pre>
)}
{lastDbNext && (
<section className="space-y-2">
<h3 className="text-sm font-semibold">
API response (/api/admin/authz/db-next) <code className="text-xs">DATABASE_URL_NEXT</code> via{' '}
<code className="text-xs">db-users-next.ts</code>
</h3>
<Row label="request">{lastDbNextHttp?.url ?? '—'}</Row>
<Row label="status">
{lastDbNextHttp ? `${lastDbNextHttp.status} ${lastDbNextHttp.ok ? 'OK' : ''}` : '—'}
</Row>
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-[360px]">
{JSON.stringify(lastDbNext, null, 2)}
</pre>
</section>
)}
{fetchErrorExpUsers && (
<pre className="text-xs bg-destructive/10 text-destructive rounded-md p-3 overflow-x-auto">
{fetchErrorExpUsers}
</pre>
)}
{lastExpUsers != null && (
<section className="space-y-2">
<h3 className="text-sm font-semibold">
API response (/api/admin/authz/experiment/users) new-auth experiment only
</h3>
<Row label="request">{lastExpUsersHttp?.url ?? '—'}</Row>
<Row label="status">
{lastExpUsersHttp ? `${lastExpUsersHttp.status} ${lastExpUsersHttp.ok ? 'OK' : ''}` : '—'}
</Row>
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-[360px]">
{JSON.stringify(lastExpUsers, null, 2)}
</pre>
</section>
)}
</CardContent>
</Card>
</div>
);
}