accounts
Version:
Tempo Accounts SDK
316 lines (273 loc) • 11 kB
text/typescript
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()
})
})