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