@multiplatform.one/auth 7.7.2

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/index.ts ADDED
@@ -0,0 +1,665 @@
1
+ /**
2
+ * @multiplatform.one/auth
3
+ *
4
+ * Better Auth helpers for multiplatform.one apps with Keycloak.
5
+ * Provides factory functions for auth instances, route handlers, and session helpers.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { createAuth, createAuthRouteHandler, createSessionHelpers } from "@multiplatform.one/auth";
10
+ *
11
+ * export const auth = createAuth();
12
+ * export const { GET, POST } = createAuthRouteHandler(auth);
13
+ * export const { verifyAuth, getSession } = createSessionHelpers(auth);
14
+ * ```
15
+ */
16
+
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+ import { betterAuth, matchesHostPattern } from "better-auth";
19
+ import type { BetterAuthPlugin } from "better-auth";
20
+ import {
21
+ APIError,
22
+ createAuthEndpoint,
23
+ createAuthMiddleware,
24
+ getSessionFromCtx,
25
+ isAPIError,
26
+ } from "better-auth/api";
27
+ import { deleteSessionCookie, getAccountCookie } from "better-auth/cookies";
28
+ import { genericOAuth, keycloak } from "better-auth/plugins";
29
+ import { expo } from "@better-auth/expo";
30
+ import { FAILED_TO_GET_ACCESS_TOKEN, TOKEN_REFRESH_UNAVAILABLE } from "./accessTokenRefresh";
31
+
32
+ export {
33
+ ACCESS_TOKEN_RETRY_DELAYS_MS,
34
+ FAILED_TO_GET_ACCESS_TOKEN,
35
+ TOKEN_REFRESH_UNAVAILABLE,
36
+ classifyAccessTokenResponse,
37
+ retryWhileTransient,
38
+ watchDocumentVisible,
39
+ } from "./accessTokenRefresh";
40
+ export type { AccessTokenFailureKind, RetryAttemptResult } from "./accessTokenRefresh";
41
+
42
+ /**
43
+ * Origin of the request currently being handled (dev only). better-auth's
44
+ * genericOAuth plugin builds OAuth redirect URIs from the context captured at
45
+ * INIT time, so a dev server reachable on several hosts (localhost + LAN IP +
46
+ * *.local) would pin callbacks to one of them — and the session cookie from a
47
+ * callback on the wrong host never reaches the origin the visitor is actually
48
+ * browsing. The route handler stores each request's origin here, and the dev
49
+ * redirect-URI getter (see createAuth) resolves against it per request.
50
+ */
51
+ const requestOriginStorage = new AsyncLocalStorage<string>();
52
+
53
+ /**
54
+ * The visitor origin for the request being handled. The app may sit behind
55
+ * the bench's dev proxy (browser → :8000 → one server :3456), where
56
+ * `req.url` shows the proxy target — so prefer `X-Forwarded-Proto/Host`
57
+ * (the bench proxy sets them for exactly this purpose), then the browser's
58
+ * `Origin` header, then the request URL. Forwarded/origin hosts are only
59
+ * honored when they match the private-network dev allowlist, so a spoofed
60
+ * header can never steer OAuth redirects at a public host.
61
+ */
62
+ function deriveRequestOrigin(req: Request): string | undefined {
63
+ const isDevHost = (host: string) =>
64
+ DEV_LAN_HOST_PATTERNS.some((pattern) => matchesHostPattern(host, pattern));
65
+
66
+ const forwardedHost = req.headers.get("x-forwarded-host");
67
+ if (forwardedHost && isDevHost(forwardedHost)) {
68
+ const proto = req.headers.get("x-forwarded-proto") === "https" ? "https" : "http";
69
+ return `${proto}://${forwardedHost}`;
70
+ }
71
+
72
+ const originHeader = req.headers.get("origin");
73
+ if (originHeader) {
74
+ try {
75
+ const url = new URL(originHeader);
76
+ if (isDevHost(url.host)) return url.origin;
77
+ } catch {
78
+ // fall through to the request URL
79
+ }
80
+ }
81
+
82
+ try {
83
+ return new URL(req.url).origin;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ // ─── Types ──────────────────────────────────────────────────────────
90
+
91
+ export interface KeycloakOptions {
92
+ baseUrl?: string;
93
+ realm?: string;
94
+ clientId?: string;
95
+ clientSecret?: string;
96
+ }
97
+
98
+ export interface CreateAuthOptions {
99
+ /** Base path for auth routes (default: "/api/auth") */
100
+ basePath?: string;
101
+ /** Base URL of the app (reads BASE_URL env var, or defaults to localhost:ONE_PORT) */
102
+ baseUrl?: string;
103
+ /** Auth secret (reads SECRET env var) */
104
+ secret?: string;
105
+ /** Trusted origins for CORS (auto-includes baseUrl and keycloak URL) */
106
+ trustedOrigins?: string[];
107
+ /** Keycloak configuration (reads env vars by default) */
108
+ keycloak?: KeycloakOptions;
109
+ /** Session cookie max age in seconds (default: 7 days) */
110
+ sessionMaxAge?: number;
111
+ /** Include @better-auth/expo plugin (default: true) */
112
+ enableExpo?: boolean;
113
+ /** Additional better-auth plugins */
114
+ plugins?: Parameters<typeof betterAuth>[0]["plugins"];
115
+ /**
116
+ * Database URL or Kysely adapter.
117
+ * Defaults to a SQLite file at `$DATA_DIR/better-auth.db` (or `./better-auth.db`).
118
+ */
119
+ database?: Parameters<typeof betterAuth>[0]["database"];
120
+ }
121
+
122
+ /** Structural type for a Better Auth instance used by helpers. */
123
+ export interface AuthLike {
124
+ handler: (request: Request) => Promise<Response> | Response;
125
+ api: {
126
+ getSession: (opts: { headers: Headers }) => Promise<unknown>;
127
+ };
128
+ }
129
+
130
+ // ─── createAuth ─────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Private-network host patterns (loopback, RFC1918 ranges, mDNS) that dev
134
+ * builds accept for LAN access — opening the app from another device (or the
135
+ * dev machine's own LAN IP, e.g. http://10.x.x.x:3456) must not 403 sign-in
136
+ * or build OAuth callbacks against localhost. Matched by better-auth's
137
+ * wildcard matcher against the request host (which includes the port).
138
+ * Never applied in production.
139
+ */
140
+ const DEV_LAN_HOST_PATTERNS = [
141
+ "localhost",
142
+ "localhost:*",
143
+ "127.0.0.1",
144
+ "127.0.0.1:*",
145
+ "10.*",
146
+ "192.168.*",
147
+ "172.*",
148
+ "*.local",
149
+ "*.local:*",
150
+ ];
151
+
152
+ // ─── Keycloak SSO plugin (front-channel logout + token resilience) ──
153
+
154
+ interface KeycloakSsoPluginOptions {
155
+ /** Realm name (for OIDC endpoint paths). */
156
+ realm: string;
157
+ /** OAuth client id sent to the end-session endpoint. */
158
+ clientId: string;
159
+ /** OAuth client secret (for the refresh-failure classification replay). */
160
+ clientSecret: string;
161
+ /** Backchannel issuer (server-to-server), e.g. http://localhost:8080/auth/realms/myrealm */
162
+ issuer: string;
163
+ /** Fallback visitor origin when none can be derived from the request. */
164
+ baseUrl: string;
165
+ /** Browser-facing Keycloak base for a visitor origin (proxy-aware). */
166
+ publicKeycloakBaseFor: (requestOrigin?: string) => string | undefined;
167
+ }
168
+
169
+ /** Minimal shape of a stored OAuth account used by the SSO plugin. */
170
+ interface KeycloakAccountLike {
171
+ providerId?: string;
172
+ idToken?: string | null;
173
+ refreshToken?: string | null;
174
+ }
175
+
176
+ /**
177
+ * Better Auth plugin wiring real single-sign-out and refresh resilience for
178
+ * the Keycloak provider:
179
+ *
180
+ * - `GET /sign-out/sso`: browser-navigable sign-out that ends the Better Auth
181
+ * session AND the Keycloak SSO session. It clears the local session, then
182
+ * 302s through Keycloak's end-session endpoint (same-origin /auth proxy,
183
+ * `id_token_hint` + `post_logout_redirect_uri`) so revisiting an SSO-first
184
+ * login page shows Keycloak's login form instead of silently re-logging in.
185
+ * Use `?callbackURL=/path` to choose the post-logout landing page.
186
+ * Native clients pass `?mode=json`: their Better Auth cookies live in a
187
+ * SecureStore jar (attachable to a fetch, but NOT to the system browser
188
+ * that holds the Keycloak SSO cookie), so instead of a 302 the endpoint
189
+ * answers `{ redirect, url }` — the same end-session URL, which the app
190
+ * then rides through the system browser itself. Session deletion and
191
+ * cookie clearing behave identically in both modes.
192
+ *
193
+ * - after-hook on `/get-access-token`: better-auth collapses every refresh
194
+ * failure into `FAILED_TO_GET_ACCESS_TOKEN` (400). We reclassify:
195
+ * • DEFINITIVE (`invalid_grant` / `invalid_token`, or no refresh token):
196
+ * revoke the Better Auth session, clear cookies, keep the 400 so
197
+ * clients sign out instead of silently degrading to guest requests.
198
+ * • TRANSIENT (Keycloak unreachable, 5xx, or the replay itself succeeded
199
+ * — the original failure happened after the token exchange): rewrite
200
+ * to `TOKEN_REFRESH_UNAVAILABLE` (5xx) and KEEP the session. Clients
201
+ * retry with backoff (see `retryWhileTransient`) so a Keycloak bounce
202
+ * never looks like a logout.
203
+ */
204
+ function createKeycloakSsoPlugin(options: KeycloakSsoPluginOptions) {
205
+ const { realm, clientId, clientSecret, issuer, baseUrl, publicKeycloakBaseFor } = options;
206
+ const tokenEndpoint = `${issuer}/protocol/openid-connect/token`;
207
+
208
+ /** Visitor origin for the current request (route-handler ALS, then headers). */
209
+ const resolveVisitorOrigin = (request?: Request): string | undefined =>
210
+ requestOriginStorage.getStore() ?? (request ? deriveRequestOrigin(request) : undefined);
211
+
212
+ /**
213
+ * Replay the refresh against Keycloak to classify a failure. Only an
214
+ * explicit OAuth `invalid_grant`/`invalid_token` answer is definitive —
215
+ * anything else (network error, 5xx, or even success, e.g. the original
216
+ * failure happened after the token exchange) is treated as transient.
217
+ */
218
+ const isRefreshDefinitivelyDead = async (refreshToken: string): Promise<boolean> => {
219
+ try {
220
+ const res = await fetch(tokenEndpoint, {
221
+ method: "POST",
222
+ headers: { "content-type": "application/x-www-form-urlencoded" },
223
+ body: new URLSearchParams({
224
+ grant_type: "refresh_token",
225
+ refresh_token: refreshToken,
226
+ client_id: clientId,
227
+ client_secret: clientSecret,
228
+ }),
229
+ signal: AbortSignal.timeout(5000),
230
+ });
231
+ if (res.ok) return false;
232
+ if (res.status !== 400 && res.status !== 401) return false;
233
+ const body = (await res.json().catch(() => null)) as { error?: string } | null;
234
+ return body?.error === "invalid_grant" || body?.error === "invalid_token";
235
+ } catch {
236
+ return false;
237
+ }
238
+ };
239
+
240
+ const signOutSso = createAuthEndpoint("/sign-out/sso", { method: "GET" }, async (ctx) => {
241
+ const visitorOrigin = resolveVisitorOrigin(ctx.request) ?? baseUrl;
242
+ const searchParams = ctx.request ? new URL(ctx.request.url).searchParams : undefined;
243
+ const rawCallback = searchParams?.get("callbackURL") ?? null;
244
+ // Native answer mode: JSON body instead of a 302 (see plugin doc above).
245
+ const jsonMode = searchParams?.get("mode") === "json";
246
+ const answer = (url: string, redirect: boolean) => {
247
+ if (jsonMode) return ctx.json({ redirect, url });
248
+ throw ctx.redirect(url);
249
+ };
250
+ const callbackURL = rawCallback || "/";
251
+ // OIDC requires an absolute post_logout_redirect_uri; app-relative paths
252
+ // resolve against the origin the visitor is actually on.
253
+ const postLogoutRedirect = callbackURL.startsWith("/")
254
+ ? `${visitorOrigin}${callbackURL}`
255
+ : callbackURL;
256
+
257
+ // Capture the id_token BEFORE the session dies: with it Keycloak logs
258
+ // out without a confirmation screen (expired id_tokens are accepted).
259
+ // Tokens are stored unencrypted here (account.encryptOAuthTokens is not
260
+ // enabled by createAuth); the account cookie covers sessions whose DB
261
+ // rows vanished (e.g. dev-server restart with the memory adapter).
262
+ let idTokenHint: string | undefined;
263
+ const sessionToken = await ctx.getSignedCookie(
264
+ ctx.context.authCookies.sessionToken.name,
265
+ ctx.context.secret,
266
+ );
267
+ const session = await getSessionFromCtx(ctx).catch(() => null);
268
+ if (session) {
269
+ const accounts = (await ctx.context.internalAdapter
270
+ .findAccounts(session.user.id)
271
+ .catch(() => [])) as KeycloakAccountLike[];
272
+ idTokenHint =
273
+ accounts.find((account) => account.providerId === "keycloak")?.idToken ?? undefined;
274
+ }
275
+ if (!idTokenHint) {
276
+ const accountCookie = (await getAccountCookie(ctx).catch(
277
+ () => null,
278
+ )) as KeycloakAccountLike | null;
279
+ if (accountCookie?.providerId === "keycloak" && accountCookie.idToken) {
280
+ idTokenHint = accountCookie.idToken;
281
+ }
282
+ }
283
+
284
+ if (sessionToken) {
285
+ await ctx.context.internalAdapter.deleteSession(sessionToken).catch((error: unknown) => {
286
+ ctx.context.logger.error("Failed to delete session during SSO sign-out", error);
287
+ });
288
+ }
289
+ deleteSessionCookie(ctx);
290
+
291
+ // Had a Better Auth session (cookie and/or id_token) → the browser must
292
+ // ride Keycloak's end-session endpoint. Skipping that when getSession
293
+ // misses but the session cookie was present is what made logout look
294
+ // like it stuck locally while the next Log in silently SSO'd back in.
295
+ // Without id_token_hint Keycloak may show a confirmation; still correct.
296
+ const keycloakBase = publicKeycloakBaseFor(visitorOrigin);
297
+ if (!keycloakBase || (!session && !idTokenHint && !sessionToken)) {
298
+ return answer(postLogoutRedirect, false);
299
+ }
300
+
301
+ const endSession = new URL(`${keycloakBase}/realms/${realm}/protocol/openid-connect/logout`);
302
+ endSession.searchParams.set("post_logout_redirect_uri", postLogoutRedirect);
303
+ endSession.searchParams.set("client_id", clientId);
304
+ if (idTokenHint) endSession.searchParams.set("id_token_hint", idTokenHint);
305
+ return answer(endSession.toString(), true);
306
+ });
307
+
308
+ return {
309
+ id: "keycloak-sso",
310
+ endpoints: {
311
+ signOutSso,
312
+ },
313
+ hooks: {
314
+ after: [
315
+ {
316
+ matcher: (ctx) => ctx.path === "/get-access-token",
317
+ handler: createAuthMiddleware(async (ctx) => {
318
+ const returned = ctx.context.returned;
319
+ if (!isAPIError(returned)) return;
320
+ const body = returned.body as { code?: string; message?: string } | undefined;
321
+ if (body?.code !== FAILED_TO_GET_ACCESS_TOKEN) return;
322
+ const providerId = (ctx.body as { providerId?: string } | undefined)?.providerId;
323
+ if (providerId && providerId !== "keycloak") return;
324
+
325
+ const session = await getSessionFromCtx(ctx).catch(() => null);
326
+ if (!session) return;
327
+
328
+ // Classify: prefer the stored account's refresh token, falling
329
+ // back to the account cookie (memory-adapter dev restarts). A
330
+ // session with no refresh token anywhere can never recover.
331
+ const accounts = (await ctx.context.internalAdapter
332
+ .findAccounts(session.user.id)
333
+ .catch(() => [])) as KeycloakAccountLike[];
334
+ let refreshToken = accounts.find(
335
+ (account) => account.providerId === "keycloak",
336
+ )?.refreshToken;
337
+ if (!refreshToken) {
338
+ const accountCookie = (await getAccountCookie(ctx).catch(
339
+ () => null,
340
+ )) as KeycloakAccountLike | null;
341
+ if (accountCookie?.providerId === "keycloak") {
342
+ refreshToken = accountCookie.refreshToken;
343
+ }
344
+ }
345
+ const definitive = refreshToken ? await isRefreshDefinitivelyDead(refreshToken) : true;
346
+ if (!definitive) {
347
+ // Distinct from FAILED_TO_GET_ACCESS_TOKEN: the session is still
348
+ // good, Keycloak is just bouncing. Clients retry this code and
349
+ // must not start the signed-out cooldown.
350
+ return new APIError("INTERNAL_SERVER_ERROR", {
351
+ message: "Keycloak token refresh temporarily unavailable",
352
+ code: TOKEN_REFRESH_UNAVAILABLE,
353
+ });
354
+ }
355
+
356
+ ctx.context.logger.warn(
357
+ "Keycloak refresh token is dead (invalid_grant); revoking the stale Better Auth session",
358
+ );
359
+ const sessionToken = await ctx.getSignedCookie(
360
+ ctx.context.authCookies.sessionToken.name,
361
+ ctx.context.secret,
362
+ );
363
+ if (sessionToken) {
364
+ await ctx.context.internalAdapter
365
+ .deleteSession(sessionToken)
366
+ .catch((error: unknown) => {
367
+ ctx.context.logger.error("Failed to delete stale session", error);
368
+ });
369
+ }
370
+ deleteSessionCookie(ctx);
371
+ // Replace the returned error so the cookie deletions ride its OWN
372
+ // headers too: when the failing endpoint accumulated no response
373
+ // headers, better-call's toResponse falls back to the APIError's
374
+ // headers — without this the clearing Set-Cookie would be lost.
375
+ const headers = new Headers();
376
+ const accumulated = (ctx as unknown as { responseHeaders?: Headers }).responseHeaders;
377
+ accumulated?.getSetCookie().forEach((cookie) => {
378
+ headers.append("set-cookie", cookie);
379
+ });
380
+ return new APIError(
381
+ "BAD_REQUEST",
382
+ {
383
+ message: body?.message || "Failed to get a valid access token",
384
+ code: FAILED_TO_GET_ACCESS_TOKEN,
385
+ },
386
+ headers,
387
+ );
388
+ }),
389
+ },
390
+ ],
391
+ },
392
+ } satisfies BetterAuthPlugin;
393
+ }
394
+
395
+ /**
396
+ * Create a Better Auth instance configured for Keycloak.
397
+ * Keycloak is expected at ${BASE_URL}/auth (proxied by Frappe in dev).
398
+ */
399
+ export function createAuth(options: CreateAuthOptions = {}) {
400
+ const baseUrl =
401
+ options.baseUrl || process.env.BASE_URL || `http://localhost:${process.env.ONE_PORT || "3000"}`;
402
+ const isProduction = process.env.NODE_ENV === "production";
403
+
404
+ // In production Keycloak is proxied at /auth by Frappe, so default to ${baseUrl}/auth.
405
+ // In dev (no proxy), use KEYCLOAK_BACKCHANNEL_URL (server-to-server direct address).
406
+ const keycloakBaseUrl =
407
+ options.keycloak?.baseUrl || process.env.KEYCLOAK_BACKCHANNEL_URL || `${baseUrl}/auth`;
408
+ const keycloakRealm = options.keycloak?.realm || process.env.KEYCLOAK_REALM || "master";
409
+ const issuer = `${keycloakBaseUrl}/realms/${keycloakRealm}`;
410
+
411
+ const keycloakClientId = options.keycloak?.clientId || process.env.KEYCLOAK_CLIENT_ID || "";
412
+ const keycloakClientSecret =
413
+ options.keycloak?.clientSecret || process.env.KEYCLOAK_CLIENT_SECRET || "";
414
+
415
+ const expoPlugin = options.enableExpo !== false ? expo() : undefined;
416
+
417
+ const basePath = options.basePath || "/api/auth";
418
+
419
+ const keycloakProviderConfig = {
420
+ ...keycloak({
421
+ clientId: keycloakClientId,
422
+ clientSecret: keycloakClientSecret,
423
+ issuer,
424
+ }),
425
+ // Backchannel token/userinfo: `/get-access-token` refresh keeps these
426
+ // when discovery fails (Keycloak restart), so we don't depend on the
427
+ // well-known document coming up in the same instant as the token
428
+ // endpoint. Browser-facing authorize/issuer stay on the public /auth
429
+ // proxy (getters below).
430
+ tokenUrl: `${issuer}/protocol/openid-connect/token`,
431
+ userInfoUrl: `${issuer}/protocol/openid-connect/userinfo`,
432
+ // Wallet (SIWE-style) users authenticate by signature and have NO email —
433
+ // Keycloak issues them a username = wallet address and no email claim.
434
+ // better-auth's genericOAuth callback hard-rejects a user with no email
435
+ // ("email_is_missing"), so synthesize a stable, unique placeholder from the
436
+ // wallet address / subject. `.wallet` is a non-routable placeholder domain;
437
+ // the address stays the stable identity.
438
+ mapProfileToUser: (profile: Record<string, any>) => {
439
+ const handle = profile.preferred_username || profile.sub || profile.id || profile.name;
440
+ const email = profile.email || (handle ? `${handle}@wallet` : undefined);
441
+ return {
442
+ ...profile,
443
+ email,
444
+ emailVerified: profile.email_verified ?? true,
445
+ name: profile.name || handle,
446
+ };
447
+ },
448
+ };
449
+ // Path prefix (e.g. /auth) signals the same-origin proxy pattern; a bare
450
+ // Keycloak origin keeps the discovery-resolved absolute URL.
451
+ const keycloakPublicPath = (() => {
452
+ try {
453
+ const path = new URL(keycloakBaseUrl).pathname.replace(/\/+$/, "");
454
+ return path && path !== "/" ? path : undefined;
455
+ } catch {
456
+ return undefined;
457
+ }
458
+ })();
459
+
460
+ /**
461
+ * Browser-facing Keycloak base for a visitor origin. With the /auth proxy
462
+ * pattern this is the canonical app URL's proxy (baseUrl + path); in dev
463
+ * only the HOSTNAME follows the visitor so every app on a hostname shares
464
+ * one Keycloak SSO session (see the authorization-URL getter below). A
465
+ * bare-origin Keycloak (no proxy) is public already.
466
+ */
467
+ const publicKeycloakBaseFor = (requestOrigin?: string): string | undefined => {
468
+ if (!keycloakPublicPath) return keycloakBaseUrl;
469
+ try {
470
+ const url = new URL(baseUrl);
471
+ if (!isProduction && requestOrigin) {
472
+ url.hostname = new URL(requestOrigin).hostname;
473
+ }
474
+ return `${url.origin}${keycloakPublicPath}`;
475
+ } catch {
476
+ return undefined;
477
+ }
478
+ };
479
+
480
+ if (!isProduction) {
481
+ // Dev: the OAuth redirect URI follows the origin of the request being
482
+ // handled (localhost, LAN IP, *.local — see requestOriginStorage), so the
483
+ // callback and its session cookie land on the host the visitor is on.
484
+ // `options.redirectURI` wins over the plugin's init-pinned fallback in
485
+ // both createAuthorizationURL and validateAuthorizationCode, and the
486
+ // getter is evaluated per call. Falls back to the plugin default (static
487
+ // baseURL) when no request is in scope.
488
+ Object.defineProperty(keycloakProviderConfig, "redirectURI", {
489
+ enumerable: true,
490
+ get() {
491
+ const origin = requestOriginStorage.getStore();
492
+ return origin ? `${origin}${basePath}/oauth2/callback/keycloak` : undefined;
493
+ },
494
+ });
495
+
496
+ // Dev: the browser-facing AUTHORIZATION endpoint rides Keycloak's ONE
497
+ // public home per hostname — the canonical app URL's /auth proxy (the
498
+ // bench) — with only the HOSTNAME following the visitor. Pointing each
499
+ // app at its own origin's proxy instead would mint Keycloak identity
500
+ // cookies bound to per-PORT issuers, so a login on the app (:3456)
501
+ // would not be recognized by the bench flow (:8000) and vice versa —
502
+ // the user gets asked for a password twice. One public origin per
503
+ // hostname = one Keycloak SSO session shared by every app on that
504
+ // hostname (cookies ignore ports), still reachable from any LAN device.
505
+ // Only under the same-origin proxy pattern (path-prefixed Keycloak).
506
+ // `/sign-in/social` (the path the web + native clients use) prefers
507
+ // an explicit `authorizationUrl` and falls back to discovery when the
508
+ // getter yields undefined (no request in scope). `/sign-in/oauth2`
509
+ // still overwrites with discovery — that route is unused here.
510
+ // Token/userinfo exchanges are server-side: we pin `tokenUrl` /
511
+ // `userInfoUrl` to the BACKCHANNEL so a discovery blip during a
512
+ // Keycloak restart cannot block refresh (refreshAccessToken overwrites
513
+ // with discovery when discovery SUCCEEDS, and keeps tokenUrl when it
514
+ // fails).
515
+ /** Canonical public Keycloak base (baseUrl + path), on the visitor's hostname. */
516
+ const keycloakPublicBase = (): string | undefined => {
517
+ const origin = requestOriginStorage.getStore();
518
+ if (!origin || !keycloakPublicPath) return undefined;
519
+ return publicKeycloakBaseFor(origin);
520
+ };
521
+ if (keycloakPublicPath) {
522
+ Object.defineProperty(keycloakProviderConfig, "authorizationUrl", {
523
+ enumerable: true,
524
+ get() {
525
+ const base = keycloakPublicBase();
526
+ return base ? `${base}/realms/${keycloakRealm}/protocol/openid-connect/auth` : undefined;
527
+ },
528
+ });
529
+ // Keycloak derives its issuer from the forwarded visitor host (the
530
+ // canonical /auth proxy forwards it so KC's login pages stay on the
531
+ // visitor's hostname), so the RFC 9207 `iss` on the callback follows
532
+ // the same public base. Explicit `issuer` wins over the discovery
533
+ // document's in the callback's issuer check; undefined (no request
534
+ // in scope) falls back to discovery. better-auth performs no
535
+ // id_token iss check.
536
+ Object.defineProperty(keycloakProviderConfig, "issuer", {
537
+ enumerable: true,
538
+ get() {
539
+ const base = keycloakPublicBase();
540
+ return base ? `${base}/realms/${keycloakRealm}` : undefined;
541
+ },
542
+ });
543
+ }
544
+ }
545
+
546
+ // Passed by reference (NOT spread): spreading would evaluate the dev
547
+ // redirectURI getter once at startup instead of per request.
548
+ const oauthPlugin = genericOAuth({
549
+ config: [keycloakProviderConfig as Parameters<typeof genericOAuth>[0]["config"][number]],
550
+ });
551
+
552
+ const keycloakSsoPlugin = createKeycloakSsoPlugin({
553
+ realm: keycloakRealm,
554
+ clientId: keycloakClientId,
555
+ clientSecret: keycloakClientSecret,
556
+ issuer,
557
+ baseUrl,
558
+ publicKeycloakBaseFor,
559
+ });
560
+
561
+ const plugins: any[] = [
562
+ ...(expoPlugin ? [expoPlugin] : []),
563
+ oauthPlugin,
564
+ keycloakSsoPlugin,
565
+ ...(options.plugins ?? []),
566
+ ];
567
+
568
+ // Database for session/account storage.
569
+ // Reads DATABASE_URL env var, or uses an explicit option.
570
+ const database = options.database ?? (process.env.DATABASE_URL || undefined);
571
+
572
+ return betterAuth({
573
+ basePath,
574
+ // Static on purpose: better-auth's dynamic `baseURL` config (allowedHosts)
575
+ // is not honored by the genericOAuth plugin — it pins redirect URIs at
576
+ // init time, which turned them relative and bounced callbacks to the
577
+ // Keycloak host. Dev multi-host support comes from the per-request
578
+ // redirectURI getter above + the LAN trustedOrigins below instead.
579
+ baseURL: baseUrl,
580
+ ...(database ? { database } : {}),
581
+ secret: options.secret || process.env.SECRET || "",
582
+ trustedOrigins: options.trustedOrigins || [
583
+ baseUrl,
584
+ keycloakBaseUrl,
585
+ // In dev, ONE_PORT may differ from BASE_URL; ensure the dev server origin is trusted.
586
+ ...(process.env.ONE_PORT && !baseUrl.includes(`:${process.env.ONE_PORT}`)
587
+ ? [`http://localhost:${process.env.ONE_PORT}`]
588
+ : []),
589
+ "multiplatform-one://",
590
+ // Expo Go (dev) receives OAuth returns on the exp:// scheme — the app
591
+ // derives its callbackURL via Linking.createURL, which yields
592
+ // exp://<host>:<port>/--/ there. Never trusted in production builds.
593
+ // Dev also trusts private-network origins (see DEV_LAN_HOST_PATTERNS)
594
+ // so sign-in from a phone / the machine's LAN IP is not rejected.
595
+ ...(isProduction ? [] : ["exp://", ...DEV_LAN_HOST_PATTERNS.map((host) => `http://${host}`)]),
596
+ ],
597
+ session: {
598
+ cookieCache: {
599
+ enabled: true,
600
+ maxAge: options.sessionMaxAge ?? 7 * 24 * 60 * 60,
601
+ strategy: "jwe",
602
+ refreshCache: true,
603
+ },
604
+ },
605
+ account: {
606
+ storeStateStrategy: "cookie",
607
+ storeAccountCookie: true,
608
+ },
609
+ plugins,
610
+ });
611
+ }
612
+
613
+ /** The return type of `createAuth()`. */
614
+ export type AuthInstance = ReturnType<typeof createAuth>;
615
+
616
+ // ─── createAuthRouteHandler ─────────────────────────────────────────
617
+
618
+ /**
619
+ * Create GET and POST route handlers for One framework `+api.ts` files.
620
+ *
621
+ * @example
622
+ * ```ts
623
+ * // lib/server/auth.ts
624
+ * export const { GET, POST } = createAuthRouteHandler(auth);
625
+ *
626
+ * // routes/api/auth/[route]+api.ts
627
+ * export { GET, POST } from "../../../lib/server/auth";
628
+ * ```
629
+ */
630
+ export function createAuthRouteHandler(auth: AuthLike) {
631
+ // Every auth request runs inside an AsyncLocalStorage scope carrying its
632
+ // visitor origin (proxy-aware; see deriveRequestOrigin), so the dev
633
+ // redirect-URI/authorization-URL getters (createAuth) can follow the host
634
+ // the visitor is actually on (survives the awaits inside better-auth).
635
+ const handler = (req: Request) => {
636
+ const origin = deriveRequestOrigin(req);
637
+ return origin ? requestOriginStorage.run(origin, () => auth.handler(req)) : auth.handler(req);
638
+ };
639
+ return { GET: handler, POST: handler } as const;
640
+ }
641
+
642
+ // ─── Session helpers ────────────────────────────────────────────────
643
+
644
+ /**
645
+ * Create session verification helpers bound to an auth instance.
646
+ *
647
+ * @returns `verifyAuth` (throws 401 Response) and `getSession` (returns session or null)
648
+ */
649
+ export function createSessionHelpers(auth: AuthLike) {
650
+ /** Get session from request. Returns session or null when unauthenticated. */
651
+ async function getSession(req: Request) {
652
+ return auth.api.getSession({ headers: req.headers });
653
+ }
654
+
655
+ /** Verify auth session. Returns session or throws a 401 Response. */
656
+ async function verifyAuth(req: Request) {
657
+ const session = await auth.api.getSession({ headers: req.headers });
658
+ if (!session) {
659
+ throw new Response("Unauthorized", { status: 401 });
660
+ }
661
+ return session;
662
+ }
663
+
664
+ return { verifyAuth, getSession };
665
+ }