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:
- Restart Zitadel container:
docker compose up -d --force-recreate zitadel
- Re-run setup script:
./scripts/setup-zitadel.sh --update-env
- Restart frontend:
./ontokit-web.sh restart
- Phase 1: Click "Sign in" → enter username → enter password → verify redirect back to app with active session
- Phase 2: Click "Forgot password" → enter email → check Mailpit for reset email → click link → set new password. Test logout flow.
- Phase 3: Enable MFA on test account via Zitadel console → login → verify MFA prompt appears → enter code → verify redirect
- Phase 4: Click "Register" → fill form → check Mailpit for verification email → verify → login
- Toggle dark mode before signing in → verify all auth pages render in dark mode
Context
The Zitadel Login V2 container runs as a separate Next.js app on a different origin (
localhost:8080). It usesnext-themeswithlocalStorage(per-origin), so it cannot read the OntoKit frontend's theme preference. SettingTHEME_MODE_AUTOonly follows the OSprefers-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
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_SECRETfor encryption.Files to Create
Server-side auth logic
lib/auth/zitadel-session.ts— Server Actions for Zitadel Session API v2:createSession(loginName)→POST /v2/sessionsverifyPassword(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/authorizelistAuthMethods(userId)→GET /v2/users/{id}/authentication_methodsrequestPasswordReset(loginName)→POST /v2/users/password_resetsetNewPassword(userId, code, password)→POST /v2/users/{id}/passwordregisterUser(profile)→POST /v2/users/humanlib/auth/login-session.ts— Encrypted cookie helpers:setLoginSession(data)— encrypt + setontokit-login-sessioncookie (10 min TTL,Path=/auth,HttpOnly,SameSite=Lax)getLoginSession()— read + decrypt cookieclearLoginSession()— delete cookieAUTH_SECRETenv var for AES encryptionShared components (reuse existing
Buttonfromcomponents/ui/button.tsx)components/auth/AuthCard.tsx— Centered card with OntoKit logo, inherits dark mode via Tailwinddark:classescomponents/auth/AuthInput.tsx— Styled text/password input with label, error statecomponents/auth/BackLink.tsx— Back navigation between login stepsLogin pages (
app/auth/login/)layout.tsx— Shared centered layout wrapping AuthCardpage.tsx— Username/email entry (reads?authRequest=param, callscreateSession, sets cookie, redirects to/auth/login/password)password/page.tsx— Password entry (reads cookie, callsverifyPassword, then checks if MFA required → redirect to/auth/login/mfaor finalize → redirect to callback)password-reset/page.tsx— Enter email to request resetpassword-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 vialistAuthMethods)totp/page.tsx— 6-digit TOTP code entrypasskey/page.tsx— WebAuthn challenge (client-sidenavigator.credentials.get()+ Server Action)Registration pages (
app/auth/register/)page.tsx— Registration form (first name, last name, email)password/page.tsx— Set password during registrationverify/page.tsx— "Check your email" confirmation screenLogout
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:Comment out the
login:service.ontokit-api/scripts/setup-zitadel.sh— Extract login-client PAT from Docker volume and writeZITADEL_LOGIN_PATtoontokit-web/.env.local.ontokit-web/.env.local— AddZITADEL_LOGIN_PAT=<value>.ontokit-web/app/auth/signin/page.tsx— Already exists and works fine (callssignIn("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:
docker compose up -d --force-recreate zitadel./scripts/setup-zitadel.sh --update-env./ontokit-web.sh restart