@fluixi/oauth2 1.0.0-alpha.1 → 1.0.0-alpha.100
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/LICENSE +1 -1
- package/dist/backchannel.cjs +462 -0
- package/dist/backchannel.d.ts +39 -0
- package/dist/backchannel.d.ts.map +1 -0
- package/dist/backchannel.js +97 -0
- package/dist/backchannel.mjs +307 -0
- package/dist/{browser-ONMHX3Z3.mjs → browser-TV25RBLT.mjs} +2 -2
- package/dist/browser.cjs +10 -3
- package/dist/browser.mjs +10 -3
- package/dist/callback.cjs +8 -7
- package/dist/callback.js +1 -1
- package/dist/callback.mjs +3 -2
- package/dist/{chunk-4NSOGPLM.mjs → chunk-2B3I72DV.mjs} +1 -1
- package/dist/chunk-KOP5Z4LB.mjs +108 -0
- package/dist/{chunk-25WE6UWI.mjs → chunk-KTGVSZE6.mjs} +1 -1
- package/dist/discovery.cjs +6 -1
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +6 -1
- package/dist/discovery.mjs +6 -1
- package/dist/dpop-O3HWR7AH.mjs +22 -0
- package/dist/{dpop-LVPWWADA.mjs → dpop-SGWG3K2I.mjs} +26 -6
- package/dist/{dpop-NS7NKOWM.mjs → dpop-YPGCGL7S.mjs} +22 -2
- package/dist/dpop-store.cjs +4 -2
- package/dist/dpop-store.mjs +4 -2
- package/dist/dpop.cjs +22 -2
- package/dist/dpop.d.ts +18 -1
- package/dist/dpop.d.ts.map +1 -1
- package/dist/dpop.js +23 -2
- package/dist/dpop.mjs +22 -2
- package/dist/index.cjs +135 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.mjs +138 -13
- package/dist/module.cjs +44 -0
- package/dist/module.d.ts +13 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +22 -0
- package/dist/module.mjs +23 -0
- package/dist/server.cjs +755 -80
- package/dist/server.d.ts +91 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +518 -65
- package/dist/server.mjs +638 -82
- package/dist/service-client.cjs +7 -2
- package/dist/service-client.mjs +9 -4
- package/dist/service.cjs +116 -7
- package/dist/service.d.ts +2 -18
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +48 -5
- package/dist/service.mjs +116 -7
- package/dist/session-monitor.cjs +98 -0
- package/dist/session-monitor.d.ts +32 -0
- package/dist/session-monitor.d.ts.map +1 -0
- package/dist/session-monitor.js +104 -0
- package/dist/session-monitor.mjs +77 -0
- package/dist/tokens.cjs +1 -1
- package/dist/tokens.d.ts +2 -2
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +1 -1
- package/dist/tokens.mjs +1 -1
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/types.d.ts +12 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -12
- package/dist/verify.cjs +30 -4
- package/dist/verify.mjs +12 -7
- package/package.json +22 -16
package/dist/server.js
CHANGED
|
@@ -32,22 +32,176 @@ 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';
|
|
38
|
+
import { createDpopKey, exportDpopKey, importDpopKey, nonceFrom, proofFor } from './dpop.js';
|
|
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
|
+
]);
|
|
37
50
|
/**
|
|
38
|
-
* The
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
42
55
|
*/
|
|
43
|
-
|
|
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);
|
|
44
97
|
const base = (config.basePath ?? '/auth').replace(/\/$/, '');
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Nonces providers asked for, per origin, shared by the routes and `sessionLocals`. A nonce
|
|
117
|
+
* is the server's to set (RFC 9449 §8, §9), so the latest one it sent is the one to use.
|
|
118
|
+
*/
|
|
119
|
+
const dpopNonces = new Map();
|
|
120
|
+
/** Put a proof for this request on `headers`, with the nonce this origin last asked for. */
|
|
121
|
+
async function prove(headers, key, method, url, accessToken) {
|
|
122
|
+
headers.dpop = await proofFor(key, { method, url, accessToken, nonce: dpopNonces.get(new URL(url).origin) });
|
|
123
|
+
}
|
|
124
|
+
/** Record a nonce the server sent. True when it refused the request with one not seen before. */
|
|
125
|
+
function learnNonce(url, response) {
|
|
126
|
+
const offered = nonceFrom(response);
|
|
127
|
+
if (!offered)
|
|
128
|
+
return false;
|
|
129
|
+
const origin = new URL(url).origin;
|
|
130
|
+
const fresh = offered !== dpopNonces.get(origin);
|
|
131
|
+
dpopNonces.set(origin, offered);
|
|
132
|
+
return fresh && !response.ok;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
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).
|
|
139
|
+
*/
|
|
140
|
+
async function sendAs(session, input, init = {}) {
|
|
141
|
+
const url = String(input);
|
|
142
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
143
|
+
const key = session.dpop && session.tokenType?.toLowerCase() === 'dpop' ? await importDpopKey(session.dpop) : null;
|
|
144
|
+
const send = async () => {
|
|
145
|
+
const credentials = {
|
|
146
|
+
authorization: `${key ? 'DPoP' : 'Bearer'} ${session.accessToken}`,
|
|
147
|
+
};
|
|
148
|
+
if (key)
|
|
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 });
|
|
154
|
+
};
|
|
155
|
+
let response = await send();
|
|
156
|
+
if (key && learnNonce(url, response))
|
|
157
|
+
response = await send();
|
|
158
|
+
return response;
|
|
159
|
+
}
|
|
160
|
+
const withToken = (url, session) => sendAs(session, url);
|
|
161
|
+
/**
|
|
162
|
+
* The provider did not process the token request: it answered 429 or 5xx, or the connection
|
|
163
|
+
* never opened. The same request may be sent again after `retryAfterMs`.
|
|
164
|
+
*/
|
|
165
|
+
class ProviderUnavailable extends OAuthError {
|
|
166
|
+
retryAfterMs;
|
|
167
|
+
constructor(retryAfterMs) {
|
|
168
|
+
super('The authorization server is unavailable.', 'temporarily_unavailable');
|
|
169
|
+
this.retryAfterMs = retryAfterMs;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The request may have been processed, but its answer is unusable: it timed out, the
|
|
174
|
+
* connection broke after it was sent, or a success came back without an access token. A
|
|
175
|
+
* refresh token sent this way may already have been rotated.
|
|
176
|
+
*/
|
|
177
|
+
class AnswerLost extends OAuthError {
|
|
178
|
+
constructor(what) {
|
|
179
|
+
super(`The token request's answer was lost (${what}).`, 'answer_lost');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Connection failures that happen before the request is sent. */
|
|
183
|
+
const NOT_SENT = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH', 'UND_ERR_CONNECT_TIMEOUT']);
|
|
184
|
+
const RETRY_DEFAULT_MS = 5_000;
|
|
185
|
+
const RETRY_MAX_MS = 300_000;
|
|
186
|
+
/** The pause a 429 or 5xx asks for (`Retry-After`, seconds or a date), within sane bounds. */
|
|
187
|
+
function retryAfter(response) {
|
|
188
|
+
const header = response.headers.get('retry-after');
|
|
189
|
+
if (!header)
|
|
190
|
+
return RETRY_DEFAULT_MS;
|
|
191
|
+
const seconds = Number(header);
|
|
192
|
+
const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - Date.now();
|
|
193
|
+
return Number.isFinite(ms) ? Math.min(Math.max(ms, 1_000), RETRY_MAX_MS) : RETRY_DEFAULT_MS;
|
|
194
|
+
}
|
|
195
|
+
/** What a failed `fetch` means for the request: never sent, or possibly processed. */
|
|
196
|
+
function unanswered(error) {
|
|
197
|
+
const code = error?.cause?.code;
|
|
198
|
+
if (code && NOT_SENT.has(code))
|
|
199
|
+
return new ProviderUnavailable(RETRY_DEFAULT_MS);
|
|
200
|
+
return new AnswerLost(code ?? (error instanceof Error ? error.name : 'no answer'));
|
|
201
|
+
}
|
|
202
|
+
/** Endpoints, client authentication and the token request, for the routes and `sessionLocals`. */
|
|
203
|
+
function tokenClient(config) {
|
|
204
|
+
const timeoutMs = config.tokenTimeoutMs ?? 10_000;
|
|
51
205
|
let endpoints = config.endpoints ?? null;
|
|
52
206
|
const resolve = async () => {
|
|
53
207
|
if (endpoints)
|
|
@@ -58,10 +212,6 @@ export function oauthRoutes(config) {
|
|
|
58
212
|
endpoints = await discover(config.issuer);
|
|
59
213
|
return endpoints;
|
|
60
214
|
};
|
|
61
|
-
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
62
|
-
status,
|
|
63
|
-
headers: { 'content-type': 'application/json', ...headers },
|
|
64
|
-
});
|
|
65
215
|
const clientAuth = {
|
|
66
216
|
clientId: config.clientId,
|
|
67
217
|
// `client_secret_post` by default here, which is what this half has always sent, so
|
|
@@ -72,32 +222,198 @@ export function oauthRoutes(config) {
|
|
|
72
222
|
keyId: config.keyId,
|
|
73
223
|
assertionAlgorithm: config.assertionAlgorithm,
|
|
74
224
|
};
|
|
75
|
-
async function tokenRequest(body) {
|
|
225
|
+
async function tokenRequest(body, key = null) {
|
|
76
226
|
const { token } = await resolve();
|
|
77
|
-
|
|
78
|
-
|
|
227
|
+
// Built afresh for a retry: a `private_key_jwt` assertion carries a `jti`, and sending
|
|
228
|
+
// the same one twice can be refused as a replay.
|
|
229
|
+
const attempt = async () => {
|
|
230
|
+
const headers = {
|
|
231
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
232
|
+
};
|
|
233
|
+
const form = new URLSearchParams(body);
|
|
234
|
+
await applyClientAuth(clientAuth, token, headers, form);
|
|
235
|
+
if (key)
|
|
236
|
+
await prove(headers, key, 'POST', token);
|
|
237
|
+
return fetch(token, { method: 'POST', headers, body: form.toString(), signal: AbortSignal.timeout(timeoutMs) });
|
|
79
238
|
};
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
239
|
+
let response;
|
|
240
|
+
let payload = null;
|
|
241
|
+
try {
|
|
242
|
+
response = await attempt();
|
|
243
|
+
if (key && learnNonce(token, response))
|
|
244
|
+
response = await attempt();
|
|
245
|
+
if (response.status === 429 || response.status >= 500)
|
|
246
|
+
throw new ProviderUnavailable(retryAfter(response));
|
|
247
|
+
payload = (await response.json().catch(() => null));
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
throw error instanceof OAuthError ? error : unanswered(error);
|
|
251
|
+
}
|
|
84
252
|
if (!response.ok) {
|
|
85
|
-
throw new OAuthError(String(payload
|
|
253
|
+
throw new OAuthError(String(payload?.error ?? 'token_request_failed'), String(payload?.error ?? 'token_request_failed'));
|
|
254
|
+
}
|
|
255
|
+
// A token endpoint's success always carries one (RFC 6749 5.1). Anything else (a proxy's
|
|
256
|
+
// page, a truncated body) is not a session, and the provider may have rotated the token.
|
|
257
|
+
if (typeof payload?.access_token !== 'string' || !payload.access_token) {
|
|
258
|
+
throw new AnswerLost('a success without an access token');
|
|
86
259
|
}
|
|
87
260
|
return payload;
|
|
88
261
|
}
|
|
262
|
+
return { resolve, tokenRequest, clientAuth };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Renewals in flight or just finished, by the refresh token they spent. The provider rotates
|
|
266
|
+
* refresh tokens and treats a second use of one as theft, revoking the whole family: requests
|
|
267
|
+
* that arrive together with the same expired cookie (a page and its server functions) must
|
|
268
|
+
* share one renewal, and one that arrives a moment later with the old cookie still gets its
|
|
269
|
+
* result. One process only; replicas need the provider to allow a short reuse window.
|
|
270
|
+
*/
|
|
271
|
+
const renewals = new Map();
|
|
272
|
+
const RENEWAL_REUSE_MS = 30_000;
|
|
273
|
+
/** How long a refused or possibly rotated refresh token stays recorded, so it is not sent again. */
|
|
274
|
+
const SPENT_MS = 5 * 60_000;
|
|
275
|
+
/** The session with fresh tokens, or null when the provider refuses to renew it. */
|
|
276
|
+
function renew(current, tokenRequest) {
|
|
277
|
+
const refreshToken = current.refreshToken;
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
for (const [key, entry] of renewals)
|
|
280
|
+
if (entry.until <= now)
|
|
281
|
+
renewals.delete(key);
|
|
282
|
+
const known = renewals.get(refreshToken);
|
|
283
|
+
if (known)
|
|
284
|
+
return known.result;
|
|
285
|
+
const result = (async () => {
|
|
286
|
+
// The key that obtained the refresh token, or the provider refuses the refresh.
|
|
287
|
+
const key = current.dpop ? await importDpopKey(current.dpop) : null;
|
|
288
|
+
const payload = await tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken }, key);
|
|
289
|
+
return {
|
|
290
|
+
accessToken: String(payload.access_token ?? ''),
|
|
291
|
+
// A provider that rotates refresh tokens sends a new one; one that does not
|
|
292
|
+
// expects the old one to keep working.
|
|
293
|
+
refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : refreshToken,
|
|
294
|
+
idToken: typeof payload.id_token === 'string' ? payload.id_token : current.idToken,
|
|
295
|
+
expiresAt: expiryFrom(payload.expires_in),
|
|
296
|
+
tokenType: typeof payload.token_type === 'string' ? payload.token_type : current.tokenType,
|
|
297
|
+
dpop: current.dpop,
|
|
298
|
+
sessionState: current.sessionState,
|
|
299
|
+
};
|
|
300
|
+
})().catch((error) => {
|
|
301
|
+
// Not processed (the provider down or busy): nothing was spent. Ask again after the pause.
|
|
302
|
+
if (error instanceof ProviderUnavailable) {
|
|
303
|
+
renewals.set(refreshToken, { until: Date.now() + error.retryAfterMs, result });
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
// Refused, or answered in a way that was lost after the provider may have rotated the
|
|
307
|
+
// token: the session is over either way. Sending the token again would be a replay, which
|
|
308
|
+
// the provider answers by ending every session of this sign-in.
|
|
309
|
+
if (error instanceof OAuthError) {
|
|
310
|
+
renewals.set(refreshToken, { until: Date.now() + SPENT_MS, result });
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
renewals.delete(refreshToken);
|
|
314
|
+
throw error;
|
|
315
|
+
});
|
|
316
|
+
renewals.set(refreshToken, { until: now + RENEWAL_REUSE_MS, result });
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
/** Renewed a little before it runs out, so a request does not reach an API with a dying token. */
|
|
320
|
+
const EXPIRY_SKEW_MS = 30_000;
|
|
321
|
+
const expired = (session) => session.expiresAt !== undefined && session.expiresAt - EXPIRY_SKEW_MS <= Date.now();
|
|
322
|
+
/**
|
|
323
|
+
* The middleware.
|
|
324
|
+
*
|
|
325
|
+
* Handles `<basePath>/login`, `/callback`, `/refresh`, `/me`, `/session-check` and
|
|
326
|
+
* `/logout`, and passes anything else along untouched.
|
|
327
|
+
*/
|
|
328
|
+
// One default store per configuration, so the routes and sessionLocals built from it share it.
|
|
329
|
+
const defaultLogouts = new WeakMap();
|
|
330
|
+
function logoutsOf(config) {
|
|
331
|
+
if (config.logouts)
|
|
332
|
+
return config.logouts;
|
|
333
|
+
let store = defaultLogouts.get(config);
|
|
334
|
+
if (!store)
|
|
335
|
+
defaultLogouts.set(config, (store = memoryLogouts()));
|
|
336
|
+
return store;
|
|
337
|
+
}
|
|
338
|
+
/** Whether the provider has ended this session over the back channel. */
|
|
339
|
+
async function endedAtProvider(config, session) {
|
|
340
|
+
const id = idTokenClaims(session.idToken);
|
|
341
|
+
if (!id.sid && !id.sub)
|
|
342
|
+
return false;
|
|
343
|
+
return logoutsOf(config).ended({ sid: id.sid, sub: id.sub, since: (id.iat ?? 0) * 1000 });
|
|
344
|
+
}
|
|
345
|
+
export function oauthRoutes(config) {
|
|
346
|
+
const { resolve, tokenRequest, clientAuth } = tokenClient(config);
|
|
347
|
+
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
348
|
+
status,
|
|
349
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
350
|
+
});
|
|
351
|
+
const base = (config.basePath ?? '/auth').replace(/\/$/, '');
|
|
352
|
+
const scopes = config.scopes ?? ['openid', 'profile', 'email'];
|
|
353
|
+
const cookie = createSessionCookie({
|
|
354
|
+
secret: config.cookieSecret,
|
|
355
|
+
name: config.cookieName,
|
|
356
|
+
secure: config.secureCookie,
|
|
357
|
+
});
|
|
89
358
|
return async function middleware(request, next) {
|
|
90
359
|
const url = new URL(request.url);
|
|
91
360
|
if (!url.pathname.startsWith(`${base}/`))
|
|
92
361
|
return next();
|
|
93
362
|
const route = url.pathname.slice(base.length);
|
|
94
363
|
const session = async () => {
|
|
95
|
-
|
|
364
|
+
const current = await cookie.read(request);
|
|
365
|
+
return current && (await endedAtProvider(config, current)) ? null : current;
|
|
96
366
|
};
|
|
367
|
+
if (route === '/backchannel-logout') {
|
|
368
|
+
// The provider, server to server: a session this app took part in has ended there.
|
|
369
|
+
const headers = { 'cache-control': 'no-store' };
|
|
370
|
+
if (request.method !== 'POST')
|
|
371
|
+
return json({ error: 'invalid_request' }, 405, headers);
|
|
372
|
+
try {
|
|
373
|
+
const form = await request.formData();
|
|
374
|
+
const token = form.get('logout_token');
|
|
375
|
+
if (typeof token !== 'string' || !token)
|
|
376
|
+
throw new Error('no logout_token');
|
|
377
|
+
const endpoints = await resolve();
|
|
378
|
+
const issuer = endpoints.issuer ?? config.issuer;
|
|
379
|
+
if (!issuer)
|
|
380
|
+
throw new Error('no issuer');
|
|
381
|
+
if (!endpoints.jwks)
|
|
382
|
+
throw new Error('no jwks_uri');
|
|
383
|
+
// Read fresh for each logout (they are rare), so a rotated key is always known.
|
|
384
|
+
const jwks = (await (await fetch(endpoints.jwks)).json());
|
|
385
|
+
const entry = await logoutTokenVerifier({ issuer, clientId: config.clientId, jwks })(token);
|
|
386
|
+
await logoutsOf(config).add(entry);
|
|
387
|
+
return new Response(null, { status: 200, headers });
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return json({ error: 'invalid_request' }, 400, headers);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
97
393
|
if (route === '/login') {
|
|
98
|
-
|
|
394
|
+
let authorization;
|
|
395
|
+
try {
|
|
396
|
+
({ authorization } = await resolve());
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (!(error instanceof OAuthError) || error.code !== 'discovery_unreachable')
|
|
400
|
+
throw error;
|
|
401
|
+
// The provider is down: back to the app, which can say so, rather than an error page.
|
|
402
|
+
const back = safeReturnTo(url.searchParams.get('returnTo'), {
|
|
403
|
+
fallback: config.defaultReturnTo ?? '/',
|
|
404
|
+
origin: url.origin,
|
|
405
|
+
allowedOrigins: config.allowedReturnOrigins,
|
|
406
|
+
});
|
|
407
|
+
const target = new URL(back, url.origin);
|
|
408
|
+
target.searchParams.set('auth_error', 'provider_unreachable');
|
|
409
|
+
const location = target.origin === url.origin ? target.pathname + target.search : target.toString();
|
|
410
|
+
return new Response(null, { status: 302, headers: { location } });
|
|
411
|
+
}
|
|
99
412
|
const { verifier, challenge } = await createPkcePair();
|
|
100
|
-
|
|
413
|
+
// A retry carries its mark in the state itself: the one place that survives the trip
|
|
414
|
+
// when the cookie does not, so a second failed attempt is told apart from the first.
|
|
415
|
+
const retry = url.searchParams.get('retry') === '1';
|
|
416
|
+
const state = createStateValue() + (retry ? RETRY_MARK : '');
|
|
101
417
|
const target = new URL(authorization);
|
|
102
418
|
target.searchParams.set('response_type', 'code');
|
|
103
419
|
target.searchParams.set('client_id', config.clientId);
|
|
@@ -106,6 +422,10 @@ export function oauthRoutes(config) {
|
|
|
106
422
|
target.searchParams.set('state', state);
|
|
107
423
|
target.searchParams.set('code_challenge', challenge);
|
|
108
424
|
target.searchParams.set('code_challenge_method', 'S256');
|
|
425
|
+
// Only the values OpenID Connect defines; anything else from the query string is dropped.
|
|
426
|
+
const prompt = url.searchParams.get('prompt');
|
|
427
|
+
if (prompt && PROMPTS.has(prompt))
|
|
428
|
+
target.searchParams.set('prompt', prompt);
|
|
109
429
|
// The verifier goes in the cookie, which is the only thing that survives the trip
|
|
110
430
|
// and is not readable by the page.
|
|
111
431
|
const pendingCookie = await cookie.write({
|
|
@@ -120,6 +440,7 @@ export function oauthRoutes(config) {
|
|
|
120
440
|
origin: url.origin,
|
|
121
441
|
allowedOrigins: config.allowedReturnOrigins,
|
|
122
442
|
}) || undefined,
|
|
443
|
+
silent: prompt === 'none' || undefined,
|
|
123
444
|
},
|
|
124
445
|
},
|
|
125
446
|
// Ten minutes: long enough to sign in, short enough that an abandoned attempt
|
|
@@ -137,26 +458,71 @@ export function oauthRoutes(config) {
|
|
|
137
458
|
const current = await session();
|
|
138
459
|
const pending = current?.pending;
|
|
139
460
|
const error = url.searchParams.get('error');
|
|
140
|
-
if (error)
|
|
141
|
-
|
|
461
|
+
if (error) {
|
|
462
|
+
// Back to the page the flow started from, never a JSON answer the visitor reads.
|
|
463
|
+
const ours = !!pending && timingSafeEqual(pending.state, url.searchParams.get('state') ?? '');
|
|
464
|
+
let location = safeReturnTo(ours ? pending.returnTo ?? config.defaultReturnTo : config.defaultReturnTo, {
|
|
465
|
+
fallback: '/',
|
|
466
|
+
origin: url.origin,
|
|
467
|
+
allowedOrigins: config.allowedReturnOrigins,
|
|
468
|
+
});
|
|
469
|
+
const headers = new Headers();
|
|
470
|
+
headers.append('set-cookie', cookie.clear());
|
|
471
|
+
if (ours && pending.silent && SILENT_REFUSALS.has(error)) {
|
|
472
|
+
// Nobody signed in at the provider: remember it was asked, and show the page as is.
|
|
473
|
+
headers.append('set-cookie', checkedCookie(config, sessionStateFrom(url)).header);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
const target = new URL(location, url.origin);
|
|
477
|
+
target.searchParams.set('auth_error', error);
|
|
478
|
+
location = target.origin === url.origin ? target.pathname + target.search : target.toString();
|
|
479
|
+
}
|
|
480
|
+
headers.set('location', location);
|
|
481
|
+
return new Response(null, { status: 302, headers });
|
|
482
|
+
}
|
|
142
483
|
const code = url.searchParams.get('code');
|
|
143
484
|
const state = url.searchParams.get('state');
|
|
144
485
|
if (!code)
|
|
145
486
|
return json({ error: 'no_code' }, 400);
|
|
146
487
|
if (!pending || !state || !timingSafeEqual(pending.state, state)) {
|
|
147
|
-
|
|
488
|
+
// An answer this browser has no flow for: its cookie expired (a sign-in that took longer
|
|
489
|
+
// than ten minutes, a provider page refreshed long after), or another tab started a
|
|
490
|
+
// newer one. Already signed in, the answer is a leftover. Otherwise start again once,
|
|
491
|
+
// and only then give up, on a page rather than with JSON.
|
|
492
|
+
const home = safeReturnTo(config.defaultReturnTo, { fallback: '/', origin: url.origin, allowedOrigins: config.allowedReturnOrigins });
|
|
493
|
+
if (current?.accessToken)
|
|
494
|
+
return new Response(null, { status: 302, headers: { location: home } });
|
|
495
|
+
if (!state?.endsWith(RETRY_MARK)) {
|
|
496
|
+
return new Response(null, { status: 302, headers: { location: `${base}/login?retry=1` } });
|
|
497
|
+
}
|
|
498
|
+
const target = new URL(home, url.origin);
|
|
499
|
+
target.searchParams.set('auth_error', 'state_mismatch');
|
|
500
|
+
return new Response(null, {
|
|
501
|
+
status: 302,
|
|
502
|
+
headers: {
|
|
503
|
+
location: target.origin === url.origin ? target.pathname + target.search : target.toString(),
|
|
504
|
+
'set-cookie': cookie.clear(),
|
|
505
|
+
},
|
|
506
|
+
});
|
|
148
507
|
}
|
|
508
|
+
// The token is bound here, at the exchange, so this is where the session's key is made.
|
|
509
|
+
const key = config.dpop
|
|
510
|
+
? await createDpopKey(config.dpopAlgorithm, { extractable: true })
|
|
511
|
+
: null;
|
|
149
512
|
const payload = await tokenRequest({
|
|
150
513
|
grant_type: 'authorization_code',
|
|
151
514
|
code,
|
|
152
515
|
redirect_uri: config.redirectUri,
|
|
153
516
|
code_verifier: pending.verifier,
|
|
154
|
-
});
|
|
517
|
+
}, key);
|
|
155
518
|
const signedIn = await cookie.write({
|
|
156
519
|
accessToken: String(payload.access_token ?? ''),
|
|
157
520
|
refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : undefined,
|
|
158
521
|
idToken: typeof payload.id_token === 'string' ? payload.id_token : undefined,
|
|
159
522
|
expiresAt: expiryFrom(payload.expires_in),
|
|
523
|
+
tokenType: typeof payload.token_type === 'string' ? payload.token_type : undefined,
|
|
524
|
+
dpop: key ? await exportDpopKey(key) : undefined,
|
|
525
|
+
sessionState: sessionStateFrom(url),
|
|
160
526
|
});
|
|
161
527
|
return new Response(null, {
|
|
162
528
|
status: 302,
|
|
@@ -177,21 +543,10 @@ export function oauthRoutes(config) {
|
|
|
177
543
|
const current = await session();
|
|
178
544
|
if (!current?.refreshToken)
|
|
179
545
|
return json({ error: 'no_session' }, 401);
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
});
|
|
184
|
-
const refreshed = await cookie.write({
|
|
185
|
-
accessToken: String(payload.access_token ?? ''),
|
|
186
|
-
// A provider that rotates refresh tokens sends a new one; one that does not
|
|
187
|
-
// expects the old one to keep working.
|
|
188
|
-
refreshToken: typeof payload.refresh_token === 'string'
|
|
189
|
-
? payload.refresh_token
|
|
190
|
-
: current.refreshToken,
|
|
191
|
-
idToken: current.idToken,
|
|
192
|
-
expiresAt: expiryFrom(payload.expires_in),
|
|
193
|
-
});
|
|
194
|
-
return json({ ok: true }, 200, { 'set-cookie': refreshed });
|
|
546
|
+
const renewed = await renew(current, tokenRequest);
|
|
547
|
+
if (!renewed)
|
|
548
|
+
return json({ error: 'refresh_refused' }, 401, { 'set-cookie': cookie.clear() });
|
|
549
|
+
return json({ ok: true }, 200, { 'set-cookie': await cookie.write(renewed) });
|
|
195
550
|
}
|
|
196
551
|
if (route === '/me') {
|
|
197
552
|
const current = await session();
|
|
@@ -200,14 +555,22 @@ export function oauthRoutes(config) {
|
|
|
200
555
|
const { userinfo } = await resolve();
|
|
201
556
|
if (!userinfo)
|
|
202
557
|
return json({ error: 'no_userinfo' }, 501);
|
|
203
|
-
const response = await
|
|
204
|
-
headers: { authorization: `Bearer ${current.accessToken}` },
|
|
205
|
-
});
|
|
558
|
+
const response = await withToken(userinfo, current);
|
|
206
559
|
if (!response.ok)
|
|
207
560
|
return json({ error: 'userinfo_failed' }, response.status);
|
|
208
561
|
const claims = (await response.json());
|
|
209
562
|
return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
|
|
210
563
|
}
|
|
564
|
+
if (route === '/session-check') {
|
|
565
|
+
// What the page needs to watch the provider's session: its check page, this client,
|
|
566
|
+
// and the last answer's session_state, signed in or not. Nothing secret: the state is
|
|
567
|
+
// a salted hash bound to this origin.
|
|
568
|
+
const { checkSession } = await resolve().catch(() => ({ checkSession: undefined }));
|
|
569
|
+
const current = await session();
|
|
570
|
+
const checked = cookieValue(request, checkedCookie(config).name);
|
|
571
|
+
const state = current?.accessToken ? current.sessionState : checked !== '1' ? checked : undefined;
|
|
572
|
+
return json({ frame: checkSession ?? null, clientId: config.clientId, state: state ?? null });
|
|
573
|
+
}
|
|
211
574
|
if (route === '/revoke') {
|
|
212
575
|
const current = await session();
|
|
213
576
|
const hint = url.searchParams.get('hint') === 'access_token' ? 'access_token' : 'refresh_token';
|
|
@@ -248,18 +611,21 @@ export function oauthRoutes(config) {
|
|
|
248
611
|
}
|
|
249
612
|
// Max-Age=0 is what removes it. Clearing the value alone leaves a cookie behind.
|
|
250
613
|
const cleared = cookie.clear();
|
|
614
|
+
// Signed out on purpose: `silent` must not sign them straight back in.
|
|
615
|
+
const headers = new Headers();
|
|
616
|
+
headers.append('set-cookie', cleared);
|
|
617
|
+
headers.append('set-cookie', checkedCookie(config).header);
|
|
251
618
|
if (endSession && current?.idToken) {
|
|
252
619
|
const target = new URL(endSession);
|
|
253
620
|
target.searchParams.set('id_token_hint', current.idToken);
|
|
254
621
|
if (config.defaultReturnTo) {
|
|
255
622
|
target.searchParams.set('post_logout_redirect_uri', config.defaultReturnTo);
|
|
256
623
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
headers: { location: target.toString(), 'set-cookie': cleared },
|
|
260
|
-
});
|
|
624
|
+
headers.set('location', target.toString());
|
|
625
|
+
return new Response(null, { status: 302, headers });
|
|
261
626
|
}
|
|
262
|
-
|
|
627
|
+
headers.set('content-type', 'application/json');
|
|
628
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
|
263
629
|
}
|
|
264
630
|
return next();
|
|
265
631
|
};
|
|
@@ -295,6 +661,7 @@ export function sessionLocals(config) {
|
|
|
295
661
|
name: config.cookieName,
|
|
296
662
|
secure: config.secureCookie,
|
|
297
663
|
});
|
|
664
|
+
const { tokenRequest } = tokenClient(config);
|
|
298
665
|
const ttl = config.userCacheMs ?? 30_000;
|
|
299
666
|
/**
|
|
300
667
|
* Keyed by the access token, which this process already holds in the cookie it just
|
|
@@ -322,25 +689,78 @@ export function sessionLocals(config) {
|
|
|
322
689
|
return async function middleware(request, next) {
|
|
323
690
|
const { getRequestLocals } = await import('@fluixi/server');
|
|
324
691
|
const locals = getRequestLocals(request);
|
|
325
|
-
|
|
692
|
+
let session = await cookie.read(request);
|
|
693
|
+
// What the browser's cookie must become: renewed tokens, or nothing.
|
|
694
|
+
let setCookie;
|
|
695
|
+
const respond = async (response) => {
|
|
696
|
+
if (!setCookie)
|
|
697
|
+
return response;
|
|
698
|
+
const copy = new Response(response.body, response);
|
|
699
|
+
copy.headers.append('set-cookie', setCookie);
|
|
700
|
+
return copy;
|
|
701
|
+
};
|
|
702
|
+
const renewOrDrop = async (current) => {
|
|
703
|
+
const renewed = current.refreshToken ? await renew(current, tokenRequest) : null;
|
|
704
|
+
setCookie = renewed ? await cookie.write(renewed) : cookie.clear();
|
|
705
|
+
return renewed;
|
|
706
|
+
};
|
|
707
|
+
// An access token lives minutes, the session days: renew it here, on the request that
|
|
708
|
+
// finds it expired, rather than rendering a signed-in visitor as nobody.
|
|
709
|
+
// Set when the provider could not renew now: the session stays, it is retried later.
|
|
710
|
+
let paused = false;
|
|
711
|
+
const keep = (current) => (error) => {
|
|
712
|
+
if (error instanceof ProviderUnavailable)
|
|
713
|
+
paused = true;
|
|
714
|
+
return current;
|
|
715
|
+
};
|
|
716
|
+
if (session?.accessToken && expired(session)) {
|
|
717
|
+
session = await renewOrDrop(session).catch(keep(session));
|
|
718
|
+
}
|
|
326
719
|
// Written either way. A route that reads locals should find "nobody" rather than
|
|
327
720
|
// nothing, so the absent case and the signed-out case look the same to it.
|
|
328
|
-
|
|
721
|
+
const signedOut = async () => {
|
|
722
|
+
const redirect = signInRedirect(request, config);
|
|
723
|
+
if (redirect)
|
|
724
|
+
return respond(redirect);
|
|
329
725
|
locals.auth = { user: null, session: null };
|
|
330
|
-
return next();
|
|
726
|
+
return respond(await next());
|
|
727
|
+
};
|
|
728
|
+
if (!session?.accessToken)
|
|
729
|
+
return signedOut();
|
|
730
|
+
// Ended at the provider (back-channel logout): this session is over too.
|
|
731
|
+
if (await endedAtProvider(config, session)) {
|
|
732
|
+
setCookie = cookie.clear();
|
|
733
|
+
session = null;
|
|
734
|
+
return signedOut();
|
|
331
735
|
}
|
|
332
|
-
|
|
736
|
+
// Server code calls the app's APIs as this user with it; the token itself stays here.
|
|
737
|
+
const live = session;
|
|
738
|
+
const fetchAs = (input, init) => sendAs(live, input, init);
|
|
739
|
+
const cached = cache.get(live.accessToken);
|
|
333
740
|
if (cached && cached.until > Date.now()) {
|
|
334
|
-
locals.auth = { user: cached.user, session: { expiresAt:
|
|
335
|
-
return next();
|
|
741
|
+
locals.auth = { user: cached.user, session: { expiresAt: live.expiresAt }, fetch: fetchAs };
|
|
742
|
+
return respond(await next());
|
|
336
743
|
}
|
|
337
744
|
let user = null;
|
|
745
|
+
let unreachable = false;
|
|
338
746
|
try {
|
|
339
747
|
const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
|
|
340
748
|
if (endpoints?.userinfo) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
749
|
+
let response = await withToken(endpoints.userinfo, session);
|
|
750
|
+
// Refused before its time (revoked, a clock apart): renew once, and when that is
|
|
751
|
+
// refused too the session is over, so the visitor goes the signed-out way.
|
|
752
|
+
if (response.status === 401 && !setCookie && !paused) {
|
|
753
|
+
session = await renewOrDrop(session).catch(keep(session));
|
|
754
|
+
if (!session)
|
|
755
|
+
return signedOut();
|
|
756
|
+
response = await withToken(endpoints.userinfo, session);
|
|
757
|
+
}
|
|
758
|
+
if (response.status === 401) {
|
|
759
|
+
// Not renewable just now: signed out for this request, renewed on a later one.
|
|
760
|
+
if (!paused)
|
|
761
|
+
setCookie = cookie.clear();
|
|
762
|
+
return signedOut();
|
|
763
|
+
}
|
|
344
764
|
if (response.ok) {
|
|
345
765
|
const claims = (await response.json());
|
|
346
766
|
user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
|
|
@@ -352,9 +772,42 @@ export function sessionLocals(config) {
|
|
|
352
772
|
}
|
|
353
773
|
catch {
|
|
354
774
|
// A provider that is unreachable should not take the page down with it. The render
|
|
355
|
-
// proceeds as signed out, and the client retries on its own
|
|
775
|
+
// proceeds as signed out, and the client retries on its own; `unreachable` tells
|
|
776
|
+
// server code it is not the visitor who is signed out.
|
|
777
|
+
unreachable = true;
|
|
356
778
|
}
|
|
357
|
-
|
|
358
|
-
|
|
779
|
+
if (!session)
|
|
780
|
+
return signedOut();
|
|
781
|
+
const current = session;
|
|
782
|
+
const fetchCurrent = (input, init) => sendAs(current, input, init);
|
|
783
|
+
locals.auth = user
|
|
784
|
+
? { user, session: { expiresAt: current.expiresAt }, fetch: fetchCurrent }
|
|
785
|
+
: { user: null, session: null, ...(unreachable ? { unreachable: true } : {}) };
|
|
786
|
+
return respond(await next());
|
|
359
787
|
};
|
|
360
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* The request's auth, for server code: a route's data loader, a server function, an API route.
|
|
791
|
+
* Reads the current request's locals, or `request`'s when one is given.
|
|
792
|
+
*
|
|
793
|
+
* ```ts
|
|
794
|
+
* const { fetch } = await requestAuth();
|
|
795
|
+
* const projects = await fetch?.(`${apiUrl}/projects`).then((r) => r.json());
|
|
796
|
+
* ```
|
|
797
|
+
*/
|
|
798
|
+
export async function requestAuth(request) {
|
|
799
|
+
const { getLocals, getRequestLocals } = await import('@fluixi/server');
|
|
800
|
+
const locals = (request ? getRequestLocals(request) : getLocals());
|
|
801
|
+
return locals.auth ?? { user: null, session: null };
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* The server half of `authModule`, listed in `src/app.config.server.ts`: the routes and the
|
|
805
|
+
* session middleware that `tokens: 'server'` needs, in the order they have to run.
|
|
806
|
+
*
|
|
807
|
+
* `oauthRoutes` first, so a request to the login route is answered rather than having its
|
|
808
|
+
* session resolved on the way past.
|
|
809
|
+
*/
|
|
810
|
+
export function authServerModule(config) {
|
|
811
|
+
return defineModule({ name: 'oauth2-server', middleware: [oauthRoutes(config), sessionLocals(config)] });
|
|
812
|
+
}
|
|
813
|
+
export { memoryLogouts } from './backchannel.js';
|