@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.
@@ -0,0 +1,369 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all) __defProp(target, name, {
7
+ get: all[name],
8
+ enumerable: true
9
+ });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
14
+ get: () => from[key],
15
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
16
+ });
17
+ }
18
+ return to;
19
+ };
20
+ var __toCommonJS = mod => __copyProps(__defProp({}, "__esModule", {
21
+ value: true
22
+ }), mod);
23
+ var index_exports = {};
24
+ __export(index_exports, {
25
+ ACCESS_TOKEN_RETRY_DELAYS_MS: () => import_accessTokenRefresh2.ACCESS_TOKEN_RETRY_DELAYS_MS,
26
+ FAILED_TO_GET_ACCESS_TOKEN: () => import_accessTokenRefresh2.FAILED_TO_GET_ACCESS_TOKEN,
27
+ TOKEN_REFRESH_UNAVAILABLE: () => import_accessTokenRefresh2.TOKEN_REFRESH_UNAVAILABLE,
28
+ classifyAccessTokenResponse: () => import_accessTokenRefresh2.classifyAccessTokenResponse,
29
+ createAuth: () => createAuth,
30
+ createAuthRouteHandler: () => createAuthRouteHandler,
31
+ createSessionHelpers: () => createSessionHelpers,
32
+ retryWhileTransient: () => import_accessTokenRefresh2.retryWhileTransient,
33
+ watchDocumentVisible: () => import_accessTokenRefresh2.watchDocumentVisible
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+ var import_node_async_hooks = require("node:async_hooks");
37
+ var import_better_auth = require("better-auth");
38
+ var import_api = require("better-auth/api");
39
+ var import_cookies = require("better-auth/cookies");
40
+ var import_plugins = require("better-auth/plugins");
41
+ var import_expo = require("@better-auth/expo");
42
+ var import_accessTokenRefresh = require("./accessTokenRefresh.cjs");
43
+ var import_accessTokenRefresh2 = require("./accessTokenRefresh.cjs");
44
+ const requestOriginStorage = new import_node_async_hooks.AsyncLocalStorage();
45
+ function deriveRequestOrigin(req) {
46
+ const isDevHost = host => DEV_LAN_HOST_PATTERNS.some(pattern => (0, import_better_auth.matchesHostPattern)(host, pattern));
47
+ const forwardedHost = req.headers.get("x-forwarded-host");
48
+ if (forwardedHost && isDevHost(forwardedHost)) {
49
+ const proto = req.headers.get("x-forwarded-proto") === "https" ? "https" : "http";
50
+ return `${proto}://${forwardedHost}`;
51
+ }
52
+ const originHeader = req.headers.get("origin");
53
+ if (originHeader) {
54
+ try {
55
+ const url = new URL(originHeader);
56
+ if (isDevHost(url.host)) return url.origin;
57
+ } catch {}
58
+ }
59
+ try {
60
+ return new URL(req.url).origin;
61
+ } catch {
62
+ return void 0;
63
+ }
64
+ }
65
+ const DEV_LAN_HOST_PATTERNS = ["localhost", "localhost:*", "127.0.0.1", "127.0.0.1:*", "10.*", "192.168.*", "172.*", "*.local", "*.local:*"];
66
+ function createKeycloakSsoPlugin(options) {
67
+ const {
68
+ realm,
69
+ clientId,
70
+ clientSecret,
71
+ issuer,
72
+ baseUrl,
73
+ publicKeycloakBaseFor
74
+ } = options;
75
+ const tokenEndpoint = `${issuer}/protocol/openid-connect/token`;
76
+ const resolveVisitorOrigin = request => requestOriginStorage.getStore() ?? (request ? deriveRequestOrigin(request) : void 0);
77
+ const isRefreshDefinitivelyDead = async refreshToken => {
78
+ try {
79
+ const res = await fetch(tokenEndpoint, {
80
+ method: "POST",
81
+ headers: {
82
+ "content-type": "application/x-www-form-urlencoded"
83
+ },
84
+ body: new URLSearchParams({
85
+ grant_type: "refresh_token",
86
+ refresh_token: refreshToken,
87
+ client_id: clientId,
88
+ client_secret: clientSecret
89
+ }),
90
+ signal: AbortSignal.timeout(5e3)
91
+ });
92
+ if (res.ok) return false;
93
+ if (res.status !== 400 && res.status !== 401) return false;
94
+ const body = await res.json().catch(() => null);
95
+ return body?.error === "invalid_grant" || body?.error === "invalid_token";
96
+ } catch {
97
+ return false;
98
+ }
99
+ };
100
+ const signOutSso = (0, import_api.createAuthEndpoint)("/sign-out/sso", {
101
+ method: "GET"
102
+ }, async ctx => {
103
+ const visitorOrigin = resolveVisitorOrigin(ctx.request) ?? baseUrl;
104
+ const searchParams = ctx.request ? new URL(ctx.request.url).searchParams : void 0;
105
+ const rawCallback = searchParams?.get("callbackURL") ?? null;
106
+ const jsonMode = searchParams?.get("mode") === "json";
107
+ const answer = (url, redirect) => {
108
+ if (jsonMode) return ctx.json({
109
+ redirect,
110
+ url
111
+ });
112
+ throw ctx.redirect(url);
113
+ };
114
+ const callbackURL = rawCallback || "/";
115
+ const postLogoutRedirect = callbackURL.startsWith("/") ? `${visitorOrigin}${callbackURL}` : callbackURL;
116
+ let idTokenHint;
117
+ const sessionToken = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
118
+ const session = await (0, import_api.getSessionFromCtx)(ctx).catch(() => null);
119
+ if (session) {
120
+ const accounts = await ctx.context.internalAdapter.findAccounts(session.user.id).catch(() => []);
121
+ idTokenHint = accounts.find(account => account.providerId === "keycloak")?.idToken ?? void 0;
122
+ }
123
+ if (!idTokenHint) {
124
+ const accountCookie = await (0, import_cookies.getAccountCookie)(ctx).catch(() => null);
125
+ if (accountCookie?.providerId === "keycloak" && accountCookie.idToken) {
126
+ idTokenHint = accountCookie.idToken;
127
+ }
128
+ }
129
+ if (sessionToken) {
130
+ await ctx.context.internalAdapter.deleteSession(sessionToken).catch(error => {
131
+ ctx.context.logger.error("Failed to delete session during SSO sign-out", error);
132
+ });
133
+ }
134
+ (0, import_cookies.deleteSessionCookie)(ctx);
135
+ const keycloakBase = publicKeycloakBaseFor(visitorOrigin);
136
+ if (!keycloakBase || !session && !idTokenHint && !sessionToken) {
137
+ return answer(postLogoutRedirect, false);
138
+ }
139
+ const endSession = new URL(`${keycloakBase}/realms/${realm}/protocol/openid-connect/logout`);
140
+ endSession.searchParams.set("post_logout_redirect_uri", postLogoutRedirect);
141
+ endSession.searchParams.set("client_id", clientId);
142
+ if (idTokenHint) endSession.searchParams.set("id_token_hint", idTokenHint);
143
+ return answer(endSession.toString(), true);
144
+ });
145
+ return {
146
+ id: "keycloak-sso",
147
+ endpoints: {
148
+ signOutSso
149
+ },
150
+ hooks: {
151
+ after: [{
152
+ matcher: ctx => ctx.path === "/get-access-token",
153
+ handler: (0, import_api.createAuthMiddleware)(async ctx => {
154
+ const returned = ctx.context.returned;
155
+ if (!(0, import_api.isAPIError)(returned)) return;
156
+ const body = returned.body;
157
+ if (body?.code !== import_accessTokenRefresh.FAILED_TO_GET_ACCESS_TOKEN) return;
158
+ const providerId = ctx.body?.providerId;
159
+ if (providerId && providerId !== "keycloak") return;
160
+ const session = await (0, import_api.getSessionFromCtx)(ctx).catch(() => null);
161
+ if (!session) return;
162
+ const accounts = await ctx.context.internalAdapter.findAccounts(session.user.id).catch(() => []);
163
+ let refreshToken = accounts.find(account => account.providerId === "keycloak")?.refreshToken;
164
+ if (!refreshToken) {
165
+ const accountCookie = await (0, import_cookies.getAccountCookie)(ctx).catch(() => null);
166
+ if (accountCookie?.providerId === "keycloak") {
167
+ refreshToken = accountCookie.refreshToken;
168
+ }
169
+ }
170
+ const definitive = refreshToken ? await isRefreshDefinitivelyDead(refreshToken) : true;
171
+ if (!definitive) {
172
+ return new import_api.APIError("INTERNAL_SERVER_ERROR", {
173
+ message: "Keycloak token refresh temporarily unavailable",
174
+ code: import_accessTokenRefresh.TOKEN_REFRESH_UNAVAILABLE
175
+ });
176
+ }
177
+ ctx.context.logger.warn("Keycloak refresh token is dead (invalid_grant); revoking the stale Better Auth session");
178
+ const sessionToken = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
179
+ if (sessionToken) {
180
+ await ctx.context.internalAdapter.deleteSession(sessionToken).catch(error => {
181
+ ctx.context.logger.error("Failed to delete stale session", error);
182
+ });
183
+ }
184
+ (0, import_cookies.deleteSessionCookie)(ctx);
185
+ const headers = new Headers();
186
+ const accumulated = ctx.responseHeaders;
187
+ accumulated?.getSetCookie().forEach(cookie => {
188
+ headers.append("set-cookie", cookie);
189
+ });
190
+ return new import_api.APIError("BAD_REQUEST", {
191
+ message: body?.message || "Failed to get a valid access token",
192
+ code: import_accessTokenRefresh.FAILED_TO_GET_ACCESS_TOKEN
193
+ }, headers);
194
+ })
195
+ }]
196
+ }
197
+ };
198
+ }
199
+ function createAuth(options = {}) {
200
+ const baseUrl = options.baseUrl || process.env.BASE_URL || `http://localhost:${process.env.ONE_PORT || "3000"}`;
201
+ const isProduction = process.env.NODE_ENV === "production";
202
+ const keycloakBaseUrl = options.keycloak?.baseUrl || process.env.KEYCLOAK_BACKCHANNEL_URL || `${baseUrl}/auth`;
203
+ const keycloakRealm = options.keycloak?.realm || process.env.KEYCLOAK_REALM || "master";
204
+ const issuer = `${keycloakBaseUrl}/realms/${keycloakRealm}`;
205
+ const keycloakClientId = options.keycloak?.clientId || process.env.KEYCLOAK_CLIENT_ID || "";
206
+ const keycloakClientSecret = options.keycloak?.clientSecret || process.env.KEYCLOAK_CLIENT_SECRET || "";
207
+ const expoPlugin = options.enableExpo !== false ? (0, import_expo.expo)() : void 0;
208
+ const basePath = options.basePath || "/api/auth";
209
+ const keycloakProviderConfig = {
210
+ ...(0, import_plugins.keycloak)({
211
+ clientId: keycloakClientId,
212
+ clientSecret: keycloakClientSecret,
213
+ issuer
214
+ }),
215
+ // Backchannel token/userinfo: `/get-access-token` refresh keeps these
216
+ // when discovery fails (Keycloak restart), so we don't depend on the
217
+ // well-known document coming up in the same instant as the token
218
+ // endpoint. Browser-facing authorize/issuer stay on the public /auth
219
+ // proxy (getters below).
220
+ tokenUrl: `${issuer}/protocol/openid-connect/token`,
221
+ userInfoUrl: `${issuer}/protocol/openid-connect/userinfo`,
222
+ // Wallet (SIWE-style) users authenticate by signature and have NO email —
223
+ // Keycloak issues them a username = wallet address and no email claim.
224
+ // better-auth's genericOAuth callback hard-rejects a user with no email
225
+ // ("email_is_missing"), so synthesize a stable, unique placeholder from the
226
+ // wallet address / subject. `.wallet` is a non-routable placeholder domain;
227
+ // the address stays the stable identity.
228
+ mapProfileToUser: profile => {
229
+ const handle = profile.preferred_username || profile.sub || profile.id || profile.name;
230
+ const email = profile.email || (handle ? `${handle}@wallet` : void 0);
231
+ return {
232
+ ...profile,
233
+ email,
234
+ emailVerified: profile.email_verified ?? true,
235
+ name: profile.name || handle
236
+ };
237
+ }
238
+ };
239
+ const keycloakPublicPath = (() => {
240
+ try {
241
+ const path = new URL(keycloakBaseUrl).pathname.replace(/\/+$/, "");
242
+ return path && path !== "/" ? path : void 0;
243
+ } catch {
244
+ return void 0;
245
+ }
246
+ })();
247
+ const publicKeycloakBaseFor = requestOrigin => {
248
+ if (!keycloakPublicPath) return keycloakBaseUrl;
249
+ try {
250
+ const url = new URL(baseUrl);
251
+ if (!isProduction && requestOrigin) {
252
+ url.hostname = new URL(requestOrigin).hostname;
253
+ }
254
+ return `${url.origin}${keycloakPublicPath}`;
255
+ } catch {
256
+ return void 0;
257
+ }
258
+ };
259
+ if (!isProduction) {
260
+ Object.defineProperty(keycloakProviderConfig, "redirectURI", {
261
+ enumerable: true,
262
+ get() {
263
+ const origin = requestOriginStorage.getStore();
264
+ return origin ? `${origin}${basePath}/oauth2/callback/keycloak` : void 0;
265
+ }
266
+ });
267
+ const keycloakPublicBase = () => {
268
+ const origin = requestOriginStorage.getStore();
269
+ if (!origin || !keycloakPublicPath) return void 0;
270
+ return publicKeycloakBaseFor(origin);
271
+ };
272
+ if (keycloakPublicPath) {
273
+ Object.defineProperty(keycloakProviderConfig, "authorizationUrl", {
274
+ enumerable: true,
275
+ get() {
276
+ const base = keycloakPublicBase();
277
+ return base ? `${base}/realms/${keycloakRealm}/protocol/openid-connect/auth` : void 0;
278
+ }
279
+ });
280
+ Object.defineProperty(keycloakProviderConfig, "issuer", {
281
+ enumerable: true,
282
+ get() {
283
+ const base = keycloakPublicBase();
284
+ return base ? `${base}/realms/${keycloakRealm}` : void 0;
285
+ }
286
+ });
287
+ }
288
+ }
289
+ const oauthPlugin = (0, import_plugins.genericOAuth)({
290
+ config: [keycloakProviderConfig]
291
+ });
292
+ const keycloakSsoPlugin = createKeycloakSsoPlugin({
293
+ realm: keycloakRealm,
294
+ clientId: keycloakClientId,
295
+ clientSecret: keycloakClientSecret,
296
+ issuer,
297
+ baseUrl,
298
+ publicKeycloakBaseFor
299
+ });
300
+ const plugins = [...(expoPlugin ? [expoPlugin] : []), oauthPlugin, keycloakSsoPlugin, ...(options.plugins ?? [])];
301
+ const database = options.database ?? (process.env.DATABASE_URL || void 0);
302
+ return (0, import_better_auth.betterAuth)({
303
+ basePath,
304
+ // Static on purpose: better-auth's dynamic `baseURL` config (allowedHosts)
305
+ // is not honored by the genericOAuth plugin — it pins redirect URIs at
306
+ // init time, which turned them relative and bounced callbacks to the
307
+ // Keycloak host. Dev multi-host support comes from the per-request
308
+ // redirectURI getter above + the LAN trustedOrigins below instead.
309
+ baseURL: baseUrl,
310
+ ...(database ? {
311
+ database
312
+ } : {}),
313
+ secret: options.secret || process.env.SECRET || "",
314
+ trustedOrigins: options.trustedOrigins || [baseUrl, keycloakBaseUrl,
315
+ // In dev, ONE_PORT may differ from BASE_URL; ensure the dev server origin is trusted.
316
+ ...(process.env.ONE_PORT && !baseUrl.includes(`:${process.env.ONE_PORT}`) ? [`http://localhost:${process.env.ONE_PORT}`] : []), "multiplatform-one://",
317
+ // Expo Go (dev) receives OAuth returns on the exp:// scheme — the app
318
+ // derives its callbackURL via Linking.createURL, which yields
319
+ // exp://<host>:<port>/--/ there. Never trusted in production builds.
320
+ // Dev also trusts private-network origins (see DEV_LAN_HOST_PATTERNS)
321
+ // so sign-in from a phone / the machine's LAN IP is not rejected.
322
+ ...(isProduction ? [] : ["exp://", ...DEV_LAN_HOST_PATTERNS.map(host => `http://${host}`)])],
323
+ session: {
324
+ cookieCache: {
325
+ enabled: true,
326
+ maxAge: options.sessionMaxAge ?? 7 * 24 * 60 * 60,
327
+ strategy: "jwe",
328
+ refreshCache: true
329
+ }
330
+ },
331
+ account: {
332
+ storeStateStrategy: "cookie",
333
+ storeAccountCookie: true
334
+ },
335
+ plugins
336
+ });
337
+ }
338
+ function createAuthRouteHandler(auth) {
339
+ const handler = req => {
340
+ const origin = deriveRequestOrigin(req);
341
+ return origin ? requestOriginStorage.run(origin, () => auth.handler(req)) : auth.handler(req);
342
+ };
343
+ return {
344
+ GET: handler,
345
+ POST: handler
346
+ };
347
+ }
348
+ function createSessionHelpers(auth) {
349
+ async function getSession(req) {
350
+ return auth.api.getSession({
351
+ headers: req.headers
352
+ });
353
+ }
354
+ async function verifyAuth(req) {
355
+ const session = await auth.api.getSession({
356
+ headers: req.headers
357
+ });
358
+ if (!session) {
359
+ throw new Response("Unauthorized", {
360
+ status: 401
361
+ });
362
+ }
363
+ return session;
364
+ }
365
+ return {
366
+ verifyAuth,
367
+ getSession
368
+ };
369
+ }