@fluixi/oauth2 1.0.0-alpha.11 → 1.0.0-alpha.12

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 (45) hide show
  1. package/dist/backchannel.cjs +462 -0
  2. package/dist/backchannel.d.ts +39 -0
  3. package/dist/backchannel.d.ts.map +1 -0
  4. package/dist/backchannel.js +97 -0
  5. package/dist/backchannel.mjs +307 -0
  6. package/dist/{browser-N432SMC3.mjs → browser-VTMW33ZW.mjs} +2 -2
  7. package/dist/browser.cjs +6 -1
  8. package/dist/browser.mjs +6 -1
  9. package/dist/callback.mjs +1 -1
  10. package/dist/chunk-6TQMM3FM.mjs +108 -0
  11. package/dist/{chunk-4TPZVJSR.mjs → chunk-MT6KXLLJ.mjs} +1 -1
  12. package/dist/{chunk-ZVDC3JW7.mjs → chunk-WRXP7BLV.mjs} +1 -1
  13. package/dist/discovery.cjs +6 -1
  14. package/dist/discovery.d.ts.map +1 -1
  15. package/dist/discovery.js +6 -1
  16. package/dist/discovery.mjs +6 -1
  17. package/dist/dpop-QIL3GC4U.mjs +22 -0
  18. package/dist/{dpop-DYUR2CH2.mjs → dpop-ZVQMDWG5.mjs} +4 -4
  19. package/dist/index.cjs +107 -7
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.mjs +111 -11
  24. package/dist/server.cjs +649 -137
  25. package/dist/server.d.ts +64 -4
  26. package/dist/server.d.ts.map +1 -1
  27. package/dist/server.js +376 -61
  28. package/dist/server.mjs +532 -139
  29. package/dist/service-client.cjs +7 -2
  30. package/dist/service-client.mjs +9 -4
  31. package/dist/service.cjs +104 -5
  32. package/dist/service.d.ts.map +1 -1
  33. package/dist/service.js +38 -6
  34. package/dist/service.mjs +105 -6
  35. package/dist/session-monitor.cjs +98 -0
  36. package/dist/session-monitor.d.ts +32 -0
  37. package/dist/session-monitor.d.ts.map +1 -0
  38. package/dist/session-monitor.js +104 -0
  39. package/dist/session-monitor.mjs +77 -0
  40. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  41. package/dist/types.d.ts +8 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/verify.cjs +7 -2
  44. package/dist/verify.mjs +12 -7
  45. package/package.json +15 -15
package/dist/server.js CHANGED
@@ -32,10 +32,86 @@ import { createSessionCookie } from '@fluixi/session/server';
32
32
  import { safeReturnTo } from './return-to.js';
33
33
  import { applyClientAuth, } from './client-auth.js';
34
34
  import { discover } from './discovery.js';
35
+ import { idTokenClaims, logoutTokenVerifier, memoryLogouts } from './backchannel.js';
35
36
  import { createPkcePair, createStateValue, expiryFrom, timingSafeEqual } from './pkce.js';
36
37
  import { OAuthError } from './types.js';
37
38
  import { createDpopKey, exportDpopKey, importDpopKey, nonceFrom, proofFor } from './dpop.js';
38
39
  import { defineModule } from '@fluixi/start/app';
