You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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
  • 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:

# .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:

  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.