Skip to content

Commit 128b4ec

Browse files
committed
🛂 server: setup better auth
1 parent 01c7d60 commit 128b4ec

9 files changed

Lines changed: 249 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@exactly/server": patch
3+
---
4+
5+
🛂 setup better auth

‎docs/astro.config.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default defineConfig({
1515
{ base: "api", schema: "node_modules/@exactly/server/generated/openapi.json", sidebar: { collapsed: false } },
1616
]),
1717
],
18-
sidebar: openAPISidebarGroups,
18+
sidebar: [{ label: "Docs", items: ["index", "organization-authentication"] }, ...openAPISidebarGroups],
1919
}),
2020
mermaid(),
2121
],
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
---
2+
title: Organizations, authentication and authorization
3+
sidebar:
4+
label: Organizations and authentication
5+
order: 10
6+
---
7+
8+
Creating organizations is permission-less. Any user can create an organization and will be the owner.
9+
Then the owner can add members with admin role and those admins will be able to add more members with different roles.
10+
11+
Better auth client and viem are the recommended libraries to use for authentication and signing using SIWE.
12+
13+
## SIWE Authentication
14+
15+
Example code to authenticate using SIWE, it will create the user if doesn't exist.
16+
Note: Check viem account to use a private key instead of a mnemonic.
17+
18+
```typescript
19+
import { createAuthClient } from "better-auth/client";
20+
import { siweClient, organizationClient } from "better-auth/client/plugins";
21+
import { mnemonicToAccount } from "viem/accounts";
22+
import { optimismSepolia } from "viem/chains";
23+
import { createSiweMessage } from "viem/siwe";
24+
25+
const chainId = optimismSepolia.id;
26+
27+
const authClient = createAuthClient({
28+
baseURL: "http://localhost:3000",
29+
plugins: [siweClient(), organizationClient()],
30+
});
31+
32+
const owner = mnemonicToAccount("test test test test test test test test test test test test");
33+
34+
authClient.siwe
35+
.nonce({
36+
walletAddress: owner.address,
37+
chainId,
38+
})
39+
.then(async ({ data: nonceResult }) => {
40+
//can be any statement
41+
const statement = "i accept exa terms and conditions";
42+
const nonce = nonceResult?.nonce ?? "";
43+
const message = createSiweMessage({
44+
statement,
45+
resources: ["https://exactly.github.io/exa"],
46+
nonce,
47+
uri: "https://localhost",
48+
address: owner.address,
49+
chainId,
50+
scheme: "https",
51+
version: "1",
52+
domain: "localhost",
53+
});
54+
const signature = await owner.signMessage({ message });
55+
56+
await authClient.siwe.verify(
57+
{
58+
message,
59+
signature,
60+
walletAddress: owner.address,
61+
chainId,
62+
},
63+
{
64+
onSuccess: async (context) => {
65+
// authentication successful, session cookie is now set
66+
},
67+
onError: (context) => {
68+
console.log("authorization error", context);
69+
},
70+
},
71+
);
72+
}).catch((error: unknown) => {
73+
console.error("nonce error", error);
74+
});
75+
```
76+
77+
## Creating an organization
78+
79+
owner account will be the owner of the created organization
80+
81+
```typescript
82+
const chainId = optimismSepolia.id;
83+
84+
const authClient = createAuthClient({
85+
baseURL: "http://localhost:3000",
86+
plugins: [siweClient(), organizationClient()],
87+
});
88+
89+
const owner = mnemonicToAccount("test test test test test test test test test test test siwe");
90+
91+
authClient.siwe
92+
.nonce({
93+
walletAddress: owner.address,
94+
chainId,
95+
})
96+
.then(async ({ data: nonceResult }) => {
97+
const statement = `i accept exa terms and conditions`;
98+
const nonce = nonceResult?.nonce ?? "";
99+
const message = createSiweMessage({
100+
statement,
101+
resources: ["https://exactly.github.io/exa"],
102+
nonce,
103+
uri: `https://localhost`,
104+
address: owner.address,
105+
chainId,
106+
scheme: "https",
107+
version: "1",
108+
domain: "localhost",
109+
});
110+
const signature = await owner.signMessage({ message });
111+
112+
await authClient.siwe.verify(
113+
{
114+
message,
115+
signature,
116+
walletAddress: owner.address,
117+
chainId,
118+
},
119+
{
120+
onSuccess: async (context) => {
121+
const headers = new Headers();
122+
headers.set("cookie", context.response.headers.get("set-cookie") ?? "");
123+
const createOrganizationResult = await authClient.organization.create({
124+
fetchOptions: { headers },
125+
name: "Uphold",
126+
slug: "uphold",
127+
keepCurrentActiveOrganization: false,
128+
});
129+
if (createOrganizationResult.data) {
130+
console.log(`organization created id: ${createOrganizationResult.data.id}`);
131+
} else {
132+
console.error("Failed to create organization error:", createOrganizationResult.error);
133+
}
134+
},
135+
onError: (context) => {
136+
console.log("authorization error", context);
137+
},
138+
},
139+
);
140+
}).catch((error: unknown) => {
141+
console.error("nonce error", error);
142+
});
143+
```

‎server/api/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import passkey from "./passkey";
1111
import pax from "./pax";
1212
import ramp from "./ramp";
1313
import appOrigin from "../utils/appOrigin";
14+
import auth from "../utils/auth";
1415

1516
const api = new Hono()
1617
.use(cors({ origin: [appOrigin, "http://localhost:8081"], credentials: true, exposeHeaders: ["X-Session-Id"] }))
@@ -26,7 +27,8 @@ const api = new Hono()
2627
.route("/kyc", kyc)
2728
.route("/passkey", passkey) // eslint-disable-line @typescript-eslint/no-deprecated -- // TODO remove
2829
.route("/pax", pax)
29-
.route("/ramp", ramp);
30+
.route("/ramp", ramp)
31+
.on(["POST", "GET"], "/auth/*", (c) => auth.handler(c.req.raw));
3032

3133
export default api;
3234
export type ExaAPI = typeof api;

‎server/database/index.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
1+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
12
import { drizzle } from "drizzle-orm/node-postgres";
23
import { env } from "node:process";
34

45
import * as schema from "./schema";
56

67
if (!env.POSTGRES_URL) throw new Error("missing postgres url");
78

8-
export default drizzle(env.POSTGRES_URL, { schema });
9+
const database = drizzle(env.POSTGRES_URL, { schema });
10+
11+
export default database;
912

1013
export * from "./schema";
14+
15+
export const authAdapter = drizzleAdapter(database, {
16+
provider: "pg",
17+
schema: {
18+
user: schema.users,
19+
session: schema.sessions,
20+
account: schema.authenticators,
21+
verification: schema.verifications,
22+
walletAddress: schema.walletAddresses,
23+
organization: schema.organizations,
24+
member: schema.members,
25+
invitation: schema.invitations,
26+
},
27+
});

‎server/index.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@ import type { UnofficialStatusCode } from "hono/utils/http-status";
2626

2727
const app = new Hono();
2828
app.use(trimTrailingSlash());
29-
3029
app.route("/api", api);
31-
3230
app.route("/hooks/activity", activityHook);
3331
app.route("/hooks/block", block);
3432
app.route("/hooks/bridge", bridge);

‎server/middleware/auth.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import { getSignedCookie } from "hono/cookie";
22
import { createMiddleware } from "hono/factory";
33

4+
import betterAuth from "../utils/auth";
45
import authSecret from "../utils/authSecret";
56

67
import type { BlankInput, Env, Input } from "hono/types";
78

89
export default function auth<E extends Env = Env, P extends string = string, I extends Input = BlankInput>() {
910
return createMiddleware<E, P, I & { out: { cookie: { credentialId: string } } }>(async (c, next) => {
1011
const credentialId = await getSignedCookie(c, authSecret, "credential_id");
11-
if (!credentialId) return c.json({ code: "unauthorized", legacy: "unauthorized" }, 401);
12+
if (!credentialId) {
13+
const session = await betterAuth.api.getSession({ headers: c.req.raw.headers });
14+
if (session) {
15+
await next();
16+
return;
17+
}
18+
return c.json({ code: "unauthorized", legacy: "unauthorized" }, 401);
19+
}
1220
c.req.addValidatedData("cookie", { credentialId });
1321
await next();
1422
});

‎server/script/openapi.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { generateSpecs } from "hono-openapi";
22
import { writeFile } from "node:fs/promises";
3-
import { padHex } from "viem";
3+
import { padHex, zeroHash } from "viem";
44

55
import { version } from "../package.json";
66

77
process.env.ALCHEMY_ACTIVITY_ID = "activity";
88
process.env.ALCHEMY_WEBHOOKS_KEY = "webhooks";
9-
process.env.AUTH_SECRET = "auth";
9+
process.env.AUTH_SECRET = zeroHash;
1010
process.env.BRIDGE_API_KEY = "bridge";
1111
process.env.BRIDGE_API_URL = "https://bridge.test";
1212
process.env.EXPO_PUBLIC_ALCHEMY_API_KEY = " ";
@@ -47,6 +47,7 @@ import("../api")
4747
in: "cookie",
4848
name: "credential_id",
4949
},
50+
siweAuth: { type: "apiKey", in: "cookie", name: "__Secure-better-auth.session_token" },
5051
},
5152
},
5253
},

‎server/utils/auth.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { captureException } from "@sentry/core";
2+
import { betterAuth } from "better-auth";
3+
import { organization, siwe } from "better-auth/plugins";
4+
import { createAccessControl } from "better-auth/plugins/access";
5+
import { adminAc, defaultStatements, memberAc, ownerAc } from "better-auth/plugins/organization/access";
6+
import { safeParse } from "valibot";
7+
import { verifyMessage } from "viem";
8+
import { generateSiweNonce } from "viem/siwe";
9+
10+
import domain from "@exactly/common/domain";
11+
import chain from "@exactly/common/generated/chain";
12+
import { Address, Hex } from "@exactly/common/validation";
13+
14+
import appOrigin from "./appOrigin";
15+
import authSecret from "./authSecret";
16+
import { authAdapter } from "../database/index";
17+
const ac = createAccessControl({
18+
...defaultStatements,
19+
});
20+
21+
export default betterAuth({
22+
database: authAdapter,
23+
baseURL: appOrigin,
24+
trustedOrigins: [appOrigin],
25+
secret: authSecret,
26+
user: { changeEmail: { enabled: true } },
27+
plugins: [
28+
siwe({
29+
domain,
30+
emailDomainName: domain === "localhost" ? "localhost.com" : domain,
31+
anonymous: true,
32+
getNonce: () => Promise.resolve(generateSiweNonce()),
33+
verifyMessage: async ({ message, signature, address, chainId }) => {
34+
if (chainId !== chain.id) return false;
35+
36+
const parsedAddress = safeParse(Address, address);
37+
const parsedSignature = safeParse(Hex, signature);
38+
if (!parsedAddress.success || !parsedSignature.success) return false;
39+
try {
40+
return await verifyMessage({
41+
address: parsedAddress.output,
42+
message,
43+
signature: parsedSignature.output,
44+
});
45+
} catch (error) {
46+
captureException(error, { level: "error" });
47+
return false;
48+
}
49+
},
50+
}),
51+
organization({
52+
ac,
53+
roles: {
54+
admin: ac.newRole({
55+
...adminAc.statements,
56+
}),
57+
owner: ac.newRole({
58+
...ownerAc.statements,
59+
}),
60+
member: ac.newRole({
61+
...memberAc.statements,
62+
}),
63+
},
64+
allowUserToCreateOrganization: () => true,
65+
}),
66+
],
67+
});

0 commit comments

Comments
 (0)