40
+ /** Ends the state of a sign-in started again after an answer the browser had no flow for. */
41
+ const RETRY_MARK = '.retry';
42
+ const PROMPTS = new Set(['none', 'login', 'consent', 'select_account']);
43
+ /** What a `prompt=none` request ends with when the provider would have to ask the visitor. */
44
+ const SILENT_REFUSALS = new Set([
45
+ 'login_required',
46
+ 'consent_required',
47
+ 'interaction_required',
48
+ 'account_selection_required',
49
+ ]);
50
+ /**
51
+ * The cookie saying the provider was just asked silently and knew nobody. It holds the
52
+ * `session_state` that answer carried, which the page watches for a later sign-in. It
53
+ * expires soon on purpose: kept for the browser session, it hid a sign-in made at the
54
+ * provider while no page was open to notice.
55
+ */
56
+ function checkedCookie(config, sessionState) {
57
+ const name = `${config.cookieName ?? 'fx_session'}_checked`;
58
+ const secure = config.secureCookie ?? process.env.NODE_ENV === 'production';
59
+ const maxAge = Math.max(0, Math.floor(config.silentRecheck ?? 60));
60
+ const value = sessionState ?? '1';
61
+ return { name, header: `${name}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}` };
62
+ }
63
+ /** A `session_state` as Session Management builds it, `<hash>.<salt>`; anything else is dropped. */
64
+ function sessionStateFrom(url) {
65
+ const value = url.searchParams.get('session_state');
66
+ return value && /^[\w-]{1,128}\.[\w-]{1,64}$/.test(value) ? value : undefined;
67
+ }
68
+ /** A document load, as opposed to a fetch, an asset or a form post. */
69
+ function isPageRequest(request) {
70
+ if (request.method !== 'GET')
71
+ return false;
72
+ const dest = request.headers.get('sec-fetch-dest');
73
+ if (dest)
74
+ return dest === 'document';
75
+ return (request.headers.get('accept') ?? '').includes('text/html');
76
+ }
77
+ function cookieValue(request, name) {
78
+ for (const part of (request.headers.get('cookie') ?? '').split(';')) {
79
+ const [key, ...rest] = part.trim().split('=');
80
+ if (key === name)
81
+ return rest.join('=');
82
+ }
83
+ return undefined;
84
+ }
85
+ function hasCookie(request, name) {
86
+ return cookieValue(request, name) !== undefined;
87
+ }
88
+ /** Whether `pathname` is `path` or below it. */
89
+ function under(pathname, path) {
90
+ return pathname === path || pathname.startsWith(path.endsWith('/') ? path : `${path}/`);
91
+ }
92
+ /** Where `signIn` sends a signed-out visitor's page request, or null to render it as is. */
93
+ function signInRedirect(request, config) {
94
+ if (!isPageRequest(request))
95
+ return null;
96
+ const url = new URL(request.url);
97
+ const base = (config.basePath ?? '/auth').replace(/\/$/, '');
98
+ const guarded = (config.protectedPaths ?? []).some((path) => under(url.pathname, path));
99
+ const mode = guarded ? 'required' : (config.signIn ?? 'manual');
100
+ if (mode === 'manual')
101
+ return null;
102
+ if ([base, ...(config.publicPaths ?? [])].some((path) => under(url.pathname, path)))
103
+ return null;
104
+ // Back from a refused sign-in: show the page and its error, or a required sign-in would
105
+ // send the visitor straight back to the provider, which refuses again, in a loop.
106
+ if (url.searchParams.has('auth_error'))
107
+ return null;
108
+ if (mode === 'silent' && hasCookie(request, checkedCookie(config).name))
109
+ return null;
110
+ const login = new URLSearchParams({ returnTo: url.pathname + url.search });
111
+ if (mode === 'silent')
112
+ login.set('prompt', 'none');
113
+ return new Response(null, { status: 302, headers: { location: `${base}/login?${login}` } });
114
+ }
39
115
  /**
40
116
  * Nonces providers asked for, per origin, shared by the routes and `sessionLocals`. A nonce
41
117
  * is the server's to set (RFC 9449 §8, §9), so the latest one it sent is the one to use.
@@ -56,38 +132,34 @@ function learnNonce(url, response) {
56
132
  return fresh && !response.ok;
57
133
  }
58
134
  /**
59
- * A request carrying the session's access token: the `DPoP` scheme and a proof bound to the
60
- * token when the provider bound it, `Bearer` otherwise.
135
+ * Sends a request with the session's access token: as a bearer token, or with a DPoP proof
136
+ * for this method and URL when the token is bound to the session's key. A nonce the server
137
+ * asks for is learned and the request sent once more, so the body must be one that can be
138
+ * sent twice (a string, not a stream).
61
139
  */
