import { serve } from '@hono/node-server' import * as Accounts from 'accounts/server' import { Hono } from 'hono' import { exportJWK, generateKeyPair } from 'jose' import { P256 } from 'ox' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { Account } from 'viem/tempo' import { tempoTestnet } from 'viem/tempo/chains' import * as TestApp from '../../../../test/App.js' import type * as App from '../../../App.js' import * as Organizations from '../../../db/tables/organizations.js' import * as Users from '../../../db/tables/users.js' import * as Me from './me.js' import * as Orgs from './orgs.js' /** Origin pinned for SIWE domain binding; Hono test requests use this host. */ const origin = 'http://localhost' const superAdminSecret = 'tempo:sk:c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2' function createApp() { return TestApp.create({ auth: { keys: [TestApp.key], superAdmin: { secret: superAdminSecret } }, session: { wallet: { origin } }, }) } test('publishes a generator-ready OpenAPI contract', async () => { const spec = await (await createApp().request('/openapi.json')).json() const operation = spec.paths['/v1/me'].get expect(spec.components.schemas.User.properties.email.examples).toEqual(['dev@example.com']) expect({ component: spec.components.schemas.User ? 'User' : undefined, errors: { 400: operation.responses[400].content['application/json'].schema, 403: operation.responses[403].content['application/json'].schema, 404: operation.responses[404].content['application/json'].schema, }, formats: { address: spec.components.schemas.User.properties.address.pattern, createdAt: spec.components.schemas.User.properties.createdAt.format, email: spec.components.schemas.User.properties.email.format, updatedAt: spec.components.schemas.User.properties.updatedAt.format, }, operationId: operation.operationId, response: operation.responses[200].content['application/json'].schema, }).toMatchInlineSnapshot(` { "component": "User", "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/UserNotFoundError", }, }, "formats": { "address": "^0x[0-9a-fA-F]{40}$", "createdAt": "date-time", "email": "email", "updatedAt": "date-time", }, "operationId": "getMe", "response": { "$ref": "#/components/schemas/User", }, } `) }) describe('SIWE verification', () => { test('surfaces configured RPC failures as dependency errors', async () => { const account = Account.fromHeadlessWebAuthn(P256.randomPrivateKey(), { origin, rpId: 'localhost', }) let chainId_seen: number | undefined const app = TestApp.create({ rpc: ({ chainId }) => { chainId_seen = chainId return { url: 'http://127.0.0.1:1' } }, }) const { verifyResponse } = await TestApp.signIn(app, account, { chainId: tempoTestnet.id, }) expect(verifyResponse.status).toBe(502) expect(chainId_seen).toMatchInlineSnapshot(`42431`) expect(await verifyResponse.json()).toMatchInlineSnapshot(` { "error": "signature verification unavailable", } `) }) }) describe('GET /me', () => { test('returns the signed-in user', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account) expect(verifyResponse.status).toBe(200) expect(cookie).toBeDefined() const response = await app.request('/v1/me', { headers: { cookie: cookie! } }) expect(response.status).toBe(200) const body = await TestApp.json(response, Me.schema.User) expect(body.address).toBe(account.address.toLowerCase()) expect(body.id.startsWith('usr_')).toBe(true) expect(body.email).toBeUndefined() }) test('omits the wallet address for another identity provider', async () => { const db = TestApp.database() const user = await Users.upsertByAddress(db, { address: `0x${'11'.repeat(20)}`, }) const app = new Hono() .use('*', async (c, next) => { c.set('db', db) c.set('principal', { id: user.id, identity: { provider: 'https://issuer.example', subject: 'oidc|123' }, type: 'session', }) await next() }) .route('/', Me.me()) const response = await app.request('/v1/me') const body = await TestApp.json(response, Me.schema.User) expect(response.status).toBe(200) expect(body.address).toBeUndefined() await db.close() }) test('sign-in is idempotent per address', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const first = await TestApp.signIn(app, account) const firstMe = await app.request('/v1/me', { headers: { cookie: first.cookie! } }) const firstBody = await TestApp.json(firstMe, Me.schema.User) const second = await TestApp.signIn(app, account) const secondMe = await app.request('/v1/me', { headers: { cookie: second.cookie! } }) const secondBody = await TestApp.json(secondMe, Me.schema.User) expect(secondBody.id).toBe(firstBody.id) }) test('supports bearer-token sessions', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const { verifyResponse } = await TestApp.signIn(app, account, { returnToken: true }) const { token } = (await verifyResponse.json()) as { token: string } expect(typeof token).toBe('string') // The bearer session token takes the API-key header path first; the failed // key lookup must fall through to the session lane. const response = await app.request('/v1/me', { headers: { authorization: `Bearer ${token}` }, }) expect(response.status).toBe(200) }) test('logout revokes the session', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const { cookie } = await TestApp.signIn(app, account) const logout = await app.request('/v1/auth/logout', { headers: { cookie: cookie! }, method: 'POST', }) expect(logout.status).toBe(204) const response = await app.request('/v1/me', { headers: { cookie: cookie! } }) expect(response.status).toBe(401) }) test('logout revokes bearer-token sessions', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const { verifyResponse } = await TestApp.signIn(app, account, { returnToken: true }) const { token } = (await verifyResponse.json()) as { token: string } const logout = await app.request('/v1/auth/logout', { headers: { authorization: `Bearer ${token}` }, method: 'POST', }) expect(logout.status).toBe(204) const response = await app.request('/v1/me', { headers: { authorization: `Bearer ${token}` }, }) expect(response.status).toBe(401) }) test('rejects anonymous requests', async () => { const app = createApp() const response = await app.request('/v1/me') expect(response.status).toBe(401) }) test('rejects API keys', async () => { const app = createApp() const response = await app.request('/v1/me', { headers: { 'tempo-api-key': TestApp.key.token }, }) expect(response.status).toBe(403) const { requestId, ...body } = (await response.json()) as { requestId?: string } expect(requestId).toBeDefined() expect(body).toMatchInlineSnapshot(` { "error": { "code": "forbidden", "message": "API key not permitted for this route", }, } `) }) test('rejects the super admin (no user row)', async () => { const app = createApp() const response = await app.request('/v1/me', { headers: { 'tempo-api-key': superAdminSecret }, }) expect(response.status).toBe(403) const { requestId, ...body } = (await response.json()) as { requestId?: string } expect(requestId).toBeDefined() expect(body).toMatchInlineSnapshot(` { "error": { "code": "forbidden", "message": "Session required", }, } `) }) }) describe('SIWE identity', () => { test('rejects sign-ins without an identity token when email is required', async () => { const app = TestApp.create({ auth: { keys: [TestApp.key] }, session: { wallet: { origin, requireEmail: true } }, }) const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account) expect(verifyResponse.status).toBe(400) expect(cookie).toBeUndefined() expect(await verifyResponse.json()).toMatchInlineSnapshot(` { "error": "identity token required", } `) }) test('allows sign-ins without an identity token when an issuer is configured', async () => { const oidc = await createIssuer({ email: 'dev@example.com' }) try { const app = TestApp.create({ auth: { keys: [TestApp.key] }, session: { wallet: { issuer: oidc.url, origin } }, }) const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account) expect(verifyResponse.status).toBe(200) expect(cookie).toBeDefined() } finally { await oidc.close() } }) test('folds the token-verified email onto the session', async () => { const oidc = await createIssuer({ email: 'dev@example.com' }) const db = TestApp.database() try { const app = TestApp.create({ auth: { keys: [TestApp.key] }, db, session: { wallet: { issuer: oidc.url, origin } }, }) const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account, { idToken: ({ nonce }) => oidc.mint({ address: account.address, nonce }), }) // Simulate the previous Worker writing after the auth-field backfill. await db.kysely .updateTable('users') .set({ email: 'Dev@Example.com', emailVerified: false }) .where('address', '=', account.address.toLowerCase()) .execute() expect(verifyResponse.status).toBe(200) const response = await app.request('/v1/me', { headers: { cookie: cookie! } }) expect(response.status).toBe(200) const body = await TestApp.json(response, Me.schema.User) expect(body.email).toBe('dev@example.com') await expect(Users.getByAddress(db, account.address)).resolves.toMatchObject({ email: 'dev@example.com', emailVerified: true, }) } finally { await db.close() await oidc.close() } }) test('reuses the email-owned user for another wallet', async () => { const oidc = await createIssuer({ email: 'dev@example.com' }) const db = TestApp.database() try { const app = TestApp.create({ auth: { keys: [TestApp.key] }, db, session: { wallet: { issuer: oidc.url, origin } }, }) const firstAccount = privateKeyToAccount(generatePrivateKey()) const first = await TestApp.signIn(app, firstAccount, { idToken: ({ nonce }) => oidc.mint({ address: firstAccount.address, nonce }), }) const firstResponse = await app.request('/v1/me', { headers: { cookie: first.cookie! } }) const firstUser = await TestApp.json(firstResponse, Me.schema.User) const secondAccount = privateKeyToAccount(generatePrivateKey()) const provisional = await TestApp.signIn(app, secondAccount) const provisionalResponse = await app.request('/v1/me', { headers: { cookie: provisional.cookie! }, }) const provisionalUser = await TestApp.json(provisionalResponse, Me.schema.User) const reconciled = await TestApp.signIn(app, secondAccount, { idToken: ({ nonce }) => oidc.mint({ address: secondAccount.address, nonce }), }) const reconciledResponse = await app.request('/v1/me', { headers: { cookie: reconciled.cookie! }, }) const reconciledUser = await TestApp.json(reconciledResponse, Me.schema.User) const repeated = await TestApp.signIn(app, secondAccount) const repeatedResponse = await app.request('/v1/me', { headers: { cookie: repeated.cookie! }, }) const repeatedUser = await TestApp.json(repeatedResponse, Me.schema.User) const staleOrganization = await Organizations.createOwned(db, { name: 'Stale session organization', userId: provisionalUser.id, walletAddress: secondAccount.address, }) const organizationsResponse = await app.request('/v1/orgs', { headers: { cookie: repeated.cookie! }, }) const organizations = await TestApp.json( organizationsResponse, Orgs.schema.listOrganizations.Response, ) expect(firstResponse.status).toBe(200) expect(provisionalResponse.status).toBe(200) expect(reconciledResponse.status).toBe(200) expect(repeatedResponse.status).toBe(200) expect(provisionalUser.id).not.toBe(firstUser.id) expect(reconciledUser.id).toBe(firstUser.id) expect(repeatedUser.id).toBe(firstUser.id) expect(reconciledUser.address).toBe(secondAccount.address.toLowerCase()) expect(repeatedUser.address).toBe(secondAccount.address.toLowerCase()) expect(staleOrganization.userId).toBe(firstUser.id) expect(organizations.data.map(({ id }) => id)).toContain(staleOrganization.id) } finally { await db.close() await oidc.close() } }) test('serializes initial ownership of a verified email', async () => { const oidc = await createIssuer({ email: 'dev@example.com' }) const db = TestApp.database() try { const app = TestApp.create({ auth: { keys: [TestApp.key] }, db, session: { wallet: { issuer: oidc.url, origin } }, }) const accounts = Array.from({ length: 4 }, () => privateKeyToAccount(generatePrivateKey())) const sessions = await Promise.all( accounts.map((account) => TestApp.signIn(app, account, { idToken: ({ nonce }) => oidc.mint({ address: account.address, nonce }), }), ), ) const responses = await Promise.all( sessions.map(({ cookie }) => Promise.resolve(app.request('/v1/me', { headers: { cookie: cookie! } })), ), ) const users = await Promise.all( responses.map((response) => TestApp.json(response, Me.schema.User)), ) expect(new Set(users.map(({ id }) => id)).size).toBe(1) } finally { await db.close() await oidc.close() } }) test('keeps a provisional user that owns organization resources', async () => { const oidc = await createIssuer({ email: 'dev@example.com' }) const db = TestApp.database() try { const app = TestApp.create({ auth: { keys: [TestApp.key] }, db, session: { wallet: { issuer: oidc.url, origin } }, }) const emailAccount = privateKeyToAccount(generatePrivateKey()) const emailSession = await TestApp.signIn(app, emailAccount, { idToken: ({ nonce }) => oidc.mint({ address: emailAccount.address, nonce }), }) const emailResponse = await app.request('/v1/me', { headers: { cookie: emailSession.cookie! }, }) const emailUser = await TestApp.json(emailResponse, Me.schema.User) const provisionalAccount = privateKeyToAccount(generatePrivateKey()) const provisionalSession = await TestApp.signIn(app, provisionalAccount) const provisionalResponse = await app.request('/v1/me', { headers: { cookie: provisionalSession.cookie! }, }) const provisionalUser = await TestApp.json(provisionalResponse, Me.schema.User) const organization = await Organizations.createOwned(db, { name: 'Provisional organization', userId: provisionalUser.id, }) const reconciledSession = await TestApp.signIn(app, provisionalAccount, { idToken: ({ nonce }) => oidc.mint({ address: provisionalAccount.address, nonce }), }) const reconciledResponse = await app.request('/v1/me', { headers: { cookie: reconciledSession.cookie! }, }) const reconciledUser = await TestApp.json(reconciledResponse, Me.schema.User) const organizationsResponse = await app.request('/v1/orgs', { headers: { cookie: reconciledSession.cookie! }, }) const organizations = await TestApp.json( organizationsResponse, Orgs.schema.listOrganizations.Response, ) expect(emailResponse.status).toBe(200) expect(provisionalResponse.status).toBe(200) expect(reconciledResponse.status).toBe(200) expect(organizationsResponse.status).toBe(200) expect(reconciledUser.id).not.toBe(emailUser.id) expect(reconciledUser.id).toBe(provisionalUser.id) expect(organizations.data.map(({ id }) => id)).toContain(organization.id) // Simulate an address-owned user from before wallet links existed. await db.kysely .deleteFrom('auth_accounts') .where('accountId', '=', provisionalAccount.address.toLowerCase()) .where('issuer', '=', 'tempo:siwe') .execute() const legacySession = await TestApp.signIn(app, provisionalAccount, { idToken: ({ nonce }) => oidc.mint({ address: provisionalAccount.address, nonce }), }) const legacyResponse = await app.request('/v1/me', { headers: { cookie: legacySession.cookie! }, }) const legacyUser = await TestApp.json(legacyResponse, Me.schema.User) const legacyOrganizationsResponse = await app.request('/v1/orgs', { headers: { cookie: legacySession.cookie! }, }) const legacyOrganizations = await TestApp.json( legacyOrganizationsResponse, Orgs.schema.listOrganizations.Response, ) expect(legacyResponse.status).toBe(200) expect(legacyOrganizationsResponse.status).toBe(200) expect(legacyUser.id).toBe(provisionalUser.id) expect(legacyOrganizations.data.map(({ id }) => id)).toContain(organization.id) } finally { await db.close() await oidc.close() } }) }) describe('open sign-up', () => { test('allows new users on any domain', async () => { const oidc = await createIssuer({ email: 'dev@example.com' }) try { const app = TestApp.create({ auth: { keys: [TestApp.key] }, session: { wallet: { issuer: oidc.url, origin } }, }) const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account, { idToken: ({ nonce }) => oidc.mint({ address: account.address, nonce }), }) expect(verifyResponse.status).toBe(200) const response = await app.request('/v1/me', { headers: { cookie: cookie! } }) expect(response.status).toBe(200) const body = await TestApp.json(response, Me.schema.User) expect(body.email).toBe('dev@example.com') } finally { await oidc.close() } }) test('allows new users without an identity token', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account) expect(verifyResponse.status).toBe(200) expect(cookie).toBeDefined() }) }) /** Local OIDC issuer standing in for the wallet: the SDK's own provider (discovery, JWKS, token mint). */ async function createIssuer(options: { email: string }) { // Extractable: the provider takes key material as JWK strings. const { privateKey, publicKey } = await generateKeyPair('EdDSA', { extractable: true }) // The provider pins its `issuer` URL at construction, but the ephemeral // port is only known after bind — listen through a closure, assign after. let provider!: ReturnType type Listener = { close: () => Promise; url: string } const { close, url } = await new Promise((resolve) => { const server = serve({ fetch: (request) => provider.fetch(request), port: 0 }, (info) => resolve({ close: () => new Promise((done) => server.close(() => done())), url: `http://127.0.0.1:${info.port}`, }), ) }) provider = Accounts.Handler.oidcProvider({ // Test-only: no `authenticate`, so the request body's `subject` is trusted. getClaims: () => ({ email: options.email, email_verified: true }), issuer: url, publicKey: JSON.stringify(await exportJWK(publicKey)), signingKey: JSON.stringify(await exportJWK(privateKey)), }) return { close, /** Mints a wallet-style identity token bound to the SIWE nonce and signer. */ async mint(mintOptions: { address: string; nonce: string }) { const response = await provider.fetch( new Request(`${url}/token`, { body: JSON.stringify({ audience: origin, nonce: mintOptions.nonce, subject: mintOptions.address, }), headers: { 'content-type': 'application/json' }, method: 'POST', }), ) const { idToken } = (await response.json()) as { idToken: string } return idToken }, url, } }