From 0b2935abd6dfc28d6e3d511c316bbacc1a179633 Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Sun, 22 Mar 2026 11:30:50 -0500 Subject: [PATCH] security: comprehensive hardening from security audit (43 findings) Fixes 5 HIGH, 16 MEDIUM, 14 LOW, 8 INFO findings across 7 phases: - Token revocation blacklist via jti claims in SQLite (H-2) - Session endpoint auth hardening with gateway token validation (H-1) - Refresh token scope validation per RFC 6749 Section 6 (M-9) - Removed plain PKCE from OIDC discovery, S256-only (H-3) - Passkey challenges moved from in-memory to SQLite (M-4) - TOTP replay protection via absolute period tracking (M-5) - Email verification lockout after 5 failed attempts (M-8) - OAuth PDS sessions moved from in-memory to SQLite (L-1) - AES-256-GCM IV migration (16->12 bytes) with HKDF key derivation (L-3/L-4) - JWT kid fallback hardened - reject when kid present but not found (L-2) - WebAuthn requireUserVerification enforced (M-2) - Webhook HTML escaping for XSS prevention (M-7) - CSP form-action directive added (M-6) - Recovery scope restricted to openid/profile/email (L-7) - Cache-Control: no-store on token info endpoint (M-1) - Docker image SHA pinning, compose security hardening (D-2/D-3) - CI kubeconfig cleanup with mktemp+trap (D-1) - Rust lib: zeroize secrets on drop, RwLock poison recovery, removed unused postgres deps, pinned chrono, upgraded rusqlite - OIDC presets: pkce_note documentation for disabled PKCE clients 423 tests passing, 0 npm vulnerabilities. Co-Authored-By: Claude Opus 4.6 --- .gitea/workflows/deploy.yml | 12 +- .gitignore | 7 ++ Cargo.toml | 14 +-- docker-compose.yml | 10 ++ gateway/.npmrc | 2 + gateway/Dockerfile | 5 +- gateway/package-lock.json | 12 +- gateway/src/data/oidc-presets.ts | 10 ++ gateway/src/index.ts | 16 ++- gateway/src/middleware/rateLimit.test.ts | 11 +- gateway/src/middleware/rateLimit.ts | 9 +- gateway/src/routes/email.ts | 12 +- gateway/src/routes/oidc/revoke.ts | 25 +++- gateway/src/routes/oidc/token.ts | 17 ++- gateway/src/routes/session.test.ts | 13 ++ gateway/src/routes/session.ts | 39 +++++- gateway/src/routes/token.ts | 3 + gateway/src/services/database.ts | 151 +++++++++++++++++++++++ gateway/src/services/email.ts | 20 +++ gateway/src/services/mfa.ts | 13 +- gateway/src/services/oauth.ts | 34 ++--- gateway/src/services/oidc/index.ts | 3 +- gateway/src/services/oidc/keys.ts | 46 ++++--- gateway/src/services/oidc/tokens.ts | 20 ++- gateway/src/services/passkey.ts | 58 +++------ gateway/src/types/index.ts | 1 + gateway/src/utils/webhook.ts | 6 +- src/lib.rs | 8 +- src/rate_limit.rs | 16 +-- src/session/mod.rs | 6 - src/session/postgres.rs | 72 ----------- src/token.rs | 13 +- 32 files changed, 461 insertions(+), 223 deletions(-) create mode 100644 gateway/.npmrc delete mode 100644 src/session/postgres.rs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index aff1771..1ab9d6b 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -82,7 +82,7 @@ jobs: -d "$MSG" || true - name: Login to DO Container Registry - run: echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin + run: echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "do-registry" --password-stdin - name: Build Docker image run: | @@ -100,12 +100,14 @@ jobs: - name: Deploy to DO Kubernetes run: | - echo "${{ secrets.DO_KUBECONFIG }}" | base64 -d > /tmp/kubeconfig - KUBECONFIG=/tmp/kubeconfig kubectl set image deployment/${{ env.DEPLOYMENT }} \ + TMPKUBE=$(mktemp) + trap "rm -f $TMPKUBE" EXIT + echo "${{ secrets.DO_KUBECONFIG }}" | base64 -d > "$TMPKUBE" + chmod 600 "$TMPKUBE" + KUBECONFIG="$TMPKUBE" kubectl set image deployment/${{ env.DEPLOYMENT }} \ ${{ env.DEPLOYMENT }}=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }} \ -n ${{ env.NAMESPACE }} - KUBECONFIG=/tmp/kubeconfig kubectl rollout status deployment/${{ env.DEPLOYMENT }} -n ${{ env.NAMESPACE }} --timeout=120s - rm -f /tmp/kubeconfig + KUBECONFIG="$TMPKUBE" kubectl rollout status deployment/${{ env.DEPLOYMENT }} -n ${{ env.NAMESPACE }} --timeout=120s - name: Notify build result if: always() diff --git a/.gitignore b/.gitignore index 2daccc4..51823b1 100644 --- a/.gitignore +++ b/.gitignore @@ -56,5 +56,12 @@ coverage/ # Build artifacts *.tsbuildinfo +# Private keys and certificates +*.pem +*.key +*.crt +*.p12 +*.pfx + # Gateway data gateway/data/ diff --git a/Cargo.toml b/Cargo.toml index 5a9f226..cb889f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,8 @@ readme = "README.md" [features] default = ["session-sqlite"] session-sqlite = ["rusqlite", "r2d2", "r2d2_sqlite"] -session-postgres = ["tokio-postgres", "deadpool-postgres"] rate-limit = [] -full = ["session-sqlite", "session-postgres", "rate-limit"] +full = ["session-sqlite", "rate-limit"] [dependencies] # Core cryptography @@ -32,19 +31,18 @@ serde_json = "1" thiserror = "1" # Time -chrono = { version = "0.4", features = ["serde"] } +chrono = { version = ">=0.4.20, <0.5", features = ["serde"] } # Async runtime (optional) tokio = { version = "1", features = ["rt-multi-thread", "macros"], optional = true } # SQLite session store (optional) -rusqlite = { version = "0.31", optional = true } +rusqlite = { version = "0.33", optional = true } r2d2 = { version = "0.8", optional = true } -r2d2_sqlite = { version = "0.24", optional = true } +r2d2_sqlite = { version = "0.25", optional = true } -# Postgres session store (optional) -tokio-postgres = { version = "0.7", optional = true } -deadpool-postgres = { version = "0.12", optional = true } +# Secret zeroing on drop +zeroize = "1" # Logging tracing = "0.1" diff --git a/docker-compose.yml b/docker-compose.yml index 578f6a7..84f7e8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,16 @@ services: image: ghcr.io/cache8063/atauth-gateway:latest container_name: atauth-gateway restart: unless-stopped + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' ports: # Bind to localhost only -- use a reverse proxy for external access - "127.0.0.1:3100:3100" diff --git a/gateway/.npmrc b/gateway/.npmrc new file mode 100644 index 0000000..c3c6634 --- /dev/null +++ b/gateway/.npmrc @@ -0,0 +1,2 @@ +registry=https://registry.npmjs.org/ +engine-strict=true diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 3a43301..6381732 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -7,7 +7,8 @@ # ============================================ # Stage 1: Build Gateway Backend # ============================================ -FROM node:20-alpine AS backend-builder +# Pin to specific digest for reproducible builds (node:20-alpine as of 2026-03-22) +FROM node:20-alpine@sha256:5bac2112f1a27db4775ce6ead86db9e0cbc087f0dbddfe3cbc11ecea122d5294 AS backend-builder WORKDIR /app @@ -23,7 +24,7 @@ RUN npm run build # ============================================ # Stage 2: Production Image # ============================================ -FROM node:20-alpine +FROM node:20-alpine@sha256:5bac2112f1a27db4775ce6ead86db9e0cbc087f0dbddfe3cbc11ecea122d5294 WORKDIR /app diff --git a/gateway/package-lock.json b/gateway/package-lock.json index b4d46a1..b8f81bf 100644 --- a/gateway/package-lock.json +++ b/gateway/package-lock.json @@ -3353,9 +3353,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5301,9 +5301,9 @@ } }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/gateway/src/data/oidc-presets.ts b/gateway/src/data/oidc-presets.ts index d83228f..a9e93ed 100644 --- a/gateway/src/data/oidc-presets.ts +++ b/gateway/src/data/oidc-presets.ts @@ -24,6 +24,8 @@ export interface OIDCAppPreset { token_endpoint_auth_method: 'client_secret_basic' | 'client_secret_post' | 'none'; /** Whether PKCE should be required */ require_pkce: boolean; + /** Explanation for why PKCE is disabled (required when require_pkce is false) */ + pkce_note?: string; /** Token TTLs in seconds */ id_token_ttl_seconds: number; access_token_ttl_seconds: number; @@ -133,6 +135,7 @@ export const OIDC_APP_PRESETS: OIDCAppPreset[] = [ redirect_uri_template: 'https://{{DOMAIN}}/login/atauth/callback', token_endpoint_auth_method: 'client_secret_basic', require_pkce: false, + pkce_note: 'Wiki.js does not support PKCE in its OIDC implementation', id_token_ttl_seconds: 3600, access_token_ttl_seconds: 3600, refresh_token_ttl_seconds: 604800, @@ -148,6 +151,7 @@ export const OIDC_APP_PRESETS: OIDCAppPreset[] = [ redirect_uri_template: 'https://{{DOMAIN}}', token_endpoint_auth_method: 'client_secret_basic', require_pkce: false, + pkce_note: 'Portainer does not support PKCE in its OAuth implementation', id_token_ttl_seconds: 3600, access_token_ttl_seconds: 3600, refresh_token_ttl_seconds: 604800, @@ -238,6 +242,7 @@ export const OIDC_APP_PRESETS: OIDCAppPreset[] = [ redirect_uri_template: 'https://{{DOMAIN}}/signup/openid/complete', token_endpoint_auth_method: 'client_secret_post', require_pkce: false, + pkce_note: 'Mattermost does not support PKCE for OpenID Connect', id_token_ttl_seconds: 3600, access_token_ttl_seconds: 3600, refresh_token_ttl_seconds: 604800, @@ -253,6 +258,7 @@ export const OIDC_APP_PRESETS: OIDCAppPreset[] = [ redirect_uri_template: 'https://{{DOMAIN}}/auth/openid/atauth', token_endpoint_auth_method: 'client_secret_basic', require_pkce: false, + pkce_note: 'Vikunja does not support PKCE in its OpenID Connect integration', id_token_ttl_seconds: 3600, access_token_ttl_seconds: 3600, refresh_token_ttl_seconds: 604800, @@ -268,6 +274,7 @@ export const OIDC_APP_PRESETS: OIDCAppPreset[] = [ redirect_uri_template: 'https://{{DOMAIN}}/auth/oidc/callback/', token_endpoint_auth_method: 'client_secret_post', require_pkce: false, + pkce_note: 'Plane does not support PKCE and requires manual endpoint URLs', id_token_ttl_seconds: 3600, access_token_ttl_seconds: 3600, refresh_token_ttl_seconds: 604800, @@ -283,6 +290,7 @@ export const OIDC_APP_PRESETS: OIDCAppPreset[] = [ redirect_uri_template: 'https://{{DOMAIN}}/auth/callback', token_endpoint_auth_method: 'client_secret_basic', require_pkce: false, + pkce_note: 'GoToSocial does not support PKCE in its OIDC integration', id_token_ttl_seconds: 3600, access_token_ttl_seconds: 3600, refresh_token_ttl_seconds: 604800, @@ -298,6 +306,7 @@ export const OIDC_APP_PRESETS: OIDCAppPreset[] = [ redirect_uri_template: 'https://{{DOMAIN}}/login/oauth2/code/atauth', token_endpoint_auth_method: 'client_secret_basic', require_pkce: false, + pkce_note: 'Stirling-PDF (Spring Security OAuth2) does not support PKCE', id_token_ttl_seconds: 3600, access_token_ttl_seconds: 3600, refresh_token_ttl_seconds: 604800, @@ -328,6 +337,7 @@ export const OIDC_APP_PRESETS: OIDCAppPreset[] = [ redirect_uri_template: 'https://{{DOMAIN}}/i/oidc/', token_endpoint_auth_method: 'client_secret_basic', require_pkce: false, + pkce_note: 'FreshRSS (mod_auth_openidc) does not reliably support PKCE', id_token_ttl_seconds: 3600, access_token_ttl_seconds: 3600, refresh_token_ttl_seconds: 604800, diff --git a/gateway/src/index.ts b/gateway/src/index.ts index e4c03e6..c08aafe 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -131,6 +131,9 @@ async function main(): Promise { if (config.forwardAuth.enabled && !config.forwardAuth.sessionSecret) { missing.push('FORWARD_AUTH_SESSION_SECRET is required when FORWARD_AUTH_ENABLED=true'); } + if (config.adminToken && config.adminToken.length < 32) { + missing.push('ADMIN_TOKEN must be at least 32 characters'); + } if (missing.length > 0) { console.error('Configuration error:'); for (const msg of missing) console.error(` - ${msg}`); @@ -231,10 +234,8 @@ async function main(): Promise { contentSecurityPolicy: { directives: { ...helmet.contentSecurityPolicy.getDefaultDirectives(), - // Chrome enforces form-action on redirect targets (not just the action URL). - // The OIDC login form POSTs to self, but the response redirects to bsky.social - // or other PDS hosts. Without this, Chrome blocks the redirect silently. - 'form-action': null, + // Restrict form-action to self + CORS origins (which include PDS hosts) + 'form-action': ["'self'", ...config.corsOrigins], // Allow inline scripts with per-request nonce (for login page) 'script-src': ["'self'", (_req: any, res: any) => `'nonce-${res.locals.cspNonce}'`], }, @@ -418,10 +419,13 @@ async function main(): Promise { const proxySessionsDeleted = db.cleanupExpiredProxySessions(); const proxyAuthRequestsDeleted = db.cleanupExpiredProxyAuthRequests(); const auditLogsDeleted = db.cleanupOldAuditLogs(); + const revokedTokensDeleted = db.cleanupExpiredRevokedTokens(); + const passkeyChallengesDeleted = db.cleanupExpiredPasskeyChallenges(); + const oauthSessionsDeleted = db.cleanupExpiredOAuthSessions(); - const total = statesDeleted + sessionsDeleted + authCodesDeleted + refreshTokensDeleted + emailCodesDeleted + proxySessionsDeleted + proxyAuthRequestsDeleted + auditLogsDeleted; + const total = statesDeleted + sessionsDeleted + authCodesDeleted + refreshTokensDeleted + emailCodesDeleted + proxySessionsDeleted + proxyAuthRequestsDeleted + auditLogsDeleted + revokedTokensDeleted + passkeyChallengesDeleted + oauthSessionsDeleted; 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`); + console.log(`Cleanup: ${statesDeleted} OAuth states, ${sessionsDeleted} sessions, ${authCodesDeleted} auth codes, ${refreshTokensDeleted} refresh tokens, ${emailCodesDeleted} email codes, ${proxySessionsDeleted} proxy sessions, ${proxyAuthRequestsDeleted} proxy auth requests, ${revokedTokensDeleted} revoked tokens, ${passkeyChallengesDeleted} passkey challenges, ${oauthSessionsDeleted} oauth sessions`); } }, 60 * 60 * 1000); diff --git a/gateway/src/middleware/rateLimit.test.ts b/gateway/src/middleware/rateLimit.test.ts index bd21ddd..a712cd1 100644 --- a/gateway/src/middleware/rateLimit.test.ts +++ b/gateway/src/middleware/rateLimit.test.ts @@ -5,6 +5,7 @@ import { rateLimit } from './rateLimit.js'; function createTestApp(maxRequests: number = 5, windowMs: number = 60000) { const app = express(); + app.set('trust proxy', 1); app.use(rateLimit({ maxRequests, windowMs })); app.get('/test', (_req, res) => res.json({ ok: true })); return app; @@ -64,13 +65,13 @@ describe('rateLimit middleware', () => { expect(res2.status).toBe(200); }); - it('should use first IP from X-Forwarded-For with multiple IPs', async () => { + it('should rate limit same IP from X-Forwarded-For', async () => { const app = createTestApp(2); - // Both requests have same first IP, so they share the same counter - await request(app).get('/test').set('X-Forwarded-For', '1.2.3.4, 10.0.0.1'); - await request(app).get('/test').set('X-Forwarded-For', '1.2.3.4, 10.0.0.2'); - const res = await request(app).get('/test').set('X-Forwarded-For', '1.2.3.4, 10.0.0.3'); + // All requests from same IP share the same counter + await request(app).get('/test').set('X-Forwarded-For', '1.2.3.4'); + await request(app).get('/test').set('X-Forwarded-For', '1.2.3.4'); + const res = await request(app).get('/test').set('X-Forwarded-For', '1.2.3.4'); expect(res.status).toBe(429); }); diff --git a/gateway/src/middleware/rateLimit.ts b/gateway/src/middleware/rateLimit.ts index bc4d276..e9539ce 100644 --- a/gateway/src/middleware/rateLimit.ts +++ b/gateway/src/middleware/rateLimit.ts @@ -111,15 +111,10 @@ setInterval(() => store.cleanup(), 5 * 60 * 1000); /** * Get client IP address from request. - * Handles proxied requests via X-Forwarded-For header. + * Relies on Express trust proxy setting (configured in index.ts). */ function getClientIp(req: Request): string { - const forwarded = req.headers['x-forwarded-for']; - if (forwarded) { - const ips = (typeof forwarded === 'string' ? forwarded : forwarded[0]).split(','); - return ips[0].trim(); - } - return req.ip || req.socket.remoteAddress || 'unknown'; + return req.ip || 'unknown'; } /** diff --git a/gateway/src/routes/email.ts b/gateway/src/routes/email.ts index 97cd826..f613731 100644 --- a/gateway/src/routes/email.ts +++ b/gateway/src/routes/email.ts @@ -263,10 +263,17 @@ export function createEmailRouter( const client = db.getOIDCClient(client_id); const mapping = db.getUserMapping(result.did, client_id); if (client) { + // Restrict recovery scope to standard scopes only + const allowedRecoveryScopes = ['openid', 'profile', 'email']; + const requestedScopes = (scope || 'openid').split(' '); + const validatedScope = requestedScopes + .filter((s: string) => allowedRecoveryScopes.includes(s)) + .join(' ') || 'openid'; + const tokenResponse = oidcService.tokenService.createTokenResponse({ sub: result.did, clientId: client_id, - scope: scope || 'openid', + scope: validatedScope, did: result.did, handle: mapping?.handle || '', accessTokenTtl: client.access_token_ttl_seconds, @@ -334,7 +341,8 @@ async function authenticateRequest( * Validate email format */ function isValidEmail(email: string): boolean { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (email.length > 254) return false; // RFC 5321 max length + const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; return emailRegex.test(email); } diff --git a/gateway/src/routes/oidc/revoke.ts b/gateway/src/routes/oidc/revoke.ts index 74fe6fe..9e72ada 100644 --- a/gateway/src/routes/oidc/revoke.ts +++ b/gateway/src/routes/oidc/revoke.ts @@ -108,9 +108,28 @@ export function createRevokeRouter(db: DatabaseService): Router { } } - // Access tokens are JWTs and can't be truly revoked without a blacklist - // For now, we just return 200 OK as per RFC 7009 - // A proper implementation would add the token to a revocation list + // Try to blacklist as an access token (JWT) + try { + const parts = token.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as { + jti?: string; + exp?: number; + client_id?: string; + }; + + if (payload.jti && payload.exp) { + // Verify client ownership if client_id provided + if (clientId && payload.client_id && payload.client_id !== clientId) { + res.status(200).send(); + return; + } + db.addRevokedToken(payload.jti, payload.exp); + } + } + } catch { + // Not a valid JWT — ignore per RFC 7009 + } res.status(200).send(); } catch (error) { diff --git a/gateway/src/routes/oidc/token.ts b/gateway/src/routes/oidc/token.ts index ea8fc19..9a6aa32 100644 --- a/gateway/src/routes/oidc/token.ts +++ b/gateway/src/routes/oidc/token.ts @@ -370,8 +370,21 @@ async function handleRefreshTokenGrant( // Revoke old refresh token (rotation) db.revokeRefreshToken(tokenHash); - // Generate new tokens - const tokenScope = scope || storedToken.scope; + // Validate requested scope is a subset of original grant (RFC 6749 Section 6) + let tokenScope = storedToken.scope; + if (scope && scope.trim() !== '') { + const requestedScopes = scope.split(' '); + const originalScopes = storedToken.scope.split(' '); + const invalidScopes = requestedScopes.filter(s => !originalScopes.includes(s)); + if (invalidScopes.length > 0) { + res.status(400).json({ + error: 'invalid_scope', + error_description: 'Requested scope exceeds original grant', + }); + return; + } + tokenScope = scope; + } // Generate new refresh token const newRefreshToken = crypto.randomBytes(32).toString('base64url'); diff --git a/gateway/src/routes/session.test.ts b/gateway/src/routes/session.test.ts index bf56638..5691903 100644 --- a/gateway/src/routes/session.test.ts +++ b/gateway/src/routes/session.test.ts @@ -4,6 +4,7 @@ import request from 'supertest'; import crypto from 'crypto'; import { createSessionRoutes } from './session.js'; import { DatabaseService } from '../services/database.js'; +import { createGatewayToken } from '../utils/hmac.js'; const TEST_SECRET = crypto.randomBytes(32).toString('hex'); @@ -36,6 +37,10 @@ function registerApp(db: DatabaseService, id = 'test-app') { }); } +function makeGatewayToken(did = 'did:plc:testuser', handle = 'test.bsky.social') { + return createGatewayToken({ did, handle, app_id: 'test-app', user_id: -1 }, TEST_SECRET); +} + function createSession(db: DatabaseService, overrides: Partial<{ id: string; did: string; @@ -257,9 +262,11 @@ describe('POST /session/update-state', () => { it('should update session state to connected', async () => { const session = createSession(db); + const token = makeGatewayToken(session.did); const res = await request(app) .post('/session/update-state') + .set('Authorization', `Bearer ${token}`) .send({ session_id: session.id, state: 'connected', client_info: 'Firefox 120' }); expect(res.status).toBe(200); @@ -269,9 +276,11 @@ describe('POST /session/update-state', () => { it('should accept disconnected state', async () => { const session = createSession(db); + const token = makeGatewayToken(session.did); const res = await request(app) .post('/session/update-state') + .set('Authorization', `Bearer ${token}`) .send({ session_id: session.id, state: 'disconnected' }); expect(res.status).toBe(200); @@ -322,9 +331,11 @@ describe('POST /session/heartbeat', () => { it('should update session activity', async () => { const session = createSession(db); + const token = makeGatewayToken(session.did); const res = await request(app) .post('/session/heartbeat') + .set('Authorization', `Bearer ${token}`) .send({ session_id: session.id }); expect(res.status).toBe(200); @@ -366,9 +377,11 @@ describe('GET /session/active', () => { it('should list active sessions and mark current', async () => { const s1 = createSession(db, { id: 'session-1' }); createSession(db, { id: 'session-2' }); + const token = makeGatewayToken(s1.did); const res = await request(app) .get('/session/active') + .set('Authorization', `Bearer ${token}`) .query({ session_id: s1.id, app_id: 'test-app' }); expect(res.status).toBe(200); diff --git a/gateway/src/routes/session.ts b/gateway/src/routes/session.ts index d768bdc..8655a33 100644 --- a/gateway/src/routes/session.ts +++ b/gateway/src/routes/session.ts @@ -7,10 +7,28 @@ import { Router, Request, Response } from 'express'; import { DatabaseService } from '../services/database.js'; -import { createGatewayToken } from '../utils/hmac.js'; +import { createGatewayToken, verifyGatewayToken } from '../utils/hmac.js'; import { httpError } from '../utils/errors.js'; import type { SessionResolution, SessionConflict } from '../types/index.js'; +const SESSION_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for pre-token endpoints + +function requireGatewayAuth(req: Request, db: DatabaseService, session: { did: string; app_id: string }): void { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw httpError.unauthorized('auth_required', 'Authorization header with gateway token required'); + } + const token = authHeader.slice(7); + const app = db.getApp(session.app_id); + if (!app) { + throw httpError.notFound('app_not_found', 'Application not found'); + } + const payload = verifyGatewayToken(token, app.hmac_secret); + if (!payload || payload.did !== session.did) { + throw httpError.unauthorized('invalid_token', 'Invalid or mismatched gateway token'); + } +} + export function createSessionRoutes(db: DatabaseService): Router { const router = Router(); @@ -34,6 +52,11 @@ export function createSessionRoutes(db: DatabaseService): Router { throw httpError.notFound('session_not_found', 'Pending session not found or expired'); } + // Session age check — only allow conflict checks on recently created sessions + if (Date.now() - pendingSession.created_at.getTime() > SESSION_MAX_AGE_MS) { + throw httpError.badRequest('session_expired', 'Session is too old for conflict check'); + } + const activeSessions = db.getActiveSessionsByDid(pendingSession.did, app_id); const otherSessions = activeSessions.filter((s) => s.id !== session_id); @@ -78,6 +101,14 @@ export function createSessionRoutes(db: DatabaseService): Router { throw httpError.notFound('session_not_found', 'Session not found or expired'); } + // Only allow resolution of pending sessions within the age window + if (session.connection_state === 'connected') { + throw httpError.badRequest('already_resolved', 'Session has already been resolved'); + } + if (Date.now() - session.created_at.getTime() > SESSION_MAX_AGE_MS) { + throw httpError.badRequest('session_expired', 'Session is too old for conflict resolution'); + } + const app = db.getApp(app_id); if (!app) { throw httpError.notFound('app_not_found', 'Application not found'); @@ -146,6 +177,8 @@ export function createSessionRoutes(db: DatabaseService): Router { throw httpError.notFound('session_not_found', 'Session not found'); } + requireGatewayAuth(req, db, session); + db.updateSessionConnectionState(session_id, state, client_info); res.json({ success: true, session_id, state }); @@ -167,6 +200,8 @@ export function createSessionRoutes(db: DatabaseService): Router { throw httpError.notFound('session_not_found', 'Session not found or expired'); } + requireGatewayAuth(req, db, session); + db.updateSessionActivity(session_id); res.json({ success: true, session_id }); @@ -192,6 +227,8 @@ export function createSessionRoutes(db: DatabaseService): Router { throw httpError.notFound('session_not_found', 'Session not found or expired'); } + requireGatewayAuth(req, db, session); + const activeSessions = db.getActiveSessionsByDid(session.did, app_id); res.json({ diff --git a/gateway/src/routes/token.ts b/gateway/src/routes/token.ts index 4dcf7ae..7df9644 100644 --- a/gateway/src/routes/token.ts +++ b/gateway/src/routes/token.ts @@ -94,6 +94,9 @@ export function createTokenRoutes(db: DatabaseService): Router { const now = Math.floor(Date.now() / 1000); const remainingSeconds = payload.exp - now; + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Pragma', 'no-cache'); + res.json({ did: payload.did, handle: payload.handle, diff --git a/gateway/src/services/database.ts b/gateway/src/services/database.ts index e90b907..585e365 100644 --- a/gateway/src/services/database.ts +++ b/gateway/src/services/database.ts @@ -338,6 +338,56 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action); `); + // Token revocation blacklist (for JWT access tokens) + this.db.exec(` + CREATE TABLE IF NOT EXISTS revoked_tokens ( + jti TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_revoked_expires ON revoked_tokens(expires_at); + `); + + // Passkey challenges (moved from in-memory to persistent) + this.db.exec(` + CREATE TABLE IF NOT EXISTS passkey_challenges ( + challenge_key TEXT PRIMARY KEY, + challenge TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_passkey_challenge_expires ON passkey_challenges(expires_at); + `); + + // OAuth PDS session/state storage (moved from in-memory to persistent) + this.db.exec(` + CREATE TABLE IF NOT EXISTS oauth_sessions ( + key TEXT PRIMARY KEY, + data TEXT NOT NULL, + type TEXT NOT NULL, + created_at INTEGER DEFAULT (unixepoch()), + expires_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires ON oauth_sessions(expires_at); + `); + + // TOTP replay protection: last used time period + try { + this.db.exec('ALTER TABLE mfa_totp ADD COLUMN last_used_period INTEGER'); + } catch { + // Column already exists + } + + // Email verification brute-force protection + try { + this.db.exec('ALTER TABLE email_verification_codes ADD COLUMN attempts INTEGER DEFAULT 0'); + } catch { + // Column already exists + } + try { + this.db.exec('ALTER TABLE email_verification_codes ADD COLUMN locked_until INTEGER'); + } catch { + // Column already exists + } + // 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) { @@ -1656,4 +1706,105 @@ export class DatabaseService { setClientAccessCheck(clientId: string, enabled: boolean): void { this.db.prepare('UPDATE apps SET require_access_check = ? WHERE id = ?').run(enabled ? 1 : 0, clientId); } + + // --- Token revocation blacklist --- + + addRevokedToken(jti: string, expiresAt: number): void { + this.db.prepare('INSERT OR REPLACE INTO revoked_tokens (jti, expires_at) VALUES (?, ?)').run(jti, expiresAt); + } + + isTokenRevoked(jti: string): boolean { + const row = this.db.prepare('SELECT 1 FROM revoked_tokens WHERE jti = ?').get(jti); + return !!row; + } + + cleanupExpiredRevokedTokens(): number { + const now = Math.floor(Date.now() / 1000); + return this.db.prepare('DELETE FROM revoked_tokens WHERE expires_at < ?').run(now).changes; + } + + // --- Passkey challenges (persistent) --- + + savePasskeyChallenge(key: string, challenge: string, expiresAt: number): void { + this.db.prepare( + 'INSERT OR REPLACE INTO passkey_challenges (challenge_key, challenge, expires_at) VALUES (?, ?, ?)' + ).run(key, challenge, expiresAt); + } + + getPasskeyChallenge(key: string): { challenge: string; expires_at: number } | null { + const row = this.db.prepare('SELECT challenge, expires_at FROM passkey_challenges WHERE challenge_key = ?').get(key) as { challenge: string; expires_at: number } | undefined; + if (!row) return null; + if (row.expires_at < Math.floor(Date.now() / 1000)) { + this.db.prepare('DELETE FROM passkey_challenges WHERE challenge_key = ?').run(key); + return null; + } + return row; + } + + deletePasskeyChallenge(key: string): void { + this.db.prepare('DELETE FROM passkey_challenges WHERE challenge_key = ?').run(key); + } + + cleanupExpiredPasskeyChallenges(): number { + return this.db.prepare('DELETE FROM passkey_challenges WHERE expires_at < ?').run(Date.now()).changes; + } + + // --- OAuth PDS session/state storage --- + + saveOAuthSession(key: string, data: string, type: string, expiresAt?: number): void { + this.db.prepare( + 'INSERT OR REPLACE INTO oauth_sessions (key, data, type, expires_at) VALUES (?, ?, ?, ?)' + ).run(key, data, type, expiresAt ?? null); + } + + getOAuthSession(key: string): { data: string; type: string } | null { + const row = this.db.prepare('SELECT data, type FROM oauth_sessions WHERE key = ?').get(key) as { data: string; type: string } | undefined; + return row || null; + } + + deleteOAuthSession(key: string): void { + this.db.prepare('DELETE FROM oauth_sessions WHERE key = ?').run(key); + } + + cleanupExpiredOAuthSessions(): number { + const now = Math.floor(Date.now() / 1000); + return this.db.prepare('DELETE FROM oauth_sessions WHERE expires_at IS NOT NULL AND expires_at < ?').run(now).changes; + } + + // --- TOTP replay protection --- + + updateTOTPLastUsedPeriod(did: string, period: number): void { + this.db.prepare('UPDATE mfa_totp SET last_used_period = ? WHERE did = ?').run(period, did); + } + + getTOTPLastUsedPeriod(did: string): number | null { + const row = this.db.prepare('SELECT last_used_period FROM mfa_totp WHERE did = ?').get(did) as { last_used_period: number | null } | undefined; + return row?.last_used_period ?? null; + } + + // --- Email verification brute-force protection --- + + incrementEmailAttempts(email: string): number { + this.db.prepare( + 'UPDATE email_verification_codes SET attempts = COALESCE(attempts, 0) + 1 WHERE email = ? AND used = 0' + ).run(email); + const row = this.db.prepare( + 'SELECT MAX(COALESCE(attempts, 0)) as attempts FROM email_verification_codes WHERE email = ? AND used = 0' + ).get(email) as { attempts: number } | undefined; + return row?.attempts ?? 0; + } + + lockEmailVerification(email: string, until: number): void { + this.db.prepare( + 'UPDATE email_verification_codes SET locked_until = ? WHERE email = ? AND used = 0' + ).run(until, email); + } + + isEmailVerificationLocked(email: string): boolean { + const now = Date.now(); + const row = this.db.prepare( + 'SELECT 1 FROM email_verification_codes WHERE email = ? AND used = 0 AND locked_until IS NOT NULL AND locked_until > ?' + ).get(email, now); + return !!row; + } } diff --git a/gateway/src/services/email.ts b/gateway/src/services/email.ts index 32adc24..6861bac 100644 --- a/gateway/src/services/email.ts +++ b/gateway/src/services/email.ts @@ -133,10 +133,20 @@ export class EmailService { email: string, code: string ): { success: boolean; error?: string } { + // Check lockout + if (this.db.isEmailVerificationLocked(email)) { + return { success: false, error: 'Too many failed attempts. Try again later.' }; + } + const codeHash = this.hashCode(code); const verificationCode = this.db.getValidEmailVerificationCode(email, codeHash, 'verify'); if (!verificationCode) { + // Track failed attempt + const attempts = this.db.incrementEmailAttempts(email); + if (attempts >= 5) { + this.db.lockEmailVerification(email, Date.now() + 15 * 60 * 1000); + } return { success: false, error: 'Invalid or expired verification code' }; } @@ -171,10 +181,20 @@ export class EmailService { email: string, code: string ): { success: boolean; did?: string; error?: string } { + // Check lockout + if (this.db.isEmailVerificationLocked(email)) { + return { success: false, error: 'Too many failed attempts. Try again later.' }; + } + const codeHash = this.hashCode(code); const verificationCode = this.db.getValidEmailVerificationCode(email, codeHash, 'recovery'); if (!verificationCode) { + // Track failed attempt + const attempts = this.db.incrementEmailAttempts(email); + if (attempts >= 5) { + this.db.lockEmailVerification(email, Date.now() + 15 * 60 * 1000); + } return { success: false, error: 'Invalid or expired recovery code' }; } diff --git a/gateway/src/services/mfa.ts b/gateway/src/services/mfa.ts index b240b65..dcfd521 100644 --- a/gateway/src/services/mfa.ts +++ b/gateway/src/services/mfa.ts @@ -130,7 +130,18 @@ export class MFAService { }); const delta = totp.validate({ token: code, window: 1 }); - return delta !== null; + if (delta === null) return false; + + // Replay protection: compute absolute TOTP period and reject if already used + const currentPeriod = Math.floor(Date.now() / 30000); + const absolutePeriod = currentPeriod + delta; + const lastUsed = this.db.getTOTPLastUsedPeriod(did); + if (lastUsed !== null && absolutePeriod <= lastUsed) { + return false; + } + this.db.updateTOTPLastUsedPeriod(did, absolutePeriod); + + return true; } /** diff --git a/gateway/src/services/oauth.ts b/gateway/src/services/oauth.ts index 627d560..2eef4ec 100644 --- a/gateway/src/services/oauth.ts +++ b/gateway/src/services/oauth.ts @@ -8,9 +8,7 @@ import { NodeOAuthClient, NodeSavedState, NodeSavedSession } from '@atproto/oaut import { DatabaseService } from './database.js'; import type { OAuthState } from '../types/index.js'; -// In-memory storage for OAuth client sessions -const sessionStore = new Map(); -const stateStore = new Map(); +// OAuth stores backed by SQLite for persistence across restarts // Capture the most recently set state key let pendingStateKey: string | null = null; @@ -63,26 +61,30 @@ export class OAuthService { dpop_bound_access_tokens: true, }, stateStore: { - async get(key: string): Promise { - return stateStore.get(key); + get: async (key: string): Promise => { + const row = this.db.getOAuthSession(key); + return row ? JSON.parse(row.data) as NodeSavedState : undefined; }, - async set(key: string, state: NodeSavedState): Promise { - stateStore.set(key, state); + set: async (key: string, state: NodeSavedState): Promise => { + const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 min + this.db.saveOAuthSession(key, JSON.stringify(state), 'state', expiresAt); pendingStateKey = key; }, - async del(key: string): Promise { - stateStore.delete(key); + del: async (key: string): Promise => { + this.db.deleteOAuthSession(key); }, }, sessionStore: { - async get(key: string): Promise { - return sessionStore.get(key); + get: async (key: string): Promise => { + const row = this.db.getOAuthSession(key); + return row ? JSON.parse(row.data) as NodeSavedSession : undefined; }, - async set(key: string, session: NodeSavedSession): Promise { - sessionStore.set(key, session); + set: async (key: string, session: NodeSavedSession): Promise => { + const expiresAt = Math.floor(Date.now() / 1000) + 30 * 24 * 3600; // 30 days + this.db.saveOAuthSession(key, JSON.stringify(session), 'session', expiresAt); }, - async del(key: string): Promise { - sessionStore.delete(key); + del: async (key: string): Promise => { + this.db.deleteOAuthSession(key); }, }, }); @@ -187,7 +189,7 @@ export class OAuthService { * Check if a PDS session exists for a DID. */ hasPdsSession(did: string): boolean { - return sessionStore.has(did); + return !!this.db.getOAuthSession(did); } private async resolveDidToHandle(did: string): Promise { diff --git a/gateway/src/services/oidc/index.ts b/gateway/src/services/oidc/index.ts index 8903541..225bc81 100644 --- a/gateway/src/services/oidc/index.ts +++ b/gateway/src/services/oidc/index.ts @@ -31,6 +31,7 @@ export class OIDCService { this.issuer = config.issuer; this.keyManager = new KeyManager(db, config.keySecret); this.tokenService = new TokenService(this.keyManager, config.issuer); + this.tokenService.setRevocationChecker((jti) => db.isTokenRevoked(jti)); } /** @@ -63,7 +64,7 @@ export class OIDCService { 'client_secret_post', 'none', ], - code_challenge_methods_supported: ['S256', 'plain'], + code_challenge_methods_supported: ['S256'], claims_supported: [ 'sub', 'iss', diff --git a/gateway/src/services/oidc/keys.ts b/gateway/src/services/oidc/keys.ts index a1ae447..fdcbaaa 100644 --- a/gateway/src/services/oidc/keys.ts +++ b/gateway/src/services/oidc/keys.ts @@ -10,13 +10,18 @@ import type { OIDCKey, JWK, JWKS } from '../../types/index.js'; export class KeyManager { private encryptionKey: Buffer; + private legacyEncryptionKey: Buffer; constructor( private db: DatabaseService, encryptionSecret: string ) { - // Derive a 32-byte key from the secret using SHA-256 - this.encryptionKey = crypto.createHash('sha256').update(encryptionSecret).digest(); + // Derive key using HKDF (proper KDF with salt and info) + this.encryptionKey = Buffer.from( + crypto.hkdfSync('sha256', encryptionSecret, '', 'atauth-oidc-keys', 32) + ); + // Keep legacy key for migration of existing encrypted data + this.legacyEncryptionKey = crypto.createHash('sha256').update(encryptionSecret).digest(); } /** @@ -209,7 +214,7 @@ export class KeyManager { * Encrypt a private key using AES-256-GCM */ private encryptPrivateKey(pem: string): string { - const iv = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); // NIST-recommended 12 bytes for GCM const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv); const encrypted = Buffer.concat([ @@ -219,7 +224,7 @@ export class KeyManager { const authTag = cipher.getAuthTag(); - // Format: iv (16 bytes) + authTag (16 bytes) + encrypted data + // Format: iv (12 bytes) + authTag (16 bytes) + encrypted data const combined = Buffer.concat([iv, authTag, encrypted]); return combined.toString('base64'); } @@ -230,18 +235,27 @@ export class KeyManager { private decryptPrivateKey(encryptedBase64: string): string { const data = Buffer.from(encryptedBase64, 'base64'); - const iv = data.subarray(0, 16); - const authTag = data.subarray(16, 32); - const encrypted = data.subarray(32); - - const decipher = crypto.createDecipheriv('aes-256-gcm', this.encryptionKey, iv); - decipher.setAuthTag(authTag); - - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final(), - ]); + // Try combinations: [HKDF key + 12-byte IV, HKDF key + 16-byte IV, legacy key + 16-byte IV] + const attempts: [Buffer, number][] = [ + [this.encryptionKey, 12], + [this.encryptionKey, 16], + [this.legacyEncryptionKey, 16], + ]; + + for (const [key, ivLen] of attempts) { + try { + const iv = data.subarray(0, ivLen); + const authTag = data.subarray(ivLen, ivLen + 16); + const encrypted = data.subarray(ivLen + 16); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); + } catch { + continue; + } + } - return decrypted.toString('utf8'); + throw new Error('Failed to decrypt private key'); } } diff --git a/gateway/src/services/oidc/tokens.ts b/gateway/src/services/oidc/tokens.ts index 71a435d..10a1bb5 100644 --- a/gateway/src/services/oidc/tokens.ts +++ b/gateway/src/services/oidc/tokens.ts @@ -9,11 +9,20 @@ import type { KeyManager } from './keys.js'; import type { IDTokenClaims, AccessTokenClaims, TokenResponse } from '../../types/index.js'; export class TokenService { + private isRevoked?: (jti: string) => boolean; + constructor( private keyManager: KeyManager, private issuer: string ) {} + /** + * Set a revocation checker function (called during access token verification) + */ + setRevocationChecker(checker: (jti: string) => boolean): void { + this.isRevoked = checker; + } + /** * Create an ID Token */ @@ -161,6 +170,11 @@ export class TokenService { return null; } + // Check revocation blacklist + if (this.isRevoked && typeof payload.jti === 'string' && this.isRevoked(payload.jti)) { + return null; + } + return payload as unknown as AccessTokenClaims; } catch { return null; @@ -256,12 +270,12 @@ export class TokenService { kid?: string; }; - // Get the key + // Get the key — if kid is specified, require it to match let key: { privateKey: crypto.KeyObject; algorithm: 'ES256' | 'RS256' } | null = null; if (header.kid) { key = this.keyManager.getKeyByKid(header.kid); - } - if (!key) { + // Reject if kid is present but not found (don't fall back) + } else { const signingKey = this.keyManager.getSigningKey(); if (signingKey) { key = { privateKey: signingKey.privateKey, algorithm: signingKey.algorithm }; diff --git a/gateway/src/services/passkey.ts b/gateway/src/services/passkey.ts index 6452aca..570cc90 100644 --- a/gateway/src/services/passkey.ts +++ b/gateway/src/services/passkey.ts @@ -28,9 +28,6 @@ export interface PasskeyConfig { origin: string; } -// In-memory challenge storage (in production, use Redis or database) -const pendingChallenges = new Map(); - export class PasskeyService { private rpName: string; private rpID: string; @@ -76,10 +73,7 @@ export class PasskeyService { // Store challenge for verification const challengeKey = `reg:${did}`; - pendingChallenges.set(challengeKey, { - challenge: options.challenge, - expires: Date.now() + 5 * 60 * 1000, // 5 minutes - }); + this.db.savePasskeyChallenge(challengeKey, options.challenge, Math.floor(Date.now() / 1000) + 300); return options; } @@ -95,24 +89,19 @@ export class PasskeyService { ): Promise<{ success: boolean; credentialId?: string; error?: string }> { // Get stored challenge const challengeKey = `reg:${did}`; - const storedChallenge = pendingChallenges.get(challengeKey); + const storedRecord = this.db.getPasskeyChallenge(challengeKey); - if (!storedChallenge) { - return { success: false, error: 'No registration challenge found' }; - } - - if (Date.now() > storedChallenge.expires) { - pendingChallenges.delete(challengeKey); - return { success: false, error: 'Registration challenge expired' }; + if (!storedRecord) { + return { success: false, error: 'No registration challenge found or challenge expired' }; } try { const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({ response, - expectedChallenge: storedChallenge.challenge, + expectedChallenge: storedRecord.challenge, expectedOrigin: this.origin, expectedRPID: this.rpID, - requireUserVerification: false, + requireUserVerification: true, }); if (!verification.verified || !verification.registrationInfo) { @@ -135,7 +124,7 @@ export class PasskeyService { }); // Clean up challenge - pendingChallenges.delete(challengeKey); + this.db.deletePasskeyChallenge(challengeKey); return { success: true, credentialId: credential.id }; } catch (error) { @@ -165,12 +154,8 @@ export class PasskeyService { }); // Store challenge for verification - // Use a unique key since we might not know the DID yet const challengeKey = `auth:${options.challenge}`; - pendingChallenges.set(challengeKey, { - challenge: options.challenge, - expires: Date.now() + 5 * 60 * 1000, // 5 minutes - }); + this.db.savePasskeyChallenge(challengeKey, options.challenge, Math.floor(Date.now() / 1000) + 300); return options; } @@ -184,15 +169,10 @@ export class PasskeyService { ): Promise<{ success: boolean; did?: string; handle?: string; error?: string }> { // Get stored challenge const challengeKey = `auth:${expectedChallenge}`; - const storedChallenge = pendingChallenges.get(challengeKey); + const storedRecord = this.db.getPasskeyChallenge(challengeKey); - if (!storedChallenge) { - return { success: false, error: 'No authentication challenge found' }; - } - - if (Date.now() > storedChallenge.expires) { - pendingChallenges.delete(challengeKey); - return { success: false, error: 'Authentication challenge expired' }; + if (!storedRecord) { + return { success: false, error: 'No authentication challenge found or challenge expired' }; } // Look up credential @@ -206,7 +186,7 @@ export class PasskeyService { try { const verification: VerifiedAuthenticationResponse = await verifyAuthenticationResponse({ response, - expectedChallenge: storedChallenge.challenge, + expectedChallenge: storedRecord.challenge, expectedOrigin: this.origin, expectedRPID: this.rpID, credential: { @@ -215,7 +195,7 @@ export class PasskeyService { counter: credential.counter, transports: credential.transports as AuthenticatorTransportFuture[] | undefined, }, - requireUserVerification: false, + requireUserVerification: true, }); if (!verification.verified) { @@ -226,7 +206,7 @@ export class PasskeyService { this.db.updatePasskeyCounter(credential.id, verification.authenticationInfo.newCounter); // Clean up challenge - pendingChallenges.delete(challengeKey); + this.db.deletePasskeyChallenge(challengeKey); return { success: true, @@ -292,13 +272,3 @@ export class PasskeyService { return this.getPasskeyCount(did) > 0; } } - -// Cleanup expired challenges periodically -setInterval(() => { - const now = Date.now(); - for (const [key, value] of pendingChallenges.entries()) { - if (now > value.expires) { - pendingChallenges.delete(key); - } - } -}, 60 * 1000); // Every minute diff --git a/gateway/src/types/index.ts b/gateway/src/types/index.ts index 6eb110c..e109b93 100644 --- a/gateway/src/types/index.ts +++ b/gateway/src/types/index.ts @@ -37,6 +37,7 @@ export interface AppSession { created_at: Date; expires_at: Date; refresh_token?: string; + connection_state?: SessionConnectionState; } export interface AppConfig { diff --git a/gateway/src/utils/webhook.ts b/gateway/src/utils/webhook.ts index 2ff3a7e..5d742e7 100644 --- a/gateway/src/utils/webhook.ts +++ b/gateway/src/utils/webhook.ts @@ -26,9 +26,13 @@ export interface LoginEvent { let txnCounter = 0; +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + export async function notifyLogin(config: WebhookConfig, event: LoginEvent): Promise { const text = `Login: ${event.handle} authenticated to ${event.client_name}`; - const html = `Login: ${event.handle} (${event.did}) → ${event.client_name}`; + const html = `Login: ${escapeHtml(event.handle)} (${escapeHtml(event.did)}) → ${escapeHtml(event.client_name)}`; const txnId = `atauth-${Date.now()}-${++txnCounter}`; const roomId = encodeURIComponent(config.matrixRoomId); diff --git a/src/lib.rs b/src/lib.rs index 9703eec..7019ce1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,7 +5,7 @@ //! ## Features //! //! - **Token Verification**: Secure HMAC-SHA256 token verification with constant-time comparison -//! - **Session Management**: Trait-based session storage with SQLite and PostgreSQL backends +//! - **Session Management**: Trait-based session storage with SQLite backend //! - **Rate Limiting**: IP-based rate limiting with configurable thresholds //! - **Input Validation**: DID and handle format validation //! @@ -49,7 +49,7 @@ pub mod error; pub mod token; pub mod validation; -#[cfg(any(feature = "session-sqlite", feature = "session-postgres"))] +#[cfg(feature = "session-sqlite")] pub mod session; #[cfg(feature = "rate-limit")] @@ -60,7 +60,7 @@ pub use error::{AuthError, AuthResult}; pub use token::{TokenPayload, TokenVerifier}; pub use validation::{validate_did, validate_handle}; -#[cfg(any(feature = "session-sqlite", feature = "session-postgres"))] +#[cfg(feature = "session-sqlite")] pub use session::{Session, SessionManager, SessionStore}; #[cfg(feature = "rate-limit")] @@ -87,7 +87,7 @@ pub mod prelude { pub use crate::token::{TokenPayload, TokenVerifier}; pub use crate::validation::{validate_did, validate_handle}; - #[cfg(any(feature = "session-sqlite", feature = "session-postgres"))] + #[cfg(feature = "session-sqlite")] pub use crate::session::{Session, SessionManager, SessionStore}; #[cfg(feature = "rate-limit")] diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 9137727..1a90da7 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -132,7 +132,7 @@ impl RateLimiter { /// Returns `Ok(())` if allowed, or `Err(AuthError::RateLimited)` with /// the number of seconds remaining in the lockout. pub fn check(&self, ip: &IpAddr) -> AuthResult<()> { - let records = self.records.read().unwrap(); + let records = self.records.read().unwrap_or_else(|e| e.into_inner()); if let Some(record) = records.get(ip) { // Check if in lockout @@ -150,7 +150,7 @@ impl RateLimiter { /// Record a failed authentication attempt for an IP. pub fn record_failure(&self, ip: &IpAddr) { - let mut records = self.records.write().unwrap(); + let mut records = self.records.write().unwrap_or_else(|e| e.into_inner()); // Cleanup if at capacity if records.len() >= self.config.max_tracked_ips { @@ -181,13 +181,13 @@ impl RateLimiter { /// Record a successful authentication, clearing the failure count. pub fn record_success(&self, ip: &IpAddr) { - let mut records = self.records.write().unwrap(); + let mut records = self.records.write().unwrap_or_else(|e| e.into_inner()); records.remove(ip); } /// Clear rate limiting for a specific IP. pub fn clear(&self, ip: &IpAddr) { - let mut records = self.records.write().unwrap(); + let mut records = self.records.write().unwrap_or_else(|e| e.into_inner()); records.remove(ip); } @@ -195,7 +195,7 @@ impl RateLimiter { /// /// Returns `None` if not locked out or `Some(seconds)` remaining. pub fn lockout_remaining(&self, ip: &IpAddr) -> Option { - let records = self.records.read().unwrap(); + let records = self.records.read().unwrap_or_else(|e| e.into_inner()); records.get(ip).and_then(|record| { record.lockout_start.and_then(|start| { @@ -211,13 +211,13 @@ impl RateLimiter { /// Get the number of failed attempts for an IP. pub fn attempt_count(&self, ip: &IpAddr) -> u32 { - let records = self.records.read().unwrap(); + let records = self.records.read().unwrap_or_else(|e| e.into_inner()); records.get(ip).map(|r| r.attempts).unwrap_or(0) } /// Clean up expired records to free memory. pub fn cleanup(&self) { - let mut records = self.records.write().unwrap(); + let mut records = self.records.write().unwrap_or_else(|e| e.into_inner()); self.cleanup_internal(&mut records); } @@ -246,7 +246,7 @@ impl RateLimiter { /// Get current number of tracked IPs. pub fn tracked_count(&self) -> usize { - self.records.read().unwrap().len() + self.records.read().unwrap_or_else(|e| e.into_inner()).len() } } diff --git a/src/session/mod.rs b/src/session/mod.rs index 136bd55..2653e9a 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -8,13 +8,7 @@ mod store; #[cfg(feature = "session-sqlite")] mod sqlite; -#[cfg(feature = "session-postgres")] -mod postgres; - pub use store::{Session, SessionManager, SessionStore}; #[cfg(feature = "session-sqlite")] pub use sqlite::SqliteSessionStore; - -#[cfg(feature = "session-postgres")] -pub use postgres::PostgresSessionStore; diff --git a/src/session/postgres.rs b/src/session/postgres.rs deleted file mode 100644 index 05dd3cc..0000000 --- a/src/session/postgres.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! PostgreSQL session store implementation (placeholder). -//! -//! This module provides a PostgreSQL-backed session store using tokio-postgres. - -use super::{Session, SessionStore}; -use crate::error::{AuthError, AuthResult}; - -/// PostgreSQL-backed session store. -/// -/// # Example -/// -/// ```rust,ignore -/// use atauth::session::PostgresSessionStore; -/// -/// let store = PostgresSessionStore::new("postgres://user:pass@localhost/db").await?; -/// ``` -pub struct PostgresSessionStore { - // TODO: Implement with deadpool-postgres - _placeholder: (), -} - -impl PostgresSessionStore { - /// Create a new PostgreSQL session store. - pub async fn new(_connection_string: &str) -> AuthResult { - // TODO: Implement PostgreSQL connection pool - Err(AuthError::Internal( - "PostgreSQL session store not yet implemented".to_string(), - )) - } -} - -impl SessionStore for PostgresSessionStore { - fn upsert(&self, _session: &Session) -> AuthResult<()> { - Err(AuthError::Internal("Not implemented".to_string())) - } - - fn get(&self, _token: &str) -> AuthResult> { - Err(AuthError::Internal("Not implemented".to_string())) - } - - fn delete(&self, _token: &str) -> AuthResult { - Err(AuthError::Internal("Not implemented".to_string())) - } - - fn delete_by_did(&self, _did: &str) -> AuthResult { - Err(AuthError::Internal("Not implemented".to_string())) - } - - fn delete_by_user_id(&self, _user_id: i64) -> AuthResult { - Err(AuthError::Internal("Not implemented".to_string())) - } - - fn get_by_did(&self, _did: &str) -> AuthResult> { - Err(AuthError::Internal("Not implemented".to_string())) - } - - fn get_by_user_id(&self, _user_id: i64) -> AuthResult> { - Err(AuthError::Internal("Not implemented".to_string())) - } - - fn extend(&self, _token: &str, _new_expires_at: i64) -> AuthResult { - Err(AuthError::Internal("Not implemented".to_string())) - } - - fn cleanup_expired(&self) -> AuthResult { - Err(AuthError::Internal("Not implemented".to_string())) - } - - fn count_active(&self) -> AuthResult { - Err(AuthError::Internal("Not implemented".to_string())) - } -} diff --git a/src/token.rs b/src/token.rs index a3c26f0..d3ee98d 100644 --- a/src/token.rs +++ b/src/token.rs @@ -8,6 +8,7 @@ use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; use subtle::ConstantTimeEq; +use zeroize::Zeroize; use crate::error::{AuthError, AuthResult}; use crate::validation::{validate_did, validate_handle}; @@ -135,10 +136,8 @@ impl TokenVerifier { /// Create a token verifier without checking secret length. /// - /// **Warning**: This bypasses the minimum key length check. Only use this - /// for testing or when you have validated the key length yourself. - /// - /// For production code, prefer `new()` which enforces security requirements. + /// Only available in test builds. For production code, use `new()`. + #[cfg(test)] pub fn new_unchecked(secret: &[u8]) -> Self { Self { secret: secret.to_vec(), @@ -306,6 +305,12 @@ impl TokenVerifier { } } +impl Drop for TokenVerifier { + fn drop(&mut self) { + self.secret.zeroize(); + } +} + // Add hex dependency for from_hex mod hex { pub fn decode(s: &str) -> Result, String> { -- knotmirror 0.0.1