@udibo/oauth2 0.27.0 → 0.29.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 +1 -1
- package/dist/adapters/hono/bff/bff.d.ts +18 -4
- package/dist/adapters/hono/bff/bff.js +69 -3
- 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/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/server/authorization-server.d.ts +2 -0
- package/dist/server/authorization-server.js +2 -0
- package/dist/server/client-authentication.d.ts +12 -6
- package/dist/server/client-authentication.js +34 -14
- package/dist/server/grants/grant.d.ts +6 -4
- package/dist/server/grants/grant.js +6 -4
- package/dist/utils/pkce.d.ts +3 -0
- package/dist/utils/pkce.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ 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):
|
|
@@ -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/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,
|
|
@@ -53,6 +53,24 @@ export declare function readProviderText(res: Response, provider: string, source
|
|
|
53
53
|
* the body, so the caller must not read it again.
|
|
54
54
|
*/
|
|
55
55
|
export declare function readErrorBody(res: Response): Promise<string>;
|
|
56
|
+
/** Input to {@link assertAuthorizationResponseIssuer}. */
|
|
57
|
+
export interface AuthorizationResponseIssuerInput {
|
|
58
|
+
/** The connector id, for the error prefix. */
|
|
59
|
+
provider: string;
|
|
60
|
+
/** The callback's `iss`, from {@link ExternalProfileInput.iss}. */
|
|
61
|
+
iss: string | undefined;
|
|
62
|
+
/** The provider's issuer identifier; `iss` must equal it exactly. */
|
|
63
|
+
issuer: string;
|
|
64
|
+
/** Whether the provider advertises `authorization_response_iss_parameter_supported`. */
|
|
65
|
+
required: boolean;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Refuses a callback whose `iss` (RFC 9207 §2.4) is not exactly `issuer`, or
|
|
69
|
+
* is absent while `required`. Call it before redeeming the code.
|
|
70
|
+
*
|
|
71
|
+
* @throws {ExternalAuthError} `invalid_callback` when the response is refused.
|
|
72
|
+
*/
|
|
73
|
+
export declare function assertAuthorizationResponseIssuer(input: AuthorizationResponseIssuerInput): void;
|
|
56
74
|
/**
|
|
57
75
|
* Drops one trailing `"/"` so issuer URLs compare and concatenate consistently
|
|
58
76
|
* (`"https://x/"` and `"https://x"` identify the same issuer).
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
|
+
import { authorizationResponseIssuerProblem } from "../../client/_issuer-refusal.js";
|
|
11
12
|
import { isOAuth2Error } from "../../errors.js";
|
|
12
13
|
import { readBoundedBody } from "../../utils/_body.js";
|
|
13
14
|
import { timingSafeEqualString } from "../../utils/crypto.js";
|
|
@@ -90,6 +91,20 @@ export async function readErrorBody(res) {
|
|
|
90
91
|
return "";
|
|
91
92
|
}
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Refuses a callback whose `iss` (RFC 9207 §2.4) is not exactly `issuer`, or
|
|
96
|
+
* is absent while `required`. Call it before redeeming the code.
|
|
97
|
+
*
|
|
98
|
+
* @throws {ExternalAuthError} `invalid_callback` when the response is refused.
|
|
99
|
+
*/
|
|
100
|
+
export function assertAuthorizationResponseIssuer(input) {
|
|
101
|
+
const problem = authorizationResponseIssuerProblem(input.iss === undefined ? [] : [input.iss], [input.issuer], input.required);
|
|
102
|
+
if (problem === undefined)
|
|
103
|
+
return;
|
|
104
|
+
throw new ExternalAuthError(input.provider, "invalid_callback", `${problem}. The callback did not come from "${input.issuer}" — a ` +
|
|
105
|
+
`response from another provider replayed here, or a provider that ` +
|
|
106
|
+
`does not send the issuer it advertises. Ask the user to start again.`);
|
|
107
|
+
}
|
|
93
108
|
/**
|
|
94
109
|
* Drops one trailing `"/"` so issuer URLs compare and concatenate consistently
|
|
95
110
|
* (`"https://x/"` and `"https://x"` identify the same issuer).
|
|
@@ -50,6 +50,9 @@ export interface AppleProviderOptions extends Pick<AppleClientSecretOptions, "te
|
|
|
50
50
|
* `id_token`'s RS256 signature against Apple's published keys before trusting
|
|
51
51
|
* its claims (`iss`, `aud`, `azp`, `exp`, and the `nonce` when one was sent,
|
|
52
52
|
* checked as {@link oidcProvider} checks them, with the default `azp` policy).
|
|
53
|
+
* A callback `iss` (RFC 9207) other than `https://appleid.apple.com` is
|
|
54
|
+
* refused before the code is redeemed; Apple does not advertise the
|
|
55
|
+
* parameter, so a callback without one is accepted.
|
|
53
56
|
* `emailVerified` is `true` when Apple's `email_verified` claim is `true` or
|
|
54
57
|
* the string `"true"`; private-relay addresses
|
|
55
58
|
* (`@privaterelay.appleid.com`) are returned as-is. The raw `id_token`
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { toArrayBuffer } from "../../utils/_buffer.js";
|
|
19
19
|
import { base64urlDecode } from "../../utils/crypto.js";
|
|
20
20
|
import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
|
|
21
|
-
import { assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, } from "./_shared.js";
|
|
21
|
+
import { assertAuthorizationResponseIssuer, assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, } from "./_shared.js";
|
|
22
22
|
import { runTokenExchange } from "./_token-exchange.js";
|
|
23
23
|
import { createAppleClientSecretFactory, } from "./apple-client-secret.js";
|
|
24
24
|
import { ExternalAuthError } from "./errors.js";
|
|
@@ -41,6 +41,9 @@ const SIGNATURE_NOT_VERIFIED = `Apple id_token signature did not verify against
|
|
|
41
41
|
* `id_token`'s RS256 signature against Apple's published keys before trusting
|
|
42
42
|
* its claims (`iss`, `aud`, `azp`, `exp`, and the `nonce` when one was sent,
|
|
43
43
|
* checked as {@link oidcProvider} checks them, with the default `azp` policy).
|
|
44
|
+
* A callback `iss` (RFC 9207) other than `https://appleid.apple.com` is
|
|
45
|
+
* refused before the code is redeemed; Apple does not advertise the
|
|
46
|
+
* parameter, so a callback without one is accepted.
|
|
44
47
|
* `emailVerified` is `true` when Apple's `email_verified` claim is `true` or
|
|
45
48
|
* the string `"true"`; private-relay addresses
|
|
46
49
|
* (`@privaterelay.appleid.com`) are returned as-is. The raw `id_token`
|
|
@@ -205,6 +208,12 @@ export function appleProvider(options) {
|
|
|
205
208
|
return Promise.resolve(url.toString());
|
|
206
209
|
},
|
|
207
210
|
async fetchProfile(input) {
|
|
211
|
+
assertAuthorizationResponseIssuer({
|
|
212
|
+
provider: id,
|
|
213
|
+
iss: input.iss,
|
|
214
|
+
issuer: APPLE_ISSUER,
|
|
215
|
+
required: false,
|
|
216
|
+
});
|
|
208
217
|
const idToken = await exchangeCode(input);
|
|
209
218
|
const claims = await verifyIdToken(idToken, input.nonce);
|
|
210
219
|
return profileFromClaims(claims);
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* - `provider_error` — the provider rejected the request (callback `error`
|
|
20
20
|
* param, token-endpoint error, profile-endpoint failure, malformed
|
|
21
21
|
* response). The message carries the provider's error code/description.
|
|
22
|
-
* - `invalid_callback` — the callback URL is missing `code` or `state
|
|
22
|
+
* - `invalid_callback` — the callback URL is missing `code` or `state`, or
|
|
23
|
+
* its `iss` (RFC 9207) is not the provider's issuer: repeated, naming
|
|
24
|
+
* another issuer, or missing from a provider that advertises it.
|
|
23
25
|
* - `state_mismatch` — the callback `state` does not match the transient
|
|
24
26
|
* (possible CSRF or a stale/mixed-up sign-in attempt).
|
|
25
27
|
* - `transient_expired` — the sign-in attempt outlived the configured max
|
|
@@ -99,8 +99,10 @@ export declare class ExternalAuthFlow {
|
|
|
99
99
|
* Completes a sign-in attempt from the provider callback: checks the
|
|
100
100
|
* transient belongs to this flow's provider, enforces the max transient
|
|
101
101
|
* age, verifies `state` with a timing-safe compare, and only then surfaces a
|
|
102
|
-
* provider `error` callback param
|
|
103
|
-
*
|
|
102
|
+
* provider `error` callback param. A callback with more than one `iss` is
|
|
103
|
+
* refused; a single `iss` is handed to the provider, which checks it against
|
|
104
|
+
* its issuer (RFC 9207) before it exchanges the code. The provider then
|
|
105
|
+
* (for nonce-using providers) validates the id_token nonce, and the
|
|
104
106
|
* normalized profile is returned.
|
|
105
107
|
*
|
|
106
108
|
* @throws {ExternalAuthError} with a code describing the failure — see
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
+
import { DUPLICATE_ISS_PROBLEM } from "../../client/_issuer-refusal.js";
|
|
9
10
|
import { timingSafeEqualString } from "../../utils/crypto.js";
|
|
10
11
|
import { generateCodeVerifier, generateState } from "../../utils/pkce.js";
|
|
11
12
|
import { ExternalAuthError } from "./errors.js";
|
|
@@ -91,8 +92,10 @@ export class ExternalAuthFlow {
|
|
|
91
92
|
* Completes a sign-in attempt from the provider callback: checks the
|
|
92
93
|
* transient belongs to this flow's provider, enforces the max transient
|
|
93
94
|
* age, verifies `state` with a timing-safe compare, and only then surfaces a
|
|
94
|
-
* provider `error` callback param
|
|
95
|
-
*
|
|
95
|
+
* provider `error` callback param. A callback with more than one `iss` is
|
|
96
|
+
* refused; a single `iss` is handed to the provider, which checks it against
|
|
97
|
+
* its issuer (RFC 9207) before it exchanges the code. The provider then
|
|
98
|
+
* (for nonce-using providers) validates the id_token nonce, and the
|
|
96
99
|
* normalized profile is returned.
|
|
97
100
|
*
|
|
98
101
|
* @throws {ExternalAuthError} with a code describing the failure — see
|
|
@@ -134,11 +137,16 @@ export class ExternalAuthFlow {
|
|
|
134
137
|
if (!code) {
|
|
135
138
|
throw new ExternalAuthError(providerId, "invalid_callback", `callback is missing the "code" parameter.`);
|
|
136
139
|
}
|
|
140
|
+
const issValues = params.getAll("iss");
|
|
141
|
+
if (issValues.length > 1) {
|
|
142
|
+
throw new ExternalAuthError(providerId, "invalid_callback", `${DUPLICATE_ISS_PROBLEM}. Ask the user to start again.`);
|
|
143
|
+
}
|
|
137
144
|
return await this.provider.fetchProfile({
|
|
138
145
|
code,
|
|
139
146
|
redirectUri: transient.redirectUri,
|
|
140
147
|
codeVerifier: transient.codeVerifier,
|
|
141
148
|
nonce: transient.nonce,
|
|
149
|
+
...(issValues.length === 1 ? { iss: issValues[0] } : {}),
|
|
142
150
|
});
|
|
143
151
|
}
|
|
144
152
|
}
|
|
@@ -85,6 +85,20 @@ export interface OAuth2ProviderOptions {
|
|
|
85
85
|
* downgrade PKCE.
|
|
86
86
|
*/
|
|
87
87
|
authorizationParams?: Record<string, string>;
|
|
88
|
+
/**
|
|
89
|
+
* The provider's issuer identifier, for the RFC 9207 check. When set, a
|
|
90
|
+
* callback `iss` that is not exactly this value is refused before the code
|
|
91
|
+
* is redeemed. Unset, the callback's `iss` is not checked.
|
|
92
|
+
*/
|
|
93
|
+
issuer?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Set `true` when the provider always sends `iss` in its authorization
|
|
96
|
+
* responses (its metadata advertises
|
|
97
|
+
* `authorization_response_iss_parameter_supported`), so a callback without
|
|
98
|
+
* one is refused. Requires {@link OAuth2ProviderOptions.issuer}. Defaults to
|
|
99
|
+
* `false`.
|
|
100
|
+
*/
|
|
101
|
+
authorizationResponseIssParameterSupported?: boolean;
|
|
88
102
|
/**
|
|
89
103
|
* Fetch implementation for all provider traffic. Defaults to
|
|
90
104
|
* `globalThis.fetch`. A wrapper must pass `init` through intact: its
|
|
@@ -100,7 +114,11 @@ export interface OAuth2ProviderOptions {
|
|
|
100
114
|
* credentials in the body (`client_secret_post`); the profile is read from
|
|
101
115
|
* `userInfoEndpoint` with the bearer token when set, else from the token
|
|
102
116
|
* response, and handed to `mapProfile`. Presets like {@link discordProvider}
|
|
103
|
-
* are thin wrappers over this.
|
|
117
|
+
* are thin wrappers over this. With `issuer` set, the callback's `iss`
|
|
118
|
+
* (RFC 9207) is checked before the exchange.
|
|
119
|
+
*
|
|
120
|
+
* @throws {ExternalAuthError} `configuration` when
|
|
121
|
+
* `authorizationResponseIssParameterSupported` is set without `issuer`.
|
|
104
122
|
*
|
|
105
123
|
* @example
|
|
106
124
|
* ```ts
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
|
|
11
11
|
import { generateCodeChallenge } from "../../utils/pkce.js";
|
|
12
|
-
import { describeError, PROVIDER_REQUEST_TIMEOUT_MS, readErrorBody, readProviderText, } from "./_shared.js";
|
|
12
|
+
import { assertAuthorizationResponseIssuer, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readErrorBody, readProviderText, } from "./_shared.js";
|
|
13
13
|
import { runTokenExchange } from "./_token-exchange.js";
|
|
14
14
|
import { ExternalAuthError } from "./errors.js";
|
|
15
15
|
/**
|
|
@@ -20,7 +20,11 @@ import { ExternalAuthError } from "./errors.js";
|
|
|
20
20
|
* credentials in the body (`client_secret_post`); the profile is read from
|
|
21
21
|
* `userInfoEndpoint` with the bearer token when set, else from the token
|
|
22
22
|
* response, and handed to `mapProfile`. Presets like {@link discordProvider}
|
|
23
|
-
* are thin wrappers over this.
|
|
23
|
+
* are thin wrappers over this. With `issuer` set, the callback's `iss`
|
|
24
|
+
* (RFC 9207) is checked before the exchange.
|
|
25
|
+
*
|
|
26
|
+
* @throws {ExternalAuthError} `configuration` when
|
|
27
|
+
* `authorizationResponseIssParameterSupported` is set without `issuer`.
|
|
24
28
|
*
|
|
25
29
|
* @example
|
|
26
30
|
* ```ts
|
|
@@ -40,6 +44,13 @@ import { ExternalAuthError } from "./errors.js";
|
|
|
40
44
|
*/
|
|
41
45
|
export function oauth2Provider(options) {
|
|
42
46
|
const id = options.id;
|
|
47
|
+
const { issuer } = options;
|
|
48
|
+
const issRequired = options.authorizationResponseIssParameterSupported === true;
|
|
49
|
+
if (issRequired && issuer === undefined) {
|
|
50
|
+
throw new ExternalAuthError(id, "configuration", `authorizationResponseIssParameterSupported needs an issuer to ` +
|
|
51
|
+
`compare the callback's "iss" with — set the provider's issuer ` +
|
|
52
|
+
`identifier as "issuer".`);
|
|
53
|
+
}
|
|
43
54
|
const fetchImpl = (input, init) => (options.fetch ?? defaultFetch)(input, init);
|
|
44
55
|
const scopeSeparator = options.scopeSeparator ?? " ";
|
|
45
56
|
async function exchangeCode(input) {
|
|
@@ -120,6 +131,14 @@ export function oauth2Provider(options) {
|
|
|
120
131
|
return url.toString();
|
|
121
132
|
},
|
|
122
133
|
async fetchProfile(input) {
|
|
134
|
+
if (issuer !== undefined) {
|
|
135
|
+
assertAuthorizationResponseIssuer({
|
|
136
|
+
provider: id,
|
|
137
|
+
iss: input.iss,
|
|
138
|
+
issuer,
|
|
139
|
+
required: issRequired,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
123
142
|
const tokens = await exchangeCode(input);
|
|
124
143
|
const profile = options.userInfoEndpoint
|
|
125
144
|
? await fetchUserInfo(tokens.accessToken)
|
|
@@ -65,6 +65,12 @@ export interface OidcProviderOptions {
|
|
|
65
65
|
* id_token `sub` (OIDC Core §5.3.2) is discarded, and a UserInfo failure is
|
|
66
66
|
* non-fatal — the profile then comes from the id_token claims alone.
|
|
67
67
|
*
|
|
68
|
+
* **Authorization response issuer:** before the code is redeemed, a callback
|
|
69
|
+
* `iss` (RFC 9207) that is not exactly the discovered issuer is refused, and
|
|
70
|
+
* so is a missing `iss` when the discovery document advertises
|
|
71
|
+
* `authorization_response_iss_parameter_supported: true`. A provider that
|
|
72
|
+
* does not advertise it may leave `iss` out.
|
|
73
|
+
*
|
|
68
74
|
* **id_token validation:** the `iss`, `aud`, `azp` (when the token has multiple
|
|
69
75
|
* audiences or an `azp` claim, unless `azp: "ignore"` is configured), `exp`,
|
|
70
76
|
* and `nonce` claims are checked per OpenID Connect Core §3.1.3.7. The
|
|
@@ -9,7 +9,7 @@ import { MemoryAuthRequestStorage } from "../../client/storage.js";
|
|
|
9
9
|
import { isOAuth2Error } from "../../errors.js";
|
|
10
10
|
import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
|
|
11
11
|
import { generateCodeChallenge } from "../../utils/pkce.js";
|
|
12
|
-
import { assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, stripTrailingSlash, } from "./_shared.js";
|
|
12
|
+
import { assertAuthorizationResponseIssuer, assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, stripTrailingSlash, } from "./_shared.js";
|
|
13
13
|
import { ExternalAuthError } from "./errors.js";
|
|
14
14
|
/**
|
|
15
15
|
* Creates a connector for any spec-compliant OpenID Connect provider.
|
|
@@ -23,6 +23,12 @@ import { ExternalAuthError } from "./errors.js";
|
|
|
23
23
|
* id_token `sub` (OIDC Core §5.3.2) is discarded, and a UserInfo failure is
|
|
24
24
|
* non-fatal — the profile then comes from the id_token claims alone.
|
|
25
25
|
*
|
|
26
|
+
* **Authorization response issuer:** before the code is redeemed, a callback
|
|
27
|
+
* `iss` (RFC 9207) that is not exactly the discovered issuer is refused, and
|
|
28
|
+
* so is a missing `iss` when the discovery document advertises
|
|
29
|
+
* `authorization_response_iss_parameter_supported: true`. A provider that
|
|
30
|
+
* does not advertise it may leave `iss` out.
|
|
31
|
+
*
|
|
26
32
|
* **id_token validation:** the `iss`, `aud`, `azp` (when the token has multiple
|
|
27
33
|
* audiences or an `azp` claim, unless `azp: "ignore"` is configured), `exp`,
|
|
28
34
|
* and `nonce` claims are checked per OpenID Connect Core §3.1.3.7. The
|
|
@@ -191,6 +197,12 @@ export function oidcProvider(options) {
|
|
|
191
197
|
},
|
|
192
198
|
async fetchProfile(input) {
|
|
193
199
|
const meta = await discover();
|
|
200
|
+
assertAuthorizationResponseIssuer({
|
|
201
|
+
provider: id,
|
|
202
|
+
iss: input.iss,
|
|
203
|
+
issuer: meta.issuer,
|
|
204
|
+
required: meta.authorization_response_iss_parameter_supported === true,
|
|
205
|
+
});
|
|
194
206
|
const response = await exchangeCode(input);
|
|
195
207
|
const claims = decodeAndValidateIdToken(response, meta, input.nonce);
|
|
196
208
|
const userInfo = await fetchUserInfoClaims(meta, response.access_token, claims.sub);
|
|
@@ -93,6 +93,14 @@ export interface ExternalProfileInput {
|
|
|
93
93
|
codeVerifier?: string;
|
|
94
94
|
/** The expected id_token `nonce` from the transient, when one was sent. */
|
|
95
95
|
nonce?: string;
|
|
96
|
+
/**
|
|
97
|
+
* The callback's `iss` parameter (RFC 9207), when it carried one. Refuse
|
|
98
|
+
* the callback before redeeming `code` when this is not exactly the
|
|
99
|
+
* provider's issuer, or when it is missing and the provider advertises
|
|
100
|
+
* `authorization_response_iss_parameter_supported`. The flow has already
|
|
101
|
+
* refused a callback with more than one `iss`.
|
|
102
|
+
*/
|
|
103
|
+
iss?: string;
|
|
96
104
|
}
|
|
97
105
|
/**
|
|
98
106
|
* What a connector must provide for {@link ExternalAuthFlow} to drive it.
|
|
@@ -125,10 +133,12 @@ export interface ExternalProvider {
|
|
|
125
133
|
/**
|
|
126
134
|
* Exchanges the authorization code and returns the verified, normalized
|
|
127
135
|
* profile. Implementations must validate the id_token `nonce` against
|
|
128
|
-
* `input.nonce` when one was sent.
|
|
136
|
+
* `input.nonce` when one was sent, and should check `input.iss` before the
|
|
137
|
+
* exchange as {@link ExternalProfileInput.iss} describes.
|
|
129
138
|
*
|
|
130
139
|
* @throws {ExternalAuthError} `provider_error` when the exchange or profile
|
|
131
|
-
* fetch fails, `nonce_mismatch` when the id_token nonce does not match
|
|
140
|
+
* fetch fails, `nonce_mismatch` when the id_token nonce does not match,
|
|
141
|
+
* `invalid_callback` when `input.iss` is refused.
|
|
132
142
|
*/
|
|
133
143
|
fetchProfile(input: ExternalProfileInput): Promise<ExternalProfile>;
|
|
134
144
|
}
|
|
@@ -678,6 +678,8 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
678
678
|
* authenticate through the same implementation, so a request that fails here
|
|
679
679
|
* fails identically at the token endpoint.
|
|
680
680
|
*
|
|
681
|
+
* @throws {InvalidRequestError} If the request uses more than one client
|
|
682
|
+
* authentication method (RFC 6749 Section 2.3)
|
|
681
683
|
* @throws {InvalidClientError} If client authentication fails
|
|
682
684
|
*/
|
|
683
685
|
authenticateClient(request: Request, body: FormData, services: AuthorizationServerServices<Client, User, S>): Promise<Client>;
|
|
@@ -970,6 +970,8 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
970
970
|
* authenticate through the same implementation, so a request that fails here
|
|
971
971
|
* fails identically at the token endpoint.
|
|
972
972
|
*
|
|
973
|
+
* @throws {InvalidRequestError} If the request uses more than one client
|
|
974
|
+
* authentication method (RFC 6749 Section 2.3)
|
|
973
975
|
* @throws {InvalidClientError} If client authentication fails
|
|
974
976
|
*/
|
|
975
977
|
async authenticateClient(request, body, services) {
|
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
import type { ClientCredentials, ClientInterface } from "../models/client.ts";
|
|
10
10
|
import type { ClientServiceInterface } from "./services/client.ts";
|
|
11
11
|
/**
|
|
12
|
-
* Reads the credentials a request presents
|
|
13
|
-
* 2.3.1),
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Reads the credentials a request presents by the one client-authentication
|
|
13
|
+
* method it uses: HTTP Basic (RFC 6749 Section 2.3.1), or
|
|
14
|
+
* `client_id`/`client_secret` in `body`, the form body the endpoint already
|
|
15
|
+
* parsed. A `client_id` alone identifies a public client. An `Authorization`
|
|
16
|
+
* header with a scheme other than Basic is not client authentication and is
|
|
17
|
+
* ignored.
|
|
17
18
|
*
|
|
18
|
-
* @throws {
|
|
19
|
+
* @throws {InvalidRequestError} If the request uses more than one method —
|
|
20
|
+
* Basic plus a body `client_secret` or a `client_assertion`, or a body
|
|
21
|
+
* `client_secret` plus a `client_assertion` — or sends Basic credentials
|
|
22
|
+
* with a body `client_id` naming a different client.
|
|
23
|
+
* @throws {InvalidClientError} If the request presents no usable credentials,
|
|
24
|
+
* or its Basic header is malformed.
|
|
19
25
|
*/
|
|
20
26
|
export declare function extractClientCredentials(request: Request, body: FormData): ClientCredentials;
|
|
21
27
|
/**
|
|
@@ -6,34 +6,54 @@
|
|
|
6
6
|
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-2.3
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import { InvalidClientError } from "../errors.js";
|
|
10
|
-
import { parseBasicAuth
|
|
9
|
+
import { InvalidClientError, InvalidRequestError } from "../errors.js";
|
|
10
|
+
import { parseBasicAuth } from "../utils/basic-auth.js";
|
|
11
|
+
const BASIC_SCHEME = /(?:^|,)[ \t]*basic(?:[ \t]|$)/i;
|
|
11
12
|
/**
|
|
12
|
-
* Reads the credentials a request presents
|
|
13
|
-
* 2.3.1),
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Reads the credentials a request presents by the one client-authentication
|
|
14
|
+
* method it uses: HTTP Basic (RFC 6749 Section 2.3.1), or
|
|
15
|
+
* `client_id`/`client_secret` in `body`, the form body the endpoint already
|
|
16
|
+
* parsed. A `client_id` alone identifies a public client. An `Authorization`
|
|
17
|
+
* header with a scheme other than Basic is not client authentication and is
|
|
18
|
+
* ignored.
|
|
17
19
|
*
|
|
18
|
-
* @throws {
|
|
20
|
+
* @throws {InvalidRequestError} If the request uses more than one method —
|
|
21
|
+
* Basic plus a body `client_secret` or a `client_assertion`, or a body
|
|
22
|
+
* `client_secret` plus a `client_assertion` — or sends Basic credentials
|
|
23
|
+
* with a body `client_id` naming a different client.
|
|
24
|
+
* @throws {InvalidClientError} If the request presents no usable credentials,
|
|
25
|
+
* or its Basic header is malformed.
|
|
19
26
|
*/
|
|
20
27
|
export function extractClientCredentials(request, body) {
|
|
21
28
|
const authorization = request.headers.get("authorization");
|
|
22
|
-
const
|
|
23
|
-
|
|
29
|
+
const usesBasic = !!authorization && BASIC_SCHEME.test(authorization);
|
|
30
|
+
const clientId = body.get("client_id");
|
|
31
|
+
const clientSecret = body.get("client_secret");
|
|
32
|
+
const methods = [
|
|
33
|
+
usesBasic,
|
|
34
|
+
clientSecret !== null,
|
|
35
|
+
body.has("client_assertion") || body.has("client_assertion_type"),
|
|
36
|
+
].filter(Boolean).length;
|
|
37
|
+
if (methods > 1) {
|
|
38
|
+
throw new InvalidRequestError("multiple client authentication methods");
|
|
39
|
+
}
|
|
40
|
+
if (usesBasic) {
|
|
41
|
+
const basicAuth = parseBasicAuth(authorization);
|
|
42
|
+
if (clientId !== null && clientId !== basicAuth.name) {
|
|
43
|
+
throw new InvalidRequestError("client_id does not match the Basic credentials");
|
|
44
|
+
}
|
|
24
45
|
return { clientId: basicAuth.name, clientSecret: basicAuth.pass };
|
|
25
46
|
}
|
|
26
|
-
const clientId = body.get("client_id");
|
|
27
47
|
if (typeof clientId === "string") {
|
|
28
48
|
const credentials = { clientId };
|
|
29
|
-
const clientSecret = body.get("client_secret");
|
|
30
49
|
if (typeof clientSecret === "string") {
|
|
31
50
|
credentials.clientSecret = clientSecret;
|
|
32
51
|
}
|
|
33
52
|
return credentials;
|
|
34
53
|
}
|
|
35
|
-
if (authorization)
|
|
36
|
-
throw
|
|
54
|
+
if (authorization) {
|
|
55
|
+
throw new InvalidClientError("unsupported authorization header");
|
|
56
|
+
}
|
|
37
57
|
throw new InvalidClientError("client authentication required");
|
|
38
58
|
}
|
|
39
59
|
/**
|
|
@@ -116,11 +116,13 @@ export declare abstract class AbstractGrant<Client extends ClientInterface, User
|
|
|
116
116
|
*/
|
|
117
117
|
acceptedScope(client: Client, user: User | undefined, scope: Scope | null | undefined, tokenService: TokenServiceInterface<Client, User, Scope>): Promise<Scope | null | undefined>;
|
|
118
118
|
/**
|
|
119
|
-
* Extracts client credentials from a request
|
|
120
|
-
* `client_id`/`client_secret` of the already-parsed `body`.
|
|
121
|
-
* grant-specific credential rules (as the authorization-code
|
|
122
|
-
* PKCE).
|
|
119
|
+
* Extracts client credentials from a request by the one method it uses: HTTP
|
|
120
|
+
* Basic, or the `client_id`/`client_secret` of the already-parsed `body`.
|
|
121
|
+
* Override to add grant-specific credential rules (as the authorization-code
|
|
122
|
+
* grant does for PKCE).
|
|
123
123
|
*
|
|
124
|
+
* @throws {InvalidRequestError} If the request uses more than one client
|
|
125
|
+
* authentication method.
|
|
124
126
|
* @throws {InvalidClientError} If the request presents no usable credentials.
|
|
125
127
|
*/
|
|
126
128
|
protected getClientCredentials(request: Request, body: FormData): ClientCredentials;
|
|
@@ -70,11 +70,13 @@ export class AbstractGrant {
|
|
|
70
70
|
return acceptedScope ?? scope;
|
|
71
71
|
}
|
|
72
72
|
/**
|
|
73
|
-
* Extracts client credentials from a request
|
|
74
|
-
* `client_id`/`client_secret` of the already-parsed `body`.
|
|
75
|
-
* grant-specific credential rules (as the authorization-code
|
|
76
|
-
* PKCE).
|
|
73
|
+
* Extracts client credentials from a request by the one method it uses: HTTP
|
|
74
|
+
* Basic, or the `client_id`/`client_secret` of the already-parsed `body`.
|
|
75
|
+
* Override to add grant-specific credential rules (as the authorization-code
|
|
76
|
+
* grant does for PKCE).
|
|
77
77
|
*
|
|
78
|
+
* @throws {InvalidRequestError} If the request uses more than one client
|
|
79
|
+
* authentication method.
|
|
78
80
|
* @throws {InvalidClientError} If the request presents no usable credentials.
|
|
79
81
|
*/
|
|
80
82
|
getClientCredentials(request, body) {
|
package/dist/utils/pkce.d.ts
CHANGED
|
@@ -113,6 +113,9 @@ export declare function validateCodeChallenge(challenge: string, method?: string
|
|
|
113
113
|
* own, callable properties of `methods` resolve here; every other name is
|
|
114
114
|
* `undefined`, including names inherited from a prototype.
|
|
115
115
|
*
|
|
116
|
+
* An omitted method means `S256`, deliberately not RFC 7636 §4.3's `plain`,
|
|
117
|
+
* following OAuth 2.1.
|
|
118
|
+
*
|
|
116
119
|
* @param methods The challenge methods the server allows
|
|
117
120
|
* @param method The client-supplied method name; defaults to `S256`
|
|
118
121
|
* @returns The transform for that method, or `undefined` when it is not allowed
|
package/dist/utils/pkce.js
CHANGED
|
@@ -127,6 +127,9 @@ export function validateCodeChallenge(challenge, method) {
|
|
|
127
127
|
* own, callable properties of `methods` resolve here; every other name is
|
|
128
128
|
* `undefined`, including names inherited from a prototype.
|
|
129
129
|
*
|
|
130
|
+
* An omitted method means `S256`, deliberately not RFC 7636 §4.3's `plain`,
|
|
131
|
+
* following OAuth 2.1.
|
|
132
|
+
*
|
|
130
133
|
* @param methods The challenge methods the server allows
|
|
131
134
|
* @param method The client-supplied method name; defaults to `S256`
|
|
132
135
|
* @returns The transform for that method, or `undefined` when it is not allowed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"authorization-server",
|