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:
128
server/api/auth/callback.get.ts
Normal file
128
server/api/auth/callback.get.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// 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 '../../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')
|
||||
}
|
||||
})
|
||||
73
server/api/auth/login.post.ts
Normal file
73
server/api/auth/login.post.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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(),
|
||||
}
|
||||
})
|
||||
24
server/api/auth/logout.post.ts
Normal file
24
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// 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,
|
||||
}
|
||||
})
|
||||
61
server/api/auth/me.get.ts
Normal file
61
server/api/auth/me.get.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// 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 '../../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,
|
||||
}
|
||||
})
|
||||
79
server/api/auth/register.post.ts
Normal file
79
server/api/auth/register.post.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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
|
||||
}
|
||||
})
|
||||
89
server/middleware/rate-limit.ts
Normal file
89
server/middleware/rate-limit.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// 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.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
268
server/utils/cidaas.ts
Normal file
268
server/utils/cidaas.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
112
server/utils/jwt.ts
Normal 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
90
server/utils/pkce.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user