@udibo/oauth2 0.26.0 → 0.28.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.
Files changed (36) hide show
  1. package/README.md +5 -3
  2. package/dist/adapters/hono/authorization-server.d.ts +21 -8
  3. package/dist/adapters/hono/authorization-server.js +2 -2
  4. package/dist/adapters/hono/bff/bff.d.ts +18 -4
  5. package/dist/adapters/hono/bff/bff.js +69 -3
  6. package/dist/cli/idp/server.js +35 -14
  7. package/dist/client/_http.d.ts +6 -1
  8. package/dist/client/_http.js +15 -2
  9. package/dist/client/_issuer-refusal.d.ts +7 -0
  10. package/dist/client/_issuer-refusal.js +22 -0
  11. package/dist/client/direct-client.d.ts +2 -1
  12. package/dist/client/direct-client.js +9 -22
  13. package/dist/errors.d.ts +34 -0
  14. package/dist/errors.js +58 -0
  15. package/dist/identity/external/_shared.d.ts +18 -0
  16. package/dist/identity/external/_shared.js +15 -0
  17. package/dist/identity/external/apple.d.ts +3 -0
  18. package/dist/identity/external/apple.js +10 -1
  19. package/dist/identity/external/errors.d.ts +3 -1
  20. package/dist/identity/external/flow.d.ts +4 -2
  21. package/dist/identity/external/flow.js +10 -2
  22. package/dist/identity/external/oauth2.d.ts +19 -1
  23. package/dist/identity/external/oauth2.js +21 -2
  24. package/dist/identity/external/oidc.d.ts +6 -0
  25. package/dist/identity/external/oidc.js +13 -1
  26. package/dist/identity/external/provider.d.ts +12 -2
  27. package/dist/models/authentication.d.ts +28 -0
  28. package/dist/models/authorization-code.d.ts +3 -3
  29. package/dist/models/responses.d.ts +8 -2
  30. package/dist/server/authentication-request.d.ts +60 -0
  31. package/dist/server/authentication-request.js +102 -0
  32. package/dist/server/authorization-server.d.ts +55 -7
  33. package/dist/server/authorization-server.js +54 -11
  34. package/dist/server/mod.d.ts +3 -2
  35. package/dist/server/mod.js +2 -1
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -85,12 +85,14 @@ The package implements these specifications, with the limits noted:
85
85
  [RFC 8628](https://www.rfc-editor.org/rfc/rfc8628) device authorization.
86
86
  - [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) `iss` in authorization
87
87
  responses. `DirectClient` and the BFF check it when the client is configured
88
- with an `issuer`.
88
+ with an `issuer`, and so do the external sign-in connectors.
89
89
  - [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068) JWT access tokens, opt-in;
90
90
  access tokens are opaque by default.
91
91
  - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html):
