@procedures/better-auth 0.0.0-bootstrap.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/auth.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A Better Auth server over MongoDB: the sign-in with GitHub and Google, the sessions in `httpOnly`
3
+ * cookies, the groups (the organization plugin) and the passkeys (WebAuthn). The app stores no
4
+ * password. Better Auth does the OAuth state, PKCE and the CSRF checks.
5
+ *
6
+ * Give Better Auth its own database: its `user` collection must not mix with the data of the app.
7
+ * The app mounts `auth.handler` on `config.basePath`: the routes belong to the app.
8
+ */
9
+ import { betterAuth } from "better-auth";
10
+ import { mongodbAdapter } from "better-auth/adapters/mongodb";
11
+ import { organization } from "better-auth/plugins";
12
+ import { passkey } from "@better-auth/passkey";
13
+ import type { Db, MongoClient } from "mongodb";
14
+ import type { BetterAuthConfig } from "./config.js";
15
+
16
+ /**
17
+ * The parts of a Better Auth server that an app uses. Mount `handler` on `config.basePath`, for
18
+ * example with `toNodeHandler` of `better-auth/node`. `SessionPrincipals` reads `api.getSession`.
19
+ */
20
+ export interface BetterAuthServer {
21
+ /** The handler of the auth routes. */
22
+ readonly handler: (request: Request) => Promise<Response>;
23
+ readonly api: {
24
+ /** The session and the user of the cookies of a request, or null. */
25
+ getSession(context: { headers: Headers }): Promise<{ user: Record<string, unknown>; session: Record<string, unknown> } | null>;
26
+ };
27
+ }
28
+
29
+ /** This function makes the Better Auth server of a configuration. */
30
+ export function createBetterAuth(db: Db, client: MongoClient, config: BetterAuthConfig): BetterAuthServer {
31
+ const testHooks = {
32
+ user: {
33
+ create: {
34
+ before: async (user: Record<string, unknown>): Promise<{ data: Record<string, unknown> }> => ({ data: { ...user, emailVerified: true } }),
35
+ },
36
+ },
37
+ };
38
+ return betterAuth({
39
+ appName: config.appName,
40
+ basePath: config.basePath,
41
+ baseURL: config.baseURL,
42
+ secret: config.secret,
43
+ database: mongodbAdapter(db, { client }),
44
+ trustedOrigins: [...config.trustedOrigins],
45
+ socialProviders: {
46
+ ...(config.github ? { github: { ...config.github } } : {}),
47
+ ...(config.google ? { google: { ...config.google } } : {}),
48
+ },
49
+ emailAndPassword: { enabled: config.testAccounts },
50
+ // A test account has no email check: the test server trusts its email
51
+ ...(config.testAccounts ? { databaseHooks: testHooks } : {}),
52
+ // The passkeys expect the origins of the app, not the Origin header of each request
53
+ plugins: [organization(), passkey({ rpID: config.rpID, rpName: config.appName, origin: [...config.trustedOrigins] })],
54
+ });
55
+ }
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Db, MongoClient } from "mongodb";
3
+ import { AGENT_ID, ANONYMOUS_ID, betterAuthConfig, betterAuthPrincipalOf, createBetterAuth, headersOf, SessionPrincipals, VIA } from "./index.js";
4
+
5
+ /** A fake database of Better Auth: its `user` and `member` collections, with a count of the reads. */
6
+ function fakeDb(users: Record<string, unknown>[], members: Record<string, unknown>[], fail = false) {
7
+ const reads = { user: 0, member: 0 };
8
+ const matches = (value: unknown, query: unknown): boolean => {
9
+ const $in = (query as { $in?: unknown[] }).$in ?? [];
10
+ return $in.some((candidate) => String(candidate) === String(value));
11
+ };
12
+ const db = {
13
+ collection(name: "user" | "member") {
14
+ return {
15
+ async findOne(filter: { _id: unknown }) {
16
+ reads.user++;
17
+ if (fail) throw new Error("down");
18
+ return users.find((u) => matches(u["_id"], filter._id)) ?? null;
19
+ },
20
+ find(filter: { userId: unknown }) {
21
+ return {
22
+ async toArray() {
23
+ reads[name]++;
24
+ if (fail) throw new Error("down");
25
+ return members.filter((m) => matches(m["userId"], filter.userId));
26
+ },
27
+ };
28
+ },
29
+ };
30
+ },
31
+ } as unknown as Db;
32
+ return { db, reads };
33
+ }
34
+
35
+ /** A fake Better Auth server: the cookie `s=<id>` is the session of user `<id>`. */
36
+ function fakeAuth(users: Record<string, Record<string, unknown>>) {
37
+ let lookups = 0;
38
+ const auth = {
39
+ api: {
40
+ async getSession({ headers }: { headers: Headers }) {
41
+ lookups++;
42
+ const match = /(?:^|; )s=([^;]+)/.exec(headers.get("cookie") ?? "");
43
+ const user = match ? users[match[1]!] : undefined;
44
+ return user ? { user } : null;
45
+ },
46
+ },
47
+ };
48
+ return { auth, lookups: () => lookups };
49
+ }
50
+
51
+ const ALICE = { id: "u1", email: "alice@example.com", emailVerified: true, name: "Alice" };
52
+ const BOB = { id: "u2", email: "bob@example.com", emailVerified: false };
53
+
54
+ function setup(options: { trustUnverifiedEmail?: boolean; failDb?: boolean } = {}) {
55
+ let now = 1_000;
56
+ const warnings: string[] = [];
57
+ const { db, reads } = fakeDb(
58
+ [{ _id: "u1", ...ALICE }, { _id: "u2", ...BOB }],
59
+ [{ userId: "u1", organizationId: "g1", role: "owner" }, { userId: "u1", organizationId: "g2" }],
60
+ options.failDb,
61
+ );
62
+ const { auth, lookups } = fakeAuth({ u1: ALICE, u2: BOB });
63
+ const principals = new SessionPrincipals({
64
+ auth,
65
+ db,
66
+ now: () => now,
67
+ warn: (m) => warnings.push(m),
68
+ ...(options.trustUnverifiedEmail ? { trustUnverifiedEmail: true } : {}),
69
+ });
70
+ return { principals, reads, lookups, warnings, tick: (ms: number) => (now += ms) };
71
+ }
72
+
73
+ const cookie = (value: string) => new Headers({ cookie: value });
74
+
75
+ describe("betterAuthConfig", () => {
76
+ const base = { BETTER_AUTH_SECRET: "0123456789abcdef" };
77
+
78
+ it("refuses a short secret, and test accounts in production", () => {
79
+ expect(betterAuthConfig({}, { port: 1, trustedOrigins: [] })).toMatch(/BETTER_AUTH_SECRET/);
80
+ expect(betterAuthConfig({ ...base, NODE_ENV: "production", AUTH_TEST_ACCOUNTS: "1" }, { port: 1, trustedOrigins: [] })).toMatch(/production/);
81
+ });
82
+
83
+ it("reads the providers, the defaults and the passkey domain", () => {
84
+ const config = betterAuthConfig(
85
+ { ...base, GITHUB_CLIENT_ID: "gid", GITHUB_CLIENT_SECRET: "gs", GOOGLE_CLIENT_ID: "only-the-id" },
86
+ { port: 9091, trustedOrigins: ["https://app.example.com:8443"], appName: "Demo" },
87
+ );
88
+ expect(config).toMatchObject({
89
+ appName: "Demo",
90
+ basePath: "/auth",
91
+ baseURL: "http://localhost:9091",
92
+ github: { clientId: "gid", clientSecret: "gs" },
93
+ testAccounts: false,
94
+ rpID: "app.example.com",
95
+ });
96
+ expect(typeof config === "object" && "google" in config).toBe(false);
97
+ });
98
+
99
+ it("takes the base URL, the passkey domain and the test accounts from the environment", () => {
100
+ const config = betterAuthConfig(
101
+ { ...base, AUTH_BASE_URL: "https://api.example.com", PASSKEY_RP_ID: "example.com", AUTH_TEST_ACCOUNTS: "1" },
102
+ { port: 1, trustedOrigins: ["not a url"], basePath: "/api/auth" },
103
+ );
104
+ expect(config).toMatchObject({ baseURL: "https://api.example.com", rpID: "example.com", testAccounts: true, basePath: "/api/auth", appName: "App" });
105
+ expect(betterAuthConfig(base, { port: 1, trustedOrigins: ["not a url"] })).toMatchObject({ rpID: "localhost" });
106
+ });
107
+ });
108
+
109
+ describe("SessionPrincipals", () => {
110
+ it("gives the user of a session, with the verified email and the groups", async () => {
111
+ const { principals } = setup();
112
+ expect(await principals.ofHeaders(cookie("s=u1"))).toEqual({
113
+ id: "u1",
114
+ email: "alice@example.com",
115
+ name: "Alice",
116
+ groups: [{ id: "g1", role: "owner" }, { id: "g2", role: "member" }],
117
+ });
118
+ expect(await principals.ofHeaders(cookie("s=u2"))).toEqual({ id: "u2", groups: [] }); // the email is not verified
119
+ expect(await principals.ofHeaders(new Headers())).toBeUndefined();
120
+ expect(await principals.ofHeaders(cookie("s=nobody"))).toBeUndefined();
121
+ });
122
+
123
+ it("uses an email that is not verified on a test server", async () => {
124
+ const { principals } = setup({ trustUnverifiedEmail: true });
125
+ expect((await principals.ofHeaders(cookie("s=u2")))?.email).toBe("bob@example.com");
126
+ });
127
+
128
+ it("keeps a session lookup and a groups lookup for their memo time", async () => {
129
+ const { principals, lookups, reads, tick } = setup();
130
+ await Promise.all([principals.ofHeaders(cookie("s=u1")), principals.ofHeaders(cookie("s=u1"))]);
131
+ expect(lookups()).toBe(1);
132
+ expect(reads.member).toBe(1);
133
+ tick(6_000);
134
+ await principals.ofHeaders(cookie("s=u1"));
135
+ expect(lookups()).toBe(2);
136
+ expect(reads.member).toBe(1); // the groups stay 10 s
137
+ tick(5_000);
138
+ principals.forgetGroups("u1");
139
+ await principals.groupsOf("u1");
140
+ expect(reads.member).toBe(2);
141
+ });
142
+
143
+ it("gives a user by id, and undefined for an unknown user", async () => {
144
+ const { principals } = setup();
145
+ expect(await principals.ofUser("u1")).toMatchObject({ id: "u1", email: "alice@example.com" });
146
+ expect(await principals.ofUser("507f1f77bcf86cd799439011")).toBeUndefined();
147
+ });
148
+
149
+ it("reports a failed lookup, and gives no user and no groups", async () => {
150
+ const { principals, warnings } = setup({ failDb: true });
151
+ expect(await principals.ofUser("u1")).toBeUndefined();
152
+ expect(await principals.groupsOf("u1")).toEqual([]);
153
+ expect(warnings).toHaveLength(2);
154
+ });
155
+
156
+ it("treats a failed session lookup as no session", async () => {
157
+ const principals = new SessionPrincipals({
158
+ auth: { api: { getSession: async () => { throw new Error("bad cookie"); } } },
159
+ db: fakeDb([], []).db,
160
+ });
161
+ expect(await principals.ofHeaders(cookie("s=u1"))).toBeUndefined();
162
+ });
163
+ });
164
+
165
+ describe("betterAuthPrincipalOf", () => {
166
+ const request = {} as never;
167
+ const TOKEN = "agent-secret-token";
168
+ const READ_ONLY = { effects: { writes: [] } };
169
+ /** A policy: the agent acts for a user who owns no group. */
170
+ const notOwners = (user: { groups: readonly { role: string }[] }) => !user.groups.some((g) => g.role === "owner");
171
+
172
+ it("gives the principal of the session user", async () => {
173
+ const { principals } = setup();
174
+ const hook = betterAuthPrincipalOf({ principals, agentToken: TOKEN });
175
+ expect(await hook({ cookies: { s: "u1" } }, request)).toEqual({
176
+ principal: { id: "u1", via: VIA.session, claims: { email: "alice@example.com", name: "Alice", groups: [{ id: "g1", role: "owner" }, { id: "g2", role: "member" }] } },
177
+ });
178
+ });
179
+
180
+ it("gives the agent for the agent token", async () => {
181
+ const { principals } = setup();
182
+ const hook = betterAuthPrincipalOf({ principals, agentToken: TOKEN });
183
+ expect(await hook({ bearer: TOKEN }, request)).toEqual({ principal: { id: AGENT_ID, via: VIA.agent, claims: { agent: true } } });
184
+ });
185
+
186
+ it("refuses each act-as request without the actAs policy (an admin too)", async () => {
187
+ const { principals } = setup();
188
+ const hook = betterAuthPrincipalOf({ principals, agentToken: TOKEN, anonymous: READ_ONLY });
189
+ expect(await hook({ bearer: TOKEN, headers: { "x-act-as": "u1" } }, request)).toBeUndefined();
190
+ expect(await hook({ bearer: TOKEN, headers: { "x-act-as": "u2" } }, request)).toBeUndefined();
191
+ });
192
+
193
+ it("never falls back to the agent principal for a refused act-as", async () => {
194
+ const { principals } = setup();
195
+ const refusing = betterAuthPrincipalOf({ principals, agentToken: TOKEN, actAs: () => false, anonymous: READ_ONLY });
196
+ expect(await refusing({ bearer: TOKEN, headers: { "x-act-as": "u2" } }, request)).toBeUndefined();
197
+ const without = betterAuthPrincipalOf({ principals, agentToken: TOKEN, anonymous: READ_ONLY });
198
+ expect(await without({ bearer: TOKEN, headers: { "x-act-as": "u2" } }, request)).toBeUndefined();
199
+ });
200
+
201
+ it("acts for a user only when the policy permits it, and records the act-as in the claims", async () => {
202
+ const { principals } = setup();
203
+ const seen: string[] = [];
204
+ const hook = betterAuthPrincipalOf({
205
+ principals,
206
+ agentToken: TOKEN,
207
+ actAs: (user, credentials) => {
208
+ seen.push(`${user.id}:${credentials.bearer === TOKEN}`);
209
+ return notOwners(user);
210
+ },
211
+ });
212
+ expect(await hook({ bearer: TOKEN, headers: { "x-act-as": "u1" } }, request)).toBeUndefined(); // u1 owns g1
213
+ expect((await hook({ bearer: TOKEN, headers: { "x-act-as": "u2" } }, request))?.principal).toEqual({
214
+ id: "u2",
215
+ via: VIA.agentAs,
216
+ claims: { groups: [], agent: true, actAs: "u2" },
217
+ });
218
+ expect(seen).toEqual(["u1:true", "u2:true"]);
219
+ });
220
+
221
+ it("acts for an unknown user as an anonymous request, or refuses it without the anonymous option", async () => {
222
+ const { principals } = setup();
223
+ const open = betterAuthPrincipalOf({ principals, agentToken: TOKEN, actAs: () => true, anonymous: READ_ONLY });
224
+ expect(await open({ bearer: TOKEN, headers: { "x-act-as": "ghost" } }, request)).toEqual({
225
+ principal: { id: ANONYMOUS_ID, via: VIA.anonymous, claims: { agent: true, actAs: "ghost" } },
226
+ narrowing: READ_ONLY,
227
+ });
228
+ const closed = betterAuthPrincipalOf({ principals, agentToken: TOKEN, actAs: () => true });
229
+ expect(await closed({ bearer: TOKEN, headers: { "x-act-as": "ghost" } }, request)).toBeUndefined();
230
+ });
231
+
232
+ it("refuses a request without a user by default, and narrows it with the anonymous option", async () => {
233
+ const { principals } = setup();
234
+ expect(await betterAuthPrincipalOf({ principals })({}, request)).toBeUndefined();
235
+ expect(await betterAuthPrincipalOf({ principals, anonymous: false })({}, request)).toBeUndefined();
236
+ expect(await betterAuthPrincipalOf({ principals, anonymous: READ_ONLY })({}, request)).toEqual({
237
+ principal: { id: ANONYMOUS_ID, via: VIA.anonymous },
238
+ narrowing: READ_ONLY,
239
+ });
240
+ });
241
+
242
+ it("does not take another bearer token as the agent, and ignores the act-as header without the token", async () => {
243
+ const { principals } = setup();
244
+ const hook = betterAuthPrincipalOf({ principals, agentToken: TOKEN, actAsHeader: "X-Graph-As", actAs: () => true, anonymous: READ_ONLY });
245
+ expect((await hook({ bearer: "wrong", headers: { "x-graph-as": "u1" } }, request))?.principal).toEqual({ id: ANONYMOUS_ID, via: VIA.anonymous });
246
+ expect((await hook({ bearer: TOKEN, headers: { "x-graph-as": "u1" } }, request))?.principal?.id).toBe("u1");
247
+ expect(await betterAuthPrincipalOf({ principals })({ bearer: TOKEN }, request)).toBeUndefined(); // no agent token: no agent
248
+ });
249
+ });
250
+
251
+ describe("headersOf", () => {
252
+ it("copies the headers, and makes a cookie header from the cookies", () => {
253
+ expect(headersOf({ headers: { cookie: "a=1", "x-y": "z" } }).get("cookie")).toBe("a=1");
254
+ expect(headersOf({ cookies: { s: "a b", t: "2" } }).get("cookie")).toBe("s=a%20b; t=2");
255
+ expect([...headersOf({ headers: { "bad header": "x" } }).keys()]).toEqual([]);
256
+ expect(headersOf({}).has("cookie")).toBe(false);
257
+ });
258
+ });
259
+
260
+ describe("createBetterAuth", () => {
261
+ it("makes a server with the auth routes under the base path, and no session without a cookie", async () => {
262
+ const config = betterAuthConfig(
263
+ { BETTER_AUTH_SECRET: "x".repeat(32), AUTH_TEST_ACCOUNTS: "1", GITHUB_CLIENT_ID: "g", GITHUB_CLIENT_SECRET: "s", GOOGLE_CLIENT_ID: "o", GOOGLE_CLIENT_SECRET: "t" },
264
+ { port: 9999, trustedOrigins: ["http://localhost:4200"], appName: "Test" },
265
+ );
266
+ if (typeof config === "string") throw new Error(config);
267
+ // The routes below do not read the database
268
+ const auth = createBetterAuth({} as Db, {} as MongoClient, config);
269
+ const ok = await auth.handler(new Request("http://localhost:9999/auth/ok"));
270
+ expect(ok.status).toBe(200);
271
+ expect(await ok.json()).toEqual({ ok: true });
272
+ expect(await auth.api.getSession({ headers: new Headers() })).toBeNull();
273
+ });
274
+ });
package/src/config.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The configuration of a Better Auth server from the environment.
3
+ *
4
+ * - `BETTER_AUTH_SECRET`: the secret that signs the session cookies (16 characters or more).
5
+ * - `AUTH_BASE_URL`: the public origin of the server (default `http://localhost:<port>`).
6
+ * - `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`: the sign-in with GitHub.
7
+ * - `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`: the sign-in with Google.
8
+ * - `AUTH_TEST_ACCOUNTS=1`: accounts with an email and a password, for the tests. Production refuses it.
9
+ * - `PASSKEY_RP_ID`: the domain of the passkeys (default: the host of the first trusted origin).
10
+ */
11
+
12
+ /** The client id and the secret of an OAuth provider. */
13
+ export interface OAuthClient {
14
+ readonly clientId: string;
15
+ readonly clientSecret: string;
16
+ }
17
+
18
+ /** The configuration that `createBetterAuth` reads. */
19
+ export interface BetterAuthConfig {
20
+ /** The name of the app. Passkeys show it. */
21
+ readonly appName: string;
22
+ /** The path of the auth routes (default `/auth`). */
23
+ readonly basePath: string;
24
+ readonly secret: string;
25
+ readonly baseURL: string;
26
+ /** The origins of the app. The CSRF checks and the passkeys accept only these origins. */
27
+ readonly trustedOrigins: readonly string[];
28
+ readonly github?: OAuthClient | undefined;
29
+ readonly google?: OAuthClient | undefined;
30
+ /** Accounts with an email and a password, for the tests. Their email counts as verified. */
31
+ readonly testAccounts: boolean;
32
+ /** The relying party of the passkeys: a domain of the origins of the app. */
33
+ readonly rpID: string;
34
+ }
35
+
36
+ /** The options of `betterAuthConfig`. */
37
+ export interface BetterAuthConfigOptions {
38
+ /** The port of the server, for the default base URL. */
39
+ readonly port: number;
40
+ /** The origins of the app. */
41
+ readonly trustedOrigins: readonly string[];
42
+ /** The name of the app (default "App"). */
43
+ readonly appName?: string | undefined;
44
+ /** The path of the auth routes (default `/auth`). */
45
+ readonly basePath?: string | undefined;
46
+ }
47
+
48
+ /** This function reads the configuration from the environment. It gives a message when the server cannot start. */
49
+ export function betterAuthConfig(env: Readonly<Record<string, string | undefined>>, options: BetterAuthConfigOptions): BetterAuthConfig | string {
50
+ const secret = env["BETTER_AUTH_SECRET"] ?? "";
51
+ if (secret.length < 16) return "BETTER_AUTH_SECRET must have 16 characters or more";
52
+ if (env["NODE_ENV"] === "production" && env["AUTH_TEST_ACCOUNTS"] === "1") return "AUTH_TEST_ACCOUNTS is refused in production";
53
+ const pair = (id: string, key: string): OAuthClient | undefined => {
54
+ const clientId = env[id];
55
+ const clientSecret = env[key];
56
+ return clientId && clientSecret ? { clientId, clientSecret } : undefined;
57
+ };
58
+ let firstHost = "localhost";
59
+ try {
60
+ firstHost = new URL(options.trustedOrigins[0] ?? "http://localhost").hostname;
61
+ } catch {
62
+ // An origin that is not a URL: the passkeys use localhost
63
+ }
64
+ const github = pair("GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET");
65
+ const google = pair("GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET");
66
+ return {
67
+ appName: options.appName ?? "App",
68
+ basePath: options.basePath ?? "/auth",
69
+ secret,
70
+ baseURL: env["AUTH_BASE_URL"] || `http://localhost:${options.port}`,
71
+ trustedOrigins: options.trustedOrigins,
72
+ ...(github ? { github } : {}),
73
+ ...(google ? { google } : {}),
74
+ testAccounts: env["AUTH_TEST_ACCOUNTS"] === "1",
75
+ rpID: env["PASSKEY_RP_ID"] || firstHost,
76
+ };
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./config.js";
3
+ export * from "./auth.js";
4
+ export * from "./principals.js";
5
+ export * from "./principal-of.js";
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The request hook of a procedures server (`principalOf`) over Better Auth. It gives the principal
3
+ * of a network request:
4
+ *
5
+ * - The agent token in the bearer token: the principal `agent`. With the act-as header, the agent
6
+ * acts for that user, with the rights of the user and no more. The `actAs` policy tells for whom.
7
+ * Without the policy, the hook refuses each act-as request.
8
+ * - Else the session cookie: the principal of the signed-in user.
9
+ * - Else the anonymous principal with the `anonymous` narrowing. Without that option, the hook gives
10
+ * no principal, and the server refuses the request.
11
+ *
12
+ * A bearer token that is not the agent token goes on to the token check of the server. A server
13
+ * with this hook sets no `token`: its token check runs after the hook, and a cookie session has no
14
+ * bearer token.
15
+ */
16
+ import { tokenMatches, type CapabilityRequest, type Credentials, type Principal, type PrincipalOf, type RequestIdentity } from "@procedures/client";
17
+ import type { SessionPrincipals, SignedInUser } from "./principals.js";
18
+
19
+ /** The `via` of each principal that the hook gives. */
20
+ export const VIA = {
21
+ session: "better-auth",
22
+ agent: "agent-token",
23
+ agentAs: "agent-as",
24
+ anonymous: "anonymous",
25
+ } as const;
26
+
27
+ /** The id of the principal of the agent token. */
28
+ export const AGENT_ID = "agent";
29
+ /** The id of the principal of a request without a user. */
30
+ export const ANONYMOUS_ID = "anonymous";
31
+
32
+ /** The policy of act-as: true when the agent can act for this user. */
33
+ export type ActAsPolicy = (user: SignedInUser, credentials: Credentials) => boolean | Promise<boolean>;
34
+
35
+ /** The options of `betterAuthPrincipalOf`. */
36
+ export interface BetterAuthPrincipalOfOptions {
37
+ /** The users of the sessions. */
38
+ readonly principals: SessionPrincipals;
39
+ /** The token of the agent. Without it, no request is the agent. */
40
+ readonly agentToken?: string | undefined;
41
+ /** The header that names the user for whom the agent acts (default `x-act-as`). */
42
+ readonly actAsHeader?: string | undefined;
43
+ /** For whom the agent can act. Without it, the hook refuses each act-as request. */
44
+ readonly actAs?: ActAsPolicy | undefined;
45
+ /**
46
+ * The narrowing of a request without a user (for example `{ effects: { writes: [] } }` for read
47
+ * only). Without it (the default), the hook gives no principal, and the server refuses the request.
48
+ */
49
+ readonly anonymous?: CapabilityRequest | false | undefined;
50
+ }
51
+
52
+ /** The claims of a user: the verified email, the name and the groups. */
53
+ function userClaims(user: SignedInUser): Record<string, unknown> {
54
+ return { ...(user.email ? { email: user.email } : {}), ...(user.name ? { name: user.name } : {}), groups: user.groups };
55
+ }
56
+
57
+ /** This function makes the headers of a request from its credentials, for the session lookup. */
58
+ export function headersOf(credentials: Credentials): Headers {
59
+ const headers = new Headers();
60
+ for (const [name, value] of Object.entries(credentials.headers ?? {})) {
61
+ try {
62
+ headers.set(name, value);
63
+ } catch {
64
+ // A header name that the Fetch API refuses: the session lookup does not need it
65
+ }
66
+ }
67
+ if (!headers.has("cookie") && credentials.cookies) {
68
+ const cookie = Object.entries(credentials.cookies)
69
+ .map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
70
+ .join("; ");
71
+ if (cookie) headers.set("cookie", cookie);
72
+ }
73
+ return headers;
74
+ }
75
+
76
+ /** This function gives the request hook of a procedures server over Better Auth. */
77
+ export function betterAuthPrincipalOf(options: BetterAuthPrincipalOfOptions): PrincipalOf {
78
+ const actAsHeader = (options.actAsHeader ?? "x-act-as").toLowerCase();
79
+ const anonymous = options.anonymous === false ? undefined : options.anonymous;
80
+ /** The identity of a request without a user, or undefined when the server refuses it. */
81
+ const anonymousIdentity = (claims?: Record<string, unknown>): RequestIdentity | undefined =>
82
+ anonymous === undefined
83
+ ? undefined
84
+ : // The via is `anonymous` also for an agent that acts for an unknown user: the via decides the subject (refer to subjectOf of @procedures/access)
85
+ { principal: { id: ANONYMOUS_ID, via: VIA.anonymous, ...(claims ? { claims } : {}) }, narrowing: anonymous };
86
+ const identity = (principal: Principal): RequestIdentity => ({ principal });
87
+
88
+ return async (credentials) => {
89
+ if (options.agentToken && tokenMatches(options.agentToken, credentials.bearer)) {
90
+ const target = credentials.headers?.[actAsHeader];
91
+ if (!target) return identity({ id: AGENT_ID, via: VIA.agent, claims: { agent: true } });
92
+ if (!options.actAs) return undefined;
93
+ const user = await options.principals.ofUser(target);
94
+ // An unknown user has no rights: the agent acts as an anonymous request, and a refused
95
+ // act-as gives no principal (never the bare agent, which is a service account)
96
+ if (!user) return anonymousIdentity({ agent: true, actAs: target });
97
+ if (!(await options.actAs(user, credentials))) return undefined;
98
+ return identity({ id: user.id, via: VIA.agentAs, claims: { ...userClaims(user), agent: true, actAs: user.id } });
99
+ }
100
+ const user = await options.principals.ofHeaders(headersOf(credentials));
101
+ if (user) return identity({ id: user.id, via: VIA.session, claims: userClaims(user) });
102
+ return anonymousIdentity();
103
+ };
104
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The person of a request: the session cookie gives the user, and the `member` records of the
3
+ * organization plugin give the groups. A short memo keeps the boot of a page, which sends many
4
+ * requests at the same time, to one session lookup.
5
+ */
6
+ import { ObjectId, type Db } from "mongodb";
7
+
8
+ /** A group of a user: an organization of the organization plugin, with the role of the user. */
9
+ export interface UserGroup {
10
+ readonly id: string;
11
+ readonly role: string;
12
+ }
13
+
14
+ /** A signed-in user. `email` is present only when it is verified. */
15
+ export interface SignedInUser {
16
+ readonly id: string;
17
+ readonly email?: string | undefined;
18
+ readonly name?: string | undefined;
19
+ readonly groups: readonly UserGroup[];
20
+ }
21
+
22
+ /** The part of a Better Auth server that `SessionPrincipals` uses. */
23
+ export interface SessionSource {
24
+ readonly api: { getSession(context: { headers: Headers }): Promise<{ user?: unknown } | null | undefined> };
25
+ }
26
+
27
+ /** The options of `SessionPrincipals`. */
28
+ export interface SessionPrincipalsOptions {
29
+ /** The Better Auth server. */
30
+ readonly auth: SessionSource;
31
+ /** The database of Better Auth: its `user` and `member` collections. */
32
+ readonly db: Db;
33
+ /** Use an email that is not verified, for a test server only (default false). */
34
+ readonly trustUnverifiedEmail?: boolean | undefined;
35
+ /** The time that a session lookup stays, in milliseconds (default 5000). */
36
+ readonly sessionMemoMs?: number | undefined;
37
+ /** The time that a groups lookup stays, in milliseconds (default 10000). */
38
+ readonly groupsMemoMs?: number | undefined;
39
+ /** The clock (default `Date.now`). The tests give their own. */
40
+ readonly now?: (() => number) | undefined;
41
+ /** The function that reports a failed lookup (default `console.warn`). */
42
+ readonly warn?: ((message: string) => void) | undefined;
43
+ }
44
+
45
+ const MAX_MEMO = 5_000;
46
+
47
+ /** The ids to look for: the text, and the `ObjectId` when the text is one. */
48
+ function idsOf(id: string): unknown[] {
49
+ return ObjectId.isValid(id) ? [id, new ObjectId(id)] : [id];
50
+ }
51
+
52
+ /** The users of the sessions of the requests. */
53
+ export class SessionPrincipals {
54
+ readonly #options: SessionPrincipalsOptions;
55
+ readonly #sessions = new Map<string, { at: number; user: Promise<SignedInUser | undefined> }>();
56
+ readonly #groups = new Map<string, { at: number; groups: Promise<readonly UserGroup[]> }>();
57
+
58
+ constructor(options: SessionPrincipalsOptions) {
59
+ this.#options = options;
60
+ }
61
+
62
+ #now(): number {
63
+ return (this.#options.now ?? Date.now)();
64
+ }
65
+
66
+ #warn(message: string): void {
67
+ (this.#options.warn ?? ((text: string) => console.warn(text)))(message);
68
+ }
69
+
70
+ #email(user: Record<string, unknown>): string | undefined {
71
+ const verified = user["emailVerified"] === true || this.#options.trustUnverifiedEmail === true;
72
+ return typeof user["email"] === "string" && user["email"] && verified ? user["email"] : undefined;
73
+ }
74
+
75
+ /** This method gives the user of the session cookie of the headers, or undefined when there is no session. */
76
+ ofHeaders(headers: Headers): Promise<SignedInUser | undefined> {
77
+ const cookie = headers.get("cookie") ?? "";
78
+ if (!cookie) return Promise.resolve(undefined);
79
+ const now = this.#now();
80
+ const ttl = this.#options.sessionMemoMs ?? 5_000;
81
+ const hit = this.#sessions.get(cookie);
82
+ if (hit && now - hit.at < ttl) return hit.user;
83
+ const user = this.#resolve(headers);
84
+ this.#sessions.set(cookie, { at: now, user });
85
+ if (this.#sessions.size > MAX_MEMO) prune(this.#sessions, now, ttl);
86
+ return user;
87
+ }
88
+
89
+ async #resolve(headers: Headers): Promise<SignedInUser | undefined> {
90
+ let session: { user?: unknown } | null | undefined;
91
+ try {
92
+ session = await this.#options.auth.api.getSession({ headers });
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ const user = session?.user;
97
+ if (typeof user !== "object" || user === null) return undefined;
98
+ const record = user as Record<string, unknown>;
99
+ if (record["id"] === undefined || record["id"] === null) return undefined;
100
+ return this.#userOf(String(record["id"]), record);
101
+ }
102
+
103
+ async #userOf(id: string, record: Record<string, unknown>): Promise<SignedInUser> {
104
+ const email = this.#email(record);
105
+ const name = typeof record["name"] === "string" && record["name"] ? record["name"] : undefined;
106
+ return { id, ...(email ? { email } : {}), ...(name ? { name } : {}), groups: await this.groupsOf(id) };
107
+ }
108
+
109
+ /** This method gives a user by id from the records of Better Auth, or undefined when the user is unknown. */
110
+ async ofUser(id: string): Promise<SignedInUser | undefined> {
111
+ let record: Record<string, unknown> | null;
112
+ try {
113
+ record = (await this.#options.db.collection("user").findOne({ _id: { $in: idsOf(id) } } as never)) as Record<string, unknown> | null;
114
+ } catch (error) {
115
+ this.#warn(`[better-auth] the lookup of a user failed: ${error instanceof Error ? error.message : String(error)}`);
116
+ return undefined;
117
+ }
118
+ return record ? this.#userOf(id, record) : undefined;
119
+ }
120
+
121
+ /** This method gives the groups of a user: the `member` records of the organization plugin. */
122
+ groupsOf(id: string): Promise<readonly UserGroup[]> {
123
+ const now = this.#now();
124
+ const ttl = this.#options.groupsMemoMs ?? 10_000;
125
+ const hit = this.#groups.get(id);
126
+ if (hit && now - hit.at < ttl) return hit.groups;
127
+ const groups = (async (): Promise<readonly UserGroup[]> => {
128
+ try {
129
+ const rows = (await this.#options.db.collection("member").find({ userId: { $in: idsOf(id) } } as never).toArray()) as Record<string, unknown>[];
130
+ return rows.map((row) => ({ id: String(row["organizationId"]), role: String(row["role"] ?? "member") }));
131
+ } catch (error) {
132
+ this.#warn(`[better-auth] the lookup of the groups failed: ${error instanceof Error ? error.message : String(error)}`);
133
+ return [];
134
+ }
135
+ })();
136
+ this.#groups.set(id, { at: now, groups });
137
+ if (this.#groups.size > MAX_MEMO) prune(this.#groups, now, ttl);
138
+ return groups;
139
+ }
140
+
141
+ /** This method forgets the memo of the groups of a user, for example after a change of a membership. */
142
+ forgetGroups(id: string): void {
143
+ this.#groups.delete(id);
144
+ }
145
+ }
146
+
147
+ /** This function removes the expired entries of a memo. */
148
+ function prune(map: Map<string, { at: number }>, now: number, ttl: number): void {
149
+ for (const [key, entry] of map) if (now - entry.at >= ttl) map.delete(key);
150
+ }