@udibo/oauth2 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -61
- package/dist/adapters/hono/authorization-server.d.ts +21 -8
- package/dist/adapters/hono/authorization-server.js +2 -2
- package/dist/adapters/hono/bff/bff.d.ts +13 -5
- package/dist/adapters/hono/bff/bff.js +31 -2
- package/dist/cli/idp/server.js +35 -14
- package/dist/client/_issuer-refusal.d.ts +3 -0
- package/dist/client/_issuer-refusal.js +10 -0
- package/dist/client/direct-client.d.ts +51 -2
- package/dist/client/direct-client.js +101 -17
- package/dist/client/mod.d.ts +1 -1
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +58 -0
- package/dist/models/authentication.d.ts +28 -0
- package/dist/models/authorization-code.d.ts +3 -3
- package/dist/models/responses.d.ts +13 -2
- package/dist/server/authentication-request.d.ts +60 -0
- package/dist/server/authentication-request.js +102 -0
- package/dist/server/authorization-server.d.ts +63 -8
- package/dist/server/authorization-server.js +80 -17
- package/dist/server/mod.d.ts +3 -2
- package/dist/server/mod.js +2 -1
- package/package.json +1 -1
|
@@ -19,6 +19,7 @@ import { sanitizeProviderText } from "../utils/text.js";
|
|
|
19
19
|
import { assertField, assertTokenResponse, errorFromResponse, receiveJson, sendGuarded, } from "./_http.js";
|
|
20
20
|
import { mergedHeaders, OAuth2ClientBase, SIGNED_OUT, } from "./base.js";
|
|
21
21
|
import { SessionStorageAuthRequestStorage } from "./browser-storage.js";
|
|
22
|
+
import { issuerRefusal } from "./_issuer-refusal.js";
|
|
22
23
|
import { DEFAULT_DISCOVERY_TTL_MS, } from "./discovery-cache.js";
|
|
23
24
|
import { MemoryAuthRequestStorage, MemoryRefreshTokenStorage, MemoryTokenStorage, } from "./storage.js";
|
|
24
25
|
const REFRESH_SKEW_MS = 30_000;
|
|
@@ -383,13 +384,20 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
383
384
|
* than redeeming the single-use code twice. A call after it settles redeems
|
|
384
385
|
* again, and fails.
|
|
385
386
|
*
|
|
387
|
+
* The response's `iss` is checked first, error responses included, as
|
|
388
|
+
* {@link verifyAuthorizationResponseIssuer} describes. A refused response
|
|
389
|
+
* is never redeemed.
|
|
390
|
+
*
|
|
386
391
|
* @param input The callback URL, its query string, or the parsed params.
|
|
392
|
+
* @param options The `origin` given to {@link login}, when it retargeted
|
|
393
|
+
* the authorize URL, so the issuer at that origin is accepted as `iss`.
|
|
387
394
|
* @returns The persisted tokens and the `returnTo` recorded at login.
|
|
388
395
|
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
389
396
|
* reached or its response does not arrive in full before the deadline;
|
|
390
397
|
* `cause` is the transport failure.
|
|
391
398
|
* @throws {OAuth2Error} when the callback carries `?error=…`, or the token
|
|
392
399
|
* exchange fails.
|
|
400
|
+
* @throws {InvalidRequestError} when the response's `iss` is refused.
|
|
393
401
|
* @throws {TypeError} when `code` or `state` is missing, or outside a
|
|
394
402
|
* browser when the client has no `authRequestStorage` or no
|
|
395
403
|
* `refreshTokenStorage`; checked before the pending request is claimed.
|
|
@@ -406,22 +414,12 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
406
414
|
* location.replace(returnTo ?? "/");
|
|
407
415
|
* ```
|
|
408
416
|
*/
|
|
409
|
-
handleAuthorizationCallback(input) {
|
|
417
|
+
handleAuthorizationCallback(input, options = {}) {
|
|
410
418
|
const params = toSearchParams(input);
|
|
419
|
+
const { origin } = options;
|
|
411
420
|
const error = params.get("error");
|
|
412
|
-
if (error)
|
|
413
|
-
|
|
414
|
-
const errorUri = params.get("error_uri") ?? undefined;
|
|
415
|
-
const instance = new OAuth2ErrorClass(description ?? "authorization error", {
|
|
416
|
-
extensions: {
|
|
417
|
-
error,
|
|
418
|
-
error_description: description,
|
|
419
|
-
error_uri: errorUri,
|
|
420
|
-
},
|
|
421
|
-
});
|
|
422
|
-
this.emit({ type: "error", error: instance });
|
|
423
|
-
return Promise.reject(instance);
|
|
424
|
-
}
|
|
421
|
+
if (error)
|
|
422
|
+
return this.#rejectAuthorizationError(error, params, origin);
|
|
425
423
|
try {
|
|
426
424
|
this.#requireAuthRequestStorage();
|
|
427
425
|
this.#requireRefreshTokenStorage();
|
|
@@ -439,19 +437,91 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
439
437
|
const existing = this.#callbackPromises.get(code);
|
|
440
438
|
if (existing)
|
|
441
439
|
return existing;
|
|
442
|
-
const promise = this.#exchangeCode(code, state);
|
|
440
|
+
const promise = this.#exchangeCode(code, state, params, origin);
|
|
443
441
|
this.#callbackPromises.set(code, promise);
|
|
444
442
|
const cleanup = () => queueMicrotask(() => this.#callbackPromises.delete(code));
|
|
445
443
|
promise.then(cleanup, cleanup);
|
|
446
444
|
return promise;
|
|
447
445
|
}
|
|
448
|
-
async #
|
|
446
|
+
async #rejectAuthorizationError(code, params, origin) {
|
|
447
|
+
const error = (await this.#issuerRefusal(params, origin)) ??
|
|
448
|
+
authorizationResponseError(code, params);
|
|
449
|
+
this.emit({ type: "error", error });
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
async #exchangeCode(code, state, params, origin) {
|
|
449
453
|
const version = ++this.#sessionVersion;
|
|
454
|
+
const refusal = await this.#issuerRefusal(params, origin);
|
|
455
|
+
if (refusal) {
|
|
456
|
+
this.emit({ type: "error", error: refusal });
|
|
457
|
+
throw refusal;
|
|
458
|
+
}
|
|
450
459
|
const result = await this.exchangeAuthorizationCode(code, state);
|
|
451
460
|
const tokens = await this.#saveTokens(result.raw, version);
|
|
452
461
|
this.emit({ type: "authenticated", tokens });
|
|
453
462
|
return { tokens, returnTo: result.returnTo };
|
|
454
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Checks an authorization response's `iss` parameter (RFC 9207), which
|
|
466
|
+
* tells a client that signs in with more than one authorization server
|
|
467
|
+
* which of them answered. {@link handleAuthorizationCallback} calls it; a
|
|
468
|
+
* server that completes the flow with {@link exchangeAuthorizationCode}
|
|
469
|
+
* calls it first, on every response, error responses included.
|
|
470
|
+
*
|
|
471
|
+
* For a client configured with an `issuer`, it refuses an `iss` that is
|
|
472
|
+
* not exactly the discovered issuer, an `iss` given more than once, and a
|
|
473
|
+
* missing `iss` when the metadata advertises
|
|
474
|
+
* `authorization_response_iss_parameter_supported: true`. A missing `iss`
|
|
475
|
+
* is accepted from a server that does not advertise it. A client
|
|
476
|
+
* configured with `endpoints` has no issuer to compare against, so it
|
|
477
|
+
* accepts every response.
|
|
478
|
+
*
|
|
479
|
+
* @param input The callback URL, its query string, or the parsed params.
|
|
480
|
+
* @param options The `origin` the authorize URL was retargeted at, if any.
|
|
481
|
+
* @throws {InvalidRequestError} when the response is refused.
|
|
482
|
+
* @throws {ServerError} when discovery fails.
|
|
483
|
+
* @throws {TemporarilyUnavailableError} when the discovery endpoint cannot
|
|
484
|
+
* be reached.
|
|
485
|
+
*
|
|
486
|
+
* @example
|
|
487
|
+
* ```ts
|
|
488
|
+
* import { DirectClient } from "@udibo/oauth2/client";
|
|
489
|
+
*
|
|
490
|
+
* declare const client: DirectClient;
|
|
491
|
+
* declare const callbackUrl: string;
|
|
492
|
+
* await client.verifyAuthorizationResponseIssuer(callbackUrl);
|
|
493
|
+
* ```
|
|
494
|
+
*/
|
|
495
|
+
async verifyAuthorizationResponseIssuer(input, options = {}) {
|
|
496
|
+
const refusal = await this.#issuerRefusal(toSearchParams(input), options.origin);
|
|
497
|
+
if (refusal)
|
|
498
|
+
throw refusal;
|
|
499
|
+
}
|
|
500
|
+
async #issuerRefusal(params, origin) {
|
|
501
|
+
if (!this.#issuer)
|
|
502
|
+
return undefined;
|
|
503
|
+
const metadata = await this.discover();
|
|
504
|
+
const received = params.getAll("iss");
|
|
505
|
+
if (received.length > 1) {
|
|
506
|
+
return issuerRefusal("the authorization response carries more than one `iss` (RFC 9207)");
|
|
507
|
+
}
|
|
508
|
+
const [iss] = received;
|
|
509
|
+
if (iss === undefined) {
|
|
510
|
+
if (metadata.authorization_response_iss_parameter_supported !== true) {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
return issuerRefusal("the authorization response is missing `iss`, which the issuer " +
|
|
514
|
+
"advertises with `authorization_response_iss_parameter_supported` " +
|
|
515
|
+
"(RFC 9207 §2.4)");
|
|
516
|
+
}
|
|
517
|
+
const accepted = [metadata.issuer];
|
|
518
|
+
if (origin)
|
|
519
|
+
accepted.push(issuerAtOrigin(metadata.issuer, origin));
|
|
520
|
+
if (accepted.includes(iss))
|
|
521
|
+
return undefined;
|
|
522
|
+
return issuerRefusal("the authorization response `iss` does not match the issuer the " +
|
|
523
|
+
"request was sent to (RFC 9207 §2.4)");
|
|
524
|
+
}
|
|
455
525
|
/**
|
|
456
526
|
* **Advanced (BFF / multi-session servers).** SPA, mobile, and CLI consumers
|
|
457
527
|
* should call {@link handleAuthorizationCallback} instead — that variant
|
|
@@ -470,7 +540,8 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
470
540
|
* two callbacks racing on one `state` reach the token endpoint at most once
|
|
471
541
|
* (atomically so for a storage that implements
|
|
472
542
|
* {@link AuthRequestStorage.take}). Emits nothing — the caller owns session
|
|
473
|
-
* semantics.
|
|
543
|
+
* semantics. It does not see the response's `iss`: call
|
|
544
|
+
* {@link verifyAuthorizationResponseIssuer} before it.
|
|
474
545
|
*
|
|
475
546
|
* @param code The authorization code from the callback.
|
|
476
547
|
* @param state The `state` the callback echoed back.
|
|
@@ -1249,6 +1320,19 @@ function metadataToEndpoints(meta) {
|
|
|
1249
1320
|
out.endSession = meta.end_session_endpoint;
|
|
1250
1321
|
return out;
|
|
1251
1322
|
}
|
|
1323
|
+
function authorizationResponseError(error, params) {
|
|
1324
|
+
const description = params.get("error_description") ?? undefined;
|
|
1325
|
+
return new OAuth2ErrorClass(description ?? "authorization error", {
|
|
1326
|
+
extensions: {
|
|
1327
|
+
error,
|
|
1328
|
+
error_description: description,
|
|
1329
|
+
error_uri: params.get("error_uri") ?? undefined,
|
|
1330
|
+
},
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
function issuerAtOrigin(issuer, origin) {
|
|
1334
|
+
return `${new URL(origin).origin}${issuer.slice(new URL(issuer).origin.length)}`;
|
|
1335
|
+
}
|
|
1252
1336
|
function toSearchParams(input) {
|
|
1253
1337
|
if (input instanceof URLSearchParams)
|
|
1254
1338
|
return input;
|
package/dist/client/mod.d.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
export { type BaseLoginOptions, type BaseOptions, type LoginRedirect, type LogoutOptions, type LogoutRedirect, OAuth2ClientBase, type SessionState, SIGNED_OUT, type UserInfoClaims, } from "./base.ts";
|
|
29
29
|
export { BffClient, type BffClientOptions, type BffEndpoints, } from "./bff-client.ts";
|
|
30
|
-
export { type AuthorizationCallbackResult, type AuthorizationRedirect, type AuthorizationServerEndpoints, type DeviceAuthorizationOptions, DirectClient, type DirectClientOptions, type ExchangeOptions, type ExchangeResult, type IntrospectionResult, type LoginOptions, type PollDeviceTokenOptions, } from "./direct-client.ts";
|
|
30
|
+
export { type AuthorizationCallbackResult, type AuthorizationRedirect, type AuthorizationServerEndpoints, type DeviceAuthorizationOptions, DirectClient, type DirectClientOptions, type ExchangeOptions, type ExchangeResult, type IntrospectionResult, type LoginOptions, type PollDeviceTokenOptions, type VerifyIssuerOptions, } from "./direct-client.ts";
|
|
31
31
|
export { DEFAULT_DISCOVERY_CACHE_MAX_ENTRIES, DEFAULT_DISCOVERY_TTL_MS, type DiscoveryCache, type DiscoveryCacheEntry, MemoryDiscoveryCache, type MemoryDiscoveryCacheOptions, } from "./discovery-cache.ts";
|
|
32
32
|
export { type AuthenticatedEvent, type ErrorEvent, type LoggedOutEvent, type OAuth2ClientEvent, type OAuth2ClientEventListener, type TokenRefreshedEvent, } from "./events.ts";
|
|
33
33
|
export { type AuthRequestRecord, type AuthRequestStorage, MemoryAuthRequestStorage, type MemoryAuthRequestStorageOptions, MemoryRefreshTokenStorage, MemoryTokenStorage, type RefreshTokenStorage, type TokenBundle, type TokenStorage, } from "./storage.ts";
|
package/dist/errors.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - RFC 6749 Section 5.2 (Token errors)
|
|
7
7
|
* - RFC 6750 Section 3.1 (Bearer token errors)
|
|
8
8
|
* - RFC 8628 Section 3.5 (Device flow errors)
|
|
9
|
+
* - OpenID Connect Core 1.0 Section 3.1.2.6 (Authentication errors)
|
|
9
10
|
*
|
|
10
11
|
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
11
12
|
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
|
|
@@ -204,3 +205,36 @@ export declare const SlowDownError: OAuth2ErrorClass;
|
|
|
204
205
|
* @see https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
|
|
205
206
|
*/
|
|
206
207
|
export declare const ExpiredTokenError: OAuth2ErrorClass;
|
|
208
|
+
/**
|
|
209
|
+
* The authorization request requires the user to authenticate, and the server
|
|
210
|
+
* cannot show a login page (`prompt=none`) or cannot re-authenticate the user
|
|
211
|
+
* as `prompt=login` or `max_age` requires.
|
|
212
|
+
*
|
|
213
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
|
214
|
+
*/
|
|
215
|
+
export declare const LoginRequiredError: OAuth2ErrorClass;
|
|
216
|
+
/**
|
|
217
|
+
* The authorization request requires the user's consent, and the server
|
|
218
|
+
* cannot obtain it: `prompt=none` forbids the consent page, or `prompt=consent`
|
|
219
|
+
* reached a server with no consent handler.
|
|
220
|
+
*
|
|
221
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
|
222
|
+
*/
|
|
223
|
+
export declare const ConsentRequiredError: OAuth2ErrorClass;
|
|
224
|
+
/**
|
|
225
|
+
* The authorization request requires user interaction other than login or
|
|
226
|
+
* consent, and `prompt=none` forbids showing it. Throw it from
|
|
227
|
+
* `authenticateUser` or `handleConsent` for an interstitial the request
|
|
228
|
+
* cannot skip.
|
|
229
|
+
*
|
|
230
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
|
231
|
+
*/
|
|
232
|
+
export declare const InteractionRequiredError: OAuth2ErrorClass;
|
|
233
|
+
/**
|
|
234
|
+
* The user must choose among several accounts and the server cannot ask:
|
|
235
|
+
* `prompt=none` with more than one signed-in account, or `prompt=select_account`
|
|
236
|
+
* that the application cannot honor.
|
|
237
|
+
*
|
|
238
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
|
239
|
+
*/
|
|
240
|
+
export declare const AccountSelectionRequiredError: OAuth2ErrorClass;
|
package/dist/errors.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - RFC 6749 Section 5.2 (Token errors)
|
|
7
7
|
* - RFC 6750 Section 3.1 (Bearer token errors)
|
|
8
8
|
* - RFC 8628 Section 3.5 (Device flow errors)
|
|
9
|
+
* - OpenID Connect Core 1.0 Section 3.1.2.6 (Authentication errors)
|
|
9
10
|
*
|
|
10
11
|
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
11
12
|
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
|
|
@@ -286,3 +287,60 @@ export const ExpiredTokenError = createHttpErrorClass({
|
|
|
286
287
|
error: "expired_token",
|
|
287
288
|
},
|
|
288
289
|
});
|
|
290
|
+
/**
|
|
291
|
+
* The authorization request requires the user to authenticate, and the server
|
|
292
|
+
* cannot show a login page (`prompt=none`) or cannot re-authenticate the user
|
|
293
|
+
* as `prompt=login` or `max_age` requires.
|
|
294
|
+
*
|
|
295
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
|
296
|
+
*/
|
|
297
|
+
export const LoginRequiredError = createHttpErrorClass({
|
|
298
|
+
name: "Login Required",
|
|
299
|
+
status: 400,
|
|
300
|
+
extensions: {
|
|
301
|
+
error: "login_required",
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
/**
|
|
305
|
+
* The authorization request requires the user's consent, and the server
|
|
306
|
+
* cannot obtain it: `prompt=none` forbids the consent page, or `prompt=consent`
|
|
307
|
+
* reached a server with no consent handler.
|
|
308
|
+
*
|
|
309
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
|
310
|
+
*/
|
|
311
|
+
export const ConsentRequiredError = createHttpErrorClass({
|
|
312
|
+
name: "Consent Required",
|
|
313
|
+
status: 400,
|
|
314
|
+
extensions: {
|
|
315
|
+
error: "consent_required",
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
/**
|
|
319
|
+
* The authorization request requires user interaction other than login or
|
|
320
|
+
* consent, and `prompt=none` forbids showing it. Throw it from
|
|
321
|
+
* `authenticateUser` or `handleConsent` for an interstitial the request
|
|
322
|
+
* cannot skip.
|
|
323
|
+
*
|
|
324
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
|
325
|
+
*/
|
|
326
|
+
export const InteractionRequiredError = createHttpErrorClass({
|
|
327
|
+
name: "Interaction Required",
|
|
328
|
+
status: 400,
|
|
329
|
+
extensions: {
|
|
330
|
+
error: "interaction_required",
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
/**
|
|
334
|
+
* The user must choose among several accounts and the server cannot ask:
|
|
335
|
+
* `prompt=none` with more than one signed-in account, or `prompt=select_account`
|
|
336
|
+
* that the application cannot honor.
|
|
337
|
+
*
|
|
338
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
|
339
|
+
*/
|
|
340
|
+
export const AccountSelectionRequiredError = createHttpErrorClass({
|
|
341
|
+
name: "Account Selection Required",
|
|
342
|
+
status: 400,
|
|
343
|
+
extensions: {
|
|
344
|
+
error: "account_selection_required",
|
|
345
|
+
},
|
|
346
|
+
});
|
|
@@ -22,3 +22,31 @@ export interface AuthenticationContext {
|
|
|
22
22
|
/** Truthful authentication methods; omitted when the complete set is unknown. */
|
|
23
23
|
readonly amr?: readonly string[];
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The OpenID Connect authentication controls of one authorization request,
|
|
27
|
+
* validated by the authorization server and handed to the application's
|
|
28
|
+
* `authenticateUser` and `handleConsent` callbacks.
|
|
29
|
+
*
|
|
30
|
+
* The server enforces what it can verify: `prompt=none` never reaches a login
|
|
31
|
+
* or consent page, and a code is issued only when the returned authentication
|
|
32
|
+
* satisfies `prompt=login` and `max_age` (see
|
|
33
|
+
* `satisfiesAuthenticationRequest`). `acrValues` is a preference the
|
|
34
|
+
* application meets as it can; report the `acr` it actually achieved.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const authentication: AuthenticationRequest = {
|
|
39
|
+
* prompt: ["login"],
|
|
40
|
+
* maxAge: 300,
|
|
41
|
+
* acrValues: ["urn:example:mfa"],
|
|
42
|
+
* };
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export interface AuthenticationRequest {
|
|
46
|
+
/** Requested `prompt` values, each one the server supports; empty when absent. */
|
|
47
|
+
readonly prompt: readonly string[];
|
|
48
|
+
/** Requested `max_age` in seconds, when present. */
|
|
49
|
+
readonly maxAge?: number;
|
|
50
|
+
/** Requested `acr_values` in preference order; empty when absent. */
|
|
51
|
+
readonly acrValues: readonly string[];
|
|
52
|
+
}
|
|
@@ -38,11 +38,11 @@ export interface AuthorizationCode<Client extends ClientInterface, User, Scope e
|
|
|
38
38
|
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
|
39
39
|
*/
|
|
40
40
|
export interface AuthorizeParameters {
|
|
41
|
-
/** Raw space-delimited ACR preferences;
|
|
41
|
+
/** Raw space-delimited ACR preferences; `authenticateUser` receives them validated. */
|
|
42
42
|
acrValues?: string;
|
|
43
|
-
/** Raw maximum authentication age in seconds;
|
|
43
|
+
/** Raw maximum authentication age in seconds; the authorize endpoint validates and enforces it. */
|
|
44
44
|
maxAge?: string;
|
|
45
|
-
/** Raw prompt values;
|
|
45
|
+
/** Raw prompt values; the authorize endpoint validates them and enforces `none`, `login` and `consent`. */
|
|
46
46
|
prompt?: string;
|
|
47
47
|
/** Must be "code" for authorization code flow. */
|
|
48
48
|
responseType?: string;
|
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* Includes the RFC 6750 Section 3.1 bearer-token error codes
|
|
5
5
|
* (`invalid_token`, `insufficient_scope`) emitted by resource servers, in
|
|
6
6
|
* addition to the RFC 6749 Section 5.2 / Section 4.1.2.1 codes and the RFC
|
|
7
|
-
* 8628 device-flow codes.
|
|
7
|
+
* 8628 device-flow codes and the OpenID Connect Core §3.1.2.6
|
|
8
|
+
* authentication-error codes.
|
|
8
9
|
*
|
|
9
10
|
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
10
11
|
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
|
|
12
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
|
11
13
|
*/
|
|
12
|
-
export type OAuth2ErrorCode = "invalid_request" | "invalid_client" | "invalid_grant" | "unauthorized_client" | "unsupported_grant_type" | "unsupported_response_type" | "unsupported_token_type" | "invalid_scope" | "invalid_token" | "insufficient_scope" | "access_denied" | "server_error" | "temporarily_unavailable" | "authorization_pending" | "slow_down" | "expired_token";
|
|
14
|
+
export type OAuth2ErrorCode = "invalid_request" | "invalid_client" | "invalid_grant" | "unauthorized_client" | "unsupported_grant_type" | "unsupported_response_type" | "unsupported_token_type" | "invalid_scope" | "invalid_token" | "insufficient_scope" | "access_denied" | "server_error" | "temporarily_unavailable" | "authorization_pending" | "slow_down" | "expired_token" | "login_required" | "consent_required" | "interaction_required" | "account_selection_required";
|
|
13
15
|
/**
|
|
14
16
|
* Standard OAuth2 error response.
|
|
15
17
|
*
|
|
@@ -61,6 +63,11 @@ export interface AuthorizationServerMetadata {
|
|
|
61
63
|
scopes_supported?: string[];
|
|
62
64
|
/** Supported response types. */
|
|
63
65
|
response_types_supported?: string[];
|
|
66
|
+
/**
|
|
67
|
+
* Whether every authorization response carries the `iss` parameter
|
|
68
|
+
* (RFC 9207 §3). A client that sees `true` refuses a response without one.
|
|
69
|
+
*/
|
|
70
|
+
authorization_response_iss_parameter_supported?: boolean;
|
|
64
71
|
/** Supported grant types. */
|
|
65
72
|
grant_types_supported?: string[];
|
|
66
73
|
/** URL of the JWKS document (OIDC Discovery / RFC 8414). */
|
|
@@ -69,6 +76,10 @@ export interface AuthorizationServerMetadata {
|
|
|
69
76
|
id_token_signing_alg_values_supported?: string[];
|
|
70
77
|
/** Subject identifier types supported (OIDC Discovery). */
|
|
71
78
|
subject_types_supported?: string[];
|
|
79
|
+
/** `prompt` values the authorization endpoint honors (OIDC Prompt Create). */
|
|
80
|
+
prompt_values_supported?: string[];
|
|
81
|
+
/** Authentication context class references the server can achieve (OIDC Discovery). */
|
|
82
|
+
acr_values_supported?: string[];
|
|
72
83
|
}
|
|
73
84
|
/**
|
|
74
85
|
* Device authorization response as defined in RFC 8628.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AuthenticationContext, AuthenticationRequest } from "../models/authentication.ts";
|
|
2
|
+
/** The `prompt` values the authorization server enforces on its own. */
|
|
3
|
+
export declare const DEFAULT_PROMPT_VALUES_SUPPORTED: readonly string[];
|
|
4
|
+
/**
|
|
5
|
+
* What an `authenticateUser` callback reports about the authentication behind
|
|
6
|
+
* the user it returns, as {@linkcode satisfiesAuthenticationRequest} reads it.
|
|
7
|
+
*/
|
|
8
|
+
export interface AuthenticationOutcome {
|
|
9
|
+
/** The verified authentication event, when known. */
|
|
10
|
+
authenticationContext?: AuthenticationContext;
|
|
11
|
+
/**
|
|
12
|
+
* `true` only when the user actively authenticated in response to this
|
|
13
|
+
* authorization request, which is what `prompt=login` and `max_age=0` ask
|
|
14
|
+
* for. A session that merely exists, or was refreshed, is not that.
|
|
15
|
+
*/
|
|
16
|
+
reauthenticated?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function assertSupportedValues(name: string, values: readonly string[]): readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* Validates the `prompt`, `max_age` and `acr_values` of an authorization
|
|
21
|
+
* request. Parameters sent without a value are treated as omitted (OIDC Core
|
|
22
|
+
* §3.1.2.1).
|
|
23
|
+
*
|
|
24
|
+
* @throws {InvalidRequestError} For a repeated parameter, a `prompt` value not
|
|
25
|
+
* in `promptValuesSupported`, `none` combined with another `prompt` value, or
|
|
26
|
+
* a `max_age` that is not a non-negative integer.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseAuthenticationRequest(params: URLSearchParams, promptValuesSupported: readonly string[]): AuthenticationRequest;
|
|
29
|
+
/**
|
|
30
|
+
* Why `outcome` does not satisfy `request`, as an `error_description`, or
|
|
31
|
+
* `undefined` when it does.
|
|
32
|
+
*/
|
|
33
|
+
export declare function authenticationShortfall(request: AuthenticationRequest, outcome: AuthenticationOutcome, now: number): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Whether an authentication meets the request's `prompt=login` and `max_age`
|
|
36
|
+
* controls — the check the authorization server applies to what
|
|
37
|
+
* `authenticateUser` returns before it issues a code. Call it from
|
|
38
|
+
* `authenticateUser` with the session's event to decide whether to send the
|
|
39
|
+
* user to sign in again instead of returning them.
|
|
40
|
+
*
|
|
41
|
+
* `prompt=login` and `max_age=0`, which OIDC Core makes equivalent, need
|
|
42
|
+
* `reauthenticated: true`. Any `max_age` needs a recorded `auth_time`, since
|
|
43
|
+
* the ID token must carry one, never more than a minute in the future. Above
|
|
44
|
+
* zero, it must also be no older than `max_age` seconds, whether or not the
|
|
45
|
+
* user re-authenticated. `acr_values` is a preference and never fails the
|
|
46
|
+
* check.
|
|
47
|
+
*
|
|
48
|
+
* @param now The current time in epoch milliseconds. Defaults to `Date.now()`.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* import { satisfiesAuthenticationRequest } from "@udibo/oauth2/server";
|
|
53
|
+
*
|
|
54
|
+
* const fresh = satisfiesAuthenticationRequest(
|
|
55
|
+
* { prompt: [], maxAge: 300, acrValues: [] },
|
|
56
|
+
* { authenticationContext: { auth_time: Math.floor(Date.now() / 1000) } },
|
|
57
|
+
* );
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export declare function satisfiesAuthenticationRequest(request: AuthenticationRequest, outcome: AuthenticationOutcome, now?: number): boolean;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { InvalidRequestError } from "../errors.js";
|
|
2
|
+
/** The `prompt` values the authorization server enforces on its own. */
|
|
3
|
+
export const DEFAULT_PROMPT_VALUES_SUPPORTED = Object.freeze(["none", "login", "consent"]);
|
|
4
|
+
const MAX_AUTH_TIME_SKEW_SECONDS = 60;
|
|
5
|
+
function spaceDelimited(value) {
|
|
6
|
+
return [...new Set((value ?? "").split(" ").filter(Boolean))];
|
|
7
|
+
}
|
|
8
|
+
export function assertSupportedValues(name, values) {
|
|
9
|
+
if (values.some((value) => typeof value !== "string" || !/^[\x21-\x7e]+$/.test(value))) {
|
|
10
|
+
throw new TypeError(`${name} entries must be non-empty strings of visible ASCII without spaces`);
|
|
11
|
+
}
|
|
12
|
+
return Object.freeze([...new Set(values)]);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Validates the `prompt`, `max_age` and `acr_values` of an authorization
|
|
16
|
+
* request. Parameters sent without a value are treated as omitted (OIDC Core
|
|
17
|
+
* §3.1.2.1).
|
|
18
|
+
*
|
|
19
|
+
* @throws {InvalidRequestError} For a repeated parameter, a `prompt` value not
|
|
20
|
+
* in `promptValuesSupported`, `none` combined with another `prompt` value, or
|
|
21
|
+
* a `max_age` that is not a non-negative integer.
|
|
22
|
+
*/
|
|
23
|
+
export function parseAuthenticationRequest(params, promptValuesSupported) {
|
|
24
|
+
for (const name of ["prompt", "max_age", "acr_values"]) {
|
|
25
|
+
if (params.getAll(name).length > 1) {
|
|
26
|
+
throw new InvalidRequestError(`${name} parameter must not be repeated`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const prompt = spaceDelimited(params.get("prompt"));
|
|
30
|
+
if (prompt.some((value) => !promptValuesSupported.includes(value))) {
|
|
31
|
+
throw new InvalidRequestError("prompt value not supported");
|
|
32
|
+
}
|
|
33
|
+
if (prompt.includes("none") && prompt.length > 1) {
|
|
34
|
+
throw new InvalidRequestError("prompt=none cannot be combined with other prompt values");
|
|
35
|
+
}
|
|
36
|
+
const rawMaxAge = params.get("max_age") || undefined;
|
|
37
|
+
if (rawMaxAge !== undefined &&
|
|
38
|
+
(!/^\d+$/.test(rawMaxAge) || !Number.isSafeInteger(Number(rawMaxAge)))) {
|
|
39
|
+
throw new InvalidRequestError("max_age must be a non-negative integer");
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
prompt: Object.freeze(prompt),
|
|
43
|
+
...(rawMaxAge === undefined ? {} : { maxAge: Number(rawMaxAge) }),
|
|
44
|
+
acrValues: Object.freeze(spaceDelimited(params.get("acr_values"))),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Why `outcome` does not satisfy `request`, as an `error_description`, or
|
|
49
|
+
* `undefined` when it does.
|
|
50
|
+
*/
|
|
51
|
+
export function authenticationShortfall(request, outcome, now) {
|
|
52
|
+
const reauthenticated = outcome.reauthenticated === true;
|
|
53
|
+
if (request.prompt.includes("login") && !reauthenticated) {
|
|
54
|
+
return "prompt=login requires the user to authenticate again";
|
|
55
|
+
}
|
|
56
|
+
if (request.maxAge === 0 && !reauthenticated) {
|
|
57
|
+
return "max_age=0 requires the user to authenticate again";
|
|
58
|
+
}
|
|
59
|
+
if (request.maxAge === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
const authTime = outcome.authenticationContext?.auth_time;
|
|
62
|
+
if (typeof authTime !== "number" || !Number.isFinite(authTime)) {
|
|
63
|
+
return "max_age requires the time the user authenticated";
|
|
64
|
+
}
|
|
65
|
+
const age = Math.floor(now / 1000) - authTime;
|
|
66
|
+
if (age < -MAX_AUTH_TIME_SKEW_SECONDS) {
|
|
67
|
+
return "the user's authentication time is in the future";
|
|
68
|
+
}
|
|
69
|
+
if (request.maxAge > 0 && age > request.maxAge) {
|
|
70
|
+
return "the user's last authentication is older than max_age";
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether an authentication meets the request's `prompt=login` and `max_age`
|
|
76
|
+
* controls — the check the authorization server applies to what
|
|
77
|
+
* `authenticateUser` returns before it issues a code. Call it from
|
|
78
|
+
* `authenticateUser` with the session's event to decide whether to send the
|
|
79
|
+
* user to sign in again instead of returning them.
|
|
80
|
+
*
|
|
81
|
+
* `prompt=login` and `max_age=0`, which OIDC Core makes equivalent, need
|
|
82
|
+
* `reauthenticated: true`. Any `max_age` needs a recorded `auth_time`, since
|
|
83
|
+
* the ID token must carry one, never more than a minute in the future. Above
|
|
84
|
+
* zero, it must also be no older than `max_age` seconds, whether or not the
|
|
85
|
+
* user re-authenticated. `acr_values` is a preference and never fails the
|
|
86
|
+
* check.
|
|
87
|
+
*
|
|
88
|
+
* @param now The current time in epoch milliseconds. Defaults to `Date.now()`.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* import { satisfiesAuthenticationRequest } from "@udibo/oauth2/server";
|
|
93
|
+
*
|
|
94
|
+
* const fresh = satisfiesAuthenticationRequest(
|
|
95
|
+
* { prompt: [], maxAge: 300, acrValues: [] },
|
|
96
|
+
* { authenticationContext: { auth_time: Math.floor(Date.now() / 1000) } },
|
|
97
|
+
* );
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function satisfiesAuthenticationRequest(request, outcome, now = Date.now()) {
|
|
101
|
+
return authenticationShortfall(request, outcome, now) === undefined;
|
|
102
|
+
}
|