# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview **my.experimenta.science** is an e-commerce platform for the experimenta Science Center. It allows visitors to purchase Makerspace annual passes (MVP) and extends their existing web shop. **Domain:** `my.experimenta.science` **Status:** In Planning / Initial Setup --- ## 📋 Task Management & Progress Tracking **WICHTIG:** Dieses Projekt nutzt ein strukturiertes Task-System im `tasks/` Ordner. ### Workflow für alle Implementierungen: **1. Vor Start einer Arbeitssession:** - Lies `tasks/00-PROGRESS.md` → Identifiziere aktuelle Phase - Öffne die relevante Phase-Datei (z.B. `tasks/03-authentication.md`) - Prüfe Dependencies der Phase (sind alle abhängigen Phasen abgeschlossen?) **2. Während der Implementierung:** - Arbeite Tasks sequenziell ab (von oben nach unten in der Phase-Datei) - Markiere abgeschlossene Tasks: `- [ ]` → `- [x]` - Aktualisiere Progress-Zeile in Phase-Datei: `Progress: X/Y tasks (Z%)` - Dokumentiere wichtige Entscheidungen im Notes-Bereich - Bei Blocker: Status auf 🚫 setzen, Grund dokumentieren **3. Nach jedem abgeschlossenen Task:** - Update Phase-Datei (Task checkbox + Progress-Zeile) - Update `tasks/00-PROGRESS.md`: - Aktualisiere Progress in "Quick Status" Tabelle - Aktualisiere "Current Work" Section (welche Phase, welcher Task, nächste Schritte) - Falls Phase abgeschlossen: Status auf ✅, Started/Completed Datum eintragen **4. Phase-Abschluss:** - Prüfe: Alle Acceptance Criteria erfüllt? - Setze Status in Phase-Datei auf ✅ Done - Setze Status in `00-PROGRESS.md` auf ✅ Done - Trage "Completed" Datum ein - **⚠️ Frage den Benutzer explizit, ob mit der nächsten Phase fortgefahren werden soll** - **Erst nach Bestätigung:** Identifiziere nächste Phase und starte mit Schritt 1 ### Niemals vergessen: - ⚠️ **Immer** `00-PROGRESS.md` aktualisieren nach jedem abgeschlossenen Task - ⚠️ **Immer** Phase-Datei und PROGRESS.md synchron halten - ⚠️ **Immer** bei Unterbrechung "Current Work" in PROGRESS.md dokumentieren - ⚠️ **Niemals** mehrere Phasen gleichzeitig bearbeiten (sequenziell arbeiten) **Siehe:** [`tasks/README.md`](./tasks/README.md) für vollständige Dokumentation des Task-Systems. --- ## Code Style Requirements - **All code comments MUST be written in English** - User-facing content (UI text, emails) should be in German - **User communication MUST use informal "Du" form** (not formal "Sie" form) - Examples: "Dein Konto", "Melde dich an", "Bestätige deine E-Mail" - This applies to all UI text, form labels, buttons, messages, and emails - Documentation should be in German (PRD, README) ## Tech Stack - **Framework:** Nuxt 4 (Vue 3 Composition API + TypeScript) - **UI Library:** shadcn-nuxt () - **Styling:** Tailwind CSS v4 - **Database:** PostgreSQL 16+ - **ORM:** Drizzle ORM (TypeScript-first, performant) - **Important:** Use `(table) => [...]` for indexes/constraints, NOT `(table) => ({...})` - **Queue System:** BullMQ (MIT License) - Async job processing - **In-Memory Store:** Redis 7 - Queue storage, sessions, caching - **Auth:** Cidaas (OIDC/OAuth2) - external platform - **Payment:** PayPal (MVP), more providers later - **i18n:** @nuxtjs/i18n - German (default) + English - **Validation:** Zod + VeeValidate - **State Management:** Pinia (minimal use, prefer composables) - **Testing:** Vitest (unit/integration), Playwright (E2E) - **Deployment:** Docker on Hetzner Proxmox - **CI/CD:** GitLab ## Architecture Principles ### Authentication & User Management **Critical:** Cidaas is **only** for authentication (login/registration). User profiles and roles are stored in the local PostgreSQL database. - Cidaas provides: OAuth2 authentication, user identity - Local DB stores: User profile, roles, preferences, purchase history - Custom UI for login/registration (not Cidaas hosted pages) - Link users via `experimenta_id` field ### Data Flow 1. **Product Sync:** NAV ERP � Push to `/api/erp/products` � PostgreSQL 2. **Order Flow:** User checkout � PayPal payment � Order in DB � **X-API** � NAV ERP 3. **Auth Flow:** User login � Cidaas OAuth � Create/update local user profile � Session ### Key Integrations - **NAV ERP:** Microsoft Dynamics NAV - Push products/stock to our API - **X-API:** Order submission to NAV ERP via `/shopware/order` endpoint - Environments: x-api-{dev|stage|live}.experimenta.science - **Authentication:** HTTP Basic Auth (username + password) - Credentials stored in environment variables (never hardcoded) - Converts our JSON orders to SOAP for NAV ERP - Critical: Prices must be in cents (Integer), dates in ISO 8601 UTC - **Cidaas:** Auth platform by Widas (company requirement) ## MVP Scope (Phase 1) **In Scope:** - User registration and login (email-based via Cidaas) - Display Makerspace annual passes ("Makerspace-Jahreskarten") - Shopping cart functionality - Checkout process - PayPal payment integration - NAV ERP product sync (receive products via push) **Out of Scope (Post-MVP):** - User roles (Educator, Company) - MVP only supports "Privatperson" implicitly - Educator annual passes ("Pädagogische Jahreskarten") - Approval workflows - Experimenta tickets + Science Dome seat reservations - Lab courses for schools - Multi-payment providers ## Project Structure (Once Initialized) ``` / ├── app/ │ ├── components/ # Vue components (Nuxt 4 structure) │ │ └── ui/ # shadcn-nuxt components │ ├── layouts/ # Nuxt layouts │ ├── lib/ # Client-side utilities │ │ └── utils.ts # Shared utilities │ └── pages/ # File-based routing (Nuxt 4) ├── server/ │ ├── api/ # API routes (Nitro) │ │ ├── auth/ # Cidaas OAuth handlers │ │ ├── products/ # Product endpoints │ │ ├── cart/ # Shopping cart │ │ ├── orders/ # Order management │ │ ├── payment/ # PayPal integration │ │ └── erp/ # NAV ERP endpoints (API key protected) │ ├── database/ │ │ ├── schema.ts # Drizzle schema definitions │ │ └── migrations/ # DB migrations │ └── utils/ # Server-side utilities ├── components/ # Legacy components (to be migrated to app/) ├── composables/ # Vue composables ├── pages/ # Legacy pages (to be migrated to app/) ├── docs/ # Documentation │ ├── PRD.md # Product Requirements Document │ ├── TECH_STACK.md # Tech decisions & rationale │ └── ARCHITECTURE.md # System architecture └── docker-compose.yml # Docker setup ``` ## Database Schema See `docs/ARCHITECTURE.md` for full schema. Key tables: - `users` - User profiles (linked to Cidaas via `experimenta_id`) - **Includes billing address fields:** `salutation`, `date_of_birth`, `street`, `post_code`, `city`, `country_code` - Address fields are optional, filled during checkout or profile edit - Pre-fills checkout form for returning customers - `products` - Products synced from NAV ERP - `carts` / `cart_items` - Shopping cart - `orders` / `order_items` - Orders and line items - **`roles`** - Role definitions (private, educator, company) - **`user_roles`** - Many-to-Many User ↔ Roles (with approval workflow prepared for Phase 2/3) - **`product_role_visibility`** - Many-to-Many Product ↔ Roles (controls product visibility) **Role-based Visibility (MVP):** - Products are ONLY visible if they have `product_role_visibility` entries - Users ONLY see products matching their approved roles in `user_roles` - Opt-in visibility: No role assignment = invisible to everyone Use Drizzle ORM for all database operations. All tables use UUID primary keys. ## Development Commands (Once Set Up) ```bash # Install dependencies pnpm install # Development server pnpm dev # Build for production pnpm build # Run production build locally pnpm preview # Database migrations pnpm db:generate # Generate migrations from schema changes pnpm db:migrate # Apply migrations to database pnpm db:studio # Open Drizzle Studio (DB GUI) # Testing pnpm test # Run unit tests (Vitest) pnpm test:e2e # Run E2E tests (Playwright) pnpm test:coverage # Generate coverage report # Code quality pnpm lint # ESLint pnpm format # Prettier pnpm typecheck # TypeScript type checking ``` ## Development Tools & Testing ### Test Credentials (Cidaas Staging) **📖 Complete test credentials and testing guide:** [`docs/TESTING.md`](./docs/TESTING.md) **Important:** - Test credentials are documented in [`docs/TESTING.md`](./docs/TESTING.md) - ⚠️ **Only for staging environment** - NEVER use in production - Used by Playwright E2E tests and Vitest integration tests ### Playwright MCP Server Ein Playwright MCP Server ist installiert und verfügbar für: - **UI/UX Überprüfung:** Visuelle Validierung von Komponenten und Layouts - **Funktionale Tests:** Interaktive Tests während der Entwicklung - **Responsive Design Checks:** Überprüfung der Mobile-First Implementierung - **User Flow Testing:** Validierung kompletter Benutzer-Workflows (z.B. Checkout-Prozess) **Wann nutzen:** - Nach Implementierung oder Änderung von UI-Komponenten - Bei Layout-Anpassungen (besonders mobile vs. desktop) - Zum Testen von Formularen und Interaktionen - Zur Validierung des gesamten Checkout-Flows - Bei Integration von shadcn-nuxt Komponenten **Workflow:** 1. Starte den Dev-Server (`pnpm dev`) 2. Nutze Playwright MCP Tools um die Anwendung zu navigieren und zu testen 3. Validiere visuelle Darstellung und Funktionalität 4. Dokumentiere gefundene Issues oder bestätige erfolgreiche Implementierung ## Important Constraints 1. **Cidaas Custom UI:** We must implement custom login/registration forms in our app (not hosted by Cidaas). Use OIDC/OAuth2 flow with Cidaas as identity provider. 2. **Mobile-First:** All UI must be optimized for mobile devices first, then scale up to desktop. 3. **Corporate Design:** Use experimenta style guide for colors and fonts (configured in Tailwind). 4. **NAV ERP Integration:** Products are pushed to us (we don't pull). Implement robust endpoint with API key authentication and validation. 5. **No Roles in MVP:** Don't implement role selection or role-based features yet. All users are implicitly "Privatperson" (private individual). 6. **TypeScript Strict Mode:** Use strict TypeScript everywhere. All schemas defined with Zod. 7. **Bilingual Support:** App must support German (default) and English. Use @nuxtjs/i18n for all user-facing text. Routes: `/produkte` (de), `/en/products` (en). ## Security Considerations - Never commit secrets (`.env` is gitignored) - All API endpoints must validate input (use Zod schemas) - ERP endpoints must be protected with API key authentication - Use session-based auth (HTTP-only cookies) - Validate JWT tokens from Cidaas using `jose` library - Implement rate limiting on public endpoints ## Common Patterns ### API Route Example ```typescript // server/api/products/[id].get.ts export default defineEventHandler(async (event) => { // Validate path params with Zod const { id } = await getValidatedRouterParams( event, z.object({ id: z.string().uuid(), }).parse ) // Query with Drizzle const product = await db.query.products.findFirst({ where: eq(products.id, id), }) if (!product) { throw createError({ statusCode: 404, message: 'Product not found', }) } return product }) ``` ### Protected Route Example ```typescript // middleware/auth.ts export default defineNuxtRouteMiddleware(async (to, from) => { const { user } = await useAuth() if (!user.value) { return navigateTo('/login') } }) ``` ### Checkout with Saved Address Pattern ```typescript // pages/checkout.vue ``` ### X-API Order Transformation Pattern ```typescript // server/utils/xapi.ts // Transform order from DB to X-API format export function transformOrderToXAPI(order: Order, user: User) { return { shopPOSOrder: { documentType: 'Order', externalDocumentNo: order.orderNumber, salesChannel: 'Shop', shoppingCartCompletion: order.createdAt.toISOString(), visitType: 'Private', amountIncludingVAT: Math.round(order.totalAmount * 100), // EUR -> Cents! language: 'DEU', salesLine: order.items.map((item, index) => ({ type: 'Item', lineNo: String((index + 1) * 10000), // 10000, 20000, 30000... no: item.product.navProductId, quantity: item.quantity, unitPrice: Math.round(item.priceSnapshot * 100), // EUR -> Cents! vatPct: 7, visitorCategory: '120', // Annual pass holders ticketCode: generateUniqueTicketCode(), // Required for annual passes! annualPass: { salutationCode: mapSalutation(user.salutation), // 'HERR', 'FRAU', 'K_ANGABE' firstName: user.firstName, lastName: user.lastName, streetAndHouseNumber: user.address.street, postCode: user.address.postCode, city: user.address.city, countryCode: user.address.countryCode || 'DE', dateOfBirth: user.dateOfBirth, eMail: user.email, validFrom: new Date().toISOString().split('T')[0], // YYYY-MM-DD }, })), payment: [ { paymentEntryNo: 10000, amount: Math.round(order.totalAmount * 100), // EUR -> Cents! paymentType: 'Shop PayPal', createdOn: order.paymentCompletedAt.toISOString(), reference: order.paymentId, paymentId: order.paymentId, }, ], personContact: { experimentaAccountID: user.experimentaId, // Our experimenta_id! eMail: user.email, salutationCode: mapSalutation(user.salutation), firstName: user.firstName, lastName: user.lastName, streetAndHouseNumber: order.billingAddress.street, postCode: order.billingAddress.postCode, city: order.billingAddress.city, countryCode: order.billingAddress.countryCode || 'DE', phoneNumber: user.phone || '', guest: false, }, }, } } // Submit to X-API with retry logic and Basic Auth export async function submitOrderToXAPI(payload: XAPIOrderPayload) { const config = useRuntimeConfig() const maxRetries = 3 const retryDelays = [1000, 3000, 9000] // Exponential backoff // Prepare Basic Auth header const authString = Buffer.from(`${config.xApiUsername}:${config.xApiPassword}`).toString('base64') for (let attempt = 0; attempt < maxRetries; attempt++) { try { const response = await fetch(`${config.xApiBaseUrl}/shopware/order`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Basic ${authString}`, }, body: JSON.stringify(payload), }) if (!response.ok) { const errorText = await response.text() throw new Error(`X-API error ${response.status}: ${errorText}`) } return await response.json() } catch (error) { if (attempt === maxRetries - 1) throw error await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt])) } } } ``` **Critical transformations:** - Prices: EUR (Decimal) → Cents (Integer) using `Math.round(price * 100)` - Dates: JavaScript Date → ISO 8601 UTC using `.toISOString()` - Line numbers: Sequential multiples of 10000 - Salutation: 'male' → 'HERR', 'female' → 'FRAU', other → 'K_ANGABE' ## Role-based Product Visibility Patterns (MVP) ### Auto-Assignment of 'private' Role (MVP) **Requirement**: All users must have at least one role to see products. New users automatically receive the `private` role on first login. **Implementation** in `server/api/auth/login.post.ts`: ```typescript 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() user = newUser // Auto-assign 'private' role on first login await assignRoleToUser(newUser.id, 'private', { adminNotes: 'Auto-assigned on first login', }) } else { // Update last login timestamp await db.update(users) .set({ updatedAt: new Date() }) .where(eq(users.id, user.id)) // Safety check: If existing user has no roles, assign 'private' role const userRoleCodes = await getUserApprovedRoleCodes(user.id) if (userRoleCodes.length === 0) { await assignRoleToUser(user.id, 'private', { adminNotes: 'Auto-assigned for existing user without roles', }) } } ``` **Key Points:** - ✅ New users → `private` role automatically assigned - ✅ Existing users without roles → `private` role assigned (safety check) - ✅ Status always `approved` (no approval workflow in MVP) - ✅ Admin notes track auto-assignment source ### Role-based Filtering Pattern ```typescript // Server-side: GET /api/products - Filter products by user roles import { getVisibleProductIdsForUser } from '~/server/utils/roles' export default defineEventHandler(async (event) => { const { user } = await getUserSession(event) // MVP: Unauthenticated users see NO products (opt-in visibility) if (!user) { return [] } // Get product IDs visible to this user (based on approved roles) const visibleProductIds = await getVisibleProductIdsForUser(user.id) if (visibleProductIds.length === 0) { return [] } // Fetch products with role-based filtering const products = await db.query.products.findMany({ where: and( eq(products.active, true), inArray(products.id, visibleProductIds) // Role filter ) }) return products }) ``` ### ERP Category to Role Mapping Pattern ```typescript // Auto-assign roles when importing products from NAV ERP import { assignRolesToProductByCategory } from '~/server/utils/roles' // server/api/erp/products.post.ts export default defineEventHandler(async (event) => { const { navProductId, category, ...productData } = await readBody(event) // 1. Create/update product const [product] = await db.insert(products) .values({ navProductId, category, ...productData }) .onConflictDoUpdate({ target: products.navProductId, set: productData }) .returning() // 2. Auto-assign roles based on category mapping await assignRolesToProductByCategory(product.id, category) return product }) ``` **Category Mapping:** ```typescript // Defined in server/utils/roles.ts const categoryRoleMapping = { 'makerspace-annual-pass': ['private', 'educator'], 'annual-pass': ['private'], 'educator-annual-pass': ['educator'], 'company-annual-pass': ['company'] } ``` ### Get User Approved Roles Pattern ```typescript // server/utils/roles.ts - Utility functions import { getUserApprovedRoles, getUserApprovedRoleCodes } from '~/server/utils/roles' // Get full role objects const roles = await getUserApprovedRoles(userId) // => [{ id: '...', code: 'private', displayName: 'Privatperson', ... }] // Get just role codes (lightweight) const roleCodes = await getUserApprovedRoleCodes(userId) // => ['private', 'educator'] ``` ### Manual Role Assignment Pattern (MVP) ```typescript // server/utils/roles.ts - For manual role assignment import { assignRoleToUser } from '~/server/utils/roles' // Assign role to user (MVP: always approved) await assignRoleToUser(userId, 'private') // With optional metadata (prepared for Phase 2/3) await assignRoleToUser(userId, 'educator', { organizationName: 'Hölderlin-Gymnasium Heilbronn', adminNotes: 'Verified via school email domain' }) ``` ### Check Product Visibility Pattern ```typescript // server/utils/roles.ts - Check if specific product is visible import { isProductVisibleForUser } from '~/server/utils/roles' const canView = await isProductVisibleForUser(productId, userId) // => true if user has approved role matching product's role assignments // => false if product has no role assignments (opt-in!) or user lacks role ``` ### JSONB Status History Pattern (Phase 2/3 prepared) ```typescript // user_roles.statusHistory stores complete audit trail const historyEntry = { status: 'approved', organizationName: 'Hölderlin-Gymnasium', adminNotes: 'Verified via Lehrerausweis', changedAt: new Date().toISOString(), changedBy: adminUserId // null for auto-assignments } await db.update(userRoles) .set({ status: 'approved', statusHistory: sql`${userRoles.statusHistory} || ${JSON.stringify(historyEntry)}::jsonb` }) .where(eq(userRoles.id, userRoleId)) ``` **Important Visibility Rules (MVP):** - Unauthenticated users → See NO products - User without approved roles → See NO products - Product without role assignments → Visible to NO ONE (opt-in) - Product with role assignments → Visible only to users with matching approved role ## Authentication Patterns See [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) for complete Cidaas OAuth2 implementation guide. ### Current Implementation: Password Grant Flow (MVP) **Important:** The current implementation uses the **Resource Owner Password Credentials Grant** (OAuth2 Password Flow) instead of the Authorization Code Flow with PKCE. **Why Password Grant for MVP:** - ✅ **Simpler UX:** User stays in our app, no redirects to Cidaas - ✅ **Faster development:** Less complex flow, fewer endpoints needed - ✅ **Sufficient for MVP:** Private users logging in with email/password - ⚠️ **Trade-off:** Client app handles passwords directly (less secure than authorization code flow) - ⚠️ **Limitation:** Doesn't support SSO/Social logins (requires redirect flow) **Future Enhancement:** For Phase 2+, we may implement Authorization Code Flow with PKCE to support: - Social login (Google, Facebook, Apple) - Single Sign-On (SSO) for organizations - Better security (app never sees password) ### Password Grant Login Pattern (Current Implementation) ```typescript // app/composables/useAuth.ts - Client-side auth composable export function useAuth() { const { loggedIn, user, clear, fetch } = useUserSession() async function login(email: string, password: string) { // Direct login via Password Grant (no redirect) try { await $fetch('/api/auth/login', { method: 'POST', body: { email, password }, }) // Refresh session data await fetch() // Redirect to homepage or intended destination navigateTo('/') } catch (error) { // Handle login error (invalid credentials, etc.) throw error } } async function logout() { await $fetch('/api/auth/logout', { method: 'POST' }) await clear() navigateTo('/') } return { user, loggedIn, login, logout } } ``` **Server-side:** ```typescript // server/api/auth/login.post.ts - Password Grant login import { loginWithPassword, fetchUserInfo } from '~/server/utils/cidaas' import { z } from 'zod' const loginSchema = z.object({ email: z.string().email(), password: z.string().min(1), }) export default defineEventHandler(async (event) => { // 1. Validate input const body = await readBody(event) const { email, password } = loginSchema.parse(body) try { // 2. Authenticate with Cidaas via Password Grant const tokens = await loginWithPassword(email, password) // 3. Fetch user info from Cidaas const cidaasUser = await fetchUserInfo(tokens.access_token) // 4. Create/update user in local DB const db = useDatabase() let user = await db.query.users.findFirst({ where: eq(users.experimentaId, cidaasUser.sub), }) if (!user) { // First time login - create user const [newUser] = await db .insert(users) .values({ experimentaId: cidaasUser.sub, email: cidaasUser.email, firstName: cidaasUser.given_name || '', lastName: cidaasUser.family_name || '', }) .returning() user = newUser } else { // Update last login timestamp await db .update(users) .set({ updatedAt: new Date() }) .where(eq(users.id, user.id)) } // 5. Create encrypted session await setUserSession(event, { user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, }, }) return { success: true } } catch (error) { throw createError({ statusCode: 401, statusMessage: 'Invalid credentials', }) } }) ``` ### OAuth2 Authorization Code Flow Pattern (Future Enhancement) ```typescript // composables/useAuth.ts - Client-side auth composable export function useAuth() { const { loggedIn, user, clear, fetch } = useUserSession() async function login(email: string) { // Initiate OAuth2 Authorization Code Flow with PKCE const { redirectUrl } = await $fetch('/api/auth/login', { method: 'POST', body: { email }, }) // Redirect to Cidaas login page navigateTo(redirectUrl, { external: true }) } async function logout() { await $fetch('/api/auth/logout', { method: 'POST' }) await clear() navigateTo('/') } return { user, loggedIn, login, logout } } ``` **Server-side:** ```typescript // server/api/auth/login.post.ts - Initiate OAuth2 flow export default defineEventHandler(async (event) => { const { email } = await readBody(event) // 1. Generate PKCE challenge const { verifier, challenge } = await generatePKCE() // 2. Store PKCE verifier in cookie (5min TTL) setCookie(event, 'pkce_verifier', verifier, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 300, }) // 3. Generate state (CSRF protection) const state = generateState(32) setCookie(event, 'oauth_state', state, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 300, }) // 4. 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) return { redirectUrl: authUrl.toString() } }) ``` ### OAuth2 Callback Pattern ```typescript // server/api/auth/callback.get.ts - Handle OAuth2 callback export default defineEventHandler(async (event) => { const { code, state } = getQuery(event) // 1. Validate state (CSRF protection) const storedState = getCookie(event, 'oauth_state') if (!storedState || state !== storedState) { throw createError({ statusCode: 400, statusMessage: 'Invalid state parameter', }) } // 2. Retrieve PKCE verifier const verifier = getCookie(event, 'pkce_verifier') if (!verifier) { throw createError({ statusCode: 400, statusMessage: 'PKCE verifier not found', }) } try { // 3. Exchange code for tokens const tokens = await exchangeCodeForToken(code as string, verifier) // 4. Validate ID token const idTokenPayload = await verifyIdToken(tokens.id_token) // 5. Fetch user info from Cidaas const cidaasUser = await fetchUserInfo(tokens.access_token) // 6. Create/update user in local DB const db = useDatabase() let user = await db.query.users.findFirst({ where: eq(users.experimentaId, cidaasUser.sub), }) if (!user) { // First time login - create user const [newUser] = await db .insert(users) .values({ experimentaId: cidaasUser.sub, email: cidaasUser.email, firstName: cidaasUser.given_name, lastName: cidaasUser.family_name, }) .returning() user = newUser } else { // Update last login await db.update(users).set({ updatedAt: new Date() }).where(eq(users.id, user.id)) } // 7. Create encrypted session await setUserSession(event, { user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, }, }) // 8. Clean up temporary cookies deleteCookie(event, 'oauth_state') deleteCookie(event, 'pkce_verifier') // 9. Redirect to homepage return sendRedirect(event, '/') } catch (error) { console.error('OAuth callback error:', error) deleteCookie(event, 'oauth_state') deleteCookie(event, 'pkce_verifier') return sendRedirect(event, '/auth?error=login_failed') } }) ``` ### Protected Route Middleware Pattern ```typescript // middleware/auth.ts - Require authentication export default defineNuxtRouteMiddleware(async (to, from) => { const { loggedIn } = useUserSession() 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 ``` ### Protected API Endpoint Pattern ```typescript // server/api/orders/index.get.ts - Protected API route export default defineEventHandler(async (event) => { // Require authentication (throws 401 if not logged in) const { user } = await requireUserSession(event) // Fetch user's orders const db = useDatabase() const orders = await db.query.orders.findMany({ where: eq(orders.userId, user.id), orderBy: desc(orders.createdAt), }) return orders }) ``` ### User Registration Pattern ```typescript // server/api/auth/register.post.ts - Register via Cidaas import { z } from 'zod' const registerSchema = z.object({ email: z.string().email(), password: z.string().min(8).regex(/[A-Z]/).regex(/[a-z]/).regex(/[0-9]/), firstName: z.string().min(2), lastName: z.string().min(2), }) export default defineEventHandler(async (event) => { // Validate input const body = await readBody(event) const validated = registerSchema.parse(body) // Register user via Cidaas API const result = await registerUser({ email: validated.email, password: validated.password, given_name: validated.firstName, family_name: validated.lastName, locale: 'de', }) return { success: true, message: 'Registration successful. Please verify your email.', } }) ``` ### Session Management Pattern ```typescript // Server-side: Create session await setUserSession(event, { user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, }, loggedInAt: new Date().toISOString(), }) // Server-side: Check session const { user } = await getUserSession(event) if (!user) { throw createError({ statusCode: 401, message: 'Not authenticated' }) } // Server-side: Require session const { user } = await requireUserSession(event) // Throws 401 if not logged in // Server-side: Clear session await clearUserSession(event) ``` ```vue ``` ### JWT Validation Pattern ```typescript // server/utils/jwt.ts - Validate Cidaas ID tokens import { jwtVerify, createRemoteJWKSet } from 'jose' const config = useRuntimeConfig() const JWKS = createRemoteJWKSet(new URL(config.cidaas.jwksUrl)) export async function verifyIdToken(idToken: string) { try { const { payload } = await jwtVerify(idToken, JWKS, { issuer: config.cidaas.issuer, audience: config.cidaas.clientId, }) return payload } catch (error) { throw createError({ statusCode: 401, statusMessage: 'Invalid ID token', }) } } ``` ### Rate Limiting Pattern ```typescript // server/middleware/rate-limit.ts - Rate limit auth endpoints interface RateLimitEntry { count: number resetAt: number } const rateLimitStore = new Map() export default defineEventHandler((event) => { const path = event.path if (!path.startsWith('/api/auth/')) return const ip = getRequestIP(event) || 'unknown' const limits = { '/api/auth/login': { maxAttempts: 5, windowMs: 15 * 60 * 1000 }, '/api/auth/register': { maxAttempts: 3, windowMs: 60 * 60 * 1000 }, } const limit = limits[path] if (!limit) return const key = `${ip}:${path}` const now = Date.now() const entry = rateLimitStore.get(key) if (!entry || entry.resetAt < now) { rateLimitStore.set(key, { count: 1, resetAt: now + limit.windowMs }) return } entry.count++ if (entry.count > limit.maxAttempts) { throw createError({ statusCode: 429, statusMessage: 'Too many requests', }) } }) ``` ### Key Security Practices 1. **PKCE for OAuth2:** - Always use PKCE (Proof Key for Code Exchange) - Prevents authorization code interception attacks - Generate random verifier, compute SHA-256 challenge - Store verifier in temporary cookie (5min TTL) 2. **State Parameter:** - Generate random state for CSRF protection - Validate state on callback - Store in temporary cookie (5min TTL) 3. **Encrypted Sessions:** - Use `nuxt-auth-utils` for session management - Sessions encrypted with AES-256-GCM - HTTP-only, Secure, SameSite=Lax cookies - 30-day expiration (configurable) 4. **Rate Limiting:** - Login: 5 attempts / 15min per IP - Register: 3 attempts / hour per IP - Implement in middleware, not individual endpoints 5. **Input Validation:** - Always validate with Zod schemas - Validate on both client and server - Never trust client-side validation alone **Implementation Details:** For complete implementation with all utilities (PKCE generator, Cidaas API client, full endpoint code), see: - [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) - Complete implementation guide - [`docs/ARCHITECTURE.md#3.6`](./docs/ARCHITECTURE.md#36-authentication--authorization-cidaas-oauth2oidc) - Architecture diagrams and flows ## Documentation - **PRD:** See `docs/PRD.md` for complete requirements - **Tech Stack:** See `docs/TECH_STACK.md` for technology decisions - **Architecture:** See `docs/ARCHITECTURE.md` for system design and data flows - **experimenta Info:** See `docs/EXPERIMENTA_INFO.md` for company information (address, opening hours, legal links, contact details) ## Deployment - **Platform:** Docker containers on Hetzner Proxmox - **CI/CD:** GitLab pipelines (build � test � deploy) - **Environments:** Staging (auto-deploy) + Production (manual approval) - **Database Backups:** Daily automated backups with 7-day retention ## Future Phases After MVP, we'll add: - **Phase 2:** Educator roles + approval workflow + educator annual passes - **Phase 3:** Experimenta tickets + Science Dome seat reservations - **Phase 4:** Lab courses for schools Keep the codebase modular to accommodate these features without major refactoring. - Always use context7 when I need code generation, setup or configuration steps, or library/API documentation. This means you should automatically use the Context7 MCP tools to resolve library id and get library docs without me having to explicitly ask.