Skip to content
Merged
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/brave-otters-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ server: add ios wallet extension provisioning
5 changes: 5 additions & 0 deletions .changeset/clever-ravens-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

✨ send client platform header
4 changes: 4 additions & 0 deletions .do/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion server/api/auth/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
picklist,
pipe,
record,
safeParse,
string,
title,
union,
Expand Down Expand Up @@ -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."))),
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<typeof LegacyAuthentication>,
200,
);
Expand Down Expand Up @@ -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<typeof LegacyAuthentication>,
200,
);
Expand Down
15 changes: 14 additions & 1 deletion server/api/auth/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
optional,
pipe,
record,
safeParse,
string,
title,
unknown,
Expand All @@ -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."))),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -382,6 +394,7 @@ export default new Hono()
{
...result,
intercomToken,
...(platform.output === "ios" ? await walletExtension(attestation.id) : {}),
} satisfies InferOutput<typeof Authentication>,
200,
);
Expand Down
104 changes: 103 additions & 1 deletion server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, Mutex>();
function createMutex(credentialId: string) {
Expand Down Expand Up @@ -256,7 +258,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
}
\`\`\`

`,
`,
tags: ["Card"],
security: [{ credentialAuth: [] }],
validateResponse: true,
Expand Down Expand Up @@ -398,6 +400,106 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
} else return c.json({ code: "no card" }, 404);
},
)
.get(
"/provisioning",
Comment thread
aguxez marked this conversation as resolved.
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<Awaited<ReturnType<typeof verifyToken>>> };
}>(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;
Comment thread
aguxez marked this conversation as resolved.
});
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<typeof CardResponse>["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(),
Expand Down
2 changes: 2 additions & 0 deletions server/script/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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" },
},
},
Expand Down
Loading