From f11d772e6736a607b0761b8acfdfe9b93b77ae2d Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Mon, 23 Feb 2026 18:24:47 -0600 Subject: [PATCH] feat: passkey login on OIDC authorize page Users with a registered passkey can now sign in via WebAuthn on the OIDC login page, skipping the Bluesky OAuth round-trip entirely. - Add POST /oauth/authorize/passkey endpoint - Wire PasskeyService through to OIDC authorize router - Add passkey button with inline WebAuthn JS (feature-detected) - 8 new tests for passkey authorize flow Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 22 +- gateway/package.json | 2 +- gateway/src/index.ts | 23 +- gateway/src/routes/oidc/authorize.test.ts | 274 ++++++++++++++++++++++ gateway/src/routes/oidc/authorize.ts | 199 +++++++++++++++- gateway/src/routes/oidc/index.ts | 6 +- 6 files changed, 487 insertions(+), 39 deletions(-) create mode 100644 gateway/src/routes/oidc/authorize.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba2fae..7dc333c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.0.3] - 2026-02-23 + +### Added + +- Passkey login on the OIDC authorize page: users with a registered passkey can now sign in with biometrics or a security key, skipping the Bluesky OAuth flow entirely +- New endpoint `POST /oauth/authorize/passkey` completes the OIDC authorization code flow via WebAuthn +- Login page shows "Sign in with a passkey" button when passkeys are enabled (feature-detected; hidden if browser lacks WebAuthn support) +- Tests for passkey authorize flow (8 new tests) + ## [2.0.2] - 2026-02-23 ### Security @@ -39,9 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- CLAUDE.md: lean rewrite, operational content moved to `.claude/commands/` skills - README.md: rewritten for OIDC provider reality (removed stale Rust/TS library references) -- Admin token rotated from placeholder to proper random token +- docs/HOMELAB.md: updated for OIDC setup wizard workflow ## [2.0.0] - 2026-02-22 @@ -68,13 +76,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Access check dry-run tool - HMAC-signed CSRF tokens on all forms - Cookie-based auth (24h TTL) alongside Bearer token auth -- **CI/CD**: Gitea Actions pipeline (test -> build -> push -> deploy to DigitalOcean k8s) +- **CI/CD**: GitHub Actions pipeline (test on push) ### Changed -- **Infrastructure**: Migrated from local k3s (Proxmox) to DigitalOcean Managed Kubernetes -- **Registry**: Moved from Gitea container registry to DigitalOcean Container Registry -- **Deployment strategy**: Recreate (RWO PVC for SQLite) +- **Infrastructure**: Docker Compose and Kubernetes deployment support - **Test suite**: Expanded from ~50 to 262 tests across 11 test files ### Fixed @@ -164,8 +170,8 @@ 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://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v2.0.0...main -[2.0.0]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v1.3.0...v2.0.0 +[2.0.1]: https://github.com/Cache8063/atauth/compare/v2.0.0...main +[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 [1.0.0]: https://github.com/Cache8063/atauth/releases/tag/v1.0.0 diff --git a/gateway/package.json b/gateway/package.json index 093cfdf..efa7129 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -1,6 +1,6 @@ { "name": "atauth-gateway", - "version": "2.0.2", + "version": "2.0.3", "description": "AT Protocol OAuth gateway for application authentication", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 841fdc2..86ddb4a 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -261,7 +261,7 @@ async function main(): Promise { // OIDC routes (if enabled) if (oidcService) { - const { wellKnownRouter, oauthRouter } = createOIDCRouter(db, oidcService, oauth); + const { wellKnownRouter, oauthRouter } = createOIDCRouter(db, oidcService, oauth, passkeyService); app.use('/.well-known', wellKnownRouter); app.use('/oauth', authRateLimit, oauthRouter); console.log('OIDC routes enabled'); @@ -324,27 +324,6 @@ async function main(): Promise { }); } - // Handle AT Protocol identity resolution failures (invalid/unresolvable handles) - if (err.constructor?.name === 'OAuthResolverError') { - const cause = (err as any).cause; - const handle = err.message.match(/identity:\s*(.+)/)?.[1] || 'unknown'; - let userMessage = `Could not resolve handle "${handle}". `; - - if (cause?.message?.includes('does not resolve to a DID')) { - userMessage += 'This handle has no _atproto DNS TXT record. Ensure the handle is configured on a PDS with proper DNS records.'; - } else if (cause?.message?.includes('not found')) { - userMessage += 'The handle was not found. Check that the handle is spelled correctly and the PDS is reachable.'; - } else { - userMessage += 'The AT Protocol identity could not be verified. Check that the handle exists and the PDS is online.'; - } - - console.error(`Identity resolution failed for "${handle}":`, cause?.message || err.message); - return res.status(400).json({ - error: 'handle_resolution_failed', - message: userMessage, - }); - } - // Log unexpected errors console.error('Unhandled error:', err); res.status(500).json({ diff --git a/gateway/src/routes/oidc/authorize.test.ts b/gateway/src/routes/oidc/authorize.test.ts new file mode 100644 index 0000000..6edc48d --- /dev/null +++ b/gateway/src/routes/oidc/authorize.test.ts @@ -0,0 +1,274 @@ +/** + * OIDC Authorize Passkey Tests + * + * Tests for passkey authentication on the OIDC authorize page. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import crypto from 'crypto'; +import { createAuthorizeRouter } from './authorize.js'; +import { DatabaseService } from '../../services/database.js'; + +function createTestApp(db: DatabaseService, mockOidc: any, mockOAuth: any, mockPasskey?: any) { + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + const router = createAuthorizeRouter(db, mockOidc, mockOAuth, mockPasskey); + app.use('/oauth', router); + + app.use((err: any, _req: any, res: any, _next: any) => { + const status = err.status || err.statusCode || 500; + res.status(status).json({ + error: err.code || 'server_error', + message: err.message, + }); + }); + + return app; +} + +function registerOIDCClient(db: DatabaseService, id = 'test-client') { + db.upsertOIDCClient({ + id, + name: 'Test App', + client_type: 'oidc', + hmac_secret: crypto.randomBytes(32).toString('hex'), + redirect_uris: ['https://app.example.com/callback'], + grant_types: ['authorization_code'], + allowed_scopes: ['openid', 'profile'], + token_ttl_seconds: 3600, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 86400, + require_pkce: false, + token_endpoint_auth_method: 'client_secret_basic', + }); +} + +function createPendingAuthCode(db: DatabaseService, code = 'test-auth-code') { + db.saveAuthorizationCode({ + code, + client_id: 'test-client', + redirect_uri: 'https://app.example.com/callback', + scope: 'openid profile', + state: 'test-state-123', + did: '', + handle: '', + created_at: Math.floor(Date.now() / 1000), + expires_at: Math.floor(Date.now() / 1000) + 600, + used: false, + }); +} + +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 /oauth/authorize/passkey', () => { + let db: DatabaseService; + let mockOidc: any; + let mockOAuth: any; + let mockPasskey: any; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + mockOidc = { + issuer: 'https://auth.example.com', + }; + mockOAuth = { + generateAuthUrl: vi.fn(), + handleCallback: vi.fn(), + }; + mockPasskey = { + verifyAuthentication: vi.fn(), + generateAuthenticationOptions: vi.fn(), + }; + registerOIDCClient(db); + app = createTestApp(db, mockOidc, mockOAuth, mockPasskey); + }); + + afterEach(() => { + db.close(); + }); + + it('should complete OIDC flow via passkey authentication', async () => { + createPendingAuthCode(db); + mockPasskey.verifyAuthentication.mockResolvedValue({ + success: true, + did: 'did:plc:test123', + handle: 'user.bsky.social', + }); + + const res = await request(app) + .post('/oauth/authorize/passkey') + .send({ + auth_code: 'test-auth-code', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(200); + expect(res.body.redirect_url).toContain('https://app.example.com/callback'); + expect(res.body.redirect_url).toContain('code=test-auth-code'); + expect(res.body.redirect_url).toContain('state=test-state-123'); + + // Verify the auth code was updated with user identity + const authData = db.getAuthorizationCode('test-auth-code'); + expect(authData?.did).toBe('did:plc:test123'); + expect(authData?.handle).toBe('user.bsky.social'); + }); + + it('should return 400 for missing parameters', async () => { + const res = await request(app) + .post('/oauth/authorize/passkey') + .send({ auth_code: 'test' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + }); + + it('should return 400 for invalid auth code', async () => { + mockPasskey.verifyAuthentication.mockResolvedValue({ + success: true, + did: 'did:plc:test123', + handle: 'user.bsky.social', + }); + + const res = await request(app) + .post('/oauth/authorize/passkey') + .send({ + auth_code: 'nonexistent-code', + 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 already-used auth code', async () => { + createPendingAuthCode(db, 'used-code'); + // Mark it as used + db.updateAuthorizationCodeUser('used-code', 'did:plc:old', 'old.bsky.social'); + const authData = db.getAuthorizationCode('used-code'); + // Manually mark as used via raw SQL since there's no direct method + db['db'].prepare('UPDATE authorization_codes SET used = 1 WHERE code = ?').run('used-code'); + + const res = await request(app) + .post('/oauth/authorize/passkey') + .send({ + auth_code: 'used-code', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + expect(res.body.error_description).toContain('already used'); + }); + + it('should return 401 for failed passkey verification', async () => { + createPendingAuthCode(db); + mockPasskey.verifyAuthentication.mockResolvedValue({ + success: false, + error: 'Unknown credential', + }); + + const res = await request(app) + .post('/oauth/authorize/passkey') + .send({ + auth_code: 'test-auth-code', + 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 404 when passkey service is not enabled', async () => { + const appNoPasskey = createTestApp(db, mockOidc, mockOAuth, null); + createPendingAuthCode(db); + + const res = await request(appNoPasskey) + .post('/oauth/authorize/passkey') + .send({ + auth_code: 'test-auth-code', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('not_found'); + }); +}); + +describe('GET /oauth/authorize - login page', () => { + let db: DatabaseService; + let mockOidc: any; + let mockOAuth: any; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + mockOidc = { issuer: 'https://auth.example.com' }; + mockOAuth = { generateAuthUrl: vi.fn(), handleCallback: vi.fn() }; + registerOIDCClient(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should render passkey button when passkey service is enabled', async () => { + const mockPasskey = { verifyAuthentication: vi.fn() }; + const app = createTestApp(db, mockOidc, mockOAuth, mockPasskey); + + const res = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: 'test-client', + redirect_uri: 'https://app.example.com/callback', + scope: 'openid profile', + state: 'test-state', + }); + + expect(res.status).toBe(200); + expect(res.text).toContain('passkeyBtn'); + expect(res.text).toContain('Sign in with a passkey'); + expect(res.text).toContain('b64urlToBuffer'); + }); + + it('should not render passkey button when passkey service is disabled', async () => { + const app = createTestApp(db, mockOidc, mockOAuth, null); + + const res = await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: 'test-client', + redirect_uri: 'https://app.example.com/callback', + scope: 'openid profile', + state: 'test-state', + }); + + expect(res.status).toBe(200); + expect(res.text).not.toContain('passkeyBtn'); + expect(res.text).not.toContain('Sign in with a 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/oidc/authorize.ts b/gateway/src/routes/oidc/authorize.ts index 1edf1e4..7e162ba 100644 --- a/gateway/src/routes/oidc/authorize.ts +++ b/gateway/src/routes/oidc/authorize.ts @@ -9,13 +9,15 @@ import crypto from 'crypto'; import type { DatabaseService } from '../../services/database.js'; import type { OIDCService } from '../../services/oidc/index.js'; import type { OAuthService } from '../../services/oauth.js'; +import type { PasskeyService } from '../../services/passkey.js'; import { parseScopes, hasOpenIdScope, validateScopes } from '../../services/oidc/claims.js'; import { isValidCodeChallengeMethod } from '../../services/oidc/pkce.js'; export function createAuthorizeRouter( db: DatabaseService, oidcService: OIDCService, - oauthService: OAuthService + oauthService: OAuthService, + passkeyService?: PasskeyService | null ): Router { const router = Router(); @@ -28,7 +30,7 @@ export function createAuthorizeRouter( .replace(/'/g, '''); } - function renderLoginPage(clientName: string, authCode: string, state: string, errorMessage?: string, nonce?: string): string { + function renderLoginPage(clientName: string, authCode: string, state: string, errorMessage?: string, nonce?: string, passkeyEnabled?: boolean): string { const errorHtml = errorMessage ? `
${esc(errorMessage)}
` : '
'; @@ -105,6 +107,28 @@ export function createAuthorizeRouter( display: none; } .logo { text-align: center; margin-bottom: 24px; font-size: 32px; } + .divider { display: flex; align-items: center; margin: 24px 0; gap: 12px; } + .divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #e0e0e0; } + .divider span { color: #999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; } + .passkey-btn { + width: 100%; + padding: 14px; + background: white; + color: #333; + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + .passkey-btn:hover { border-color: #667eea; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2); } + .passkey-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } + .passkey-icon { font-size: 20px; line-height: 1; } .privacy { font-size: 12px; color: #888; margin-top: 20px; line-height: 1.5; text-align: center; } .privacy strong { color: #555; } @@ -123,7 +147,11 @@ export function createAuthorizeRouter(

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. ${esc(clientName)} only reads your identity (handle). It will never post, follow, or access your data.

@@ -137,7 +165,96 @@ export function createAuthorizeRouter( 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.innerHTML = '🔑 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(opts) { + 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('/oauth/authorize/passkey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auth_code: document.querySelector('input[name="auth_code"]').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.innerHTML = '🔑 Sign in with a passkey'; + } + }) + .catch(function(err) { + if (err.name !== 'NotAllowedError') { + showError('Passkey authentication failed. Try signing in with your handle.'); + } + passkeyBtn.disabled = false; + passkeyBtn.innerHTML = '🔑 Sign in with a passkey'; + }); + }); + }` : ''} `; @@ -276,7 +393,7 @@ export function createAuthorizeRouter( }); // Show login page asking for handle - res.type('html').send(renderLoginPage(client.name || client_id, authCode, state || '', undefined, res.locals.cspNonce)); + res.type('html').send(renderLoginPage(client.name || client_id, authCode, state || '', undefined, res.locals.cspNonce, !!passkeyService)); } catch (error) { console.error('[OIDC Authorize] Error:', error); res.status(500).json({ @@ -382,8 +499,78 @@ export function createAuthorizeRouter( const errAuthData = errAuthCode ? db.getAuthorizationCode(errAuthCode) : null; const errClient = errAuthData ? db.getApp(errAuthData.client_id) : null; const errClientName = errClient?.name || errAuthData?.client_id || 'Unknown'; - res.status(400).type('html').send(renderLoginPage(errClientName, errAuthCode || '', errAuthData?.state || '', userMessage, res.locals.cspNonce)); + res.status(400).type('html').send(renderLoginPage(errClientName, errAuthCode || '', errAuthData?.state || '', userMessage, res.locals.cspNonce, !!passkeyService)); + } + } + }); + + /** + * POST /oauth/authorize/passkey + * Authenticate via passkey and complete the OIDC authorization flow + */ + router.post('/authorize/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_code, credential, challenge } = req.body; + + if (!auth_code || !credential || !challenge) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing required parameters: auth_code, credential, challenge', + }); } + + // Validate the pending authorization code + const authData = db.getAuthorizationCode(auth_code); + if (!authData) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Authorization request expired or invalid', + }); + } + + if (authData.used) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Authorization code already used', + }); + } + + // 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(`[OIDC Passkey] Authenticated user: ${result.handle} (${result.did})`); + + // Update the authorization code with the user's identity + db.updateAuthorizationCodeUser(auth_code, result.did, result.handle); + + // Build the redirect URL back to the original client + const clientRedirectUrl = new URL(authData.redirect_uri); + clientRedirectUrl.searchParams.set('code', auth_code); + if (authData.state) { + clientRedirectUrl.searchParams.set('state', authData.state); + } + + res.json({ redirect_url: clientRedirectUrl.toString() }); + } catch (error) { + console.error('[OIDC Passkey] Error:', error); + res.status(500).json({ + error: 'server_error', + error_description: 'Internal server error', + }); } }); diff --git a/gateway/src/routes/oidc/index.ts b/gateway/src/routes/oidc/index.ts index 840c1bf..ae9b071 100644 --- a/gateway/src/routes/oidc/index.ts +++ b/gateway/src/routes/oidc/index.ts @@ -8,6 +8,7 @@ import { Router } from 'express'; import type { DatabaseService } from '../../services/database.js'; import type { OIDCService } from '../../services/oidc/index.js'; import type { OAuthService } from '../../services/oauth.js'; +import type { PasskeyService } from '../../services/passkey.js'; import { createDiscoveryRouter } from './discovery.js'; import { createAuthorizeRouter } from './authorize.js'; @@ -19,7 +20,8 @@ import { createLogoutRouter } from './logout.js'; export function createOIDCRouter( db: DatabaseService, oidcService: OIDCService, - oauthService: OAuthService + oauthService: OAuthService, + passkeyService?: PasskeyService | null ): { wellKnownRouter: Router; oauthRouter: Router } { // Discovery endpoints go under /.well-known const wellKnownRouter = createDiscoveryRouter(oidcService); @@ -28,7 +30,7 @@ export function createOIDCRouter( const oauthRouter = Router(); // Mount sub-routers - const authorizeRouter = createAuthorizeRouter(db, oidcService, oauthService); + const authorizeRouter = createAuthorizeRouter(db, oidcService, oauthService, passkeyService); const tokenRouter = createTokenRouter(db, oidcService); const userInfoRouter = createUserInfoRouter(db, oidcService); const revokeRouter = createRevokeRouter(db); -- knotmirror 0.0.1