From 1b31c4da7176cd14984a9afb7abbe9ccb8745b94 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 14:17:50 +0000 Subject: [PATCH] feat: zitadel oidc auth with pkce, iron-session, middleware --- src/app/api/auth/callback/route.ts | 33 ++++++++++++ src/app/api/auth/login/route.ts | 18 +++++++ src/app/api/auth/logout/route.ts | 9 ++++ src/lib/auth/pkce.ts | 13 +++++ src/lib/auth/session.ts | 31 +++++++++++ src/lib/auth/zitadel.ts | 87 ++++++++++++++++++++++++++++++ src/middleware.ts | 28 ++++++++++ 7 files changed, 219 insertions(+) create mode 100644 src/app/api/auth/callback/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/lib/auth/pkce.ts create mode 100644 src/lib/auth/session.ts create mode 100644 src/lib/auth/zitadel.ts create mode 100644 src/middleware.ts diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts new file mode 100644 index 0000000..8cb3462 --- /dev/null +++ b/src/app/api/auth/callback/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth/session' +import { exchangeCode, verifyIdToken, isAllowedUser } from '@/lib/auth/zitadel' + +export async function GET(req: NextRequest) { + const url = new URL(req.url) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + if (!code || !state) return NextResponse.json({ error: 'missing code/state' }, { status: 400 }) + + const session = await getSession() + const pending = session.loginInProgress + if (!pending || pending.state !== state) { + return NextResponse.json({ error: 'state mismatch' }, { status: 400 }) + } + + const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback` + const tokens = await exchangeCode({ code, codeVerifier: pending.codeVerifier, redirectUri }) + const claims = await verifyIdToken(tokens.id_token) + + if (!isAllowedUser(claims.sub)) { + session.destroy() + return NextResponse.json({ error: 'user not allowed' }, { status: 403 }) + } + + session.userId = claims.sub + session.email = claims.email + session.name = claims.name + delete session.loginInProgress + await session.save() + + return NextResponse.redirect(new URL('/', req.url)) +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..3e75253 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth/session' +import { generateVerifier, challengeFromVerifier, generateState } from '@/lib/auth/pkce' +import { buildAuthorizeUrl } from '@/lib/auth/zitadel' + +export async function GET() { + const verifier = generateVerifier() + const challenge = challengeFromVerifier(verifier) + const state = generateState() + const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback` + + const session = await getSession() + session.loginInProgress = { state, codeVerifier: verifier } + await session.save() + + const url = buildAuthorizeUrl({ state, codeChallenge: challenge, redirectUri }) + return NextResponse.redirect(url) +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..fd3c542 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth/session' +import { buildEndSessionUrl } from '@/lib/auth/zitadel' + +export async function GET() { + const session = await getSession() + session.destroy() + return NextResponse.redirect(buildEndSessionUrl(`${process.env.NEXT_PUBLIC_BASE_URL}/`)) +} diff --git a/src/lib/auth/pkce.ts b/src/lib/auth/pkce.ts new file mode 100644 index 0000000..2138927 --- /dev/null +++ b/src/lib/auth/pkce.ts @@ -0,0 +1,13 @@ +import { randomBytes, createHash } from 'node:crypto' + +export function generateVerifier(): string { + return randomBytes(64).toString('base64url') +} + +export function challengeFromVerifier(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url') +} + +export function generateState(): string { + return randomBytes(32).toString('base64url') +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts new file mode 100644 index 0000000..06a4bc9 --- /dev/null +++ b/src/lib/auth/session.ts @@ -0,0 +1,31 @@ +import type { SessionOptions } from 'iron-session' +import { getIronSession } from 'iron-session' +import { cookies } from 'next/headers' + +export interface SessionData { + userId?: string + email?: string + name?: string + loginInProgress?: { state: string; codeVerifier: string } +} + +const opts: SessionOptions = { + password: process.env.SESSION_PASSWORD || '', + cookieName: 'preis_tracker_session', + cookieOptions: { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 7 * 24 * 60 * 60, + }, +} + +export async function getSession() { + if (!opts.password || (typeof opts.password === 'string' && opts.password.length < 32)) { + throw new Error('SESSION_PASSWORD must be set to a 32+ char value') + } + const c = await cookies() + return getIronSession(c, opts) +} + +export const sessionOptions = opts diff --git a/src/lib/auth/zitadel.ts b/src/lib/auth/zitadel.ts new file mode 100644 index 0000000..2d352c3 --- /dev/null +++ b/src/lib/auth/zitadel.ts @@ -0,0 +1,87 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose' + +const issuer = process.env.ZITADEL_ISSUER! +const clientId = process.env.ZITADEL_CLIENT_ID! + +let jwksCache: ReturnType | null = null +function getJWKS() { + if (!jwksCache) { + jwksCache = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`)) + } + return jwksCache +} + +export interface TokenSet { + access_token: string + id_token: string + refresh_token?: string + expires_in: number + token_type: string +} + +export async function exchangeCode(args: { + code: string + codeVerifier: string + redirectUri: string +}): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code: args.code, + code_verifier: args.codeVerifier, + redirect_uri: args.redirectUri, + }) + const res = await fetch(`${issuer}/oauth/v2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + if (!res.ok) { + throw new Error(`Zitadel token exchange failed: ${res.status} ${await res.text()}`) + } + return res.json() +} + +export interface IdTokenClaims { + sub: string + email?: string + name?: string + iss: string + aud: string | string[] + exp: number +} + +export async function verifyIdToken(idToken: string): Promise { + const { payload } = await jwtVerify(idToken, getJWKS(), { + issuer, + audience: clientId, + }) + return payload as unknown as IdTokenClaims +} + +export function buildAuthorizeUrl(args: { + state: string + codeChallenge: string + redirectUri: string +}): string { + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + scope: 'openid email profile', + redirect_uri: args.redirectUri, + state: args.state, + code_challenge: args.codeChallenge, + code_challenge_method: 'S256', + }) + return `${issuer}/oauth/v2/authorize?${params.toString()}` +} + +export function buildEndSessionUrl(redirectTo: string): string { + const params = new URLSearchParams({ post_logout_redirect_uri: redirectTo }) + return `${issuer}/oidc/v1/end_session?${params.toString()}` +} + +export function isAllowedUser(sub: string): boolean { + const allowed = (process.env.ALLOWED_USER_IDS || '').split(',').map((s) => s.trim()).filter(Boolean) + return allowed.includes(sub) +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..c257baf --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getIronSession } from 'iron-session' +import type { SessionData } from '@/lib/auth/session' + +const PUBLIC_PREFIXES = ['/api/auth/', '/api/cron/', '/_next/', '/favicon'] + +export async function middleware(req: NextRequest) { + const { pathname } = req.nextUrl + if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) return NextResponse.next() + + const res = NextResponse.next() + const session = await getIronSession(req, res, { + password: process.env.SESSION_PASSWORD || '', + cookieName: 'preis_tracker_session', + }) + + if (!session.userId) { + if (pathname.startsWith('/api/')) { + return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }) + } + return NextResponse.redirect(new URL('/api/auth/login', req.url)) + } + return res +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +}