# 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 ```bash 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` - [ ] **3. PKCE aktivieren** - ✅ Require PKCE: `true` - Code Challenge Method: `S256` (SHA-256) - [ ] **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! - [ ] **5. Scopes konfigurieren** - ✅ `openid` (mandatory für OIDC) - ✅ `profile` (Name, etc.) - ✅ `email` (E-Mail-Adresse) - [ ] **6. Token Lifetimes** - Access Token Lifetime: `3600` Sekunden (1 Stunde) - Refresh Token Lifetime: `2592000` Sekunden (30 Tage) - ID Token Lifetime: `3600` Sekunden (1 Stunde) - [ ] **7. Client Credentials notieren** - **Client ID:** z.B. `abc123def456` (wird in .env hinterlegt) - **Client Secret:** z.B. `secret_xyz789` (wird in .env hinterlegt) - [ ] **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` --- ### 3. Environment Variables Erstelle `.env` Datei im Projekt-Root: ```bash # .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): ```bash # .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:** ```bash openssl rand -hex 32 ``` --- ### 4. Nuxt Configuration **nuxt.config.ts:** ```typescript // 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` ```typescript // 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 { // 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` ```typescript // 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 { 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 { 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 { 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` ```typescript // 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 | 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 { 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` ```typescript // 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` ```typescript // 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` ```typescript // 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` ```typescript // 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` ```typescript // 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` ```typescript // 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` ```vue ``` --- ### 3. Login Form Component **File:** `components/auth/LoginForm.vue` ```vue ``` --- ### 4. Register Form Component **File:** `components/auth/RegisterForm.vue` ```vue ``` --- ## Middleware ### 1. Auth Route Protection **File:** `middleware/auth.ts` ```typescript // 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:** ```vue ``` --- ### 2. Rate Limiting Middleware **File:** `server/middleware/rate-limit.ts` ```typescript // 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() // 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 = { '/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: ```typescript // 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` ```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` ```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 - [x] **PKCE implemented** - Prevents authorization code interception - [x] **State parameter** - CSRF protection for OAuth2 callback - [x] **HTTP-only cookies** - Session cookie not accessible via JavaScript - [x] **Secure flag** - Cookies only transmitted over HTTPS (production) - [x] **SameSite=Lax** - Prevents CSRF attacks - [x] **Encrypted sessions** - AES-256-GCM via nuxt-auth-utils - [x] **JWT validation** - Signature, expiry, issuer, audience checks - [x] **Rate limiting** - Prevents brute force attacks - [x] **Input validation** - Zod schemas on all user inputs - [x] **Short-lived temporary cookies** - PKCE verifier, state (5min TTL) - [x] **HTTPS mandatory** - All auth flows over HTTPS - [x] **Secrets in environment variables** - Never hardcoded - [x] **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** ```typescript // 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** ```typescript // 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** ```typescript // 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:** ```typescript // nuxt.config.ts export default defineNuxtConfig({ nitro: { logLevel: 'debug', // Show all logs }, }) ``` **2. Inspect session cookie:** ```typescript // In browser console document.cookie.split(';').find((c) => c.includes('experimenta-session')) ``` **3. Decode JWT (client-side):** ```javascript // 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:** ```bash # 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) ```bash # .env.production CIDAAS_CLIENT_ID= CIDAAS_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:** ```yaml 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:** ```nginx 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:** 1. Cidaas Admin-Zugang erhalten 2. OAuth2 Client in Cidaas konfigurieren 3. Dependencies installieren (`nuxt-auth-utils`, `jose`) 4. Code aus dieser Dokumentation implementieren 5. Lokal testen mit `pnpm dev` 6. E2E Tests schreiben und durchführen 7. Production Deployment **Geschätzter Zeitaufwand:** 3-4 Wochen von Setup bis Production-ready Bei Fragen oder Problemen: siehe Troubleshooting Guide oder Cidaas Support kontaktieren.