@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.
- package/README.md +5 -3
- 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 +18 -4
- package/dist/adapters/hono/bff/bff.js +69 -3
- package/dist/cli/idp/server.js +35 -14
- package/dist/client/_http.d.ts +6 -1
- package/dist/client/_http.js +15 -2
- package/dist/client/_issuer-refusal.d.ts +7 -0
- package/dist/client/_issuer-refusal.js +22 -0
- package/dist/client/direct-client.d.ts +2 -1
- package/dist/client/direct-client.js +9 -22
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +58 -0
- package/dist/identity/external/_shared.d.ts +18 -0
- package/dist/identity/external/_shared.js +15 -0
- package/dist/identity/external/apple.d.ts +3 -0
- package/dist/identity/external/apple.js +10 -1
- package/dist/identity/external/errors.d.ts +3 -1
- package/dist/identity/external/flow.d.ts +4 -2
- package/dist/identity/external/flow.js +10 -2
- package/dist/identity/external/oauth2.d.ts +19 -1
- package/dist/identity/external/oauth2.js +21 -2
- package/dist/identity/external/oidc.d.ts +6 -0
- package/dist/identity/external/oidc.js +13 -1
- package/dist/identity/external/provider.d.ts +12 -2
- package/dist/models/authentication.d.ts +28 -0
- package/dist/models/authorization-code.d.ts +3 -3
- package/dist/models/responses.d.ts +8 -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 +55 -7
- package/dist/server/authorization-server.js +54 -11
- package/dist/server/mod.d.ts +3 -2
- package/dist/server/mod.js +2 -1
- 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
|
|
93
|
-
|
|
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? }` —
|
|
51
|
-
* the authorize flow continues. Supply only verified
|
|
52
|
-
* `authenticationContext`; the code and its tokens retain
|
|
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 `
|
|
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,
|
|
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 {
|
|
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,
|
|
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",
|
package/dist/cli/idp/server.js
CHANGED
|
@@ -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
|
-
|
|
193
|
+
function currentSession(state, request) {
|
|
185
194
|
const sessionId = readCookie(request, SESSION_COOKIE);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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,
|
package/dist/client/_http.d.ts
CHANGED
|
@@ -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`. */
|
package/dist/client/_http.js
CHANGED
|
@@ -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 =
|
|
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,
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
+
});
|