- Changed `NUXT_SESSION_SECRET` to `NUXT_SESSION_PASSWORD` across configuration files and documentation to align with the usage in nuxt-auth-utils. - Updated related comments and documentation to reflect the new variable name, ensuring clarity for developers and users.
2393 lines
69 KiB
Markdown
2393 lines
69 KiB
Markdown
# 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<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`
|
|
|
|
```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<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`
|
|
|
|
```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<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`
|
|
|
|
```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
|
|
<!-- 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`
|
|
|
|
```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`
|
|
|
|
```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`
|
|
|
|
```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
|
|
<!-- 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`
|
|
|
|
```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<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:
|
|
|
|
```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=<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:**
|
|
|
|
```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.
|