@@ -102,4 +104,6 @@ definePageMeta({
+
+
diff --git a/server/api/auth/callback.get.ts b/server/api/auth/callback.get.ts
deleted file mode 100644
index 39bad18..0000000
--- a/server/api/auth/callback.get.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-// 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')
- }
-})
diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts
index 840f102..94f7dc6 100644
--- a/server/api/auth/login.post.ts
+++ b/server/api/auth/login.post.ts
@@ -3,71 +3,105 @@
/**
* POST /api/auth/login
*
- * Initiates OAuth2 Authorization Code Flow with PKCE
+ * Direct login with email and password (no OAuth2 redirect)
*
* Request body:
* {
- * "email": "user@example.com"
+ * "email": "user@example.com",
+ * "password": "SecureP@ssw0rd"
* }
*
* Response:
* {
- * "redirectUrl": "https://experimenta.cidaas.de/authz-srv/authz?..."
+ * "success": true
* }
*
- * Client should redirect user to redirectUrl
+ * Creates session cookie on success
*/
import { z } from 'zod'
+import { eq } from 'drizzle-orm'
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
+ password: z.string().min(1, 'Password is required'),
})
export default defineEventHandler(async (event) => {
// 1. Validate request body
const body = await readBody(event)
- const { email } = loginSchema.parse(body)
+ const { email, password } = loginSchema.parse(body)
- // 2. Generate PKCE challenge
- const { verifier, challenge } = await generatePKCE()
+ try {
+ // 2. Authenticate with Cidaas (Resource Owner Password Credentials flow)
+ const tokens = await loginWithPassword(email, password)
- // 3. Generate state for CSRF protection
- const state = generateState(32)
+ // 3. Validate ID token (JWT)
+ const idTokenPayload = await verifyIdToken(tokens.id_token)
- // 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: '/',
- })
+ // 4. Fetch user info from Cidaas
+ const cidaasUser = await fetchUserInfo(tokens.access_token)
- // 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: '/',
- })
+ // 5. Create/update user in local database
+ const db = useDatabase()
- // 6. Build Cidaas authorization URL
- const config = useRuntimeConfig()
- const authUrl = new URL(config.cidaas.authorizeUrl)
+ let user = await db.query.users.findFirst({
+ where: eq(users.experimentaId, cidaasUser.sub),
+ })
- 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
+ if (!user) {
+ // First time login - create user profile
+ const [newUser] = await db
+ .insert(users)
+ .values({
+ experimentaId: cidaasUser.sub,
+ email: cidaasUser.email,
+ firstName: cidaasUser.given_name || '',
+ lastName: cidaasUser.family_name || '',
+ })
+ .returning()
- // 7. Return redirect URL to client
- return {
- redirectUrl: authUrl.toString(),
+ user = newUser
+ } else {
+ // Update last login timestamp
+ await db
+ .update(users)
+ .set({
+ updatedAt: new Date(),
+ })
+ .where(eq(users.id, user.id))
+ }
+
+ // 6. Create encrypted session
+ await setUserSession(event, {
+ user: {
+ id: user.id,
+ experimentaId: user.experimentaId,
+ email: user.email,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ },
+ loggedInAt: new Date().toISOString(),
+ })
+
+ // 7. Return success
+ return {
+ success: true,
+ }
+ } catch (error: any) {
+ console.error('Login error:', error)
+
+ // Handle specific error cases
+ if (error.statusCode === 401) {
+ throw createError({
+ statusCode: 401,
+ statusMessage: 'Ungültige E-Mail-Adresse oder Passwort',
+ })
+ }
+
+ throw createError({
+ statusCode: 500,
+ statusMessage: 'Anmeldung fehlgeschlagen',
+ })
}
})
diff --git a/server/utils/cidaas.ts b/server/utils/cidaas.ts
index 5f31b63..222ab20 100644
--- a/server/utils/cidaas.ts
+++ b/server/utils/cidaas.ts
@@ -218,6 +218,74 @@ export async function registerUser(
}
}
+/**
+ * Login with username and password (Resource Owner Password Credentials Flow)
+ *
+ * @param email - User email address
+ * @param password - User password
+ * @returns Token response with access_token and id_token
+ * @throws H3Error if login fails
+ */
+export async function loginWithPassword(
+ email: string,
+ password: string
+): Promise
{
+ const config = useRuntimeConfig()
+
+ // Prepare token request with password grant
+ const params = new URLSearchParams({
+ grant_type: 'password',
+ username: email, // Cidaas uses 'username' field for email
+ password,
+ client_id: config.cidaas.clientId,
+ client_secret: config.cidaas.clientSecret,
+ scope: 'openid profile email', // Request OIDC scopes
+ })
+
+ 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 password login failed:', errorData)
+
+ // Handle specific errors
+ if (response.status === 401) {
+ throw createError({
+ statusCode: 401,
+ statusMessage: 'Invalid email or password',
+ })
+ }
+
+ throw createError({
+ statusCode: response.status,
+ statusMessage: 'Login failed',
+ data: errorData,
+ })
+ }
+
+ const tokens: CidaasTokenResponse = await response.json()
+ return tokens
+ } catch (error) {
+ console.error('Password login error:', error)
+
+ if ((error as H3Error).statusCode) {
+ throw error // Re-throw H3Error
+ }
+
+ throw createError({
+ statusCode: 500,
+ statusMessage: 'Failed to authenticate with Cidaas',
+ })
+ }
+}
+
/**
* Refresh access token using refresh token
*
diff --git a/server/utils/pkce.ts b/server/utils/pkce.ts
deleted file mode 100644
index db57352..0000000
--- a/server/utils/pkce.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-// 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 {
- // 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)
-}