UNPKG

accounts

Version:

Tempo Accounts SDK

316 lines (273 loc) 11 kB
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vp/test' 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' 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) }) }) 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('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: 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() }) })