UNPKG

accounts

Version:

Tempo Accounts SDK

412 lines (361 loc) 13.7 kB
import { vi } from 'vitest' import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vp/test' import { Registration } from 'webauthx/server' import { createServer, type Server } from '../../../../test/utils.js' import * as WebAuthnCeremony from '../../../core/WebAuthnCeremony.js' import * as Kv from '../../Kv.js' import { type SessionPayload, webAuthn } from './webAuthn.js' vi.mock('webauthx/server', { spy: true }) let server: Server let ceremony: WebAuthnCeremony.WebAuthnCeremony beforeAll(async () => { server = await createServer( webAuthn({ kv: Kv.memory(), origin: 'http://localhost', rpId: 'localhost', }).listener, ) ceremony = WebAuthnCeremony.server({ url: server.url }) }) afterAll(async () => { await server.closeAsync() }) describe('POST /register/options', () => { test('default: returns registration options', async () => { const { options } = await ceremony.getRegistrationOptions({ name: 'Test' }) expect(options.publicKey).toBeDefined() expect(options.publicKey!.rp.id).toMatchInlineSnapshot(`"localhost"`) expect(options.publicKey!.rp.name).toMatchInlineSnapshot(`"localhost"`) expect(typeof options.publicKey!.challenge).toMatchInlineSnapshot(`"string"`) }) test('behavior: each call generates a unique challenge', async () => { const { options: a } = await ceremony.getRegistrationOptions({ name: 'Test' }) const { options: b } = await ceremony.getRegistrationOptions({ name: 'Test' }) expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge) }) }) describe('POST /login/options', () => { test('default: returns authentication options', async () => { const { options } = await ceremony.getAuthenticationOptions() expect(options.publicKey).toBeDefined() expect(options.publicKey!.rpId).toMatchInlineSnapshot(`"localhost"`) expect(typeof options.publicKey!.challenge).toMatchInlineSnapshot(`"string"`) }) test('behavior: each call generates a unique challenge', async () => { const { options: a } = await ceremony.getAuthenticationOptions() const { options: b } = await ceremony.getAuthenticationOptions() expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge) }) test('behavior: restricts authentication to multiple credentials', async () => { const { options } = await ceremony.getAuthenticationOptions({ credentialId: ['Y3JlZC0x', 'Y3JlZC0y'], }) expect(options.publicKey?.allowCredentials).toMatchInlineSnapshot(` [ { "id": "Y3JlZC0x", "type": "public-key", }, { "id": "Y3JlZC0y", "type": "public-key", }, ] `) }) }) describe('POST /register', () => { test('error: invalid credential → 400', async () => { const response = await fetch(`${server.url}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'fake', clientDataJSON: 'bad', attestationObject: 'bad' }), }) expect(response.status).toBe(400) const body = await response.json() expect(body.error).toBeTypeOf('string') }) }) describe('kv', () => { test('store without atomic create is accepted', () => { const kv: Kv.Kv = { async get() { return undefined }, async set() {}, async delete() {}, } expect(() => webAuthn({ kv, origin: 'http://localhost', rpId: 'localhost', }), ).not.toThrow() }) }) describe('POST /login', () => { test('error: unknown credential → 400', async () => { const response = await fetch(`${server.url}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'unknown', metadata: { authenticatorData: '0x00', clientDataJSON: '{"challenge":"0xdead"}' }, raw: { id: 'unknown', type: 'public-key', authenticatorAttachment: null, rawId: 'unknown', response: { clientDataJSON: 'e30' }, }, signature: '0x00', }), }) expect(response.status).toBe(400) const body = await response.json() expect(body.error).toMatchInlineSnapshot(`"Missing or expired challenge"`) }) }) describe('challenge replay', () => { test('behavior: challenge consumed after register/options → re-fetching is required', async () => { // Get options twice — each should have a unique challenge stored in KV const { options: a } = await ceremony.getRegistrationOptions({ name: 'Replay' }) const { options: b } = await ceremony.getRegistrationOptions({ name: 'Replay' }) expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge) }) test('behavior: challenge consumed after login/options → re-fetching is required', async () => { const { options: a } = await ceremony.getAuthenticationOptions() const { options: b } = await ceremony.getAuthenticationOptions() expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge) }) }) describe('hooks', () => { test('behavior: onRegister error does not call hook', async () => { let called = false const hookServer = await createServer( webAuthn({ kv: Kv.memory(), origin: 'http://localhost', rpId: 'localhost', onRegister() { called = true return Response.json({ extra: true }) }, }).listener, ) const response = await fetch(`${hookServer.url}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'fake', clientDataJSON: 'bad', attestationObject: 'bad' }), }) expect(response.status).toBe(400) expect(called).toBe(false) await hookServer.closeAsync() }) test('behavior: onRegister rejection removes the credential', async () => { const kv = Kv.memory() await kv.set('challenge:0x01', { created: Date.now(), name: 'Test' }) const publicKey = '0x04ab891400140fc4f8e941ce0ff90e419de9470acaca613bbd717a4775435031a7d884318e919fd3b3e5a631d866d8a380b44063e70f0c381ee16e0652f7f97554' vi.mocked(Registration.verify).mockReturnValueOnce({ aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd', credential: { publicKey }, } as never) const hookServer = await createServer( webAuthn({ kv, origin: 'http://localhost', rpId: 'localhost', onRegister() { throw new Error('Authenticator not allowed') }, }).listener, ) try { const clientDataJSON = 'eyJjaGFsbGVuZ2UiOiJBUSJ9' const response = await fetch(`${hookServer.url}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ attestationObject: 'AA', clientDataJSON, id: 'credential', publicKey, raw: { authenticatorAttachment: null, id: 'credential', rawId: 'Y3JlZGVudGlhbA', response: { clientDataJSON }, type: 'public-key', }, }), }) expect(response.status).toMatchInlineSnapshot(`400`) expect(await response.json()).toMatchInlineSnapshot(` { "error": "Authenticator not allowed", } `) expect(await kv.get('credential:credential')).toBeUndefined() expect(await kv.get('challenge:0x01')).toBeUndefined() } finally { await hookServer.closeAsync() } }) test('behavior: onAuthenticate error does not call hook', async () => { let called = false const hookServer = await createServer( webAuthn({ kv: Kv.memory(), origin: 'http://localhost', rpId: 'localhost', onAuthenticate() { called = true return Response.json({ extra: true }) }, }).listener, ) const response = await fetch(`${hookServer.url}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'unknown', metadata: { authenticatorData: '0x00', clientDataJSON: '{"challenge":"0xdead"}' }, raw: { id: 'unknown', type: 'public-key', authenticatorAttachment: null, rawId: 'unknown', response: { clientDataJSON: 'e30' }, }, signature: '0x00', }), }) expect(response.status).toBe(400) expect(called).toBe(false) await hookServer.closeAsync() }) }) // Successful login requires a real authenticator, so the cookie / session // surfaces are exercised by manually seeding the session in the shared // `kv` and hitting `getSession` and `/logout` directly. describe('session — getSession & /logout', () => { let kv: Kv.Kv let handler: ReturnType<typeof webAuthn> let s: Server const seedSession = async (token = 'tok-default'): Promise<SessionPayload> => { const issuedAt = Math.floor(Date.now() / 1000) const payload: SessionPayload = { credentialId: 'cred-1', publicKey: '0xpub', userId: 'user-1', issuedAt, expiresAt: issuedAt + 60, } await kv.set(`session:${token}`, payload, { ttl: 60 }) return payload } afterEach(async () => { if (s) await s.closeAsync() }) test('default (cookie mode): getSession resolves bearer or cookie; /logout clears cookie + revokes', async () => { kv = Kv.memory() handler = webAuthn({ kv, origin: 'http://localhost', rpId: 'localhost' }) s = await createServer(handler.listener) await seedSession('tok-A') const bearerSession = await handler.getSession( new Request('http://localhost/', { headers: { authorization: 'Bearer tok-A' } }), ) expect(bearerSession?.credentialId).toBe('cred-1') const cookieSession = await handler.getSession( new Request('http://localhost/', { headers: { cookie: 'accounts_webauthn=tok-A' } }), ) expect(cookieSession?.credentialId).toBe('cred-1') const noAuthSession = await handler.getSession(new Request('http://localhost/')) expect(noAuthSession).toBeUndefined() const logout = await fetch(`${s.url}/logout`, { method: 'POST', headers: { authorization: 'Bearer tok-A' }, }) expect(logout.status).toBe(204) const setCookie = logout.headers.get('set-cookie') expect(setCookie).toContain('accounts_webauthn=') expect(setCookie).toContain('Max-Age=0') expect(await kv.get('session:tok-A')).toBeUndefined() const after = await handler.getSession( new Request('http://localhost/', { headers: { authorization: 'Bearer tok-A' } }), ) expect(after).toBeUndefined() }) test('cookie: false: getSession ignores cookies; /logout returns 204 without Set-Cookie', async () => { kv = Kv.memory() handler = webAuthn({ kv, cookie: false, origin: 'http://localhost', rpId: 'localhost' }) s = await createServer(handler.listener) await seedSession('tok-B') const bearer = await handler.getSession( new Request('http://localhost/', { headers: { authorization: 'Bearer tok-B' } }), ) expect(bearer?.credentialId).toBe('cred-1') const cookieIgnored = await handler.getSession( new Request('http://localhost/', { headers: { cookie: 'accounts_webauthn=tok-B' } }), ) expect(cookieIgnored).toBeUndefined() const logout = await fetch(`${s.url}/logout`, { method: 'POST', headers: { authorization: 'Bearer tok-B' }, }) expect(logout.status).toBe(204) expect(logout.headers.get('set-cookie')).toBeNull() expect(await kv.get('session:tok-B')).toBeUndefined() }) test('custom cookieName is honored on getSession and /logout', async () => { kv = Kv.memory() handler = webAuthn({ kv, cookieName: 'custom_cookie', origin: 'http://localhost', rpId: 'localhost', }) s = await createServer(handler.listener) await seedSession('tok-C') expect( await handler.getSession( new Request('http://localhost/', { headers: { cookie: 'custom_cookie=tok-C' } }), ), ).toBeTruthy() expect( await handler.getSession( new Request('http://localhost/', { headers: { cookie: 'accounts_webauthn=tok-C' } }), ), ).toBeUndefined() const logout = await fetch(`${s.url}/logout`, { method: 'POST' }) expect(logout.headers.get('set-cookie')).toContain('custom_cookie=') }) test('/logout returns 204 even without a session token', async () => { kv = Kv.memory() handler = webAuthn({ kv, origin: 'http://localhost', rpId: 'localhost' }) s = await createServer(handler.listener) const res = await fetch(`${s.url}/logout`, { method: 'POST' }) expect(res.status).toBe(204) }) test('session: false: getSession always undefined; /logout route is not mounted (404)', async () => { kv = Kv.memory() handler = webAuthn({ kv, origin: 'http://localhost', rpId: 'localhost', session: false }) s = await createServer(handler.listener) // Even with a manually-seeded session in kv, getSession ignores it. await seedSession('tok-D') const bearer = await handler.getSession( new Request('http://localhost/', { headers: { authorization: 'Bearer tok-D' } }), ) expect(bearer).toBeUndefined() const logout = await fetch(`${s.url}/logout`, { method: 'POST', headers: { authorization: 'Bearer tok-D' }, }) expect(logout.status).toBe(404) // The seeded entry must survive — no logout route, nothing to delete. expect(await kv.get('session:tok-D')).toBeDefined() }) })