diff --git a/gateway/package-lock.json b/gateway/package-lock.json index fea926f..b4d46a1 100644 --- a/gateway/package-lock.json +++ b/gateway/package-lock.json @@ -17,8 +17,7 @@ "helmet": "^8.0.0", "nodemailer": "^7.0.13", "otpauth": "^9.2.1", - "qrcode": "^1.5.3", - "uuid": "^9.0.0" + "qrcode": "^1.5.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", @@ -28,7 +27,6 @@ "@types/nodemailer": "^7.0.9", "@types/qrcode": "^1.5.6", "@types/supertest": "^6.0.3", - "@types/uuid": "^9.0.7", "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.0.0", "globals": "^15.0.0", @@ -1825,13 +1823,6 @@ "@types/superagent": "^8.1.0" } }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -5356,19 +5347,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/gateway/package.json b/gateway/package.json index 7695639..b5b8b59 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -39,8 +39,7 @@ "helmet": "^8.0.0", "nodemailer": "^7.0.13", "otpauth": "^9.2.1", - "qrcode": "^1.5.3", - "uuid": "^9.0.0" + "qrcode": "^1.5.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", @@ -50,7 +49,6 @@ "@types/nodemailer": "^7.0.9", "@types/qrcode": "^1.5.6", "@types/supertest": "^6.0.3", - "@types/uuid": "^9.0.7", "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.0.0", "globals": "^15.0.0", diff --git a/gateway/src/index.ts b/gateway/src/index.ts index a14f6aa..0864479 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -206,6 +206,9 @@ async function main(): Promise { // Create Express app const app = express(); + // Trust the first proxy (k8s ingress / cloudflared) for correct client IP in rate limiting + app.set('trust proxy', 1); + // Middleware // Generate a per-request nonce for inline scripts (CSP script-src) app.use((_req, res, next) => { @@ -265,7 +268,7 @@ async function main(): Promise { // /auth/verify is called by nginx auth_request on every subrequest from a single // pod IP, so per-IP rate limiting would block legitimate traffic. if (config.forwardAuth.enabled) { - const proxyRouter = createProxyAuthRoutes(db, oauth, { ...config.forwardAuth, sessionSecret: config.forwardAuth.sessionSecret! }, config.oidc.issuer); + const proxyRouter = createProxyAuthRoutes(db, oauth, { ...config.forwardAuth, sessionSecret: config.forwardAuth.sessionSecret! }, config.oidc.issuer, passkeyService); // Mount entire proxy router at /auth -- no rate limit on /auth/verify app.use('/auth', proxyRouter); console.log('Forward-auth proxy enabled'); diff --git a/gateway/src/routes/auth.ts b/gateway/src/routes/auth.ts index af966f1..0f0d483 100644 --- a/gateway/src/routes/auth.ts +++ b/gateway/src/routes/auth.ts @@ -6,7 +6,7 @@ */ import { Router, Request, Response } from 'express'; -import { v4 as uuidv4 } from 'uuid'; +import crypto from 'crypto'; import { OAuthService } from '../services/oauth.js'; import { DatabaseService } from '../services/database.js'; import { createGatewayToken } from '../utils/hmac.js'; @@ -170,7 +170,7 @@ export function createAuthRoutes( app.token_ttl_seconds ); - const sessionId = uuidv4(); + const sessionId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + app.token_ttl_seconds * 1000); db.createSession({ @@ -241,10 +241,15 @@ export function createAuthRoutes( throw httpError.notFound('app_not_found', 'Application not found'); } + const parsedUserId = parseInt(user_id, 10); + if (!Number.isFinite(parsedUserId)) { + throw httpError.badRequest('invalid_user_id', 'user_id must be a valid integer'); + } + db.setUserMapping({ did: session.did, app_id, - user_id: parseInt(user_id, 10), + user_id: parsedUserId, handle: session.handle, }); @@ -252,7 +257,7 @@ export function createAuthRoutes( { did: session.did, handle: session.handle, - user_id: parseInt(user_id, 10), + user_id: parsedUserId, app_id, }, app.hmac_secret, @@ -264,7 +269,7 @@ export function createAuthRoutes( token, did: session.did, handle: session.handle, - user_id: parseInt(user_id, 10), + user_id: parsedUserId, }); }); diff --git a/gateway/src/routes/mfa.ts b/gateway/src/routes/mfa.ts index 6f7d2b3..89ded40 100644 --- a/gateway/src/routes/mfa.ts +++ b/gateway/src/routes/mfa.ts @@ -87,18 +87,24 @@ export function createMFARouter( /** * POST /auth/mfa/totp/verify * Verify TOTP code during login + * Requires authentication -- DID comes from the authenticated session, not the request body */ router.post('/totp/verify', async (req: Request, res: Response) => { try { - const { did, code, client_id, scope } = req.body as { - did: string; + const { did: authedDid } = await authenticateRequest(req, db, oidcService); + const { code, client_id, scope } = req.body as { code: string; client_id?: string; scope?: string; }; - if (!did || !code) { - throw new HttpError(400, 'invalid_request', 'Missing did or code'); + const did = authedDid; + if (!did) { + throw new HttpError(401, 'unauthorized', 'Authentication required'); + } + + if (!code) { + throw new HttpError(400, 'invalid_request', 'Missing code'); } const success = mfaService.verifyTOTP(did, code); @@ -217,18 +223,24 @@ export function createMFARouter( /** * POST /auth/mfa/backup-codes/verify * Verify a backup code during login + * Requires authentication -- DID comes from the authenticated session, not the request body */ router.post('/backup-codes/verify', async (req: Request, res: Response) => { try { - const { did, code, client_id, scope } = req.body as { - did: string; + const { did: authedDid } = await authenticateRequest(req, db, oidcService); + const { code, client_id, scope } = req.body as { code: string; client_id?: string; scope?: string; }; - if (!did || !code) { - throw new HttpError(400, 'invalid_request', 'Missing did or code'); + const did = authedDid; + if (!did) { + throw new HttpError(401, 'unauthorized', 'Authentication required'); + } + + if (!code) { + throw new HttpError(400, 'invalid_request', 'Missing code'); } const success = mfaService.verifyBackupCode(did, code); diff --git a/gateway/src/routes/oidc/authorize.ts b/gateway/src/routes/oidc/authorize.ts index 9ca7f38..14265e2 100644 --- a/gateway/src/routes/oidc/authorize.ts +++ b/gateway/src/routes/oidc/authorize.ts @@ -452,6 +452,11 @@ export function createAuthorizeRouter( sanitizedHandle = sanitizedHandle + '.bsky.social'; } + // Validate handle format + 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' }); + } + // Get the pending authorization const authData = db.getAuthorizationCode(auth_code); if (!authData) { diff --git a/gateway/src/routes/oidc/logout.ts b/gateway/src/routes/oidc/logout.ts index 348606a..5dd87e4 100644 --- a/gateway/src/routes/oidc/logout.ts +++ b/gateway/src/routes/oidc/logout.ts @@ -54,9 +54,15 @@ export function createLogoutRouter(db: DatabaseService, oidcService: OIDCService }); } - // Check if redirect URI is registered - // For logout, we could have a separate list, but for simplicity use redirect_uris - if (!client.redirect_uris.some((uri) => post_logout_redirect_uri.startsWith(uri.split('?')[0]))) { + // Check if redirect URI origin matches a registered redirect_uri origin + let redirectAllowed = false; + try { + const redirectOrigin = new URL(post_logout_redirect_uri).origin; + redirectAllowed = client.redirect_uris.some((uri) => { + try { return new URL(uri).origin === redirectOrigin; } catch { return false; } + }); + } catch { /* invalid URL */ } + if (!redirectAllowed) { return res.status(400).json({ error: 'invalid_request', error_description: 'Invalid post_logout_redirect_uri', diff --git a/gateway/src/routes/oidc/token.ts b/gateway/src/routes/oidc/token.ts index 959a5c8..ea8fc19 100644 --- a/gateway/src/routes/oidc/token.ts +++ b/gateway/src/routes/oidc/token.ts @@ -223,6 +223,15 @@ async function handleAuthorizationCodeGrant( } } + // Ensure the auth code has been populated with a user identity + if (!authCode.did) { + res.status(400).json({ + error: 'invalid_grant', + error_description: 'Authorization code has not been authenticated', + }); + return; + } + // Mark code as used db.markAuthorizationCodeUsed(code); diff --git a/gateway/src/routes/proxy-auth.test.ts b/gateway/src/routes/proxy-auth.test.ts index ed9f425..13b3b64 100644 --- a/gateway/src/routes/proxy-auth.test.ts +++ b/gateway/src/routes/proxy-auth.test.ts @@ -26,7 +26,7 @@ const forwardAuthConfig: ForwardAuthConfig = { proxyCookieTtl: 86400, }; -function createTestApp(db: DatabaseService) { +function createTestApp(db: DatabaseService, mockPasskey?: any) { const app = express(); app.use((_req, res, next) => { res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); @@ -41,7 +41,7 @@ function createTestApp(db: DatabaseService) { handleCallback: vi.fn(), } as any; - const router = createProxyAuthRoutes(db, mockOAuth, forwardAuthConfig, TEST_ISSUER); + const router = createProxyAuthRoutes(db, mockOAuth, forwardAuthConfig, TEST_ISSUER, mockPasskey || null); app.use('/auth', router); return { app, mockOAuth }; } @@ -601,3 +601,246 @@ describe('GET /auth/proxy/callback', () => { expect(res.text).toContain('unexpected error'); }); }); + +const mockCredential = { + id: 'credential-id-123', + rawId: 'credential-id-123', + response: { + clientDataJSON: 'mock-client-data', + authenticatorData: 'mock-auth-data', + signature: 'mock-signature', + }, + type: 'public-key', +}; + +describe('POST /auth/proxy/passkey', () => { + let db: DatabaseService; + let app: express.Application; + let mockPasskey: { verifyAuthentication: ReturnType }; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + mockPasskey = { + verifyAuthentication: vi.fn(), + }; + ({ app } = createTestApp(db, mockPasskey)); + }); + + afterEach(() => { + db.close(); + }); + + it('should complete proxy flow via passkey authentication', async () => { + const now = Math.floor(Date.now() / 1000); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); + db.saveProxyAuthRequest({ + id: 'passkey-req-1', + redirect_uri: 'https://search.example.com/path', + created_at: now, + expires_at: now + 600, + }); + + mockPasskey.verifyAuthentication.mockResolvedValue({ + success: true, + did: 'did:plc:test123', + handle: 'user.bsky.social', + }); + + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'passkey-req-1', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(200); + expect(res.body.redirect_url).toContain('search.example.com'); + expect(res.body.redirect_url).toContain('_atauth_ticket='); + + // Session cookie should be set + expect(res.headers['set-cookie']).toBeDefined(); + expect(res.headers['set-cookie'][0]).toContain(SESSION_COOKIE_NAME); + + // Auth request should be cleaned up + expect(db.getProxyAuthRequest('passkey-req-1')).toBeNull(); + + // Proxy session should exist + const sessions = db.getAllProxySessions('did:plc:test123'); + expect(sessions).toHaveLength(1); + expect(sessions[0].handle).toBe('user.bsky.social'); + }); + + it('should return 400 for missing parameters', async () => { + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ auth_request_id: 'test' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + }); + + it('should return 400 for invalid auth request', async () => { + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'nonexistent', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + expect(res.body.error_description).toContain('expired or invalid'); + }); + + it('should return 400 for expired auth request', async () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'expired-passkey-req', + redirect_uri: 'https://search.example.com/', + created_at: now - 700, + expires_at: now - 100, + }); + + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'expired-passkey-req', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(400); + expect(res.body.error_description).toContain('expired'); + }); + + it('should return 401 for failed passkey verification', async () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'fail-passkey-req', + redirect_uri: 'https://search.example.com/', + created_at: now, + expires_at: now + 600, + }); + + mockPasskey.verifyAuthentication.mockResolvedValue({ + success: false, + error: 'Unknown credential', + }); + + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'fail-passkey-req', + credential: mockCredential, + challenge: 'bad-challenge', + }); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('authentication_failed'); + expect(res.body.error_description).toContain('Unknown credential'); + }); + + it('should return 403 when access rules deny the user', async () => { + const now = Math.floor(Date.now() / 1000); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); + db.saveProxyAuthRequest({ + id: 'denied-passkey-req', + redirect_uri: 'https://search.example.com/', + created_at: now, + expires_at: now + 600, + }); + + // Only allow *.example.com handles + db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.example.com', + description: null, + }); + + mockPasskey.verifyAuthentication.mockResolvedValue({ + success: true, + did: 'did:plc:outsider', + handle: 'outsider.bsky.social', + }); + + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'denied-passkey-req', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('access_denied'); + }); + + it('should return 404 when passkey service is not enabled', async () => { + const { app: appNoPasskey } = createTestApp(db); + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'no-passkey-req', + redirect_uri: 'https://search.example.com/', + created_at: now, + expires_at: now + 600, + }); + + const res = await request(appNoPasskey) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'no-passkey-req', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('not_found'); + }); +}); + +describe('Proxy login page passkey rendering', () => { + let db: DatabaseService; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); + }); + + afterEach(() => { + db.close(); + }); + + it('should render passkey button when passkey service is enabled', async () => { + const mockPasskey = { verifyAuthentication: vi.fn() }; + const { app } = createTestApp(db, mockPasskey); + + const res = await request(app) + .get('/auth/proxy/login') + .query({ rd: 'https://search.example.com/page' }); + + expect(res.status).toBe(200); + expect(res.text).toContain('passkeyBtn'); + expect(res.text).toContain('Sign in with passkey'); + expect(res.text).toContain('b64urlToBuffer'); + expect(res.text).toContain('/auth/proxy/passkey'); + }); + + it('should not render passkey button when passkey service is disabled', async () => { + const { app } = createTestApp(db); + + const res = await request(app) + .get('/auth/proxy/login') + .query({ rd: 'https://search.example.com/page' }); + + expect(res.status).toBe(200); + expect(res.text).not.toContain('passkeyBtn'); + expect(res.text).not.toContain('Sign in with passkey'); + // Handle form should still be present + expect(res.text).toContain('loginForm'); + expect(res.text).toContain('you.bsky.social'); + }); +}); diff --git a/gateway/src/routes/proxy-auth.ts b/gateway/src/routes/proxy-auth.ts index 3909056..a10744f 100644 --- a/gateway/src/routes/proxy-auth.ts +++ b/gateway/src/routes/proxy-auth.ts @@ -18,6 +18,7 @@ import { Router, Request, Response } from 'express'; import crypto from 'crypto'; import type { DatabaseService } from '../services/database.js'; import type { OAuthService } from '../services/oauth.js'; +import type { PasskeyService } from '../services/passkey.js'; import type { ForwardAuthConfig, AccessCheckResult } from '../types/proxy.js'; import { checkAccess } from '../utils/access-check.js'; import { @@ -39,6 +40,7 @@ export function createProxyAuthRoutes( oauthService: OAuthService, forwardAuthConfig: ForwardAuthConfig, oidcIssuer: string, + passkeyService: PasskeyService | null = null, ): Router { const router = Router(); const secret = forwardAuthConfig.sessionSecret; @@ -188,7 +190,7 @@ export function createProxyAuthRoutes( expires_at: now + 600, // 10 minutes }); - res.type('html').send(renderProxyLoginPage(authRequestId, res.locals.cspNonce)); + res.type('html').send(renderProxyLoginPage(authRequestId, res.locals.cspNonce, undefined, !!passkeyService)); }); // ===== POST /auth/proxy/login ===== @@ -281,11 +283,117 @@ export function createProxyAuthRoutes( if (isJsonRequest) { res.status(400).json({ error: userMessage }); } else { - res.status(400).type('html').send(renderProxyLoginPage(auth_request_id, res.locals.cspNonce, userMessage)); + res.status(400).type('html').send(renderProxyLoginPage(auth_request_id, res.locals.cspNonce, userMessage, !!passkeyService)); } } }); + // ===== POST /auth/proxy/passkey ===== + // Authenticate via passkey for the forward-auth flow. + router.post('/proxy/passkey', async (req: Request, res: Response) => { + try { + if (!passkeyService) { + return res.status(404).json({ + error: 'not_found', + error_description: 'Passkey authentication is not enabled', + }); + } + + const { auth_request_id, credential, challenge } = req.body; + + if (!auth_request_id || !credential || !challenge) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing required parameters: auth_request_id, credential, challenge', + }); + } + + // Validate the pending auth request + const authRequest = db.getProxyAuthRequest(auth_request_id); + if (!authRequest) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Login request expired or invalid', + }); + } + + if (authRequest.expires_at < Math.floor(Date.now() / 1000)) { + db.deleteProxyAuthRequest(auth_request_id); + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Login request expired', + }); + } + + // Verify the passkey + const result = await passkeyService.verifyAuthentication(credential, challenge); + + if (!result.success || !result.did || !result.handle) { + return res.status(401).json({ + error: 'authentication_failed', + error_description: result.error || 'Passkey authentication failed', + }); + } + + console.log(`[Proxy Passkey] Authenticated user: ${result.handle} (${result.did})`); + + // Check access rules before creating session + const accessResult = enforceAccess(result.did, result.handle, authRequest.redirect_uri); + if (!accessResult.allowed) { + db.deleteProxyAuthRequest(auth_request_id); + return res.status(403).json({ + error: 'access_denied', + error_description: 'You do not have access to this service', + }); + } + + // Create a proxy session + const now = Math.floor(Date.now() / 1000); + const sessionId = crypto.randomBytes(32).toString('base64url'); + db.createProxySession({ + id: sessionId, + did: result.did, + handle: result.handle, + created_at: now, + expires_at: now + forwardAuthConfig.sessionTtl, + last_activity: now, + user_agent: req.headers['user-agent'], + ip_address: req.headers['x-forwarded-for'] as string || req.ip, + }); + + // Set the ATAuth session cookie + const sessionCookieValue = createSessionCookie(sessionId, secret, forwardAuthConfig.sessionTtl); + res.setHeader('Set-Cookie', + `${SESSION_COOKIE_NAME}=${sessionCookieValue}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${forwardAuthConfig.sessionTtl}`, + ); + + // Generate auth ticket for the redirect target + const targetOrigin = extractOrigin(authRequest.redirect_uri); + if (!targetOrigin) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Invalid redirect URI', + }); + } + + const ticket = createAuthTicket(sessionId, result.did, result.handle, targetOrigin, secret); + + // Clean up + db.deleteProxyAuthRequest(auth_request_id); + + // Redirect back to the original URL with ticket + const redirectUrl = new URL(authRequest.redirect_uri); + redirectUrl.searchParams.set('_atauth_ticket', ticket); + res.json({ redirect_url: redirectUrl.toString() }); + } catch (error) { + console.error('[Proxy Passkey] Error:', error); + res.status(500).json({ + error: 'server_error', + error_description: 'Internal server error', + }); + } + }); + // ===== GET /auth/proxy/callback ===== // AT Protocol OAuth callback for the forward-auth flow. router.get('/proxy/callback', async (req: Request, res: Response) => { @@ -443,7 +551,7 @@ function escapeHtml(str: string): string { .replace(/'/g, '''); } -function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessage?: string): string { +function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessage?: string, passkeyEnabled = false): string { const errorHtml = errorMessage ? `
${escapeHtml(errorMessage)}
` : '
'; @@ -468,7 +576,12 @@ function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessag

Enter your Bluesky handle or custom domain

- + ${passkeyEnabled ? ` +
or
+ ` : ''}

Bluesky will ask to authorize broad access -- this is a limitation of the AT Protocol OAuth standard. This gateway only reads your identity (handle). It will never post, follow, or access your data.

@@ -482,7 +595,97 @@ function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessag submitBtn.disabled = true; submitBtn.textContent = 'Redirecting...'; if (errorDiv) errorDiv.style.display = 'none'; - }); + });${passkeyEnabled ? ` + function b64urlToBuffer(s) { + var b = s.replace(/-/g, '+').replace(/_/g, '/'); + var pad = b.length % 4 === 0 ? '' : '='.repeat(4 - (b.length % 4)); + var bin = atob(b + pad); + var arr = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr.buffer; + } + function bufferToB64url(buf) { + var bytes = new Uint8Array(buf); + var bin = ''; + for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, ''); + } + function showError(msg) { + var e = document.getElementById('error'); + if (e) { e.textContent = msg; e.style.display = 'block'; } + } + var passkeyBtn = document.getElementById('passkeyBtn'); + if (passkeyBtn && window.PublicKeyCredential) { + passkeyBtn.style.display = 'flex'; + passkeyBtn.addEventListener('click', function() { + passkeyBtn.disabled = true; + passkeyBtn.textContent = 'Authenticating...'; + if (errorDiv) errorDiv.style.display = 'none'; + fetch('/auth/passkey/authenticate/options', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + var opts = data.options || data; + var pubKeyOpts = { + challenge: b64urlToBuffer(opts.challenge), + timeout: opts.timeout, + rpId: opts.rpId, + userVerification: opts.userVerification + }; + if (opts.allowCredentials && opts.allowCredentials.length > 0) { + pubKeyOpts.allowCredentials = opts.allowCredentials.map(function(c) { + return { id: b64urlToBuffer(c.id), type: c.type, transports: c.transports }; + }); + } + return navigator.credentials.get({ publicKey: pubKeyOpts }).then(function(cred) { + return { cred: cred, challenge: opts.challenge }; + }); + }) + .then(function(res) { + var cred = res.cred; + return fetch('/auth/proxy/passkey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auth_request_id: document.querySelector('input[name="auth_request_id"]').value, + challenge: res.challenge, + credential: { + id: cred.id, + rawId: bufferToB64url(cred.rawId), + response: { + clientDataJSON: bufferToB64url(cred.response.clientDataJSON), + authenticatorData: bufferToB64url(cred.response.authenticatorData), + signature: bufferToB64url(cred.response.signature), + userHandle: cred.response.userHandle ? bufferToB64url(cred.response.userHandle) : undefined + }, + type: cred.type, + authenticatorAttachment: cred.authenticatorAttachment + } + }) + }); + }) + .then(function(r) { return r.json(); }) + .then(function(result) { + if (result.redirect_url) { + window.location.href = result.redirect_url; + } else { + showError(result.error_description || 'Passkey authentication failed'); + passkeyBtn.disabled = false; + passkeyBtn.textContent = 'Sign in with passkey'; + } + }) + .catch(function(err) { + if (err.name !== 'NotAllowedError') { + showError('Passkey authentication failed. Try signing in with your handle.'); + } + passkeyBtn.disabled = false; + passkeyBtn.textContent = 'Sign in with passkey'; + }); + }); + }` : ''} `; @@ -644,6 +847,29 @@ function sharedStyles(): string { font-size: 14px; display: none; } + .divider { display: flex; align-items: center; margin: 24px 0; gap: 12px; } + .divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #334155; } + .divider span { color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; } + .passkey-btn { + width: 100%; + padding: 14px; + background: transparent; + color: #e2e8f0; + border: 1px solid #334155; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: border-color 0.2s, transform 0.15s, box-shadow 0.15s, background 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 0; + } + .passkey-btn:hover { border-color: #3b82f6; background: rgba(59, 130, 246, 0.06); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1); } + .passkey-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } + .passkey-btn svg { width: 18px; height: 18px; } .privacy { font-size: 12px; color: #64748b; margin-top: 24px; line-height: 1.6; text-align: center; } .privacy strong { color: #94a3b8; } `; diff --git a/gateway/src/services/database.ts b/gateway/src/services/database.ts index 17e7e8b..c211a2e 100644 --- a/gateway/src/services/database.ts +++ b/gateway/src/services/database.ts @@ -415,7 +415,7 @@ export class DatabaseService { } getSession(sessionId: string): AppSession | null { - const stmt = this.db.prepare('SELECT * FROM sessions WHERE id = ?'); + const stmt = this.db.prepare('SELECT * FROM sessions WHERE id = ? AND expires_at > datetime(\'now\')'); const row = stmt.get(sessionId) as (Omit & { created_at: string; expires_at: string; diff --git a/gateway/src/services/oidc/pkce.test.ts b/gateway/src/services/oidc/pkce.test.ts index f2f7efc..1c5c97f 100644 --- a/gateway/src/services/oidc/pkce.test.ts +++ b/gateway/src/services/oidc/pkce.test.ts @@ -65,11 +65,11 @@ describe('PKCE', () => { expect(verifyCodeChallenge(wrongVerifier, challenge, 'S256')).toBe(false); }); - it('should verify plain challenge correctly', () => { + it('should reject plain method as insecure', () => { const verifier = generateCodeVerifier(); const challenge = generateCodeChallenge(verifier, 'plain'); - expect(verifyCodeChallenge(verifier, challenge, 'plain')).toBe(true); + expect(verifyCodeChallenge(verifier, challenge, 'plain')).toBe(false); }); it('should reject invalid plain verifier', () => { @@ -123,7 +123,10 @@ describe('PKCE', () => { describe('isValidCodeChallengeMethod', () => { it('should accept valid methods', () => { expect(isValidCodeChallengeMethod('S256')).toBe(true); - expect(isValidCodeChallengeMethod('plain')).toBe(true); + }); + + it('should reject plain method', () => { + expect(isValidCodeChallengeMethod('plain')).toBe(false); }); it('should reject invalid methods', () => { diff --git a/gateway/src/services/oidc/pkce.ts b/gateway/src/services/oidc/pkce.ts index 8deb9b9..3c4e608 100644 --- a/gateway/src/services/oidc/pkce.ts +++ b/gateway/src/services/oidc/pkce.ts @@ -14,15 +14,20 @@ export function verifyCodeChallenge( codeChallenge: string, method: 'S256' | 'plain' = 'S256' ): boolean { + // Reject plain method -- only S256 is secure if (method === 'plain') { - return codeVerifier === codeChallenge; + return false; } // S256: SHA256(code_verifier) base64url encoded const hash = crypto.createHash('sha256').update(codeVerifier).digest(); const computed = hash.toString('base64url'); - return computed === codeChallenge; + // Constant-time comparison to prevent timing attacks + const a = Buffer.from(computed); + const b = Buffer.from(codeChallenge); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); } /** @@ -59,6 +64,6 @@ export function isValidCodeVerifier(codeVerifier: string): boolean { /** * Validate code challenge method */ -export function isValidCodeChallengeMethod(method: string): method is 'S256' | 'plain' { - return method === 'S256' || method === 'plain'; +export function isValidCodeChallengeMethod(method: string): method is 'S256' { + return method === 'S256'; }