@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.
- package/README.md +4 -1
- package/index.d.ts +461 -102
- package/index.js +9 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-input.js +208 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +112 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/helpers.js +85 -0
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +133 -28
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +109 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +24 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/dev-status.js +122 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +632 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +191 -133
- package/src/dev/server.js +109 -37
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +322 -78
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev-styles.js +64 -6
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +150 -69
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +49 -17
- package/src/ssr/head.js +228 -25
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +243 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +52 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +85 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +3 -8
- package/src/component-elision.js +0 -1852
- package/src/elision-differential.js +0 -101
- 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
|
-
|
|
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
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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', `${
|
|
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 =
|
|
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
|
|
398
|
-
*
|
|
399
|
-
*
|
|
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
|
-
|
|
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 = `${
|
|
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
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
512
|
-
|
|
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')
|
|
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
|
-
|
|
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) ---------------------------------
|