@webjsdev/server 0.8.91 → 0.10.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.
Files changed (105) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +461 -102
  3. package/index.js +9 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-input.js +208 -0
  7. package/src/action-params.js +156 -0
  8. package/src/action-route.js +8 -9
  9. package/src/action-seed.js +44 -5
  10. package/src/actions.js +112 -15
  11. package/src/api.js +67 -10
  12. package/src/app-layout.js +133 -0
  13. package/src/asset-hash.js +82 -49
  14. package/src/auth.js +612 -108
  15. package/src/browser-entries.js +2 -17
  16. package/src/build-epoch.js +1 -1
  17. package/src/cache-tags.js +5 -1
  18. package/src/check/helpers.js +85 -0
  19. package/src/check/rules-actions.js +37 -1
  20. package/src/check/rules-components.js +133 -28
  21. package/src/check/rules-imports.js +175 -4
  22. package/src/check/rules-route-modules.js +200 -0
  23. package/src/check/rules-routes.js +55 -0
  24. package/src/check/rules-routing.js +143 -95
  25. package/src/check/rules-typescript.js +24 -2
  26. package/src/check/rules.js +109 -9
  27. package/src/check/runner-support.js +53 -171
  28. package/src/check/runner.js +24 -6
  29. package/src/client-signals.js +171 -0
  30. package/src/component-scanner.js +22 -16
  31. package/src/context.js +152 -16
  32. package/src/cookie-store.js +262 -0
  33. package/src/core-exports.js +190 -0
  34. package/src/dev/bun-app-source.js +95 -5
  35. package/src/dev/config.js +7 -51
  36. package/src/dev/dev-service-worker.js +48 -0
  37. package/src/dev/dev-status.js +122 -0
  38. package/src/dev/esm-hot-hooks.js +373 -0
  39. package/src/dev/handler.js +632 -155
  40. package/src/dev/helpers.js +127 -96
  41. package/src/dev/hot-host.js +33 -6
  42. package/src/dev/poke-state.js +110 -0
  43. package/src/dev/serve.js +191 -133
  44. package/src/dev/server.js +109 -37
  45. package/src/dev/specifier-versions.js +125 -0
  46. package/src/dev/watch-ignore.js +49 -4
  47. package/src/dev/watch-poll.js +78 -0
  48. package/src/dev/watch-tree.js +59 -22
  49. package/src/dev-classify.js +59 -26
  50. package/src/dev-error.js +104 -3
  51. package/src/dev-hot-classify.js +291 -0
  52. package/src/dev-hot-client.js +245 -0
  53. package/src/dev-import.js +22 -5
  54. package/src/dev-overlay.js +26 -0
  55. package/src/dev-ready-probe.js +69 -0
  56. package/src/dev-regenerate.js +322 -78
  57. package/src/dev-reload-hold.js +49 -20
  58. package/src/dev-reload-reason.js +61 -0
  59. package/src/dev-reload-worker.js +62 -13
  60. package/src/dev-secret.js +73 -0
  61. package/src/dev-source-locations-hook.js +4 -1
  62. package/src/dev-source-locations.js +33 -2
  63. package/src/dev-styles.js +64 -6
  64. package/src/dev.js +0 -1
  65. package/src/env-files.js +124 -0
  66. package/src/file-storage.js +2 -2
  67. package/src/foreign-imports.js +228 -0
  68. package/src/form-action-identity.js +52 -25
  69. package/src/form-dispatch.js +150 -69
  70. package/src/html-cache.js +35 -4
  71. package/src/importmap.js +2 -2
  72. package/src/instrumentation.js +59 -3
  73. package/src/js-scan.js +5 -4
  74. package/src/jwe.js +155 -0
  75. package/src/lazy-components.js +166 -0
  76. package/src/listener-core.js +4 -2
  77. package/src/lit-hints.js +172 -0
  78. package/src/middleware-load.js +248 -0
  79. package/src/module-graph.js +73 -47
  80. package/src/module-side-effect.js +284 -0
  81. package/src/public-files.js +73 -0
  82. package/src/redirect-location.js +19 -0
  83. package/src/redirects.js +9 -1
  84. package/src/route-frontier.js +151 -0
  85. package/src/route-report.js +130 -0
  86. package/src/router.js +120 -13
  87. package/src/session.js +32 -10
  88. package/src/ssr/client-router-flag.js +1 -1
  89. package/src/ssr/document.js +49 -17
  90. package/src/ssr/head.js +228 -25
  91. package/src/ssr/preloads.js +31 -45
  92. package/src/ssr/render.js +243 -55
  93. package/src/ssr/responses.js +51 -4
  94. package/src/ssr/shipped-modules.js +52 -16
  95. package/src/testing.d.ts +31 -2
  96. package/src/testing.js +85 -17
  97. package/src/thenable-params.js +26 -0
  98. package/src/vendor/jspm.js +12 -1
  99. package/src/vendor/pins.js +9 -8
  100. package/src/vendor/scanner.js +14 -9
  101. package/src/vendor.js +5 -3
  102. package/webjs-config.schema.json +3 -8
  103. package/src/component-elision.js +0 -1852
  104. package/src/elision-differential.js +0 -101
  105. package/src/elision-report.js +0 -193
package/src/auth.js CHANGED
@@ -8,8 +8,14 @@
8
8
  */
9
9
 
10
10
  import { getStore } from './cache.js';
