accounts
Version:
Tempo Accounts SDK
889 lines (759 loc) • 28.5 kB
text/typescript
import { Hono } from 'hono'
import { privateKeyToAccount } from 'viem/accounts'
import { parseSiweMessage } from 'viem/siwe'
import { describe, expect, test } from 'vp/test'
import * as Handler from '../../Handler.js'
import * as Kv from '../../Kv.js'
import { auth } from './auth.js'
const account = privateKeyToAccount(
'0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
)
const otherAccount = privateKeyToAccount(
'0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba',
)
describe('challenge', () => {
test('returns challenge message with chainId, nonce, zero-address placeholder', async () => {
const { app } = setup()
const { status, body } = await getChallenge(app, { chainId: 1 })
expect(status).toBe(200)
const parsed = parseSiweMessage(body.message!)
expect(parsed.address).toBe('0x0000000000000000000000000000000000000000')
expect(parsed.chainId).toBe(1)
expect(parsed.domain).toBe('wallet.example')
expect(parsed.uri).toBe('http://wallet.example')
expect(parsed.version).toBe('1')
expect(parsed.nonce).toMatch(/^[a-z0-9]+$/)
})
test('defaults chainId to 0 when omitted', async () => {
const { app } = setup()
const res = await app.request('/challenge', {
method: 'POST',
headers: { 'content-type': 'application/json', host: 'wallet.example' },
body: JSON.stringify({}),
})
expect(res.status).toBe(200)
const { message } = (await res.json()) as { message: string }
expect(parseSiweMessage(message).chainId).toBe(0)
})
test('persists the nonce in the store with TTL', async () => {
const store = Kv.memory()
const { app } = setup({ store })
const { body } = await getChallenge(app, { chainId: 1 })
const nonce = parseSiweMessage(body.message!).nonce!
expect(await store.get(`challenge:${nonce}`)).toMatchObject({ chainId: 1 })
})
})
describe('verify (EOA, cookie mode)', () => {
test('default: verifies signature, sets cookie, persists session', async () => {
const store = Kv.memory()
const { handler, app } = setup({ store })
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: account.address,
message,
signature,
})
expect(res.status).toBe(200)
expect(await res.json()).toMatchInlineSnapshot(`{}`)
const setCookie = res.headers.get('set-cookie')
expect(setCookie).toContain('accounts_auth=')
expect(setCookie).toContain('HttpOnly')
expect(setCookie).toContain('SameSite=Lax')
// getSession resolves the persisted payload from a follow-up request.
const followUp = new Request('http://wallet.example/', {
headers: { cookie: setCookie!.split(';')[0]! },
})
const session = await handler.getSession(followUp)
expect(session?.address).toBe(account.address)
expect(session?.chainId).toBe(1)
// Session is also persisted in the store under `session:` prefix.
const token = setCookie!.split(';')[0]!.split('=')[1]!
expect(await store.get(`session:${token}`)).toBeDefined()
})
test('rejects replayed nonce with 409', async () => {
const { app } = setup()
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const ok = await postVerify(app, {
address: account.address,
message,
signature,
})
expect(ok.status).toBe(200)
const replay = await postVerify(app, {
address: account.address,
message,
signature,
})
expect(replay.status).toBe(409)
expect(await replay.json()).toMatchInlineSnapshot(`
{
"error": "invalid or replayed nonce",
}
`)
})
test('rejects signature for a different address with 401', async () => {
const { app } = setup()
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: otherAccount.address,
message,
signature,
})
expect(res.status).toBe(401)
expect(await res.json()).toMatchInlineSnapshot(`
{
"error": "signature does not match address",
}
`)
})
test('rejects domain mismatch with 400', async () => {
const { app } = setup()
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const tampered = challengeBody.message!.replace('wallet.example', 'evil.example')
const signature = await account.signMessage({ message: tampered })
const res = await postVerify(app, {
address: account.address,
message: tampered,
signature,
})
expect(res.status).toBe(400)
expect(await res.json()).toMatchInlineSnapshot(`
{
"error": "domain mismatch",
}
`)
})
test('rejects tampered message (statement injection) with 400', async () => {
// Regression: an attacker could fetch a valid challenge, inject a
// benign-looking `statement` into the SIWE text, get a victim to sign
// it, and replay the signature here. The server must reject any
// message that doesn't byte-match the challenge it issued.
const { app } = setup()
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
// SIWE injects `statement` as the line between the address line and
// the blank line preceding `URI:`. Splice one in.
const tampered = message.replace(
/(0x0000000000000000000000000000000000000000\n)\n/,
'$1\nSign to prove you are human\n\n',
)
expect(tampered).not.toBe(message)
const signature = await account.signMessage({ message: tampered })
const res = await postVerify(app, {
address: account.address,
message: tampered,
signature,
})
expect(res.status).toBe(400)
expect(await res.json()).toMatchInlineSnapshot(`
{
"error": "message mismatch",
}
`)
})
test('rejects malformed body with 400', async () => {
const { app } = setup()
const res = await app.request('/', {
method: 'POST',
headers: { 'content-type': 'application/json', host: 'wallet.example' },
body: '',
})
expect(res.status).toBe(400)
})
})
describe('logout', () => {
test('clears the session cookie and deletes the store entry', async () => {
const store = Kv.memory()
const { handler, app } = setup({ store })
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const verify = await postVerify(app, {
address: account.address,
message,
signature,
})
const sessionCookie = verify.headers.get('set-cookie')!.split(';')[0]!
const token = sessionCookie.split('=')[1]!
expect(await store.get(`session:${token}`)).toBeDefined()
const logout = await app.request('/logout', {
method: 'POST',
headers: { cookie: sessionCookie, host: 'wallet.example' },
})
expect(logout.status).toBe(204)
const clearCookie = logout.headers.get('set-cookie')!
expect(clearCookie).toContain('accounts_auth=')
expect(clearCookie).toContain('Max-Age=0')
expect(await store.get(`session:${token}`)).toBeUndefined()
const followUp = new Request('http://wallet.example/', {
headers: { cookie: sessionCookie },
})
expect(await handler.getSession(followUp)).toBeUndefined()
})
test('204 unconditionally even without a session cookie', async () => {
const { app } = setup()
const res = await app.request('/logout', {
method: 'POST',
headers: { host: 'wallet.example' },
})
expect(res.status).toBe(204)
})
})
describe('verify (token mode)', () => {
test('returnToken=true returns { token } in body and skips Set-Cookie', async () => {
const store = Kv.memory()
const { handler, app } = setup({ store })
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: account.address,
message,
signature,
returnToken: true,
})
expect(res.status).toBe(200)
expect(res.headers.get('set-cookie')).toBeNull()
const { token } = (await res.json()) as { token: string }
expect(token).toMatch(/^[a-z0-9]+$/)
expect(await store.get(`session:${token}`)).toBeDefined()
// Bearer-mode getSession resolves the token.
const followUp = new Request('http://wallet.example/', {
headers: { authorization: `Bearer ${token}` },
})
const session = await handler.getSession(followUp)
expect(session?.address).toBe(account.address)
})
})
describe('cookie: false', () => {
test('verify always returns { token } in body and never sets a cookie', async () => {
const store = Kv.memory()
const { handler, app } = setup({ cookie: false, store })
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
// Even without `returnToken: true`, the body carries the token and
// no Set-Cookie is emitted.
const res = await postVerify(app, {
address: account.address,
message,
signature,
})
expect(res.status).toBe(200)
expect(res.headers.get('set-cookie')).toBeNull()
const { token } = (await res.json()) as { token: string }
expect(token).toMatch(/^[a-z0-9]+$/)
expect(await store.get(`session:${token}`)).toBeDefined()
const followUp = new Request('http://wallet.example/', {
headers: { authorization: `Bearer ${token}` },
})
expect((await handler.getSession(followUp))?.address).toBe(account.address)
})
test('getSession ignores cookies even when present', async () => {
const store = Kv.memory()
const { handler, app } = setup({ cookie: false, store })
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const verify = await postVerify(app, {
address: account.address,
message,
signature,
})
const { token } = (await verify.json()) as { token: string }
// Cookie carrying the very token that's valid in the store — but
// cookie mode is disabled, so it must not resolve a session.
const req = new Request('http://wallet.example/', {
headers: { cookie: `accounts_auth=${token}` },
})
expect(await handler.getSession(req)).toBeUndefined()
// Bearer mode still works against the same token.
const bearerReq = new Request('http://wallet.example/', {
headers: { authorization: `Bearer ${token}` },
})
expect((await handler.getSession(bearerReq))?.address).toBe(account.address)
})
test('logout via Authorization: Bearer revokes the session and skips Set-Cookie', async () => {
const store = Kv.memory()
const { handler, app } = setup({ cookie: false, store })
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const verify = await postVerify(app, {
address: account.address,
message,
signature,
})
const { token } = (await verify.json()) as { token: string }
expect(await store.get(`session:${token}`)).toBeDefined()
const logout = await app.request('/logout', {
method: 'POST',
headers: { host: 'wallet.example', authorization: `Bearer ${token}` },
})
expect(logout.status).toBe(204)
expect(logout.headers.get('set-cookie')).toBeNull()
expect(await store.get(`session:${token}`)).toBeUndefined()
const followUp = new Request('http://wallet.example/', {
headers: { authorization: `Bearer ${token}` },
})
expect(await handler.getSession(followUp)).toBeUndefined()
})
})
describe('session: false', () => {
test('verify returns {} with no token, no Set-Cookie, no store write', async () => {
const store = Kv.memory()
const { handler, app } = setup({ session: false, store })
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: account.address,
message,
signature,
})
expect(res.status).toBe(200)
expect(await res.json()).toMatchInlineSnapshot(`{}`)
expect(res.headers.get('set-cookie')).toBeNull()
// getSession always resolves undefined; even a manually-seeded
// session token must not leak through.
await store.set('session:tok', { address: account.address }, { ttl: 60 })
const req = new Request('http://wallet.example/', {
headers: { authorization: 'Bearer tok' },
})
expect(await handler.getSession(req)).toBeUndefined()
})
test('returnToken: true is ignored when session: false', async () => {
const { app } = setup({ session: false })
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: account.address,
message,
signature,
returnToken: true,
})
expect(res.status).toBe(200)
expect(await res.json()).toMatchInlineSnapshot(`{}`)
})
test('logout route is not mounted (returns 404)', async () => {
const store = Kv.memory()
const { app } = setup({ session: false, store })
await store.set('session:tok', { address: account.address }, { ttl: 60 })
const res = await app.request('/logout', {
method: 'POST',
headers: { host: 'wallet.example', authorization: 'Bearer tok' },
})
expect(res.status).toBe(404)
// The seeded entry survives — no logout route, nothing to delete.
expect(await store.get('session:tok')).toBeDefined()
})
test('onAuthenticate still runs and can reject before the short-circuit', async () => {
let invoked = false
const { app } = setup({
session: false,
onAuthenticate: () => {
invoked = true
throw new Error('blocked')
},
})
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: account.address,
message,
signature,
})
expect(res.status).toBe(401)
expect(invoked).toBe(true)
expect(await res.json()).toMatchInlineSnapshot(`
{
"error": "blocked",
}
`)
})
})
describe('onAuthenticate', () => {
test('invoked with verified address, chainId, message, signature, request', async () => {
const calls: Array<{
address: string
chainId: number
message: string
signature: string
requestUrl: string
}> = []
const { app } = setup({
onAuthenticate: ({ address, chainId, message, signature, request }) => {
calls.push({
address,
chainId,
message,
signature,
requestUrl: request.url,
})
},
})
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: account.address,
message,
signature,
})
expect(res.status).toBe(200)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
address: account.address,
chainId: 1,
message,
signature,
})
expect(calls[0]?.requestUrl).toContain('/')
})
test('not invoked when signature verification fails', async () => {
let invoked = false
const { app } = setup({
onAuthenticate: () => {
invoked = true
},
})
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: otherAccount.address,
message,
signature,
})
expect(res.status).toBe(401)
expect(invoked).toBe(false)
})
test('throwing rejects the request with 401 and surfaces the error message', async () => {
const store = Kv.memory()
const { app } = setup({
store,
onAuthenticate: () => {
throw new Error('user is blocked')
},
})
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: account.address,
message,
signature,
})
expect(res.status).toBe(401)
expect(await res.json()).toMatchInlineSnapshot(`
{
"error": "user is blocked",
}
`)
expect(res.headers.get('set-cookie')).toBeNull()
})
test('returning a Response merges body fields and status onto the verify response', async () => {
const { app } = setup({
onAuthenticate: () => Response.json({ jwt: 'eyJ...', userId: 'u_42' }, { status: 201 }),
})
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: account.address,
message,
signature,
returnToken: true,
})
expect(res.status).toBe(201)
const { token, ...rest } = (await res.json()) as Record<string, unknown>
expect(token).toMatch(/^[a-z0-9]+$/)
expect(rest).toMatchInlineSnapshot(`
{
"jwt": "eyJ...",
"userId": "u_42",
}
`)
})
test('returned Response without `session: false` still issues a session token', async () => {
const store = Kv.memory()
const { handler, app } = setup({
store,
onAuthenticate: () => Response.json({ extra: 'meta' }),
})
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: account.address,
message,
signature,
})
expect(res.status).toBe(200)
expect(((await res.json()) as Record<string, unknown>).extra).toBe('meta')
const setCookie = res.headers.get('set-cookie')
expect(setCookie).toContain('accounts_auth=')
const token = setCookie!.split(';')[0]!.split('=')[1]!
expect(await store.get(`session:${token}`)).toBeDefined()
// getSession resolves the issued session via the cookie.
const followUp = new Request('http://wallet.example/', {
headers: { cookie: setCookie!.split(';')[0]! },
})
const sessionPayload = await handler.getSession(followUp)
expect(sessionPayload?.address).toBe(account.address)
})
test('async hook is awaited before issuing the session', async () => {
let resolveHook: (() => void) | undefined
const blocker = new Promise<void>((resolve) => {
resolveHook = resolve
})
let hookFinished = false
const { app } = setup({
onAuthenticate: async () => {
await blocker
hookFinished = true
},
})
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const verifyPromise = postVerify(app, {
address: account.address,
message,
signature,
})
// Yield once to let verify dispatch into the hook.
await new Promise((r) => setTimeout(r, 10))
expect(hookFinished).toBe(false)
resolveHook!()
const res = await verifyPromise
expect(res.status).toBe(200)
expect(hookFinished).toBe(true)
})
})
describe('getSession', () => {
test('returns undefined when no cookie is present', async () => {
const { handler } = setup()
const req = new Request('http://wallet.example/')
expect(await handler.getSession(req)).toBeUndefined()
})
test('prefers Authorization: Bearer over cookie', async () => {
const store = Kv.memory()
const { handler, app } = setup({ store })
// Issue session #1 via cookie mode.
const ch1 = await getChallenge(app, { chainId: 1 })
const sig1 = await account.signMessage({ message: ch1.body.message! })
const v1 = await postVerify(app, {
address: account.address,
message: ch1.body.message!,
signature: sig1,
})
const cookie = v1.headers.get('set-cookie')!.split(';')[0]!
// Issue session #2 via token mode for a different address.
const ch2 = await getChallenge(app, { chainId: 1 })
const sig2 = await otherAccount.signMessage({ message: ch2.body.message! })
const v2 = await postVerify(app, {
address: otherAccount.address,
message: ch2.body.message!,
signature: sig2,
returnToken: true,
})
const { token } = (await v2.json()) as { token: string }
// When both are present, the bearer wins.
const req = new Request('http://wallet.example/', {
headers: { cookie, authorization: `Bearer ${token}` },
})
const session = await handler.getSession(req)
expect(session?.address).toBe(otherAccount.address)
})
})
describe('store: atomic `take` preferred, non-atomic fallback', () => {
test('Kv.memory() (has `take`) is accepted', () => {
expect(() => auth({ store: Kv.memory() })).not.toThrow()
})
test('store without `take` falls back to non-atomic get + delete', async () => {
// The fallback path is racy on eventually-consistent stores but
// works correctly in single-process serial usage. Verify the
// handler still constructs and the verify endpoint can consume a
// challenge end-to-end.
const noTake: Kv.Kv = (() => {
const map = new Map<string, unknown>()
return {
async get(key) {
return map.get(key) as never
},
async set(key, value) {
map.set(key, value)
},
async delete(key) {
map.delete(key)
},
}
})()
const handler = auth({ domain: 'wallet.example', store: noTake })
const app = new Hono()
app.route('/', handler)
const challenge = await app.request('/challenge', {
method: 'POST',
headers: { 'content-type': 'application/json', host: 'wallet.example' },
body: JSON.stringify({ chainId: 1 }),
})
expect(challenge.status).toBe(200)
})
})
describe('origin / trustProxy', () => {
test('default: ignores `x-forwarded-host` and `x-forwarded-proto`', async () => {
// No domain pin — relies on host header. trustProxy defaults to false.
const handler = auth()
const app = new Hono()
app.route('/', handler)
const res = await app.request('/challenge', {
method: 'POST',
headers: {
'content-type': 'application/json',
host: 'real.example',
'x-forwarded-host': 'attacker.example',
'x-forwarded-proto': 'http',
},
body: JSON.stringify({ chainId: 1 }),
})
const body = (await res.json()) as { message: string }
const parsed = parseSiweMessage(body.message)
expect(parsed.domain).toBe('real.example')
})
test('trustProxy: true → honors `x-forwarded-host` and `x-forwarded-proto`', async () => {
const handler = auth({ trustProxy: true })
const app = new Hono()
app.route('/', handler)
const res = await app.request('/challenge', {
method: 'POST',
headers: {
'content-type': 'application/json',
host: 'internal.example',
'x-forwarded-host': 'app.example',
'x-forwarded-proto': 'https',
},
body: JSON.stringify({ chainId: 1 }),
})
const body = (await res.json()) as { message: string }
const parsed = parseSiweMessage(body.message)
expect(parsed.domain).toBe('app.example')
expect(parsed.uri).toBe('https://app.example')
})
test('origin: pinned origin overrides host and forwarded headers', async () => {
const handler = auth({
origin: 'https://app.example.com',
trustProxy: true,
})
const app = new Hono()
app.route('/', handler)
const res = await app.request('/challenge', {
method: 'POST',
headers: {
'content-type': 'application/json',
host: 'internal.example',
'x-forwarded-host': 'attacker.example',
'x-forwarded-proto': 'http',
},
body: JSON.stringify({ chainId: 1 }),
})
const body = (await res.json()) as { message: string }
const parsed = parseSiweMessage(body.message)
expect(parsed.domain).toBe('app.example.com')
expect(parsed.uri).toBe('https://app.example.com')
})
test('origin: invalid URL throws at construction time', async () => {
expect(() => auth({ origin: 'not-a-url' })).toThrowErrorMatchingInlineSnapshot(
`[Error: \`auth({ origin })\` must be a valid absolute URL. Got: not-a-url]`,
)
})
test('default trustProxy: true on Cloudflare Workers runtime', async () => {
// Spoof the Cloudflare Workers runtime marker.
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Cloudflare-Workers' },
configurable: true,
})
try {
const handler = auth()
const app = new Hono()
app.route('/', handler)
const res = await app.request('/challenge', {
method: 'POST',
headers: {
'content-type': 'application/json',
host: 'internal.example',
'x-forwarded-host': 'app.example',
'x-forwarded-proto': 'https',
},
body: JSON.stringify({ chainId: 1 }),
})
const body = (await res.json()) as { message: string }
const parsed = parseSiweMessage(body.message)
expect(parsed.domain).toBe('app.example')
expect(parsed.uri).toBe('https://app.example')
} finally {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
})
}
})
})
describe('Handler.compose integration', () => {
test('mounts under a custom path and routes correctly', async () => {
const composed = Handler.compose([auth({ domain: 'wallet.example' })], {
path: '/api/auth',
})
const challengeRes = await composed.request('/api/auth/challenge', {
method: 'POST',
headers: { 'content-type': 'application/json', host: 'wallet.example' },
body: JSON.stringify({ chainId: 1 }),
})
expect(challengeRes.status).toBe(200)
const notFound = await composed.request('/api/auth/whatever', {
method: 'GET',
headers: { host: 'wallet.example' },
})
expect(notFound.status).toBe(404)
})
})
function setup(options: Parameters<typeof auth>[0] = {}) {
const handler = auth({ domain: 'wallet.example', ...options })
// Mount under '/' so tests hit /challenge, /, /logout directly.
const app = new Hono()
app.route('/', handler)
return { handler, app }
}
async function getChallenge(app: Hono, body: { chainId: number }) {
const res = await app.request('/challenge', {
method: 'POST',
headers: { 'content-type': 'application/json', host: 'wallet.example' },
body: JSON.stringify(body),
})
return { status: res.status, body: (await res.json()) as { message?: string; error?: string } }
}
async function postVerify(
app: Hono,
body: {
address: string
message: string
signature: string
returnToken?: boolean
},
) {
const res = await app.request('/', {
method: 'POST',
headers: { 'content-type': 'application/json', host: 'wallet.example' },
body: JSON.stringify(body),
})
return res
}