feat: zitadel oidc auth with pkce, iron-session, middleware
This commit is contained in:
33
src/app/api/auth/callback/route.ts
Normal file
33
src/app/api/auth/callback/route.ts
Normal 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))
|
||||
}
|
||||
18
src/app/api/auth/login/route.ts
Normal file
18
src/app/api/auth/login/route.ts
Normal 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)
|
||||
}
|
||||
9
src/app/api/auth/logout/route.ts
Normal file
9
src/app/api/auth/logout/route.ts
Normal 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
13
src/lib/auth/pkce.ts
Normal 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
31
src/lib/auth/session.ts
Normal 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
87
src/lib/auth/zitadel.ts
Normal 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
28
src/middleware.ts
Normal 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).*)'],
|
||||
}
|
||||
Reference in New Issue
Block a user