Implement authentication phase with Cidaas OAuth2 integration

- Add authentication middleware to protect routes
- Create API endpoints for login, logout, registration, and user info
- Develop UI components for login and registration forms
- Integrate VeeValidate for form validation
- Update environment configuration for Cidaas settings
- Add i18n support for English and German languages
- Enhance Tailwind CSS for improved styling of auth components
- Document authentication flow and testing procedures
This commit is contained in:
Bastian Masanek
2025-10-31 11:44:48 +01:00
parent 749d5401c6
commit f8572c3386
57 changed files with 3357 additions and 132 deletions

268
server/utils/cidaas.ts Normal file
View File

@@ -0,0 +1,268 @@
// 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',
})
}
}

View File

@@ -32,3 +32,13 @@ const client = postgres(config.databaseUrl)
// Create Drizzle ORM instance with schema
export const db = drizzle(client, { schema })
/**
* Helper function to get database instance
* Used in event handlers for consistency with Nuxt patterns
*
* @returns Drizzle database instance
*/
export function useDatabase() {
return db
}

112
server/utils/jwt.ts Normal file
View File

@@ -0,0 +1,112 @@
// 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
}
}

90
server/utils/pkce.ts Normal file
View File

@@ -0,0 +1,90 @@
// 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)
}