@atlasauth/backend 0.8.0 → 0.9.1
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/CHANGELOG.md +22 -0
- package/README.md +147 -0
- package/dist/client/branding.d.ts +20 -0
- package/dist/client/branding.js.map +1 -1
- package/dist/client/diagnostics.d.ts +16 -0
- package/dist/client/diagnostics.js.map +1 -1
- package/dist/client/email-templates.d.ts +1 -1
- package/dist/client/email-templates.js.map +1 -1
- package/dist/client/error.d.ts +10 -0
- package/dist/client/error.js +10 -0
- package/dist/client/error.js.map +1 -1
- package/dist/client/index.d.ts +11 -1
- package/dist/client/instance.d.ts +30 -2
- package/dist/client/instance.js.map +1 -1
- package/dist/client/messaging.d.ts +85 -8
- package/dist/client/messaging.js +19 -1
- package/dist/client/messaging.js.map +1 -1
- package/dist/client/oauth-clients.d.ts +25 -1
- package/dist/client/oauth-clients.js +11 -2
- package/dist/client/oauth-clients.js.map +1 -1
- package/dist/client/platform.d.ts +22 -5
- package/dist/client/platform.js.map +1 -1
- package/dist/client/users.d.ts +15 -0
- package/dist/client/users.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/oidc.d.ts +225 -0
- package/dist/oidc.js +631 -0
- package/dist/oidc.js.map +1 -0
- package/dist/testing.d.ts +90 -1
- package/dist/testing.js +202 -3
- package/dist/testing.js.map +1 -1
- package/dist/verify.d.ts +21 -0
- package/dist/verify.js +20 -0
- package/dist/verify.js.map +1 -1
- package/dist/webhooks.js +32 -7
- package/dist/webhooks.js.map +1 -1
- package/package.json +1 -1
package/dist/oidc.js
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.oidc = exports.OIDC_FLOW_MAX_AGE_SECONDS = exports.OIDC_FLOW_COOKIE = exports.AtlasOidcError = void 0;
|
|
4
|
+
exports.oidcStart = oidcStart;
|
|
5
|
+
exports.oidcCallback = oidcCallback;
|
|
6
|
+
exports.oidcVerifyIdToken = oidcVerifyIdToken;
|
|
7
|
+
exports.oidcLogoutUrl = oidcLogoutUrl;
|
|
8
|
+
exports.oidcClearCookie = oidcClearCookie;
|
|
9
|
+
exports.resetOidcCaches = resetOidcCaches;
|
|
10
|
+
const jose_1 = require("jose");
|
|
11
|
+
const handshake_1 = require("./handshake");
|
|
12
|
+
const jwks_cache_1 = require("./jwks-cache");
|
|
13
|
+
class AtlasOidcError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
/** The OAuth `error` value from the authorization or token endpoint, when there was one. */
|
|
16
|
+
oauthError;
|
|
17
|
+
/** The OAuth `error_description`, when there was one. */
|
|
18
|
+
description;
|
|
19
|
+
/** HTTP status of the token endpoint's answer, for `token_exchange_failed`. */
|
|
20
|
+
status;
|
|
21
|
+
/**
|
|
22
|
+
* A `Set-Cookie` value that removes the flow cookie. Send it with your error
|
|
23
|
+
* response too, so a failed sign-in leaves nothing behind. Set on every error
|
|
24
|
+
* thrown by {@link oidcCallback}.
|
|
25
|
+
*/
|
|
26
|
+
clearCookie;
|
|
27
|
+
constructor(code, message, extra = {}) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'AtlasOidcError';
|
|
30
|
+
this.code = code;
|
|
31
|
+
if (extra.cause !== undefined)
|
|
32
|
+
this.cause = extra.cause;
|
|
33
|
+
if (extra.oauthError !== undefined)
|
|
34
|
+
this.oauthError = extra.oauthError;
|
|
35
|
+
if (extra.description !== undefined)
|
|
36
|
+
this.description = extra.description;
|
|
37
|
+
if (extra.status !== undefined)
|
|
38
|
+
this.status = extra.status;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.AtlasOidcError = AtlasOidcError;
|
|
42
|
+
/** The default flow cookie name. */
|
|
43
|
+
exports.OIDC_FLOW_COOKIE = 'atlas_oidc';
|
|
44
|
+
/** How long a started sign-in stays valid, in seconds. */
|
|
45
|
+
exports.OIDC_FLOW_MAX_AGE_SECONDS = 600;
|
|
46
|
+
const OWNED_PARAMS = new Set([
|
|
47
|
+
'response_type',
|
|
48
|
+
'client_id',
|
|
49
|
+
'redirect_uri',
|
|
50
|
+
'state',
|
|
51
|
+
'nonce',
|
|
52
|
+
'code_challenge',
|
|
53
|
+
'code_challenge_method',
|
|
54
|
+
]);
|
|
55
|
+
/**
|
|
56
|
+
* Begin a sign-in: build the authorize URL and the flow cookie. Send `cookie`
|
|
57
|
+
* as `Set-Cookie` and redirect to `url`.
|
|
58
|
+
*/
|
|
59
|
+
async function oidcStart(options) {
|
|
60
|
+
requireString(options.issuer, 'issuer');
|
|
61
|
+
requireString(options.clientId, 'clientId');
|
|
62
|
+
const redirect = parseRedirectUri(options.redirectUri);
|
|
63
|
+
const discovery = await discover(options);
|
|
64
|
+
const state = randomToken();
|
|
65
|
+
const nonce = randomToken();
|
|
66
|
+
const pkce = await (0, handshake_1.createPkcePair)();
|
|
67
|
+
const scope = normaliseScope(options.scope);
|
|
68
|
+
const url = new URL(discovery.authorization_endpoint);
|
|
69
|
+
for (const [key, value] of Object.entries(options.extraParams ?? {})) {
|
|
70
|
+
if (value !== undefined && !OWNED_PARAMS.has(key))
|
|
71
|
+
url.searchParams.set(key, value);
|
|
72
|
+
}
|
|
73
|
+
url.searchParams.set('response_type', 'code');
|
|
74
|
+
url.searchParams.set('client_id', options.clientId);
|
|
75
|
+
url.searchParams.set('redirect_uri', options.redirectUri);
|
|
76
|
+
url.searchParams.set('scope', scope);
|
|
77
|
+
url.searchParams.set('state', state);
|
|
78
|
+
url.searchParams.set('nonce', nonce);
|
|
79
|
+
url.searchParams.set('code_challenge', pkce.challenge);
|
|
80
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
81
|
+
if (options.prompt)
|
|
82
|
+
url.searchParams.set('prompt', options.prompt);
|
|
83
|
+
if (options.loginHint)
|
|
84
|
+
url.searchParams.set('login_hint', options.loginHint);
|
|
85
|
+
if (options.audienceHint)
|
|
86
|
+
url.searchParams.set('audience_hint', options.audienceHint);
|
|
87
|
+
const payload = {
|
|
88
|
+
s: state,
|
|
89
|
+
n: nonce,
|
|
90
|
+
v: pkce.verifier,
|
|
91
|
+
r: options.redirectUri,
|
|
92
|
+
i: stripSlash(options.issuer),
|
|
93
|
+
c: options.clientId,
|
|
94
|
+
t: Math.floor(clock(options) / 1000),
|
|
95
|
+
};
|
|
96
|
+
const value = await sealFlow(payload, options.secret);
|
|
97
|
+
const cookie = serializeCookie(options.cookieName ?? exports.OIDC_FLOW_COOKIE, value, {
|
|
98
|
+
path: redirect.pathname,
|
|
99
|
+
maxAge: exports.OIDC_FLOW_MAX_AGE_SECONDS,
|
|
100
|
+
secure: redirect.protocol === 'https:',
|
|
101
|
+
});
|
|
102
|
+
return { url: url.toString(), cookie, state, nonce };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Finish a sign-in: check `state`, exchange the code (with PKCE and client
|
|
106
|
+
* authentication), verify the ID token, and return who signed in.
|
|
107
|
+
*/
|
|
108
|
+
async function oidcCallback(options) {
|
|
109
|
+
const name = options.cookieName ?? exports.OIDC_FLOW_COOKIE;
|
|
110
|
+
const clearCookie = oidcClearCookie({ redirectUri: options.redirectUri, cookieName: name });
|
|
111
|
+
try {
|
|
112
|
+
return { ...(await runCallback(options, name)), clearCookie };
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const failure = error instanceof AtlasOidcError
|
|
116
|
+
? error
|
|
117
|
+
: new AtlasOidcError('invalid_request', error?.message ?? String(error), { cause: error });
|
|
118
|
+
failure.clearCookie = clearCookie;
|
|
119
|
+
throw failure;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function runCallback(options, cookieName) {
|
|
123
|
+
requireString(options.issuer, 'issuer');
|
|
124
|
+
requireString(options.clientId, 'clientId');
|
|
125
|
+
const issuer = stripSlash(options.issuer);
|
|
126
|
+
const query = toParams(options.query);
|
|
127
|
+
// The flow cookie first: without it nothing in the query can be trusted, and
|
|
128
|
+
// an authorization `error` for a flow we never started is not ours to report.
|
|
129
|
+
const raw = readFlowCookie(options.cookie, cookieName);
|
|
130
|
+
if (!raw)
|
|
131
|
+
throw new AtlasOidcError('state_mismatch', 'The sign-in flow cookie is missing or expired. Start sign-in again.');
|
|
132
|
+
const flow = await openFlow(raw, options.secret);
|
|
133
|
+
if (!flow)
|
|
134
|
+
throw new AtlasOidcError('state_mismatch', 'The sign-in flow cookie could not be read. Start sign-in again.');
|
|
135
|
+
const state = query.get('state');
|
|
136
|
+
if (!state || !timingSafeEqual(state, flow.s)) {
|
|
137
|
+
throw new AtlasOidcError('state_mismatch', 'The `state` returned to the callback does not match this browser\'s sign-in.');
|
|
138
|
+
}
|
|
139
|
+
const nowSec = Math.floor(clock(options) / 1000);
|
|
140
|
+
if (nowSec - flow.t > exports.OIDC_FLOW_MAX_AGE_SECONDS) {
|
|
141
|
+
throw new AtlasOidcError('state_mismatch', 'This sign-in took longer than 10 minutes. Start sign-in again.');
|
|
142
|
+
}
|
|
143
|
+
if (flow.i !== issuer || flow.c !== options.clientId || flow.r !== options.redirectUri) {
|
|
144
|
+
throw new AtlasOidcError('state_mismatch', 'This sign-in was started for a different issuer, client or redirect URI.');
|
|
145
|
+
}
|
|
146
|
+
// RFC 9207: an authorization server that names itself must be the one we sent the user to.
|
|
147
|
+
const returnedIss = query.get('iss');
|
|
148
|
+
if (returnedIss !== null && stripSlash(returnedIss) !== issuer) {
|
|
149
|
+
throw new AtlasOidcError('issuer_mismatch', `The callback came from ${returnedIss}, not ${issuer}.`);
|
|
150
|
+
}
|
|
151
|
+
const oauthError = query.get('error');
|
|
152
|
+
if (oauthError) {
|
|
153
|
+
const description = query.get('error_description') ?? undefined;
|
|
154
|
+
throw new AtlasOidcError('access_denied', `Sign-in did not complete: ${oauthError}${description ? ` (${description})` : ''}.`, { oauthError, description });
|
|
155
|
+
}
|
|
156
|
+
const code = query.get('code');
|
|
157
|
+
if (!code)
|
|
158
|
+
throw new AtlasOidcError('invalid_request', 'The callback has no `code` parameter.');
|
|
159
|
+
const discovery = await discover(options);
|
|
160
|
+
const tokens = await exchangeCode(discovery, options, code, flow.v);
|
|
161
|
+
const claims = await verifyIdToken(tokens.id_token, discovery, {
|
|
162
|
+
clientId: options.clientId,
|
|
163
|
+
nonce: flow.n,
|
|
164
|
+
accessToken: tokens.access_token,
|
|
165
|
+
clockToleranceSec: options.clockToleranceSec ?? 60,
|
|
166
|
+
fetchImpl: options.fetchImpl,
|
|
167
|
+
now: options.now,
|
|
168
|
+
});
|
|
169
|
+
const sub = claims.sub;
|
|
170
|
+
const expiresIn = typeof tokens.expires_in === 'number' ? tokens.expires_in : Number(tokens.expires_in);
|
|
171
|
+
const expiresAt = Number.isFinite(expiresIn) && expiresIn > 0
|
|
172
|
+
? clock(options) + expiresIn * 1000
|
|
173
|
+
: claims.exp * 1000;
|
|
174
|
+
return {
|
|
175
|
+
sub,
|
|
176
|
+
namespacedSub: `${issuer}#${sub}`,
|
|
177
|
+
email: typeof claims.email === 'string' ? claims.email : null,
|
|
178
|
+
emailVerified: claims.email_verified === true,
|
|
179
|
+
name: typeof claims.name === 'string' ? claims.name : null,
|
|
180
|
+
idToken: tokens.id_token,
|
|
181
|
+
accessToken: tokens.access_token,
|
|
182
|
+
...(typeof tokens.refresh_token === 'string' ? { refreshToken: tokens.refresh_token } : {}),
|
|
183
|
+
expiresAt,
|
|
184
|
+
claims,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
async function exchangeCode(discovery, options, code, verifier) {
|
|
188
|
+
const method = options.clientAuth ?? (options.clientSecret ? 'client_secret_basic' : 'none');
|
|
189
|
+
if (method !== 'none' && !options.clientSecret) {
|
|
190
|
+
throw new AtlasOidcError('token_exchange_failed', `clientAuth '${method}' needs \`clientSecret\`.`);
|
|
191
|
+
}
|
|
192
|
+
const body = new URLSearchParams({
|
|
193
|
+
grant_type: 'authorization_code',
|
|
194
|
+
code,
|
|
195
|
+
redirect_uri: options.redirectUri,
|
|
196
|
+
code_verifier: verifier,
|
|
197
|
+
});
|
|
198
|
+
const headers = {
|
|
199
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
200
|
+
accept: 'application/json',
|
|
201
|
+
};
|
|
202
|
+
if (method === 'client_secret_basic') {
|
|
203
|
+
// RFC 6749 §2.3.1: both halves are form-encoded before Basic encoding.
|
|
204
|
+
headers.authorization = `Basic ${btoa(`${encodeURIComponent(options.clientId)}:${encodeURIComponent(options.clientSecret)}`)}`;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
body.set('client_id', options.clientId);
|
|
208
|
+
if (method === 'client_secret_post')
|
|
209
|
+
body.set('client_secret', options.clientSecret);
|
|
210
|
+
}
|
|
211
|
+
let response;
|
|
212
|
+
try {
|
|
213
|
+
response = await (options.fetchImpl ?? fetch)(discovery.token_endpoint, { method: 'POST', headers, body });
|
|
214
|
+
}
|
|
215
|
+
catch (cause) {
|
|
216
|
+
throw new AtlasOidcError('token_exchange_failed', `The token endpoint could not be reached: ${errText(cause)}.`, { cause });
|
|
217
|
+
}
|
|
218
|
+
const json = (await response.json().catch(() => null));
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
const oauthError = typeof json?.error === 'string' ? json.error : undefined;
|
|
221
|
+
const description = typeof json?.error_description === 'string' ? json.error_description : undefined;
|
|
222
|
+
throw new AtlasOidcError('token_exchange_failed', `The token endpoint answered ${response.status}${oauthError ? ` ${oauthError}` : ''}${description ? `: ${description}` : ''}.`, { oauthError, description, status: response.status });
|
|
223
|
+
}
|
|
224
|
+
if (!json || typeof json.id_token !== 'string' || typeof json.access_token !== 'string') {
|
|
225
|
+
throw new AtlasOidcError('token_exchange_failed', 'The token response has no `id_token`. Request the `openid` scope.', { status: response.status });
|
|
226
|
+
}
|
|
227
|
+
return json;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Verify an ID token on its own (for an app that does its own code exchange).
|
|
231
|
+
* Same checks as {@link oidcCallback}. Throws {@link AtlasOidcError}.
|
|
232
|
+
*/
|
|
233
|
+
async function oidcVerifyIdToken(idToken, options) {
|
|
234
|
+
const discovery = await discover({ ...options, clientId: options.clientId });
|
|
235
|
+
return verifyIdToken(idToken, discovery, {
|
|
236
|
+
clientId: options.clientId,
|
|
237
|
+
nonce: options.nonce,
|
|
238
|
+
accessToken: options.accessToken,
|
|
239
|
+
clockToleranceSec: options.clockToleranceSec ?? 60,
|
|
240
|
+
fetchImpl: options.fetchImpl,
|
|
241
|
+
now: options.now,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async function verifyIdToken(idToken, discovery, opts) {
|
|
245
|
+
const invalid = (why, cause) => new AtlasOidcError('invalid_token', `The ID token was refused: ${why}.`, cause === undefined ? {} : { cause });
|
|
246
|
+
const header = decodeHeader(idToken);
|
|
247
|
+
if (!header)
|
|
248
|
+
throw invalid('it is not a JWT');
|
|
249
|
+
// Pinned before any key lookup, so `none` and HS* never get near a key.
|
|
250
|
+
if (header.alg !== 'RS256')
|
|
251
|
+
throw invalid(`its algorithm is ${String(header.alg)}, not RS256`);
|
|
252
|
+
const keys = await jwksFor(discovery.jwks_uri, opts).get(typeof header.kid === 'string' ? header.kid : undefined);
|
|
253
|
+
if (!keys || keys.keys.length === 0) {
|
|
254
|
+
throw new AtlasOidcError('discovery_failed', `The issuer's signing keys could not be fetched from ${discovery.jwks_uri}.`);
|
|
255
|
+
}
|
|
256
|
+
let payload;
|
|
257
|
+
try {
|
|
258
|
+
({ payload } = await (0, jose_1.jwtVerify)(idToken, (0, jose_1.createLocalJWKSet)(keys), {
|
|
259
|
+
algorithms: ['RS256'],
|
|
260
|
+
issuer: discovery.issuer,
|
|
261
|
+
audience: opts.clientId,
|
|
262
|
+
clockTolerance: opts.clockToleranceSec,
|
|
263
|
+
currentDate: opts.now ? new Date(opts.now()) : undefined,
|
|
264
|
+
requiredClaims: ['sub', 'iat', 'exp'],
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
catch (cause) {
|
|
268
|
+
throw invalid(joseReason(cause), cause);
|
|
269
|
+
}
|
|
270
|
+
// OIDC Core §3.1.3.7 (4)-(5): several audiences need an azp, and any azp must be us.
|
|
271
|
+
const aud = payload.aud;
|
|
272
|
+
if (Array.isArray(aud) && aud.length > 1 && payload.azp === undefined) {
|
|
273
|
+
throw invalid('it has several audiences and no azp');
|
|
274
|
+
}
|
|
275
|
+
if (payload.azp !== undefined && payload.azp !== opts.clientId) {
|
|
276
|
+
throw invalid(`its azp is ${String(payload.azp)}, not this client`);
|
|
277
|
+
}
|
|
278
|
+
if (typeof payload.nonce !== 'string' || !timingSafeEqual(payload.nonce, opts.nonce)) {
|
|
279
|
+
throw invalid('its nonce does not match this sign-in');
|
|
280
|
+
}
|
|
281
|
+
const nowSec = Math.floor((opts.now ?? Date.now)() / 1000);
|
|
282
|
+
if (typeof payload.iat === 'number' && payload.iat > nowSec + opts.clockToleranceSec) {
|
|
283
|
+
throw invalid('it was issued in the future');
|
|
284
|
+
}
|
|
285
|
+
if (opts.accessToken && typeof payload.at_hash === 'string') {
|
|
286
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(opts.accessToken)));
|
|
287
|
+
if (!timingSafeEqual(base64url(digest.slice(0, 16)), payload.at_hash)) {
|
|
288
|
+
throw invalid('its at_hash does not match the access token');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return payload;
|
|
292
|
+
}
|
|
293
|
+
function joseReason(error) {
|
|
294
|
+
if (error instanceof jose_1.errors.JWTExpired)
|
|
295
|
+
return 'it has expired';
|
|
296
|
+
if (error instanceof jose_1.errors.JWTClaimValidationFailed) {
|
|
297
|
+
if (error.claim === 'aud')
|
|
298
|
+
return 'its audience is not this client';
|
|
299
|
+
if (error.claim === 'iss')
|
|
300
|
+
return 'its issuer is not the configured issuer';
|
|
301
|
+
return `its ${error.claim} claim is invalid`;
|
|
302
|
+
}
|
|
303
|
+
if (error instanceof jose_1.errors.JWSSignatureVerificationFailed)
|
|
304
|
+
return 'its signature does not verify';
|
|
305
|
+
if (error instanceof jose_1.errors.JWKSNoMatchingKey)
|
|
306
|
+
return 'no published key matches it';
|
|
307
|
+
return errText(error);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The RP-initiated logout URL (`end_session_endpoint` from discovery). End your
|
|
311
|
+
* own session first, then redirect here so the Atlas session ends too.
|
|
312
|
+
*/
|
|
313
|
+
async function oidcLogoutUrl(options) {
|
|
314
|
+
const discovery = await discover(options);
|
|
315
|
+
if (!discovery.end_session_endpoint) {
|
|
316
|
+
throw new AtlasOidcError('discovery_failed', 'The issuer does not advertise an end_session_endpoint.');
|
|
317
|
+
}
|
|
318
|
+
const url = new URL(discovery.end_session_endpoint);
|
|
319
|
+
url.searchParams.set('client_id', options.clientId);
|
|
320
|
+
if (options.idTokenHint)
|
|
321
|
+
url.searchParams.set('id_token_hint', options.idTokenHint);
|
|
322
|
+
if (options.postLogoutRedirectUri)
|
|
323
|
+
url.searchParams.set('post_logout_redirect_uri', options.postLogoutRedirectUri);
|
|
324
|
+
if (options.state)
|
|
325
|
+
url.searchParams.set('state', options.state);
|
|
326
|
+
return url.toString();
|
|
327
|
+
}
|
|
328
|
+
/** A `Set-Cookie` value that removes the flow cookie for this callback path. */
|
|
329
|
+
function oidcClearCookie(options) {
|
|
330
|
+
const redirect = parseRedirectUri(options.redirectUri);
|
|
331
|
+
return serializeCookie(options.cookieName ?? exports.OIDC_FLOW_COOKIE, '', {
|
|
332
|
+
path: redirect.pathname,
|
|
333
|
+
maxAge: 0,
|
|
334
|
+
secure: redirect.protocol === 'https:',
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The relying-party helpers as one namespace: `oidc.start`, `oidc.callback`,
|
|
339
|
+
* `oidc.logoutUrl`, `oidc.verifyIdToken`, `oidc.clearCookie`. Import the
|
|
340
|
+
* standalone `oidcStart` / `oidcCallback` / … for the smallest bundle.
|
|
341
|
+
*/
|
|
342
|
+
exports.oidc = {
|
|
343
|
+
start: oidcStart,
|
|
344
|
+
callback: oidcCallback,
|
|
345
|
+
logoutUrl: oidcLogoutUrl,
|
|
346
|
+
verifyIdToken: oidcVerifyIdToken,
|
|
347
|
+
clearCookie: oidcClearCookie,
|
|
348
|
+
};
|
|
349
|
+
/** Clear the discovery and key caches (tests, or after an issuer change). */
|
|
350
|
+
function resetOidcCaches() {
|
|
351
|
+
discoveryCaches = new WeakMap();
|
|
352
|
+
jwksCaches = new WeakMap();
|
|
353
|
+
}
|
|
354
|
+
const DISCOVERY_TTL_MS = 3_600_000;
|
|
355
|
+
const DISCOVERY_RETRY_MS = 60_000;
|
|
356
|
+
// Keyed by the fetch implementation first, so two test issuers (or a test and
|
|
357
|
+
// production code) never share a cached document.
|
|
358
|
+
let discoveryCaches = new WeakMap();
|
|
359
|
+
let jwksCaches = new WeakMap();
|
|
360
|
+
function cacheKeyFor(fetchImpl) {
|
|
361
|
+
return (fetchImpl ?? globalThis.fetch);
|
|
362
|
+
}
|
|
363
|
+
async function discover(options) {
|
|
364
|
+
const issuer = stripSlash(options.issuer);
|
|
365
|
+
const url = options.discoveryUrl ?? `${issuer}/.well-known/openid-configuration`;
|
|
366
|
+
const key = cacheKeyFor(options.fetchImpl);
|
|
367
|
+
let cache = discoveryCaches.get(key);
|
|
368
|
+
if (!cache) {
|
|
369
|
+
cache = new Map();
|
|
370
|
+
discoveryCaches.set(key, cache);
|
|
371
|
+
}
|
|
372
|
+
const cacheKey = `${issuer} ${url}`;
|
|
373
|
+
const now = clock(options);
|
|
374
|
+
const hit = cache.get(cacheKey);
|
|
375
|
+
if (hit instanceof Promise)
|
|
376
|
+
return hit;
|
|
377
|
+
if (hit?.ok && now - hit.at < DISCOVERY_TTL_MS)
|
|
378
|
+
return hit.doc;
|
|
379
|
+
if (hit && !hit.ok && now - hit.at < DISCOVERY_RETRY_MS)
|
|
380
|
+
throw hit.error;
|
|
381
|
+
const flight = fetchDiscovery(url, issuer, options.fetchImpl).then((doc) => {
|
|
382
|
+
cache.set(cacheKey, { ok: true, doc, at: now });
|
|
383
|
+
return doc;
|
|
384
|
+
}, (error) => {
|
|
385
|
+
// Keep serving a document we already had through an outage.
|
|
386
|
+
if (hit?.ok) {
|
|
387
|
+
cache.set(cacheKey, hit);
|
|
388
|
+
return hit.doc;
|
|
389
|
+
}
|
|
390
|
+
cache.set(cacheKey, { ok: false, error, at: now });
|
|
391
|
+
throw error;
|
|
392
|
+
});
|
|
393
|
+
cache.set(cacheKey, flight);
|
|
394
|
+
return flight;
|
|
395
|
+
}
|
|
396
|
+
async function fetchDiscovery(url, issuer, fetchImpl) {
|
|
397
|
+
const fail = (why, cause) => new AtlasOidcError('discovery_failed', `OIDC discovery at ${url} failed: ${why}.`, cause === undefined ? {} : { cause });
|
|
398
|
+
let response;
|
|
399
|
+
try {
|
|
400
|
+
response = await (fetchImpl ?? fetch)(url, { headers: { accept: 'application/json' } });
|
|
401
|
+
}
|
|
402
|
+
catch (cause) {
|
|
403
|
+
throw fail(errText(cause), cause);
|
|
404
|
+
}
|
|
405
|
+
if (!response.ok)
|
|
406
|
+
throw fail(`HTTP ${response.status}`);
|
|
407
|
+
const doc = (await response.json().catch(() => null));
|
|
408
|
+
if (!doc || typeof doc !== 'object')
|
|
409
|
+
throw fail('the response is not JSON');
|
|
410
|
+
// OIDC Discovery §4.3: the document must be for the issuer we asked about.
|
|
411
|
+
if (typeof doc.issuer !== 'string' || stripSlash(doc.issuer) !== issuer) {
|
|
412
|
+
throw fail(`its issuer (${String(doc.issuer)}) is not ${issuer}`);
|
|
413
|
+
}
|
|
414
|
+
for (const field of ['authorization_endpoint', 'token_endpoint', 'jwks_uri']) {
|
|
415
|
+
if (typeof doc[field] !== 'string')
|
|
416
|
+
throw fail(`it has no ${field}`);
|
|
417
|
+
if (!isSecureUrl(doc[field]))
|
|
418
|
+
throw fail(`its ${field} is not an https URL`);
|
|
419
|
+
}
|
|
420
|
+
if (doc.end_session_endpoint !== undefined && typeof doc.end_session_endpoint !== 'string') {
|
|
421
|
+
throw fail('its end_session_endpoint is not a string');
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
issuer: doc.issuer,
|
|
425
|
+
authorization_endpoint: doc.authorization_endpoint,
|
|
426
|
+
token_endpoint: doc.token_endpoint,
|
|
427
|
+
jwks_uri: doc.jwks_uri,
|
|
428
|
+
...(typeof doc.end_session_endpoint === 'string' ? { end_session_endpoint: doc.end_session_endpoint } : {}),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function jwksFor(uri, opts) {
|
|
432
|
+
const key = cacheKeyFor(opts.fetchImpl);
|
|
433
|
+
let cache = jwksCaches.get(key);
|
|
434
|
+
if (!cache) {
|
|
435
|
+
cache = new Map();
|
|
436
|
+
jwksCaches.set(key, cache);
|
|
437
|
+
}
|
|
438
|
+
let jwks = cache.get(uri);
|
|
439
|
+
if (!jwks) {
|
|
440
|
+
jwks = new jwks_cache_1.JwksCache({ url: uri, fetchImpl: opts.fetchImpl, now: opts.now });
|
|
441
|
+
cache.set(uri, jwks);
|
|
442
|
+
}
|
|
443
|
+
return jwks;
|
|
444
|
+
}
|
|
445
|
+
const SEALED_PREFIX = 's1.';
|
|
446
|
+
const PLAIN_PREFIX = 'p1.';
|
|
447
|
+
const keyCache = new Map();
|
|
448
|
+
function flowKey(secret) {
|
|
449
|
+
if (secret.length < 32) {
|
|
450
|
+
throw new TypeError('The OIDC flow-cookie `secret` must be at least 32 characters.');
|
|
451
|
+
}
|
|
452
|
+
let key = keyCache.get(secret);
|
|
453
|
+
if (!key) {
|
|
454
|
+
key = (async () => {
|
|
455
|
+
const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), 'HKDF', false, [
|
|
456
|
+
'deriveKey',
|
|
457
|
+
]);
|
|
458
|
+
return crypto.subtle.deriveKey({
|
|
459
|
+
name: 'HKDF',
|
|
460
|
+
hash: 'SHA-256',
|
|
461
|
+
salt: new TextEncoder().encode('atlas-oidc-flow-cookie'),
|
|
462
|
+
info: new TextEncoder().encode('v1'),
|
|
463
|
+
}, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
464
|
+
})();
|
|
465
|
+
keyCache.set(secret, key);
|
|
466
|
+
}
|
|
467
|
+
return key;
|
|
468
|
+
}
|
|
469
|
+
async function sealFlow(payload, secret) {
|
|
470
|
+
const json = new TextEncoder().encode(JSON.stringify(payload));
|
|
471
|
+
if (secret === undefined)
|
|
472
|
+
return PLAIN_PREFIX + base64url(json);
|
|
473
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
474
|
+
const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await flowKey(secret), json));
|
|
475
|
+
return `${SEALED_PREFIX}${base64url(iv)}.${base64url(sealed)}`;
|
|
476
|
+
}
|
|
477
|
+
async function openFlow(value, secret) {
|
|
478
|
+
let json;
|
|
479
|
+
try {
|
|
480
|
+
if (secret !== undefined) {
|
|
481
|
+
// With a secret configured an unsealed cookie is a downgrade: refuse it.
|
|
482
|
+
if (!value.startsWith(SEALED_PREFIX))
|
|
483
|
+
return null;
|
|
484
|
+
const [iv, data] = value.slice(SEALED_PREFIX.length).split('.');
|
|
485
|
+
if (!iv || !data)
|
|
486
|
+
return null;
|
|
487
|
+
json = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromBase64url(iv) }, await flowKey(secret), fromBase64url(data)));
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
if (!value.startsWith(PLAIN_PREFIX))
|
|
491
|
+
return null;
|
|
492
|
+
json = fromBase64url(value.slice(PLAIN_PREFIX.length));
|
|
493
|
+
}
|
|
494
|
+
const parsed = JSON.parse(new TextDecoder().decode(json));
|
|
495
|
+
const fields = ['s', 'n', 'v', 'r', 'i', 'c'];
|
|
496
|
+
if (!fields.every((f) => typeof parsed[f] === 'string') || typeof parsed.t !== 'number')
|
|
497
|
+
return null;
|
|
498
|
+
return parsed;
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (error instanceof TypeError && /secret/.test(error.message))
|
|
502
|
+
throw error;
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function readFlowCookie(input, name) {
|
|
507
|
+
if (!input)
|
|
508
|
+
return undefined;
|
|
509
|
+
const trimmed = input.trim();
|
|
510
|
+
if (trimmed.startsWith(SEALED_PREFIX) || trimmed.startsWith(PLAIN_PREFIX))
|
|
511
|
+
return trimmed;
|
|
512
|
+
for (const part of trimmed.split(';')) {
|
|
513
|
+
const eq = part.indexOf('=');
|
|
514
|
+
if (eq < 0)
|
|
515
|
+
continue;
|
|
516
|
+
if (part.slice(0, eq).trim() === name) {
|
|
517
|
+
const value = part.slice(eq + 1).trim();
|
|
518
|
+
return value || undefined;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
function serializeCookie(name, value, opts) {
|
|
524
|
+
const parts = [`${name}=${value}`, `Path=${opts.path}`, `Max-Age=${opts.maxAge}`, 'HttpOnly', 'SameSite=Lax'];
|
|
525
|
+
if (opts.maxAge === 0)
|
|
526
|
+
parts.push('Expires=Thu, 01 Jan 1970 00:00:00 GMT');
|
|
527
|
+
// Secure except on a plain-http local callback, where the browser would drop it.
|
|
528
|
+
if (opts.secure)
|
|
529
|
+
parts.push('Secure');
|
|
530
|
+
return parts.join('; ');
|
|
531
|
+
}
|
|
532
|
+
// ── small helpers ───────────────────────────────────────────────────────────
|
|
533
|
+
function clock(options) {
|
|
534
|
+
return (options.now ?? Date.now)();
|
|
535
|
+
}
|
|
536
|
+
function requireString(value, name) {
|
|
537
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
538
|
+
throw new TypeError(`oidc: \`${name}\` is required.`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function parseRedirectUri(value) {
|
|
542
|
+
requireString(value, 'redirectUri');
|
|
543
|
+
let url;
|
|
544
|
+
try {
|
|
545
|
+
url = new URL(value);
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
throw new TypeError('oidc: `redirectUri` must be an absolute URL.');
|
|
549
|
+
}
|
|
550
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback(url.hostname))) {
|
|
551
|
+
throw new TypeError('oidc: `redirectUri` must be https (http is accepted only for localhost).');
|
|
552
|
+
}
|
|
553
|
+
return url;
|
|
554
|
+
}
|
|
555
|
+
function normaliseScope(scope) {
|
|
556
|
+
const parts = (scope ?? 'openid profile email').split(/\s+/).filter(Boolean);
|
|
557
|
+
if (!parts.includes('openid'))
|
|
558
|
+
parts.unshift('openid');
|
|
559
|
+
return [...new Set(parts)].join(' ');
|
|
560
|
+
}
|
|
561
|
+
function toParams(query) {
|
|
562
|
+
if (query instanceof URLSearchParams)
|
|
563
|
+
return query;
|
|
564
|
+
if (typeof query === 'string') {
|
|
565
|
+
const q = query.includes('?') ? query.slice(query.indexOf('?') + 1) : query;
|
|
566
|
+
return new URLSearchParams(q.split('#')[0]);
|
|
567
|
+
}
|
|
568
|
+
const out = new URLSearchParams();
|
|
569
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
570
|
+
if (typeof value === 'string')
|
|
571
|
+
out.set(key, value);
|
|
572
|
+
else if (Array.isArray(value) && typeof value[0] === 'string')
|
|
573
|
+
out.set(key, value[0]);
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
function isLoopback(hostname) {
|
|
578
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
|
|
579
|
+
}
|
|
580
|
+
function isSecureUrl(value) {
|
|
581
|
+
try {
|
|
582
|
+
const url = new URL(value);
|
|
583
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback(url.hostname));
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const stripSlash = (value) => value.replace(/\/+$/, '');
|
|
590
|
+
function randomToken() {
|
|
591
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
592
|
+
}
|
|
593
|
+
function base64url(bytes) {
|
|
594
|
+
let bin = '';
|
|
595
|
+
for (const b of bytes)
|
|
596
|
+
bin += String.fromCharCode(b);
|
|
597
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
598
|
+
}
|
|
599
|
+
function fromBase64url(value) {
|
|
600
|
+
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((value.length + 3) % 4);
|
|
601
|
+
const bin = atob(padded);
|
|
602
|
+
const out = new Uint8Array(bin.length);
|
|
603
|
+
for (let i = 0; i < bin.length; i++)
|
|
604
|
+
out[i] = bin.charCodeAt(i);
|
|
605
|
+
return out;
|
|
606
|
+
}
|
|
607
|
+
function decodeHeader(jwt) {
|
|
608
|
+
const parts = jwt.split('.');
|
|
609
|
+
if (parts.length !== 3 || !parts[0])
|
|
610
|
+
return null;
|
|
611
|
+
try {
|
|
612
|
+
return JSON.parse(new TextDecoder().decode(fromBase64url(parts[0])));
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/** Length-independent comparison of two strings, so a mismatch position does not leak. */
|
|
619
|
+
function timingSafeEqual(a, b) {
|
|
620
|
+
const ea = new TextEncoder().encode(a);
|
|
621
|
+
const eb = new TextEncoder().encode(b);
|
|
622
|
+
let diff = ea.length ^ eb.length;
|
|
623
|
+
const n = Math.max(ea.length, eb.length);
|
|
624
|
+
for (let i = 0; i < n; i++)
|
|
625
|
+
diff |= (ea[i] ?? 0) ^ (eb[i] ?? 0);
|
|
626
|
+
return diff === 0;
|
|
627
|
+
}
|
|
628
|
+
function errText(error) {
|
|
629
|
+
return error instanceof Error ? error.message : String(error);
|
|
630
|
+
}
|
|
631
|
+
//# sourceMappingURL=oidc.js.map
|