From f5785c51ce6900fc3195c0cc65aa602747f4dfb9 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Tue, 29 Sep 2026 23:16:01 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20server:=20support=20multiple=20fron?= =?UTF-8?q?tend=20origins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cool-baths-hide.md | 5 +++ .changeset/plenty-results-feel.md | 5 +++ server/api/auth/authentication.ts | 29 +++++++++---- server/api/auth/registration.ts | 19 +++++--- server/api/card.ts | 14 +++--- server/api/index.ts | 9 ++-- server/api/kyc.ts | 5 ++- server/index.ts | 3 +- server/test/api/api.test.ts | 54 +++++++++++++++++++++++ server/test/api/auth.test.ts | 58 ++++++++++++++++++++++++- server/test/e2e.ts | 12 +++++ server/utils/appOrigin.ts | 25 ++++++++++- server/utils/auth.ts | 12 ++--- server/utils/createCredential.ts | 2 +- server/vitest.config.mts | 1 + src/app/_layout.tsx | 4 +- src/components/add-funds/AddFunds.tsx | 5 +-- src/components/add-funds/Ramp.tsx | 6 +-- src/components/add-funds/Status.tsx | 6 +-- src/components/send-funds/SendFunds.tsx | 5 +-- src/utils/persona.ts | 3 +- src/utils/server.ts | 4 +- src/utils/useRampOnboarding.ts | 6 +-- 23 files changed, 235 insertions(+), 57 deletions(-) create mode 100644 .changeset/cool-baths-hide.md create mode 100644 .changeset/plenty-results-feel.md diff --git a/.changeset/cool-baths-hide.md b/.changeset/cool-baths-hide.md new file mode 100644 index 0000000000..c3d5e4deb2 --- /dev/null +++ b/.changeset/cool-baths-hide.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ support multiple frontend origins diff --git a/.changeset/plenty-results-feel.md b/.changeset/plenty-results-feel.md new file mode 100644 index 0000000000..0416b363a9 --- /dev/null +++ b/.changeset/plenty-results-feel.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ use same-origin api requests diff --git a/server/api/auth/authentication.ts b/server/api/auth/authentication.ts index af6c2e47ea..472ddc9342 100644 --- a/server/api/auth/authentication.ts +++ b/server/api/auth/authentication.ts @@ -44,7 +44,7 @@ import { Address, Base64URL, Credential, Hex } from "@exactly/common/validation" import { credentials } from "../../database/schema"; import androidOrigins from "../../utils/android/origins"; -import appOrigin from "../../utils/appOrigin"; +import { origin, origins } from "../../utils/appOrigin"; import decodePublicKey from "../../utils/decodePublicKey"; import publicClient from "../../utils/publicClient"; import { IpAddress } from "../../utils/sardine"; @@ -205,7 +205,8 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates path: "/", expires, httpOnly: true, - ...(domain === "localhost" ? { sameSite: "lax", secure: false } : { domain, sameSite: "none", secure: true }), + sameSite: domain === "localhost" ? "lax" : "none", + secure: domain !== "localhost", }); c.header("X-Session-Id", sessionId); const { credentialId } = c.req.valid("query"); @@ -217,10 +218,10 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates address: credentialId, chainId: chain.id, nonce: sessionId, - uri: appOrigin, + uri: origin(c.req.raw), version: "1", issuedAt, - domain, + domain: new URL(origin(c.req.raw)).hostname, scheme, }); await redis.set(sessionId, message, "PX", timeout); @@ -378,7 +379,13 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se try { const message = parseSiweMessage(challenge); if ( - !validateSiweMessage({ message, address: assertion.id, nonce: sessionId, domain, scheme }) || + !validateSiweMessage({ + message, + address: assertion.id, + nonce: sessionId, + domain: new URL(origin(c.req.raw)).hostname, + scheme, + }) || !(await publicClient.verifySiweMessage({ message: challenge, address: assertion.id, @@ -417,7 +424,13 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se case "siwe": { const message = parseSiweMessage(challenge); if ( - !validateSiweMessage({ message, address: assertion.id, nonce: sessionId, domain, scheme }) || + !validateSiweMessage({ + message, + address: assertion.id, + nonce: sessionId, + domain: new URL(origin(c.req.raw)).hostname, + scheme, + }) || !(await publicClient.verifySiweMessage({ message: challenge, address: assertion.id, @@ -432,7 +445,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se const { verified, authenticationInfo } = await verifyAuthenticationResponse({ response: assertion, expectedRPID: domain, - expectedOrigin: [appOrigin, ...androidOrigins], + expectedOrigin: [...origins, ...androidOrigins], expectedChallenge: challenge, credential: { id: assertion.id, @@ -459,7 +472,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se httpOnly: true, ...(domain === "localhost" ? { sameSite: "lax", secure: false } - : { domain, sameSite: "none", secure: true, partitioned: true }), + : { sameSite: "none", secure: true, partitioned: true }), }), ]); diff --git a/server/api/auth/registration.ts b/server/api/auth/registration.ts index 0cfea06dab..71c2852a94 100644 --- a/server/api/auth/registration.ts +++ b/server/api/auth/registration.ts @@ -41,7 +41,7 @@ import { Address, Base64URL, Hex } from "@exactly/common/validation"; import { Authentication } from "./authentication"; import androidOrigins from "../../utils/android/origins"; -import appOrigin from "../../utils/appOrigin"; +import { origin, origins } from "../../utils/appOrigin"; import publicClient from "../../utils/publicClient"; import { IpAddress } from "../../utils/sardine"; import validatorHook from "../../utils/validatorHook"; @@ -205,7 +205,8 @@ export default function route({ path: "/", expires, httpOnly: true, - ...(domain === "localhost" ? { sameSite: "lax", secure: false } : { domain, sameSite: "none", secure: true }), + sameSite: domain === "localhost" ? "lax" : "none", + secure: domain !== "localhost", }); c.header("X-Session-Id", sessionId); const query = c.req.valid("query"); @@ -217,10 +218,10 @@ export default function route({ address: query.credentialId, chainId: chain.id, nonce: sessionId, - uri: appOrigin, + uri: origin(c.req.raw), version: "1", issuedAt, - domain, + domain: new URL(origin(c.req.raw)).hostname, scheme, }); await redis.set(sessionId, message, "PX", timeout); @@ -364,7 +365,13 @@ export default function route({ case "siwe": { const message = parseSiweMessage(challenge); if ( - !validateSiweMessage({ message, address: attestation.id, nonce: sessionId, domain, scheme }) || + !validateSiweMessage({ + message, + address: attestation.id, + nonce: sessionId, + domain: new URL(origin(c.req.raw)).hostname, + scheme, + }) || !(await publicClient.verifySiweMessage({ message: challenge, address: attestation.id, @@ -386,7 +393,7 @@ export default function route({ }, }, expectedRPID: domain, - expectedOrigin: [appOrigin, ...androidOrigins], + expectedOrigin: [...origins, ...androidOrigins], expectedChallenge: challenge, supportedAlgorithmIDs: [cose.COSEALG.ES256], }); diff --git a/server/api/card.ts b/server/api/card.ts index e3e31831e6..d2b9196c4a 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -33,13 +33,13 @@ import { import { base } from "viem/chains"; import { createSiweMessage, parseSiweMessage, verifySiweMessage } from "viem/siwe"; -import domain from "@exactly/common/domain"; import chain from "@exactly/common/generated/chain"; import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS"; import { BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; import { Address, Base64URL, Hex } from "@exactly/common/validation"; import { cards, credentials } from "../database/schema"; +import { origin } from "../utils/appOrigin"; import publicClient from "../utils/publicClient"; import ServiceError from "../utils/ServiceError"; import validatorHook from "../utils/validatorHook"; @@ -363,10 +363,10 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str if (!credential.pandaId) return; return panda.getNonce(credential.pandaId).then(({ nonce }) => createSiweMessage({ - domain, + domain: new URL(origin(c.req.raw)).hostname, address: parse(Address, credentialId), statement: `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`, - uri: `https://${domain}`, + uri: origin(c.req.raw), version: "1", chainId: chain.id, nonce, @@ -908,12 +908,16 @@ async function encryptPIN(pin: string) { const verified = await Promise.resolve() .then(() => parseSiweMessage(patch.message)) .then((m) => { - if (m.statement !== statement || m.chainId !== chain.id || m.domain !== domain) { + if ( + m.statement !== statement || + m.chainId !== chain.id || + m.domain !== new URL(origin(c.req.raw)).hostname + ) { return false; } return verifySiweMessage(publicClient, { address: parse(Address, credentialId), - domain, + domain: new URL(origin(c.req.raw)).hostname, message: patch.message, signature: patch.signature, }); diff --git a/server/api/index.ts b/server/api/index.ts index fb9e28ef8f..7875072b1e 100644 --- a/server/api/index.ts +++ b/server/api/index.ts @@ -13,7 +13,7 @@ import ramp from "./ramp"; import webhook from "./webhook"; import createAuth from "../middleware/auth"; import createOrg from "../middleware/org"; -import appOrigin from "../utils/appOrigin"; +import { origin, origins } from "../utils/appOrigin"; import createBetterAuth from "../utils/auth"; import createCredential from "../utils/createCredential"; @@ -64,15 +64,16 @@ export default function api({ walletExtension: ReturnType; }) { const betterAuth = createBetterAuth(database, authSecret); + const providers = new Map(origins.slice(1).map((url) => [url, createBetterAuth(database, authSecret, url)])); const auth = createAuth(authSecret); const org = createOrg(betterAuth); const credential = createCredential({ authSecret, database, sardine, segment, subscribe }); const app = new Hono() - .use(cors({ origin: [appOrigin, "http://localhost:8081"], credentials: true, exposeHeaders: ["X-Session-Id"] })) + .use(cors({ origin: [...origins, "http://localhost:8081"], credentials: true, exposeHeaders: ["X-Session-Id"] })) .use((c, next) => { if (c.req.method.toUpperCase() === "OPTIONS") return next(); if (!c.req.header("origin") && !c.req.header("sec-fetch-site")) return next(); - return csrf({ origin: [appOrigin, "http://localhost:8081"] })(c, next); + return csrf({ origin: [...origins, "http://localhost:8081"] })(c, next); }) .route("/auth/registration", registration({ createCredential: credential, intercom, redis, walletExtension })) .route( @@ -86,7 +87,7 @@ export default function api({ .route("/pax", paxRoute({ auth, database, pax })) .route("/ramp", ramp({ auth, bridge, database, manteca, persona })) .route("/webhook", webhook({ betterAuth, database, org })) - .on(["POST", "GET"], "/auth/*", (c) => betterAuth.handler(c.req.raw)); + .on(["POST", "GET"], "/auth/*", (c) => (providers.get(origin(c.req.raw)) ?? betterAuth).handler(c.req.raw)); return { app, ready: Promise.resolve() }; } diff --git a/server/api/kyc.ts b/server/api/kyc.ts index ae50687536..9caa1cfeb0 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -9,7 +9,6 @@ import { getAddress, sha256, verifyMessage } from "viem"; import { parseSiweMessage } from "viem/siwe"; import accountInit from "@exactly/common/accountInit"; -import domain from "@exactly/common/domain"; import chain, { exaAccountFactoryAddress, exaPluginAddress, @@ -18,6 +17,7 @@ import chain, { import { Address, Hex } from "@exactly/common/validation"; import { credentials, walletAddresses } from "../database/schema"; +import { origin } from "../utils/appOrigin"; import decodePublicKey from "../utils/decodePublicKey"; import { Application, UpdateApplicationRequest as ApplicationUpdate } from "../utils/panda"; import { @@ -539,7 +539,8 @@ The admin should add a member using [addMember method](https://www.better-auth.c const siweMessage = parseSiweMessage(payload.verify.message); - if (siweMessage.domain !== domain) return c.json({ code: "no permission", message: "invalid domain" }, 403); + if (siweMessage.domain !== new URL(origin(c.req.raw)).hostname) + return c.json({ code: "no permission", message: "invalid domain" }, 403); if (siweMessage.chainId !== chain.id) return c.json({ code: "bad chain", message: `expected ${chain.id} but got ${siweMessage.chainId}` }, 400); diff --git a/server/index.ts b/server/index.ts index 0263ba48c7..2dcb412c64 100644 --- a/server/index.ts +++ b/server/index.ts @@ -22,7 +22,7 @@ import createPersonaHook from "./hooks/persona"; import supervise, { own } from "./supervise"; import createAlchemy from "./utils/alchemy"; import androidFingerprints from "./utils/android/fingerprints"; -import appOrigin from "./utils/appOrigin"; +import appOrigin, { origins } from "./utils/appOrigin"; import createIntercom from "./utils/intercom"; import { closeQueue as closeMaturity, reminders, setup as setupMaturity } from "./utils/maturity"; import createOnesignal from "./utils/onesignal"; @@ -157,6 +157,7 @@ app.route("/hooks/manteca", mantecaHook.app); app.route("/hooks/panda", pandaHook.app); app.route("/hooks/persona", personaHook.app); +app.get("/.well-known/webauthn", (c) => c.json({ origins })); app.get("/.well-known/apple-app-site-association", (c) => c.json({ webcredentials: { apps: ["665NDX7LBZ.app.exactly"] } }), ); diff --git a/server/test/api/api.test.ts b/server/test/api/api.test.ts index 7d71eb6fad..b7112c456e 100644 --- a/server/test/api/api.test.ts +++ b/server/test/api/api.test.ts @@ -21,8 +21,62 @@ beforeAll(() => { describe("api", () => { it("loads the factory without environment variables", async () => { await expect(import("../../api").then(({ default: api }) => api)).resolves.toBeTypeOf("function"); + const { default: origin, origins } = await import("../../utils/appOrigin"); + expect(origins).toStrictEqual([origin]); }); + it("resolves only configured frontend origins, including behind a tls proxy", async () => { + vi.stubEnv("APP_ORIGINS", "https://secondary.example"); + vi.resetModules(); + const { default: appOrigin, origin, origins } = await import("../../utils/appOrigin"); + + expect(origins).toStrictEqual([appOrigin, "https://secondary.example"]); + expect(origin(new Request("http://secondary.example/api"))).toBe("https://secondary.example"); + expect(origin(new Request("http://internal/api", { headers: { origin: "https://secondary.example" } }))).toBe( + "https://secondary.example", + ); + expect(origin(new Request("http://internal/api"))).toBe(appOrigin); + expect(origin(new Request("http://internal/api", { headers: { origin: "https://untrusted.example" } }))).toBe( + appOrigin, + ); + }); + + it.each(["", " ", " , , "])("ignores empty configured origins: %j", async (value) => { + vi.stubEnv("APP_ORIGINS", value); + vi.resetModules(); + const { default: appOrigin, origins } = await import("../../utils/appOrigin"); + expect(origins).toStrictEqual([appOrigin]); + }); + + it("normalizes and deduplicates configured origins", async () => { + const { default: appOrigin } = await import("../../utils/appOrigin"); + vi.stubEnv( + "APP_ORIGINS", + ` ${appOrigin}/, HTTPS://SECONDARY.example:443/, https://user:password@secondary.example/app?query=value#fragment, http://localhost:8081/, https://secondary.example:8443/, , `, + ); + vi.resetModules(); + const { origin, origins } = await import("../../utils/appOrigin"); + + expect(origins).toStrictEqual([ + appOrigin, + "https://secondary.example", + "http://localhost:8081", + "https://secondary.example:8443", + ]); + expect(origin(new Request("http://internal/api", { headers: { origin: "https://secondary.example" } }))).toBe( + "https://secondary.example", + ); + }); + + it.each(["invalid", "https://secondary.example:invalid", "file:///frontend"])( + "rejects invalid configured origins: %s", + async (value) => { + vi.stubEnv("APP_ORIGINS", value); + vi.resetModules(); + await expect(import("../../utils/appOrigin")).rejects.toThrow(); + }, + ); + it("preserves every client response type", () => { expectTypeOf>>>().toBeNever(); }); diff --git a/server/test/api/auth.test.ts b/server/test/api/auth.test.ts index 0fa59f6a7b..4312137bd7 100644 --- a/server/test/api/auth.test.ts +++ b/server/test/api/auth.test.ts @@ -11,18 +11,21 @@ import { testClient } from "hono/testing"; import { decodeJwt, decodeProtectedHeader, jwtVerify } from "jose"; import assert from "node:assert"; import { env } from "node:process"; -import { nonEmpty, parse, pipe, string, type InferOutput } from "valibot"; +import { nonEmpty, object, parse, pipe, string, type InferOutput } from "valibot"; import { getAddress, keccak256, padHex, slice, toBytes, zeroAddress } from "viem"; import { optimism } from "viem/chains"; import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, onTestFinished, vi } from "vitest"; import * as derive from "@exactly/common/deriveAddress"; +import domain from "@exactly/common/domain"; import chain, { exaAccountFactoryAddress } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; import authentication, { Authentication } from "../../api/auth/authentication"; import registration from "../../api/auth/registration"; import database, { credentials } from "../../database"; +import androidOrigins from "../../utils/android/origins"; +import { origins } from "../../utils/appOrigin"; import authSecret from "../../utils/authSecret"; import createCredentialFactory from "../../utils/createCredential"; import createIntercom from "../../utils/intercom"; @@ -91,6 +94,39 @@ describe("authentication", () => { await redis.del("test-session"); }); + it.each(origins)("accepts related-origin passkey authentication from %s", async (origin) => { + const json = { + method: "webauthn" as const, + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key" as const, + }; + const response = await appClient.index.$post({ json }, { headers: { origin, cookie: "session_id=test-session" } }); + + expect(response.status).toBe(200); + expect(response.headers.get("set-cookie")).not.toContain("Domain="); + expect(verifyAuthenticationResponse).toHaveBeenCalledExactlyOnceWith({ + response: json, + expectedRPID: domain, + expectedOrigin: [...origins, ...androidOrigins], + expectedChallenge: "test-challenge", + credential: { id: json.id, publicKey: Buffer.alloc(0), transports: [], counter: 0 }, + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it.each(origins)("issues wallet challenges for %s", async (origin) => { + for (const client of [appClient, registrationAppClient]) { + const response = await client.index.$get({ query: { credentialId: zeroAddress } }, { headers: { origin } }); + expect(response.status).toBe(200); + expect(parse(object({ message: string() }), await response.json()).message).toContain(`URI: ${origin}`); + expect(response.headers.get("set-cookie")).not.toContain("Domain="); + } + expect(captureException).not.toHaveBeenCalled(); + }); + it("returns intercom token on successful login", async () => { const response = await appClient.index.$post( { @@ -660,6 +696,26 @@ describe("registration", () => { await redis.del("test-session"); }); + it.each(origins)("accepts related-origin passkey registration from %s", async (origin) => { + const id = own(Buffer.from(origin).toString("base64url")); + const json = registrationWebauthnAssertion({ id, rawId: id }); + const response = await registrationAppClient.index.$post( + { json }, + { headers: { origin, cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("set-cookie")).not.toContain("Domain="); + expect(verifyRegistrationResponse).toHaveBeenCalledExactlyOnceWith({ + response: json, + expectedRPID: domain, + expectedOrigin: [...origins, ...androidOrigins], + expectedChallenge: "test-challenge", + supportedAlgorithmIDs: [-7], + }); + expect(captureException).not.toHaveBeenCalled(); + }); + it("returns 400 if registration challenge is missing", async () => { await redis.del("test-session"); const response = await postRegistrationWebauthn(); diff --git a/server/test/e2e.ts b/server/test/e2e.ts index 63ddf99998..e902c0dbf3 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -17,6 +17,7 @@ import { describe, expect, it, vi } from "vitest"; import database from "../database"; import createAlchemy from "../utils/alchemy"; +import { origins } from "../utils/appOrigin"; import createOnesignal from "../utils/onesignal"; import createPanda from "../utils/panda"; import createSardine from "../utils/sardine"; @@ -89,6 +90,17 @@ describe("e2e", () => { Promise.all(workers.map((worker) => worker.ready)).catch(reject); }), ).resolves.toBeDefined(); + + const related = await app.request("/.well-known/webauthn"); + expect(related.status).toBe(200); + expect(related.headers.get("content-type")).toBe("application/json"); + await expect(related.json()).resolves.toStrictEqual({ origins }); + for (const origin of origins) { + const response = await app.request(`${origin}/api/auth/get-session`, { headers: { origin } }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toBeNull(); + expect(response.headers.get("access-control-allow-origin")).toBe(origin); + } }, Infinity, ); diff --git a/server/utils/appOrigin.ts b/server/utils/appOrigin.ts index 27b48d80bc..b537810880 100644 --- a/server/utils/appOrigin.ts +++ b/server/utils/appOrigin.ts @@ -1,3 +1,26 @@ +import { env } from "node:process"; +import { array, parse, pipe, string, url } from "valibot"; + import domain from "@exactly/common/domain"; -export default domain === "localhost" ? "http://localhost:8081" : `https://${domain}`; +const appOrigin = domain === "localhost" ? "http://localhost:8081" : `https://${domain}`; +export default appOrigin; + +export const origins = [ + ...new Set([ + appOrigin, + ...parse( + array(pipe(string(), url())), + env.APP_ORIGINS?.split(",") + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => new URL(value).origin) ?? [], + ), + ]), +]; + +export function origin(request: Request) { + const header = request.headers.get("origin"); + if (header !== null) return origins.includes(header) ? header : appOrigin; + return origins.find((allowed) => new URL(allowed).host === new URL(request.url).host) ?? appOrigin; +} diff --git a/server/utils/auth.ts b/server/utils/auth.ts index c0d203dcd5..202563c712 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -11,7 +11,7 @@ import domain from "@exactly/common/domain"; import chain from "@exactly/common/generated/chain"; import { Address, Hex } from "@exactly/common/validation"; -import appOrigin from "./appOrigin"; +import appOrigin, { origins } from "./appOrigin"; import publicClient from "./publicClient"; import * as schema from "../database/schema"; @@ -22,7 +22,7 @@ const ac = createAccessControl({ kyc: ["create", "delete", "read"], }); -export default function auth(database: NodePgDatabase, secret: string) { +export default function auth(database: NodePgDatabase, secret: string, origin = appOrigin) { return betterAuth({ database: drizzleAdapter(database, { provider: "pg", @@ -37,12 +37,12 @@ export default function auth(database: NodePgDatabase, secret: st invitation: schema.invitations, }, }), - baseURL: appOrigin, - trustedOrigins: [appOrigin], + baseURL: origin, + trustedOrigins: origins, secret, plugins: [ siwe({ - domain, + domain: new URL(origin).hostname, emailDomainName: domain === "localhost" ? "localhost.com" : domain, anonymous: true, getNonce: () => Promise.resolve(generateSiweNonce()), @@ -55,7 +55,7 @@ export default function auth(database: NodePgDatabase, secret: st const siweMessage = parseSiweMessage(message); if ( siweMessage.nonce !== cacao.p.nonce || - siweMessage.domain !== domain || + siweMessage.domain !== new URL(origin).hostname || siweMessage.chainId !== chain.id ) { return false; diff --git a/server/utils/createCredential.ts b/server/utils/createCredential.ts index fae337e107..81b7c76051 100644 --- a/server/utils/createCredential.ts +++ b/server/utils/createCredential.ts @@ -67,7 +67,7 @@ export default function createCredential({ httpOnly: true, ...(domain === "localhost" ? { sameSite: "lax", secure: false } - : { domain, sameSite: "none", secure: true, partitioned: true }), + : { sameSite: "none", secure: true, partitioned: true }), }), sardine .customer({ diff --git a/server/vitest.config.mts b/server/vitest.config.mts index 22d57764e9..abc77f020a 100644 --- a/server/vitest.config.mts +++ b/server/vitest.config.mts @@ -14,6 +14,7 @@ export default defineConfig({ env: { ALCHEMY_BLOCK_KEY: "block", ALCHEMY_WEBHOOKS_KEY: "webhooks", + APP_ORIGINS: "https://secondary.example", AUTH_SECRET: "auth", BRIDGE_API_KEY: "bridge", BRIDGE_API_URL: "https://bridge.test", diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index cf454b9aba..307053d79d 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,5 +1,4 @@ import "../utils/onesignal"; -import "../utils/server"; import React, { useLayoutEffect as useClientLayoutEffect, useEffect, useRef } from "react"; import { initReactI18next } from "react-i18next"; @@ -59,6 +58,7 @@ import { newMessage, present, type Session } from "../utils/intercom"; import queryClient, { isServer, persistOptions } from "../utils/queryClient"; import reportError, { classifyError } from "../utils/reportError"; import { page } from "../utils/segment"; +import { origin } from "../utils/server"; import exaConfig from "../utils/wagmi/exa"; import ownerConfig from "../utils/wagmi/owner"; @@ -146,7 +146,7 @@ init({ attachStacktrace: true, attachViewHierarchy: true, enableAutoSessionTracking: true, - tracePropagationTargets: [domain], + tracePropagationTargets: [domain, origin], enableNativeFramesTracking: !isRunningInExpoGo(), enableUserInteractionTracing: true, replaysOnErrorSampleRate: __DEV__ || e2e ? undefined : 1, diff --git a/src/components/add-funds/AddFunds.tsx b/src/components/add-funds/AddFunds.tsx index b5a43b0d63..6c39916fb9 100644 --- a/src/components/add-funds/AddFunds.tsx +++ b/src/components/add-funds/AddFunds.tsx @@ -12,7 +12,6 @@ import { isAddress } from "viem"; import { base, mainnet } from "viem/chains"; import { useEnsName } from "wagmi"; -import domain from "@exactly/common/domain"; import chain from "@exactly/common/generated/chain"; import shortenHex from "@exactly/common/shortenHex"; @@ -20,7 +19,7 @@ import AddFundsOption from "./AddFundsOption"; import { presentArticle } from "../../utils/intercom"; import queryClient, { type AuthMethod } from "../../utils/queryClient"; import reportError from "../../utils/reportError"; -import { getKYCStatus, getRampProviders } from "../../utils/server"; +import { getKYCStatus, getRampProviders, origin } from "../../utils/server"; import useBeginKYC from "../../utils/useBeginKYC"; import useKYC from "../../utils/useKYC"; import ownerConfig from "../../utils/wagmi/owner"; @@ -61,7 +60,7 @@ export default function AddFunds() { retry: false, }); - const redirectURL = `https://${domain}/add-funds`; + const redirectURL = `${origin}/add-funds`; const { data: providers, isPending } = useQuery({ queryKey: ["ramp", "providers", countryCode, redirectURL], queryFn: () => getRampProviders(countryCode, redirectURL), diff --git a/src/components/add-funds/Ramp.tsx b/src/components/add-funds/Ramp.tsx index 050cdc6702..081a83b0a6 100644 --- a/src/components/add-funds/Ramp.tsx +++ b/src/components/add-funds/Ramp.tsx @@ -14,15 +14,13 @@ import { createStatic } from "@pix.js/qrcode"; import { useQuery } from "@tanstack/react-query"; import { parseISO } from "date-fns"; -import domain from "@exactly/common/domain"; - import BridgeDisclaimer from "./BridgeDisclaimer"; import MantecaDisclaimer from "./MantecaDisclaimer"; import RampWebView from "./RampWebView"; import { isValidCurrency, fees as rampFees } from "../../utils/currencies"; import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; -import { getRampProviders, getRampQuote } from "../../utils/server"; +import { getRampProviders, getRampQuote, origin } from "../../utils/server"; import IconButton from "../shared/IconButton"; import InfoAlert from "../shared/InfoAlert"; import ModalSheet from "../shared/ModalSheet"; @@ -71,7 +69,7 @@ export default function Ramp() { staleTime: 10_000, }); - const redirectURL = `https://${domain}/add-funds`; + const redirectURL = `${origin}/add-funds`; const { data: providers } = useQuery({ queryKey: ["ramp", "providers", countryCode, redirectURL], queryFn: () => getRampProviders(countryCode, redirectURL), diff --git a/src/components/add-funds/Status.tsx b/src/components/add-funds/Status.tsx index d099ad5cb1..1da598a484 100644 --- a/src/components/add-funds/Status.tsx +++ b/src/components/add-funds/Status.tsx @@ -10,8 +10,6 @@ import { ScrollView, Spinner, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; -import domain from "@exactly/common/domain"; - import BridgeDisclaimer from "./BridgeDisclaimer"; import MantecaDisclaimer from "./MantecaDisclaimer"; import RampWebView from "./RampWebView"; @@ -22,7 +20,7 @@ import { isValidCurrency } from "../../utils/currencies"; import { newMessage } from "../../utils/intercom"; import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; -import { getRampProviders } from "../../utils/server"; +import { getRampProviders, origin } from "../../utils/server"; import IconButton from "../shared/IconButton"; import SafeView from "../shared/SafeView"; import Button from "../shared/StyledButton"; @@ -59,7 +57,7 @@ export default function Status() { }, [isPending]); const { data: countryCode } = useQuery({ queryKey: ["user", "country"] }); - const redirectURL = `https://${domain}/${offramp ? "send-funds" : "add-funds"}`; + const redirectURL = `${origin}/${offramp ? "send-funds" : "add-funds"}`; const { data: providers, isFetching } = useQuery({ queryKey: ["ramp", "providers", countryCode, redirectURL], queryFn: () => getRampProviders(countryCode, redirectURL), diff --git a/src/components/send-funds/SendFunds.tsx b/src/components/send-funds/SendFunds.tsx index a97c794c2b..8a99fa283b 100644 --- a/src/components/send-funds/SendFunds.tsx +++ b/src/components/send-funds/SendFunds.tsx @@ -10,13 +10,12 @@ import { ScrollView, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { base } from "viem/chains"; -import domain from "@exactly/common/domain"; import chain from "@exactly/common/generated/chain"; import { presentArticle } from "../../utils/intercom"; import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; -import { getKYCStatus, getRampProviders } from "../../utils/server"; +import { getKYCStatus, getRampProviders, origin } from "../../utils/server"; import useBeginKYC from "../../utils/useBeginKYC"; import useKYC from "../../utils/useKYC"; import AddFundsOption from "../add-funds/AddFundsOption"; @@ -46,7 +45,7 @@ export default function SendFunds() { retry: false, }); - const redirectURL = `https://${domain}/send-funds`; + const redirectURL = `${origin}/send-funds`; const { data: providers, isPending } = useQuery({ queryKey: ["ramp", "providers", countryCode, redirectURL], queryFn: () => getRampProviders(countryCode, redirectURL), diff --git a/src/utils/persona.ts b/src/utils/persona.ts index 80a9ce1f46..5938dcf58b 100644 --- a/src/utils/persona.ts +++ b/src/utils/persona.ts @@ -7,7 +7,7 @@ import domain from "@exactly/common/domain"; import queryClient, { type EmbeddingContext } from "./queryClient"; import reportError from "./reportError"; -import { getKYCTokens, type KYCStatus } from "./server"; +import { getKYCTokens, origin, type KYCStatus } from "./server"; import type { UseMutationOptions } from "@tanstack/react-query"; @@ -247,6 +247,7 @@ async function getRedirectURI() { }[domain] }/exa-app`; } + return origin; } function handleComplete() { diff --git a/src/utils/server.ts b/src/utils/server.ts index d7a87973dd..a7a72c32cf 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -71,7 +71,9 @@ queryClient.setQueryDefaults(["auth"], { }, }); -const api = hc(domain === "localhost" ? "http://localhost:3000/api" : `https://${domain}/api`, { +export const origin = Platform.OS === "web" && !isServer ? window.location.origin : `https://${domain}`; + +const api = hc(domain === "localhost" ? "http://localhost:3000/api" : `${origin}/api`, { init: { credentials: "include" }, fetch: async (input: Request | string | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); diff --git a/src/utils/useRampOnboarding.ts b/src/utils/useRampOnboarding.ts index 5aa14449b1..29c7925631 100644 --- a/src/utils/useRampOnboarding.ts +++ b/src/utils/useRampOnboarding.ts @@ -7,12 +7,10 @@ import { useToastController } from "@tamagui/toast"; import { useMutation, useQuery } from "@tanstack/react-query"; -import domain from "@exactly/common/domain"; - import completeOnboarding from "./completeOnboarding"; import { APIError } from "./queryClient"; import reportError from "./reportError"; -import { getKYCStatus, getRampProviders } from "./server"; +import { getKYCStatus, getRampProviders, origin } from "./server"; export default function useRampOnboarding(direction: "offramp" | "onramp" = "onramp") { const { t } = useTranslation(); @@ -25,7 +23,7 @@ export default function useRampOnboarding(direction: "offramp" | "onramp" = "onr const [tosLink, setTOSLink] = useState(); const { data: countryCode } = useQuery({ queryKey: ["user", "country"] }); - const redirectURL = `https://${domain}/${offramp ? "send-funds" : "add-funds"}`; + const redirectURL = `${origin}/${offramp ? "send-funds" : "add-funds"}`; const { data: providers } = useQuery({ queryKey: ["ramp", "providers", countryCode, redirectURL],