diff --git a/.changeset/brave-otters-serve.md b/.changeset/brave-otters-serve.md new file mode 100644 index 0000000000..8ea4e9bc37 --- /dev/null +++ b/.changeset/brave-otters-serve.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ server: add ios wallet extension provisioning diff --git a/.changeset/clever-ravens-send.md b/.changeset/clever-ravens-send.md new file mode 100644 index 0000000000..9c737b6c14 --- /dev/null +++ b/.changeset/clever-ravens-send.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ send client platform header diff --git a/.do/app.yaml b/.do/app.yaml index 4b67a083a6..5c329eb376 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -165,6 +165,10 @@ services: - key: SENTRY_DSN scope: RUN_TIME value: ${{ env.SENTRY_DSN }} + - key: WALLET_EXTENSION_SECRET + scope: RUN_TIME + type: SECRET + value: ${{ env.ENCRYPTED_WALLET_EXTENSION_SECRET || env.WALLET_EXTENSION_SECRET }} http_port: 3000 image: registry: exactly diff --git a/server/api/auth/authentication.ts b/server/api/auth/authentication.ts index 6ccb6ba430..72eb5cdd11 100644 --- a/server/api/auth/authentication.ts +++ b/server/api/auth/authentication.ts @@ -24,6 +24,7 @@ import { picklist, pipe, record, + safeParse, string, title, union, @@ -51,6 +52,7 @@ import publicClient from "../../utils/publicClient"; import redis from "../../utils/redis"; import validatorHook from "../../utils/validatorHook"; import validFactories from "../../utils/validFactories"; +import { walletExtension } from "../../utils/walletExtension"; const Cookie = object({ session_id: optional(pipe(Base64URL, title("Session identifier"), description("HTTP-only cookie."))), @@ -115,6 +117,12 @@ export const Authentication = object({ ...Credential.entries, auth: pipe(number(), title("Session expiry"), description("When the authenticated session will expire.")), intercomToken: pipe(nullable(string()), description("Intercom Identity Verification Token")), + walletExtension: optional( + object({ + token: pipe(string(), description("Apple Wallet Extension bearer token.")), + expire: pipe(number(), description("Apple Wallet Extension bearer token expiry.")), + }), + ), }); export const LegacyAuthentication = object({ @@ -250,7 +258,15 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se Cookie, validatorHook({ code: "bad session" }), ), - vValidator("header", optional(object({ "Client-Fid": optional(pipe(string(), maxLength(36))) }))), + vValidator( + "header", + optional( + object({ + "Client-Fid": optional(pipe(string(), maxLength(36))), + "Client-Platform": optional(literal("ios")), + }), + ), + ), vValidator( "query", optional( @@ -315,6 +331,8 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se async (c) => { const assertion = c.req.valid("json"); const factory = c.req.valid("query")?.factory ?? undefined; + const platform = safeParse(optional(literal("ios")), c.req.header("Client-Platform")); + if (!platform.success) return c.json({ code: "bad client platform" }, 400); setContext("auth", assertion); const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id; if (!sessionId) return c.json({ code: "bad session" }, 400); @@ -349,6 +367,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se ...result, expires: result.auth, intercomToken, + ...(platform.output === "ios" ? await walletExtension(assertion.id) : {}), } satisfies InferOutput, 200, ); @@ -419,6 +438,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se auth: expires.getTime(), expires: expires.getTime(), intercomToken, + ...(platform.output === "ios" ? await walletExtension(assertion.id) : {}), } satisfies InferOutput, 200, ); diff --git a/server/api/auth/registration.ts b/server/api/auth/registration.ts index 8a84e48cd8..8e018817f5 100644 --- a/server/api/auth/registration.ts +++ b/server/api/auth/registration.ts @@ -23,6 +23,7 @@ import { optional, pipe, record, + safeParse, string, title, unknown, @@ -46,6 +47,7 @@ import publicClient from "../../utils/publicClient"; import redis from "../../utils/redis"; import validatorHook from "../../utils/validatorHook"; import validFactories from "../../utils/validFactories"; +import { walletExtension } from "../../utils/walletExtension"; const Cookie = object({ session_id: optional(pipe(Base64URL, title("Session identifier"), description("HTTP-only cookie."))), @@ -255,7 +257,15 @@ export default new Hono() Cookie, validatorHook({ code: "bad session" }), ), - vValidator("header", optional(object({ "Client-Fid": optional(pipe(string(), maxLength(36))) }))), + vValidator( + "header", + optional( + object({ + "Client-Fid": optional(pipe(string(), maxLength(36))), + "Client-Platform": optional(literal("ios")), + }), + ), + ), vValidator( "query", optional( @@ -315,6 +325,8 @@ export default new Hono() async (c) => { const attestation = c.req.valid("json"); const factory = c.req.valid("query")?.factory ?? undefined; + const platform = safeParse(optional(literal("ios")), c.req.header("Client-Platform")); + if (!platform.success) return c.json({ code: "bad client platform" }, 400); setContext("auth", attestation); const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id; if (!sessionId) return c.json({ code: "bad session" }, 400); @@ -382,6 +394,7 @@ export default new Hono() { ...result, intercomToken, + ...(platform.output === "ios" ? await walletExtension(attestation.id) : {}), } satisfies InferOutput, 200, ); diff --git a/server/api/card.ts b/server/api/card.ts index de435aa74a..8e869b2194 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -2,6 +2,7 @@ import { captureException, setContext, setUser, withScope } from "@sentry/node"; import { Mutex } from "async-mutex"; import { eq, inArray, ne } from "drizzle-orm"; import { Hono } from "hono"; +import { createMiddleware } from "hono/factory"; import { describeRoute } from "hono-openapi"; import { resolver, validator as vValidator } from "hono-openapi/valibot"; import { @@ -64,6 +65,7 @@ import { customer } from "../utils/sardine"; import { track } from "../utils/segment"; import ServiceError from "../utils/ServiceError"; import validatorHook from "../utils/validatorHook"; +import { verifyToken } from "../utils/walletExtension"; const mutexes = new Map(); function createMutex(credentialId: string) { @@ -256,7 +258,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str } \`\`\` -`, + `, tags: ["Card"], security: [{ credentialAuth: [] }], validateResponse: true, @@ -398,6 +400,106 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str } else return c.json({ code: "no card" }, 404); }, ) + .get( + "/provisioning", + describeRoute({ + summary: "Get wallet extension card provisioning information", + description: ` +Retrieve push-provisioning credentials for Apple Wallet Extension callers. + +This endpoint only accepts Wallet Extension bearer access. It does not accept \`credential_id\` cookies, Better Auth sessions, or \`sessionid\`. + `, + tags: ["Card"], + security: [{ extensionAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Card provisioning information", + content: { + "application/json": { + schema: resolver( + object({ + id: pipe(string(), metadata({ examples: ["card_abc123"] })), + secret: pipe(string(), metadata({ examples: ["otp_xyz"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver(object({ code: literal("unauthorized") }), { errorMode: "ignore" }), + }, + }, + }, + 403: { + description: "Forbidden", + content: { + "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) }, + }, + }, + 404: { + description: "Not found", + content: { + "application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) }, + }, + }, + }, + }), + createMiddleware<{ + Variables: { walletExtension: NonNullable>> }; + }>(async (c, next) => { + const authorization = c.req.header("authorization"); + if (!authorization) return c.json({ code: "unauthorized" }, 401); + if (!/^Bearer \S+$/i.test(authorization)) return c.json({ code: "unauthorized" }, 401); + if (c.req.header("cookie") || c.req.header("sessionid")) return c.json({ code: "unauthorized" }, 401); + const verified = await verifyToken(authorization.slice("Bearer ".length)); + if (!verified) return c.json({ code: "unauthorized" }, 401); + c.set("walletExtension", verified); + await next(); + }), + async (c) => { + c.header("Cache-Control", "no-store"); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, c.get("walletExtension").credentialId), + columns: { pandaId: true }, + with: { + cards: { + columns: { id: true }, + where: inArray(cards.status, ["ACTIVE", "FROZEN"]), + }, + }, + }); + if (!credential) return c.json({ code: "unauthorized" }, 401); + const [card] = credential.cards; + if (!card) return c.json({ code: "no card" }, 404); + if (!credential.pandaId) return c.json({ code: "no panda" }, 403); + const provider = await getCard(card.id).catch((error: unknown) => { + if (error instanceof ServiceError && error.status === 404) return null; + throw error; + }); + if (!provider) return c.json({ code: "no card" }, 404); + if (provider.userId !== credential.pandaId) return c.json({ code: "no panda" }, 403); + if (provider.status !== "active" && provider.status !== "locked") return c.json({ code: "no card" }, 404); + try { + const { processorCardId, timeBasedSecret } = await getProcessorDetails(card.id); + return c.json( + { + id: processorCardId, + secret: timeBasedSecret, + } satisfies InferOutput["provisioning"], + 200, + ); + } catch (error) { + if (error instanceof ServiceError && error.status === 404) return c.json({ code: "no card" }, 404); + if (error instanceof ServiceError && error.status === 403) return c.json({ code: "no panda" }, 403); + throw error; + } + }, + ) .post( "/", auth(), diff --git a/server/script/openapi.ts b/server/script/openapi.ts index afb309743d..d4c0ede390 100644 --- a/server/script/openapi.ts +++ b/server/script/openapi.ts @@ -31,6 +31,7 @@ process.env.REDIS_URL = "redis"; process.env.SARDINE_API_KEY = "sardine"; process.env.SARDINE_API_URL = "https://api.sardine.ai"; process.env.SEGMENT_WRITE_KEY = "segment"; +process.env.WALLET_EXTENSION_SECRET = zeroHash; /* eslint-disable n/no-process-exit, unicorn/no-process-exit, no-console -- cli */ import("../api") @@ -49,6 +50,7 @@ import("../api") in: "cookie", name: "credential_id", }, + extensionAuth: { type: "http", scheme: "bearer" }, siweAuth: { type: "apiKey", in: "cookie", name: "__Secure-better-auth.session_token" }, }, }, diff --git a/server/test/api/auth.test.ts b/server/test/api/auth.test.ts index 5a997d250e..5d2309173b 100644 --- a/server/test/api/auth.test.ts +++ b/server/test/api/auth.test.ts @@ -3,10 +3,11 @@ import "../expect"; import customer from "../mocks/sardine"; import "../mocks/sentry"; +import { captureException } from "@sentry/node"; import { verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { decodeJwt } from "jose"; +import { decodeJwt, decodeProtectedHeader, jwtVerify } from "jose"; import assert from "node:assert"; import { parse, type InferOutput } from "valibot"; import { getAddress, padHex, zeroAddress } from "viem"; @@ -16,12 +17,14 @@ import * as derive from "@exactly/common/deriveAddress"; import chain, { exaAccountFactoryAddress } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; -import app, { type Authentication } from "../../api/auth/authentication"; +import app, { Authentication } from "../../api/auth/authentication"; import registrationApp from "../../api/auth/registration"; import database, { credentials } from "../../database"; +import authSecret from "../../utils/authSecret"; import * as publicClient from "../../utils/publicClient"; import redis from "../../utils/redis"; import validFactories from "../../utils/validFactories"; +import { verifyToken } from "../../utils/walletExtension"; import type * as SimpleWebAuthn from "@simplewebauthn/server"; import type * as SimpleWebAuthnHelpers from "@simplewebauthn/server/helpers"; @@ -29,6 +32,15 @@ import type * as ViemSiwe from "viem/siwe"; const appClient = testClient(app); const registrationAppClient = testClient(registrationApp); +const WALLET_EXTENSION_EXPIRY = 60 * 24 * 60 * 60_000; + +vi.mock("@sentry/node", { spy: true }); + +function expectWalletExtensionExpire(expire: number, auth: number, start: number) { + expect(expire).toBeGreaterThan(auth); + expect(expire).toBeGreaterThan(start + WALLET_EXTENSION_EXPIRY - 1000); + expect(expire).toBeLessThanOrEqual(Date.now() + WALLET_EXTENSION_EXPIRY); +} describe("authentication", () => { beforeAll(async () => { @@ -69,8 +81,7 @@ describe("authentication", () => { expect(response.status).toBe(200); - const json = await response.json(); - const authResponse = json as InferOutput; + const authResponse = parse(Authentication, await response.json()); assert.ok(authResponse.intercomToken); @@ -84,6 +95,123 @@ describe("authentication", () => { await expect(redis.exists("test-session")).resolves.toBe(0); }); + it("returns wallet extension token on ios login", async () => { + const start = Date.now(); + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + }, + { headers: { cookie: "session_id=test-session", "Client-Platform": "ios" } }, + ); + + expect(response.status).toBe(200); + const json = await response.json(); + const authResponse = parse(Authentication, json); + + assert.ok(authResponse.walletExtension); + const { token } = authResponse.walletExtension; + const payload = decodeJwt(token); + const header = decodeProtectedHeader(token); + expectWalletExtensionExpire(authResponse.walletExtension.expire, authResponse.auth, start); + await expect(verifyToken(token)).resolves.toStrictEqual({ + credentialId: "dGVzdC1jcmVkLWlk", + scope: "card:provisioning", + }); + await expect( + jwtVerify(token, new TextEncoder().encode(authSecret), { + audience: "wallet-extension", + }), + ).rejects.toThrow(); + expect(payload.exp).toBe(Math.floor(authResponse.walletExtension.expire / 1000)); + expect(payload.iss).toBe("exa-server"); + expect(header.alg).toBe("HS256"); + }); + + it("captures invalid wallet extension token verification", async () => { + await expect(verifyToken("invalid")).resolves.toBeNull(); + + expect(captureException).toHaveBeenCalledExactlyOnceWith(expect.any(Error), { level: "warning" }); + }); + + it("rejects short wallet extension secrets", async () => { + const secret = process.env.WALLET_EXTENSION_SECRET; + vi.resetModules(); + vi.stubEnv("WALLET_EXTENSION_SECRET", "short"); + + await expect(import("../../utils/walletExtension")).rejects.toThrow("wallet extension secret too short for HS256"); + + vi.stubEnv("WALLET_EXTENSION_SECRET", secret); + vi.resetModules(); + }); + + it("returns wallet extension token on ios siwe signup", async () => { + vi.spyOn(publicClient.default, "verifySiweMessage").mockResolvedValue(true); + const id = "0x1234567890123456789012345678901234567888"; + const start = Date.now(); + const response = await appClient.index.$post( + { json: { method: "siwe", id, signature: "0xdeadbeef" } }, + { headers: { cookie: "session_id=test-session", "Client-Platform": "ios" } }, + ); + + expect(response.status).toBe(200); + const json = await response.json(); + const authResponse = parse(Authentication, json); + + assert.ok(authResponse.walletExtension); + expectWalletExtensionExpire(authResponse.walletExtension.expire, authResponse.auth, start); + await expect(verifyToken(authResponse.walletExtension.token)).resolves.toStrictEqual({ + credentialId: id, + scope: "card:provisioning", + }); + }); + + it("rejects unknown client platform login", async () => { + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + }, + { headers: { cookie: "session_id=test-session", "Client-Platform": "desktop" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad client platform" }); + }); + + it("omits wallet extension token without client platform", async () => { + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(200); + const authResponse = await response.json(); + + expect(authResponse).not.toHaveProperty("walletExtension"); + }); + it("returns 400 if authentication challenge is missing", async () => { await redis.del("test-session"); @@ -631,6 +759,56 @@ describe("registration", () => { await expect(redis.exists("test-session")).resolves.toBe(0); }); + it("returns wallet extension token on ios webauthn registration", async () => { + const id = "aW9zLXJlZ2lzdHJhdGlvbg"; // cspell:ignore Glvbg + const account = parse(Address, "0x1234567890123456789012345678901234567894"); + vi.spyOn(derive, "default").mockReturnValue(account); + const start = Date.now(); + const response = await registrationAppClient.index.$post( + { json: registrationWebauthnAssertion({ id, rawId: id }) }, + { headers: { cookie: "session_id=test-session", "Client-Platform": "ios" } }, + ); + + expect(response.status).toBe(200); + const json = await response.json(); + const authResponse = parse(Authentication, json); + + assert.ok(authResponse.walletExtension); + expectWalletExtensionExpire(authResponse.walletExtension.expire, authResponse.auth, start); + await expect(verifyToken(authResponse.walletExtension.token)).resolves.toStrictEqual({ + credentialId: id, + scope: "card:provisioning", + }); + }); + + it("omits wallet extension token without client platform webauthn registration", async () => { + const id = "bm8tcGxhdGZvcm0tcmVnaXN0cmF0aW9u"; // cspell:ignore bm8tcGxhdGZvcm0tcmVnaXN0cmF0aW9u + const account = parse(Address, "0x1234567890123456789012345678901234567897"); + vi.spyOn(derive, "default").mockReturnValue(account); + const response = await registrationAppClient.index.$post( + { json: registrationWebauthnAssertion({ id, rawId: id }) }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(200); + const json = await response.json(); + + expect(json).not.toHaveProperty("walletExtension"); + }); + + it("rejects unknown client platform webauthn registration", async () => { + const id = "ZGVza3RvcC1yZWdpc3RyYXRpb24"; // cspell:ignore ZGVza3RvcC1yZWdpc3RyYXRpb24 + const account = parse(Address, "0x1234567890123456789012345678901234567898"); + vi.spyOn(derive, "default").mockReturnValue(account); + const response = await registrationAppClient.index.$post( + { json: registrationWebauthnAssertion({ id, rawId: id }) }, + { headers: { cookie: "session_id=test-session", "Client-Platform": "desktop" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad client platform" }); + }); + it("creates a credential using webauthn", async () => { const account = parse(Address, "0x1234567890123456789012345678901234567892"); vi.spyOn(derive, "default").mockReturnValue(account); diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 4ec485517c..da62a720dc 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -10,6 +10,9 @@ import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { testClient } from "hono/testing"; +import { serializeSigned } from "hono/utils/cookie"; +import { SignJWT } from "jose"; +import { createSecretKey } from "node:crypto"; import { parse } from "valibot"; import { checksumAddress, hexToBigInt, padHex, parseEther, zeroHash } from "viem"; import { privateKeyToAccount, privateKeyToAddress } from "viem/accounts"; @@ -25,15 +28,21 @@ import { Address } from "@exactly/common/validation"; import app from "../../api/card"; import database, { cards, credentials } from "../../database"; +import auth from "../../utils/auth"; +import authSecret from "../../utils/authSecret"; import keeper from "../../utils/keeper"; import * as panda from "../../utils/panda"; import * as pax from "../../utils/pax"; import * as persona from "../../utils/persona"; import ServiceError from "../../utils/ServiceError"; +import { walletExtension } from "../../utils/walletExtension"; import type { UnofficialStatusCode } from "hono/utils/http-status"; const appClient = testClient(app); +const { WALLET_EXTENSION_SECRET } = process.env; +if (!WALLET_EXTENSION_SECRET) throw new Error("missing wallet extension secret"); +const walletExtensionKey = createSecretKey(Buffer.from(WALLET_EXTENSION_SECRET, "utf8")); describe("authenticated", () => { beforeAll(async () => { @@ -2227,6 +2236,377 @@ describe("authenticated", () => { }); }); +describe("wallet extension", () => { + beforeAll(async () => { + await database.insert(credentials).values([ + { + id: "wallet-extension", + publicKey: new Uint8Array(), + account: parse(Address, "0x0000000000000000000000000000000000000456"), + factory: parse(Address, inject("ExaAccountFactory")), + pandaId: "wallet-extension", + }, + { + id: "wallet-extension-empty", + publicKey: new Uint8Array(), + account: parse(Address, "0x0000000000000000000000000000000000000457"), + factory: parse(Address, inject("ExaAccountFactory")), + pandaId: "wallet-extension-empty", + }, + { + id: "wallet-extension-no-panda", + publicKey: new Uint8Array(), + account: parse(Address, "0x0000000000000000000000000000000000000458"), + factory: parse(Address, inject("ExaAccountFactory")), + }, + { + id: "wallet-extension-frozen", + publicKey: new Uint8Array(), + account: parse(Address, "0x0000000000000000000000000000000000000460"), + factory: parse(Address, inject("ExaAccountFactory")), + pandaId: "wallet-extension-frozen", + }, + { + id: "wallet-extension-deleted", + publicKey: new Uint8Array(), + account: parse(Address, "0x0000000000000000000000000000000000000461"), + factory: parse(Address, inject("ExaAccountFactory")), + pandaId: "wallet-extension-deleted", + }, + ]); + await database.insert(cards).values([ + { + id: "wallet-extension-card", + credentialId: "wallet-extension", + lastFour: "4567", + }, + { + id: "wallet-extension-no-panda-card", + credentialId: "wallet-extension-no-panda", + lastFour: "4568", + }, + { + id: "wallet-extension-frozen-card", + credentialId: "wallet-extension-frozen", + lastFour: "4570", + status: "FROZEN", + }, + { + id: "wallet-extension-deleted-card", + credentialId: "wallet-extension-deleted", + lastFour: "4571", + status: "DELETED", + }, + ]); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("rejects cookie auth", async () => { + const response = await app.request("/provisioning", { + headers: { cookie: await serializeSigned("credential_id", "wallet-extension", authSecret) }, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + }); + + it("rejects better auth", async () => { + const session = vi.spyOn(auth.api, "getSession"); + const response = await app.request("/provisioning", { + headers: { cookie: "__Secure-better-auth.session_token=session" }, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + expect(session).not.toHaveBeenCalled(); + }); + + it("rejects missing authorization", async () => { + const getProcessorDetails = vi.spyOn(panda, "getProcessorDetails"); + const response = await app.request("/provisioning"); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + expect(getProcessorDetails).not.toHaveBeenCalled(); + }); + + it("rejects bearer auth with credential cookie", async () => { + const response = await app.request("/provisioning", { + headers: { + authorization: await bearer(), + cookie: await serializeSigned("credential_id", "wallet-extension", authSecret), + }, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + }); + + it("rejects bearer auth with sessionid", async () => { + const response = await app.request("/provisioning", { + headers: { + authorization: await bearer(), + sessionid: "session", + }, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + }); + + it.each([ + ["malformed", "Bearer nope"], + ["wrong scheme", "Basic nope"], + ["missing token", "Bearer"], + ])("rejects %s authorization", async (_, authorization) => { + const getProcessorDetails = vi.spyOn(panda, "getProcessorDetails"); + const response = await app.request("/provisioning", { headers: { authorization } }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + expect(getProcessorDetails).not.toHaveBeenCalled(); + }); + + it.each([ + { name: "expired", expires: Date.now() - 1000 }, + { name: "wrong algorithm", algorithm: "HS384" }, + { name: "wrong audience", audience: "other" }, + { name: "wrong issuer", issuer: "other" }, + { name: "wrong scope", payload: { scope: "other" } }, + ])( + "rejects $name token", + async ({ + algorithm = "HS256", + audience = "wallet-extension", + expires = Date.now() + 60_000, + issuer = "exa-server", + payload = {}, + }) => { + const getProcessorDetails = vi.spyOn(panda, "getProcessorDetails"); + const calls = vi.mocked(captureException).mock.calls.length; + const response = await app.request("/provisioning", { + headers: { + authorization: `Bearer ${await new SignJWT({ + credentialId: "wallet-extension", + scope: "card:provisioning", + ...payload, + }) + .setProtectedHeader({ alg: algorithm }) + .setAudience(audience) + .setIssuer(issuer) + .setIssuedAt() + .setExpirationTime(Math.floor(expires / 1000)) + .sign(walletExtensionKey)}`, + }, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + expect(getProcessorDetails).not.toHaveBeenCalled(); + expect(vi.mocked(captureException).mock.calls.slice(calls)).toStrictEqual([ + [expect.any(Error), { level: "warning" }], + ]); + }, + ); + + it("rejects bearer auth with extra authorization segments", async () => { + const getProcessorDetails = vi.spyOn(panda, "getProcessorDetails"); + const response = await app.request("/provisioning", { + headers: { authorization: `${await bearer()} extra` }, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + expect(getProcessorDetails).not.toHaveBeenCalled(); + }); + + it("returns bearer card secret", async () => { + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ + ...cardTemplate, + expirationMonth: "1", + expirationYear: "2030", + id: "wallet-extension-card", + last4: "4567", + limit: { amount: 100, frequency: "per24HourPeriod" }, + userId: "wallet-extension", + }); + const getUser = vi.spyOn(panda, "getUser"); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-wallet-extension", + timeBasedSecret: "secret-wallet-extension", + }); + vi.spyOn(panda, "getPIN"); + vi.spyOn(panda, "getSecrets"); + + const response = await app.request("/provisioning", { + headers: { authorization: await bearer() }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + await expect(response.json()).resolves.toStrictEqual({ + id: "proc-wallet-extension", + secret: "secret-wallet-extension", + }); + expect(getUser).not.toHaveBeenCalled(); + expect(panda.getCard).toHaveBeenCalledExactlyOnceWith("wallet-extension-card"); + expect(panda.getProcessorDetails).toHaveBeenCalledExactlyOnceWith("wallet-extension-card"); + expect(panda.getPIN).not.toHaveBeenCalled(); + expect(panda.getSecrets).not.toHaveBeenCalled(); + }); + + it("returns bearer card secret when local card is frozen", async () => { + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ + ...cardTemplate, + id: "wallet-extension-frozen-card", + last4: "4570", + status: "locked", + userId: "wallet-extension-frozen", + }); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-wallet-extension-frozen", + timeBasedSecret: "secret-wallet-extension-frozen", + }); + vi.spyOn(panda, "getPIN"); + vi.spyOn(panda, "getSecrets"); + + const response = await app.request("/provisioning", { + headers: { authorization: await bearer("wallet-extension-frozen") }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + await expect(response.json()).resolves.toStrictEqual({ + id: "proc-wallet-extension-frozen", + secret: "secret-wallet-extension-frozen", + }); + expect(panda.getCard).toHaveBeenCalledExactlyOnceWith("wallet-extension-frozen-card"); + expect(panda.getProcessorDetails).toHaveBeenCalledExactlyOnceWith("wallet-extension-frozen-card"); + expect(panda.getPIN).not.toHaveBeenCalled(); + expect(panda.getSecrets).not.toHaveBeenCalled(); + }); + + it("returns no card when provider card is stale", async () => { + vi.spyOn(panda, "getCard").mockRejectedValueOnce(new ServiceError("Panda", 404, "card not found")); + vi.spyOn(panda, "getProcessorDetails"); + + const response = await app.request("/provisioning", { + headers: { authorization: await bearer() }, + }); + + expect(response.status).toBe(404); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + await expect(response.json()).resolves.toStrictEqual({ code: "no card" }); + expect(panda.getCard).toHaveBeenCalledExactlyOnceWith("wallet-extension-card"); + expect(panda.getProcessorDetails).not.toHaveBeenCalled(); + }); + + it.each([ + [404, "no card"], + [403, "no panda"], + ])("returns %s when processor details fail with %s", async (status, code) => { + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ + ...cardTemplate, + id: "wallet-extension-card", + userId: "wallet-extension", + }); + vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Panda", status, code)); + + const response = await app.request("/provisioning", { + headers: { authorization: await bearer() }, + }); + + expect(response.status).toBe(status); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + await expect(response.json()).resolves.toStrictEqual({ code }); + expect(panda.getProcessorDetails).toHaveBeenCalledExactlyOnceWith("wallet-extension-card"); + }); + + it("rejects bearer card secret when credential is missing", async () => { + const getProcessorDetails = vi.spyOn(panda, "getProcessorDetails"); + const response = await app.request("/provisioning", { + headers: { authorization: await bearer("missing-wallet-extension") }, + }); + + expect(response.status).toBe(401); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + expect(getProcessorDetails).not.toHaveBeenCalled(); + }); + + it("rejects bearer card secret when card is missing", async () => { + const getProcessorDetails = vi.spyOn(panda, "getProcessorDetails"); + const response = await app.request("/provisioning", { + headers: { authorization: await bearer("wallet-extension-empty") }, + }); + + expect(response.status).toBe(404); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + await expect(response.json()).resolves.toStrictEqual({ code: "no card" }); + expect(getProcessorDetails).not.toHaveBeenCalled(); + }); + + it("rejects bearer card secret when local card is deleted", async () => { + const getCard = vi.spyOn(panda, "getCard"); + const getProcessorDetails = vi.spyOn(panda, "getProcessorDetails"); + const response = await app.request("/provisioning", { + headers: { authorization: await bearer("wallet-extension-deleted") }, + }); + + expect(response.status).toBe(404); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + await expect(response.json()).resolves.toStrictEqual({ code: "no card" }); + expect(getCard).not.toHaveBeenCalled(); + expect(getProcessorDetails).not.toHaveBeenCalled(); + }); + + it("rejects bearer card secret when credential has no panda user", async () => { + const getProcessorDetails = vi.spyOn(panda, "getProcessorDetails"); + const response = await app.request("/provisioning", { + headers: { authorization: await bearer("wallet-extension-no-panda") }, + }); + + expect(response.status).toBe(403); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + expect(getProcessorDetails).not.toHaveBeenCalled(); + }); + + it("rejects bearer card secret when provider card belongs to another user", async () => { + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ ...cardTemplate, id: "wallet-extension-card", userId: "other" }); + vi.spyOn(panda, "getProcessorDetails"); + + const response = await app.request("/provisioning", { + headers: { authorization: await bearer() }, + }); + + expect(response.status).toBe(403); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + expect(panda.getProcessorDetails).not.toHaveBeenCalled(); + }); + + it("rejects bearer card secret when provider card is not active", async () => { + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ + ...cardTemplate, + id: "wallet-extension-card", + status: "canceled", + userId: "wallet-extension", + }); + vi.spyOn(panda, "getProcessorDetails"); + + const response = await app.request("/provisioning", { + headers: { authorization: await bearer() }, + }); + + expect(response.status).toBe(404); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + await expect(response.json()).resolves.toStrictEqual({ code: "no card" }); + expect(panda.getProcessorDetails).not.toHaveBeenCalled(); + }); +}); + const cardTemplate = { expirationMonth: "9", expirationYear: "2029", @@ -2269,6 +2649,11 @@ const mockERC20Abi = [ }, ] as const; +async function bearer(credentialId = "wallet-extension") { + const { walletExtension: extension } = await walletExtension(credentialId); + return `Bearer ${extension.token}`; +} + const { captureException } = vi.hoisted(() => ({ captureException: vi.fn() })); vi.mock("@sentry/node", async (importOriginal) => { const module = await importOriginal(); diff --git a/server/utils/walletExtension.ts b/server/utils/walletExtension.ts new file mode 100644 index 0000000000..aacd508b70 --- /dev/null +++ b/server/utils/walletExtension.ts @@ -0,0 +1,38 @@ +import { captureException } from "@sentry/node"; +import { jwtVerify, SignJWT } from "jose"; +import { createSecretKey } from "node:crypto"; +import { literal, object, parse, string } from "valibot"; + +const { WALLET_EXTENSION_SECRET } = process.env; + +if (!WALLET_EXTENSION_SECRET) throw new Error("missing wallet extension secret"); + +const key = createSecretKey(Buffer.from(WALLET_EXTENSION_SECRET, "utf8")); +if ((key.symmetricKeySize ?? 0) < 32) throw new Error("wallet extension secret too short for HS256"); +const issuer = "exa-server"; + +export async function walletExtension(credentialId: string) { + const expire = Date.now() + 60 * 24 * 60 * 60_000; + + return { + walletExtension: { + token: await new SignJWT({ credentialId, scope: "card:provisioning" }) + .setProtectedHeader({ alg: "HS256" }) + .setAudience("wallet-extension") + .setIssuer(issuer) + .setIssuedAt() + .setExpirationTime(Math.floor(expire / 1000)) + .sign(key), + expire, + }, + }; +} + +export function verifyToken(token: string) { + return jwtVerify(token, key, { algorithms: ["HS256"], audience: "wallet-extension", issuer }) + .then(({ payload }) => parse(object({ credentialId: string(), scope: literal("card:provisioning") }), payload)) + .catch((error: unknown) => { + captureException(error, { level: "warning" }); + return null; + }); +} diff --git a/server/vitest.config.mts b/server/vitest.config.mts index 76e3b67d16..8c7edee4f2 100644 --- a/server/vitest.config.mts +++ b/server/vitest.config.mts @@ -65,6 +65,7 @@ YQIDAQAB SARDINE_API_KEY: "sardine", SARDINE_API_URL: "https://api.sardine.ai", SEGMENT_WRITE_KEY: "segment", + WALLET_EXTENSION_SECRET: "wallet-extension-secret-32-bytes", ...(env.NODE_ENV === "e2e" && { APP_DOMAIN: "localhost", DEBUG: "exa:*" }), }, ...(env.NODE_ENV === "e2e" && { diff --git a/src/utils/server.ts b/src/utils/server.ts index f3986babb9..ccee5892f3 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -74,9 +74,10 @@ queryClient.setQueryDefaults(["auth"], { const api = hc(domain === "localhost" ? "http://localhost:3000/api" : `https://${domain}/api`, { init: { credentials: "include" }, fetch: async (input: Request | string | URL, init?: RequestInit) => { - if (!(await sdk.isInMiniApp())) return fetch(input, init); - const { client } = await sdk.context; const headers = new Headers(init?.headers); + if (Platform.OS === "ios") headers.set("Client-Platform", Platform.OS); + if (!(await sdk.isInMiniApp())) return fetch(input, { ...init, headers }); + const { client } = await sdk.context; headers.set("Client-Fid", String(client.clientFid)); return fetch(input, { ...init, headers }); },