Skip to content

Implement native login UI in cdcf-website (replace Zitadel hosted v2 login) #189

@JohnRDOrazio

Description

@JohnRDOrazio

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.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

  1. 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.
  2. 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.
  3. 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.
  4. 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 APIPOST /v2/sessions, PATCH /v2/sessions/{sessionId}, DELETE /v2/sessions/{sessionId}. The session is the umbrella identity object the v2 login UI orchestrates.
  • User APIPOST /v2/users/human for registration; GET /v2/users for username/email lookup at the "what's your email?" step.
  • OIDC servicePOST /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.

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-login

Auth.js v5 wiring

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({
  async authorize() {
    // 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

  1. 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+.
  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.
  3. Phase 3 — Register + email verification.
  4. Phase 4 — Passkey + MFA.
  5. 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.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    authAuthentication / sign-in / sign-out flowenhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions