Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cool-baths-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ support multiple frontend origins
5 changes: 5 additions & 0 deletions .changeset/plenty-results-feel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

✨ use same-origin api requests
29 changes: 21 additions & 8 deletions server/api/auth/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand All @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve port numbers in SIWE domains

When a configured frontend uses a non-default port (explicitly retained by server/test/api/api.test.ts:64), this emits secondary.example as the SIWE domain while the URI is https://secondary.example:8443. A SIWE domain is the URI authority and includes the port, so strict clients or validators can reject the challenge and the later hostname-only checks reject standards-compliant messages. Use the URL's host rather than hostname consistently in the SIWE creation and validation paths.

Useful? React with 👍 / 👎.

scheme,
});
await redis.set(sessionId, message, "PX", timeout);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 }),
}),
]);

Expand Down
19 changes: 13 additions & 6 deletions server/api/auth/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -386,7 +393,7 @@ export default function route({
},
},
expectedRPID: domain,
expectedOrigin: [appOrigin, ...androidOrigins],
expectedOrigin: [...origins, ...androidOrigins],
expectedChallenge: challenge,
supportedAlgorithmIDs: [cose.COSEALG.ES256],
});
Expand Down
14 changes: 9 additions & 5 deletions server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
9 changes: 5 additions & 4 deletions server/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -64,15 +64,16 @@ export default function api({
walletExtension: ReturnType<typeof createWalletExtension>;
}) {
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(
Expand All @@ -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() };
}

Expand Down
5 changes: 3 additions & 2 deletions server/api/kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"] } }),
);
Expand Down
54 changes: 54 additions & 0 deletions server/test/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyResponses<ReturnType<typeof hc<ExaAPI>>>>().toBeNever();
});
Expand Down
Loading
Loading