From 51eeb30a92df87aa518b17b7a03dddf7b49ece9e Mon Sep 17 00:00:00 2001 From: Babayaga Date: Wed, 8 Apr 2026 19:24:10 +0200 Subject: [PATCH] supabase --- packages/ui/src/App.tsx | 6 +- packages/ui/src/pages/AuthZ.tsx | 57 +++- packages/ui/src/playground/auth.tsx | 510 ++++++++++++++++++++++++++++ 3 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/playground/auth.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 286f9325..d3c72914 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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 = () => { Loading...}>} /> Loading...}>} /> Loading...}>} /> + Loading...}>} /> )} @@ -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 diff --git a/packages/ui/src/pages/AuthZ.tsx b/packages/ui/src/pages/AuthZ.tsx index 83176fd3..9cb98f04 100644 --- a/packages/ui/src/pages/AuthZ.tsx +++ b/packages/ui/src/pages/AuthZ.tsx @@ -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 ( +
+ + +
+ +
+ + Signed in + + + {email || Session active} + +
+ + + + +
+
+ ); + } + return (
diff --git a/packages/ui/src/playground/auth.tsx b/packages/ui/src/playground/auth.tsx new file mode 100644 index 00000000..1900003e --- /dev/null +++ b/packages/ui/src/playground/auth.tsx @@ -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 | 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; + } 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 ( +
+ {label} +
{children}
+
+ ); +} + +/** + * 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(null); + const [fetchError, setFetchError] = useState(null); + const [lastHttp, setLastHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null); + const [lastDbNext, setLastDbNext] = useState(null); + const [lastDbNextHttp, setLastDbNextHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null); + const [loadingDbNext, setLoadingDbNext] = useState(false); + const [fetchErrorDbNext, setFetchErrorDbNext] = useState(null); + /** `/api/admin/authz/experiment/users` β€” admin-gated user list for new-auth experiments only */ + const [loadingExpUsers, setLoadingExpUsers] = useState(false); + const [lastExpUsers, setLastExpUsers] = useState(null); + const [lastExpUsersHttp, setLastExpUsersHttp] = useState<{ status: number; ok: boolean; url: string } | null>( + null, + ); + const [fetchErrorExpUsers, setFetchErrorExpUsers] = useState(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 ( +
+ + +
+ + Auth / Zitadel debug +
+ + Same flow as pages/AuthZ.tsx. The API verifies JWTs (JWKS); if Zitadel + gives an opaque access token, this page sends the OIDC id_token instead. + +
+ +
+ + + + {/* New-auth experiment only β€” see server `authzExperimentUsersRoute` */} + + {jwtPick && ( + + )} +
+ +
+

Environment

+ + {base || VITE_SERVER_IMAGE_API_URL unset} + +
+ + + +
+

OIDC client session

+ {String(auth.isLoading)} + {String(auth.isAuthenticated)} + {auth.activeNavigator ?? 'β€”'} + {auth.error?.message ?? 'β€”'} + {oidcUser?.scope ?? 'β€”'} + {oidcUser?.session_state ?? 'β€”'} + + {oidcUser?.expires_at != null + ? `${new Date(oidcUser.expires_at * 1000).toISOString()} (oidc-client session)` + : 'β€”'} + +
+ + + +
+

Tokens (client storage)

+
+
+ access_token + + {atFp.kind} + + + len {atFp.length} Β· dots {atFp.dots} + +
+

{atFp.preview}

+ {atPayload && ( +
+                  {JSON.stringify(
+                    {
+                      iss: atPayload.iss,
+                      sub: atPayload.sub,
+                      aud: atPayload.aud,
+                      exp: atPayload.exp,
+                      exp_h: formatExp(atPayload.exp),
+                    },
+                    null,
+                    0,
+                  )}
+                
+ )} +
+
+
+ id_token + + {idFp.kind} + + + len {idFp.length} Β· dots {idFp.dots} + +
+

{idFp.preview}

+ {idPayload && ( +
+                  {JSON.stringify(
+                    {
+                      iss: idPayload.iss,
+                      sub: idPayload.sub,
+                      aud: idPayload.aud,
+                      exp: idPayload.exp,
+                      exp_h: formatExp(idPayload.exp),
+                      email: idPayload.email,
+                    },
+                    null,
+                    0,
+                  )}
+                
+ )} +
+ + {jwtPick ? ( + + {jwtPick.source}{' '} + (JWKS verifies this JWT) + + ) : ( + no JWT β€” cannot call verify endpoint + )} + + {pickedPayload && ( +
+                {JSON.stringify(pickedPayload, null, 2)}
+              
+ )} +
+ + + +
+

Profile (userinfo / id_token claims)

+
+              {auth.user?.profile
+                ? JSON.stringify(auth.user.profile, null, 2)
+                : 'β€”'}
+            
+
+ + + +
+

Last HTTP

+ {lastHttp?.url ?? 'β€”'} + + {lastHttp ? ( + <> + {lastHttp.status} {lastHttp.ok ? 'OK' : '(not ok)'} + + ) : ( + 'β€”' + )} + +
+ + {fetchError && ( +
+              {fetchError}
+            
+ )} + + {last && ( +
+

API response (/api/admin/authz/debug)

+
+                {JSON.stringify(last, null, 2)}
+              
+
+ )} + + {fetchErrorDbNext && ( +
+              {fetchErrorDbNext}
+            
+ )} + + {lastDbNext && ( +
+

+ API response (/api/admin/authz/db-next) β€” DATABASE_URL_NEXT via{' '} + db-users-next.ts +

+ {lastDbNextHttp?.url ?? 'β€”'} + + {lastDbNextHttp ? `${lastDbNextHttp.status} ${lastDbNextHttp.ok ? 'OK' : ''}` : 'β€”'} + +
+                {JSON.stringify(lastDbNext, null, 2)}
+              
+
+ )} + + {fetchErrorExpUsers && ( +
+              {fetchErrorExpUsers}
+            
+ )} + + {lastExpUsers != null && ( +
+

+ API response (/api/admin/authz/experiment/users) β€” new-auth experiment only +

+ {lastExpUsersHttp?.url ?? 'β€”'} + + {lastExpUsersHttp ? `${lastExpUsersHttp.status} ${lastExpUsersHttp.ok ? 'OK' : ''}` : 'β€”'} + +
+                {JSON.stringify(lastExpUsers, null, 2)}
+              
+
+ )} +
+
+
+ ); +}