@procedures/better-auth 0.14.0 → 0.15.0
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/README.md +36 -6
- package/dist/.tsbuildinfo +1 -1
- package/dist/auth.d.ts +3 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +3 -27
- package/dist/auth.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/handler.d.ts +101 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +122 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +10 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +32 -0
- package/dist/options.js.map +1 -0
- package/package.json +7 -3
- package/src/auth.ts +6 -29
- package/src/config.ts +8 -0
- package/src/handler.test.ts +216 -0
- package/src/handler.ts +200 -0
- package/src/index.ts +1 -0
- package/src/options.ts +39 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The auth routes with credentialed CORS from the trusted origins of the host (CLI-49). The test
|
|
3
|
+
* runs Better Auth with the options of `createBetterAuth` over the memory adapter. A page on a
|
|
4
|
+
* trusted origin signs in, reads its session and posts, with its cookies. Another origin gets no
|
|
5
|
+
* CORS header.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it, afterEach } from "vitest";
|
|
8
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
9
|
+
import type { AddressInfo } from "node:net";
|
|
10
|
+
import { betterAuth } from "better-auth";
|
|
11
|
+
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
12
|
+
import { hostPolicy } from "@procedures/client";
|
|
13
|
+
import { betterAuthConfig, betterAuthFace, betterAuthHandler, type AuthRequestTracker, type BetterAuthServer } from "./index.js";
|
|
14
|
+
import { betterAuthOptions } from "./options.js";
|
|
15
|
+
|
|
16
|
+
/** The origin of the page: the dev port of an app. The server is on another port. */
|
|
17
|
+
const PAGE = "http://localhost:5173";
|
|
18
|
+
const EVIL = "https://evil.example";
|
|
19
|
+
|
|
20
|
+
let cleanups: Array<() => Promise<void>> = [];
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
for (const cleanup of cleanups.reverse()) await cleanup().catch(() => {});
|
|
23
|
+
cleanups = [];
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** The tables of Better Auth and of its plugins in the memory adapter. */
|
|
27
|
+
function memoryTables(): Record<string, Record<string, unknown>[]> {
|
|
28
|
+
return { user: [], session: [], account: [], verification: [], organization: [], member: [], invitation: [], passkey: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* This function starts a listener: the auth routes first, then a route of the app. The handler of
|
|
33
|
+
* the listener comes from `mount`, after the listener has its port, because the base URL of Better
|
|
34
|
+
* Auth names the port.
|
|
35
|
+
*/
|
|
36
|
+
async function start(
|
|
37
|
+
mount: (auth: BetterAuthServer) => (req: IncomingMessage, res: ServerResponse) => boolean,
|
|
38
|
+
env: Record<string, string> = {},
|
|
39
|
+
): Promise<string> {
|
|
40
|
+
let handler: ((req: IncomingMessage, res: ServerResponse) => boolean) | undefined;
|
|
41
|
+
const server: Server = createServer((req, res) => {
|
|
42
|
+
if (handler?.(req, res)) return;
|
|
43
|
+
res.statusCode = 200;
|
|
44
|
+
res.end("the route of the app");
|
|
45
|
+
});
|
|
46
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
47
|
+
cleanups.push(
|
|
48
|
+
() =>
|
|
49
|
+
new Promise<void>((resolve) => {
|
|
50
|
+
server.close(() => resolve());
|
|
51
|
+
server.closeAllConnections();
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
const port = (server.address() as AddressInfo).port;
|
|
55
|
+
const config = betterAuthConfig(
|
|
56
|
+
{ BETTER_AUTH_SECRET: "a-secret-of-the-test-0123456789", AUTH_TEST_ACCOUNTS: "1", AUTH_BASE_URL: `http://127.0.0.1:${port}`, ...env },
|
|
57
|
+
{ port, trustedOrigins: [PAGE] },
|
|
58
|
+
);
|
|
59
|
+
if (typeof config === "string") throw new Error(config);
|
|
60
|
+
const options = betterAuthOptions(config, memoryAdapter(memoryTables()));
|
|
61
|
+
// Better Auth turns its origin check off in a test environment: the test turns it on, as in production
|
|
62
|
+
const auth = betterAuth({ ...options, advanced: { ...options.advanced, disableOriginCheck: false } }) as unknown as BetterAuthServer;
|
|
63
|
+
handler = mount(auth);
|
|
64
|
+
return `http://127.0.0.1:${port}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The CORS headers of a response. */
|
|
68
|
+
function corsOf(response: Response): Record<string, string> {
|
|
69
|
+
const headers: Record<string, string> = {};
|
|
70
|
+
response.headers.forEach((value, name) => {
|
|
71
|
+
if (name.startsWith("access-control-")) headers[name] = value;
|
|
72
|
+
});
|
|
73
|
+
return headers;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The cookies of the Set-Cookie headers of a response, as a Cookie header. */
|
|
77
|
+
function cookiesOf(response: Response): string {
|
|
78
|
+
return response.headers
|
|
79
|
+
.getSetCookie()
|
|
80
|
+
.map((cookie) => cookie.split(";")[0])
|
|
81
|
+
.join("; ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const json = (body: unknown): RequestInit => ({ method: "POST", headers: { "Content-Type": "application/json", Origin: PAGE }, body: JSON.stringify(body) });
|
|
85
|
+
|
|
86
|
+
describe("betterAuthHandler (CLI-49)", () => {
|
|
87
|
+
it("lets a page on a trusted origin sign in, read its session and post, with its cookies", async () => {
|
|
88
|
+
const base = await start((auth) => betterAuthHandler(auth, { allowedOrigins: [PAGE] }));
|
|
89
|
+
|
|
90
|
+
// The preflight of the POST of the sign-up
|
|
91
|
+
const preflight = await fetch(`${base}/auth/sign-up/email`, {
|
|
92
|
+
method: "OPTIONS",
|
|
93
|
+
headers: { Origin: PAGE, "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type" },
|
|
94
|
+
});
|
|
95
|
+
expect(preflight.status).toBe(204);
|
|
96
|
+
expect(corsOf(preflight)).toMatchObject({
|
|
97
|
+
"access-control-allow-origin": PAGE,
|
|
98
|
+
"access-control-allow-credentials": "true",
|
|
99
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
100
|
+
"access-control-allow-headers": "Content-Type, Authorization",
|
|
101
|
+
});
|
|
102
|
+
expect(preflight.headers.get("vary")).toContain("Origin");
|
|
103
|
+
|
|
104
|
+
// The sign-in: a test account signs up, and the response sets the session cookie
|
|
105
|
+
const signUp = await fetch(`${base}/auth/sign-up/email`, json({ email: "alice@example.com", password: "a-long-password", name: "Alice" }));
|
|
106
|
+
expect(signUp.status).toBe(200);
|
|
107
|
+
expect(corsOf(signUp)).toMatchObject({ "access-control-allow-origin": PAGE, "access-control-allow-credentials": "true" });
|
|
108
|
+
const cookie = cookiesOf(signUp);
|
|
109
|
+
expect(cookie).toContain("session_token");
|
|
110
|
+
|
|
111
|
+
// The session, with the cookies
|
|
112
|
+
const session = await fetch(`${base}/auth/get-session`, { headers: { Origin: PAGE, Cookie: cookie } });
|
|
113
|
+
expect(session.status).toBe(200);
|
|
114
|
+
expect(corsOf(session)).toMatchObject({ "access-control-allow-origin": PAGE, "access-control-allow-credentials": "true" });
|
|
115
|
+
expect(((await session.json()) as { user?: { email?: string } }).user?.email).toBe("alice@example.com");
|
|
116
|
+
|
|
117
|
+
// A POST with the cookies: the sign-out
|
|
118
|
+
const signOut = await fetch(`${base}/auth/sign-out`, { method: "POST", headers: { Origin: PAGE, Cookie: cookie } });
|
|
119
|
+
expect(signOut.status).toBe(200);
|
|
120
|
+
expect(corsOf(signOut)).toMatchObject({ "access-control-allow-origin": PAGE, "access-control-allow-credentials": "true" });
|
|
121
|
+
|
|
122
|
+
// The other routes stay with the app
|
|
123
|
+
expect(await (await fetch(`${base}/chat`)).text()).toBe("the route of the app");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("sends no CORS header to an origin that is not trusted, also for its preflight", async () => {
|
|
127
|
+
const base = await start((auth) => betterAuthHandler(auth, { allowedOrigins: [PAGE] }));
|
|
128
|
+
const signUp = await fetch(`${base}/auth/sign-up/email`, json({ email: "bob@example.com", password: "a-long-password", name: "Bob" }));
|
|
129
|
+
const cookie = cookiesOf(signUp);
|
|
130
|
+
|
|
131
|
+
const preflight = await fetch(`${base}/auth/sign-out`, { method: "OPTIONS", headers: { Origin: EVIL, "Access-Control-Request-Method": "POST" } });
|
|
132
|
+
expect(preflight.status).toBe(204);
|
|
133
|
+
expect(corsOf(preflight)).toEqual({});
|
|
134
|
+
const session = await fetch(`${base}/auth/get-session`, { headers: { Origin: EVIL, Cookie: cookie } });
|
|
135
|
+
expect(corsOf(session)).toEqual({});
|
|
136
|
+
// The CSRF check of Better Auth refuses the write of the other origin
|
|
137
|
+
const signOut = await fetch(`${base}/auth/sign-out`, { method: "POST", headers: { Origin: EVIL, Cookie: cookie } });
|
|
138
|
+
expect(signOut.status).toBe(403);
|
|
139
|
+
expect(corsOf(signOut)).toEqual({});
|
|
140
|
+
// Each answer varies by the origin, so a shared cache does not give it to a trusted origin
|
|
141
|
+
for (const response of [preflight, session, signOut]) expect(response.headers.get("vary")).toContain("Origin");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("gives the cookies SameSite=None and Secure for a page on another site", async () => {
|
|
145
|
+
const base = await start((auth) => betterAuthHandler(auth, { allowedOrigins: [PAGE] }), { AUTH_CROSS_SITE_COOKIES: "1" });
|
|
146
|
+
const signUp = await fetch(`${base}/auth/sign-up/email`, json({ email: "carol@example.com", password: "a-long-password", name: "Carol" }));
|
|
147
|
+
const sessionCookie = signUp.headers.getSetCookie().find((cookie) => cookie.includes("session_token"));
|
|
148
|
+
expect(sessionCookie).toMatch(/SameSite=None/i);
|
|
149
|
+
expect(sessionCookie).toMatch(/; Secure/i);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("takes the trusted origins of the policy of a procedures host as a face", async () => {
|
|
153
|
+
const policy = hostPolicy({ browser: { allowedOrigins: [PAGE] } });
|
|
154
|
+
let endpoint: unknown;
|
|
155
|
+
const base = await start((auth) => {
|
|
156
|
+
const face = betterAuthFace(auth)({ policy });
|
|
157
|
+
endpoint = face.endpoint;
|
|
158
|
+
return face.handler;
|
|
159
|
+
});
|
|
160
|
+
expect(endpoint).toEqual({ type: "auth", scheme: "http", path: "/auth" });
|
|
161
|
+
const session = await fetch(`${base}/auth/get-session`, { headers: { Origin: PAGE } });
|
|
162
|
+
expect(corsOf(session)).toMatchObject({ "access-control-allow-origin": PAGE, "access-control-allow-credentials": "true" });
|
|
163
|
+
expect(corsOf(await fetch(`${base}/auth/get-session`, { headers: { Origin: EVIL } }))).toEqual({});
|
|
164
|
+
// A policy that permits each origin cannot give credentialed CORS (CSRF)
|
|
165
|
+
const open = hostPolicy({ browser: { allowedOrigins: "*" } });
|
|
166
|
+
expect(() => betterAuthFace({ handler: async () => new Response() })({ policy: open })).toThrow(/not "\*"/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("keeps Origin in the Vary header of a route", async () => {
|
|
170
|
+
const routes = async (): Promise<Response> => new Response("ok", { headers: { Vary: "Accept-Encoding" } });
|
|
171
|
+
const base = await start(() => betterAuthHandler({ handler: routes }, { allowedOrigins: [PAGE] }));
|
|
172
|
+
for (const origin of [PAGE, EVIL]) {
|
|
173
|
+
const response = await fetch(`${base}/auth/ok`, { headers: { Origin: origin } });
|
|
174
|
+
expect(response.headers.get("vary")).toBe("Accept-Encoding, Origin");
|
|
175
|
+
}
|
|
176
|
+
expect((await fetch(`${base}/auth/ok`, { headers: { Origin: PAGE } })).headers.get("access-control-allow-origin")).toBe(PAGE);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("sends each request of the routes through the request tracking of the host, and no preflight (CLI-54)", async () => {
|
|
180
|
+
const tracked: string[] = [];
|
|
181
|
+
// A tracking of a host: it refuses the key "refused" with 503, as a host does after the stop of its intake
|
|
182
|
+
const track: AuthRequestTracker = async (req, res, key, serve) => {
|
|
183
|
+
tracked.push(`${req.method} ${key}`);
|
|
184
|
+
if (key === "refused") {
|
|
185
|
+
res.statusCode = 503;
|
|
186
|
+
res.setHeader("Content-Type", "application/json");
|
|
187
|
+
res.end(JSON.stringify({ code: "UNAVAILABLE", retryable: true }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await serve(new AbortController().signal);
|
|
191
|
+
};
|
|
192
|
+
const routes = async (request: Request): Promise<Response> => new Response(new URL(request.url).pathname);
|
|
193
|
+
const policy = hostPolicy({ browser: { allowedOrigins: [PAGE] } });
|
|
194
|
+
const base = await start(() => betterAuthFace({ handler: routes })({ policy, track }).handler);
|
|
195
|
+
expect(await (await fetch(`${base}/auth/get-session`, { headers: { Origin: PAGE } })).text()).toBe("/auth/get-session");
|
|
196
|
+
expect(await (await fetch(`${base}/auth/sign-in/email`, { method: "POST", headers: { Origin: PAGE } })).text()).toBe("/auth/sign-in/email");
|
|
197
|
+
const preflight = await fetch(`${base}/auth/sign-in/email`, { method: "OPTIONS", headers: { Origin: PAGE, "Access-Control-Request-Method": "POST" } });
|
|
198
|
+
expect(preflight.status).toBe(204);
|
|
199
|
+
// The CORS comes before the tracking, so the page can read a refusal
|
|
200
|
+
const refused = await fetch(`${base}/auth/refused`, { method: "POST", headers: { Origin: PAGE } });
|
|
201
|
+
expect(refused.status).toBe(503);
|
|
202
|
+
expect(corsOf(refused)).toMatchObject({ "access-control-allow-origin": PAGE, "access-control-allow-credentials": "true" });
|
|
203
|
+
expect(tracked).toEqual(["GET get-session", "POST sign-in/email", "POST refused"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("takes only the requests under its path, and gives the middleware of an app", async () => {
|
|
207
|
+
const handler = betterAuthHandler({ handler: async () => new Response("auth") }, { basePath: "/api/auth/" });
|
|
208
|
+
expect(handler.basePath).toBe("/api/auth");
|
|
209
|
+
expect(handler.matches({ url: "/api/auth/get-session?x=1" } as IncomingMessage)).toBe(true);
|
|
210
|
+
expect(handler.matches({ url: "/api/auth" } as IncomingMessage)).toBe(true);
|
|
211
|
+
expect(handler.matches({ url: "/api/authors" } as IncomingMessage)).toBe(false);
|
|
212
|
+
let next = 0;
|
|
213
|
+
handler.middleware({ url: "/chat" } as IncomingMessage, {} as ServerResponse, () => next++);
|
|
214
|
+
expect(next).toBe(1);
|
|
215
|
+
});
|
|
216
|
+
});
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node handler of the auth routes, with credentialed CORS from the trusted origins of the
|
|
3
|
+
* host (CLI-49).
|
|
4
|
+
*
|
|
5
|
+
* The handler of Better Auth sends no CORS headers. A page on another origin then gets no
|
|
6
|
+
* session, and its POST requests fail. `betterAuthHandler()` puts the CORS of
|
|
7
|
+
* `credentialedCors()` of `@procedures/client/node` in front of the routes. Thus the
|
|
8
|
+
* `allowedOrigins` of the host, the list of the browser checks of `procedureHandler`, drives
|
|
9
|
+
* the CORS of the auth routes too. `betterAuthFace()` takes that list from the policy of a
|
|
10
|
+
* procedures host.
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
13
|
+
import { toNodeHandler } from "better-auth/node";
|
|
14
|
+
import type { BrowserGuardOptions, HostPolicy } from "@procedures/client";
|
|
15
|
+
import { credentialedCors, type CredentialedCorsOptions } from "@procedures/client/node";
|
|
16
|
+
import type { BetterAuthServer } from "./auth.js";
|
|
17
|
+
|
|
18
|
+
/** The methods of a preflight of the auth routes: Better Auth uses GET and POST. */
|
|
19
|
+
export const AUTH_CORS_METHODS: readonly string[] = Object.freeze(["GET", "POST", "OPTIONS"]);
|
|
20
|
+
|
|
21
|
+
/** The request headers of a preflight of the auth routes. */
|
|
22
|
+
export const AUTH_CORS_HEADERS: readonly string[] = Object.freeze(["Content-Type", "Authorization"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The request tracking of a procedures host: `track` of the context of a face (CLI-54). The
|
|
26
|
+
* function does the work of one request through the service of the host, on the route
|
|
27
|
+
* `<face> <key>`. It answers a refusal with 503. The type is structural.
|
|
28
|
+
*/
|
|
29
|
+
export type AuthRequestTracker = (
|
|
30
|
+
req: IncomingMessage,
|
|
31
|
+
res: ServerResponse,
|
|
32
|
+
key: string,
|
|
33
|
+
serve: (signal: AbortSignal) => Promise<void> | void,
|
|
34
|
+
) => Promise<void>;
|
|
35
|
+
|
|
36
|
+
/** The options of `betterAuthHandler()`. */
|
|
37
|
+
export interface BetterAuthHandlerOptions {
|
|
38
|
+
/**
|
|
39
|
+
* The origins that get credentialed CORS: the `allowedOrigins` of the host. A list or a
|
|
40
|
+
* function of origins. `"*"` is refused (CSRF). Without it, no other origin gets CORS.
|
|
41
|
+
*/
|
|
42
|
+
readonly allowedOrigins?: BrowserGuardOptions["allowedOrigins"];
|
|
43
|
+
/** The path of the auth routes. The default is `/auth`, as `betterAuthConfig` gives it. */
|
|
44
|
+
readonly basePath?: string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* More of the CORS: `methods`, `allowedHeaders`, `exposedHeaders` and `maxAge`. The defaults are
|
|
47
|
+
* `AUTH_CORS_METHODS` and `AUTH_CORS_HEADERS`.
|
|
48
|
+
*/
|
|
49
|
+
readonly cors?: CredentialedCorsOptions | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* The request tracking of a host (CLI-54). Each request of the routes goes through it, with the
|
|
52
|
+
* path under `basePath` as its key. A preflight does not. `betterAuthFace()` gives the tracking
|
|
53
|
+
* of the host. Without it, each request starts directly.
|
|
54
|
+
*/
|
|
55
|
+
readonly track?: AuthRequestTracker | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The Node handler of the auth routes. A call gives true when the handler takes the request (its
|
|
60
|
+
* path is under `basePath`), and false for another request. The handler then does not touch the
|
|
61
|
+
* request.
|
|
62
|
+
*/
|
|
63
|
+
export interface NodeAuthHandler {
|
|
64
|
+
(req: IncomingMessage, res: ServerResponse): boolean;
|
|
65
|
+
/** This method gives true when the handler takes the request: its path is under `basePath`. */
|
|
66
|
+
matches(req: IncomingMessage): boolean;
|
|
67
|
+
/** The handler as a middleware of Connect or Express, at the level of the app: a request outside `basePath` goes to `next`. */
|
|
68
|
+
middleware(req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void): void;
|
|
69
|
+
/** The path under which the handler takes the requests. */
|
|
70
|
+
readonly basePath: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when the value of a `Vary` header names the field, or is `*`. */
|
|
74
|
+
function varies(value: string, field: string): boolean {
|
|
75
|
+
return value.split(",").some((part) => {
|
|
76
|
+
const name = part.trim().toLowerCase();
|
|
77
|
+
return name === "*" || name === field.toLowerCase();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The handler of the routes, with `Origin` in each `Vary` header. The Node adapter of Better Auth
|
|
83
|
+
* sets each header of a response with `setHeader`, so a `Vary` of a route replaces the `Vary:
|
|
84
|
+
* Origin` of the CORS. A shared cache then can give the answer of one origin to another origin.
|
|
85
|
+
*/
|
|
86
|
+
function keepVaryOrigin(handler: BetterAuthServer["handler"]): BetterAuthServer["handler"] {
|
|
87
|
+
return async (request) => {
|
|
88
|
+
const response = await handler(request);
|
|
89
|
+
const vary = response.headers.get("vary");
|
|
90
|
+
if (vary === null || varies(vary, "Origin")) return response;
|
|
91
|
+
const headers = new Headers(response.headers);
|
|
92
|
+
headers.set("vary", `${vary}, Origin`);
|
|
93
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The path of a request URL, without its query. */
|
|
98
|
+
function pathOf(url: string | undefined): string {
|
|
99
|
+
const path = (url ?? "/").split("?")[0] ?? "/";
|
|
100
|
+
return path === "" ? "/" : path;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* This function gives the Node handler of the auth routes, with credentialed CORS for the trusted
|
|
105
|
+
* origins. Mount it on the mount list of a listener, or as a middleware of the app.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```typescript
|
|
109
|
+
* const handler = betterAuthHandler(auth, { allowedOrigins: ["http://localhost:5173"] });
|
|
110
|
+
* app.use(handler.middleware); // Express, at the level of the app
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function betterAuthHandler(auth: Pick<BetterAuthServer, "handler">, options: BetterAuthHandlerOptions = {}): NodeAuthHandler {
|
|
114
|
+
const basePath = (options.basePath ?? "/auth").replace(/\/+$/, "") || "/";
|
|
115
|
+
const prefix = basePath === "/" ? "/" : `${basePath}/`;
|
|
116
|
+
const cors = credentialedCors(options.allowedOrigins, { methods: AUTH_CORS_METHODS, allowedHeaders: AUTH_CORS_HEADERS, ...options.cors });
|
|
117
|
+
// The CORS sets `Vary: Origin` on each response, for each origin: the routes keep it
|
|
118
|
+
const routes = toNodeHandler(keepVaryOrigin(auth.handler));
|
|
119
|
+
const matches = (req: IncomingMessage): boolean => {
|
|
120
|
+
const path = pathOf(req.url);
|
|
121
|
+
return path === basePath || path.startsWith(prefix);
|
|
122
|
+
};
|
|
123
|
+
const fail = (res: ServerResponse, error: unknown): void => {
|
|
124
|
+
if (res.headersSent) {
|
|
125
|
+
res.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
res.statusCode = 500;
|
|
129
|
+
res.setHeader("Content-Type", "application/json");
|
|
130
|
+
res.end(JSON.stringify({ error: "The auth routes failed", code: "INTERNAL_ERROR" }));
|
|
131
|
+
};
|
|
132
|
+
const { track } = options;
|
|
133
|
+
const serve = (req: IncomingMessage, res: ServerResponse): void => {
|
|
134
|
+
// A preflight ends in the CORS. A request of another origin goes on without CORS headers:
|
|
135
|
+
// the CSRF checks of Better Auth refuse a write of an origin that it does not trust
|
|
136
|
+
if (cors(req, res)) return;
|
|
137
|
+
if (!track) {
|
|
138
|
+
routes(req, res).catch((error: unknown) => fail(res, error));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// The tracking of the host: the request counts until its response closes, and the drain waits
|
|
142
|
+
// for it. The CORS headers are on the response already, so a page can read a refusal (503).
|
|
143
|
+
const path = pathOf(req.url);
|
|
144
|
+
const key = path.length > prefix.length ? path.slice(prefix.length) : "/";
|
|
145
|
+
track(req, res, key, () => routes(req, res)).catch((error: unknown) => fail(res, error));
|
|
146
|
+
};
|
|
147
|
+
const handler = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
148
|
+
if (!matches(req)) return false;
|
|
149
|
+
serve(req, res);
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
152
|
+
return Object.assign(handler, {
|
|
153
|
+
matches,
|
|
154
|
+
middleware: (req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void): void => {
|
|
155
|
+
if (!handler(req, res)) next();
|
|
156
|
+
},
|
|
157
|
+
basePath,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What the auth face needs of its host: the policy, for its trusted origins, and the request tracking. */
|
|
162
|
+
export interface BetterAuthFaceContext {
|
|
163
|
+
readonly policy: HostPolicy;
|
|
164
|
+
/** The request tracking of the host, on the route pattern of the face (`auth *`, CLI-54). */
|
|
165
|
+
readonly track?: AuthRequestTracker | undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The auth face: its request handler and its endpoint, for `host.info`. */
|
|
169
|
+
export interface BetterAuthFace {
|
|
170
|
+
readonly handler: (req: IncomingMessage, res: ServerResponse) => boolean;
|
|
171
|
+
readonly endpoint: { readonly type: string; readonly scheme: "http"; readonly path: string };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* This function gives the auth routes as a face of a procedures host (`createProcedureHost()` of
|
|
176
|
+
* `@procedures/peer`). The face takes the trusted origins of the policy of the host
|
|
177
|
+
* (`browser.allowedOrigins`), unless the options give other origins. Each request of the routes
|
|
178
|
+
* goes through the request tracking of the host (`track`, CLI-54). Thus a request after the stop
|
|
179
|
+
* of the intake gets 503, and the drain waits for a request in flight. The types are structural:
|
|
180
|
+
* this package does not depend on `@procedures/peer`.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```typescript
|
|
184
|
+
* const host = createProcedureHost({ registry, policy, faces: { http: true, auth: betterAuthFace(auth) }, listen });
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export function betterAuthFace(auth: Pick<BetterAuthServer, "handler">, options: BetterAuthHandlerOptions = {}): (context: BetterAuthFaceContext) => BetterAuthFace {
|
|
188
|
+
return (context) => {
|
|
189
|
+
const track = options.track ?? context.track;
|
|
190
|
+
const handler = betterAuthHandler(auth, {
|
|
191
|
+
...options,
|
|
192
|
+
allowedOrigins: options.allowedOrigins ?? context.policy.browser.allowedOrigins,
|
|
193
|
+
...(track ? { track } : {}),
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
handler: (req, res) => handler(req, res),
|
|
197
|
+
endpoint: { type: "auth", scheme: "http", path: handler.basePath },
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/index.ts
CHANGED
package/src/options.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The options of Better Auth for a configuration and a database adapter. `createBetterAuth` gives
|
|
3
|
+
* them the MongoDB adapter, and a test gives them the memory adapter. The package root does not
|
|
4
|
+
* export this module.
|
|
5
|
+
*/
|
|
6
|
+
import type { BetterAuthOptions } from "better-auth";
|
|
7
|
+
import { organization } from "better-auth/plugins";
|
|
8
|
+
import { passkey } from "@better-auth/passkey";
|
|
9
|
+
import type { BetterAuthConfig } from "./config.js";
|
|
10
|
+
|
|
11
|
+
/** This function gives the options of Better Auth for a configuration and a database adapter. */
|
|
12
|
+
export function betterAuthOptions(config: BetterAuthConfig, database: BetterAuthOptions["database"]): BetterAuthOptions {
|
|
13
|
+
const testHooks = {
|
|
14
|
+
user: {
|
|
15
|
+
create: {
|
|
16
|
+
before: async (user: Record<string, unknown>): Promise<{ data: Record<string, unknown> }> => ({ data: { ...user, emailVerified: true } }),
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
appName: config.appName,
|
|
22
|
+
basePath: config.basePath,
|
|
23
|
+
baseURL: config.baseURL,
|
|
24
|
+
secret: config.secret,
|
|
25
|
+
database: database,
|
|
26
|
+
trustedOrigins: [...config.trustedOrigins],
|
|
27
|
+
socialProviders: {
|
|
28
|
+
...(config.github ? { github: { ...config.github } } : {}),
|
|
29
|
+
...(config.google ? { google: { ...config.google } } : {}),
|
|
30
|
+
},
|
|
31
|
+
emailAndPassword: { enabled: config.testAccounts },
|
|
32
|
+
// A test account has no email check: the test server trusts its email
|
|
33
|
+
...(config.testAccounts ? { databaseHooks: testHooks } : {}),
|
|
34
|
+
// A page on another site sends a cookie only with SameSite=None, and a browser keeps such a cookie only when it is Secure (CLI-49)
|
|
35
|
+
...(config.crossSiteCookies === true ? { advanced: { defaultCookieAttributes: { sameSite: "none" as const, secure: true } } } : {}),
|
|
36
|
+
// The passkeys expect the origins of the app, not the Origin header of each request
|
|
37
|
+
plugins: [organization(), passkey({ rpID: config.rpID, rpName: config.appName, origin: [...config.trustedOrigins] })],
|
|
38
|
+
};
|
|
39
|
+
}
|