AtAuth
7

Configure Feed

Select the types of activity you want to include in your feed.

test: add OIDC endpoint and passkey service tests

Add tests for OIDC revocation (RFC 7009), logout/end_session,
userinfo endpoint, and passkey service (WebAuthn registration,
authentication, credential management). Raises overall coverage
from ~50% to ~56% statements.

Bryan Brooks (Feb 24, 2026, 8:08 PM -0600) 502faf47 23ddd24f

+879
+164
gateway/src/routes/oidc/logout.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 + import express from 'express'; 3 + import request from 'supertest'; 4 + import crypto from 'crypto'; 5 + import { createLogoutRouter } from './logout.js'; 6 + import { DatabaseService } from '../../services/database.js'; 7 + 8 + function createMockOIDCService(verifyResult: any = null) { 9 + return { 10 + tokenService: { 11 + verifyIdToken: vi.fn().mockReturnValue(verifyResult), 12 + verifyAccessToken: vi.fn(), 13 + createTokenResponse: vi.fn(), 14 + }, 15 + keyService: { getPublicKeySet: vi.fn() }, 16 + } as any; 17 + } 18 + 19 + function createTestApp(db: DatabaseService, oidcService: any) { 20 + const app = express(); 21 + app.use(express.json()); 22 + app.use(express.urlencoded({ extended: true })); 23 + 24 + const router = createLogoutRouter(db, oidcService); 25 + app.use('/oauth', router); 26 + 27 + return app; 28 + } 29 + 30 + function registerOIDCClient(db: DatabaseService, id = 'test-client') { 31 + db.upsertOIDCClient({ 32 + id, 33 + name: 'Test App', 34 + client_type: 'oidc', 35 + hmac_secret: crypto.randomBytes(32).toString('hex'), 36 + redirect_uris: ['https://app.example.com/callback'], 37 + grant_types: ['authorization_code'], 38 + allowed_scopes: ['openid', 'profile'], 39 + token_ttl_seconds: 3600, 40 + id_token_ttl_seconds: 3600, 41 + access_token_ttl_seconds: 3600, 42 + refresh_token_ttl_seconds: 86400, 43 + require_pkce: false, 44 + token_endpoint_auth_method: 'client_secret_basic', 45 + }); 46 + } 47 + 48 + describe('GET /oauth/end_session', () => { 49 + let db: DatabaseService; 50 + 51 + beforeEach(() => { 52 + db = new DatabaseService(':memory:'); 53 + }); 54 + 55 + afterEach(() => db.close()); 56 + 57 + it('should render logged out page when no redirect URI', async () => { 58 + const oidcService = createMockOIDCService(); 59 + const app = createTestApp(db, oidcService); 60 + 61 + const res = await request(app).get('/oauth/end_session'); 62 + 63 + expect(res.status).toBe(200); 64 + expect(res.text).toContain('Logged Out'); 65 + expect(res.text).toContain('successfully logged out'); 66 + }); 67 + 68 + it('should redirect to post_logout_redirect_uri when valid', async () => { 69 + registerOIDCClient(db); 70 + const oidcService = createMockOIDCService(); 71 + const app = createTestApp(db, oidcService); 72 + 73 + const res = await request(app) 74 + .get('/oauth/end_session') 75 + .query({ 76 + client_id: 'test-client', 77 + post_logout_redirect_uri: 'https://app.example.com/callback', 78 + }); 79 + 80 + expect(res.status).toBe(302); 81 + expect(res.headers.location).toContain('app.example.com/callback'); 82 + }); 83 + 84 + it('should append state to redirect', async () => { 85 + registerOIDCClient(db); 86 + const oidcService = createMockOIDCService(); 87 + const app = createTestApp(db, oidcService); 88 + 89 + const res = await request(app) 90 + .get('/oauth/end_session') 91 + .query({ 92 + client_id: 'test-client', 93 + post_logout_redirect_uri: 'https://app.example.com/callback', 94 + state: 'my-state-123', 95 + }); 96 + 97 + expect(res.status).toBe(302); 98 + expect(res.headers.location).toContain('state=my-state-123'); 99 + }); 100 + 101 + it('should reject invalid post_logout_redirect_uri', async () => { 102 + registerOIDCClient(db); 103 + const oidcService = createMockOIDCService(); 104 + const app = createTestApp(db, oidcService); 105 + 106 + const res = await request(app) 107 + .get('/oauth/end_session') 108 + .query({ 109 + client_id: 'test-client', 110 + post_logout_redirect_uri: 'https://evil.example.com/steal', 111 + }); 112 + 113 + expect(res.status).toBe(400); 114 + expect(res.body.error).toBe('invalid_request'); 115 + expect(res.body.error_description).toContain('Invalid post_logout_redirect_uri'); 116 + }); 117 + 118 + it('should reject unknown client_id', async () => { 119 + const oidcService = createMockOIDCService(); 120 + const app = createTestApp(db, oidcService); 121 + 122 + const res = await request(app) 123 + .get('/oauth/end_session') 124 + .query({ 125 + client_id: 'nonexistent', 126 + post_logout_redirect_uri: 'https://app.example.com', 127 + }); 128 + 129 + expect(res.status).toBe(400); 130 + expect(res.body.error_description).toContain('Unknown client'); 131 + }); 132 + 133 + it('should extract client_id from id_token_hint', async () => { 134 + registerOIDCClient(db); 135 + const oidcService = createMockOIDCService({ sub: 'did:plc:test', aud: 'test-client' }); 136 + const app = createTestApp(db, oidcService); 137 + 138 + const res = await request(app) 139 + .get('/oauth/end_session') 140 + .query({ 141 + id_token_hint: 'mock-id-token', 142 + post_logout_redirect_uri: 'https://app.example.com/callback', 143 + }); 144 + 145 + expect(res.status).toBe(302); 146 + expect(oidcService.tokenService.verifyIdToken).toHaveBeenCalledWith('mock-id-token'); 147 + }); 148 + 149 + it('should revoke refresh tokens on logout with id_token_hint', async () => { 150 + registerOIDCClient(db); 151 + const oidcService = createMockOIDCService({ sub: 'did:plc:test', aud: 'test-client' }); 152 + const app = createTestApp(db, oidcService); 153 + 154 + const revokeSpy = vi.spyOn(db, 'revokeAllRefreshTokensForUser'); 155 + 156 + await request(app) 157 + .get('/oauth/end_session') 158 + .query({ 159 + id_token_hint: 'mock-id-token', 160 + }); 161 + 162 + expect(revokeSpy).toHaveBeenCalledWith('did:plc:test', 'test-client'); 163 + }); 164 + });
+165
gateway/src/routes/oidc/revoke.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 + import express from 'express'; 3 + import request from 'supertest'; 4 + import crypto from 'crypto'; 5 + import { createRevokeRouter } from './revoke.js'; 6 + import { DatabaseService } from '../../services/database.js'; 7 + 8 + function createTestApp(db: DatabaseService) { 9 + const app = express(); 10 + app.use(express.json()); 11 + app.use(express.urlencoded({ extended: true })); 12 + 13 + const router = createRevokeRouter(db); 14 + app.use('/oauth', router); 15 + 16 + return app; 17 + } 18 + 19 + function registerOIDCClient(db: DatabaseService, id = 'test-client', secret = 'my-secret') { 20 + const secretHash = crypto.createHash('sha256').update(secret).digest('hex'); 21 + db.upsertOIDCClient({ 22 + id, 23 + name: 'Test App', 24 + client_type: 'oidc', 25 + hmac_secret: crypto.randomBytes(32).toString('hex'), 26 + client_secret: secretHash, 27 + redirect_uris: ['https://app.example.com/callback'], 28 + grant_types: ['authorization_code', 'refresh_token'], 29 + allowed_scopes: ['openid', 'profile'], 30 + token_ttl_seconds: 3600, 31 + id_token_ttl_seconds: 3600, 32 + access_token_ttl_seconds: 3600, 33 + refresh_token_ttl_seconds: 86400, 34 + require_pkce: false, 35 + token_endpoint_auth_method: 'client_secret_basic', 36 + }); 37 + } 38 + 39 + function createRefreshToken(db: DatabaseService, token: string, clientId = 'test-client') { 40 + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); 41 + db.saveRefreshToken({ 42 + token_hash: tokenHash, 43 + client_id: clientId, 44 + did: 'did:plc:testuser', 45 + handle: 'test.bsky.social', 46 + scope: 'openid profile', 47 + expires_at: new Date(Date.now() + 86400 * 1000), 48 + family_id: `family-${Date.now()}`, 49 + }); 50 + return tokenHash; 51 + } 52 + 53 + describe('POST /oauth/revoke', () => { 54 + let db: DatabaseService; 55 + let app: express.Application; 56 + 57 + beforeEach(() => { 58 + db = new DatabaseService(':memory:'); 59 + app = createTestApp(db); 60 + registerOIDCClient(db); 61 + }); 62 + 63 + afterEach(() => db.close()); 64 + 65 + it('should return 200 for missing token (per RFC 7009)', async () => { 66 + const res = await request(app) 67 + .post('/oauth/revoke') 68 + .send({}); 69 + 70 + expect(res.status).toBe(200); 71 + }); 72 + 73 + it('should revoke a refresh token', async () => { 74 + const rawToken = 'test-refresh-token-123'; 75 + const tokenHash = createRefreshToken(db, rawToken); 76 + 77 + const res = await request(app) 78 + .post('/oauth/revoke') 79 + .send({ token: rawToken, client_id: 'test-client', client_secret: 'my-secret' }); 80 + 81 + expect(res.status).toBe(200); 82 + 83 + // Token should be revoked in DB 84 + const stored = db.getRefreshToken(tokenHash); 85 + expect(stored?.revoked).toBe(true); 86 + }); 87 + 88 + it('should accept client credentials via Basic auth', async () => { 89 + const rawToken = 'test-refresh-token-basic'; 90 + createRefreshToken(db, rawToken); 91 + 92 + const credentials = Buffer.from('test-client:my-secret').toString('base64'); 93 + const res = await request(app) 94 + .post('/oauth/revoke') 95 + .set('Authorization', `Basic ${credentials}`) 96 + .send({ token: rawToken }); 97 + 98 + expect(res.status).toBe(200); 99 + }); 100 + 101 + it('should return 401 for unknown client', async () => { 102 + const res = await request(app) 103 + .post('/oauth/revoke') 104 + .send({ token: 'some-token', client_id: 'nonexistent' }); 105 + 106 + expect(res.status).toBe(401); 107 + expect(res.body.error).toBe('invalid_client'); 108 + }); 109 + 110 + it('should return 401 for wrong client secret', async () => { 111 + const res = await request(app) 112 + .post('/oauth/revoke') 113 + .send({ token: 'some-token', client_id: 'test-client', client_secret: 'wrong-secret' }); 114 + 115 + expect(res.status).toBe(401); 116 + expect(res.body.error).toBe('invalid_client'); 117 + }); 118 + 119 + it('should return 401 for missing client secret when required', async () => { 120 + const res = await request(app) 121 + .post('/oauth/revoke') 122 + .send({ token: 'some-token', client_id: 'test-client' }); 123 + 124 + expect(res.status).toBe(401); 125 + expect(res.body.error).toBe('invalid_client'); 126 + expect(res.body.error_description).toContain('Client authentication required'); 127 + }); 128 + 129 + it('should return 200 for unknown token (per RFC 7009)', async () => { 130 + const res = await request(app) 131 + .post('/oauth/revoke') 132 + .send({ token: 'nonexistent-token', client_id: 'test-client', client_secret: 'my-secret' }); 133 + 134 + expect(res.status).toBe(200); 135 + }); 136 + 137 + it('should return 200 for token belonging to different client', async () => { 138 + // Register a second client so FK constraint is satisfied 139 + registerOIDCClient(db, 'other-client', 'other-secret'); 140 + 141 + const rawToken = 'other-client-token'; 142 + createRefreshToken(db, rawToken, 'other-client'); 143 + 144 + const res = await request(app) 145 + .post('/oauth/revoke') 146 + .send({ token: rawToken, client_id: 'test-client', client_secret: 'my-secret' }); 147 + 148 + // Per RFC 7009, still returns 200 149 + expect(res.status).toBe(200); 150 + }); 151 + 152 + it('should handle access token revocation (returns 200)', async () => { 153 + const res = await request(app) 154 + .post('/oauth/revoke') 155 + .send({ 156 + token: 'some-access-token', 157 + token_type_hint: 'access_token', 158 + client_id: 'test-client', 159 + client_secret: 'my-secret', 160 + }); 161 + 162 + // Access tokens can't be truly revoked (JWT), returns 200 per RFC 163 + expect(res.status).toBe(200); 164 + }); 165 + });
+179
gateway/src/routes/oidc/userinfo.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 + import express from 'express'; 3 + import request from 'supertest'; 4 + import { createUserInfoRouter } from './userinfo.js'; 5 + import { DatabaseService } from '../../services/database.js'; 6 + 7 + function createMockOIDCService(accessTokenClaims: any = null) { 8 + return { 9 + tokenService: { 10 + verifyAccessToken: vi.fn().mockReturnValue(accessTokenClaims), 11 + verifyIdToken: vi.fn(), 12 + createTokenResponse: vi.fn(), 13 + }, 14 + keyService: { getPublicKeySet: vi.fn() }, 15 + } as any; 16 + } 17 + 18 + function createTestApp(db: DatabaseService, oidcService: any) { 19 + const app = express(); 20 + app.use(express.json()); 21 + 22 + const router = createUserInfoRouter(db, oidcService); 23 + app.use('/oauth', router); 24 + 25 + return app; 26 + } 27 + 28 + describe('/oauth/userinfo', () => { 29 + let db: DatabaseService; 30 + 31 + beforeEach(() => { 32 + db = new DatabaseService(':memory:'); 33 + // Mock the global fetch for AT Protocol API calls 34 + vi.spyOn(global, 'fetch').mockResolvedValue({ 35 + ok: true, 36 + json: async () => ({ handle: 'test.bsky.social' }), 37 + } as Response); 38 + }); 39 + 40 + afterEach(() => { 41 + db.close(); 42 + vi.restoreAllMocks(); 43 + }); 44 + 45 + it('should return 401 for missing Authorization header', async () => { 46 + const oidcService = createMockOIDCService(); 47 + const app = createTestApp(db, oidcService); 48 + 49 + const res = await request(app).get('/oauth/userinfo'); 50 + 51 + expect(res.status).toBe(401); 52 + expect(res.body.error).toBe('invalid_token'); 53 + }); 54 + 55 + it('should return 401 for non-Bearer auth', async () => { 56 + const oidcService = createMockOIDCService(); 57 + const app = createTestApp(db, oidcService); 58 + 59 + const res = await request(app) 60 + .get('/oauth/userinfo') 61 + .set('Authorization', 'Basic abc123'); 62 + 63 + expect(res.status).toBe(401); 64 + expect(res.body.error).toBe('invalid_token'); 65 + }); 66 + 67 + it('should return 401 for invalid access token', async () => { 68 + const oidcService = createMockOIDCService(null); // null = invalid token 69 + const app = createTestApp(db, oidcService); 70 + 71 + const res = await request(app) 72 + .get('/oauth/userinfo') 73 + .set('Authorization', 'Bearer invalid-token'); 74 + 75 + expect(res.status).toBe(401); 76 + expect(res.body.error).toBe('invalid_token'); 77 + expect(res.body.error_description).toContain('expired'); 78 + }); 79 + 80 + it('should return user info for valid token with openid scope', async () => { 81 + const oidcService = createMockOIDCService({ 82 + sub: 'did:plc:testuser', 83 + scope: 'openid', 84 + client_id: 'test-client', 85 + }); 86 + const app = createTestApp(db, oidcService); 87 + 88 + const res = await request(app) 89 + .get('/oauth/userinfo') 90 + .set('Authorization', 'Bearer valid-token'); 91 + 92 + expect(res.status).toBe(200); 93 + expect(res.body.sub).toBe('did:plc:testuser'); 94 + }); 95 + 96 + it('should return profile claims for profile scope', async () => { 97 + const oidcService = createMockOIDCService({ 98 + sub: 'did:plc:testuser', 99 + scope: 'openid profile', 100 + client_id: 'test-client', 101 + }); 102 + const app = createTestApp(db, oidcService); 103 + 104 + const res = await request(app) 105 + .get('/oauth/userinfo') 106 + .set('Authorization', 'Bearer valid-token'); 107 + 108 + expect(res.status).toBe(200); 109 + expect(res.body.sub).toBe('did:plc:testuser'); 110 + expect(res.body.preferred_username).toBe('test.bsky.social'); 111 + }); 112 + 113 + it('should support POST method', async () => { 114 + const oidcService = createMockOIDCService({ 115 + sub: 'did:plc:testuser', 116 + scope: 'openid', 117 + client_id: 'test-client', 118 + }); 119 + const app = createTestApp(db, oidcService); 120 + 121 + const res = await request(app) 122 + .post('/oauth/userinfo') 123 + .set('Authorization', 'Bearer valid-token'); 124 + 125 + expect(res.status).toBe(200); 126 + expect(res.body.sub).toBe('did:plc:testuser'); 127 + }); 128 + 129 + it('should use user mapping handle when available', async () => { 130 + const oidcService = createMockOIDCService({ 131 + sub: 'did:plc:testuser', 132 + scope: 'openid profile', 133 + client_id: 'test-app', 134 + }); 135 + 136 + // Register app and create user mapping 137 + db.upsertApp({ 138 + id: 'test-app', 139 + name: 'Test', 140 + hmac_secret: 'a'.repeat(64), 141 + token_ttl_seconds: 3600, 142 + }); 143 + db.setUserMapping({ 144 + did: 'did:plc:testuser', 145 + app_id: 'test-app', 146 + user_id: 1, 147 + handle: 'mapped.handle.social', 148 + }); 149 + 150 + const app = createTestApp(db, oidcService); 151 + 152 + const res = await request(app) 153 + .get('/oauth/userinfo') 154 + .set('Authorization', 'Bearer valid-token'); 155 + 156 + expect(res.status).toBe(200); 157 + expect(res.body.preferred_username).toBe('mapped.handle.social'); 158 + // Should NOT have called fetch since mapping was found 159 + expect(global.fetch).not.toHaveBeenCalled(); 160 + }); 161 + 162 + it('should fall back to DID when API call fails', async () => { 163 + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error')); 164 + 165 + const oidcService = createMockOIDCService({ 166 + sub: 'did:plc:testuser', 167 + scope: 'openid profile', 168 + client_id: 'test-client', 169 + }); 170 + const app = createTestApp(db, oidcService); 171 + 172 + const res = await request(app) 173 + .get('/oauth/userinfo') 174 + .set('Authorization', 'Bearer valid-token'); 175 + 176 + expect(res.status).toBe(200); 177 + expect(res.body.preferred_username).toBe('did:plc:testuser'); 178 + }); 179 + });
+371
gateway/src/services/passkey.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 + import { PasskeyService } from './passkey.js'; 3 + import { DatabaseService } from './database.js'; 4 + 5 + // Mock @simplewebauthn/server 6 + vi.mock('@simplewebauthn/server', () => ({ 7 + generateRegistrationOptions: vi.fn().mockResolvedValue({ 8 + challenge: 'mock-challenge-registration', 9 + rp: { name: 'Test', id: 'localhost' }, 10 + user: { id: 'dGVzdA', name: 'test', displayName: 'test' }, 11 + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], 12 + authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, 13 + }), 14 + verifyRegistrationResponse: vi.fn().mockResolvedValue({ 15 + verified: true, 16 + registrationInfo: { 17 + credentialID: 'cred-id-123', 18 + credentialPublicKey: Buffer.from('public-key-bytes'), 19 + counter: 0, 20 + credentialDeviceType: 'singleDevice', 21 + credentialBackedUp: false, 22 + }, 23 + }), 24 + generateAuthenticationOptions: vi.fn().mockResolvedValue({ 25 + challenge: 'mock-challenge-authentication', 26 + rpId: 'localhost', 27 + allowCredentials: [], 28 + userVerification: 'preferred', 29 + }), 30 + verifyAuthenticationResponse: vi.fn().mockResolvedValue({ 31 + verified: true, 32 + authenticationInfo: { 33 + newCounter: 1, 34 + credentialID: 'cred-id-123', 35 + }, 36 + }), 37 + })); 38 + 39 + const PASSKEY_CONFIG = { 40 + rpName: 'Test RP', 41 + rpID: 'localhost', 42 + origin: 'http://localhost:3000', 43 + }; 44 + 45 + describe('PasskeyService', () => { 46 + let db: DatabaseService; 47 + let service: PasskeyService; 48 + 49 + beforeEach(() => { 50 + db = new DatabaseService(':memory:'); 51 + service = new PasskeyService(db, PASSKEY_CONFIG); 52 + }); 53 + 54 + afterEach(() => { 55 + db.close(); 56 + vi.clearAllMocks(); 57 + }); 58 + 59 + describe('generateRegistrationOptions', () => { 60 + it('should return registration options', async () => { 61 + const options = await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social'); 62 + expect(options).toBeDefined(); 63 + expect(options.challenge).toBe('mock-challenge-registration'); 64 + }); 65 + 66 + it('should exclude existing credentials', async () => { 67 + // Save an existing credential 68 + db.savePasskeyCredential({ 69 + id: 'existing-cred', 70 + did: 'did:plc:test', 71 + handle: 'test.bsky.social', 72 + public_key: Buffer.from('key').toString('base64'), 73 + counter: 0, 74 + device_type: 'platform', 75 + backed_up: false, 76 + transports: null, 77 + name: null, 78 + }); 79 + 80 + await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social'); 81 + 82 + const { generateRegistrationOptions } = await import('@simplewebauthn/server'); 83 + expect(generateRegistrationOptions).toHaveBeenCalledWith( 84 + expect.objectContaining({ 85 + excludeCredentials: expect.arrayContaining([ 86 + expect.objectContaining({ id: 'existing-cred' }), 87 + ]), 88 + }) 89 + ); 90 + }); 91 + }); 92 + 93 + describe('verifyRegistration', () => { 94 + it('should verify and store a credential', async () => { 95 + // First generate options to store challenge 96 + await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social'); 97 + 98 + const result = await service.verifyRegistration( 99 + 'did:plc:test', 100 + 'test.bsky.social', 101 + { id: 'cred-1', rawId: 'raw', response: { clientDataJSON: 'x', attestationObject: 'y', transports: ['internal'] }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' }, 102 + 'My Passkey', 103 + ); 104 + 105 + expect(result.success).toBe(true); 106 + expect(result.credentialId).toBe('cred-id-123'); 107 + 108 + // Credential should be stored in DB 109 + const stored = db.getPasskeyCredential('cred-id-123'); 110 + expect(stored).not.toBeNull(); 111 + expect(stored!.did).toBe('did:plc:test'); 112 + }); 113 + 114 + it('should return error when no challenge exists', async () => { 115 + const result = await service.verifyRegistration( 116 + 'did:plc:unknown', 117 + 'unknown', 118 + { id: 'x', rawId: 'x', response: { clientDataJSON: 'x', attestationObject: 'y' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' } as any, 119 + ); 120 + 121 + expect(result.success).toBe(false); 122 + expect(result.error).toContain('No registration challenge'); 123 + }); 124 + 125 + it('should return error when challenge is expired', async () => { 126 + vi.useFakeTimers(); 127 + const now = Date.now(); 128 + vi.setSystemTime(now); 129 + 130 + await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social'); 131 + 132 + // Advance past 5 minute expiry 133 + vi.setSystemTime(now + 6 * 60 * 1000); 134 + 135 + const result = await service.verifyRegistration( 136 + 'did:plc:test', 137 + 'test.bsky.social', 138 + { id: 'x', rawId: 'x', response: { clientDataJSON: 'x', attestationObject: 'y' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' } as any, 139 + ); 140 + 141 + expect(result.success).toBe(false); 142 + expect(result.error).toContain('expired'); 143 + 144 + vi.useRealTimers(); 145 + }); 146 + }); 147 + 148 + describe('generateAuthenticationOptions', () => { 149 + it('should generate options without DID (discoverable)', async () => { 150 + const options = await service.generateAuthenticationOptions(); 151 + expect(options).toBeDefined(); 152 + expect(options.challenge).toBe('mock-challenge-authentication'); 153 + }); 154 + 155 + it('should include credentials when DID provided', async () => { 156 + db.savePasskeyCredential({ 157 + id: 'user-cred', 158 + did: 'did:plc:test', 159 + handle: 'test.bsky.social', 160 + public_key: Buffer.from('key').toString('base64'), 161 + counter: 0, 162 + device_type: 'platform', 163 + backed_up: false, 164 + transports: ['internal'], 165 + name: null, 166 + }); 167 + 168 + await service.generateAuthenticationOptions('did:plc:test'); 169 + 170 + const { generateAuthenticationOptions } = await import('@simplewebauthn/server'); 171 + expect(generateAuthenticationOptions).toHaveBeenCalledWith( 172 + expect.objectContaining({ 173 + allowCredentials: expect.arrayContaining([ 174 + expect.objectContaining({ id: 'user-cred' }), 175 + ]), 176 + }) 177 + ); 178 + }); 179 + }); 180 + 181 + describe('verifyAuthentication', () => { 182 + it('should verify and return user info', async () => { 183 + // Store credential 184 + db.savePasskeyCredential({ 185 + id: 'cred-id-123', 186 + did: 'did:plc:test', 187 + handle: 'test.bsky.social', 188 + public_key: Buffer.from('key').toString('base64'), 189 + counter: 0, 190 + device_type: 'platform', 191 + backed_up: false, 192 + transports: null, 193 + name: null, 194 + }); 195 + 196 + // Generate options to store challenge 197 + await service.generateAuthenticationOptions(); 198 + 199 + const result = await service.verifyAuthentication( 200 + { id: 'cred-id-123', rawId: 'raw', response: { clientDataJSON: 'x', authenticatorData: 'y', signature: 'z' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' }, 201 + 'mock-challenge-authentication', 202 + ); 203 + 204 + expect(result.success).toBe(true); 205 + expect(result.did).toBe('did:plc:test'); 206 + expect(result.handle).toBe('test.bsky.social'); 207 + }); 208 + 209 + it('should return error for unknown credential', async () => { 210 + await service.generateAuthenticationOptions(); 211 + 212 + const result = await service.verifyAuthentication( 213 + { id: 'unknown-cred', rawId: 'raw', response: { clientDataJSON: 'x', authenticatorData: 'y', signature: 'z' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' }, 214 + 'mock-challenge-authentication', 215 + ); 216 + 217 + expect(result.success).toBe(false); 218 + expect(result.error).toContain('Unknown credential'); 219 + }); 220 + 221 + it('should return error when no challenge exists', async () => { 222 + const result = await service.verifyAuthentication( 223 + { id: 'x', rawId: 'raw', response: { clientDataJSON: 'x', authenticatorData: 'y', signature: 'z' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' }, 224 + 'nonexistent-challenge', 225 + ); 226 + 227 + expect(result.success).toBe(false); 228 + expect(result.error).toContain('No authentication challenge'); 229 + }); 230 + }); 231 + 232 + describe('listPasskeys', () => { 233 + it('should list passkeys for a user', () => { 234 + db.savePasskeyCredential({ 235 + id: 'cred-1', 236 + did: 'did:plc:test', 237 + handle: 'test.bsky.social', 238 + public_key: Buffer.from('key1').toString('base64'), 239 + counter: 0, 240 + device_type: 'platform', 241 + backed_up: true, 242 + transports: ['internal'], 243 + name: 'My Macbook', 244 + }); 245 + 246 + db.savePasskeyCredential({ 247 + id: 'cred-2', 248 + did: 'did:plc:test', 249 + handle: 'test.bsky.social', 250 + public_key: Buffer.from('key2').toString('base64'), 251 + counter: 0, 252 + device_type: 'cross-platform', 253 + backed_up: false, 254 + transports: ['usb'], 255 + name: 'YubiKey', 256 + }); 257 + 258 + const passkeys = service.listPasskeys('did:plc:test'); 259 + expect(passkeys).toHaveLength(2); 260 + expect(passkeys[0].name).toBe('My Macbook'); 261 + expect(passkeys[1].name).toBe('YubiKey'); 262 + }); 263 + 264 + it('should return empty array for user with no passkeys', () => { 265 + const passkeys = service.listPasskeys('did:plc:nobody'); 266 + expect(passkeys).toEqual([]); 267 + }); 268 + }); 269 + 270 + describe('renamePasskey', () => { 271 + it('should rename an existing passkey', () => { 272 + db.savePasskeyCredential({ 273 + id: 'cred-1', 274 + did: 'did:plc:test', 275 + handle: 'test.bsky.social', 276 + public_key: Buffer.from('key').toString('base64'), 277 + counter: 0, 278 + device_type: 'platform', 279 + backed_up: false, 280 + transports: null, 281 + name: 'Old Name', 282 + }); 283 + 284 + const result = service.renamePasskey('did:plc:test', 'cred-1', 'New Name'); 285 + expect(result).toBe(true); 286 + }); 287 + 288 + it('should return false for wrong DID', () => { 289 + db.savePasskeyCredential({ 290 + id: 'cred-1', 291 + did: 'did:plc:other', 292 + handle: 'other.bsky.social', 293 + public_key: Buffer.from('key').toString('base64'), 294 + counter: 0, 295 + device_type: 'platform', 296 + backed_up: false, 297 + transports: null, 298 + name: null, 299 + }); 300 + 301 + const result = service.renamePasskey('did:plc:test', 'cred-1', 'New Name'); 302 + expect(result).toBe(false); 303 + }); 304 + 305 + it('should return false for nonexistent credential', () => { 306 + const result = service.renamePasskey('did:plc:test', 'nonexistent', 'Name'); 307 + expect(result).toBe(false); 308 + }); 309 + }); 310 + 311 + describe('deletePasskey', () => { 312 + it('should delete an existing passkey', () => { 313 + db.savePasskeyCredential({ 314 + id: 'cred-1', 315 + did: 'did:plc:test', 316 + handle: 'test.bsky.social', 317 + public_key: Buffer.from('key').toString('base64'), 318 + counter: 0, 319 + device_type: 'platform', 320 + backed_up: false, 321 + transports: null, 322 + name: null, 323 + }); 324 + 325 + const result = service.deletePasskey('did:plc:test', 'cred-1'); 326 + expect(result).toBe(true); 327 + expect(db.getPasskeyCredential('cred-1')).toBeNull(); 328 + }); 329 + 330 + it('should return false for wrong DID', () => { 331 + db.savePasskeyCredential({ 332 + id: 'cred-1', 333 + did: 'did:plc:other', 334 + handle: 'other', 335 + public_key: Buffer.from('key').toString('base64'), 336 + counter: 0, 337 + device_type: 'platform', 338 + backed_up: false, 339 + transports: null, 340 + name: null, 341 + }); 342 + 343 + const result = service.deletePasskey('did:plc:test', 'cred-1'); 344 + expect(result).toBe(false); 345 + }); 346 + }); 347 + 348 + describe('hasPasskeys / getPasskeyCount', () => { 349 + it('should return false and 0 for user with no passkeys', () => { 350 + expect(service.hasPasskeys('did:plc:nobody')).toBe(false); 351 + expect(service.getPasskeyCount('did:plc:nobody')).toBe(0); 352 + }); 353 + 354 + it('should return true and correct count', () => { 355 + db.savePasskeyCredential({ 356 + id: 'cred-1', 357 + did: 'did:plc:test', 358 + handle: 'test', 359 + public_key: Buffer.from('key').toString('base64'), 360 + counter: 0, 361 + device_type: 'platform', 362 + backed_up: false, 363 + transports: null, 364 + name: null, 365 + }); 366 + 367 + expect(service.hasPasskeys('did:plc:test')).toBe(true); 368 + expect(service.getPasskeyCount('did:plc:test')).toBe(1); 369 + }); 370 + }); 371 + });