diff --git a/.claude/commands/patterns.md b/.claude/commands/patterns.md new file mode 100644 index 0000000..24ac88c --- /dev/null +++ b/.claude/commands/patterns.md @@ -0,0 +1,90 @@ +# Code Patterns + +## Error Handling + +Express 5 catches async throws. Use the `HttpError` factories: + +```typescript +import { badRequest, unauthorized, notFound, forbidden, conflict } from '../utils/errors.js'; + +// In any route handler -- no try/catch needed +throw badRequest('Missing required field'); +throw unauthorized('Token expired'); +throw notFound('App not found'); +``` + +The error handler in `index.ts` catches these and returns JSON: +```json +{ "error": "bad_request", "message": "Missing required field" } +``` + +## Database Access + +All DB methods are synchronous (better-sqlite3): + +```typescript +// Read +const app = db.getApp('my-app'); +const client = db.getOIDCClient('client-id'); +const session = db.getSession('session-id'); + +// Write +db.upsertApp({ id: 'my-app', name: 'My App', hmac_secret: '...', token_ttl_seconds: 3600 }); +db.upsertOIDCClient({ id: 'client-id', name: 'Client', ... }); +db.createSession({ id: 'uuid', did: 'did:plc:...', handle: '...', app_id: '...', expires_at: new Date() }); + +// User mappings +db.setUserMapping({ did: 'did:plc:...', app_id: 'my-app', user_id: 1, handle: 'user.bsky.social' }); +const mapping = db.getUserMapping('did:plc:...', 'my-app'); + +// Passkeys +db.savePasskeyCredential({ id: 'cred-id', did: '...', handle: '...', public_key: '...', counter: 0, device_type: 'platform', backed_up: false, transports: null, name: null }); +db.getPasskeyCredential('cred-id'); +db.getPasskeyCredentialsByDid('did:plc:...'); + +// Audit +db.logAuditEvent('action', 'actor', 'target', 'details', 'ip'); +``` + +## Adding OIDC Presets + +Edit `src/data/oidc-presets.ts`. Each preset needs: + +```typescript +{ + id: 'my-app', + name: 'My App', + icon: `...`, // Inline SVG for the setup wizard + defaultConfig: { + redirect_uris: (domain: string) => [`https://${domain}/callback`], + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + require_pkce: true, + token_endpoint_auth_method: 'client_secret_basic', + }, + setup_notes: `Optional markdown instructions shown in the wizard.`, +} +``` + +## Admin Authentication + +Two auth methods (both checked by `requireAdmin` middleware): + +1. **Bearer token**: `Authorization: Bearer ` +2. **Cookie**: `admin_session` cookie (set via `/admin/login`, 24h TTL, HMAC-signed) + +## Proxy Cookie Types + +Proxy cookies have a `typ` field to prevent cross-endpoint replay: + +- `session` — forward-auth session (long-lived, `/auth/login`) +- `ticket` — one-time ticket for header exchange (`/auth/verify`) + +## HMAC Token Format + +`base64url(JSON payload).base64url(HMAC-SHA256 signature)` + +Both sides must use UTF-8 encoding of the hex secret string: +```typescript +createHmac('sha256', secretString) // NOT Buffer.from(secret, 'hex') +``` diff --git a/.claude/commands/structure.md b/.claude/commands/structure.md new file mode 100644 index 0000000..1b8b545 --- /dev/null +++ b/.claude/commands/structure.md @@ -0,0 +1,71 @@ +# Project Structure + +``` +gateway/ + src/ + index.ts # Express app setup, middleware, route mounting + config.ts # Environment config with validation + routes/ + auth.ts # Legacy HMAC auth flow (/auth/init, /auth/callback) + admin.ts # Admin API (Bearer + cookie auth) + admin-dashboard.ts # Server-rendered admin UI + CSRF + session.ts # Session conflict detection/resolution + token.ts # HMAC token verify/info + passkey.ts # WebAuthn registration/authentication routes + user-profile.ts # User profile page (passkey + session management) + proxy-auth.ts # Forward-auth routes + enforceAccess + email.ts # Email verification routes + mfa.ts # TOTP MFA routes + oidc/ + index.ts # OIDC route aggregator + authorize.ts # OIDC authorization + AT Proto OAuth callback + token.ts # Token exchange (auth_code + refresh_token) + userinfo.ts # User claims (DID-to-handle resolution) + revoke.ts # Token revocation (RFC 7009) + logout.ts # End session endpoint + discovery.ts # .well-known/openid-configuration + JWKS + services/ + database.ts # SQLite schema, migrations, all DB methods + oauth.ts # AT Protocol OAuth client (NodeOAuthClient) + oidc/ # OIDC token + key services + passkey.ts # WebAuthn credential management + email.ts # Email sending (SMTP/SES) + mfa.ts # TOTP generation/verification + middleware/ + rateLimit.ts # IP-based rate limiting + utils/ + hmac.ts # HMAC-SHA256 token creation/verification + errors.ts # HttpError class + factory functions + access-check.ts # Handle pattern matching + access control + proxy-auth.ts # Proxy cookie + ticket helpers + data/ + oidc-presets.ts # Setup wizard presets (22 apps) + types/ + index.ts # Shared TypeScript types + tests/ + oidc-flow.test.ts # E2E OIDC authorization flow tests + data/ # SQLite database (runtime, gitignored) +``` + +## Route Mounting Order (index.ts) + +1. `/auth/verify` — forward-auth verify (before rate limit) +2. Rate limit middleware +3. `/oauth` — OIDC routes (authorize, token, userinfo, revoke, logout, discovery) +4. `/auth` — legacy HMAC auth + forward-auth login/callback +5. `/admin` — admin API + dashboard +6. `/session` — session management +7. `/token` — HMAC token verify/info +8. `/passkey` — WebAuthn routes +9. `/user` — user profile page +10. `/email` — email verification +11. `/mfa` — TOTP MFA + +## Key Service Dependencies + +- `DatabaseService` — used by all routes +- `OAuthService` — AT Protocol OAuth, used by auth + OIDC authorize +- `OIDCService` — token signing/verification, used by OIDC routes +- `PasskeyService` — WebAuthn, used by passkey + OIDC authorize routes +- `MFAService` — TOTP, used by MFA routes + admin +- `EmailService` — email sending, used by email routes diff --git a/.claude/commands/testing.md b/.claude/commands/testing.md new file mode 100644 index 0000000..0be1c64 --- /dev/null +++ b/.claude/commands/testing.md @@ -0,0 +1,113 @@ +# Testing Guide + +394 tests across 22 test files. + +## Commands + +```bash +cd gateway +npm run test:run # All tests +npm run test:run -- --coverage # With coverage report +npm test # Watch mode +``` + +## Test Patterns + +### Database +Use in-memory SQLite for isolation: +```typescript +const db = new DatabaseService(':memory:'); +afterEach(() => db.close()); +``` + +### HTTP Routes +Use supertest with a minimal Express app: +```typescript +function createTestApp(db: DatabaseService) { + const app = express(); + app.use(express.json()); + const router = createMyRouter(db); + app.use('/path', router); + // Add error handler for HttpError + app.use((err: any, _req: any, res: any, _next: any) => { + res.status(err.statusCode || 500).json({ error: err.code, message: err.message }); + }); + return app; +} +``` + +### Mocking +- `vi.fn()` for simple function mocks +- `vi.spyOn(obj, 'method')` for spying on existing methods +- `vi.mock('module')` for full module mocks (hoisted to top of file) + +### OIDC Service Mocks +```typescript +function createMockOIDCService(accessTokenClaims: any = null) { + return { + tokenService: { + verifyAccessToken: vi.fn().mockReturnValue(accessTokenClaims), + verifyIdToken: vi.fn(), + createTokenResponse: vi.fn(), + }, + keyService: { getPublicKeySet: vi.fn() }, + } as any; +} +``` + +### WebAuthn Mocks +Mock the entire `@simplewebauthn/server` module: +```typescript +vi.mock('@simplewebauthn/server', () => ({ + generateRegistrationOptions: vi.fn().mockResolvedValue({ challenge: 'mock' }), + verifyRegistrationResponse: vi.fn().mockResolvedValue({ verified: true, registrationInfo: { ... } }), + generateAuthenticationOptions: vi.fn().mockResolvedValue({ challenge: 'mock' }), + verifyAuthenticationResponse: vi.fn().mockResolvedValue({ verified: true, authenticationInfo: { ... } }), +})); +``` + +### Time-Dependent Tests +```typescript +vi.useFakeTimers(); +vi.setSystemTime(new Date('2026-01-01')); +// ... test expiry logic ... +vi.useRealTimers(); +``` + +### Registering Test Data + +OIDC clients (for revoke, logout, token tests): +```typescript +function registerOIDCClient(db: DatabaseService, id = 'test-client', secret = 'test-secret') { + const secretHash = crypto.createHash('sha256').update(secret).digest('hex'); + db.upsertOIDCClient({ + id, name: 'Test', client_type: 'oidc', hmac_secret: secretHash, + redirect_uris: ['https://app.example.com/callback'], + grant_types: ['authorization_code'], allowed_scopes: ['openid', 'profile'], + token_ttl_seconds: 3600, id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, refresh_token_ttl_seconds: 86400, + require_pkce: false, token_endpoint_auth_method: 'client_secret_basic', + }); +} +``` + +Legacy apps (for session, token tests): +```typescript +function registerApp(db: DatabaseService, id = 'test-app') { + db.upsertApp({ + id, name: 'Test App', hmac_secret: TEST_SECRET, + token_ttl_seconds: 3600, callback_url: 'https://app.example.com/callback', + }); +} +``` + +## File Organization + +- Tests live alongside source: `foo.ts` -> `foo.test.ts` +- E2E tests in `tests/` directory +- No test utilities directory -- helpers are defined per test file + +## Coverage + +Run `npm run test:run -- --coverage` and check the terminal table. +Key areas with room for improvement: passkey routes, OIDC authorize, OIDC token exchange. diff --git a/CHANGELOG.md b/CHANGELOG.md index e7cadc4..6e6c547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Passkey registration now requires discoverable credentials (`residentKey: 'required'`) - Rate limit increased from 10 to 30 requests per window -- Test suite expanded to 827 tests across 45 test files (~56% statement coverage) +- Test suite expanded to 394 tests across 22 test files (~56% statement coverage) - Request body size limited to 16kb (JSON and URL-encoded) ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index f1f9b36..0051053 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,141 +1,28 @@ -# ATAuth - Development Guide +# ATAuth - AT Protocol OIDC Provider -## Overview - -ATAuth is an OIDC Provider that uses AT Protocol OAuth (Bluesky) as the identity source. It also provides a forward-auth SSO proxy for nginx `auth_request`. - -## Project Structure - -``` -gateway/ - src/ - index.ts # Express app setup, middleware, route mounting - config.ts # Environment config with validation - routes/ - auth.ts # Legacy HMAC auth flow (/auth/init, /auth/callback) - admin.ts # Admin API (Bearer + cookie auth) - admin-dashboard.ts # Server-rendered admin UI + CSRF - session.ts # Session conflict detection/resolution - token.ts # HMAC token verify/info - passkey.ts # WebAuthn registration/authentication routes - user-profile.ts # User profile page (passkey + session management) - proxy-auth.ts # Forward-auth routes + enforceAccess - email.ts # Email verification routes - mfa.ts # TOTP MFA routes - oidc/ - index.ts # OIDC route aggregator - authorize.ts # OIDC authorization + AT Proto OAuth callback - token.ts # Token exchange (auth_code + refresh_token) - userinfo.ts # User claims (DID-to-handle resolution) - revoke.ts # Token revocation (RFC 7009) - logout.ts # End session endpoint - discovery.ts # .well-known/openid-configuration + JWKS - services/ - database.ts # SQLite schema, migrations, all DB methods - oauth.ts # AT Protocol OAuth client (NodeOAuthClient) - oidc/ # OIDC token + key services - passkey.ts # WebAuthn credential management - email.ts # Email sending (SMTP/SES) - mfa.ts # TOTP generation/verification - middleware/ - rateLimit.ts # IP-based rate limiting - utils/ - hmac.ts # HMAC-SHA256 token creation/verification - errors.ts # HttpError class + factory functions - access-check.ts # Handle pattern matching + access control - proxy-auth.ts # Proxy cookie + ticket helpers - data/ - oidc-presets.ts # Setup wizard presets (22 apps) - types/ - index.ts # Shared TypeScript types - tests/ - oidc-flow.test.ts # E2E OIDC authorization flow tests - data/ # SQLite database (runtime, gitignored) -``` +OIDC Provider using AT Protocol OAuth (Bluesky) as identity source, plus a forward-auth SSO proxy for nginx `auth_request`. ## Development ```bash cd gateway npm install -cp .env.example .env # Fill in required secrets -npm run dev # tsx watch (hot reload) +cp .env.example .env # Fill in required secrets (openssl rand -hex 32) +npm run dev # tsx watch (hot reload) ``` -### Commands - | Command | Description | |---------|-------------| -| `npm run dev` | Start with hot reload (tsx watch) | -| `npm run build` | TypeScript compile to dist/ | -| `npm start` | Run compiled output | -| `npm test` | Vitest in watch mode | +| `npm run dev` | Hot reload (tsx watch) | +| `npm run build` | TypeScript compile | | `npm run test:run` | Vitest single run | +| `npm run test:run -- --coverage` | With coverage | | `npm run typecheck` | tsc --noEmit | | `npm run lint` | ESLint | -### Required Environment Variables - -Generate secrets with `openssl rand -hex 32`: - -- `ADMIN_TOKEN` - Admin API authentication -- `OIDC_KEY_SECRET` - ES256 key derivation (when OIDC enabled) -- `MFA_ENCRYPTION_KEY` - TOTP secret encryption (when MFA enabled) -- `FORWARD_AUTH_SESSION_SECRET` - Proxy session cookies (when forward-auth enabled) - -## Testing - -**827 tests** across 45 test files. Run with: - -```bash -npm run test:run # All tests -npm run test:run -- --coverage # With coverage report -``` - -### Test Patterns - -- **Database**: `new DatabaseService(':memory:')` for in-memory SQLite -- **HTTP routes**: supertest with Express app -- **Mocks**: `vi.fn()`, `vi.spyOn()`, `vi.mock()` for module mocks -- **Time**: `vi.useFakeTimers()` / `vi.useRealTimers()` for expiry tests -- **OIDC services**: Mock object with `tokenService` and `keyService` stubs -- **WebAuthn**: Full `vi.mock('@simplewebauthn/server')` at module level +## Stack -### Test file naming - -Tests live alongside source files as `*.test.ts`. E2E tests are in `tests/`. - -## Key Technical Details - -- **Express 5** with async error handling (throw from route handlers) -- **SQLite** via better-sqlite3 (synchronous API, WAL mode) -- **ES256 JWTs** for OIDC tokens, **HMAC-SHA256** for gateway/proxy tokens -- **Client secrets** stored as SHA-256 hashes, never plaintext -- **PKCE** configurable per OIDC client -- **CSP** with per-request nonces for inline scripts -- **CSRF** via HMAC-signed tokens on all dashboard forms - -## Common Patterns - -### Error handling - -```typescript -import { badRequest, unauthorized, notFound } from '../utils/errors.js'; -// Throw from any route handler -- Express 5 catches async throws -throw badRequest('Missing required field'); -``` - -### Database access - -```typescript -// All DB methods are synchronous (better-sqlite3) -const app = db.getApp('my-app'); -db.upsertApp({ id: 'my-app', name: 'My App', ... }); -``` - -### Adding OIDC presets - -Edit `src/data/oidc-presets.ts`. Each preset needs: `id`, `name`, `icon` (SVG), `defaultConfig`, and optional `setup_notes` (markdown). +Express 5, TypeScript, SQLite (better-sqlite3), ES256 JWTs (OIDC), HMAC-SHA256 (gateway/proxy tokens). ## Gotchas @@ -144,3 +31,10 @@ Edit `src/data/oidc-presets.ts`. Each preset needs: `id`, `name`, `icon` (SVG), - HMAC tokens: both sides must use UTF-8 encoding of the hex secret string - Proxy cookies use a `typ` discriminator to prevent cross-endpoint replay - OIDC issuer URL must exactly match what clients configure (including path) +- Client secrets stored as SHA-256 hashes, never plaintext + +## Skills + +- `/structure` -- project file layout, route mounting order, service dependencies +- `/testing` -- test patterns, mock examples, coverage, helper recipes +- `/patterns` -- error handling, DB access, OIDC presets, auth, HMAC format