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/ten-memes-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ add business onboarding applications
3 changes: 3 additions & 0 deletions .do/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ services:
scope: RUN_TIME
type: SECRET
value: ${{ env.ENCRYPTED_PERSONA_API_KEY || env.PERSONA_API_KEY }}
- key: PERSONA_BUSINESS_ACCOUNT_TYPE_ID
scope: RUN_TIME
value: acttp_AWN3X1Rb7Rnt5o7EA4e8VcU7D61xH2 # cspell:ignore acttp
- key: PERSONA_URL
scope: RUN_TIME
value: ${{ env.PERSONA_URL }}
Expand Down
2 changes: 2 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"words": [
"abigen",
"abitype",
"accounttype",
"adduser",
"aguxez",
"airalo",
Expand Down Expand Up @@ -191,6 +192,7 @@
"subproject",
"subprojects",
"substreams",
"acttp",
"surl",
"tamagui",
"tanstack",
Expand Down
1 change: 1 addition & 0 deletions infra/Pulumi.base-sepolia.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ config:
gcp:project: exa-dev
exa:subscribeTimeout: 600s
exa:whatsappPhoneNumberId: "1284591438064923"
exa:personaBusinessAccountTypeId: acttp_AWN3X1Rb7Rnt5o7EA4e8VcU7D61xH2
1 change: 1 addition & 0 deletions infra/Pulumi.base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ config:
gcp:project: eexxxaa
exa:pokeMinimum: 1
exa:subscribeTimeout: 900s
exa:personaBusinessAccountTypeId: acttp_AWN3X1Rb7Rnt5o7EA4e8VcU7D61xH2
1 change: 1 addition & 0 deletions infra/Pulumi.production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ config:
exa:pokeMinimum: 1
exa:subscribeTimeout: 900s
exa:whatsappPhoneNumberId: "1287864854409817"
exa:personaBusinessAccountTypeId: acttp_AWN3X1Rb7Rnt5o7EA4e8VcU7D61xH2
1 change: 1 addition & 0 deletions infra/Pulumi.sandbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ config:
gcp:project: exa-dev
exa:subscribeTimeout: 600s
exa:whatsappPhoneNumberId: "1287864854409817"
exa:personaBusinessAccountTypeId: acttp_AWN3X1Rb7Rnt5o7EA4e8VcU7D61xH2
4 changes: 4 additions & 0 deletions infra/utils/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export default define({
crema: ["redis-address", "redis-password", "redis-username"],
services: {
api: {
env: {
PERSONA_BUSINESS_ACCOUNT_TYPE_ID: "personaBusinessAccountTypeId",
Comment thread
aguxez marked this conversation as resolved.
},
secrets: [
"auth-secret",
"bridge-api-key",
Expand Down Expand Up @@ -46,6 +49,7 @@ export default define({
signers: ["settler", "issuer"],
},
persona: {
env: { PERSONA_BUSINESS_ACCOUNT_TYPE_ID: "personaBusinessAccountTypeId" },
secrets: [
"panda-api-key",
"pax-associate-id-key",
Expand Down
30 changes: 21 additions & 9 deletions server/api/auth/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ 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 { decode, encode } from "../../utils/authChallenge";
import { accountSalt, isBusinessSalt } from "../../utils/createCredential";
import decodePublicKey from "../../utils/decodePublicKey";
import publicClient from "../../utils/publicClient";
import { IpAddress } from "../../utils/sardine";
Expand Down Expand Up @@ -176,6 +178,7 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates
tags: ["Credential"],
validateResponse: true,
}),
vValidator("header", optional(object({ "account-type": optional(literal("business")) }))),
vValidator(
"query",
object({
Expand Down Expand Up @@ -208,6 +211,7 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates
...(domain === "localhost" ? { sameSite: "lax", secure: false } : { domain, sameSite: "none", secure: true }),
});
c.header("X-Session-Id", sessionId);
const { "account-type": accountType } = c.req.valid("header") ?? {};
const { credentialId } = c.req.valid("query");
if (credentialId && (isAddress as (address: string) => address is Address)(credentialId)) {
const message = createSiweMessage({
Expand All @@ -223,7 +227,7 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates
domain,
scheme,
});
await redis.set(sessionId, message, "PX", timeout);
await redis.set(sessionId, encode(message, accountType), "PX", timeout);
return c.json(
{ method: "siwe" as const, address: credentialId, message } satisfies InferOutput<
typeof AuthenticationOptions
Expand All @@ -236,7 +240,7 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates
allowCredentials: credentialId ? [{ id: credentialId }] : undefined,
timeout,
});
await redis.set(sessionId, options.challenge, "PX", timeout);
await redis.set(sessionId, encode(options.challenge, accountType), "PX", timeout);
Comment thread
aguxez marked this conversation as resolved.
return c.json(
{
method: "webauthn" as const,
Expand Down Expand Up @@ -283,6 +287,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se
object({
"Client-Fid": optional(pipe(string(), maxLength(36))),
"Client-Platform": optional(literal("ios")),
"account-type": optional(literal("business")),
"do-connecting-ip": fallback(optional(IpAddress), () => undefined),
}),
),
Expand Down Expand Up @@ -365,22 +370,25 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se
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);
const [credential, challenge] = await Promise.all([
const [credential, storedChallenge] = await Promise.all([
database.query.credentials.findFirst({
columns: { publicKey: true, account: true, factory: true, salt: true, transports: true },
where: eq(credentials.id, assertion.id),
}),
redis.getdel(sessionId),
]);
if (!challenge) return c.json({ code: "no authentication", legacy: "no authentication" }, 400);
if (!storedChallenge) return c.json({ code: "no authentication", legacy: "no authentication" }, 400);
const challenge = decode(storedChallenge);
if (!challenge) return c.json({ code: "bad authentication", legacy: "bad authentication" }, 400);
if (challenge.accountType !== headers?.["account-type"]) return c.json({ code: "bad account type" }, 400);
if (!credential) {
if (assertion.method !== "siwe") return c.json({ code: "no credential", legacy: "no credential" }, 400);
try {
const message = parseSiweMessage(challenge);
const message = parseSiweMessage(challenge.challenge);
if (
!validateSiweMessage({ message, address: assertion.id, nonce: sessionId, domain, scheme }) ||
!(await publicClient.verifySiweMessage({
message: challenge,
message: challenge.challenge,
address: assertion.id,
signature: assertion.signature,
}))
Expand All @@ -390,6 +398,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se
if (factory && !validFactories.has(factory)) return c.json({ code: "bad factory" }, 400);
const result = await createCredential(c, assertion.id, {
factory,
salt: accountSalt(headers?.["account-type"]),
source: c.req.header("Client-Fid"),
ip: headers?.["do-connecting-ip"],
Comment thread
aguxez marked this conversation as resolved.
});
Expand All @@ -410,16 +419,19 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se
}
}
if (factory && factory !== parse(Address, credential.factory)) return c.json({ code: "bad factory" }, 400);
if (headers?.["account-type"] === "business" && !isBusinessSalt(parse(Address, credential.salt))) {
return c.json({ code: "bad account type" }, 400);
}
setUser({ id: parse(Address, credential.account) });

try {
switch (assertion.method) {
case "siwe": {
const message = parseSiweMessage(challenge);
const message = parseSiweMessage(challenge.challenge);
if (
!validateSiweMessage({ message, address: assertion.id, nonce: sessionId, domain, scheme }) ||
!(await publicClient.verifySiweMessage({
message: challenge,
message: challenge.challenge,
address: assertion.id,
signature: assertion.signature,
}))
Expand All @@ -433,7 +445,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se
response: assertion,
expectedRPID: domain,
expectedOrigin: [appOrigin, ...androidOrigins],
expectedChallenge: challenge,
expectedChallenge: challenge.challenge,
credential: {
id: assertion.id,
publicKey: credential.publicKey,
Expand Down
23 changes: 16 additions & 7 deletions server/api/auth/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import { Address, Base64URL, Hex } from "@exactly/common/validation";
import { Authentication } from "./authentication";
import androidOrigins from "../../utils/android/origins";
import appOrigin from "../../utils/appOrigin";
import { decode, encode } from "../../utils/authChallenge";
import { accountSalt } from "../../utils/createCredential";
import publicClient from "../../utils/publicClient";
import { IpAddress } from "../../utils/sardine";
import validatorHook from "../../utils/validatorHook";
Expand Down Expand Up @@ -181,6 +183,7 @@ export default function route({
tags: ["Credential"],
validateResponse: true,
}),
vValidator("header", optional(object({ "account-type": optional(literal("business")) }))),
vValidator(
"query",
optional(
Expand Down Expand Up @@ -209,6 +212,7 @@ export default function route({
});
c.header("X-Session-Id", sessionId);
const query = c.req.valid("query");
const accountType = c.req.valid("header")?.["account-type"];
if (query?.credentialId) {
const message = createSiweMessage({
resources: ["https://exactly.github.io/exa"],
Expand All @@ -223,7 +227,7 @@ export default function route({
domain,
scheme,
});
await redis.set(sessionId, message, "PX", timeout);
await redis.set(sessionId, encode(message, accountType), "PX", timeout);
return c.json({ method: "siwe" as const, address: query.credentialId, message }, 200);
}
const userName = new Date().toISOString().slice(0, 16);
Expand All @@ -237,7 +241,7 @@ export default function route({
// TODO excludeCredentials?
timeout,
});
await redis.set(sessionId, options.challenge, "PX", timeout);
await redis.set(sessionId, encode(options.challenge, accountType), "PX", timeout);
return c.json(
{
method: "webauthn" as const,
Expand Down Expand Up @@ -277,6 +281,7 @@ export default function route({
object({
"Client-Fid": optional(pipe(string(), maxLength(36))),
"Client-Platform": optional(literal("ios")),
"account-type": optional(literal("business")),
"do-connecting-ip": fallback(optional(IpAddress), () => undefined),
}),
),
Expand Down Expand Up @@ -355,18 +360,21 @@ export default function route({
const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id;
if (!sessionId) return c.json({ code: "bad session" }, 400);
if (factory && !validFactories.has(factory)) return c.json({ code: "bad factory" }, 400);
const challenge = await redis.getdel(sessionId);
if (!challenge) return c.json({ code: "no registration", legacy: "no registration" }, 400);
const storedChallenge = await redis.getdel(sessionId);
if (!storedChallenge) return c.json({ code: "no registration", legacy: "no registration" }, 400);
const challenge = decode(storedChallenge);
if (!challenge) return c.json({ code: "bad registration", legacy: "bad registration" }, 400);
if (challenge.accountType !== headers?.["account-type"]) return c.json({ code: "bad account type" }, 400);

let webauthn: undefined | WebAuthnCredential;
try {
switch (attestation.method) {
case "siwe": {
const message = parseSiweMessage(challenge);
const message = parseSiweMessage(challenge.challenge);
if (
!validateSiweMessage({ message, address: attestation.id, nonce: sessionId, domain, scheme }) ||
!(await publicClient.verifySiweMessage({
message: challenge,
message: challenge.challenge,
address: attestation.id,
signature: attestation.signature,
}))
Expand All @@ -387,7 +395,7 @@ export default function route({
},
expectedRPID: domain,
expectedOrigin: [appOrigin, ...androidOrigins],
expectedChallenge: challenge,
expectedChallenge: challenge.challenge,
supportedAlgorithmIDs: [cose.COSEALG.ES256],
});
if (!verified) return c.json({ code: "bad registration", legacy: "bad registration" }, 400);
Expand All @@ -409,6 +417,7 @@ export default function route({
try {
const result = await createCredential(c, attestation.id, {
factory,
salt: accountSalt(headers?.["account-type"]),
webauthn,
source: headers?.["Client-Fid"],
ip: headers?.["do-connecting-ip"],
Expand Down
Loading
Loading