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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user