accounts
Version:
Tempo Accounts SDK
1,488 lines (1,287 loc) • 47.6 kB
text/typescript
import { Hono } from 'hono'
import { P256, Secp256k1 } from 'ox'
import { KeyAuthorization } from 'ox/tempo'
import { createClient, custom, hashMessage } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { parseSiweMessage } from 'viem/siwe'
import { Account } from 'viem/tempo'
import { tempoModerato } from 'viem/tempo/chains'
import { afterAll, beforeAll, describe, expect, test, vi } from 'vp/test'
import { createServer } from '../../../../test/utils.js'
import * as Handler from '../../Handler.js'
import * as Kv from '../../Kv.js'
import { auth } from './auth.js'
const privateKey = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'
const account = privateKeyToAccount(privateKey)
const otherAccount = privateKeyToAccount(
'0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba',
)
/**
* Builds a TIP-1053 witness-bound key authorization signed (secp256k1) by
* `privateKey`, mirroring what the SDK produces on the witness path. Returns
* the RLP-serialized signed key authorization for the verify `keyAuthorization` field.
*/
function signWitnessKeyAuth(options: {
chainId: number
message: string
signWith?: `0x${string}`
witness?: `0x${string}`
}) {
const unsigned = KeyAuthorization.from({
address: otherAccount.address,
chainId: BigInt(options.chainId),
expiry: 0,
type: 'secp256k1',
witness: options.witness ?? hashMessage(options.message),
})
const signature = Secp256k1.sign({
payload: KeyAuthorization.getSignPayload(unsigned),
privateKey: options.signWith ?? privateKey,
})
return KeyAuthorization.serialize(KeyAuthorization.from(unsigned, { signature }))
}
describe('challenge', () => {
test('error: requires pinned origin or domain', () => {
expect(() => auth()).toThrowErrorMatchingInlineSnapshot(
`[Error: \`auth()\` requires \`origin\` or \`domain\` to pin SIWE domain binding (or explicit \`trustProxy: true\`).]`,
)
})
test('explicit trustProxy substitutes for a pinned origin', async () => {
const app = new Hono().route('/', auth({ trustProxy: true }))
const res = await app.request('/challenge', {
body: JSON.stringify({ chainId: 1 }),
headers: {
'content-type': 'application/json',
host: 'internal.upstream',
'x-forwarded-host': 'app.example.com',
'x-forwarded-proto': 'https',
},
method: 'POST',
})
expect(res.status).toBe(200)
const { message } = (await res.json()) as { message: string }
expect(parseSiweMessage(message).domain).toBe('app.example.com')
})
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('includes requested resources and configured statement in the challenge message', async () => {
const { app } = setup({ statement: 'Authorize account access.' })
const resources = ['urn:tempo:api-signing-key:test', 'https://api.example.com/signing-keys/1']
const { status, body } = await getChallenge(app, {
chainId: 1,
resources,
})
expect(status).toBe(200)
const parsed = parseSiweMessage(body.message!)
expect({ resources: parsed.resources, statement: parsed.statement }).toMatchInlineSnapshot(`
{
"resources": [
"urn:tempo:api-signing-key:test",
"https://api.example.com/signing-keys/1",
],
"statement": "Authorize account access.",
}
`)
})
test('uses statement callback output in the challenge message', async () => {
const resources = ['https://api.example.com/signing-keys/1']
let params_seen:
| {
chainId: number
resources?: readonly string[] | undefined
url: string
}
| undefined
const statement = vi.fn(
(params: {
chainId: number
resources?: readonly string[] | undefined
request: Request
}) => {
params_seen = {
chainId: params.chainId,
resources: params.resources,
url: params.request.url,
}
return `Authorize ${params.resources?.length ?? 0} resource.`
},
)
const { app } = setup({ statement })
const { status, body } = await getChallenge(app, {
chainId: 1,
resources,
})
expect(status).toBe(200)
const parsed = parseSiweMessage(body.message!)
expect({ params: params_seen, statement: parsed.statement }).toMatchInlineSnapshot(`
{
"params": {
"chainId": 1,
"resources": [
"https://api.example.com/signing-keys/1",
],
"url": "http://localhost/challenge",
},
"statement": "Authorize 1 resource.",
}
`)
})
test('supports async statement callbacks', async () => {
const resources = ['https://api.example.com/signing-keys/1']
const statement = vi.fn(async (params: { resources?: readonly string[] | undefined }) => {
return `Authorize ${params.resources?.length ?? 0} resource.`
})
const { app } = setup({ statement })
const { status, body } = await getChallenge(app, {
chainId: 1,
resources,
})
expect(status).toBe(200)
const parsed = parseSiweMessage(body.message!)
expect({ calls: statement.mock.calls.length, statement: parsed.statement })
.toMatchInlineSnapshot(`
{
"calls": 1,
"statement": "Authorize 1 resource.",
}
`)
})
test('ignores requester-provided statement', async () => {
const { app } = setup({ statement: 'Server statement.' })
const res = await app.request('/challenge', {
method: 'POST',
headers: { 'content-type': 'application/json', host: 'wallet.example' },
body: JSON.stringify({
chainId: 1,
statement: 'Requester statement.',
}),
})
expect(res.status).toBe(200)
const { message } = (await res.json()) as { message: string }
expect(parseSiweMessage(message).statement).toMatchInlineSnapshot(`"Server statement."`)
})
test('rejects invalid resource URIs', async () => {
const { app } = setup()
const { status, body } = await getChallenge(app, {
chainId: 1,
resources: ['not a uri'],
})
expect(status).toBe(400)
expect(body).toMatchInlineSnapshot(`
{
"error": "invalid SIWE challenge parameters",
}
`)
})
test('rejects line breaks in resources and configured statement', async () => {
const { app } = setup()
const resource = await getChallenge(app, {
chainId: 1,
resources: ['urn:tempo:one\ntwo'],
})
const statement = await getChallenge(setup({ statement: 'one\ntwo' }).app, { chainId: 1 })
expect({
resource: { body: resource.body, status: resource.status },
statement: { body: statement.body, status: statement.status },
}).toMatchInlineSnapshot(`
{
"resource": {
"body": {
"error": "resources must not include line breaks",
},
"status": 400,
},
"statement": {
"body": {
"error": "statement must not include line breaks",
},
"status": 400,
},
}
`)
})
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('resolves the verification client from the challenge chain', async () => {
const account = Account.fromHeadlessWebAuthn(P256.randomPrivateKey(), {
origin: 'http://wallet.example',
rpId: 'wallet.example',
})
const client = createClient({
chain: tempoModerato,
transport: custom({
request: async () => {
throw new Error('RPC unavailable')
},
}),
})
let chainId_seen: number | undefined
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = await (async () => {
try {
const { app } = setup({
getClient: (chainId) => {
chainId_seen = chainId
return client
},
})
const { body: challengeBody } = await getChallenge(app, {
chainId: tempoModerato.id,
})
const message = challengeBody.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, { address: account.address, message, signature })
return {
body: await res.json(),
chainId: chainId_seen,
logs: log.mock.calls.map(([message]) => message),
status: res.status,
}
} finally {
log.mockRestore()
}
})()
expect(result).toMatchInlineSnapshot(`
{
"body": {
"error": "signature verification unavailable",
},
"chainId": 42431,
"logs": [
"[accounts/auth] signature verification dependency failed",
],
"status": 502,
}
`)
})
test('keeps malformed signatures as authentication errors', async () => {
let clientCalled = false
const { app } = setup({
getClient: () => {
clientCalled = true
throw new Error('unexpected client resolution')
},
})
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const res = await postVerify(app, {
address: account.address,
message,
signature: '0xdeadbeef',
})
expect({ body: await res.json(), clientCalled, status: res.status }).toMatchInlineSnapshot(`
{
"body": {
"error": "invalid signature",
},
"clientCalled": false,
"status": 401,
}
`)
})
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 tampered message resources with 400', async () => {
const { app } = setup()
const { body: challengeBody } = await getChallenge(app, {
chainId: 1,
resources: ['https://api.example.com/signing-keys/1'],
})
const message = challengeBody.message!
const tampered = message.replace(
'https://api.example.com/signing-keys/1',
'https://api.example.com/signing-keys/2',
)
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({ domain: 'wallet.example', 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 () => {
const handler = auth({ domain: 'real.example' })
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({ domain: 'app.example', 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({ domain: 'app.example' })
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)
})
})
describe('verify (TIP-1053 witness)', () => {
test('default: verifies witness-bound key authorization and issues a session', async () => {
const store = Kv.memory()
const { app } = setup({ store })
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const keyAuthorization = signWitnessKeyAuth({ chainId: 1, message })
const res = await postVerify(app, {
address: account.address,
message,
signature: await account.signMessage({ message }),
keyAuthorization,
})
expect(res.status).toBe(200)
expect(await res.json()).toMatchInlineSnapshot(`{}`)
})
test('error: rejects when witness does not match hashMessage(message)', async () => {
const { app } = setup()
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const keyAuthorization = signWitnessKeyAuth({
chainId: 1,
message,
witness: hashMessage('different message'),
})
const res = await postVerify(app, {
address: account.address,
message,
signature: await account.signMessage({ message }),
keyAuthorization,
})
expect(res.status).toBe(401)
expect(await res.json()).toMatchInlineSnapshot(`
{
"error": "witness mismatch",
}
`)
})
test('error: rejects when the key authorization was signed by another account', async () => {
const { app } = setup()
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
// Bind the correct witness but sign with a different key.
const keyAuthorization = signWitnessKeyAuth({
chainId: 1,
message,
signWith: '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba',
})
const res = await postVerify(app, {
address: account.address,
message,
signature: await account.signMessage({ message }),
keyAuthorization,
})
expect(res.status).toBe(401)
expect(await res.json()).toMatchInlineSnapshot(`
{
"error": "signature does not match address",
}
`)
})
test('error: rejects when the key authorization chain differs from the challenge', async () => {
const { app } = setup()
const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
const message = challengeBody.message!
const keyAuthorization = signWitnessKeyAuth({ chainId: 2, message })
const res = await postVerify(app, {
address: account.address,
message,
signature: await account.signMessage({ message }),
keyAuthorization,
})
expect(res.status).toBe(400)
expect(await res.json()).toMatchInlineSnapshot(`
{
"error": "chainId mismatch",
}
`)
})
})
describe('verify (identity / OIDC)', () => {
// Ed25519 keypair for the issuer (JWK strings).
const signingKey = JSON.stringify({
alg: 'Ed25519',
crv: 'Ed25519',
d: 'tx-s_Aj4ltT_rpY_AIEKexmitq2eyWMkuuIy5JMzmn4',
x: 'eZEsf-38KiwfrWnn88cokaJmAoOVgTocC1TndJsz_uQ',
kty: 'OKP',
})
const publicKey = JSON.stringify({
alg: 'Ed25519',
crv: 'Ed25519',
x: 'eZEsf-38KiwfrWnn88cokaJmAoOVgTocC1TndJsz_uQ',
kty: 'OKP',
})
// The resolved verify-request origin (host `wallet.example`, http) — the
// audience the wallet must bind the token to.
const audience = 'http://wallet.example'
/** Spins up a real `Handler.oidcProvider` so verify can fetch its JWKS. */
async function startIssuer(
getClaims: Parameters<typeof Handler.oidcProvider>[0]['getClaims'] = () => ({
email: 'alice@example.com',
email_verified: true,
}),
) {
let listener: Parameters<typeof createServer>[0] | undefined
const server = await createServer((req, res) => {
if (!listener) {
const oidc = Handler.oidcProvider({
claimsSupported: ['iss', 'aud', 'sub', 'iat', 'exp', 'nonce', 'email', 'email_verified'],
getClaims,
issuer: `${server.url}/oidc`,
kid: 'test-1',
path: '/oidc',
publicKey,
signingKey,
})
listener = oidc.listener
}
return listener(req, res)
})
return Object.assign(server, { issuer: `${server.url}/oidc` })
}
/** Mints an id token via a running issuer's `/token` endpoint. */
async function mint(issuer: string, body: { audience: string; nonce: string; subject: string }) {
const res = await fetch(`${issuer}/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
const { idToken } = (await res.json()) as { idToken: string }
return idToken
}
/** Runs a full SIWE verify carrying an id token; returns the verify Response. */
async function connect(
app: Hono,
options: { issuer: string; audience?: string; nonce?: string; subject?: string },
) {
const { body } = await getChallenge(app, { chainId: 1 })
const message = body.message!
const nonce = parseSiweMessage(message).nonce!
const signature = await account.signMessage({ message })
const idToken = await mint(options.issuer, {
audience: options.audience ?? audience,
nonce: options.nonce ?? nonce,
subject: options.subject ?? account.address,
})
return postVerify(app, {
address: account.address,
idToken,
message,
returnToken: true,
signature,
})
}
/** Reads the session for a bearer token issued by `setup().handler`. */
function getSession(handler: ReturnType<typeof setup>['handler'], token: string) {
return handler.getSession(
new Request('http://wallet.example/', { headers: { authorization: `Bearer ${token}` } }),
)
}
let server: Awaited<ReturnType<typeof startIssuer>>
beforeAll(async () => {
server = await startIssuer()
})
afterAll(() => server.close())
test('default: valid idToken folds the verified email onto the session', async () => {
const { handler, app } = setup({ identity: { issuer: server.issuer } })
const res = await connect(app, { issuer: server.issuer })
expect(res.status).toBe(200)
const { token } = (await res.json()) as { token: string }
const session = await getSession(handler, token)
expect(session?.address).toBe(account.address)
expect(session?.email).toBe('alice@example.com')
})
test('error: audience mismatch rejects with 401', async () => {
const { app } = setup({ identity: { issuer: server.issuer } })
const res = await connect(app, { audience: 'http://evil.example', issuer: server.issuer })
expect(res.status).toBe(401)
})
test('error: nonce mismatch rejects with 401', async () => {
const { app } = setup({ identity: { issuer: server.issuer } })
const res = await connect(app, { issuer: server.issuer, nonce: 'not-the-siwe-nonce' })
expect(res.status).toBe(401)
})
test('error: subject (address) mismatch rejects with 401', async () => {
const { app } = setup({ identity: { issuer: server.issuer } })
const res = await connect(app, { issuer: server.issuer, subject: otherAccount.address })
expect(res.status).toBe(401)
})
test('error: email_verified=false rejects with 401', async () => {
const issuer = await startIssuer(() => ({ email: 'x@example.com', email_verified: false }))
try {
const { app } = setup({ identity: { issuer: issuer.issuer } })
const res = await connect(app, { issuer: issuer.issuer })
expect(res.status).toBe(401)
} finally {
issuer.close()
}
})
test('default: no idToken (not required) issues a session without email', async () => {
const { handler, app } = setup({ identity: { issuer: server.issuer } })
const { body } = await getChallenge(app, { chainId: 1 })
const message = body.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, {
address: account.address,
message,
returnToken: true,
signature,
})
expect(res.status).toBe(200)
const { token } = (await res.json()) as { token: string }
const session = await getSession(handler, token)
expect(session?.email).toBeUndefined()
})
test('error: required identity but no idToken rejects with 400', async () => {
const { app } = setup({ identity: { issuer: server.issuer, required: true } })
const { body } = await getChallenge(app, { chainId: 1 })
const message = body.message!
const signature = await account.signMessage({ message })
const res = await postVerify(app, { address: account.address, message, signature })
expect(res.status).toBe(400)
})
test('default: identity is verified against the default issuer when not configured', async () => {
// Identity is on by default: with no `identity` option the issuer defaults
// to the Tempo wallet's OIDC mount, so a token from a different issuer is
// rejected rather than silently ignored.
const { app } = setup()
const res = await connect(app, { issuer: server.issuer })
expect(res.status).toBe(401)
})
})
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
resources?: readonly string[] | undefined
},
) {
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
idToken?: string
message: string
signature?: string
keyAuthorization?: string
returnToken?: boolean
},
) {
const res = await app.request('/', {
method: 'POST',
headers: { 'content-type': 'application/json', host: 'wallet.example' },
body: JSON.stringify(body),
})
return res
}