diff --git a/CHANGELOG.md b/CHANGELOG.md index 5519f13..e7cadc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.2.0] - 2026-02-24 + +### Added + +- 10 new OIDC setup wizard presets: Paperless-ngx, Vaultwarden, Miniflux, Mattermost, Vikunja, Plane, GoToSocial, Stirling-PDF, Tandoor Recipes, FreshRSS +- Development guide (CLAUDE.md) with project structure, testing patterns, and common gotchas +- Unit tests for OIDC revocation, logout, userinfo, passkey service, session management, token verification, rate limiting, HMAC utilities, and error handling +- Audit log for all admin operations with 90-day retention cleanup +- `GET /admin/audit-log` endpoint for viewing admin activity + +### Changed + +- 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) +- Request body size limited to 16kb (JSON and URL-encoded) + +### Fixed + +- Forward-auth user profile: `x-forwarded-proto` header handling for correct protocol detection +- Email removal endpoint validates email format before processing +- Proxy auth handle validation rejects malformed handles +- CORS startup validation rejects wildcard origin with credentials mode + ## [2.1.0] - 2026-02-24 ### Added @@ -184,7 +208,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Input validation and sanitization - Rate limiting on all endpoints -[2.0.1]: https://github.com/Cache8063/atauth/compare/v2.0.0...main +[2.2.0]: https://github.com/Cache8063/atauth/compare/v2.1.0...v2.2.0 +[2.1.0]: https://github.com/Cache8063/atauth/compare/v2.0.3...v2.1.0 +[2.0.3]: https://github.com/Cache8063/atauth/compare/v2.0.2...v2.0.3 +[2.0.2]: https://github.com/Cache8063/atauth/compare/v2.0.1...v2.0.2 +[2.0.1]: https://github.com/Cache8063/atauth/compare/v2.0.0...v2.0.1 [2.0.0]: https://github.com/Cache8063/atauth/compare/v1.3.0...v2.0.0 [1.3.0]: https://github.com/Cache8063/atauth/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/Cache8063/atauth/compare/v1.0.0...v1.2.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f1f9b36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,146 @@ +# ATAuth - Development Guide + +## 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) +``` + +## Development + +```bash +cd gateway +npm install +cp .env.example .env # Fill in required secrets +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 test:run` | Vitest single run | +| `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 + +### 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). + +## 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) diff --git a/README.md b/README.md index aa84ffd..d80edaa 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,16 @@ The setup wizard includes presets for: | Portainer | OIDC (built-in) | | Outline | OIDC (built-in) | | Mealie | OIDC (built-in) | +| Paperless-ngx | OIDC (built-in) | +| Vaultwarden | OIDC (built-in) | +| Miniflux | OIDC (built-in) | +| Mattermost | OIDC (built-in) | +| Vikunja | OIDC (built-in) | +| Plane | OIDC (built-in) | +| GoToSocial | OIDC (built-in) | +| Stirling-PDF | OIDC (built-in) | +| Tandoor Recipes | OIDC (built-in) | +| FreshRSS | OIDC (built-in) | | Any web service | Forward-auth proxy (nginx `auth_request`) | ## OIDC Discovery @@ -144,6 +154,7 @@ 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) diff --git a/SECURITY.md b/SECURITY.md index 6cde68c..0dec58c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,11 +15,11 @@ Instead, please send an email describing the vulnerability to the maintainers. I ## Supported Versions -| Version | Supported | -| ------- | ------------------ | -| 1.2.x | Yes | -| 1.0.x | Security fixes only | -| < 1.0 | No | +| Version | Supported | +| ------- | ------------------- | +| 2.x | Yes | +| 1.x | Security fixes only | +| < 1.0 | No | ## Security Best Practices diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 30aab4f..ea7d2f0 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -103,6 +103,12 @@ const config = { async function main(): Promise { console.log('Starting ATAuth Gateway...'); + // Validate CORS configuration + if (config.corsOrigins.includes('*')) { + console.error('CORS_ORIGINS cannot include "*" — credentials mode requires explicit origins'); + process.exit(1); + } + // Validate required secrets when features are enabled const missing: string[] = []; if (config.oidc.enabled && !config.oidc.keySecret) { @@ -226,8 +232,8 @@ async function main(): Promise { methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Session-Id'], })); - app.use(express.json()); - app.use(express.urlencoded({ extended: true })); + app.use(express.json({ limit: '16kb' })); + app.use(express.urlencoded({ extended: true, limit: '16kb' })); // Request logging app.use((req, _res, next) => { @@ -382,8 +388,9 @@ async function main(): Promise { const emailCodesDeleted = db.cleanupExpiredEmailVerificationCodes(); const proxySessionsDeleted = db.cleanupExpiredProxySessions(); const proxyAuthRequestsDeleted = db.cleanupExpiredProxyAuthRequests(); + const auditLogsDeleted = db.cleanupOldAuditLogs(); - const total = statesDeleted + sessionsDeleted + authCodesDeleted + refreshTokensDeleted + emailCodesDeleted + proxySessionsDeleted + proxyAuthRequestsDeleted; + const total = statesDeleted + sessionsDeleted + authCodesDeleted + refreshTokensDeleted + emailCodesDeleted + proxySessionsDeleted + proxyAuthRequestsDeleted + auditLogsDeleted; if (total > 0) { console.log(`Cleanup: ${statesDeleted} OAuth states, ${sessionsDeleted} sessions, ${authCodesDeleted} auth codes, ${refreshTokensDeleted} refresh tokens, ${emailCodesDeleted} email codes, ${proxySessionsDeleted} proxy sessions, ${proxyAuthRequestsDeleted} proxy auth requests`); } diff --git a/gateway/src/routes/admin.ts b/gateway/src/routes/admin.ts index b36717e..3b6039e 100644 --- a/gateway/src/routes/admin.ts +++ b/gateway/src/routes/admin.ts @@ -18,6 +18,10 @@ import type { OIDCService } from '../services/oidc/index.js'; import type { PasskeyService } from '../services/passkey.js'; import type { MFAService } from '../services/mfa.js'; +function clientIp(req: Request): string { + return (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || 'unknown'; +} + /** * Constant-time string comparison to prevent timing attacks. */ @@ -167,6 +171,8 @@ export function createAdminRoutes( callback_url, }); + db.logAuditEvent('app.create', 'admin', id, `Created app "${name}"`, clientIp(req)); + res.status(201).json({ id, name, @@ -217,6 +223,12 @@ export function createAdminRoutes( db.upsertApp(updated); + if (rotate_secret) { + db.logAuditEvent('app.secret_rotate', 'admin', req.params.id, 'HMAC secret rotated', clientIp(req)); + } else { + db.logAuditEvent('app.update', 'admin', req.params.id, `Updated app config`, clientIp(req)); + } + const response: Record = { id: updated.id, name: updated.name, @@ -329,6 +341,8 @@ export function createAdminRoutes( refresh_token_ttl_seconds: refresh_token_ttl_seconds || 604800, }); + db.logAuditEvent('oidc_client.create', 'admin', id, `Created OIDC client "${name}"`, clientIp(req)); + res.status(201).json({ id, name, @@ -408,6 +422,8 @@ export function createAdminRoutes( } } + db.logAuditEvent('oidc_client.update', 'admin', req.params.id, 'Updated OIDC client config', clientIp(req)); + res.json({ id: req.params.id, message: 'OIDC client updated', @@ -425,6 +441,7 @@ export function createAdminRoutes( } db.deleteApp(req.params.id); + db.logAuditEvent('oidc_client.delete', 'admin', req.params.id, `Deleted OIDC client "${existing.name}"`, clientIp(req)); res.json({ message: 'OIDC client deleted', @@ -445,6 +462,7 @@ export function createAdminRoutes( const clientSecretHash = crypto.createHash('sha256').update(clientSecret).digest('hex'); db.updateOIDCClientSecret(req.params.id, clientSecretHash); + db.logAuditEvent('oidc_client.secret_rotate', 'admin', req.params.id, 'Client secret rotated', clientIp(req)); res.json({ id: req.params.id, @@ -487,6 +505,7 @@ export function createAdminRoutes( */ router.delete('/sessions/:id', requireAdmin, async (req: Request, res: Response) => { db.deleteSession(req.params.id); + db.logAuditEvent('session.revoke', 'admin', req.params.id, 'Session revoked', clientIp(req)); res.json({ message: 'Session revoked' }); }); @@ -501,6 +520,7 @@ export function createAdminRoutes( } const count = db.revokeAllSessionsForUser(did, app_id); + db.logAuditEvent('session.revoke_all', 'admin', did, `Revoked ${count} sessions${app_id ? ` for app ${app_id}` : ''}`, clientIp(req)); res.json({ sessions_revoked: count, }); @@ -541,6 +561,7 @@ export function createAdminRoutes( const { algorithm } = req.body; await oidcService.keyManager.rotateKeys(algorithm || 'ES256'); + db.logAuditEvent('key.rotate', 'admin', undefined, `Rotated signing key (${algorithm || 'ES256'})`, clientIp(req)); res.json({ message: 'Signing key rotated', @@ -595,6 +616,8 @@ export function createAdminRoutes( mfaService.disableTOTP(did); } + db.logAuditEvent('user.mfa_reset', 'admin', did, 'MFA disabled by admin', clientIp(req)); + res.json({ message: 'MFA disabled for user', }); @@ -614,6 +637,8 @@ export function createAdminRoutes( } } + db.logAuditEvent('user.passkey_delete', 'admin', did, `Deleted passkey ${passkeyId}`, clientIp(req)); + res.json({ message: 'Passkey deleted', }); @@ -658,6 +683,7 @@ export function createAdminRoutes( try { const created = db.addProxyAllowedOrigin(origin, name); + db.logAuditEvent('proxy.origin_add', 'admin', origin, `Added origin "${name}"`, clientIp(req)); res.status(201).json(created); } catch (e) { const msg = e instanceof Error ? e.message : ''; @@ -674,6 +700,7 @@ export function createAdminRoutes( */ router.delete('/proxy/origins/:id', requireAdmin, async (req: Request, res: Response) => { db.removeProxyAllowedOrigin(parseInt(req.params.id, 10)); + db.logAuditEvent('proxy.origin_remove', 'admin', req.params.id, 'Removed proxy origin', clientIp(req)); res.json({ message: 'Origin removed' }); }); @@ -696,6 +723,7 @@ export function createAdminRoutes( */ router.delete('/proxy/sessions/:id', requireAdmin, async (req: Request, res: Response) => { db.deleteProxySession(req.params.id); + db.logAuditEvent('proxy.session_revoke', 'admin', req.params.id, 'Proxy session revoked', clientIp(req)); res.json({ message: 'Proxy session revoked' }); }); @@ -768,6 +796,8 @@ export function createAdminRoutes( description: description || null, }); + db.logAuditEvent('proxy.access_rule_create', 'admin', String(rule.id), `Created ${rule_type} rule for ${subject_type}:${subject_value}`, clientIp(req)); + res.status(201).json(rule); }); @@ -777,6 +807,7 @@ export function createAdminRoutes( */ router.delete('/proxy/access/:id', requireAdmin, async (req: Request, res: Response) => { db.deleteProxyAccessRule(parseInt(req.params.id, 10)); + db.logAuditEvent('proxy.access_rule_delete', 'admin', req.params.id, 'Deleted access rule', clientIp(req)); res.json({ message: 'Access rule deleted' }); }); @@ -807,6 +838,19 @@ export function createAdminRoutes( res.json(result); }); + // ===== Audit Log ===== + + /** + * GET /admin/audit-log + * List audit log entries (most recent first) + */ + router.get('/audit-log', requireAdmin, async (req: Request, res: Response) => { + const limit = Math.min(parseInt(req.query.limit as string, 10) || 100, 500); + const offset = parseInt(req.query.offset as string, 10) || 0; + const entries = db.getAuditLog(limit, offset); + res.json({ entries, limit, offset }); + }); + // ===== Stats ===== /** diff --git a/gateway/src/routes/email.ts b/gateway/src/routes/email.ts index 81d6f73..507d17e 100644 --- a/gateway/src/routes/email.ts +++ b/gateway/src/routes/email.ts @@ -130,6 +130,10 @@ export function createEmailRouter( const { email } = req.params; + if (!email || !isValidEmail(email)) { + throw new HttpError(400, 'invalid_request', 'Invalid email address'); + } + const result = emailService.removeEmail(did, email); if (!result.success) { diff --git a/gateway/src/routes/oidc/revoke.test.ts b/gateway/src/routes/oidc/revoke.test.ts index f9a25c5..c81c415 100644 --- a/gateway/src/routes/oidc/revoke.test.ts +++ b/gateway/src/routes/oidc/revoke.test.ts @@ -46,6 +46,7 @@ function createRefreshToken(db: DatabaseService, token: string, clientId = 'test scope: 'openid profile', expires_at: new Date(Date.now() + 86400 * 1000), family_id: `family-${Date.now()}`, + revoked: false, }); return tokenHash; } diff --git a/gateway/src/routes/proxy-auth.ts b/gateway/src/routes/proxy-auth.ts index 1bbe450..3909056 100644 --- a/gateway/src/routes/proxy-auth.ts +++ b/gateway/src/routes/proxy-auth.ts @@ -208,6 +208,11 @@ export function createProxyAuthRoutes( sanitizedHandle = sanitizedHandle + '.bsky.social'; } + // Validate handle format: domain-like segments separated by dots + if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(sanitizedHandle)) { + return res.status(400).json({ error: 'invalid_handle', message: 'Invalid handle format' }); + } + // Look up the pending auth request const authRequest = db.getProxyAuthRequest(auth_request_id); if (!authRequest) { diff --git a/gateway/src/services/database.ts b/gateway/src/services/database.ts index 05e021e..17e7e8b 100644 --- a/gateway/src/services/database.ts +++ b/gateway/src/services/database.ts @@ -305,6 +305,21 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_proxy_access_rules_origin ON proxy_access_rules(origin_id); `); + // Audit log for admin operations + this.db.exec(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + actor TEXT NOT NULL, + target TEXT, + details TEXT, + ip TEXT, + timestamp INTEGER DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp); + CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action); + `); + // Ensure sentinel proxy-auth app exists for forward-auth OAuth flows const proxyApp = this.db.prepare('SELECT 1 FROM apps WHERE id = ?').get('proxy-auth'); if (!proxyApp) { @@ -536,6 +551,28 @@ export class DatabaseService { this.db.close(); } + // ===== Audit Log Methods ===== + + logAuditEvent(action: string, actor: string, target?: string, details?: string, ip?: string): void { + const stmt = this.db.prepare(` + INSERT INTO audit_log (action, actor, target, details, ip) + VALUES (?, ?, ?, ?, ?) + `); + stmt.run(action, actor, target || null, details || null, ip || null); + } + + getAuditLog(limit = 100, offset = 0): Array<{ id: number; action: string; actor: string; target: string | null; details: string | null; ip: string | null; timestamp: number }> { + const stmt = this.db.prepare('SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ? OFFSET ?'); + return stmt.all(limit, offset) as Array<{ id: number; action: string; actor: string; target: string | null; details: string | null; ip: string | null; timestamp: number }>; + } + + cleanupOldAuditLogs(): number { + const ninetyDaysAgo = Math.floor(Date.now() / 1000) - (90 * 24 * 60 * 60); + const stmt = this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?'); + const result = stmt.run(ninetyDaysAgo); + return result.changes; + } + // ===== OIDC Key Management Methods ===== saveOIDCKey(key: Omit): void {