diff --git a/.claude/commands/patterns.md b/.claude/commands/patterns.md deleted file mode 100644 index 24ac88c..0000000 --- a/.claude/commands/patterns.md +++ /dev/null @@ -1,90 +0,0 @@ -# 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 deleted file mode 100644 index 1b8b545..0000000 --- a/.claude/commands/structure.md +++ /dev/null @@ -1,71 +0,0 @@ -# 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 deleted file mode 100644 index 0be1c64..0000000 --- a/.claude/commands/testing.md +++ /dev/null @@ -1,113 +0,0 @@ -# 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/.gitignore b/.gitignore index e4859ad..12663c9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ gateway/node_modules/ gateway/dist/ gateway/.turbo/ +# Claude Code +CLAUDE.md +.claude/ + # IDE .idea/ .vscode/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 0051053..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,40 +0,0 @@ -# ATAuth - AT Protocol OIDC Provider - -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 (openssl rand -hex 32) -npm run dev # tsx watch (hot reload) -``` - -| Command | Description | -|---------|-------------| -| `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 | - -## Stack - -Express 5, TypeScript, SQLite (better-sqlite3), ES256 JWTs (OIDC), HMAC-SHA256 (gateway/proxy tokens). - -## Gotchas - -- `req.accepts('json')` matches `*/*` -- use `req.is('json')` to check Content-Type -- `/auth/verify` is mounted before rate limit middleware (called on every nginx subrequest) -- 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 diff --git a/README.md b/README.md index d80edaa..ea49495 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,6 @@ With a self-hosted PDS, ATAuth becomes a fully independent auth system: ## Documentation -- [Development Guide](CLAUDE.md) - [Homelab Deployment Guide](docs/HOMELAB.md) - [Security Policy](SECURITY.md) - [Changelog](CHANGELOG.md)