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