feat: zitadel oidc auth with pkce, iron-session, middleware

This commit is contained in:
2026-05-25 14:17:50 +00:00
parent 835f3bb2bb
commit 1b31c4da71
7 changed files with 219 additions and 0 deletions

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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}/`))
}

13
src/lib/auth/pkce.ts Normal file
View File

@@ -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')
}

31
src/lib/auth/session.ts Normal file
View File

@@ -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<SessionData>(c, opts)
}
export const sessionOptions = opts

87
src/lib/auth/zitadel.ts Normal file
View File

@@ -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<typeof createRemoteJWKSet> | 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<TokenSet> {
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<IdTokenClaims> {
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)
}

28
src/middleware.ts Normal file
View File

@@ -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<SessionData>(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).*)'],
}