Skip to content

Custom login UI to replace Zitadel Login V2 container #3

Description

@JohnRDOrazio

Context

The Zitadel Login V2 container runs as a separate Next.js app on a different origin (localhost:8080). It uses next-themes with localStorage (per-origin), so it cannot read the OntoKit frontend's theme preference. Setting THEME_MODE_AUTO only follows the OS prefers-color-scheme, not the app's manual toggle.

Solution: Build custom login pages inside the OntoKit frontend (ontokit-web) that use the Zitadel Session API v2 for authentication. These pages inherit the app's dark/light theme automatically since they're part of the same Next.js app. The Login V2 container is then removed.

Architecture

User clicks "Sign in"
  → NextAuth redirects to Zitadel /oauth/v2/authorize
  → Zitadel creates authRequest, redirects to our custom login:
    http://localhost:3000/auth/login?authRequest=<id>
  → Custom pages authenticate via Zitadel Session API v2 (Server Actions)
  → Finalize: POST /v2/oidc/authorize → callbackUrl with auth code
  → Redirect to NextAuth callback → session created

All Zitadel API calls are made server-side via Next.js Server Actions using the login-client PAT. The PAT never reaches the browser.

Login state (sessionId, sessionToken, authRequestId) is stored in an encrypted HTTP-only cookie between steps (username → password → MFA), using AUTH_SECRET for encryption.

Files to Create

Server-side auth logic

  • lib/auth/zitadel-session.ts — Server Actions for Zitadel Session API v2:
    • createSession(loginName)POST /v2/sessions
    • verifyPassword(sessionId, token, password)PATCH /v2/sessions/{id}
    • verifyTOTP(sessionId, token, code)PATCH /v2/sessions/{id}
    • verifyPasskey(sessionId, token, credentialData)PATCH /v2/sessions/{id}
    • finalizeAuthRequest(authRequestId, sessionId, token)POST /v2/oidc/authorize
    • listAuthMethods(userId)GET /v2/users/{id}/authentication_methods
    • requestPasswordReset(loginName)POST /v2/users/password_reset
    • setNewPassword(userId, code, password)POST /v2/users/{id}/password
    • registerUser(profile)POST /v2/users/human
  • lib/auth/login-session.ts — Encrypted cookie helpers:
    • setLoginSession(data) — encrypt + set ontokit-login-session cookie (10 min TTL, Path=/auth, HttpOnly, SameSite=Lax)
    • getLoginSession() — read + decrypt cookie
    • clearLoginSession() — delete cookie
    • Uses AUTH_SECRET env var for AES encryption

Shared components (reuse existing Button from components/ui/button.tsx)

  • components/auth/AuthCard.tsx — Centered card with OntoKit logo, inherits dark mode via Tailwind dark: classes
  • components/auth/AuthInput.tsx — Styled text/password input with label, error state
  • components/auth/BackLink.tsx — Back navigation between login steps

Login pages (app/auth/login/)

  • layout.tsx — Shared centered layout wrapping AuthCard
  • page.tsx — Username/email entry (reads ?authRequest= param, calls createSession, sets cookie, redirects to /auth/login/password)
  • password/page.tsx — Password entry (reads cookie, calls verifyPassword, then checks if MFA required → redirect to /auth/login/mfa or finalize → redirect to callback)
  • password-reset/page.tsx — Enter email to request reset
  • password-reset/set/page.tsx — Enter new password (reads ?userId= and ?code= from email link)

MFA pages (app/auth/login/mfa/)

  • page.tsx — MFA method selection (lists available methods via listAuthMethods)
  • totp/page.tsx — 6-digit TOTP code entry
  • passkey/page.tsx — WebAuthn challenge (client-side navigator.credentials.get() + Server Action)

Registration pages (app/auth/register/)

  • page.tsx — Registration form (first name, last name, email)
  • password/page.tsx — Set password during registration
  • verify/page.tsx — "Check your email" confirmation screen

Logout

  • app/auth/logout/page.tsx — Logout confirmation (reads ?post_logout_redirect=, clears sessions, shows "signed out" with redirect link)

Files to Modify

  • ontokit-api/compose.yaml — Change Zitadel login URLs to point to our frontend:

    ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:3000/auth/login
    ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:3000/auth/login?authRequest=
    ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:3000/auth/logout?post_logout_redirect=

    Comment out the login: service.

  • ontokit-api/scripts/setup-zitadel.sh — Extract login-client PAT from Docker volume and write ZITADEL_LOGIN_PAT to ontokit-web/.env.local.

  • ontokit-web/.env.local — Add ZITADEL_LOGIN_PAT=<value>.

  • ontokit-web/app/auth/signin/page.tsx — Already exists and works fine (calls signIn("zitadel")). No changes needed — it triggers the OIDC redirect which now lands on our custom login pages.

Phased Implementation

Phase 1: Core Login (username + password + finalization)

Create the server actions, encrypted cookie helpers, shared components, login layout, username page, and password page. Update compose.yaml and setup script. This makes the basic login flow work end-to-end.

Phase 2: Password Reset + Logout

Add password-reset pages, logout page, and corresponding server actions.

Phase 3: MFA (TOTP + Passkey)

Add MFA selection, TOTP entry, and passkey/WebAuthn pages.

Phase 4: Registration + Email Verification

Add registration form, password setup, and verification pending pages.

Verification

After each phase:

  1. Restart Zitadel container: docker compose up -d --force-recreate zitadel
  2. Re-run setup script: ./scripts/setup-zitadel.sh --update-env
  3. Restart frontend: ./ontokit-web.sh restart
  4. Phase 1: Click "Sign in" → enter username → enter password → verify redirect back to app with active session
  5. Phase 2: Click "Forgot password" → enter email → check Mailpit for reset email → click link → set new password. Test logout flow.
  6. Phase 3: Enable MFA on test account via Zitadel console → login → verify MFA prompt appears → enter code → verify redirect
  7. Phase 4: Click "Register" → fill form → check Mailpit for verification email → verify → login
  8. Toggle dark mode before signing in → verify all auth pages render in dark mode

Metadata

Metadata

Assignees

No one assigned

    Labels

    UXUser experience improvementsenhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions