Init
This commit is contained in:
903
CLAUDE.md
Normal file
903
CLAUDE.md
Normal file
@@ -0,0 +1,903 @@
|
||||
# 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
|
||||
- Documentation should be in German (PRD, README)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Nuxt 4 (Vue 3 Composition API + TypeScript)
|
||||
- **UI Library:** shadcn-nuxt (<https://nuxt.com/modules/shadcn>)
|
||||
- **Styling:** Tailwind CSS v4
|
||||
- **Database:** PostgreSQL 16+
|
||||
- **ORM:** Drizzle ORM (TypeScript-first, performant)
|
||||
- **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 <20> Push to `/api/erp/products` <20> PostgreSQL
|
||||
2. **Order Flow:** User checkout <20> PayPal payment <20> Order in DB <20> **X-API** <20> NAV ERP
|
||||
3. **Auth Flow:** User login <20> Cidaas OAuth <20> Create/update local user profile <20> 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
|
||||
|
||||
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
|
||||
|
||||
### 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 neuer 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
|
||||
<script setup lang="ts">
|
||||
const { user } = await useAuth()
|
||||
const { saveAddress } = useUserProfile()
|
||||
|
||||
// Pre-fill form if user has saved address
|
||||
const form = reactive({
|
||||
salutation: user.value?.salutation || '',
|
||||
dateOfBirth: user.value?.dateOfBirth || '',
|
||||
street: user.value?.street || '',
|
||||
postCode: user.value?.postCode || '',
|
||||
city: user.value?.city || '',
|
||||
countryCode: user.value?.countryCode || 'DE',
|
||||
// Pre-checked if user doesn't have address yet
|
||||
saveForFuture: !user.value?.street
|
||||
})
|
||||
|
||||
async function handleCheckout() {
|
||||
// Validate form
|
||||
const validated = await checkoutSchema.parseAsync(form)
|
||||
|
||||
// Save address to profile if checkbox is checked
|
||||
if (form.saveForFuture) {
|
||||
await saveAddress({
|
||||
salutation: validated.salutation,
|
||||
dateOfBirth: validated.dateOfBirth,
|
||||
street: validated.street,
|
||||
postCode: validated.postCode,
|
||||
city: validated.city,
|
||||
countryCode: validated.countryCode,
|
||||
})
|
||||
}
|
||||
|
||||
// Continue with payment
|
||||
await processPayment(validated)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleCheckout">
|
||||
<h2>{{ $t('checkout.billingAddress') }}</h2>
|
||||
|
||||
<!-- Address form fields -->
|
||||
<Input v-model="form.street" :label="$t('checkout.street')" required />
|
||||
<Input v-model="form.postCode" :label="$t('checkout.postCode')" required />
|
||||
<Input v-model="form.city" :label="$t('checkout.city')" required />
|
||||
|
||||
<!-- Save address checkbox -->
|
||||
<Checkbox
|
||||
v-model="form.saveForFuture"
|
||||
:label="$t('checkout.saveAddress')"
|
||||
/>
|
||||
|
||||
<Button type="submit">{{ $t('checkout.continue') }}</Button>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 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'
|
||||
|
||||
## Authentication Patterns
|
||||
|
||||
See [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) for complete Cidaas OAuth2 implementation guide.
|
||||
|
||||
### OAuth2 Login Flow Pattern
|
||||
|
||||
```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
|
||||
<!-- pages/profile.vue -->
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth', // Require authentication
|
||||
})
|
||||
|
||||
const { user } = useAuth()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Welcome, {{ user.firstName }}!</h1>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 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
|
||||
<!-- Client-side: Use session in components -->
|
||||
<script setup lang="ts">
|
||||
const { user, loggedIn } = useUserSession()
|
||||
|
||||
// user.value: { id, email, firstName, ... } or null
|
||||
// loggedIn.value: boolean
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loggedIn">
|
||||
<p>Welcome {{ user.firstName }}!</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<NuxtLink to="/auth">Login</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 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<string, RateLimitEntry>()
|
||||
|
||||
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
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Platform:** Docker containers on Hetzner Proxmox
|
||||
- **CI/CD:** GitLab pipelines (build <20> test <20> 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.
|
||||
Reference in New Issue
Block a user