Skip to content

feat: custom login UI Phase 1 — username + password flow#38

Open
JohnRDOrazio wants to merge 1 commit intodevfrom
feature/custom-login-ui
Open

feat: custom login UI Phase 1 — username + password flow#38
JohnRDOrazio wants to merge 1 commit intodevfrom
feature/custom-login-ui

Conversation

@JohnRDOrazio
Copy link
Copy Markdown
Member

Summary

Implements Phase 1 of the custom login UI (#3) to replace the Zitadel Login V2 container. Login pages now live inside the OntoKit frontend, inheriting the app's dark/light theme automatically.

  • Server Actions (lib/auth/zitadel-session.ts): createSession, verifyPassword, listAuthMethods, finalizeAuthRequest — all calls made server-side using the login-client PAT
  • Encrypted cookie (lib/auth/login-session.ts): JWE (A256GCM) encrypted HTTP-only cookie stores login state between steps (10 min TTL)
  • Shared components: AuthCard, AuthInput, BackLink — reusable across all auth pages
  • Login pages: Username entry (/auth/login?authRequest=<id>) and password verification (/auth/login/password)
  • MFA detection: After password verification, checks for second-factor requirements and redirects to /auth/login/mfa (Phase 3)
  • Adds ZITADEL_LOGIN_PAT env var to schema and .env.example

Architecture

User clicks "Sign in"
  → NextAuth redirects to Zitadel /oauth/v2/authorize
  → Zitadel redirects to /auth/login?authRequest=<id>
  → Username page: createSession → encrypted cookie → /auth/login/password
  → Password page: verifyPassword → finalizeAuthRequest → callback URL
  → NextAuth callback completes OIDC flow → session created

Remaining phases (future PRs)

  • Phase 2: Password reset + logout pages
  • Phase 3: MFA (TOTP + Passkey)
  • Phase 4: Registration + email verification

Test plan

  • Update compose.yaml to point Zitadel login URLs to http://localhost:3000/auth/login
  • Set ZITADEL_LOGIN_PAT in .env.local
  • Click "Sign in" → verify redirect to custom username page
  • Enter valid username → verify redirect to password page
  • Enter valid password → verify redirect back to app with session
  • Enter invalid username → verify "User not found" error
  • Enter wrong password → verify "Incorrect password" error
  • Verify dark mode renders correctly on login pages
  • Verify the login session cookie expires after 10 minutes

Closes #3

🤖 Generated with Claude Code

Implement Phase 1 of the custom login UI to replace the Zitadel Login V2
container. This allows login pages to inherit the app's dark/light theme.

Server-side auth logic:
- lib/auth/zitadel-session.ts: Server Actions for Zitadel Session API v2
  (createSession, verifyPassword, listAuthMethods, finalizeAuthRequest)
- lib/auth/login-session.ts: Encrypted cookie helpers using JWE (A256GCM)
  with AUTH_SECRET for login state between steps

Shared components:
- components/auth/AuthCard.tsx: Centered card with OntoKit branding
- components/auth/AuthInput.tsx: Styled input with label and error state
- components/auth/BackLink.tsx: Back navigation between login steps

Login pages:
- app/auth/login/layout.tsx: Shared centered layout
- app/auth/login/page.tsx: Username/email entry (reads ?authRequest= param)
- app/auth/login/password/page.tsx: Password verification with MFA check

Also adds ZITADEL_LOGIN_PAT to env schema and .env.example.

Closes #3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 1, 2026

Warning

Rate limit exceeded

@JohnRDOrazio has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 9 minutes and 49 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 9 minutes and 49 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8e5ce418-8060-45ed-8190-c81367e0d04e

📥 Commits

Reviewing files that changed from the base of the PR and between a68dfb3 and 85e9976.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (11)
  • .env.example
  • app/auth/login/layout.tsx
  • app/auth/login/page.tsx
  • app/auth/login/password/page.tsx
  • components/auth/AuthCard.tsx
  • components/auth/AuthInput.tsx
  • components/auth/BackLink.tsx
  • lib/auth/login-session.ts
  • lib/auth/zitadel-session.ts
  • lib/env.ts
  • package.json
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/custom-login-ui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment on lines +111 to +117
const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
method: "GET",
headers: {
...zitadelHeaders(),
"x-zitadel-session-token": sessionToken,
},
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI about 1 month ago

In general, to mitigate SSRF when constructing URLs from user input, you (a) ensure that the origin (scheme + host + port) is not user-controlled, and (b) strictly validate or normalize any user-controlled path segments so they cannot inject extra slashes, .., or other unexpected characters. Using an allow‑list of acceptable patterns is preferable to trying to sanitize arbitrary strings.

For this specific case, we should validate sessionId before using it in the URL path. Zitadel session IDs are opaque strings but are typically safe to treat as URL-safe tokens. A conservative fix is to restrict sessionId to a safe character set (e.g., alphanumerics, -, _, ., ~) and optionally a reasonable length. If the value does not match, we throw an error instead of issuing the HTTP request. This keeps existing functionality for valid IDs but blocks malformed or attacker-crafted paths.

Concretely, in lib/auth/zitadel-session.ts:

  1. Add a small helper function, e.g., validateSessionId, near the other helpers in this file. It checks sessionId against a regular expression like /^[A-Za-z0-9._~-]{1,256}$/ and throws an error if invalid.
  2. Call validateSessionId(sessionId) at the start of getSession and at the start of verifyPassword (or at least once early in the flow before any network request uses it). This ensures all uses of sessionId are constrained, including the internal call from verifyPassword to getSession.
  3. No external dependencies are required; TypeScript/JavaScript regexes suffice.

This approach does not change successful behavior for legitimate session IDs, but it prevents malformed values from reaching fetch and addresses the CodeQL SSRF concern.

Suggested changeset 1
lib/auth/zitadel-session.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/lib/auth/zitadel-session.ts b/lib/auth/zitadel-session.ts
--- a/lib/auth/zitadel-session.ts
+++ b/lib/auth/zitadel-session.ts
@@ -3,6 +3,17 @@
 const ZITADEL_ISSUER = process.env.ZITADEL_ISSUER || "http://localhost:8080";
 const ZITADEL_LOGIN_PAT = process.env.ZITADEL_LOGIN_PAT || "";
 
+/**
+ * Validate session IDs before using them in URLs to avoid SSRF-style issues.
+ * Allows URL-safe characters only and limits length to a reasonable size.
+ */
+function validateSessionId(sessionId: string): void {
+  const SESSION_ID_REGEX = /^[A-Za-z0-9._~-]{1,256}$/;
+  if (!SESSION_ID_REGEX.test(sessionId)) {
+    throw new Error("Invalid session ID format");
+  }
+}
+
 interface ZitadelSessionResponse {
   sessionId: string;
   sessionToken: string;
@@ -108,6 +119,7 @@
  * Get session details.
  */
 async function getSession(sessionId: string, sessionToken: string): Promise<ZitadelSession> {
+  validateSessionId(sessionId);
   const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
     method: "GET",
     headers: {
@@ -136,6 +148,7 @@
   sessionToken: string;
   factors: ZitadelSession["session"]["factors"];
 }> {
+  validateSessionId(sessionId);
   const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
     method: "PATCH",
     headers: zitadelHeaders(),
EOF
@@ -3,6 +3,17 @@
const ZITADEL_ISSUER = process.env.ZITADEL_ISSUER || "http://localhost:8080";
const ZITADEL_LOGIN_PAT = process.env.ZITADEL_LOGIN_PAT || "";

/**
* Validate session IDs before using them in URLs to avoid SSRF-style issues.
* Allows URL-safe characters only and limits length to a reasonable size.
*/
function validateSessionId(sessionId: string): void {
const SESSION_ID_REGEX = /^[A-Za-z0-9._~-]{1,256}$/;
if (!SESSION_ID_REGEX.test(sessionId)) {
throw new Error("Invalid session ID format");
}
}

interface ZitadelSessionResponse {
sessionId: string;
sessionToken: string;
@@ -108,6 +119,7 @@
* Get session details.
*/
async function getSession(sessionId: string, sessionToken: string): Promise<ZitadelSession> {
validateSessionId(sessionId);
const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
method: "GET",
headers: {
@@ -136,6 +148,7 @@
sessionToken: string;
factors: ZitadelSession["session"]["factors"];
}> {
validateSessionId(sessionId);
const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
method: "PATCH",
headers: zitadelHeaders(),
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +139 to +150
const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
method: "PATCH",
headers: zitadelHeaders(),
body: JSON.stringify({
sessionToken,
checks: {
password: {
password,
},
},
}),
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI about 1 month ago

To fix this, we should constrain the sessionId parameter before using it in the request URL. Since the hostname is already fixed, the main remaining risk is path manipulation or unexpected characters in sessionId. A robust mitigation is to validate sessionId against a strict pattern that matches the format used by Zitadel session IDs (or, if that format is not known, a conservative alphanumeric/UUID-like pattern), and reject or error out if the value does not conform. This ensures that only well-formed identifiers are used in the path, preventing an attacker from inserting /, \, ?, #, or .. sequences.

The best way to fix the problem without changing existing functionality is:

  • Introduce a small helper function, e.g. validateSessionId(sessionId: string): void, near the top of lib/auth/zitadel-session.ts.
  • This function should check sessionId against a whitelist regex allowing only expected characters (for example, [A-Za-z0-9_-]+), and throw an error if it fails.
  • Call validateSessionId(sessionId); at the start of any function that ultimately uses sessionId in a request URL, specifically verifyPassword (and optionally also getSession for consistency).
  • The rest of the logic (fetch, response handling) remains unchanged, so existing, valid IDs continue to work.

No external libraries are necessary; a simple regex-based validation is sufficient. All changes are confined to lib/auth/zitadel-session.ts inside the shown regions.

Suggested changeset 1
lib/auth/zitadel-session.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/lib/auth/zitadel-session.ts b/lib/auth/zitadel-session.ts
--- a/lib/auth/zitadel-session.ts
+++ b/lib/auth/zitadel-session.ts
@@ -60,6 +60,15 @@
   };
 }
 
+function validateSessionId(sessionId: string): void {
+  // Allow only conservative, expected characters in session IDs.
+  // Adjust this pattern if Zitadel uses a more specific format.
+  const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
+  if (!SESSION_ID_PATTERN.test(sessionId)) {
+    throw new Error("Invalid session identifier format");
+  }
+}
+
 /**
  * Create a new Zitadel session with a login name (username or email).
  * This is step 1 of the login flow.
@@ -108,6 +117,7 @@
  * Get session details.
  */
 async function getSession(sessionId: string, sessionToken: string): Promise<ZitadelSession> {
+  validateSessionId(sessionId);
   const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
     method: "GET",
     headers: {
@@ -136,6 +146,7 @@
   sessionToken: string;
   factors: ZitadelSession["session"]["factors"];
 }> {
+  validateSessionId(sessionId);
   const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
     method: "PATCH",
     headers: zitadelHeaders(),
EOF
@@ -60,6 +60,15 @@
};
}

function validateSessionId(sessionId: string): void {
// Allow only conservative, expected characters in session IDs.
// Adjust this pattern if Zitadel uses a more specific format.
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
if (!SESSION_ID_PATTERN.test(sessionId)) {
throw new Error("Invalid session identifier format");
}
}

/**
* Create a new Zitadel session with a login name (username or email).
* This is step 1 of the login flow.
@@ -108,6 +117,7 @@
* Get session details.
*/
async function getSession(sessionId: string, sessionToken: string): Promise<ZitadelSession> {
validateSessionId(sessionId);
const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
method: "GET",
headers: {
@@ -136,6 +146,7 @@
sessionToken: string;
factors: ZitadelSession["session"]["factors"];
}> {
validateSessionId(sessionId);
const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
method: "PATCH",
headers: zitadelHeaders(),
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +173 to +179
const response = await fetch(
`${ZITADEL_ISSUER}/v2/users/${userId}/authentication_methods`,
{
method: "GET",
headers: zitadelHeaders(),
}
);

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI about 1 month ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

@JohnRDOrazio JohnRDOrazio added this to the v0.4.0 milestone Apr 2, 2026
@JohnRDOrazio JohnRDOrazio changed the base branch from main to dev April 13, 2026 19:55
@JohnRDOrazio JohnRDOrazio added enhancement New feature or request UX User experience improvements labels Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request UX User experience improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom login UI to replace Zitadel Login V2 container

2 participants