11
- import { getRequest, getBodyLimits, markDynamicAccess } from './context.js';
11
+ import { getRequest, getBodyLimits, getAllowedOrigins, markDynamicAccess, requestCookieStore } from './context.js';
12
+ import { verifyOrigin } from './csrf.js';
13
+ import { redirect } from '@webjsdev/core';
12
14
  import { readTextBounded, readFormDataBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
15
+ import { devSecret } from './dev-secret.js';
16
+ import { encryptSession, decryptSession } from './jwe.js';
17
+ import { basePath as appBasePath } from './importmap.js';
18
+ import { headerSafeLocation } from './redirect-location.js';
13
19
 
14
20
  const enc = new TextEncoder();
15
21
  const dec = new TextDecoder();
@@ -71,6 +77,17 @@ function randomId() {
71
77
  return b64url(bytes.buffer);
72
78
  }
73
79
 
80
+ /**
81
+ * `path` with `key=value` added to its query, joining one it already has.
82
+ * @param {string} path @param {string} key @param {string} value
83
+ */
84
+ function withQuery(path, key, value) {
85
+ const hash = path.indexOf('#');
86
+ const base = hash < 0 ? path : path.slice(0, hash);
87
+ const frag = hash < 0 ? '' : path.slice(hash);
88
+ return `${base}${base.includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}${frag}`;
89
+ }
90
+
74
91
  /**
75
92
  * Accept a post-sign-in target only when it is a same-origin local path: one
76
93
  * leading `/`, the next character neither `/` nor `\` (both make a
@@ -92,6 +109,40 @@ function safeRedirectPath(value) {
92
109
  return value;
93
110
  }
94
111
 
112
+ /**
113
+ * The page a server-side `signIn()` / `signOut()` was called from, as a local
114
+ * path (Auth.js v5: `redirectTo ?? headers.get("Referer") ?? "/"`, #1686). A
115
+ * Referer from another origin is ignored, as Auth.js's default `redirect`
116
+ * callback keeps only same-origin targets.
117
+ * @param {Request | null | undefined} req
118
+ * @returns {string | null}
119
+ */
120
+ function refererPath(req) {
121
+ const ref = req && req.headers.get('referer');
122
+ if (!ref) return null;
123
+ try {
124
+ const u = new URL(ref);
125
+ if (u.origin !== new URL(req.url).origin) return null;
126
+ return safeRedirectPath(u.pathname + u.search + u.hash);
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Whether `adapter` is an Auth.js database adapter (`@auth/drizzle-adapter`
134
+ * and the rest), rather than WebJs's own session store.
135
+ * @param {unknown} adapter
136
+ */
137
+ function isAuthAdapter(adapter) {
138
+ return !!adapter && typeof (/** @type any */ (adapter)).getSessionAndUser === 'function';
139
+ }
140
+
141
+ /** Whether a cookie value is the encrypted session JWT (a compact JWE). @param {string} v */
142
+ function isJwe(v) {
143
+ return String(v).split('.').length === 5;
144
+ }
145
+
95
146
  // -- Cookie helpers ---------------------------------------------------------
96
147
 
97
148
  /** @param {string} header @returns {Record<string,string>} */
@@ -101,7 +152,11 @@ function parseCookies(header) {
101
152
  for (const pair of header.split(';')) {
102
153
  const eq = pair.indexOf('=');
103
154
  if (eq < 0) continue;
104
- out[pair.slice(0, eq).trim()] = decodeURIComponent(pair.slice(eq + 1).trim());
155
+ // One malformed value (a stray `%`) must not make auth() throw for every
156
+ // request that carries it, so it is skipped (#1686).
157
+ try {
158
+ out[pair.slice(0, eq).trim()] = decodeURIComponent(pair.slice(eq + 1).trim());
159
+ } catch {}
105
160
  }
106
161
  return out;
107
162
  }
@@ -137,29 +192,29 @@ function clearCookie(name, secure = false) {
137
192
 
138
193
  // -- JWT --------------------------------------------------------------------
139
194
 
140
- /** @param {Record<string,unknown>} payload @param {string} secret */
141
- export async function encodeJwt(payload, secret) {
142
- const h = b64url(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
143
- const p = b64url(enc.encode(JSON.stringify(payload)));
144
- const unsigned = `${h}.${p}`;
145
- const sig = await crypto.subtle.sign('HMAC', await hmacKey(secret), enc.encode(unsigned));
146
- return `${unsigned}.${b64url(sig)}`;
195
+ /**
196
+ * Encrypt a session payload as Auth.js does (#1686): a JWE (`dir`,
197
+ * `A256CBC-HS512`) keyed by HKDF over the secret with the session cookie's
198
+ * name as salt, so the browser holding the cookie cannot read what is in it.
199
+ * It used to be a signed (HS256) JWT, readable as base64. See `jwe.js`.
200
+ * @param {Record<string,unknown>} payload
201
+ * @param {string | string[]} secret
202
+ * @param {string} [salt] the session cookie's name
203
+ */
204
+ export async function encodeJwt(payload, secret, salt = AUTH_COOKIE) {
205
+ return encryptSession(payload, secret, salt);
147
206
  }
148
207
 
149
- /** @param {string} token @param {string} secret @returns {Promise<Record<string,unknown>|null>} */
150
- export async function decodeJwt(token, secret) {
151
- const parts = token.split('.');
152
- if (parts.length !== 3) return null;
153
- // `unb64url` → `atob` throws InvalidCharacterError on non-base64 input.
154
- // Any failure here (bad base64, bad HMAC verify) means the cookie is
155
- // garbage; treat it as "no session" rather than crashing the request.
156
- try {
157
- const ok = await crypto.subtle.verify('HMAC', await hmacKey(secret), unb64url(parts[2]), enc.encode(`${parts[0]}.${parts[1]}`));
158
- if (!ok) return null;
159
- const payload = JSON.parse(dec.decode(unb64url(parts[1])));
160
- if (payload.exp && Date.now() / 1000 > payload.exp) return null;
161
- return payload;
162
- } catch { return null; }
208
+ /**
209
+ * Decrypt a session token, or null when it is malformed, tampered with,
210
+ * expired, or made with another secret or salt. A garbage cookie never throws.
211
+ * @param {string} token
212
+ * @param {string | string[]} secret
213
+ * @param {string} [salt]
214
+ * @returns {Promise<Record<string,unknown>|null>}
215
+ */
216
+ export async function decodeJwt(token, secret, salt = AUTH_COOKIE) {
217
+ return decryptSession(token, secret, salt);
163
218
  }
164
219
 
165
220
  // -- Providers --------------------------------------------------------------
@@ -202,7 +257,7 @@ export function Google(opts = {}) {
202
257
  clientId: opts.clientId || process.env.AUTH_GOOGLE_ID,
203
258
  clientSecret: opts.clientSecret || process.env.AUTH_GOOGLE_SECRET,
204
259
  scope: ['openid', 'email', 'profile'],
205
- profile: (p) => ({ id: String(p.sub), name: p.name, email: p.email, image: p.picture }),
260
+ profile: (p) => ({ id: p.sub == null ? undefined : String(p.sub), name: p.name, email: p.email, image: p.picture }),
206
261
  };
207
262
  }
208
263
 
@@ -220,7 +275,7 @@ export function GitHub(opts = {}) {
220
275
  clientId: opts.clientId || process.env.AUTH_GITHUB_ID,
221
276
  clientSecret: opts.clientSecret || process.env.AUTH_GITHUB_SECRET,
222
277
  scope: ['read:user', 'user:email'],
223
- profile: (p) => ({ id: String(p.id), name: p.name || p.login, email: p.email, image: p.avatar_url }),
278
+ profile: (p) => ({ id: p.id == null ? undefined : String(p.id), name: p.name || p.login, email: p.email, image: p.avatar_url }),
224
279
  };
225
280
  }
226
281
 
@@ -230,10 +285,12 @@ export function GitHub(opts = {}) {
230
285
  * @typedef {Object} AuthConfig
231
286
  * @property {ProviderConfig[]} providers
232
287
  * @property {{ strategy?: 'jwt'|'database', maxAge?: number }} [session]
233
- * @property {string} secret
288
+ * @property {string} [secret] defaults to `process.env.AUTH_SECRET`, as in Auth.js
234
289
  * @property {{ session?: Function, jwt?: Function, signIn?: Function, redirect?: Function }} [callbacks]
235
290
  * @property {{ load?: Function, save?: Function, destroy?: Function }} [adapter]
236
291
  * @property {{ signIn?: string, signOut?: string, error?: string }} [pages]
292
+ * @property {boolean} [useSecureCookies] Auth.js's: force secure cookies on or
293
+ * off; unset, the auth URL's protocol decides (never NODE_ENV)
237
294
  */
238
295
 
239
296
  /**
@@ -254,21 +311,89 @@ export function GitHub(opts = {}) {
254
311
  * }}
255
312
  */
256
313
  export function createAuth(config) {
257
- const { secret } = config;
258
- if (!secret) throw new Error('createAuth() requires a `secret` (set AUTH_SECRET or SESSION_SECRET)');
259
-
260
- const strategy = config.session?.strategy || 'jwt';
314
+ // Auth.js reads AUTH_SECRET when `secret` is left out, so an app can write
315
+ // createAuth({ providers }) or pass process.env.AUTH_SECRET (string | undefined)
316
+ // without a cast.
317
+ // In development and tests a missing secret falls back to a generated one
318
+ // kept in .webjs/dev-secret (dev-secret.js), so a fresh clone or worktree works.
319
+ const secret = config.secret || process.env.AUTH_SECRET || devSecret('AUTH_SECRET');
320
+ if (!secret) throw new Error('createAuth() needs a secret to sign its session: set AUTH_SECRET in .env (webjs create writes one), or pass createAuth({ secret }). Only a development or test process (NODE_ENV=development or test) gets a generated one.');
321
+
322
+ // An Auth.js database adapter (`DrizzleAdapter(db)` and the rest) switches
323
+ // the default strategy to "database", as in Auth.js (#1686). WebJs's own
324
+ // session store ({ load, save, destroy }) is the other `adapter` shape.
325
+ const authAdapter = isAuthAdapter(config.adapter) ? /** @type {any} */ (config.adapter) : null;
326
+ const strategy = config.session?.strategy || (authAdapter ? 'database' : 'jwt');
327
+ /** Seconds between database session extensions, Auth.js's `updateAge`. */
328
+ const updateAge = /** @type {any} */ (config.session)?.updateAge ?? 24 * 60 * 60;
261
329
  const maxAge = config.session?.maxAge || DEFAULT_MAX_AGE;
262
330
  const maxAgeMs = maxAge * 1000;
263
331
  const cb = config.callbacks || {};
264
332
  const pages = config.pages || {};
265
- const secure = () => process.env.NODE_ENV === 'production';
333
+ // Where the auth routes live and the origin their callback URLs use, as in
334
+ // Auth.js v5 (#1686): `AUTH_URL` (or the older `NEXTAUTH_URL`) gives the
335
+ // origin, and its path is the base path unless `basePath` is set; without it
336
+ // the request's own origin is used. The default base path is /api/auth.
337
+ const envAuthUrl = process.env.AUTH_URL || process.env.NEXTAUTH_URL || '';
338
+ /** @type {string | null} */
339
+ let authOrigin = null;
340
+ let rawBase = /** @type {any} */ (config).basePath;
341
+ if (envAuthUrl) {
342
+ try {
343
+ const u = new URL(envAuthUrl);
344
+ authOrigin = u.origin;
345
+ if (!rawBase && u.pathname !== '/') rawBase = u.pathname;
346
+ } catch { /* an unparsable AUTH_URL leaves the request origin in charge */ }
347
+ }
348
+ const basePath = '/' + String(rawBase || '/api/auth').replace(/^\/+|\/+$/g, '');
349
+ /** @param {Request | null | undefined} req */
350
+ const authBase = (req) => `${authOrigin || (req ? new URL(req.url).origin : 'http://localhost:8080')}${basePath}`;
351
+ // Secure cookies (the Secure flag and the __Secure- / __Host- names) follow
352
+ // Auth.js's rule (core/src/lib/init.ts): `useSecureCookies` when set, else
353
+ // whether the auth URL is https, AUTH_URL's when set, else the request's.
354
+ // Not NODE_ENV: `npm start` over plain http (a LAN address, a staging host)
355
+ // would set cookies the browser drops, and sign-in would loop. A request
356
+ // and every cookie it reads or writes share one answer, and so do the two
357
+ // legs of an OAuth round trip, which share a protocol.
358
+ /** @param {Request | null | undefined} req */
359
+ const secure = (req) => {
360
+ if (typeof config.useSecureCookies === 'boolean') return config.useSecureCookies;
361
+ if (authOrigin) return authOrigin.startsWith('https:');
362
+ if (req) { try { return new URL(req.url).protocol === 'https:'; } catch { /* unparsable: not secure */ } }
363
+ return false;
364
+ };
266
365
 
267
366
  /** @type {Map<string, ProviderConfig>} */
268
367
  const providers = new Map();
269
- for (const p of config.providers) providers.set(p.id, p);
368
+ // Auth.js takes a provider called or bare (`providers: [GitHub]`), so a
369
+ // function is called with no options to get its config (#1686).
370
+ for (const entry of config.providers) {
371
+ const p = typeof entry === 'function' ? entry() : entry;
372
+ providers.set(p.id, p);
373
+ }
374
+
375
+ /**
376
+ * What the signIn callback decided, Auth.js's rule: a string redirects there
377
+ * (and does not sign in), any other falsy value denies, truthy allows.
378
+ * @returns {Promise<Response | null>} the refusal, or null to continue
379
+ */
380
+ async function signInRefusal(user, account, extraCookies = [], req = getRequest()) {
381
+ if (!cb.signIn) return null;
382
+ const ok = await cb.signIn({ user, account });
383
+ if (ok && typeof ok !== 'string') return null;
384
+ const hdrs = new Headers();
385
+ // A returned string is where to send the user instead. As in Auth.js it
386
+ // goes through callbacks.redirect, whose default allows only this origin,
387
+ // so the callback cannot become an open redirect; a target the redirect
388
+ // callback drops is refused like a false.
389
+ const denied = withQuery(pages.error || '/', 'error', 'AccessDenied');
390
+ const target = typeof ok === 'string' ? await resolveRedirect(ok, req, null) : null;
391
+ hdrs.set('location', headerSafeLocation(target || denied));
392
+ for (const c of extraCookies) hdrs.append('set-cookie', c);
393
+ return new Response(null, { status: 302, headers: hdrs });
394
+ }
270
395
 
271
- const dbStore = strategy === 'database' ? (config.adapter || defaultAdapter()) : null;
396
+ const dbStore = strategy === 'database' && !authAdapter ? (config.adapter || defaultAdapter()) : null;
272
397
 
273
398
  // -- Session read/write ---------------------------------------------------
274
399
 
@@ -281,18 +406,32 @@ export function createAuth(config) {
281
406
  // page cacheable so a logged-in body could be served to the next visitor.
282
407
  markDynamicAccess();
283
408
  const cookies = parseCookies(req.headers.get('cookie') || '');
284
- const raw = cookies[cookieName(AUTH_COOKIE, secure())];
409
+ const raw = cookies[cookieName(AUTH_COOKIE, secure(req))];
285
410
  if (!raw) return null;
286
411
 
287
- if (strategy === 'jwt') {
288
- const payload = await decodeJwt(raw, secret);
412
+ // A credentials sign-in always writes the encrypted JWT, as in Auth.js, so
413
+ // under the database strategy that cookie is read as one too: Auth.js
414
+ // itself cannot read it back there, and the sign-in silently fails.
415
+ if (strategy === 'jwt' || isJwe(raw)) {
416
+ const payload = await decodeJwt(raw, secret, cookieName(AUTH_COOKIE, secure(req)));
289
417
  if (!payload) return null;
290
418
  let token = cb.jwt ? await cb.jwt({ token: payload, user: undefined }) : payload;
291
- let session = { user: { id: token.sub, name: token.name, email: token.email, image: token.image, ...token } };
419
+ // Returning null (or anything but an object) from the jwt callback ends
420
+ // the session, as in Auth.js.
421
+ if (!token || typeof token !== 'object') {
422
+ // The cookie goes too, so later requests do not decrypt it and run the
423
+ // callback again until it expires. Best effort: a read outside a
424
+ // request, or after the headers were sent, cannot set a cookie.
425
+ try { if (getRequest()) queueSetCookie(clearCookie(cookieName(AUTH_COOKIE, secure(req)), secure(req))); } catch {}
426
+ return null;
427
+ }
428
+ const exp = typeof token.exp === 'number' ? token.exp : Math.floor(Date.now() / 1000) + maxAge;
429
+ let session = { user: { id: token.sub, name: token.name, email: token.email, image: token.image, ...token }, expires: new Date(exp * 1000).toISOString() };
292
430
  delete session.user.iat; delete session.user.exp; delete session.user.sub;
293
431
  return cb.session ? cb.session({ session, token, user: undefined }) : session;
294
432
  }
295
433
 
434
+ if (authAdapter) return readAdapterSession(raw, req);
296
435
  const sid = await unsign(raw, secret);
297
436
  if (!sid || !dbStore) return null;
298
437
  const data = await dbStore.load(sid);
@@ -301,22 +440,177 @@ export function createAuth(config) {
301
440
  return cb.session ? cb.session({ session, token: undefined, user: data }) : session;
302
441
  }
303
442
 
304
- async function writeSession(user) {
305
- if (strategy === 'jwt') {
306
- let token = { sub: user.id, name: user.name, email: user.email, image: user.image, role: user.role, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + maxAge };
307
- if (cb.jwt) token = await cb.jwt({ token, user });
308
- return setCookie(cookieName(AUTH_COOKIE, secure()), await encodeJwt(token, secret), maxAgeMs, secure());
443
+ // The jwt callback at sign-in gets what Auth.js passes it (#1686): the
444
+ // account (for OAuth, the provider's token response: access_token,
445
+ // refresh_token, expires_at, ...) and the raw profile, with trigger
446
+ // 'signIn', so `if (account) token.accessToken = account.access_token` works.
447
+ async function writeSession(user, account, profile, req = getRequest()) {
448
+ if (strategy === 'jwt') return writeJwtSession(user, account, profile, req);
449
+ if (authAdapter) {
450
+ // Under the database strategy a credentials sign-in gets a database
451
+ // session too, so it can be revoked. Auth.js writes a JWT here that its
452
+ // own database read cannot find, so the sign-in silently fails; WebJs
453
+ // keeps it working, and falls back to the encrypted JWT only when the
454
+ // adapter refuses the session (a user not in its users table).
455
+ try {
456
+ const sessionToken = crypto.randomUUID();
457
+ await authAdapter.createSession({ sessionToken, userId: user.id, expires: new Date(Date.now() + maxAgeMs) });
458
+ return setCookie(cookieName(AUTH_COOKIE, secure(req)), sessionToken, maxAgeMs, secure(req));
459
+ } catch (e) {
460
+ if (account?.type !== 'credentials') throw e;
461
+ console.warn('[webjs] createAuth: the adapter refused a session for a credentials user, so a JWT session is used instead:', /** @type any */ (e)?.message || e);
462
+ return writeJwtSession(user, account, profile, req);
463
+ }
309
464
  }
310
465
  const sid = randomId();
311
466
  await dbStore.save(sid, user, maxAgeMs);
312
- return setCookie(cookieName(AUTH_COOKIE, secure()), await sign(sid, secret), maxAgeMs, secure());
467
+ return setCookie(cookieName(AUTH_COOKIE, secure(req)), await sign(sid, secret), maxAgeMs, secure(req));
468
+ }
469
+
470
+ /**
471
+ * The encrypted JWT session cookie, with the jwt callback run at sign-in.
472
+ * @param {any} user @param {any} account @param {any} profile
473
+ */
474
+ async function writeJwtSession(user, account, profile, req = getRequest()) {
475
+ let token = { sub: user.id, name: user.name, email: user.email, image: user.image, role: user.role, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + maxAge };
476
+ if (cb.jwt) token = await cb.jwt({ token, user, account, profile, trigger: 'signIn' });
477
+ // Anything but an object from the jwt callback denies the sign-in: no
478
+ // cookie is written, and the caller answers with AccessDenied.
479
+ if (!token || typeof token !== 'object') return null;
480
+ // The session's lifetime is the cookie's, as Auth.js's encode() sets it,
481
+ // whatever object the jwt callback returned.
482
+ const now = Math.floor(Date.now() / 1000);
483
+ token = { ...token, iat: typeof token.iat === 'number' ? token.iat : now, exp: typeof token.exp === 'number' ? token.exp : now + maxAge };
484
+ return setCookie(cookieName(AUTH_COOKIE, secure(req)), await encodeJwt(token, secret, cookieName(AUTH_COOKIE, secure(req))), maxAgeMs, secure(req));
485
+ }
486
+
487
+ /**
488
+ * A database session through an Auth.js adapter, as Auth.js reads one
489
+ * (`core/src/lib/actions/session.ts`): an unknown token is no session, an
490
+ * expired one is deleted, and one past its `updateAge` is extended and its
491
+ * cookie re-set. The `session` callback receives `{ session, user }`.
492
+ * @param {string} sessionToken
493
+ */
494
+ async function readAdapterSession(sessionToken, req = getRequest()) {
495
+ const found = await authAdapter.getSessionAndUser(sessionToken);
496
+ if (!found) return null;
497
+ const { session: row, user } = found;
498
+ const expires = new Date(row.expires).getTime();
499
+ if (expires < Date.now()) {
500
+ await authAdapter.deleteSession?.(sessionToken);
501
+ return null;
502
+ }
503
+ let until = expires;
504
+ if (expires - maxAgeMs + updateAge * 1000 <= Date.now()) {
505
+ until = Date.now() + maxAgeMs;
506
+ await authAdapter.updateSession?.({ sessionToken, expires: new Date(until) });
507
+ try { if (getRequest()) queueSetCookie(setCookie(cookieName(AUTH_COOKIE, secure(req)), sessionToken, maxAgeMs, secure(req))); } catch { /* headers already sent: the row is extended, the cookie catches up next time */ }
508
+ }
509
+ const session = { user: { id: user.id, name: user.name, email: user.email, image: user.image }, expires: new Date(until).toISOString() };
510
+ return cb.session ? cb.session({ session, user, token: undefined }) : session;
511
+ }
512
+
513
+ /**
514
+ * Who an OAuth sign-in is, through an Auth.js adapter, as Auth.js decides it
515
+ * (`core/src/lib/actions/callback/handle-login.ts`): a linked account is
516
+ * its user; otherwise the account is linked to the signed-in user, or to a
517
+ * new user, and an email another user already has is refused unless the
518
+ * provider allows dangerous email linking. Returns the user, or the
519
+ * `OAuthAccountNotLinked` error code.
520
+ * @param {Request} req
521
+ * @param {any} user the provider's profile, normalised
522
+ * @param {any} account
523
+ * @param {any} provider
524
+ */
525
+ async function adapterUserFor(req, user, account, provider) {
526
+ const raw = parseCookies(req.headers.get('cookie') || '')[cookieName(AUTH_COOKIE, secure(req))];
527
+ const current = raw && !isJwe(raw) ? (await authAdapter.getSessionAndUser(raw))?.user ?? null : null;
528
+ const { accessToken: _legacy, ...accountRow } = account;
529
+ const linked = await authAdapter.getUserByAccount({ provider: account.provider, providerAccountId: account.providerAccountId });
530
+ if (linked) {
531
+ if (current && current.id !== linked.id) return 'OAuthAccountNotLinked';
532
+ return linked;
533
+ }
534
+ if (current) {
535
+ await authAdapter.linkAccount({ ...accountRow, userId: current.id });
536
+ return current;
537
+ }
538
+ const email = typeof user.email === 'string' ? user.email.toLowerCase() : user.email;
539
+ const byEmail = email ? await authAdapter.getUserByEmail(email) : null;
540
+ if (byEmail) {
541
+ if (!provider.allowDangerousEmailAccountLinking) return 'OAuthAccountNotLinked';
542
+ await authAdapter.linkAccount({ ...accountRow, userId: byEmail.id });
543
+ return byEmail;
544
+ }
545
+ const created = await authAdapter.createUser({ ...user, id: crypto.randomUUID(), email, emailVerified: null });
546
+ await authAdapter.linkAccount({ ...accountRow, userId: created.id });
547
+ return created;
313
548
  }
314
549
 
315
550
  // -- auth() ---------------------------------------------------------------
316
551
 
317
- async function auth(req) {
318
- const r = req || getRequest();
319
- return r ? readSession(r) : null;
552
+ // Auth.js's three shapes (#1686): `auth()` / `auth(req)` reads the session;
553
+ // `export { auth as middleware }` runs with `(req, next)` and must answer as
554
+ // middleware, never with the session object; `auth((req) => ...)` wraps a
555
+ // middleware, which sees the session on `req.auth`. Both middleware shapes
556
+ // run `callbacks.authorized` first when the app set one.
557
+ async function guard(req) {
558
+ const session = await readSession(req);
559
+ try { /** @type {any} */ (req).auth = session; } catch {}
560
+ if (!cb.authorized) return null;
561
+ const verdict = await cb.authorized({ auth: session, request: req });
562
+ if (verdict instanceof Response) return verdict;
563
+ if (verdict) return null;
564
+ const url = new URL(req.url);
565
+ const target = new URL(pages.signIn || `${basePath}/signin`, url.origin);
566
+ target.searchParams.set('callbackUrl', url.pathname + url.search);
567
+ return Response.redirect(target.toString(), 302);
568
+ }
569
+
570
+ function auth(arg, next) {
571
+ if (typeof arg === 'function') {
572
+ const handler = arg;
573
+ return async (req, ...rest) => (await guard(req)) ?? handler(req, ...rest);
574
+ }
575
+ if (arg instanceof Request && next !== undefined) {
576
+ return guard(arg).then((res) => res ?? (typeof next === 'function' ? next() : undefined));
577
+ }
578
+ const r = arg || getRequest();
579
+ return r ? readSession(r) : Promise.resolve(null);
580
+ }
581
+
582
+ // -- Where a sign-in or sign-out lands --------------------------------------
583
+
584
+ /**
585
+ * Auth.js's `callbacks.redirect({ url, baseUrl })` (#1686): every target a
586
+ * sign-in or sign-out lands on goes through it, whether the app's server
587
+ * code passed it or a form submitted it. The default keeps a local path or a
588
+ * same-origin URL (as its path) and sends anything else to "/", so no
589
+ * caller can become an open redirect; an app that needs another origin (an
590
+ * identity provider's end-session URL) returns it from the callback.
591
+ * @template F
592
+ * @param {unknown} url
593
+ * @param {Request | null | undefined} req
594
+ * @param {F} [fallback] what a dropped target becomes ("/" by default; the
595
+ * OAuth leg passes null so it stores nothing and clears a stale target)
596
+ * @returns {Promise<string | F>}
597
+ */
598
+ async function resolveRedirect(url, req, fallback = /** @type {any} */ ('/')) {
599
+ if (typeof url !== 'string' || url === '') return fallback;
600
+ const baseUrl = authOrigin || (req ? new URL(req.url).origin : '');
601
+ if (typeof cb.redirect === 'function') {
602
+ const out = await cb.redirect({ url, baseUrl });
603
+ return typeof out === 'string' && out !== '' ? out : fallback;
604
+ }
605
+ const local = safeRedirectPath(url);
606
+ if (local) return local;
607
+ try {
608
+ const u = new URL(url);
609
+ // Its path must itself be a safe local path: https://app//evil.com has
610
+ // the path //evil.com, which a browser reads as another host.
611
+ if (baseUrl && u.origin === baseUrl) return safeRedirectPath(u.pathname + u.search + u.hash) || fallback;
612
+ } catch { /* not a URL: dropped */ }
613
+ return fallback;
320
614
  }
321
615
 
322
616
  // -- signIn() -------------------------------------------------------------
@@ -327,20 +621,20 @@ export function createAuth(config) {
327
621
 
328
622
  if (provider.type === 'credentials') {
329
623
  if (!provider.authorize) return new Response('No authorize function', { status: 500 });
330
- const user = await provider.authorize(data || {});
624
+ // Auth.js passes the request as authorize's second argument.
625
+ const user = await provider.authorize(data || {}, opts.req || getRequest() || undefined);
331
626
  if (!user) {
332
- return new Response(null, { status: 302, headers: { location: `${pages.error || pages.signIn || '/'}?error=CredentialsSignin` } });
333
- }
334
- if (cb.signIn) {
335
- const ok = await cb.signIn({ user, account: { provider: providerId } });
336
- if (ok === false) return new Response(null, { status: 302, headers: { location: pages.error || '/?error=AccessDenied' } });
627
+ return new Response(null, { status: 302, headers: { location: withQuery(pages.error || pages.signIn || '/', 'error', 'CredentialsSignin') } });
337
628
  }
338
- const cookie = await writeSession(user);
339
- // `opts.redirectTo` is the app's own server-side choice and is trusted.
340
- // `data.redirectTo` usually arrives from a submitted form, so it goes
341
- // through the same local-path check the OAuth flow uses (#1495).
342
- const redirectTo = opts.redirectTo || safeRedirectPath(data && data.redirectTo) || '/';
343
- return new Response(null, { status: 302, headers: { location: /** @type {string} */ (redirectTo), 'set-cookie': cookie } });
629
+ const account = { provider: providerId, type: 'credentials', providerAccountId: String(user.id ?? '') };
630
+ const refused = await signInRefusal(user, account, [], opts.req || getRequest());
631
+ if (refused) return refused;
632
+ const cookie = await writeSession(user, account, undefined, opts.req || getRequest());
633
+ if (!cookie) return new Response(null, { status: 302, headers: { location: withQuery(pages.error || '/', 'error', 'AccessDenied') } });
634
+ // The app's own value and a submitted one both go through the redirect
635
+ // callback (its default allows only this origin), as in Auth.js.
636
+ const redirectTo = await resolveRedirect(opts.redirectTo || (data && data.redirectTo), opts.req || getRequest());
637
+ return new Response(null, { status: 302, headers: { location: headerSafeLocation(redirectTo), 'set-cookie': cookie } });
344
638
  }
345
639
 
346
640
  if (provider.type === 'oauth') {
@@ -353,16 +647,27 @@ export function createAuth(config) {
353
647
 
354
648
  async function signOutFn(opts = {}) {
355
649
  const hdrs = new Headers();
356
- hdrs.set('location', opts.redirectTo || pages.signOut || '/');
357
- hdrs.append('set-cookie', clearCookie(cookieName(AUTH_COOKIE, secure()), secure()));
650
+ const req = opts.req || getRequest();
651
+ // Through the redirect callback, as every sign-in target is: the default
652
+ // keeps this origin only, and an app allows an identity provider's
653
+ // end-session URL by returning it from `callbacks.redirect`.
654
+ hdrs.set('location', headerSafeLocation(await resolveRedirect(opts.redirectTo, opts.req || getRequest())));
655
+ hdrs.append('set-cookie', clearCookie(cookieName(AUTH_COOKIE, secure(req)), secure(req)));
358
656
 
359
657
  if (strategy === 'database' && dbStore) {
360
658
  const r = opts.req || getRequest();
361
659
  if (r) {
362
- const raw = parseCookies(r.headers.get('cookie') || '')[cookieName(AUTH_COOKIE, secure())];
660
+ const raw = parseCookies(r.headers.get('cookie') || '')[cookieName(AUTH_COOKIE, secure(req))];
363
661
  if (raw) { const sid = await unsign(raw, secret); if (sid) await dbStore.destroy(sid); }
364
662
  }
365
663
  }
664
+ if (authAdapter) {
665
+ const r = opts.req || getRequest();
666
+ const raw = r && parseCookies(r.headers.get('cookie') || '')[cookieName(AUTH_COOKIE, secure(req))];
667
+ if (raw && !isJwe(raw)) {
668
+ try { await authAdapter.deleteSession(raw); } catch (e) { console.error('[webjs] signOut: deleteSession failed:', e); }
669
+ }
670
+ }
366
671
  return new Response(null, { status: 302, headers: hdrs });
367
672
  }
368
673
 
@@ -370,41 +675,42 @@ export function createAuth(config) {
370
675
 
371
676
  async function oauthRedirect(provider, opts) {
372
677
  const req = opts.req || getRequest();
373
- const origin = req ? new URL(req.url).origin : 'http://localhost:8080';
374
678
  const state = randomId();
375
679
 
376
680
  const url = new URL(provider.authorizationUrl);
377
681
  url.searchParams.set('client_id', provider.clientId);
378
- url.searchParams.set('redirect_uri', `${origin}/api/auth/callback/${provider.id}`);
682
+ url.searchParams.set('redirect_uri', `${authBase(req)}/callback/${provider.id}`);
379
683
  url.searchParams.set('response_type', 'code');
380
684
  url.searchParams.set('scope', (provider.scope || []).join(' '));
381
685
  url.searchParams.set('state', state);
382
686
 
383
687
  const hdrs = new Headers();
384
688
  hdrs.set('location', url.toString());
385
- hdrs.append('set-cookie', setCookie(cookieName(STATE_COOKIE, secure()), await sign(state, secret), 600_000, secure()));
689
+ hdrs.append('set-cookie', setCookie(cookieName(STATE_COOKIE, secure(req)), await sign(state, secret), 600_000, secure(req)));
386
690
  // The post-sign-in target rides in its own signed cookie (#1495). With no
387
691
  // valid target, any leftover cookie from an abandoned earlier attempt is
388
692
  // cleared so it cannot steer this sign-in somewhere stale.
389
- const target = safeRedirectPath(opts.redirectTo);
693
+ const target = await resolveRedirect(opts.redirectTo, req, null);
390
694
  hdrs.append('set-cookie', target
391
- ? setCookie(cookieName(REDIRECT_COOKIE, secure()), await sign(target, secret), 600_000, secure())
392
- : clearCookie(cookieName(REDIRECT_COOKIE, secure()), secure()));
695
+ ? setCookie(cookieName(REDIRECT_COOKIE, secure(req)), await sign(target, secret), 600_000, secure(req))
696
+ : clearCookie(cookieName(REDIRECT_COOKIE, secure(req)), secure(req)));
393
697
  return new Response(null, { status: 302, headers: hdrs });
394
698
  }
395
699
 
396
700
  /**
397
- * Read the signed post-sign-in target the redirect leg stored, re-checking it
398
- * as a local path (defence in depth: the signature proves this app wrote it,
399
- * the check proves it is still a safe target). Null when absent or invalid.
701
+ * Read the signed post-sign-in target the redirect leg stored. It went
702
+ * through callbacks.redirect before it was signed, so the signature proves
703
+ * it is a target this app allowed, and it is used as is, exactly as the
704
+ * credentials path uses the callback's answer. Null when absent or forged.
400
705
  *
401
706
  * @param {Request} req
402
707
  * @returns {Promise<string | null>}
403
708
  */
404
709
  async function readRedirectCookie(req) {
405
- const raw = parseCookies(req.headers.get('cookie') || '')[cookieName(REDIRECT_COOKIE, secure())];
710
+ const raw = parseCookies(req.headers.get('cookie') || '')[cookieName(REDIRECT_COOKIE, secure(req))];
406
711
  if (!raw) return null;
407
- return safeRedirectPath(await unsign(raw, secret));
712
+ const value = await unsign(raw, secret);
713
+ return typeof value === 'string' && value !== '' ? value : null;
408
714
  }
409
715
 
410
716
  async function oauthCallback(req, provider) {
@@ -413,20 +719,21 @@ export function createAuth(config) {
413
719
  const state = url.searchParams.get('state');
414
720
  if (!code || !state) return new Response('Missing code or state', { status: 400 });
415
721
 
416
- const rawState = parseCookies(req.headers.get('cookie') || '')[cookieName(STATE_COOKIE, secure())];
722
+ const rawState = parseCookies(req.headers.get('cookie') || '')[cookieName(STATE_COOKIE, secure(req))];
417
723
  if (!rawState) return new Response('Missing state cookie', { status: 403 });
418
724
  const verified = await unsign(rawState, secret);
419
725
  if (!verified || verified !== state) return new Response('Invalid state', { status: 403 });
420
726
 
421
727
  // Exchange code for token
422
- const callbackUrl = `${url.origin}/api/auth/callback/${provider.id}`;
728
+ const callbackUrl = `${authBase(req)}/callback/${provider.id}`;
423
729
  const tokenRes = await fetch(provider.tokenUrl, {
424
730
  method: 'POST',
425
731
  headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
426
732
  body: new URLSearchParams({ client_id: provider.clientId, client_secret: provider.clientSecret, code, redirect_uri: callbackUrl, grant_type: 'authorization_code' }),
427
733
  });
428
734
  if (!tokenRes.ok) return new Response('Token exchange failed', { status: 502 });
429
- const { access_token: accessToken } = await tokenRes.json();
735
+ const tokens = await tokenRes.json();
736
+ const accessToken = tokens.access_token;
430
737
  if (!accessToken) return new Response('No access token', { status: 502 });
431
738
 
432
739
  // Fetch profile
@@ -446,29 +753,88 @@ export function createAuth(config) {
446
753
 
447
754
  const user = provider.profile ? provider.profile(rawProfile) : rawProfile;
448
755
 
449
- if (cb.signIn) {
450
- const ok = await cb.signIn({ user, account: { provider: provider.id, accessToken } });
451
- if (ok === false) {
452
- const denied = new Headers();
453
- denied.set('location', pages.error || '/?error=AccessDenied');
454
- denied.append('set-cookie', clearCookie(cookieName(STATE_COOKIE, secure()), secure()));
455
- denied.append('set-cookie', clearCookie(cookieName(REDIRECT_COOKIE, secure()), secure()));
456
- return new Response(null, { status: 302, headers: denied });
457
- }
756
+ // Auth.js's account: the provider's token response, plus who it is for.
757
+ // `accessToken` stays alongside `access_token` for the earlier shape.
758
+ const flowCookies = [
759
+ clearCookie(cookieName(STATE_COOKIE, secure(req)), secure(req)),
760
+ clearCookie(cookieName(REDIRECT_COOKIE, secure(req)), secure(req)),
761
+ ];
762
+ /** A refused OAuth sign-in: the error page with its code, the flow's cookies cleared. */
763
+ const oauthError = (code) => {
764
+ const h = new Headers({ location: withQuery(pages.error || pages.signIn || `${basePath}/signin`, 'error', code) });
765
+ for (const c of flowCookies) h.append('set-cookie', c);
766
+ return new Response(null, { status: 302, headers: h });
767
+ };
768
+ // The account is keyed by the profile's id, so a profile without one is
769
+ // refused rather than linked under an empty id every such user would share.
770
+ if (user?.id == null || String(user.id) === '') return oauthError('OAuthProfileParseError');
771
+ const account = { ...tokens, provider: provider.id, type: 'oauth', providerAccountId: String(user.id), accessToken };
772
+ const refused = await signInRefusal(user, account, flowCookies, req);
773
+ if (refused) return refused;
774
+
775
+ let sessionUser = user;
776
+ if (authAdapter) {
777
+ const resolved = await adapterUserFor(req, user, account, provider);
778
+ if (typeof resolved === 'string') return oauthError(resolved);
779
+ sessionUser = resolved;
458
780
  }
459
-
781
+ const sessionCookie = await writeSession(sessionUser, account, rawProfile, req);
782
+ if (!sessionCookie) return oauthError('AccessDenied');
460
783
  const hdrs = new Headers();
461
- hdrs.set('location', (await readRedirectCookie(req)) || '/');
462
- hdrs.append('set-cookie', await writeSession(user));
463
- hdrs.append('set-cookie', clearCookie(cookieName(STATE_COOKIE, secure()), secure()));
464
- hdrs.append('set-cookie', clearCookie(cookieName(REDIRECT_COOKIE, secure()), secure()));
784
+ hdrs.set('location', headerSafeLocation((await readRedirectCookie(req)) || '/'));
785
+ hdrs.append('set-cookie', sessionCookie);
786
+ hdrs.append('set-cookie', clearCookie(cookieName(STATE_COOKIE, secure(req)), secure(req)));
787
+ hdrs.append('set-cookie', clearCookie(cookieName(REDIRECT_COOKIE, secure(req)), secure(req)));
465
788
  return new Response(null, { status: 302, headers: hdrs });
466
789
  }
467
790
 
791
+ // -- Built-in pages ------------------------------------------------------
792
+
793
+ /** @param {string} title @param {string} body */
794
+ function htmlPage(title, body) {
795
+ return new Response(
796
+ `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title></head>`
797
+ + `<body style="font-family:system-ui;max-width:24rem;margin:4rem auto">${body}</body></html>`,
798
+ { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } },
799
+ );
800
+ }
801
+
802
+ function signOutPage() {
803
+ return htmlPage('Sign out', '<h1>Sign out</h1><p>Are you sure you want to sign out?</p>'
804
+ + `<form method="post" action="${basePath}/signout"><button type="submit">Sign out</button></form>`);
805
+ }
806
+
807
+ /** @param {string | null} callbackUrl */
808
+ function signInPage(callbackUrl) {
809
+ if (pages.signIn) {
810
+ const target = new URL(pages.signIn, 'http://x');
811
+ if (callbackUrl) target.searchParams.set('callbackUrl', callbackUrl);
812
+ return new Response(null, { status: 302, headers: { location: target.pathname + target.search } });
813
+ }
814
+ const back = safeRedirectPath(callbackUrl) || '/';
815
+ const esc = (v) => String(v).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
816
+ const buttons = [...providers.values()].filter((p) => p.type === 'oauth').map((p) =>
817
+ `<form method="post" action="${basePath}/signin/${esc(p.id)}"><input type="hidden" name="redirectTo" value="${esc(back)}">`
818
+ + `<button type="submit">Sign in with ${esc(p.name)}</button></form>`).join('');
819
+ return htmlPage('Sign in', `<h1>Sign in</h1>${buttons || '<p>Set <code>pages.signIn</code> to your sign-in page.</p>'}`);
820
+ }
821
+
468
822
  // -- Route handlers (mount at app/api/auth/[...path]/route.js) ------------
469
823
 
824
+ // The request's path has lost the app's `webjs.basePath` mount prefix at
825
+ // ingress, while a base path taken from AUTH_URL is the public one and may
826
+ // still carry it, so both spellings are tried. A base only matches at a
827
+ // segment boundary: /api/authx/signin is not under /api/auth.
470
828
  function parseSegments(req) {
471
- return new URL(req.url).pathname.replace(/^\/api\/auth\/?/, '').split('/').filter(Boolean);
829
+ const path = new URL(req.url).pathname;
830
+ const mount = appBasePath();
831
+ const bases = [basePath];
832
+ if (mount && basePath.startsWith(mount + '/')) bases.push(basePath.slice(mount.length));
833
+ if (!bases.includes('/api/auth')) bases.push('/api/auth');
834
+ for (const b of bases) {
835
+ if (path === b || path.startsWith(b + '/')) return path.slice(b.length).split('/').filter(Boolean);
836
+ }
837
+ return [];
472
838
  }
473
839
 
474
840
  async function GET(req) {
@@ -487,17 +853,50 @@ export function createAuth(config) {
487
853
  if (!p || p.type !== 'oauth') return new Response('Unknown OAuth provider', { status: 404 });
488
854
  return oauthCallback(req, p);
489
855
  }
490
- if (seg[0] === 'signout') return signOutFn({ req });
856
+ // A GET never signs out (#1686): a link prefetch, a crawler or an image
857
+ // tag would. As in Auth.js it answers a page whose button POSTs.
858
+ if (seg[0] === 'signout') return pages.signOut ? Response.redirect(new URL(pages.signOut, req.url).toString(), 302) : signOutPage();
859
+ if (seg[0] === 'signin' && !seg[1]) return signInPage(new URL(req.url).searchParams.get('callbackUrl'));
491
860
  if (seg[0] === 'providers') {
492
861
  const list = {};
493
- for (const [id, p] of providers) list[id] = { id: p.id, name: p.name, type: p.type, signinUrl: `/api/auth/signin/${id}` };
862
+ for (const [id, p] of providers) list[id] = { id: p.id, name: p.name, type: p.type, signinUrl: `${basePath}/signin/${id}` };
494
863
  return new Response(JSON.stringify(list), { headers: { 'content-type': 'application/json' } });
495
864
  }
496
865
  return new Response('Not found', { status: 404 });
497
866
  }
498
867
 
868
+ /**
869
+ * The bounded JSON or form body an auth POST carries, or a 413 Response.
870
+ * Auth.js names the post-auth target `callbackUrl`; it is read as
871
+ * `redirectTo` when that is not given (#1686).
872
+ * @param {Request} req
873
+ * @returns {Promise<Record<string, unknown> | Response>}
874
+ */
875
+ async function readAuthBody(req) {
876
+ const limits = getBodyLimits();
877
+ const limit = limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
878
+ /** @type {Record<string, unknown>} */
879
+ let body = {};
880
+ const ct = req.headers.get('content-type') || '';
881
+ if (ct.includes('json')) {
882
+ const { tooLarge, text } = await readTextBounded(req, limit);
883
+ if (tooLarge) return payloadTooLarge();
884
+ try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
885
+ } else if (ct.includes('form')) {
886
+ const { tooLarge, formData } = await readFormDataBounded(req, limit);
887
+ if (tooLarge) return payloadTooLarge();
888
+ for (const [k, v] of formData.entries()) body[k] = v;
889
+ }
890
+ if (body.redirectTo == null && typeof body.callbackUrl === 'string') body.redirectTo = body.callbackUrl;
891
+ return body;
892
+ }
893
+
499
894
  async function POST(req) {
500
895
  const seg = parseSegments(req);
896
+ // Sign-in and sign-out change who the visitor is, so a cross-site form
897
+ // posting here is refused, as the action endpoint refuses one (#1686).
898
+ const origin = verifyOrigin(req, getAllowedOrigins());
899
+ if (!origin.ok) return new Response('Forbidden: ' + origin.reason, { status: 403 });
501
900
 
502
901
  if (seg[0] === 'signin' && seg[1]) {
503
902
  const provider = providers.get(seg[1]);
@@ -508,31 +907,136 @@ export function createAuth(config) {
508
907
  // fixed-shape JSON / form data, so the JSON / RPC limit applies. An
509
908
  // over-limit body is a 413 before any parse, and is never buffered
510
909
  // whole (see body-limit.js).
511
- const limits = getBodyLimits();
512
- const limit = limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
513
- /** @type {Record<string, unknown>} */
514
- let body = {};
515
- const ct = req.headers.get('content-type') || '';
516
- if (ct.includes('json')) {
517
- const { tooLarge, text } = await readTextBounded(req, limit);
518
- if (tooLarge) return payloadTooLarge();
519
- body = text ? JSON.parse(text) : {};
520
- } else if (ct.includes('form')) {
521
- const { tooLarge, formData } = await readFormDataBounded(req, limit);
522
- if (tooLarge) return payloadTooLarge();
523
- for (const [k, v] of formData.entries()) body[k] = v;
524
- }
910
+ const body = await readAuthBody(req);
911
+ if (body instanceof Response) return body;
525
912
  if (provider.type === 'credentials') return signInFn('credentials', body, { req });
526
913
  if (provider.type === 'oauth') {
527
914
  const redirectTo = body.redirectTo ?? new URL(req.url).searchParams.get('redirectTo');
528
915
  return oauthRedirect(provider, { req, redirectTo });
529
916
  }
530
917
  }
531
- if (seg[0] === 'signout') return signOutFn({ req });
918
+ if (seg[0] === 'signout') {
919
+ // The form's own target, as Auth.js's sign-out route honours its
920
+ // submitted callbackUrl (#1686); otherwise "/".
921
+ const body = await readAuthBody(req);
922
+ if (body instanceof Response) return body;
923
+ return signOutFn({ req, redirectTo: /** @type {any} */ (body.redirectTo) });
924
+ }
532
925
  return new Response('Not found', { status: 404 });
533
926
  }
534
927
 
535
- return { auth, signIn: signInFn, signOut: signOutFn, handlers: { GET, POST } };
928
+ // -- The public signIn() / signOut(): Auth.js v5 semantics ----------------
929
+ //
930
+ // Inside a request (a server action, a route handler, middleware) they act
931
+ // the way Auth.js's do in a Next server action: the session cookie goes onto
932
+ // this request's response through the cookie store, and success THROWS a
933
+ // redirect, so the habit `await signIn('credentials', formData)` works and
934
+ // so does `return signIn(...)`. A credentials failure throws
935
+ // `CredentialsSignin` (an `AuthError`), which an action can catch to return
936
+ // a message; uncaught, it is also a redirect to the error page, as the
937
+ // `/api/auth/*` route does. `{ redirect: false }` returns the target url
938
+ // instead of throwing. With no request in scope (a script, a unit test that
939
+ // calls it bare) they return the Response the route handlers send.
940
+
941
+ async function signIn(providerId, data, opts = {}) {
942
+ const fields = data instanceof FormData ? formFields(data) : (data || {});
943
+ // No target given: back to the page signIn was called from, as Auth.js.
944
+ if (!opts.redirectTo && !fields.redirectTo) {
945
+ const back = refererPath(opts.req || getRequest());
946
+ if (back) opts = { ...opts, redirectTo: back };
947
+ }
948
+ const noRedirect = opts.redirect === false || fields.redirect === false || fields.redirect === 'false';
949
+ if (!getRequest()) return signInFn(providerId, fields, opts);
950
+ const res = await signInFn(providerId, fields, opts);
951
+ const location = res.headers.get('location');
952
+ // getSetCookie(): get('set-cookie') joins several cookies with commas, which
953
+ // breaks an Expires date and a second cookie (the OAuth state and redirect).
954
+ const cookies = res.headers.getSetCookie();
955
+ if (cookies.length === 0 && /[?&]error=CredentialsSignin\b/.test(location || '')) throw credentialsSignin(location);
956
+ for (const cookie of cookies) queueSetCookie(cookie);
957
+ if (res.status !== 302 || !location) return res;
958
+ if (noRedirect) return location;
959
+ return redirect(location);
960
+ }
961
+
962
+ async function signOut(opts = {}) {
963
+ if (!opts.redirectTo) {
964
+ const back = refererPath(opts.req || getRequest());
965
+ if (back) opts = { ...opts, redirectTo: back };
966
+ }
967
+ if (!getRequest()) return signOutFn(opts);
968
+ const res = await signOutFn(opts);
969
+ for (const c of res.headers.getSetCookie()) queueSetCookie(c);
970
+ const location = /** @type {string} */ (res.headers.get('location'));
971
+ if (opts.redirect === false) return location;
972
+ return redirect(location);
973
+ }
974
+
975
+ return { auth, signIn, signOut, handlers: { GET, POST } };
976
+ }
977
+
978
+ /**
979
+ * Put a `Set-Cookie` the auth flow built onto the in-flight response.
980
+ * @param {string} setCookie
981
+ */
982
+ function queueSetCookie(setCookie) {
983
+ const store = requestCookieStore();
984
+ if (store.sealed) throw new Error('signIn()/signOut(): the response headers were already sent, so the session cookie cannot be set. Call it from a server action, a route handler or middleware.');
985
+ store.pending.set(setCookie.slice(0, setCookie.indexOf('=')), setCookie);
986
+ }
987
+
988
+ /**
989
+ * A FormData as the plain credentials object Auth.js hands `authorize`.
990
+ * @param {FormData} fd
991
+ * @returns {Record<string, unknown>}
992
+ */
993
+ function formFields(fd) {
994
+ /** @type {Record<string, unknown>} */
995
+ const out = {};
996
+ for (const [k, v] of fd.entries()) out[k] = v;
997
+ return out;
998
+ }
999
+
1000
+ /**
1001
+ * Auth.js's base error class: `error instanceof AuthError` and `error.type`
1002
+ * tell an action why a sign-in failed.
1003
+ */
1004
+ export class AuthError extends Error {
1005
+ /** @param {string} [message] */
1006
+ constructor(message) {
1007
+ super(message);
1008
+ this.name = 'AuthError';
1009
+ /** @type {string} */
1010
+ this.type = 'AuthError';
1011
+ }
1012
+ }
1013
+
1014
+ /** Thrown by `signIn('credentials', ...)` when `authorize` returns null. */
1015
+ export class CredentialsSignin extends AuthError {
1016
+ /** @param {string} [message] */
1017
+ constructor(message = 'Invalid credentials.') {
1018
+ super(message);
1019
+ this.name = 'CredentialsSignin';
1020
+ this.type = 'CredentialsSignin';
1021
+ /** Auth.js's `code`, the value of the `error` query parameter. */
1022
+ this.code = 'credentials';
1023
+ }
1024
+ }
1025
+
1026
+ /**
1027
+ * A `CredentialsSignin` that is ALSO a redirect to the error page, so an
1028
+ * action that does not catch it sends the user where the route handler would.
1029
+ * @param {string} location
1030
+ */
1031
+ function credentialsSignin(location) {
1032
+ const err = new CredentialsSignin();
1033
+ try {
1034
+ redirect(location);
1035
+ } catch (r) {
1036
+ const rr = /** @type {any} */ (r);
1037
+ Object.assign(err, { __webjs: rr.__webjs, url: rr.url, status: rr.status, redirectType: rr.redirectType });
1038
+ }
1039
+ return err;
536
1040
  }
537
1041
 
538
1042
  // -- Default database adapter (cache store) ---------------------------------