92
- the authorization code flow, ES256 ID tokens, `nonce`, and UserInfo. The
93
- server does not act on `prompt` or `max_age`.
92
+ the authorization code flow, ES256 ID tokens, `nonce`, UserInfo, and the
93
+ `prompt`, `max_age` and `acr_values` controls, with `auth_time` and `acr` in
94
+ the ID token. Your login callback performs any re-authentication they need,
95
+ and there is no `claims` request parameter.
94
96
  - OpenID Connect [Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
95
97
  and [RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
96
98
  - [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html),
@@ -35,7 +35,7 @@
35
35
  import { Hono } from "hono";
36
36
  import type { Context, Handler, MiddlewareHandler } from "hono";
37
37
  import type { RequireConditions } from "../../models/authorization.ts";
38
- import type { AuthenticationContext } from "../../models/authentication.ts";
38
+ import type { AuthenticationContext, AuthenticationRequest } from "../../models/authentication.ts";
39
39
  import type { ClientInterface } from "../../models/client.ts";
40
40
  import type { AbstractScope, BasicScope } from "../../models/scope.ts";
41
41
  import { AuthorizationServer, type AuthorizationServerOptions } from "../../server/authorization-server.ts";
@@ -47,20 +47,29 @@ import type { AuthenticatedContext } from "../../server/resource-server.ts";
47
47
  * or session variables without reconstructing a {@link Request}. Return one
48
48
  * of:
49
49
  *
50
- * - `{ user, authorizedScope?, authenticationContext? }` — user is authenticated;
51
- * the authorize flow continues. Supply only verified event claims in
52
- * `authenticationContext`; the code and its tokens retain that snapshot.
50
+ * - `{ user, authorizedScope?, authenticationContext?, reauthenticated? }` —
51
+ * user is authenticated; the authorize flow continues. Supply only verified
52
+ * event claims in `authenticationContext`; the code and its tokens retain
53
+ * that snapshot. Set `reauthenticated: true` only when the user actively
54
+ * authenticated in response to this authorization request.
53
55
  * - `null` — explicit denial; the framework redirects to `redirect_uri` with
54
56
  * `error=access_denied`.
55
57
  * - A `Response` (typically `c.redirect(...)` to a login page) —
56
58
  * short-circuits the authorize flow and is returned to the browser as-is.
57
59
  * This is how login redirects, interstitials, etc. are wired without
58
- * wrapping the framework handler.
60
+ * wrapping the framework handler. Under `prompt=none` the server answers
61
+ * `login_required` instead.
62
+ *
63
+ * The second argument, always passed, carries the request's validated
64
+ * `prompt`, `max_age` and `acr_values`. The server answers `login_required`
65
+ * when the returned user fails `satisfiesAuthenticationRequest`, so send the
66
+ * user to sign in again whenever the session cannot meet it.
59
67
  */
60
- export type HonoAuthenticateUserFn<User> = (c: Context) => Promise<{
68
+ export type HonoAuthenticateUserFn<User> = (c: Context, authentication?: AuthenticationRequest) => Promise<{
61
69
  user: User;
62
70
  authorizedScope?: AbstractScope;
63
71
  authenticationContext?: AuthenticationContext;
72
+ reauthenticated?: boolean;
64
73
  } | Response | null>;
65
74
  /**
66
75
  * Collects consent from the resource owner for the requested scope.
@@ -76,9 +85,13 @@ export type HonoAuthenticateUserFn<User> = (c: Context) => Promise<{
76
85
  * - A `Response` (typically `c.html(...)` rendering a consent page) —
77
86
  * short-circuits the authorize flow and is returned to the browser as-is.
78
87
  * The consent UI should resubmit to the authorize endpoint with a
79
- * decision parameter the callback can read on the next request.
88
+ * decision parameter the callback can read on the next request. Under
89
+ * `prompt=none` the server answers `consent_required` instead.
90
+ *
91
+ * With `prompt=consent` the server calls it even when the scope is already
92
+ * authorized, and the fifth argument, always passed, says so.
80
93
  */
81
- export type HonoHandleConsentFn<Client extends ClientInterface, S extends AbstractScope> = (c: Context, client: Client, requestedScope: S | undefined, user: unknown) => Promise<{
94
+ export type HonoHandleConsentFn<Client extends ClientInterface, S extends AbstractScope> = (c: Context, client: Client, requestedScope: S | undefined, user: unknown, authentication?: AuthenticationRequest) => Promise<{
82
95
  approved: boolean;
83
96
  scope?: S;
84
97
  } | Response>;
@@ -104,9 +104,9 @@ export class HonoAuthorizationServer extends AuthorizationServer {
104
104
  authorizeHandler(options) {
105
105
  const { authenticateUser, handleConsent } = options;
106
106
  return (c) => {
107
- const auth = () => authenticateUser(c);
107
+ const auth = (_request, authentication) => authenticateUser(c, authentication);
108
108
  const consent = handleConsent
109
- ? (client, requestedScope, user) => handleConsent(c, client, requestedScope, user)
109
+ ? (client, requestedScope, user, authentication) => handleConsent(c, client, requestedScope, user, authentication)
110
110
  : undefined;
111
111
  return this.handleAuthorizeRequest(c.req.raw, auth, consent);
112
112
  };
@@ -296,7 +296,9 @@ export interface BffCallbackError {
296
296
  * `resolveOrigin` threw, so `iss` could not be checked; the description is
297
297
  * fixed and the failure is in `cause`), or `invalid_grant`
298
298
  * (the code exchange failed — expired/replayed code, unknown `state` — or
299
- * a later step threw: `resolveUser` or the session store).
299
+ * a later step threw: `resolveUser` or the session store), or
300
+ * `login_required` (a pinned `max_age` and an ID token whose `auth_time`
301
+ * does not satisfy it — see {@link HonoBffOptions.extraParams}).
300
302
  */
301
303
  error: string;
302
304
  /** Human-readable description; never contains secrets. */
@@ -514,7 +516,18 @@ export interface HonoBffOptions {
514
516
  * value, name it in {@link forwardedParams} instead.
515
517
  *
516
518
  * `prompt` is an ordinary name here — pin `{ prompt: "login" }` to make
517
- * every sign-in re-authenticate. A pinned `scope` is accepted only when
519
+ * every sign-in re-authenticate. A pinned `max_age` must be a non-negative
520
+ * integer, and `/auth/callback` then checks the ID token's `auth_time`
521
+ * (OpenID Connect Core §3.1.3.7): a sign-in whose `auth_time` is missing,
522
+ * more than 60 seconds in the future, or older than `max_age` plus those
523
+ * 60 seconds (an allowance for clock skew between the BFF and the
524
+ * authorization server) fails with
525
+ * `login_required`. `max_age=0` means `prompt=login`, so it needs
526
+ * `auth_time` but no particular age. A token response without an ID token
527
+ * is not checked, unless {@link HonoBffOptions.forwardedParams} names
528
+ * `scope`: the browser could then leave out `openid` to skip the check, so
529
+ * a missing ID token fails with `login_required` too. A pinned `scope` is
530
+ * accepted only when
518
531
  * {@link scope} is unset; setting both would leave which one applies
519
532
  * ambiguous. Either configuration supplies the scope the login flow records
520
533
  * for its authorization request.
@@ -744,9 +757,10 @@ export declare class HonoBff {
744
757
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
745
758
  * parameter, gives one a name that is empty or not equal to its trimmed
746
759
  * form (`" state"` and `"organization "` are refused rather than trimmed),
747
- * names the same parameter in both, or pins `scope` in
760
+ * names the same parameter in both, pins `scope` in
748
761
  * {@linkcode HonoBffOptions.extraParams} while
749
- * {@linkcode HonoBffOptions.scope} is also set.
762
+ * {@linkcode HonoBffOptions.scope} is also set, or pins a `max_age` that is
763
+ * not a non-negative integer.
750
764
  */
751
765
  constructor(options: HonoBffOptions);
752
766
  /**
@@ -150,6 +150,47 @@ const RESERVED_AUTHORIZE_PARAMS = [
150
150
  "request_uri",
151
151
  ];
152
152
  const RESERVED_AUTHORIZE_PARAM_SET = new Set(RESERVED_AUTHORIZE_PARAMS);
153
+ const AUTH_TIME_LEEWAY_SECONDS = 60;
154
+ function pinnedMaxAge(fixed) {
155
+ let strictest;
156
+ for (const [name, value] of Object.entries(fixed)) {
157
+ if (name.toLowerCase() !== "max_age" || value === "")
158
+ continue;
159
+ const seconds = Number(value);
160
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(seconds)) {
161
+ throw new Error(`extraParams pins ${JSON.stringify(name)} to ${JSON.stringify(value)}, ` +
162
+ "which is not a non-negative integer number of seconds. The " +
163
+ "authorization server refuses such a max_age, and the callback " +
164
+ "could not check the ID token's auth_time against it.");
165
+ }
166
+ strictest =
167
+ strictest === undefined ? seconds : Math.min(strictest, seconds);
168
+ }
169
+ return strictest;
170
+ }
171
+ class CallbackRefusal extends Error {
172
+ refusal;
173
+ constructor(refusal) {
174
+ super(refusal.error_description);
175
+ this.refusal = refusal;
176
+ }
177
+ }
178
+ function authTimeShortfall(claims, maxAge, now) {
179
+ const authTime = typeof claims === "object" && claims !== null
180
+ ? claims.auth_time
181
+ : undefined;
182
+ if (typeof authTime !== "number" || !Number.isFinite(authTime)) {
183
+ return "the ID token has no auth_time, which the max_age this sign-in requested requires";
184
+ }
185
+ const age = Math.floor(now / 1000) - authTime;
186
+ if (age < -AUTH_TIME_LEEWAY_SECONDS) {
187
+ return "the ID token's auth_time is in the future";
188
+ }
189
+ if (maxAge > 0 && age > maxAge + AUTH_TIME_LEEWAY_SECONDS) {
190
+ return "the ID token's auth_time is older than the max_age this sign-in requested";
191
+ }
192
+ return undefined;
193
+ }
153
194
  function assertUsableAuthorizeParamName(name, option) {
154
195
  if (name === "" || name !== name.trim()) {
155
196
  throw new Error(`${option} may not use ${JSON.stringify(name)} as an ` +
@@ -207,7 +248,12 @@ function resolveExtraAuthorizeParams(options) {
207
248
  "alone.");
208
249
  }
209
250
  }
210
- return { fixed, forwarded };
251
+ return {
252
+ fixed,
253
+ forwarded,
254
+ maxAge: pinnedMaxAge(fixed),
255
+ scopeForwarded: forwarded.some((name) => name.toLowerCase() === "scope"),
256
+ };
211
257
  }
212
258
  function authorizeParamsForRequest(resolved, requestParams) {
213
259
  const entries = Object.entries(resolved.fixed);
@@ -365,9 +411,10 @@ export class HonoBff {
365
411
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
366
412
  * parameter, gives one a name that is empty or not equal to its trimmed
367
413
  * form (`" state"` and `"organization "` are refused rather than trimmed),
368
- * names the same parameter in both, or pins `scope` in
414
+ * names the same parameter in both, pins `scope` in
369
415
  * {@linkcode HonoBffOptions.extraParams} while
370
- * {@linkcode HonoBffOptions.scope} is also set.
416
+ * {@linkcode HonoBffOptions.scope} is also set, or pins a `max_age` that is
417
+ * not a non-negative integer.
371
418
  */
372
419
  constructor(options) {
373
420
  assertStoresConfigured(options);
@@ -737,6 +784,23 @@ export class HonoBff {
737
784
  let user = null;
738
785
  let sid;
739
786
  const idToken = raw.id_token;
787
+ const { maxAge, scopeForwarded } = this.#extraAuthorizeParams;
788
+ if (maxAge !== undefined && (idToken || scopeForwarded)) {
789
+ let claims;
790
+ try {
791
+ claims = idToken ? this.#client.decodeIdToken(idToken) : undefined;
792
+ }
793
+ catch {
794
+ claims = undefined;
795
+ }
796
+ const shortfall = authTimeShortfall(claims, maxAge, Date.now());
797
+ if (shortfall) {
798
+ throw new CallbackRefusal({
799
+ error: "login_required",
800
+ error_description: shortfall,
801
+ });
802
+ }
803
+ }
740
804
  if (idToken) {
741
805
  try {
742
806
  const claims = this.#client.decodeIdToken(idToken);
@@ -774,6 +838,8 @@ export class HonoBff {
774
838
  return c.redirect(this.#safeReturnTo(returnTo));
775
839
  }
776
840
  catch (cause) {
841
+ if (cause instanceof CallbackRefusal)
842
+ return await fail(cause.refusal);
777
843
  return await fail({
778
844
  error: "invalid_grant",
779
845
  error_description: "code exchange failed",
@@ -21,6 +21,7 @@ import { AuthorizationCodeGrant } from "../../server/grants/authorization-code.j
21
21
  import { ClientCredentialsGrant } from "../../server/grants/client-credentials.js";
22
22
  import { PasswordGrant } from "../../server/grants/password.js";
23
23
  import { RefreshTokenGrant } from "../../server/grants/refresh-token.js";
24
+ import { satisfiesAuthenticationRequest } from "../../server/authentication-request.js";
24
25
  import { StaticSigningKeyProvider, } from "../../server/signing-keys.js";
25
26
  import { generateCodeChallenge, generateCodeVerifier, generateState, } from "../../utils/pkce.js";
26
27
  import { DEFAULT_MAX_BODY_BYTES, readBoundedBody } from "../../utils/_body.js";
@@ -43,6 +44,14 @@ const WILDCARD_HOSTNAMES = new Set(["0.0.0.0", "::", "[::]"]);
43
44
  export function isLoopbackHostname(hostname) {
44
45
  return LOOPBACK_HOSTNAMES.has(hostname) || hostname.startsWith("127.");
45
46
  }
47
+ const REAUTHENTICATION_WINDOW_SECONDS = 600;
48
+ function newSession(userId, signedInFor) {
49
+ return {
50
+ userId,
51
+ authTime: Math.floor(Date.now() / 1000),
52
+ ...(signedInFor === undefined ? {} : { signedInFor }),
53
+ };
54
+ }
46
55
  async function createState(config, signingKey, issuer) {
47
56
  const userService = new MemoryUserService();
48
57
  const clientService = new MemoryClientService(userService);
@@ -181,21 +190,33 @@ function formValue(form, name) {
181
190
  const value = form.get(name);
182
191
  return typeof value === "string" ? value : undefined;
183
192
  }
184
- async function currentUser(state, request) {
193
+ function currentSession(state, request) {
185
194
  const sessionId = readCookie(request, SESSION_COOKIE);
186
- if (!sessionId)
187
- return undefined;
188
- const userId = state.sessions.get(sessionId);
189
- if (!userId)
190
- return undefined;
191
- return await state.userService.get(userId);
195
+ return sessionId ? state.sessions.get(sessionId) : undefined;
196
+ }
197
+ async function currentUser(state, request) {
198
+ const session = currentSession(state, request);
199
+ return session ? await state.userService.get(session.userId) : undefined;
192
200
  }
193
201
  function authenticateUserFor(state, config, request) {
194
- return async () => {
195
- const user = await currentUser(state, request);
196
- if (user)
197
- return { user };
198
- return htmlResponse(loginPage({ returnTo: pathAndQuery(request), users: config.users }));
202
+ return async (_request, authentication) => {
203
+ const returnTo = pathAndQuery(request);
204
+ const session = currentSession(state, request);
205
+ const user = session && (await state.userService.get(session.userId));
206
+ if (session && user) {
207
+ const result = {
208
+ user,
209
+ authenticationContext: { auth_time: session.authTime },
210
+ reauthenticated: session.signedInFor === returnTo &&
211
+ Math.floor(Date.now() / 1000) - session.authTime <=
212
+ REAUTHENTICATION_WINDOW_SECONDS,
213
+ };
214
+ if (!authentication ||
215
+ satisfiesAuthenticationRequest(authentication, result)) {
216
+ return result;
217
+ }
218
+ }
219
+ return htmlResponse(loginPage({ returnTo, users: config.users }));
199
220
  };
200
221
  }
201
222
  function handleConsentFor(state, request) {
@@ -258,7 +279,7 @@ async function handleLoginSubmission(state, config, request) {
258
279
  return htmlResponse(loginPage({ returnTo, users: config.users, error }), 401);
259
280
  }
260
281
  const sessionId = crypto.randomUUID();
261
- state.sessions.set(sessionId, user.id);
282
+ state.sessions.set(sessionId, newSession(user.id, returnTo));
262
283
  return redirect(returnTo, {
263
284
  "set-cookie": cookie(SESSION_COOKIE, sessionId),
264
285
  });
@@ -334,7 +355,7 @@ async function handleAdminSession(state, request) {
334
355
  return jsonError(400, 'no seeded user matched "userId" or "username"');
335
356
  }
336
357
  const sessionId = crypto.randomUUID();
337
- state.sessions.set(sessionId, user.id);
358
+ state.sessions.set(sessionId, newSession(user.id));
338
359
  return new Response(JSON.stringify({
339
360
  sessionId,
340
361
  cookie: SESSION_COOKIE,
@@ -6,11 +6,16 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- import { type OAuth2Error } from "../errors.ts";
9
+ import { type OAuth2Error, type OAuth2ErrorClass as OAuth2ErrorClassType } from "../errors.ts";
10
10
  /** Response bytes accepted from an endpoint before the call is refused. */
11
11
  export declare const MAX_RESPONSE_BYTES: number;
12
12
  /** Wall-clock budget for one call, covering the connect and the body read. */
13
13
  export declare const REQUEST_TIMEOUT_MS = 10000;
14
+ /**
15
+ * The typed error class for an OAuth2 or OpenID Connect error code, or the
16
+ * generic `OAuth2Error` for a code without one.
17
+ */
18
+ export declare function errorClassForCode(code: string): OAuth2ErrorClassType;
14
19
  /** How a call describes itself in an error message, e.g. `"token endpoint"`. */
15
20
  export type Endpoint = string;
16
21
  /** How {@link sendGuarded} treats a `3xx`. */
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- import { AccessDeniedError, AuthorizationPendingError, ExpiredTokenError, InsufficientScopeError, InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTokenError, OAuth2Error as OAuth2ErrorClass, ServerError, SlowDownError, TemporarilyUnavailableError, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, UnsupportedTokenTypeError, } from "../errors.js";
9
+ import { AccessDeniedError, AccountSelectionRequiredError, AuthorizationPendingError, ConsentRequiredError, ExpiredTokenError, InsufficientScopeError, InteractionRequiredError, InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTokenError, LoginRequiredError, OAuth2Error as OAuth2ErrorClass, ServerError, SlowDownError, TemporarilyUnavailableError, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, UnsupportedTokenTypeError, } from "../errors.js";
10
10
  import { packageDeadlineSignal } from "../utils/_default-fetch.js";
11
11
  import { sanitizeProviderText } from "../utils/text.js";
12
12
  /** Response bytes accepted from an endpoint before the call is refused. */
@@ -15,14 +15,18 @@ export const MAX_RESPONSE_BYTES = 64 * 1024;
15
15
  export const REQUEST_TIMEOUT_MS = 10_000;
16
16
  const ERROR_CLASS_BY_CODE = {
17
17
  access_denied: AccessDeniedError,
18
+ account_selection_required: AccountSelectionRequiredError,
18
19
  authorization_pending: AuthorizationPendingError,
20
+ consent_required: ConsentRequiredError,
19
21
  expired_token: ExpiredTokenError,
20
22
  insufficient_scope: InsufficientScopeError,
23
+ interaction_required: InteractionRequiredError,
21
24
  invalid_client: InvalidClientError,
22
25
  invalid_grant: InvalidGrantError,
23
26
  invalid_request: InvalidRequestError,
24
27
  invalid_scope: InvalidScopeError,
25
28
  invalid_token: InvalidTokenError,
29
+ login_required: LoginRequiredError,
26
30
  server_error: ServerError,
27
31
  slow_down: SlowDownError,
28
32
  temporarily_unavailable: TemporarilyUnavailableError,
@@ -31,6 +35,15 @@ const ERROR_CLASS_BY_CODE = {
31
35
  unsupported_response_type: UnsupportedResponseTypeError,
32
36
  unsupported_token_type: UnsupportedTokenTypeError,
33
37
  };
38
+ /**
39
+ * The typed error class for an OAuth2 or OpenID Connect error code, or the
40
+ * generic `OAuth2Error` for a code without one.
41
+ */
42
+ export function errorClassForCode(code) {
43
+ return Object.hasOwn(ERROR_CLASS_BY_CODE, code)
44
+ ? ERROR_CLASS_BY_CODE[code]
45
+ : OAuth2ErrorClass;
46
+ }
34
47
  /**
35
48
  * Sends one request with a deadline.
36
49
  *
@@ -228,7 +241,7 @@ function reportedError(value) {
228
241
  };
229
242
  }
230
243
  function buildError(reported, status) {
231
- const ErrorClass = ERROR_CLASS_BY_CODE[reported.error] ?? OAuth2ErrorClass;
244
+ const ErrorClass = errorClassForCode(reported.error);
232
245
  return new ErrorClass(reported.description ?? reported.error, {
233
246
  status: status !== undefined && status >= 400 ? status : undefined,
234
247
  extensions: {
@@ -1,3 +1,10 @@
1
1
  import { type OAuth2Error } from "../errors.ts";
2
2
  export declare function issuerRefusal(message: string): OAuth2Error;
3
3
  export declare function isIssuerRefusal(error: unknown): error is OAuth2Error;
4
+ export declare const DUPLICATE_ISS_PROBLEM = "the authorization response carries more than one `iss` (RFC 9207)";
5
+ /**
6
+ * Why an authorization response's `iss` values (RFC 9207 §2.4) are refused, or
7
+ * `undefined` when they are accepted: more than one value, none when
8
+ * `required`, or one that is not exactly one of `accepted`.
9
+ */
10
+ export declare function authorizationResponseIssuerProblem(received: readonly string[], accepted: readonly string[], required: boolean): string | undefined;
@@ -8,3 +8,25 @@ export function issuerRefusal(message) {
8
8
  export function isIssuerRefusal(error) {
9
9
  return error instanceof Error && refusals.has(error);
10
10
  }
11
+ export const DUPLICATE_ISS_PROBLEM = "the authorization response carries more than one `iss` (RFC 9207)";
12
+ /**
13
+ * Why an authorization response's `iss` values (RFC 9207 §2.4) are refused, or
14
+ * `undefined` when they are accepted: more than one value, none when
15
+ * `required`, or one that is not exactly one of `accepted`.
16
+ */
17
+ export function authorizationResponseIssuerProblem(received, accepted, required) {
18
+ if (received.length > 1)
19
+ return DUPLICATE_ISS_PROBLEM;
20
+ const [iss] = received;
21
+ if (iss === undefined) {
22
+ if (!required)
23
+ return undefined;
24
+ return ("the authorization response is missing `iss`, which the issuer " +
25
+ "advertises with `authorization_response_iss_parameter_supported` " +
26
+ "(RFC 9207 §2.4)");
27
+ }
28
+ if (accepted.includes(iss))
29
+ return undefined;
30
+ return ("the authorization response `iss` does not match the issuer the " +
31
+ "request was sent to (RFC 9207 §2.4)");
32
+ }
@@ -423,7 +423,8 @@ export declare class DirectClient extends OAuth2ClientBase {
423
423
  * reached or its response does not arrive in full before the deadline;
424
424
  * `cause` is the transport failure.
425
425
  * @throws {OAuth2Error} when the callback carries `?error=…`, or the token
426
- * exchange fails.
426
+ * exchange fails. A code with a typed class, such as `login_required`
427
+ * (`LoginRequiredError`), is thrown as that class.
427
428
  * @throws {InvalidRequestError} when the response's `iss` is refused.
428
429
  * @throws {TypeError} when `code` or `state` is missing, or outside a
429
430
  * browser when the client has no `authRequestStorage` or no
@@ -10,16 +10,16 @@
10
10
  *
11
11
  * @module
12
12
  */
13
- import { AccessDeniedError, InvalidGrantError, isOAuth2Error, OAuth2Error as OAuth2ErrorClass, ServerError, } from "../errors.js";
13
+ import { AccessDeniedError, InvalidGrantError, isOAuth2Error, ServerError, } from "../errors.js";
14
14
  import { challengeMethods, generateCodeVerifier, generateState, } from "../utils/pkce.js";
15
15
  import { encodeBasicAuth } from "../utils/basic-auth.js";
16
16
  import { base64urlDecode } from "../utils/crypto.js";
17
17
  import { delay } from "../utils/_delay.js";
18
18
  import { sanitizeProviderText } from "../utils/text.js";
19
- import { assertField, assertTokenResponse, errorFromResponse, receiveJson, sendGuarded, } from "./_http.js";
19
+ import { assertField, assertTokenResponse, errorClassForCode, 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
+ import { authorizationResponseIssuerProblem, issuerRefusal, } from "./_issuer-refusal.js";
23
23
  import { DEFAULT_DISCOVERY_TTL_MS, } from "./discovery-cache.js";
24
24
  import { MemoryAuthRequestStorage, MemoryRefreshTokenStorage, MemoryTokenStorage, } from "./storage.js";
25
25
  const REFRESH_SKEW_MS = 30_000;
@@ -396,7 +396,8 @@ export class DirectClient extends OAuth2ClientBase {
396
396
  * reached or its response does not arrive in full before the deadline;
397
397
  * `cause` is the transport failure.
398
398
  * @throws {OAuth2Error} when the callback carries `?error=…`, or the token
399
- * exchange fails.
399
+ * exchange fails. A code with a typed class, such as `login_required`
400
+ * (`LoginRequiredError`), is thrown as that class.
400
401
  * @throws {InvalidRequestError} when the response's `iss` is refused.
401
402
  * @throws {TypeError} when `code` or `state` is missing, or outside a
402
403
  * browser when the client has no `authRequestStorage` or no
@@ -501,26 +502,11 @@ export class DirectClient extends OAuth2ClientBase {
501
502
  if (!this.#issuer)
502
503
  return undefined;
503
504
  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
505
  const accepted = [metadata.issuer];
518
506
  if (origin)
519
507
  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)");
508
+ const problem = authorizationResponseIssuerProblem(params.getAll("iss"), accepted, metadata.authorization_response_iss_parameter_supported === true);
509
+ return problem === undefined ? undefined : issuerRefusal(problem);
524
510
  }
525
511
  /**
526
512
  * **Advanced (BFF / multi-session servers).** SPA, mobile, and CLI consumers
@@ -1322,7 +1308,8 @@ function metadataToEndpoints(meta) {
1322
1308
  }
1323
1309
  function authorizationResponseError(error, params) {
1324
1310
  const description = params.get("error_description") ?? undefined;
1325
- return new OAuth2ErrorClass(description ?? "authorization error", {
1311
+ const ErrorClass = errorClassForCode(error);
1312
+ return new ErrorClass(description ?? "authorization error", {
1326
1313
  extensions: {
1327
1314
  error,
1328
1315
  error_description: description,
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
+ });