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