diff --git a/.env.example b/.env.example
index b7a901cf..c37edad8 100644
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,8 @@ NEXT_PUBLIC_WS_URL=ws://localhost:8000
ZITADEL_ISSUER=http://localhost:8080
ZITADEL_CLIENT_ID=
ZITADEL_CLIENT_SECRET=
+# Login-client PAT for custom login pages (extracted by setup-zitadel.sh)
+ZITADEL_LOGIN_PAT=
# NextAuth.js
AUTH_URL=http://localhost:3000
diff --git a/app/auth/login/layout.tsx b/app/auth/login/layout.tsx
new file mode 100644
index 00000000..b444089c
--- /dev/null
+++ b/app/auth/login/layout.tsx
@@ -0,0 +1,7 @@
+export default function LoginLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx
new file mode 100644
index 00000000..5083b951
--- /dev/null
+++ b/app/auth/login/page.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { useSearchParams, useRouter } from "next/navigation";
+import { Suspense } from "react";
+import { AuthCard } from "@/components/auth/AuthCard";
+import { AuthInput } from "@/components/auth/AuthInput";
+import { Button } from "@/components/ui/button";
+import { createSession } from "@/lib/auth/zitadel-session";
+import { setLoginSession } from "@/lib/auth/login-session";
+
+function LoginContent() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const authRequestId = searchParams.get("authRequest") || "";
+
+ const [loginName, setLoginName] = useState("");
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!loginName.trim()) {
+ setError("Please enter your username or email.");
+ return;
+ }
+
+ if (!authRequestId) {
+ setError("Missing auth request. Please start the sign-in flow again.");
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const result = await createSession(loginName.trim());
+
+ await setLoginSession({
+ sessionId: result.sessionId,
+ sessionToken: result.sessionToken,
+ authRequestId,
+ loginName: loginName.trim(),
+ userId: result.userId,
+ });
+
+ router.push("/auth/login/password");
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message.includes("not found")
+ ? "User not found. Please check your username or email."
+ : "Something went wrong. Please try again."
+ : "Something went wrong. Please try again."
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ Sign in
+
+
+
+
+ );
+}
+
+export default function LoginPage() {
+ return (
+
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/app/auth/login/password/page.tsx b/app/auth/login/password/page.tsx
new file mode 100644
index 00000000..aa4bf078
--- /dev/null
+++ b/app/auth/login/password/page.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { AuthCard } from "@/components/auth/AuthCard";
+import { AuthInput } from "@/components/auth/AuthInput";
+import { BackLink } from "@/components/auth/BackLink";
+import { Button } from "@/components/ui/button";
+import { verifyPassword, listAuthMethods, finalizeAuthRequest } from "@/lib/auth/zitadel-session";
+import { getLoginSession, setLoginSession, clearLoginSession } from "@/lib/auth/login-session";
+
+export default function PasswordPage() {
+ const router = useRouter();
+
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [loginName, setLoginName] = useState(null);
+ const [sessionReady, setSessionReady] = useState(false);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ async function loadSession() {
+ const session = await getLoginSession();
+ if (!session) {
+ // No session cookie — redirect back to login
+ router.replace("/auth/signin");
+ return;
+ }
+ setLoginName(session.loginName || null);
+ setSessionReady(true);
+ }
+ loadSession();
+ }, [router]);
+
+ useEffect(() => {
+ if (sessionReady) {
+ inputRef.current?.focus();
+ }
+ }, [sessionReady]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!password) {
+ setError("Please enter your password.");
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const session = await getLoginSession();
+ if (!session) {
+ router.replace("/auth/signin");
+ return;
+ }
+
+ const result = await verifyPassword(session.sessionId, session.sessionToken, password);
+
+ // Update the session token (it changes after each check)
+ await setLoginSession({
+ ...session,
+ sessionToken: result.sessionToken,
+ });
+
+ // Check if MFA is required
+ const mfaMethods: string[] = [];
+ if (session.userId) {
+ try {
+ const methods = await listAuthMethods(session.userId);
+ // Filter for second-factor methods (TOTP, passkey, etc.)
+ const secondFactors = methods.filter(
+ (m) => m !== "AUTHENTICATION_METHOD_TYPE_PASSWORD" && m !== "AUTHENTICATION_METHOD_TYPE_UNSPECIFIED"
+ );
+ mfaMethods.push(...secondFactors);
+ } catch {
+ // If we can't check auth methods, try to finalize directly
+ }
+ }
+
+ if (mfaMethods.length > 0) {
+ // MFA required — redirect to MFA page (Phase 3)
+ // For now, try to finalize anyway; Zitadel will enforce MFA if required
+ router.push("/auth/login/mfa");
+ return;
+ }
+
+ // No MFA required — finalize the auth request
+ const callbackUrl = await finalizeAuthRequest(
+ session.authRequestId,
+ session.sessionId,
+ result.sessionToken
+ );
+
+ // Clean up the login session cookie
+ await clearLoginSession();
+
+ // Redirect to the NextAuth callback to complete OIDC flow
+ router.push(callbackUrl);
+ } catch (err) {
+ if (err instanceof Error && err.message.includes("Invalid password")) {
+ setError("Incorrect password. Please try again.");
+ } else {
+ setError("Something went wrong. Please try again.");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (!sessionReady) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Enter your password
+
+ {loginName && (
+
+ Signing in as {loginName}
+
+ )}
+
+
+
+ );
+}
diff --git a/components/auth/AuthCard.tsx b/components/auth/AuthCard.tsx
new file mode 100644
index 00000000..0b00a66c
--- /dev/null
+++ b/components/auth/AuthCard.tsx
@@ -0,0 +1,26 @@
+import { cn } from "@/lib/utils";
+
+interface AuthCardProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function AuthCard({ children, className }: AuthCardProps) {
+ return (
+
+
+
OntoKit
+
+ Collaborative ontology curation
+
+
+ {children}
+
+ );
+}
diff --git a/components/auth/AuthInput.tsx b/components/auth/AuthInput.tsx
new file mode 100644
index 00000000..4f1c7bce
--- /dev/null
+++ b/components/auth/AuthInput.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { forwardRef, type InputHTMLAttributes } from "react";
+import { cn } from "@/lib/utils";
+
+interface AuthInputProps extends InputHTMLAttributes {
+ label: string;
+ error?: string;
+}
+
+export const AuthInput = forwardRef(
+ ({ label, error, className, id, ...props }, ref) => {
+ const inputId = id || label.toLowerCase().replace(/\s+/g, "-");
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+ }
+);
+
+AuthInput.displayName = "AuthInput";
diff --git a/components/auth/BackLink.tsx b/components/auth/BackLink.tsx
new file mode 100644
index 00000000..d85f6f16
--- /dev/null
+++ b/components/auth/BackLink.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+
+interface BackLinkProps {
+ href: string;
+ label?: string;
+}
+
+export function BackLink({ href, label = "Back" }: BackLinkProps) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/lib/auth/login-session.ts b/lib/auth/login-session.ts
new file mode 100644
index 00000000..51bed5d4
--- /dev/null
+++ b/lib/auth/login-session.ts
@@ -0,0 +1,67 @@
+"use server";
+
+import { cookies } from "next/headers";
+import { EncryptJWT, jwtDecrypt } from "jose";
+
+const COOKIE_NAME = "ontokit-login-session";
+const TTL_SECONDS = 600; // 10 minutes
+
+export interface LoginSessionData {
+ sessionId: string;
+ sessionToken: string;
+ authRequestId: string;
+ loginName?: string;
+ userId?: string;
+}
+
+function getEncryptionKey(): Uint8Array {
+ const secret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET;
+ if (!secret) {
+ throw new Error("AUTH_SECRET or NEXTAUTH_SECRET must be set for login session encryption");
+ }
+ // Derive a 256-bit key from the secret by hashing it
+ const encoder = new TextEncoder();
+ const keyMaterial = encoder.encode(secret);
+ // Pad or truncate to 32 bytes for A256GCM
+ const key = new Uint8Array(32);
+ key.set(keyMaterial.slice(0, 32));
+ return key;
+}
+
+export async function setLoginSession(data: LoginSessionData): Promise {
+ const key = getEncryptionKey();
+ const token = await new EncryptJWT(data as unknown as Record)
+ .setProtectedHeader({ alg: "dir", enc: "A256GCM" })
+ .setIssuedAt()
+ .setExpirationTime(`${TTL_SECONDS}s`)
+ .encrypt(key);
+
+ const cookieStore = await cookies();
+ cookieStore.set(COOKIE_NAME, token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ path: "/auth",
+ maxAge: TTL_SECONDS,
+ });
+}
+
+export async function getLoginSession(): Promise {
+ const cookieStore = await cookies();
+ const token = cookieStore.get(COOKIE_NAME)?.value;
+ if (!token) return null;
+
+ try {
+ const key = getEncryptionKey();
+ const { payload } = await jwtDecrypt(token, key);
+ return payload as unknown as LoginSessionData;
+ } catch {
+ // Token expired or invalid
+ return null;
+ }
+}
+
+export async function clearLoginSession(): Promise {
+ const cookieStore = await cookies();
+ cookieStore.delete(COOKIE_NAME);
+}
diff --git a/lib/auth/zitadel-session.ts b/lib/auth/zitadel-session.ts
new file mode 100644
index 00000000..91da1c8a
--- /dev/null
+++ b/lib/auth/zitadel-session.ts
@@ -0,0 +1,217 @@
+"use server";
+
+const ZITADEL_ISSUER = process.env.ZITADEL_ISSUER || "http://localhost:8080";
+const ZITADEL_LOGIN_PAT = process.env.ZITADEL_LOGIN_PAT || "";
+
+interface ZitadelSessionResponse {
+ sessionId: string;
+ sessionToken: string;
+ details: {
+ sequence: string;
+ changeDate: string;
+ resourceOwner: string;
+ };
+ challenges?: {
+ webAuthN?: {
+ publicKeyCredentialRequestOptions: Record;
+ };
+ };
+}
+
+interface ZitadelSession {
+ session: {
+ id: string;
+ creationDate: string;
+ changeDate: string;
+ sequence: string;
+ factors: {
+ user?: {
+ verifiedAt: string;
+ id: string;
+ loginName: string;
+ displayName: string;
+ };
+ password?: {
+ verifiedAt: string;
+ };
+ webAuthN?: {
+ verifiedAt: string;
+ userVerified: boolean;
+ };
+ otp?: {
+ verifiedAt: string;
+ };
+ };
+ };
+}
+
+interface AuthMethodsResponse {
+ authMethodTypes: string[];
+}
+
+interface FinalizeResponse {
+ callbackUrl: string;
+}
+
+function zitadelHeaders(): Record {
+ return {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${ZITADEL_LOGIN_PAT}`,
+ };
+}
+
+/**
+ * Create a new Zitadel session with a login name (username or email).
+ * This is step 1 of the login flow.
+ */
+export async function createSession(loginName: string): Promise<{
+ sessionId: string;
+ sessionToken: string;
+ userId?: string;
+}> {
+ const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions`, {
+ method: "POST",
+ headers: zitadelHeaders(),
+ body: JSON.stringify({
+ checks: {
+ user: {
+ loginName,
+ },
+ },
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Failed to create session: ${error}`);
+ }
+
+ const data: ZitadelSessionResponse = await response.json();
+
+ // Fetch the session to get the user ID
+ let userId: string | undefined;
+ try {
+ const sessionData = await getSession(data.sessionId, data.sessionToken);
+ userId = sessionData.session.factors.user?.id;
+ } catch {
+ // User ID lookup is best-effort
+ }
+
+ return {
+ sessionId: data.sessionId,
+ sessionToken: data.sessionToken,
+ userId,
+ };
+}
+
+/**
+ * Get session details.
+ */
+async function getSession(sessionId: string, sessionToken: string): Promise {
+ const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
+ method: "GET",
+ headers: {
+ ...zitadelHeaders(),
+ "x-zitadel-session-token": sessionToken,
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Failed to get session: ${error}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Verify password for an existing session.
+ * This is step 2 of the login flow.
+ */
+export async function verifyPassword(
+ sessionId: string,
+ sessionToken: string,
+ password: string
+): Promise<{
+ sessionToken: string;
+ factors: ZitadelSession["session"]["factors"];
+}> {
+ const response = await fetch(`${ZITADEL_ISSUER}/v2/sessions/${sessionId}`, {
+ method: "PATCH",
+ headers: zitadelHeaders(),
+ body: JSON.stringify({
+ sessionToken,
+ checks: {
+ password: {
+ password,
+ },
+ },
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Invalid password: ${error}`);
+ }
+
+ const data: ZitadelSessionResponse = await response.json();
+
+ // Get updated session factors
+ const sessionData = await getSession(sessionId, data.sessionToken);
+
+ return {
+ sessionToken: data.sessionToken,
+ factors: sessionData.session.factors,
+ };
+}
+
+/**
+ * List authentication methods available for a user.
+ * Used to determine if MFA is required after password verification.
+ */
+export async function listAuthMethods(userId: string): Promise {
+ const response = await fetch(
+ `${ZITADEL_ISSUER}/v2/users/${userId}/authentication_methods`,
+ {
+ method: "GET",
+ headers: zitadelHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ return [];
+ }
+
+ const data: AuthMethodsResponse = await response.json();
+ return data.authMethodTypes || [];
+}
+
+/**
+ * Finalize the auth request by linking it to the authenticated session.
+ * Returns the callback URL that completes the OIDC flow.
+ */
+export async function finalizeAuthRequest(
+ authRequestId: string,
+ sessionId: string,
+ sessionToken: string
+): Promise {
+ const response = await fetch(`${ZITADEL_ISSUER}/v2/oidc/authorize`, {
+ method: "POST",
+ headers: zitadelHeaders(),
+ body: JSON.stringify({
+ authRequestId,
+ session: {
+ sessionId,
+ sessionToken,
+ },
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Failed to finalize auth request: ${error}`);
+ }
+
+ const data: FinalizeResponse = await response.json();
+ return data.callbackUrl;
+}
diff --git a/lib/env.ts b/lib/env.ts
index e0d1d504..4f0e790a 100644
--- a/lib/env.ts
+++ b/lib/env.ts
@@ -6,6 +6,7 @@ const serverSchema = z.object({
ZITADEL_CLIENT_SECRET: z.string().min(1, "ZITADEL_CLIENT_SECRET is required"),
NEXTAUTH_URL: z.string().url().optional(),
NEXTAUTH_SECRET: z.string().min(1, "NEXTAUTH_SECRET is required"),
+ ZITADEL_LOGIN_PAT: z.string().optional(),
});
const clientSchema = z.object({
diff --git a/package-lock.json b/package-lock.json
index 99933b45..16dc0397 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"clsx": "^2.1.0",
"d3": "^7.9.0",
"elkjs": "^0.11.0",
+ "jose": "^6.2.2",
"lucide-react": "^0.468.0",
"monaco-editor": "^0.55.1",
"next": "^15.1.0",
@@ -54,6 +55,9 @@
"tailwindcss": "^3.4.0",
"typescript": "^5.7.0",
"vitest": "^4.0.18"
+ },
+ "engines": {
+ "node": ">=20.9.0"
}
},
"node_modules/@acemir/cssom": {
@@ -10054,9 +10058,9 @@
}
},
"node_modules/jose": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
- "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
+ "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
diff --git a/package.json b/package.json
index 22d7889e..39ea66b2 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"clsx": "^2.1.0",
"d3": "^7.9.0",
"elkjs": "^0.11.0",
+ "jose": "^6.2.2",
"lucide-react": "^0.468.0",
"monaco-editor": "^0.55.1",
"next": "^15.1.0",