62
- async function withToken(url, session) {
140
+ async function sendAs(session, input, init = {}) {
141
+ const url = String(input);
142
+ const method = (init.method ?? 'GET').toUpperCase();
63
143
  const key = session.dpop && session.tokenType?.toLowerCase() === 'dpop' ? await importDpopKey(session.dpop) : null;
64
144
  const send = async () => {
65
- const headers = {
145
+ const credentials = {
66
146
  authorization: `${key ? 'DPoP' : 'Bearer'} ${session.accessToken}`,
67
147
  };
68
148
  if (key)
69
- await prove(headers, key, 'GET', url, session.accessToken);
70
- return fetch(url, { headers });
149
+ await prove(credentials, key, method, url, session.accessToken);
150
+ const headers = new Headers(init.headers);
151
+ for (const [name, value] of Object.entries(credentials))
152
+ headers.set(name, value);
153
+ return fetch(url, { ...init, method, headers });
71
154
  };
72
155
  let response = await send();
73
156
  if (key && learnNonce(url, response))
74
157
  response = await send();
75
158
  return response;
76
159
  }
77
- /**
78
- * The middleware.
79
- *
80
- * Handles `<basePath>/login`, `/callback`, `/refresh`, `/me` and `/logout`, and passes
81
- * anything else along untouched.
82
- */
83
- export function oauthRoutes(config) {
84
- const base = (config.basePath ?? '/auth').replace(/\/$/, '');
85
- const scopes = config.scopes ?? ['openid', 'profile', 'email'];
86
- const cookie = createSessionCookie({
87
- secret: config.cookieSecret,
88
- name: config.cookieName,
89
- secure: config.secureCookie,
90
- });
160
+ const withToken = (url, session) => sendAs(session, url);
161
+ /** Endpoints, client authentication and the token request, for the routes and `sessionLocals`. */
162
+ function tokenClient(config) {
91
163
  let endpoints = config.endpoints ?? null;
92
164
  const resolve = async () => {
93
165
  if (endpoints)
@@ -98,10 +170,6 @@ export function oauthRoutes(config) {
98
170
  endpoints = await discover(config.issuer);
99
171
  return endpoints;
100
172
  };
101
- const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
102
- status,
103
- headers: { 'content-type': 'application/json', ...headers },
104
- });
105
173
  const clientAuth = {
106
174
  clientId: config.clientId,
107
175
  // `client_secret_post` by default here, which is what this half has always sent, so
@@ -135,18 +203,150 @@ export function oauthRoutes(config) {
135
203
  }
136
204
  return payload;
137
205
  }
206
+ return { resolve, tokenRequest, clientAuth };
207
+ }
208
+ /**
209
+ * Renewals in flight or just finished, by the refresh token they spent. The provider rotates
210
+ * refresh tokens and treats a second use of one as theft, revoking the whole family: requests
211
+ * that arrive together with the same expired cookie (a page and its server functions) must
212
+ * share one renewal, and one that arrives a moment later with the old cookie still gets its
213
+ * result. One process only; replicas need the provider to allow a short reuse window.
214
+ */
215
+ const renewals = new Map();
216
+ const RENEWAL_REUSE_MS = 30_000;
217
+ /** The session with fresh tokens, or null when the provider refuses to renew it. */
218
+ function renew(current, tokenRequest) {
219
+ const refreshToken = current.refreshToken;
220
+ const now = Date.now();
221
+ for (const [key, entry] of renewals)
222
+ if (entry.until <= now)
223
+ renewals.delete(key);
224
+ const known = renewals.get(refreshToken);
225
+ if (known)
226
+ return known.result;
227
+ const result = (async () => {
228
+ // The key that obtained the refresh token, or the provider refuses the refresh.
229
+ const key = current.dpop ? await importDpopKey(current.dpop) : null;
230
+ const payload = await tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken }, key);
231
+ return {
232
+ accessToken: String(payload.access_token ?? ''),
233
+ // A provider that rotates refresh tokens sends a new one; one that does not
234
+ // expects the old one to keep working.
235
+ refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : refreshToken,
236
+ idToken: typeof payload.id_token === 'string' ? payload.id_token : current.idToken,
237
+ expiresAt: expiryFrom(payload.expires_in),
238
+ tokenType: typeof payload.token_type === 'string' ? payload.token_type : current.tokenType,
239
+ dpop: current.dpop,
240
+ sessionState: current.sessionState,
241
+ };
242
+ })().catch((error) => {
243
+ // A refusal is an answer: the session is over. Anything else (the provider down) is not.
244
+ if (error instanceof OAuthError)
245
+ return null;
246
+ renewals.delete(refreshToken);
247
+ throw error;
248
+ });
249
+ renewals.set(refreshToken, { until: now + RENEWAL_REUSE_MS, result });
250
+ return result;
251
+ }
252
+ /** Renewed a little before it runs out, so a request does not reach an API with a dying token. */
253
+ const EXPIRY_SKEW_MS = 30_000;
254
+ const expired = (session) => session.expiresAt !== undefined && session.expiresAt - EXPIRY_SKEW_MS <= Date.now();
255
+ /**
256
+ * The middleware.
257
+ *
258
+ * Handles `<basePath>/login`, `/callback`, `/refresh`, `/me`, `/session-check` and
259
+ * `/logout`, and passes anything else along untouched.
260
+ */
261
+ // One default store per configuration, so the routes and sessionLocals built from it share it.
262
+ const defaultLogouts = new WeakMap();
263
+ function logoutsOf(config) {
264
+ if (config.logouts)
265
+ return config.logouts;
266
+ let store = defaultLogouts.get(config);
267
+ if (!store)
268
+ defaultLogouts.set(config, (store = memoryLogouts()));
269
+ return store;
270
+ }
271
+ /** Whether the provider has ended this session over the back channel. */
272
+ async function endedAtProvider(config, session) {
273
+ const id = idTokenClaims(session.idToken);
274
+ if (!id.sid && !id.sub)
275
+ return false;
276
+ return logoutsOf(config).ended({ sid: id.sid, sub: id.sub, since: (id.iat ?? 0) * 1000 });
277
+ }
278
+ export function oauthRoutes(config) {
279
+ const { resolve, tokenRequest, clientAuth } = tokenClient(config);
280
+ const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
281
+ status,
282
+ headers: { 'content-type': 'application/json', ...headers },
283
+ });
284
+ const base = (config.basePath ?? '/auth').replace(/\/$/, '');
285
+ const scopes = config.scopes ?? ['openid', 'profile', 'email'];
286
+ const cookie = createSessionCookie({
287
+ secret: config.cookieSecret,
288
+ name: config.cookieName,
289
+ secure: config.secureCookie,
290
+ });
138
291
  return async function middleware(request, next) {
139
292
  const url = new URL(request.url);
140
293
  if (!url.pathname.startsWith(`${base}/`))
141
294
  return next();
142
295
  const route = url.pathname.slice(base.length);
143
296
  const session = async () => {
144
- return cookie.read(request);
297
+ const current = await cookie.read(request);
298
+ return current && (await endedAtProvider(config, current)) ? null : current;
145
299
  };
300
+ if (route === '/backchannel-logout') {
301
+ // The provider, server to server: a session this app took part in has ended there.
302
+ const headers = { 'cache-control': 'no-store' };
303
+ if (request.method !== 'POST')
304
+ return json({ error: 'invalid_request' }, 405, headers);
305
+ try {
306
+ const form = await request.formData();
307
+ const token = form.get('logout_token');
308
+ if (typeof token !== 'string' || !token)
309
+ throw new Error('no logout_token');
310
+ const endpoints = await resolve();
311
+ const issuer = endpoints.issuer ?? config.issuer;
312
+ if (!issuer)
313
+ throw new Error('no issuer');
314
+ if (!endpoints.jwks)
315
+ throw new Error('no jwks_uri');
316
+ // Read fresh for each logout (they are rare), so a rotated key is always known.
317
+ const jwks = (await (await fetch(endpoints.jwks)).json());
318
+ const entry = await logoutTokenVerifier({ issuer, clientId: config.clientId, jwks })(token);
319
+ await logoutsOf(config).add(entry);
320
+ return new Response(null, { status: 200, headers });
321
+ }
322
+ catch {
323
+ return json({ error: 'invalid_request' }, 400, headers);
324
+ }
325
+ }
146
326
  if (route === '/login') {
147
- const { authorization } = await resolve();
327
+ let authorization;
328
+ try {
329
+ ({ authorization } = await resolve());
330
+ }
331
+ catch (error) {
332
+ if (!(error instanceof OAuthError) || error.code !== 'discovery_unreachable')
333
+ throw error;
334
+ // The provider is down: back to the app, which can say so, rather than an error page.
335
+ const back = safeReturnTo(url.searchParams.get('returnTo'), {
336
+ fallback: config.defaultReturnTo ?? '/',
337
+ origin: url.origin,
338
+ allowedOrigins: config.allowedReturnOrigins,
339
+ });
340
+ const target = new URL(back, url.origin);
341
+ target.searchParams.set('auth_error', 'provider_unreachable');
342
+ const location = target.origin === url.origin ? target.pathname + target.search : target.toString();
343
+ return new Response(null, { status: 302, headers: { location } });
344
+ }
148
345
  const { verifier, challenge } = await createPkcePair();
149
- const state = createStateValue();
346
+ // A retry carries its mark in the state itself: the one place that survives the trip
347
+ // when the cookie does not, so a second failed attempt is told apart from the first.
348
+ const retry = url.searchParams.get('retry') === '1';
349
+ const state = createStateValue() + (retry ? RETRY_MARK : '');
150
350
  const target = new URL(authorization);
151
351
  target.searchParams.set('response_type', 'code');
152
352
  target.searchParams.set('client_id', config.clientId);
@@ -155,6 +355,10 @@ export function oauthRoutes(config) {
155
355
  target.searchParams.set('state', state);
156
356
  target.searchParams.set('code_challenge', challenge);
157
357
  target.searchParams.set('code_challenge_method', 'S256');
358
+ // Only the values OpenID Connect defines; anything else from the query string is dropped.
359
+ const prompt = url.searchParams.get('prompt');
360
+ if (prompt && PROMPTS.has(prompt))
361
+ target.searchParams.set('prompt', prompt);
158
362
  // The verifier goes in the cookie, which is the only thing that survives the trip
159
363
  // and is not readable by the page.
160
364
  const pendingCookie = await cookie.write({
@@ -169,6 +373,7 @@ export function oauthRoutes(config) {
169
373
  origin: url.origin,
170
374
  allowedOrigins: config.allowedReturnOrigins,
171
375
  }) || undefined,
376
+ silent: prompt === 'none' || undefined,
172
377
  },
173
378
  },
174
379
  // Ten minutes: long enough to sign in, short enough that an abandoned attempt
@@ -186,14 +391,52 @@ export function oauthRoutes(config) {
186
391
  const current = await session();
187
392
  const pending = current?.pending;
188
393
  const error = url.searchParams.get('error');
189
- if (error)
190
- return json({ error }, 400);
394
+ if (error) {
395
+ // Back to the page the flow started from, never a JSON answer the visitor reads.
396
+ const ours = !!pending && timingSafeEqual(pending.state, url.searchParams.get('state') ?? '');
397
+ let location = safeReturnTo(ours ? pending.returnTo ?? config.defaultReturnTo : config.defaultReturnTo, {
398
+ fallback: '/',
399
+ origin: url.origin,
400
+ allowedOrigins: config.allowedReturnOrigins,
401
+ });
402
+ const headers = new Headers();
403
+ headers.append('set-cookie', cookie.clear());
404
+ if (ours && pending.silent && SILENT_REFUSALS.has(error)) {
405
+ // Nobody signed in at the provider: remember it was asked, and show the page as is.
406
+ headers.append('set-cookie', checkedCookie(config, sessionStateFrom(url)).header);
407
+ }
408
+ else {
409
+ const target = new URL(location, url.origin);
410
+ target.searchParams.set('auth_error', error);
411
+ location = target.origin === url.origin ? target.pathname + target.search : target.toString();
412
+ }
413
+ headers.set('location', location);
414
+ return new Response(null, { status: 302, headers });
415
+ }
191
416
  const code = url.searchParams.get('code');
192
417
  const state = url.searchParams.get('state');
193
418
  if (!code)
194
419
  return json({ error: 'no_code' }, 400);
195
420
  if (!pending || !state || !timingSafeEqual(pending.state, state)) {
196
- return json({ error: 'state_mismatch' }, 400);
421
+ // An answer this browser has no flow for: its cookie expired (a sign-in that took longer
422
+ // than ten minutes, a provider page refreshed long after), or another tab started a
423
+ // newer one. Already signed in, the answer is a leftover. Otherwise start again once,
424
+ // and only then give up, on a page rather than with JSON.
425
+ const home = safeReturnTo(config.defaultReturnTo, { fallback: '/', origin: url.origin, allowedOrigins: config.allowedReturnOrigins });
426
+ if (current?.accessToken)
427
+ return new Response(null, { status: 302, headers: { location: home } });
428
+ if (!state?.endsWith(RETRY_MARK)) {
429
+ return new Response(null, { status: 302, headers: { location: `${base}/login?retry=1` } });
430
+ }
431
+ const target = new URL(home, url.origin);
432
+ target.searchParams.set('auth_error', 'state_mismatch');
433
+ return new Response(null, {
434
+ status: 302,
435
+ headers: {
436
+ location: target.origin === url.origin ? target.pathname + target.search : target.toString(),
437
+ 'set-cookie': cookie.clear(),
438
+ },
439
+ });
197
440
  }
198
441
  // The token is bound here, at the exchange, so this is where the session's key is made.
199
442
  const key = config.dpop
@@ -212,6 +455,7 @@ export function oauthRoutes(config) {
212
455
  expiresAt: expiryFrom(payload.expires_in),
213
456
  tokenType: typeof payload.token_type === 'string' ? payload.token_type : undefined,
214
457
  dpop: key ? await exportDpopKey(key) : undefined,
458
+ sessionState: sessionStateFrom(url),
215
459
  });
216
460
  return new Response(null, {
217
461
  status: 302,
@@ -232,22 +476,10 @@ export function oauthRoutes(config) {
232
476
  const current = await session();
233
477
  if (!current?.refreshToken)
234
478
  return json({ error: 'no_session' }, 401);
235
- // The key that obtained the refresh token, or the provider refuses the refresh.
236
- const key = current.dpop ? await importDpopKey(current.dpop) : null;
237
- const payload = await tokenRequest({ grant_type: 'refresh_token', refresh_token: current.refreshToken }, key);
238
- const refreshed = await cookie.write({
239
- accessToken: String(payload.access_token ?? ''),
240
- // A provider that rotates refresh tokens sends a new one; one that does not
241
- // expects the old one to keep working.
242
- refreshToken: typeof payload.refresh_token === 'string'
243
- ? payload.refresh_token
244
- : current.refreshToken,
245
- idToken: current.idToken,
246
- expiresAt: expiryFrom(payload.expires_in),
247
- tokenType: typeof payload.token_type === 'string' ? payload.token_type : current.tokenType,
248
- dpop: current.dpop,
249
- });
250
- return json({ ok: true }, 200, { 'set-cookie': refreshed });
479
+ const renewed = await renew(current, tokenRequest);
480
+ if (!renewed)
481
+ return json({ error: 'refresh_refused' }, 401, { 'set-cookie': cookie.clear() });
482
+ return json({ ok: true }, 200, { 'set-cookie': await cookie.write(renewed) });
251
483
  }
252
484
  if (route === '/me') {
253
485
  const current = await session();
@@ -262,6 +494,16 @@ export function oauthRoutes(config) {
262
494
  const claims = (await response.json());
263
495
  return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
264
496
  }
497
+ if (route === '/session-check') {
498
+ // What the page needs to watch the provider's session: its check page, this client,
499
+ // and the last answer's session_state, signed in or not. Nothing secret: the state is
500
+ // a salted hash bound to this origin.
501
+ const { checkSession } = await resolve().catch(() => ({ checkSession: undefined }));
502
+ const current = await session();
503
+ const checked = cookieValue(request, checkedCookie(config).name);
504
+ const state = current?.accessToken ? current.sessionState : checked !== '1' ? checked : undefined;
505
+ return json({ frame: checkSession ?? null, clientId: config.clientId, state: state ?? null });
506
+ }
265
507
  if (route === '/revoke') {
266
508
  const current = await session();
267
509
  const hint = url.searchParams.get('hint') === 'access_token' ? 'access_token' : 'refresh_token';
@@ -302,18 +544,21 @@ export function oauthRoutes(config) {
302
544
  }
303
545
  // Max-Age=0 is what removes it. Clearing the value alone leaves a cookie behind.
304
546
  const cleared = cookie.clear();
547
+ // Signed out on purpose: `silent` must not sign them straight back in.
548
+ const headers = new Headers();
549
+ headers.append('set-cookie', cleared);
550
+ headers.append('set-cookie', checkedCookie(config).header);
305
551
  if (endSession && current?.idToken) {
306
552
  const target = new URL(endSession);
307
553
  target.searchParams.set('id_token_hint', current.idToken);
308
554
  if (config.defaultReturnTo) {
309
555
  target.searchParams.set('post_logout_redirect_uri', config.defaultReturnTo);
310
556
  }
311
- return new Response(null, {
312
- status: 302,
313
- headers: { location: target.toString(), 'set-cookie': cleared },
314
- });
557
+ headers.set('location', target.toString());
558
+ return new Response(null, { status: 302, headers });
315
559
  }
316
- return json({ ok: true }, 200, { 'set-cookie': cleared });
560
+ headers.set('content-type', 'application/json');
561
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
317
562
  }
318
563
  return next();
319
564
  };
@@ -349,6 +594,7 @@ export function sessionLocals(config) {
349
594
  name: config.cookieName,
350
595
  secure: config.secureCookie,
351
596
  });
597
+ const { tokenRequest } = tokenClient(config);
352
598
  const ttl = config.userCacheMs ?? 30_000;
353
599
  /**
354
600
  * Keyed by the access token, which this process already holds in the cookie it just
@@ -376,23 +622,69 @@ export function sessionLocals(config) {
376
622
  return async function middleware(request, next) {
377
623
  const { getRequestLocals } = await import('@fluixi/server');
378
624
  const locals = getRequestLocals(request);
379
- const session = await cookie.read(request);
625
+ let session = await cookie.read(request);
626
+ // What the browser's cookie must become: renewed tokens, or nothing.
627
+ let setCookie;
628
+ const respond = async (response) => {
629
+ if (!setCookie)
630
+ return response;
631
+ const copy = new Response(response.body, response);
632
+ copy.headers.append('set-cookie', setCookie);
633
+ return copy;
634
+ };
635
+ const renewOrDrop = async (current) => {
636
+ const renewed = current.refreshToken ? await renew(current, tokenRequest) : null;
637
+ setCookie = renewed ? await cookie.write(renewed) : cookie.clear();
638
+ return renewed;
639
+ };
640
+ // An access token lives minutes, the session days: renew it here, on the request that
641
+ // finds it expired, rather than rendering a signed-in visitor as nobody.
642
+ if (session?.accessToken && expired(session)) {
643
+ session = await renewOrDrop(session).catch(() => session);
644
+ }
380
645
  // Written either way. A route that reads locals should find "nobody" rather than
381
646
  // nothing, so the absent case and the signed-out case look the same to it.
382
- if (!session?.accessToken) {
647
+ const signedOut = async () => {
648
+ const redirect = signInRedirect(request, config);
649
+ if (redirect)
650
+ return respond(redirect);
383
651
  locals.auth = { user: null, session: null };
384
- return next();
652
+ return respond(await next());
653
+ };
654
+ if (!session?.accessToken)
655
+ return signedOut();
656
+ // Ended at the provider (back-channel logout): this session is over too.
657
+ if (await endedAtProvider(config, session)) {
658
+ setCookie = cookie.clear();
659
+ session = null;
660
+ return signedOut();
385
661
  }
386
- const cached = cache.get(session.accessToken);
662
+ // Server code calls the app's APIs as this user with it; the token itself stays here.
663
+ const live = session;
664
+ const fetchAs = (input, init) => sendAs(live, input, init);
665
+ const cached = cache.get(live.accessToken);
387
666
  if (cached && cached.until > Date.now()) {
388
- locals.auth = { user: cached.user, session: { expiresAt: session.expiresAt } };
389
- return next();
667
+ locals.auth = { user: cached.user, session: { expiresAt: live.expiresAt }, fetch: fetchAs };
668
+ return respond(await next());
390
669
  }
391
670
  let user = null;
671
+ let unreachable = false;
392
672
  try {
393
673
  const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
394
674
  if (endpoints?.userinfo) {
395
- const response = await withToken(endpoints.userinfo, session);
675
+ let response = await withToken(endpoints.userinfo, session);
676
+ // Refused before its time (revoked, a clock apart): renew once, and when that is
677
+ // refused too the session is over, so the visitor goes the signed-out way.
678
+ if (response.status === 401 && !setCookie) {
679
+ session = await renewOrDrop(session).catch(() => session);
680
+ if (!session)
681
+ return signedOut();
682
+ response = await withToken(endpoints.userinfo, session);
683
+ }
684
+ if (response.status === 401) {
685
+ setCookie = cookie.clear();
686
+ return signedOut();
687
+ }
396
688
  if (response.ok) {
397
689
  const claims = (await response.json());
398
690
  user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
@@ -404,12 +696,34 @@ export function sessionLocals(config) {
404
696
  }
405
697
  catch {
406
698
  // A provider that is unreachable should not take the page down with it. The render
407
- // proceeds as signed out, and the client retries on its own.
699
+ // proceeds as signed out, and the client retries on its own; `unreachable` tells
700
+ // server code it is not the visitor who is signed out.
701
+ unreachable = true;
408
702
  }
409
- locals.auth = { user, session: user ? { expiresAt: session.expiresAt } : null };
410
- return next();
703
+ if (!session)
704
+ return signedOut();
705
+ const current = session;
706
+ const fetchCurrent = (input, init) => sendAs(current, input, init);
707
+ locals.auth = user
708
+ ? { user, session: { expiresAt: current.expiresAt }, fetch: fetchCurrent }
709
+ : { user: null, session: null, ...(unreachable ? { unreachable: true } : {}) };
710
+ return respond(await next());
411
711
  };
412
712
  }
713
+ /**
714
+ * The request's auth, for server code: a route's data loader, a server function, an API route.
715
+ * Reads the current request's locals, or `request`'s when one is given.
716
+ *
717
+ * ```ts
718
+ * const { fetch } = await requestAuth();
719
+ * const projects = await fetch?.(`${apiUrl}/projects`).then((r) => r.json());
720
+ * ```
721
+ */
722
+ export async function requestAuth(request) {
723
+ const { getLocals, getRequestLocals } = await import('@fluixi/server');
724
+ const locals = (request ? getRequestLocals(request) : getLocals());
725
+ return locals.auth ?? { user: null, session: null };
726
+ }
413
727
  /**
414
728
  * The server half of `authModule`, listed in `src/app.config.server.ts`: the routes and the
415
729
  * session middleware that `tokens: 'server'` needs, in the order they have to run.
@@ -420,3 +734,4 @@ export function sessionLocals(config) {
420
734
  export function authServerModule(config) {
421
735
  return defineModule({ name: 'oauth2-server', middleware: [oauthRoutes(config), sessionLocals(config)] });
422
736
  }
737
+ export { memoryLogouts } from './backchannel.js';