diff --git a/.agents/skills/constructive-cookie-csrf/SKILL.md b/.agents/skills/constructive-cookie-csrf/SKILL.md new file mode 100644 index 0000000..cabfaa9 --- /dev/null +++ b/.agents/skills/constructive-cookie-csrf/SKILL.md @@ -0,0 +1,122 @@ +# Constructive Cookie & CSRF + +Cookie-based authentication and CSRF protection for Constructive platform. + +## Status + +Per issue #749, cookie lifecycle and CSRF enforcement are **partially implemented**. + +| Component | Status | +|-----------|--------| +| CSRF middleware (`@constructive-io/csrf`) | βœ… Done | +| CSRF middleware wired to server | βœ… Done | +| Cookie setting on sign-in | 🚧 Not wired | +| Anonymous session creation | 🚧 Not implemented | +| CSRF + DB validation | 🚧 Not connected | + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Browser │────▢│ Auth Server │────▢│ PostgreSQL β”‚ +β”‚ β”‚ β”‚ (Express) β”‚ β”‚ (sessions) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ csrf_token cookie β”‚ session cookie + β”‚ X-CSRF-Token header β”‚ +``` + +## How CSRF Works + +**Double Submit Cookie pattern:** +1. Server sets `csrf_token` cookie (JS-readable) +2. Client reads cookie, sends value in `X-CSRF-Token` header +3. Server validates header matches cookie + +## Middleware Configuration + +In `server.ts`: + +```typescript +const csrf = createCsrfMiddleware({ + cookieOptions: { + httpOnly: false, // SPA clients read via document.cookie + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + }, +}); +``` + +The middleware: +- Sets `csrf_token` cookie on all responses +- Validates `X-CSRF-Token` header on mutations +- Skips validation for Bearer token auth +- Skips validation for anonymous requests (no session cookie) + +## Current Gap + +**Middleware layer** and **Database layer** are not connected: + +- Middleware sets `csrf_token` cookie +- Database `sign_in` function expects anonymous session in `sessions` table with matching `csrf_secret` +- No mechanism creates this anonymous session + +## Database Settings + +```sql +-- Check CSRF settings +SELECT require_csrf_for_auth, enable_cookie_auth +FROM "{schema}-auth-private".app_settings_auth; + +-- Disable CSRF requirement (workaround) +UPDATE "{schema}-auth-private".app_settings_auth +SET require_csrf_for_auth = false; + +-- Enable cookie auth +UPDATE "{schema}-auth-private".app_settings_auth +SET enable_cookie_auth = true; +``` + +## Client Usage (When Fully Implemented) + +```typescript +// Read CSRF token from cookie +function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + return null; +} + +// Include in requests +fetch('/graphql', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCookie('csrf_token'), + }, + body: JSON.stringify({ query: '...' }) +}); +``` + +## Recommendation + +Use **Bearer token auth** for now: +- Not vulnerable to CSRF (requires Authorization header) +- Fully working with cross-origin flow +- Store token in localStorage/sessionStorage + +## Key Files + +| File | Purpose | +|------|---------| +| `graphql/server/src/server.ts` | CSRF middleware wiring | +| `graphql/server/src/middleware/cookie.ts` | Cookie utilities | +| `packages/csrf/src/middleware.ts` | CSRF double-submit logic | + +## Related + +- Issue #749 - Cookie Lifecycle & CSRF Enforcement +- Issue #735 - Server-Side Auth Implementation Plan +- `constructive-oauth` - OAuth identity sign-in (uses Bearer tokens) diff --git a/.agents/skills/constructive-oauth/SKILL.md b/.agents/skills/constructive-oauth/SKILL.md new file mode 100644 index 0000000..4f7d6c0 --- /dev/null +++ b/.agents/skills/constructive-oauth/SKILL.md @@ -0,0 +1,346 @@ +# Constructive OAuth + +OAuth identity sign-in with cross-origin token exchange for Constructive platform. + +## Features + +| Feature | Status | +|---------|--------| +| GitHub OAuth | βœ… Ready | +| Google OAuth | βœ… Ready | +| Apple OAuth | βœ… Ready | +| Cross-origin token exchange | βœ… Ready | +| Multi-tenant support | βœ… Ready | +| Device tracking | βœ… Ready | +| Rate limiting | βœ… Ready | +| Remember me | βœ… Ready | + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Frontend │────▢│ Auth Server │────▢│ OAuth β”‚ +β”‚ (SPA/App) β”‚ β”‚ (Express) β”‚ β”‚ Provider β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ signInCrossOrigin β”‚ sign_in_identity (DB) + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ API │◀────│ PostgreSQL β”‚ +β”‚ Server β”‚ β”‚ (sessions) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Same-Origin vs Cross-Origin + +OAuth flow supports two credential modes depending on your deployment: + +| Mode | When to Use | Credential | +|------|-------------|------------| +| **Cross-Origin** | Frontend and auth server on different domains | Bearer token (Authorization header) | +| **Same-Origin** | Frontend and auth server on same domain | Cookie (HttpOnly session) | + +### Cross-Origin (Bearer Token) + +Use when frontend (`app.example.com`) and auth server (`auth.example.com`) are on different origins: + +1. OAuth callback returns a one-time `token` in URL +2. Frontend exchanges token via `signInCrossOrigin` mutation +3. Response contains `accessToken` for Bearer authentication +4. Store token in localStorage/sessionStorage +5. Include `Authorization: Bearer ` header on all API requests + +**Pros:** Works across any origin, no CSRF concerns +**Cons:** Token management, must handle expiry/refresh + +### Same-Origin (Cookie) + +Use when frontend and auth server share the same origin or are on subdomains with shared cookies: + +1. OAuth callback sets session cookie directly (HttpOnly) +2. No token exchange needed +3. Cookies sent automatically with `credentials: 'include'` +4. CSRF protection required (see `constructive-cookie-csrf`) + +**Pros:** Simpler flow, automatic credential handling +**Cons:** Requires CSRF protection, same-origin constraints + +**Note:** Cookie auth is partially implemented (see issue #749). Use cross-origin Bearer token flow for now. + +--- + +## Quick Start (Cross-Origin) + +### 1. Redirect to OAuth + +```typescript +const authEndpoint = 'http://auth.localhost:3000'; +const provider = 'github'; +const callbackUrl = encodeURIComponent(window.location.origin + '/auth/callback'); + +localStorage.setItem('oauth_auth_endpoint', authEndpoint); +window.location.href = `${authEndpoint}/auth/${provider}?redirect_uri=${callbackUrl}`; +``` + +### 2. Handle Callback + +```typescript +const params = new URLSearchParams(window.location.search); +const token = params.get('token'); +const error = params.get('error'); + +if (error) { + console.error('OAuth failed:', error); + return; +} +``` + +### 3. Exchange Token + +```typescript +const authEndpoint = localStorage.getItem('oauth_auth_endpoint'); + +const response = await fetch(`${authEndpoint}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + mutation SignInCrossOrigin($input: SignInCrossOriginInput!) { + signInCrossOrigin(input: $input) { + result { + id + userId + accessToken + accessTokenExpiresAt + isVerified + totpEnabled + } + } + } + `, + variables: { + input: { token, credentialKind: 'bearer' } + } + }) +}); + +const { accessToken, userId } = (await response.json()).data.signInCrossOrigin.result; +``` + +### 4. Use Access Token + +```typescript +fetch('http://api.localhost:3000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + }, + body: JSON.stringify({ query: '{ currentUserId }' }) +}); +``` + +## Server Configuration + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `OAUTH_SECRET` | **Yes** | Secret for signing OAuth state (CSRF protection) | + +**Required in all environments.** Server throws error if not configured. + +```bash +# Generate a secure secret +openssl rand -base64 32 + +# Set in environment +export OAUTH_SECRET="your-generated-secret" +``` + +--- + +## Configure Identity Provider + +### 1. Create OAuth App + +**GitHub:** https://github.com/settings/developers +- Callback URL: `http://auth.localhost:3000/auth/github/callback` + +**Google:** https://console.cloud.google.com/apis/credentials +- Redirect URI: `http://auth.localhost:3000/auth/google/callback` + +### 2. Configure Database + +```sql +-- Set client_id and enable +UPDATE "{schema}-auth-private".identity_providers +SET client_id = 'your-client-id', enabled = true +WHERE slug = 'github'; + +-- Set client secret +SELECT "{schema}-auth-private".rotate_identity_provider_secret( + 'provider-uuid', + 'your-client-secret' +); + +-- Enable identity sign-in +UPDATE "{schema}-auth-private".app_settings_auth +SET allow_identity_sign_in = true, + allow_identity_sign_up = true; +``` + +## Query Available Providers + +No authentication required: + +```graphql +query { + identityProviders { + nodes { + slug + kind + displayName + enabled + } + } +} +``` + +## Multi-Tenant + +Each tenant has its own auth endpoint and providers: + +```typescript +const tenantAuthEndpoint = `http://auth-${tenantSubdomain}.localhost:3000`; +``` + +Find tenant endpoint: +```sql +SELECT dom.subdomain, dom.domain +FROM services_public.domains dom +JOIN metaschema_public.database d ON dom.database_id = d.id +WHERE dom.subdomain LIKE 'auth-%'; +``` + +## References + +- `references/troubleshooting.md` - Common issues and fixes + +## Key Files + +| File | Purpose | +|------|---------| +| `graphql/server/src/middleware/oauth.ts` | OAuth callback handling | +| `graphql/server/src/middleware/auth.ts` | Authentication middleware | +| `packages/oauth/src/index.ts` | OAuth provider configuration | + +--- + +## Device Tracking + +OAuth sign-in automatically tracks user devices when `devices_module` is provisioned. + +### How It Works + +1. First login β†’ new device record created, `device_token` returned +2. Subsequent logins with `device_token` β†’ existing device reused, `last_seen_at` updated +3. Invalid/missing `device_token` β†’ new device created + +### Cross-Origin Device Token + +For cross-origin flows, device token is passed via OAuth state (not cookies): + +```typescript +// Include device_token in OAuth initiate URL +const deviceToken = localStorage.getItem('device_token'); +let oauthUrl = `${authEndpoint}/auth/${provider}?redirect_uri=${callbackUrl}`; +if (deviceToken) { + oauthUrl += `&device_token=${encodeURIComponent(deviceToken)}`; +} + +// Save device_token from callback +const params = new URLSearchParams(window.location.search); +const newDeviceToken = params.get('device_token'); +if (newDeviceToken) { + localStorage.setItem('device_token', newDeviceToken); +} +``` + +### Database Tables + +| Table | Purpose | +|-------|---------| +| `auth_user_devices` | Device records per user | +| `app_settings_device` | Device tracking settings | + +### Settings + +```sql +SELECT * FROM "{schema}_auth_private".app_settings_device; +-- enable_device_tracking: true +-- max_devices_per_user: 50 +-- device_trust_duration: 30 days +-- require_mfa_new_device: false +``` + +--- + +## Rate Limiting + +OAuth endpoints have two layers of rate limiting: + +### Layer 1: Express Middleware + +| Endpoint | Limit | Window | +|----------|-------|--------| +| `/:provider` (initiate) | 10 requests | 1 minute | +| `/:provider/callback` | 30 requests | 1 minute | + +Skipped in development/test environments (`NODE_ENV`). + +### Layer 2: Database (sign_in_identity) + +| Type | Limit | Window | Lockout | +|------|-------|--------|---------| +| IP only | 250 attempts | 15 min | 30 min | +| IP + User-Agent | 50 attempts | 15 min | 15 min | +| User/account | 10 attempts | 15 min | 15 min | +| Login | 5 failures | - | 15 min | + +### Why Two Layers? + +``` +Express rate limit β†’ Protects OAuth providers (GitHub/Google API limits) +Database rate limit β†’ Protects sign_in_identity (brute force prevention) +``` + +Express layer blocks requests before hitting OAuth provider APIs. + +--- + +## Remember Me + +OAuth sign-in uses `remember_me=true` by default, extending session duration. + +### Duration Settings + +```sql +SELECT remember_me_duration, default_session_duration +FROM "{schema}_auth_private".app_settings_auth; +-- remember_me_duration: 30 days +-- default_session_duration: 14 days +``` + +### Cookie and Session Sync + +Both cookie `Max-Age` and database session `expires_at` use `remember_me_duration` when enabled, ensuring they stay synchronized. + +--- + +## Related + +- Issue #735 - Server-Side Auth Implementation Plan +- `constructive-cookie-csrf` - Cookie auth and CSRF (partial) +- PR #1141 - OAuth identity sign-in implementation +- PR #1163 (constructive-db) - Device tracking unit tests diff --git a/.agents/skills/constructive-oauth/references/troubleshooting.md b/.agents/skills/constructive-oauth/references/troubleshooting.md new file mode 100644 index 0000000..d3fcd1e --- /dev/null +++ b/.agents/skills/constructive-oauth/references/troubleshooting.md @@ -0,0 +1,129 @@ +# OAuth Troubleshooting + +## OAuth Callback Errors + +### fetch failed + +**Symptom:** `CALLBACK_FAILED` with message `fetch failed` + +**Cause:** HTTP_PROXY/HTTPS_PROXY environment variables interfere with Node.js fetch. + +**Fix:** +```bash +HTTP_PROXY="" HTTPS_PROXY="" NO_PROXY="*" pnpm start +``` + +### PROVIDER_NOT_CONFIGURED + +**Symptom:** OAuth redirect fails immediately + +**Check:** +```sql +SELECT slug, client_id, client_secret_id, enabled +FROM "{schema}-auth-private".identity_providers +WHERE slug = 'github'; +``` + +**Requirements:** +- `client_id` set +- `client_secret_id` set (use `rotate_identity_provider_secret`) +- `enabled = true` + +### IDENTITY_SIGN_IN_DISABLED + +**Symptom:** OAuth succeeds but returns error + +**Fix:** +```sql +UPDATE "{schema}-auth-private".app_settings_auth +SET allow_identity_sign_in = true, + allow_identity_sign_up = true; +``` + +### GitHub "redirect_uri not associated" + +**Cause:** OAuth App callback URL mismatch + +**Fix:** Update GitHub OAuth App callback URL to: +``` +http://auth.localhost:3000/auth/github/callback +``` + +For tenants: +``` +http://auth-{subdomain}.localhost:3000/auth/github/callback +``` + +## Token Exchange Errors + +### Invalid token or token expired + +**Causes:** +1. Token already used (one-time only) +2. Token expired (5 min TTL) +3. Wrong endpoint (must match issuer) +4. JWT claims not persisted (server issue) + +**Debug:** +```sql +-- Check recent sessions +SELECT id, user_id, created_at +FROM "{schema}-auth-private".sessions +ORDER BY created_at DESC LIMIT 5; +``` + +### JWT Claims Not Persisted + +**Cause:** `set_config(..., true)` loses settings with connection pooling. + +**Fix in oauth.ts:** +```typescript +// WRONG +await pool.query(`SELECT set_config('jwt.claims.user_agent', $1, true)`, [ua]); + +// CORRECT - use dedicated client with session-level config +const client = await pool.connect(); +try { + await client.query(`SELECT set_config('jwt.claims.user_agent', $1, false)`, [ua]); + // use same client for sign_in_identity +} finally { + client.release(); +} +``` + +## Database Queries + +### Find Tenant Schema + +```sql +SELECT schema_name FROM information_schema.schemata +WHERE schema_name LIKE '%auth-private'; +``` + +### Find Tenant Auth Endpoint + +```sql +SELECT d.name, dom.subdomain, dom.domain +FROM services_public.domains dom +JOIN metaschema_public.database d ON dom.database_id = d.id +WHERE dom.subdomain LIKE 'auth-%'; +``` + +### Verify Provider Config + +```sql +SELECT id, slug, client_id, client_secret_id, enabled +FROM "{schema}-auth-private".identity_providers; +``` + +## Server Logs + +```bash +# Hub environment +pnpm log public-server + +# Check log files +cat .local/logs/public-server.log | tail -100 +``` + +Look for `[oauth]`, `[auth]`, or `[server]` prefixed messages.