69 KiB
Cidaas Authentication Integration Guide
Übersicht
Diese Dokumentation beschreibt die vollständige Integration von Cidaas (OAuth2/OIDC Identity Provider) in die my.experimenta.science E-Commerce-Plattform.
Cidaas wird ausschließlich für Authentifizierung verwendet. Nutzerprofile, Rollen und Kaufhistorie werden lokal in PostgreSQL gespeichert.
Architektur-Entscheidungen
| Aspekt | Technologie | Begründung |
|---|---|---|
| Auth Module | nuxt-auth-utils |
Offiziell, lightweight, perfekt für Custom OAuth2 |
| OAuth2 Provider | Cidaas (OIDC) | Unternehmens-Anforderung, Enterprise-CIAM |
| Auth Flow | Authorization Code + PKCE | Sicherheitsstandard (verhindert Code Interception) |
| Session Storage | Encrypted HTTP-only Cookies | Stateless, sicher, von nuxt-auth-utils verwaltet |
| Session Dauer | 30 Tage | E-Commerce Standard (Balance Sicherheit/UX) |
| User Storage | PostgreSQL (lokal) | Vollständige Kontrolle über Nutzerdaten |
| JWT Validation | jose Library |
Modern, sicher, gut gewartet |
| UI Approach | Custom Forms (eine Seite mit Tabs) | Volle Kontrolle über Design, experimenta Branding |
Architektur-Diagramm
┌────────────────────────────────────────────────────────────────────────────┐
│ USER LOGIN FLOW │
└────────────────────────────────────────────────────────────────────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ Browser │ HTTPS │ Nuxt 4 App │ OIDC │ Cidaas │ Link │ PostgreSQL │
│ (Client) │◄───────►│ (Server) │◄───────►│ (Identity) │ │ (User Store) │
└─────────────┘ └─────────────┘ └─────────────┘ └──────────────┘
│ │ │ │
│ 1. Navigate /auth │ │ │
├──────────────────────►│ │ │
│ │ │ │
│ 2. Render Auth Page │ │ │
│ (Login Tab) │ │ │
│◄──────────────────────┤ │ │
│ │ │ │
│ 3. Submit Email/Pass │ │ │
│ POST /api/auth/ │ │ │
│ login │ │ │
├──────────────────────►│ │ │
│ │ │ │
│ │ 4. Generate PKCE │ │
│ │ (verifier + challenge) │
│ │ │ │
│ │ 5. Store verifier │ │
│ │ in cookie (5min) │ │
│ │ │ │
│ │ 6. Build OAuth2 URL │ │
│ │ with challenge │ │
│ │ │ │
│ 7. Redirect to │ │ │
│ Cidaas Authz │ │ │
│◄──────────────────────┤ │ │
│ │ │ │
│ 8. Submit credentials │ │
│ to Cidaas │ │ │
├──────────────────────────────────────────────►│ │
│ │ │ │
│ │ 9. Validate credentials │
│ │ (email + password) │
│ │ │ │
│ 10. Redirect to │ │ │
│ callback with │ │ │
│ auth code │ │ │
│◄──────────────────────────────────────────────┤ │
│ │ │ │
│ 11. GET /api/auth/ │ │ │
│ callback?code=xxx│ │ │
├──────────────────────►│ │ │
│ │ │ │
│ │ 12. Validate state │ │
│ │ (CSRF protection)│ │
│ │ │ │
│ │ 13. Retrieve PKCE │ │
│ │ verifier from │ │
│ │ cookie │ │
│ │ │ │
│ │ 14. Exchange code + │ │
│ │ verifier for │ │
│ │ tokens │ │
│ ├──────────────────────►│ │
│ │ │ │
│ │ 15. Access Token + │ │
│ │ ID Token (JWT) │ │
│ │◄──────────────────────┤ │
│ │ │ │
│ │ 16. Validate JWT │ │
│ │ (signature, exp, │ │
│ │ issuer, aud) │ │
│ │ │ │
│ │ 17. Fetch UserInfo │ │
│ │ with access token│ │
│ ├──────────────────────►│ │
│ │ │ │
│ │ 18. User Profile │ │
│ │ (sub, email, │ │
│ │ name, ...) │ │
│ │◄──────────────────────┤ │
│ │ │ │
│ │ 19. Query DB by experimenta_id = sub │
│ ├─────────────────────────────────────────────────►│
│ │ │ │
│ │ 20. User exists? │
│ │◄─────────────────────────────────────────────────┤
│ │ │ │
│ │ 21. If new: INSERT user │
│ │ If exists: UPDATE updated_at │
│ ├─────────────────────────────────────────────────►│
│ │ │ │
│ │ 22. User record │
│ │◄─────────────────────────────────────────────────┤
│ │ │ │
│ │ 23. Create Session │ │
│ │ (encrypted cookie│ │
│ │ with user data) │ │
│ │ │ │
│ 24. Set Session │ │ │
│ Cookie & │ │ │
│ Redirect to / │ │ │
│◄──────────────────────┤ │ │
│ │ │ │
│ 25. Navigate Home │ │ │
│ (logged in) │ │ │
├──────────────────────►│ │ │
│ │ │ │
Setup-Anleitung
1. Dependencies installieren
pnpm add nuxt-auth-utils jose
Packages:
nuxt-auth-utils- Offizielles Nuxt Auth Module (Session Management)jose- JWT Validation Library (OIDC ID Token Verification)
2. Cidaas Admin Panel Konfiguration
Bevor die Integration funktioniert, muss ein OAuth2 Client in Cidaas konfiguriert werden.
Checklist:
-
1. OAuth2 Application erstellen
- Login im Cidaas Admin Panel
- Navigiere zu: Applications → Create New Application
- Type: "Web Application"
- Name: "my.experimenta.science"
-
2. Grant Types konfigurieren
- ✅ Enable:
authorization_code - ✅ Enable:
refresh_token(optional, für Token Refresh) - ❌ Disable:
implicit,client_credentials
- ✅ Enable:
-
3. PKCE aktivieren
- ✅ Require PKCE:
true - Code Challenge Method:
S256(SHA-256)
- ✅ Require PKCE:
-
4. Redirect URIs konfigurieren
- Development:
http://localhost:3000/api/auth/callback - Staging:
https://staging.my.experimenta.science/api/auth/callback - Production:
https://my.experimenta.science/api/auth/callback - ⚠️ Wichtig: Exakte URIs, keine Wildcards!
- Development:
-
5. Scopes konfigurieren
- ✅
openid(mandatory für OIDC) - ✅
profile(Name, etc.) - ✅
email(E-Mail-Adresse)
- ✅
-
6. Token Lifetimes
- Access Token Lifetime:
3600Sekunden (1 Stunde) - Refresh Token Lifetime:
2592000Sekunden (30 Tage) - ID Token Lifetime:
3600Sekunden (1 Stunde)
- Access Token Lifetime:
-
7. Client Credentials notieren
- Client ID: z.B.
abc123def456(wird in .env hinterlegt) - Client Secret: z.B.
secret_xyz789(wird in .env hinterlegt)
- Client ID: z.B.
-
8. Cidaas Endpoints notieren
- Issuer:
https://experimenta.cidaas.de - Authorization Endpoint:
https://experimenta.cidaas.de/authz-srv/authz - Token Endpoint:
https://experimenta.cidaas.de/token-srv/token - UserInfo Endpoint:
https://experimenta.cidaas.de/users-srv/userinfo - JWKS Endpoint:
https://experimenta.cidaas.de/.well-known/jwks.json
- Issuer:
3. Environment Variables
Erstelle .env Datei im Projekt-Root:
# .env (NEVER commit this file!)
# Cidaas OAuth2 Configuration
CIDAAS_CLIENT_ID=abc123def456
CIDAAS_CLIENT_SECRET=secret_xyz789
CIDAAS_ISSUER=https://experimenta.cidaas.de
CIDAAS_AUTHORIZE_URL=https://experimenta.cidaas.de/authz-srv/authz
CIDAAS_TOKEN_URL=https://experimenta.cidaas.de/token-srv/token
CIDAAS_USERINFO_URL=https://experimenta.cidaas.de/users-srv/userinfo
CIDAAS_JWKS_URL=https://experimenta.cidaas.de/.well-known/jwks.json
# Callback URL (must match Cidaas config!)
CIDAAS_REDIRECT_URI=http://localhost:3000/api/auth/callback # Dev
# CIDAAS_REDIRECT_URI=https://my.experimenta.science/api/auth/callback # Production
# Session Encryption Secret (generate with: openssl rand -hex 32)
NUXT_SESSION_PASSWORD=your-64-character-hex-secret-here
Erstelle .env.example (für Git - ohne echte Secrets):
# .env.example
CIDAAS_CLIENT_ID=your-client-id
CIDAAS_CLIENT_SECRET=your-client-secret
CIDAAS_ISSUER=https://experimenta.cidaas.de
CIDAAS_AUTHORIZE_URL=https://experimenta.cidaas.de/authz-srv/authz
CIDAAS_TOKEN_URL=https://experimenta.cidaas.de/token-srv/token
CIDAAS_USERINFO_URL=https://experimenta.cidaas.de/users-srv/userinfo
CIDAAS_JWKS_URL=https://experimenta.cidaas.de/.well-known/jwks.json
CIDAAS_REDIRECT_URI=http://localhost:3000/api/auth/callback
NUXT_SESSION_PASSWORD=generate-with-openssl-rand-hex-32
Session Secret generieren:
openssl rand -hex 32
4. Nuxt Configuration
nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'nuxt-auth-utils',
'@nuxtjs/i18n',
'shadcn-nuxt',
// ... weitere Module
],
runtimeConfig: {
// Private keys (nur Server-seitig verfügbar)
cidaas: {
clientId: process.env.CIDAAS_CLIENT_ID,
clientSecret: process.env.CIDAAS_CLIENT_SECRET,
issuer: process.env.CIDAAS_ISSUER,
authorizeUrl: process.env.CIDAAS_AUTHORIZE_URL,
tokenUrl: process.env.CIDAAS_TOKEN_URL,
userinfoUrl: process.env.CIDAAS_USERINFO_URL,
jwksUrl: process.env.CIDAAS_JWKS_URL,
redirectUri: process.env.CIDAAS_REDIRECT_URI,
},
// Session configuration
// Note: nuxt-auth-utils automatically reads NUXT_SESSION_PASSWORD from process.env
session: {
maxAge: 60 * 60 * 24 * 30, // 30 days in seconds
name: 'experimenta-session',
},
// Public keys (auch Client-seitig verfügbar)
public: {
appUrl: process.env.APP_URL || 'http://localhost:3000',
},
},
// Security headers
nitro: {
routeRules: {
'/api/auth/**': {
headers: {
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
},
},
},
},
compatibilityDate: '2024-10-29',
})
Server-Side Implementation
1. PKCE Utilities
File: server/utils/pkce.ts
// server/utils/pkce.ts
/**
* PKCE (Proof Key for Code Exchange) utilities for OAuth2 security.
*
* PKCE prevents authorization code interception attacks by requiring
* the client to prove possession of the original code verifier.
*
* Flow:
* 1. Generate random code_verifier (43-128 chars)
* 2. Hash verifier with SHA-256 → code_challenge
* 3. Send challenge to authorization server
* 4. Server returns authorization code
* 5. Exchange code + verifier for tokens
* 6. Server validates: SHA256(verifier) === stored_challenge
*/
/**
* Generate a random code verifier (43-128 URL-safe characters)
*
* @param length - Length of verifier (default: 64)
* @returns Base64URL-encoded random string
*/
export function generateCodeVerifier(length: number = 64): string {
// Generate random bytes
const randomBytes = crypto.getRandomValues(new Uint8Array(length))
// Convert to base64url (URL-safe base64 without padding)
return base64UrlEncode(randomBytes)
}
/**
* Generate SHA-256 hash of code verifier → code challenge
*
* @param verifier - The code verifier
* @returns Base64URL-encoded SHA-256 hash
*/
export async function generateCodeChallenge(verifier: string): Promise<string> {
// Convert verifier string to Uint8Array
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
// Hash with SHA-256
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
// Convert to base64url
return base64UrlEncode(new Uint8Array(hashBuffer))
}
/**
* Generate PKCE verifier + challenge pair
*
* @returns Object with verifier and challenge
*/
export async function generatePKCE(): Promise<{
verifier: string
challenge: string
}> {
const verifier = generateCodeVerifier()
const challenge = await generateCodeChallenge(verifier)
return { verifier, challenge }
}
/**
* Convert Uint8Array to Base64URL string
*
* Base64URL is URL-safe variant of Base64:
* - Replace '+' with '-'
* - Replace '/' with '_'
* - Remove padding '='
*/
function base64UrlEncode(buffer: Uint8Array): string {
// Convert to base64
const base64 = btoa(String.fromCharCode(...buffer))
// Convert to base64url
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
/**
* Generate random state parameter for CSRF protection
*
* @param length - Length of state string (default: 32)
* @returns Random URL-safe string
*/
export function generateState(length: number = 32): string {
const randomBytes = crypto.getRandomValues(new Uint8Array(length))
return base64UrlEncode(randomBytes)
}
2. Cidaas API Client
File: server/utils/cidaas.ts
// server/utils/cidaas.ts
/**
* Cidaas API Client for OAuth2/OIDC integration
*
* Provides functions to interact with Cidaas endpoints:
* - Token exchange (authorization code → access/ID tokens)
* - UserInfo fetch
* - User registration
*/
import type { H3Error } from 'h3'
/**
* Cidaas Token Response
*/
export interface CidaasTokenResponse {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
id_token: string // JWT with user identity
scope: string
}
/**
* Cidaas UserInfo Response
*/
export interface CidaasUserInfo {
sub: string // Unique user ID (experimenta_id)
email: string
email_verified: boolean
given_name?: string
family_name?: string
name?: string
phone_number?: string
updated_at?: number
}
/**
* Cidaas Registration Request
*/
export interface CidaasRegistrationRequest {
email: string
password: string
given_name: string
family_name: string
locale?: string
}
/**
* Exchange authorization code for access/ID tokens
*
* @param code - Authorization code from callback
* @param codeVerifier - PKCE code verifier
* @returns Token response
* @throws H3Error if exchange fails
*/
export async function exchangeCodeForToken(
code: string,
codeVerifier: string
): Promise<CidaasTokenResponse> {
const config = useRuntimeConfig()
// Prepare token request
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.cidaas.redirectUri,
client_id: config.cidaas.clientId,
client_secret: config.cidaas.clientSecret,
code_verifier: codeVerifier, // PKCE proof
})
try {
const response = await fetch(config.cidaas.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.error('Cidaas token exchange failed:', errorData)
throw createError({
statusCode: response.status,
statusMessage: 'Token exchange failed',
data: errorData,
})
}
const tokens: CidaasTokenResponse = await response.json()
return tokens
} catch (error) {
console.error('Token exchange error:', error)
if ((error as H3Error).statusCode) {
throw error // Re-throw H3Error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to exchange authorization code',
})
}
}
/**
* Fetch user info from Cidaas UserInfo endpoint
*
* @param accessToken - OAuth2 access token
* @returns User profile data
* @throws H3Error if fetch fails
*/
export async function fetchUserInfo(accessToken: string): Promise<CidaasUserInfo> {
const config = useRuntimeConfig()
try {
const response = await fetch(config.cidaas.userinfoUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
console.error('Cidaas UserInfo fetch failed:', response.status)
throw createError({
statusCode: response.status,
statusMessage: 'Failed to fetch user info',
})
}
const userInfo: CidaasUserInfo = await response.json()
return userInfo
} catch (error) {
console.error('UserInfo fetch error:', error)
if ((error as H3Error).statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch user information',
})
}
}
/**
* Register new user via Cidaas Registration API
*
* @param data - Registration data
* @returns Success indicator (user must verify email before login)
* @throws H3Error if registration fails
*/
export async function registerUser(
data: CidaasRegistrationRequest
): Promise<{ success: boolean; message: string }> {
const config = useRuntimeConfig()
// Cidaas registration endpoint (adjust based on actual API)
const registrationUrl = `${config.cidaas.issuer}/users-srv/register`
try {
const response = await fetch(registrationUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: data.email,
password: data.password,
given_name: data.given_name,
family_name: data.family_name,
locale: data.locale || 'de',
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.error('Cidaas registration failed:', errorData)
// Handle specific errors
if (response.status === 409) {
throw createError({
statusCode: 409,
statusMessage: 'Email already registered',
})
}
throw createError({
statusCode: response.status,
statusMessage: 'Registration failed',
data: errorData,
})
}
return {
success: true,
message: 'Registration successful. Please verify your email.',
}
} catch (error) {
console.error('Registration error:', error)
if ((error as H3Error).statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to register user',
})
}
}
/**
* Refresh access token using refresh token
*
* @param refreshToken - Refresh token from previous login
* @returns New token response
* @throws H3Error if refresh fails
*/
export async function refreshAccessToken(refreshToken: string): Promise<CidaasTokenResponse> {
const config = useRuntimeConfig()
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: config.cidaas.clientId,
client_secret: config.cidaas.clientSecret,
})
try {
const response = await fetch(config.cidaas.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
})
if (!response.ok) {
throw createError({
statusCode: response.status,
statusMessage: 'Token refresh failed',
})
}
const tokens: CidaasTokenResponse = await response.json()
return tokens
} catch (error) {
console.error('Token refresh error:', error)
if ((error as H3Error).statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to refresh token',
})
}
}
3. JWT Validation
File: server/utils/jwt.ts
// server/utils/jwt.ts
/**
* JWT Token Validation using jose library
*
* Validates Cidaas ID tokens (OIDC JWT) to ensure:
* - Signature is valid (using Cidaas public keys from JWKS)
* - Token has not expired
* - Issuer matches expected Cidaas instance
* - Audience matches our client ID
*/
import { jwtVerify, createRemoteJWKSet, type JWTPayload } from 'jose'
// Cache JWKS (Cidaas public keys) to avoid fetching on every request
let jwksCache: ReturnType<typeof createRemoteJWKSet> | null = null
/**
* Get or create JWKS cache
*
* JWKS (JSON Web Key Set) contains public keys used to verify JWT signatures.
* We cache this to improve performance.
*/
function getJWKS() {
if (!jwksCache) {
const config = useRuntimeConfig()
jwksCache = createRemoteJWKSet(new URL(config.cidaas.jwksUrl))
}
return jwksCache
}
/**
* Extended JWT payload with OIDC claims
*/
export interface CidaasJWTPayload extends JWTPayload {
sub: string // User ID (experimenta_id)
email?: string
email_verified?: boolean
given_name?: string
family_name?: string
name?: string
}
/**
* Verify Cidaas ID token
*
* @param idToken - JWT ID token from Cidaas
* @returns Decoded and verified JWT payload
* @throws Error if verification fails
*/
export async function verifyIdToken(idToken: string): Promise<CidaasJWTPayload> {
const config = useRuntimeConfig()
const JWKS = getJWKS()
try {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: config.cidaas.issuer, // Must match Cidaas issuer
audience: config.cidaas.clientId, // Must match our client ID
})
return payload as CidaasJWTPayload
} catch (error) {
console.error('JWT verification failed:', error)
// Provide specific error messages
if (error instanceof Error) {
if (error.message.includes('expired')) {
throw createError({
statusCode: 401,
statusMessage: 'Token has expired',
})
}
if (error.message.includes('signature')) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid token signature',
})
}
}
throw createError({
statusCode: 401,
statusMessage: 'Invalid ID token',
})
}
}
/**
* Decode JWT without verification (for debugging only!)
*
* ⚠️ WARNING: Only use for debugging. Never trust unverified tokens!
*
* @param token - JWT token
* @returns Decoded payload (unverified!)
*/
export function decodeJWT(token: string): JWTPayload | null {
try {
const parts = token.split('.')
if (parts.length !== 3) {
return null
}
const payload = parts[1]
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'))
return decoded
} catch (error) {
console.error('JWT decode failed:', error)
return null
}
}
Auth API Endpoints
1. Login Endpoint
File: server/api/auth/login.post.ts
// server/api/auth/login.post.ts
/**
* POST /api/auth/login
*
* Initiates OAuth2 Authorization Code Flow with PKCE
*
* Request body:
* {
* "email": "user@example.com"
* }
*
* Response:
* {
* "redirectUrl": "https://experimenta.cidaas.de/authz-srv/authz?..."
* }
*
* Client should redirect user to redirectUrl
*/
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
})
export default defineEventHandler(async (event) => {
// 1. Validate request body
const body = await readBody(event)
const { email } = loginSchema.parse(body)
// 2. Generate PKCE challenge
const { verifier, challenge } = await generatePKCE()
// 3. Generate state for CSRF protection
const state = generateState(32)
// 4. Store PKCE verifier in encrypted cookie (5 min TTL)
setCookie(event, 'pkce_verifier', verifier, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 300, // 5 minutes
path: '/',
})
// 5. Store state in cookie for validation
setCookie(event, 'oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 300, // 5 minutes
path: '/',
})
// 6. Build Cidaas authorization URL
const config = useRuntimeConfig()
const authUrl = new URL(config.cidaas.authorizeUrl)
authUrl.searchParams.set('client_id', config.cidaas.clientId)
authUrl.searchParams.set('redirect_uri', config.cidaas.redirectUri)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', 'openid profile email')
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('code_challenge', challenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
authUrl.searchParams.set('login_hint', email) // Pre-fill email in Cidaas form
// 7. Return redirect URL to client
return {
redirectUrl: authUrl.toString(),
}
})
2. Callback Endpoint
File: server/api/auth/callback.get.ts
// server/api/auth/callback.get.ts
/**
* GET /api/auth/callback
*
* OAuth2 callback handler - receives authorization code from Cidaas
*
* Query params:
* - code: Authorization code
* - state: CSRF protection token
*
* Flow:
* 1. Validate state parameter
* 2. Exchange code for tokens
* 3. Validate ID token
* 4. Fetch user info
* 5. Create/update user in PostgreSQL
* 6. Create session
* 7. Redirect to homepage
*/
import { eq } from 'drizzle-orm'
import { users } from '~/server/database/schema'
export default defineEventHandler(async (event) => {
// 1. Extract query parameters
const query = getQuery(event)
const { code, state } = query
if (!code || !state) {
throw createError({
statusCode: 400,
statusMessage: 'Missing code or state parameter',
})
}
// 2. Validate state (CSRF protection)
const storedState = getCookie(event, 'oauth_state')
if (!storedState || state !== storedState) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid state parameter - possible CSRF attack',
})
}
// 3. Retrieve PKCE verifier
const verifier = getCookie(event, 'pkce_verifier')
if (!verifier) {
throw createError({
statusCode: 400,
statusMessage: 'PKCE verifier not found - session expired',
})
}
try {
// 4. Exchange authorization code for tokens
const tokens = await exchangeCodeForToken(code as string, verifier)
// 5. Validate ID token (JWT)
const idTokenPayload = await verifyIdToken(tokens.id_token)
// 6. Fetch detailed user info from Cidaas
const cidaasUser = await fetchUserInfo(tokens.access_token)
// 7. Get database instance
const db = useDatabase()
// 8. Check if user already exists in our database
let user = await db.query.users.findFirst({
where: eq(users.experimentaId, cidaasUser.sub),
})
if (!user) {
// First time login - create new user
const [newUser] = await db
.insert(users)
.values({
experimentaId: cidaasUser.sub, // Cidaas user ID
email: cidaasUser.email,
firstName: cidaasUser.given_name || null,
lastName: cidaasUser.family_name || null,
})
.returning()
user = newUser
console.log('New user created:', user.id)
} else {
// Existing user - update last login timestamp
await db.update(users).set({ updatedAt: new Date() }).where(eq(users.id, user.id))
console.log('User logged in:', user.id)
}
// 9. Create encrypted session (nuxt-auth-utils)
await setUserSession(event, {
user: {
id: user.id,
experimentaId: user.experimentaId,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
},
loggedInAt: new Date().toISOString(),
})
// 10. Clean up temporary cookies
deleteCookie(event, 'oauth_state')
deleteCookie(event, 'pkce_verifier')
// 11. Redirect to homepage (or original requested page)
const redirectTo = getCookie(event, 'redirect_after_login') || '/'
deleteCookie(event, 'redirect_after_login')
return sendRedirect(event, redirectTo)
} catch (error) {
console.error('OAuth callback error:', error)
// Clean up cookies on error
deleteCookie(event, 'oauth_state')
deleteCookie(event, 'pkce_verifier')
// Redirect to login page with error
return sendRedirect(event, '/auth?error=login_failed')
}
})
3. Register Endpoint
File: server/api/auth/register.post.ts
// server/api/auth/register.post.ts
/**
* POST /api/auth/register
*
* Register new user via Cidaas Registration API
*
* Request body:
* {
* "email": "user@example.com",
* "password": "SecurePassword123!",
* "firstName": "Max",
* "lastName": "Mustermann"
* }
*
* Response:
* {
* "success": true,
* "message": "Registration successful. Please verify your email."
* }
*
* Note: User must verify email before they can log in
*/
import { z } from 'zod'
const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
})
export default defineEventHandler(async (event) => {
// 1. Validate request body
const body = await readBody(event)
let validatedData
try {
validatedData = registerSchema.parse(body)
} catch (error) {
if (error instanceof z.ZodError) {
throw createError({
statusCode: 400,
statusMessage: 'Validation failed',
data: error.errors,
})
}
throw error
}
// 2. Register user via Cidaas API
try {
const result = await registerUser({
email: validatedData.email,
password: validatedData.password,
given_name: validatedData.firstName,
family_name: validatedData.lastName,
locale: 'de', // Default to German
})
return result
} catch (error) {
// Handle specific registration errors
if ((error as any).statusCode === 409) {
throw createError({
statusCode: 409,
statusMessage: 'Email address already registered',
})
}
throw error
}
})
4. Logout Endpoint
File: server/api/auth/logout.post.ts
// server/api/auth/logout.post.ts
/**
* POST /api/auth/logout
*
* End user session and clear session cookie
*
* Response:
* {
* "success": true
* }
*/
export default defineEventHandler(async (event) => {
// Clear session (nuxt-auth-utils)
await clearUserSession(event)
// Optional: Revoke Cidaas tokens (Single Sign-Out)
// This would require storing refresh_token in session and calling Cidaas revoke endpoint
return {
success: true,
}
})
5. Current User Endpoint
File: server/api/auth/me.get.ts
// server/api/auth/me.get.ts
/**
* GET /api/auth/me
*
* Get current authenticated user
*
* Response:
* {
* "id": "uuid",
* "experimentaId": "cidaas-sub",
* "email": "user@example.com",
* "firstName": "Max",
* "lastName": "Mustermann",
* ...
* }
*
* Returns 401 if not authenticated
*/
import { eq } from 'drizzle-orm'
import { users } from '~/server/database/schema'
export default defineEventHandler(async (event) => {
// 1. Require authentication (throws 401 if not logged in)
const { user: sessionUser } = await requireUserSession(event)
// 2. Fetch fresh user data from database
const db = useDatabase()
const user = await db.query.users.findFirst({
where: eq(users.id, sessionUser.id),
})
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'User not found',
})
}
// 3. Return user profile (exclude sensitive fields if any)
return {
id: user.id,
experimentaId: user.experimentaId,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
phone: user.phone,
// Billing address
salutation: user.salutation,
dateOfBirth: user.dateOfBirth,
street: user.street,
postCode: user.postCode,
city: user.city,
countryCode: user.countryCode,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
})
Client-Side Implementation
1. Auth Composable
File: composables/useAuth.ts
// composables/useAuth.ts
/**
* Authentication composable
*
* Wrapper around nuxt-auth-utils useUserSession() with convenience methods
*
* Usage:
* const { user, loggedIn, login, logout } = useAuth()
*/
export function useAuth() {
const { loggedIn, user, clear, fetch } = useUserSession()
/**
* Login with email
* Initiates OAuth2 flow
*/
async function login(email: string) {
try {
// Call login endpoint to get redirect URL
const { redirectUrl } = await $fetch('/api/auth/login', {
method: 'POST',
body: { email },
})
// Redirect to Cidaas
navigateTo(redirectUrl, { external: true })
} catch (error) {
console.error('Login failed:', error)
throw error
}
}
/**
* Register new user
*/
async function register(data: {
email: string
password: string
firstName: string
lastName: string
}) {
try {
const result = await $fetch('/api/auth/register', {
method: 'POST',
body: data,
})
return result
} catch (error) {
console.error('Registration failed:', error)
throw error
}
}
/**
* Logout
* Clears session and redirects to homepage
*/
async function logout() {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
await clear() // Clear client-side state
navigateTo('/') // Redirect to homepage
} catch (error) {
console.error('Logout failed:', error)
throw error
}
}
/**
* Refresh user data from server
*/
async function refreshUser() {
try {
await fetch() // Re-fetch session from server
} catch (error) {
console.error('Refresh user failed:', error)
}
}
return {
user,
loggedIn,
login,
register,
logout,
refreshUser,
}
}
2. Auth Page (Login + Register Tabs)
File: pages/auth.vue
<!-- pages/auth.vue -->
<script setup lang="ts">
/**
* Combined Authentication Page
*
* Features:
* - Tab navigation (Login / Register)
* - Redirects logged-in users to homepage
* - Stores intended destination for post-login redirect
*/
const route = useRoute()
const { loggedIn } = useAuth()
// Redirect if already logged in
if (loggedIn.value) {
navigateTo('/')
}
// Active tab state
const activeTab = ref<'login' | 'register'>('login')
// Set tab from query param if present
onMounted(() => {
if (route.query.tab === 'register') {
activeTab.value = 'register'
}
})
// Error message from OAuth callback
const errorMessage = computed(() => {
if (route.query.error === 'login_failed') {
return 'Login fehlgeschlagen. Bitte versuchen Sie es erneut.'
}
return null
})
// Set page meta
definePageMeta({
layout: 'auth', // Optional: Use separate layout for auth pages
})
// i18n
const { t } = useI18n()
</script>
<template>
<div class="container mx-auto max-w-md px-4 py-16">
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold">
{{ t('auth.welcome') }}
</h1>
<p class="mt-2 text-muted-foreground">
{{ t('auth.subtitle') }}
</p>
</div>
<!-- Error Alert -->
<Alert v-if="errorMessage" variant="destructive" class="mb-6">
<AlertCircle class="h-4 w-4" />
<AlertTitle>{{ t('auth.error') }}</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription>
</Alert>
<!-- Tabs -->
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="login">
{{ t('auth.login') }}
</TabsTrigger>
<TabsTrigger value="register">
{{ t('auth.register') }}
</TabsTrigger>
</TabsList>
<!-- Login Tab -->
<TabsContent value="login">
<Card>
<CardHeader>
<CardTitle>{{ t('auth.loginTitle') }}</CardTitle>
<CardDescription>
{{ t('auth.loginDescription') }}
</CardDescription>
</CardHeader>
<CardContent>
<AuthLoginForm />
</CardContent>
</Card>
</TabsContent>
<!-- Register Tab -->
<TabsContent value="register">
<Card>
<CardHeader>
<CardTitle>{{ t('auth.registerTitle') }}</CardTitle>
<CardDescription>
{{ t('auth.registerDescription') }}
</CardDescription>
</CardHeader>
<CardContent>
<AuthRegisterForm />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</template>
3. Login Form Component
File: components/auth/LoginForm.vue
<!-- components/auth/LoginForm.vue -->
<script setup lang="ts">
import { z } from 'zod'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
const { login } = useAuth()
const { t } = useI18n()
// Validation schema
const loginSchema = toTypedSchema(
z.object({
email: z.string().email(t('auth.validation.invalidEmail')),
})
)
// Form state
const { handleSubmit, isSubmitting, errors } = useForm({
validationSchema: loginSchema,
})
// Success/error state
const submitError = ref<string | null>(null)
// Form submit handler
const onSubmit = handleSubmit(async (values) => {
submitError.value = null
try {
await login(values.email)
// Redirect happens in login() function
} catch (error: any) {
console.error('Login error:', error)
submitError.value = error.data?.message || t('auth.loginError')
}
})
</script>
<template>
<form @submit="onSubmit" class="space-y-4">
<!-- Error Alert -->
<Alert v-if="submitError" variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>{{ submitError }}</AlertDescription>
</Alert>
<!-- Email Field -->
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>{{ t('auth.email') }}</FormLabel>
<FormControl>
<Input type="email" :placeholder="t('auth.emailPlaceholder')" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit Button -->
<Button type="submit" class="w-full" :disabled="isSubmitting">
<Loader2 v-if="isSubmitting" class="mr-2 h-4 w-4 animate-spin" />
{{ isSubmitting ? t('auth.loggingIn') : t('auth.loginButton') }}
</Button>
<!-- Info Text -->
<p class="text-sm text-muted-foreground text-center">
{{ t('auth.loginInfo') }}
</p>
</form>
</template>
4. Register Form Component
File: components/auth/RegisterForm.vue
<!-- components/auth/RegisterForm.vue -->
<script setup lang="ts">
import { z } from 'zod'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
const { register } = useAuth()
const { t } = useI18n()
// Validation schema
const registerSchema = toTypedSchema(
z.object({
email: z.string().email(t('auth.validation.invalidEmail')),
password: z
.string()
.min(8, t('auth.validation.passwordMinLength'))
.regex(/[A-Z]/, t('auth.validation.passwordUppercase'))
.regex(/[a-z]/, t('auth.validation.passwordLowercase'))
.regex(/[0-9]/, t('auth.validation.passwordNumber')),
firstName: z.string().min(2, t('auth.validation.firstNameMinLength')),
lastName: z.string().min(2, t('auth.validation.lastNameMinLength')),
})
)
// Form state
const { handleSubmit, isSubmitting } = useForm({
validationSchema: registerSchema,
})
// Success/error state
const submitError = ref<string | null>(null)
const submitSuccess = ref(false)
// Form submit handler
const onSubmit = handleSubmit(async (values) => {
submitError.value = null
submitSuccess.value = false
try {
const result = await register(values)
submitSuccess.value = true
// Show success message for 3 seconds, then switch to login tab
setTimeout(() => {
navigateTo('/auth?tab=login')
}, 3000)
} catch (error: any) {
console.error('Registration error:', error)
if (error.status === 409) {
submitError.value = t('auth.emailAlreadyRegistered')
} else {
submitError.value = error.data?.message || t('auth.registrationError')
}
}
})
</script>
<template>
<form @submit="onSubmit" class="space-y-4">
<!-- Success Alert -->
<Alert v-if="submitSuccess" variant="default" class="border-green-500">
<CheckCircle class="h-4 w-4 text-green-500" />
<AlertTitle>{{ t('auth.registrationSuccess') }}</AlertTitle>
<AlertDescription>
{{ t('auth.registrationSuccessMessage') }}
</AlertDescription>
</Alert>
<!-- Error Alert -->
<Alert v-if="submitError" variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>{{ submitError }}</AlertDescription>
</Alert>
<!-- First Name -->
<FormField v-slot="{ componentField }" name="firstName">
<FormItem>
<FormLabel>{{ t('auth.firstName') }}</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="t('auth.firstNamePlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Last Name -->
<FormField v-slot="{ componentField }" name="lastName">
<FormItem>
<FormLabel>{{ t('auth.lastName') }}</FormLabel>
<FormControl>
<Input type="text" :placeholder="t('auth.lastNamePlaceholder')" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Email -->
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>{{ t('auth.email') }}</FormLabel>
<FormControl>
<Input type="email" :placeholder="t('auth.emailPlaceholder')" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Password -->
<FormField v-slot="{ componentField }" name="password">
<FormItem>
<FormLabel>{{ t('auth.password') }}</FormLabel>
<FormControl>
<Input
type="password"
:placeholder="t('auth.passwordPlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
{{ t('auth.passwordRequirements') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit Button -->
<Button type="submit" class="w-full" :disabled="isSubmitting || submitSuccess">
<Loader2 v-if="isSubmitting" class="mr-2 h-4 w-4 animate-spin" />
{{ isSubmitting ? t('auth.registering') : t('auth.registerButton') }}
</Button>
<!-- Terms & Privacy -->
<p class="text-xs text-muted-foreground text-center">
{{ t('auth.termsAgreement') }}
<a href="/datenschutz" class="underline hover:text-primary">
{{ t('auth.privacyPolicy') }}
</a>
{{ t('auth.and') }}
<a href="/agb" class="underline hover:text-primary">
{{ t('auth.termsOfService') }}
</a>
</p>
</form>
</template>
Middleware
1. Auth Route Protection
File: middleware/auth.ts
// middleware/auth.ts
/**
* Authentication middleware
*
* Protects routes from unauthenticated access
*
* Usage in pages:
*
* definePageMeta({
* middleware: 'auth'
* })
*/
export default defineNuxtRouteMiddleware(async (to, from) => {
const { loggedIn } = useUserSession()
// Not logged in - redirect to auth page
if (!loggedIn.value) {
// Store intended destination for post-login redirect
useCookie('redirect_after_login', {
maxAge: 600, // 10 minutes
path: '/',
}).value = to.fullPath
return navigateTo('/auth')
}
})
Usage in pages:
<!-- pages/profile.vue -->
<script setup lang="ts">
definePageMeta({
middleware: 'auth', // Require authentication
})
const { user } = useAuth()
</script>
<template>
<div>
<h1>Welcome, {{ user.firstName }}!</h1>
</div>
</template>
2. Rate Limiting Middleware
File: server/middleware/rate-limit.ts
// server/middleware/rate-limit.ts
/**
* Rate limiting middleware for auth endpoints
*
* Prevents brute force attacks on login/registration
*
* Limits:
* - /api/auth/login: 5 attempts per 15 minutes per IP
* - /api/auth/register: 3 attempts per hour per IP
*/
interface RateLimitEntry {
count: number
resetAt: number
}
// In-memory rate limit store (use Redis in production!)
const rateLimitStore = new Map<string, RateLimitEntry>()
// Clean up expired entries every 5 minutes
setInterval(
() => {
const now = Date.now()
for (const [key, entry] of rateLimitStore.entries()) {
if (entry.resetAt < now) {
rateLimitStore.delete(key)
}
}
},
5 * 60 * 1000
)
export default defineEventHandler((event) => {
const path = event.path
// Only apply to auth endpoints
if (!path.startsWith('/api/auth/')) {
return
}
// Get client IP
const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
// Define rate limits per endpoint
const limits: Record<string, { maxAttempts: number; windowMs: number }> = {
'/api/auth/login': { maxAttempts: 5, windowMs: 15 * 60 * 1000 }, // 5 per 15min
'/api/auth/register': { maxAttempts: 3, windowMs: 60 * 60 * 1000 }, // 3 per hour
}
const limit = limits[path]
if (!limit) {
return // No rate limit for this endpoint
}
// Check rate limit
const key = `${ip}:${path}`
const now = Date.now()
const entry = rateLimitStore.get(key)
if (!entry || entry.resetAt < now) {
// First attempt or window expired - reset counter
rateLimitStore.set(key, {
count: 1,
resetAt: now + limit.windowMs,
})
return
}
// Increment counter
entry.count++
if (entry.count > limit.maxAttempts) {
// Rate limit exceeded
const retryAfter = Math.ceil((entry.resetAt - now) / 1000)
setResponseStatus(event, 429)
setResponseHeader(event, 'Retry-After', retryAfter.toString())
throw createError({
statusCode: 429,
statusMessage: 'Too many requests',
data: {
retryAfter,
message: `Too many attempts. Please try again in ${retryAfter} seconds.`,
},
})
}
})
Production Note: Use Redis for rate limiting in production:
// Alternative: Redis-based rate limiting
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
async function checkRateLimit(key: string, maxAttempts: number, windowMs: number) {
const count = await redis.incr(key)
if (count === 1) {
await redis.pexpire(key, windowMs)
}
return count <= maxAttempts
}
i18n Translations
German Translations
File: locales/de.json
{
"auth": {
"welcome": "Willkommen",
"subtitle": "Melden Sie sich an oder erstellen Sie ein Konto",
"login": "Anmelden",
"register": "Registrieren",
"loginTitle": "Anmelden",
"loginDescription": "Melden Sie sich mit Ihrer E-Mail-Adresse an",
"loginButton": "Anmelden",
"loggingIn": "Wird angemeldet...",
"loginInfo": "Sie werden zur sicheren Anmeldeseite weitergeleitet",
"loginError": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"registerTitle": "Konto erstellen",
"registerDescription": "Erstellen Sie ein neues experimenta-Konto",
"registerButton": "Konto erstellen",
"registering": "Wird registriert...",
"registrationSuccess": "Registrierung erfolgreich!",
"registrationSuccessMessage": "Bitte bestätigen Sie Ihre E-Mail-Adresse über den Link, den wir Ihnen gesendet haben.",
"registrationError": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"emailAlreadyRegistered": "Diese E-Mail-Adresse ist bereits registriert.",
"error": "Fehler",
"email": "E-Mail-Adresse",
"emailPlaceholder": "ihre.email@beispiel.de",
"password": "Passwort",
"passwordPlaceholder": "Mindestens 8 Zeichen",
"passwordRequirements": "Mindestens 8 Zeichen, Groß-/Kleinbuchstaben und eine Zahl",
"firstName": "Vorname",
"firstNamePlaceholder": "Max",
"lastName": "Nachname",
"lastNamePlaceholder": "Mustermann",
"termsAgreement": "Mit der Registrierung stimmen Sie unserer",
"privacyPolicy": "Datenschutzerklärung",
"and": "und den",
"termsOfService": "Nutzungsbedingungen",
"validation": {
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
"passwordUppercase": "Das Passwort muss mindestens einen Großbuchstaben enthalten",
"passwordLowercase": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten",
"passwordNumber": "Das Passwort muss mindestens eine Zahl enthalten",
"firstNameMinLength": "Der Vorname muss mindestens 2 Zeichen lang sein",
"lastNameMinLength": "Der Nachname muss mindestens 2 Zeichen lang sein"
}
}
}
English Translations
File: locales/en.json
{
"auth": {
"welcome": "Welcome",
"subtitle": "Sign in or create an account",
"login": "Sign In",
"register": "Sign Up",
"loginTitle": "Sign In",
"loginDescription": "Sign in with your email address",
"loginButton": "Sign In",
"loggingIn": "Signing in...",
"loginInfo": "You will be redirected to our secure login page",
"loginError": "Login failed. Please try again.",
"registerTitle": "Create Account",
"registerDescription": "Create a new experimenta account",
"registerButton": "Create Account",
"registering": "Creating account...",
"registrationSuccess": "Registration successful!",
"registrationSuccessMessage": "Please verify your email address using the link we sent you.",
"registrationError": "Registration failed. Please try again.",
"emailAlreadyRegistered": "This email address is already registered.",
"error": "Error",
"email": "Email Address",
"emailPlaceholder": "your.email@example.com",
"password": "Password",
"passwordPlaceholder": "At least 8 characters",
"passwordRequirements": "At least 8 characters, upper/lowercase letters and a number",
"firstName": "First Name",
"firstNamePlaceholder": "John",
"lastName": "Last Name",
"lastNamePlaceholder": "Doe",
"termsAgreement": "By registering, you agree to our",
"privacyPolicy": "Privacy Policy",
"and": "and",
"termsOfService": "Terms of Service",
"validation": {
"invalidEmail": "Please enter a valid email address",
"passwordMinLength": "Password must be at least 8 characters",
"passwordUppercase": "Password must contain at least one uppercase letter",
"passwordLowercase": "Password must contain at least one lowercase letter",
"passwordNumber": "Password must contain at least one number",
"firstNameMinLength": "First name must be at least 2 characters",
"lastNameMinLength": "Last name must be at least 2 characters"
}
}
}
Security Best Practices
Security Checklist
- PKCE implemented - Prevents authorization code interception
- State parameter - CSRF protection for OAuth2 callback
- HTTP-only cookies - Session cookie not accessible via JavaScript
- Secure flag - Cookies only transmitted over HTTPS (production)
- SameSite=Lax - Prevents CSRF attacks
- Encrypted sessions - AES-256-GCM via nuxt-auth-utils
- JWT validation - Signature, expiry, issuer, audience checks
- Rate limiting - Prevents brute force attacks
- Input validation - Zod schemas on all user inputs
- Short-lived temporary cookies - PKCE verifier, state (5min TTL)
- HTTPS mandatory - All auth flows over HTTPS
- Secrets in environment variables - Never hardcoded
- Session expiration - 30 days with encrypted storage
Threat Model & Mitigations
| Threat | Mitigation |
|---|---|
| Authorization Code Interception | PKCE (code_verifier + code_challenge) |
| CSRF on OAuth callback | State parameter validation |
| XSS attacks | HTTP-only cookies, input sanitization |
| Session hijacking | Secure + SameSite cookies, HTTPS only |
| Token forgery | JWT signature validation with JWKS |
| Brute force login | Rate limiting (5 attempts / 15min) |
| Registration spam | Rate limiting (3 attempts / hour) |
| Man-in-the-middle | HTTPS enforcement, HSTS headers |
Testing Strategy
Unit Tests
Example: PKCE Utilities Test
// server/utils/pkce.test.ts
import { describe, it, expect } from 'vitest'
import { generateCodeVerifier, generateCodeChallenge, generatePKCE } from './pkce'
describe('PKCE Utilities', () => {
it('generates code verifier with correct length', () => {
const verifier = generateCodeVerifier(64)
expect(verifier).toHaveLength(86) // Base64URL encoding adds ~33% length
})
it('generates consistent code challenge for same verifier', async () => {
const verifier = 'test-verifier-123'
const challenge1 = await generateCodeChallenge(verifier)
const challenge2 = await generateCodeChallenge(verifier)
expect(challenge1).toBe(challenge2)
})
it('generates different challenges for different verifiers', async () => {
const challenge1 = await generateCodeChallenge('verifier-1')
const challenge2 = await generateCodeChallenge('verifier-2')
expect(challenge1).not.toBe(challenge2)
})
it('generates PKCE pair with verifier and challenge', async () => {
const { verifier, challenge } = await generatePKCE()
expect(verifier).toBeDefined()
expect(challenge).toBeDefined()
expect(typeof verifier).toBe('string')
expect(typeof challenge).toBe('string')
})
})
Integration Tests
Example: Login Flow Test
// tests/integration/auth-flow.test.ts
import { describe, it, expect, vi } from 'vitest'
import { setup, $fetch } from '@nuxt/test-utils'
describe('Auth Flow Integration', () => {
await setup({
// Test configuration
})
it('initiates OAuth2 flow on login', async () => {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: { email: 'test@example.com' },
})
expect(response.redirectUrl).toContain('cidaas.de')
expect(response.redirectUrl).toContain('code_challenge')
expect(response.redirectUrl).toContain('state')
})
it('validates state parameter in callback', async () => {
// Mock invalid state
await expect($fetch('/api/auth/callback?code=abc&state=invalid')).rejects.toThrow(
'Invalid state parameter'
)
})
it('creates user on first login', async () => {
// Mock Cidaas responses
vi.mock('~/server/utils/cidaas', () => ({
exchangeCodeForToken: vi.fn().mockResolvedValue({
access_token: 'mock-token',
id_token: 'mock-id-token',
}),
fetchUserInfo: vi.fn().mockResolvedValue({
sub: 'new-user-123',
email: 'newuser@example.com',
given_name: 'New',
family_name: 'User',
}),
}))
// Test callback creates user
// ... (test implementation)
})
})
E2E Tests
Example: Playwright E2E Test
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication Flow', () => {
test('user can login successfully', async ({ page }) => {
// Navigate to auth page
await page.goto('/auth')
// Enter email
await page.fill('input[type="email"]', 'test@example.com')
// Click login button
await page.click('button[type="submit"]')
// Should redirect to Cidaas
await expect(page).toHaveURL(/cidaas\.de/)
// Fill Cidaas login form (on Cidaas page)
await page.fill('input[name="username"]', 'test@example.com')
await page.fill('input[name="password"]', 'Test123!')
await page.click('button[type="submit"]')
// Should redirect back to our app
await expect(page).toHaveURL('/')
// Should show user menu
await expect(page.locator('text=Profil')).toBeVisible()
})
test('user can register new account', async ({ page }) => {
await page.goto('/auth?tab=register')
await page.fill('input[name="firstName"]', 'Max')
await page.fill('input[name="lastName"]', 'Mustermann')
await page.fill('input[name="email"]', 'max@example.com')
await page.fill('input[name="password"]', 'SecurePassword123!')
await page.click('button[type="submit"]')
// Should show success message
await expect(page.locator('text=Registrierung erfolgreich')).toBeVisible()
})
test('protected route redirects to login', async ({ page }) => {
// Visit protected page while logged out
await page.goto('/profile')
// Should redirect to auth page
await expect(page).toHaveURL('/auth')
})
test('user can logout', async ({ page, context }) => {
// Assume user is logged in (set session cookie)
// ... setup logged-in state
await page.goto('/')
await page.click('button:has-text("Abmelden")')
// Should redirect to homepage
await expect(page).toHaveURL('/')
// Session cookie should be cleared
const cookies = await context.cookies()
const sessionCookie = cookies.find((c) => c.name === 'experimenta-session')
expect(sessionCookie).toBeUndefined()
})
})
Troubleshooting Guide
Common Errors
1. "Invalid state parameter"
Cause: State cookie expired or CSRF attack
Solution:
- Ensure cookies are enabled
- Check that cookie domain matches application domain
- Increase state cookie TTL if needed (currently 5 minutes)
2. "PKCE verifier not found"
Cause: PKCE verifier cookie expired before callback
Solution:
- Ensure cookies are enabled
- User must complete OAuth2 flow within 5 minutes
- Check cookie SameSite settings
3. "JWT verification failed"
Cause: Invalid ID token signature or expired token
Solution:
- Verify CIDAAS_JWKS_URL is correct
- Check system clock is synchronized (JWT exp check)
- Verify CIDAAS_ISSUER and CIDAAS_CLIENT_ID match token claims
4. "Token exchange failed"
Cause: Invalid authorization code or client credentials
Solution:
- Verify CIDAAS_CLIENT_ID and CIDAAS_CLIENT_SECRET
- Ensure redirect URI matches exactly (no trailing slash!)
- Check Cidaas admin panel configuration
5. "Too many requests (429)"
Cause: Rate limit exceeded
Solution:
- Wait for rate limit window to expire
- Increase rate limit thresholds if legitimate traffic
- Implement Redis-based rate limiting for production
Debug Tips
1. Enable detailed logging:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
logLevel: 'debug', // Show all logs
},
})
2. Inspect session cookie:
// In browser console
document.cookie.split(';').find((c) => c.includes('experimenta-session'))
3. Decode JWT (client-side):
// In browser console
const idToken = 'eyJ...' // From network inspector
const payload = JSON.parse(atob(idToken.split('.')[1]))
console.log(payload)
4. Test OAuth2 flow manually:
# 1. Generate PKCE challenge
CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '=' | tr '+/' '-_')
CODE_CHALLENGE=$(echo -n $CODE_VERIFIER | openssl dgst -binary -sha256 | base64 | tr -d '=' | tr '+/' '-_')
# 2. Build authorization URL
https://experimenta.cidaas.de/authz-srv/authz?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:3000/api/auth/callback&response_type=code&scope=openid%20profile%20email&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=test-state
Production Deployment
Environment Variables (Production)
# .env.production
CIDAAS_CLIENT_ID=<production-client-id>
CIDAAS_CLIENT_SECRET=<production-client-secret>
CIDAAS_ISSUER=https://experimenta.cidaas.de
CIDAAS_AUTHORIZE_URL=https://experimenta.cidaas.de/authz-srv/authz
CIDAAS_TOKEN_URL=https://experimenta.cidaas.de/token-srv/token
CIDAAS_USERINFO_URL=https://experimenta.cidaas.de/users-srv/userinfo
CIDAAS_JWKS_URL=https://experimenta.cidaas.de/.well-known/jwks.json
CIDAAS_REDIRECT_URI=https://my.experimenta.science/api/auth/callback
# Generate new secret for production!
NUXT_SESSION_PASSWORD=<64-char-hex-secret>
NODE_ENV=production
Docker Secrets
docker-compose.prod.yml:
services:
app:
secrets:
- cidaas_client_id
- cidaas_client_secret
- session_secret
environment:
- CIDAAS_CLIENT_ID=/run/secrets/cidaas_client_id
- CIDAAS_CLIENT_SECRET=/run/secrets/cidaas_client_secret
- NUXT_SESSION_PASSWORD=/run/secrets/session_secret
secrets:
cidaas_client_id:
file: ./secrets/cidaas_client_id.txt
cidaas_client_secret:
file: ./secrets/cidaas_client_secret.txt
session_secret:
file: ./secrets/session_secret.txt
HTTPS Configuration
Nginx Reverse Proxy:
server {
listen 443 ssl http2;
server_name my.experimenta.science;
ssl_certificate /etc/letsencrypt/live/my.experimenta.science/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/my.experimenta.science/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name my.experimenta.science;
return 301 https://$server_name$request_uri;
}
Conclusion
Diese vollständige Implementierungs-Dokumentation bietet:
✅ Produktionsreife Code-Beispiele für alle Auth-Komponenten ✅ Schritt-für-Schritt Setup-Anleitung von Cidaas-Konfiguration bis Deployment ✅ Sicherheits-Best-Practices (PKCE, State, Encrypted Sessions, Rate Limiting) ✅ Vollständige UI-Komponenten mit shadcn-nuxt + Tailwind ✅ i18n-Übersetzungen (Deutsch + Englisch) ✅ Testing-Strategie (Unit, Integration, E2E) ✅ Troubleshooting-Guide für häufige Probleme
Nächste Schritte:
- Cidaas Admin-Zugang erhalten
- OAuth2 Client in Cidaas konfigurieren
- Dependencies installieren (
nuxt-auth-utils,jose) - Code aus dieser Dokumentation implementieren
- Lokal testen mit
pnpm dev - E2E Tests schreiben und durchführen
- Production Deployment
Geschätzter Zeitaufwand: 3-4 Wochen von Setup bis Production-ready
Bei Fragen oder Problemen: siehe Troubleshooting Guide oder Cidaas Support kontaktieren.