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 +

+ +
+ setLoginName(e.target.value)} + placeholder="you@example.com" + autoComplete="username" + error={error || undefined} + disabled={isLoading} + /> + + + +
+ ); +} + +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} +

+ )} + +
+ setPassword(e.target.value)} + placeholder="Enter your password" + autoComplete="current-password" + error={error || undefined} + disabled={isLoading} + /> + + + +
+ ); +} 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 && ( + + )} +
+ ); + } +); + +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",