You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implement the login / register / sign-out flow natively inside cdcf-website, calling Zitadel's HTTP APIs directly from our own Next.js routes & components — instead of redirecting the user to Zitadel's hosted v2 login UI at auth.catholicdigitalcommons.org/ui/v2/login/*.
End-state: a user clicking "Sign in" on catholicdigitalcommons.org stays on the same origin from start to finish. The browser never leaves the consumer domain; Zitadel becomes an authorization backend reached over HTTPS APIs, not a redirected-to identity UI.
Why now
The cross-origin RSC prefetch story is unfixable upstream-side. The hosted v2 login UI is a Next.js client that optimistically tries to client-side-transition into the OIDC redirect_uri. In our umbrella architecture, every property's redirect_uri is on a different origin from auth.catholicdigitalcommons.org, so the prefetch is structurally cross-origin and always fails. Even after merging cdcf-infra PR feat: add generic rest-get and rest-post CLI commands #15 to widen the login UI's CSP connect-src, the next layer (CORS on the callback) still blocks the prefetch. Login works (the React client falls back to a regular navigation), but every login attempt produces a wall of Failed to fetch RSC payload … Falling back to browser navigation console errors that look broken and mask real regressions.
The hosted login UI never matched our intent anyway. The original umbrella plan (memory feedback_login_ui_per_property.md) was: each property implements its own login UI calling Zitadel APIs; do not deploy zitadel-login image. We landed on the hosted image as a stopgap. Time to do it properly.
UX consistency — sign-in lives in the same visual language as the rest of cdcf-website (CDCF navy, locale-aware copy, i18n via next-intl, accessible focus styles, dark-mode if/when we add it). No more hard cut to a Zitadel-branded page mid-flow.
Drops the hosted login as a deployable. Once every umbrella property has its own login, we can remove the zitadel-login container from cdcf-infra/auth/docker-compose.prod.yml, the /ui/v2/login location block from cdcf-infra/auth/nginx/zitadel.conf, and several Zitadel env vars (ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_*, ZITADEL_OIDC_DEFAULTLOGINURLV2, etc.). Smaller infra surface.
Approach
Replicate what the zitadel-login Next.js app does today, but in-tree. Auth.js v5 stays as the session-cookie layer; the Zitadel provider is what we replace with our own UI-driven flow against Zitadel's HTTP APIs.
Zitadel APIs we'll need
(Confirm exact path versions on v4.15.0 against https://zitadel.com/docs/apis/openapi.)
Session API — POST /v2/sessions, PATCH /v2/sessions/{sessionId}, DELETE /v2/sessions/{sessionId}. The session is the umbrella identity object the v2 login UI orchestrates.
User API — POST /v2/users/human for registration; GET /v2/users for username/email lookup at the "what's your email?" step.
OIDC service — POST /v2/oidc/auth_requests/{authRequestId}/sessions to bind a session to an in-flight OIDC auth request; that's how the v2 login UI completes the auth code flow on our behalf.
Auth methods — password, passkey (WebAuthN, both registration and verification), TOTP/OTP, IDP (Google/Microsoft if/when we add them).
Self-service — password reset, email verification (POST /v2/users/{userId}/email/_resend_code, POST /v2/users/{userId}/email/_verify).
Authentication to these APIs uses the login-client machine user PAT (login-client.pat, already provisioned per cdcf-infra/auth/docker-compose.prod.yml's ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_* block). We'd consume the same PAT pattern that zitadel-login consumes today.
Keep Auth.js v5 for the session cookie layer, but switch from the Zitadel provider (which assumes the hosted UI) to a Credentials provider that finalizes against Zitadel:
Credentials({asyncauthorize(){// After our pages complete the Zitadel session flow, finalize// the OIDC auth request and exchange the resulting code →// tokens via the OIDC token endpoint. Return the user shape// Auth.js expects; the rest of the cookie / refresh-token// machinery is unchanged from PR #177.},})
lib/auth.ts's refreshAccessToken, JWT/Session type augmentations, prompt=select_account analogue (no-op now since we own the chooser), and the RP-initiated logout route (/api/auth/zitadel-signout) all carry over. The Org-scoping (PR #177's AUTH_ZITADEL_ORG_ID) becomes a server-side filter on the user-lookup step instead of an OIDC scope on a redirect.
What stays
WP bearer validator in wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php — unchanged. It validates the access token by aud, sub, and userinfo round-trip; how the access token was minted on the cdcf-website side doesn't matter to WP.
The OIDC client registration in Zitadel (CDCF Website prod + non-prod apps) — unchanged. The redirect_uri is now an internal Next.js route rather than /api/auth/callback/zitadel, but the registration shape is the same.
The post-logout flow (RP-initiated logout via /oidc/v1/end_session) — unchanged in shape; we still call /oidc/v1/end_session from /api/auth/zitadel-signout, just from an in-process Auth.js sign-out rather than from a redirect originating at the hosted login.
Out of scope
Replacing auth.catholicdigitalcommons.org itself. Zitadel keeps running there for OIDC discovery, JWKS, /oidc/v1/userinfo, /oidc/v1/end_session, and /management/v1/* — exactly as it does today. We only remove the /ui/v2/login* path (the hosted login UI).
Cross-property session sharing. Each property's native login establishes its own cookie; users sign in once per property until / unless we add OIDC SSO between them as a future scope.
Risks & guardrails
Auth UX is sensitive. Bugs are visible immediately to every user and have security implications. Roll out via the staging env first; gate on a feature flag so we can revert by toggling without redeploying.
Passkey / WebAuthN is non-trivial. Plan to do password + email-verification first, then layer passkey / MFA in follow-up PRs.
Email deliverability. Today Zitadel's hosted UI is the bottleneck for "verify your email" links. After this PR, we own the email-verification UX too. Verify the Zitadel SMTP config still delivers correctly when invoked from API call (vs. from the hosted UI).
Loss of upstream UX improvements. Vercel-style fixes to the hosted login (better passkey browser support, locale strings, accessibility wins) won't reach us. Mitigated by Zitadel still owning the API contract; we just own presentation.
Suggested phases
Phase 1 — Discovery & spike: enumerate the exact API calls the v2 login UI makes for a happy-path password sign-in, sign-up, and sign-out (record via DevTools Network on staging). Document them here for Phases 2+.
Phase 2 — Read-only login routes: /auth/login with email + password against the Session API, no register / no MFA / no passkey. Behind a feature flag (AUTH_NATIVE_LOGIN_ENABLED=true) on staging. Existing hosted login still served at /ui/v2/login/* until we cut over.
Phase 3 — Register + email verification.
Phase 4 — Passkey + MFA.
Phase 5 — Cut-over: flip the feature flag on prod, observe for a release, remove the Zitadel({ … }) provider call from lib/auth.ts, remove cdcf-infra's zitadel-login container + /ui/v2/login nginx block + the related CSP override (PR feat: add generic rest-get and rest-post CLI commands #15), close out hosted-UI handoff doc lines.
Goal
Implement the login / register / sign-out flow natively inside cdcf-website, calling Zitadel's HTTP APIs directly from our own Next.js routes & components — instead of redirecting the user to Zitadel's hosted v2 login UI at
auth.catholicdigitalcommons.org/ui/v2/login/*.End-state: a user clicking "Sign in" on
catholicdigitalcommons.orgstays on the same origin from start to finish. The browser never leaves the consumer domain; Zitadel becomes an authorization backend reached over HTTPS APIs, not a redirected-to identity UI.Why now
auth.catholicdigitalcommons.org, so the prefetch is structurally cross-origin and always fails. Even after merging cdcf-infra PR feat: add generic rest-get and rest-post CLI commands #15 to widen the login UI's CSPconnect-src, the next layer (CORS on the callback) still blocks the prefetch. Login works (the React client falls back to a regular navigation), but every login attempt produces a wall ofFailed to fetch RSC payload … Falling back to browser navigationconsole errors that look broken and mask real regressions.feedback_login_ui_per_property.md) was: each property implements its own login UI calling Zitadel APIs; do not deploy zitadel-login image. We landed on the hosted image as a stopgap. Time to do it properly.zitadel-logincontainer fromcdcf-infra/auth/docker-compose.prod.yml, the/ui/v2/loginlocation block fromcdcf-infra/auth/nginx/zitadel.conf, and several Zitadel env vars (ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_*,ZITADEL_OIDC_DEFAULTLOGINURLV2, etc.). Smaller infra surface.Approach
Replicate what the
zitadel-loginNext.js app does today, but in-tree. Auth.js v5 stays as the session-cookie layer; the Zitadel provider is what we replace with our own UI-driven flow against Zitadel's HTTP APIs.Zitadel APIs we'll need
(Confirm exact path versions on v4.15.0 against
https://zitadel.com/docs/apis/openapi.)POST /v2/sessions,PATCH /v2/sessions/{sessionId},DELETE /v2/sessions/{sessionId}. The session is the umbrella identity object the v2 login UI orchestrates.POST /v2/users/humanfor registration;GET /v2/usersfor username/email lookup at the "what's your email?" step.POST /v2/oidc/auth_requests/{authRequestId}/sessionsto bind a session to an in-flight OIDC auth request; that's how the v2 login UI completes the auth code flow on our behalf.POST /v2/users/{userId}/email/_resend_code,POST /v2/users/{userId}/email/_verify).Authentication to these APIs uses the login-client machine user PAT (
login-client.pat, already provisioned percdcf-infra/auth/docker-compose.prod.yml'sZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_*block). We'd consume the same PAT pattern thatzitadel-loginconsumes today.Pages to add (next-intl routed)
/[lang]/auth/login— email → password|passkey → MFA flow/[lang]/auth/register— sign-up form, email-verified gate (same Zitadel policy as today)/[lang]/auth/verify-email— landing page for the verification email link/[lang]/auth/reset-password— request + confirm flow/[lang]/auth/mfa-setup— passkey / OTP enrollment, post-first-loginAuth.js v5 wiring
Keep Auth.js v5 for the session cookie layer, but switch from the
Zitadelprovider (which assumes the hosted UI) to a Credentials provider that finalizes against Zitadel:lib/auth.ts'srefreshAccessToken, JWT/Session type augmentations,prompt=select_accountanalogue (no-op now since we own the chooser), and the RP-initiated logout route (/api/auth/zitadel-signout) all carry over. The Org-scoping (PR #177'sAUTH_ZITADEL_ORG_ID) becomes a server-side filter on the user-lookup step instead of an OIDC scope on a redirect.What stays
wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php— unchanged. It validates the access token by aud, sub, and userinfo round-trip; how the access token was minted on the cdcf-website side doesn't matter to WP.redirect_uriis now an internal Next.js route rather than/api/auth/callback/zitadel, but the registration shape is the same./oidc/v1/end_session) — unchanged in shape; we still call/oidc/v1/end_sessionfrom/api/auth/zitadel-signout, just from an in-process Auth.js sign-out rather than from a redirect originating at the hosted login.Out of scope
auth.catholicdigitalcommons.orgitself. Zitadel keeps running there for OIDC discovery, JWKS,/oidc/v1/userinfo,/oidc/v1/end_session, and/management/v1/*— exactly as it does today. We only remove the/ui/v2/login*path (the hosted login UI).Risks & guardrails
Suggested phases
/auth/loginwith email + password against the Session API, no register / no MFA / no passkey. Behind a feature flag (AUTH_NATIVE_LOGIN_ENABLED=true) on staging. Existing hosted login still served at/ui/v2/login/*until we cut over.Zitadel({ … })provider call fromlib/auth.ts, remove cdcf-infra'szitadel-logincontainer +/ui/v2/loginnginx block + the related CSP override (PR feat: add generic rest-get and rest-post CLI commands #15), close out hosted-UI handoff doc lines.Related
connect-srcwidening on/ui/v2/login. Becomes obsolete on cut-over but worth keeping until then to silence console noise.feedback_login_ui_per_property.md— original architectural intent.project_zitadel_umbrella_architecture.md— umbrella shape.