oauth4webapi 2.4.5 → 2.6.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 CHANGED
@@ -43,7 +43,7 @@ import * as oauth2 from 'oauth4webapi'
43
43
  **`example`** Deno import
44
44
 
45
45
  ```js
46
- import * as oauth2 from 'https://deno.land/x/oauth4webapi@v2.4.5/mod.ts'
46
+ import * as oauth2 from 'https://deno.land/x/oauth4webapi@v2.6.0/mod.ts'
47
47
  ```
48
48
 
49
49
  - Authorization Code Flow - OpenID Connect [source](examples/code.ts), or plain OAuth 2 [source](examples/oauth.ts)
@@ -53,7 +53,8 @@ import * as oauth2 from 'https://deno.land/x/oauth4webapi@v2.4.5/mod.ts'
53
53
  - Pushed Authorization Request (PAR) - [source](examples/par.ts) | [diff from code flow](examples/par.diff)
54
54
  - Client Credentials Grant - [source](examples/client_credentials.ts)
55
55
  - Device Authorization Grant - [source](examples/device_authorization_grant.ts)
56
- - FAPI 2.0 (Private Key JWT, PAR, DPoP) - [source](examples/fapi2.ts)
56
+ - FAPI 1.0 Advanced (Private Key JWT, MTLS, JAR) - [source](examples/fapi1-advanced.ts)
57
+ - FAPI 2.0 Security Profile (Private Key JWT, PAR, DPoP) - [source](examples/fapi2.ts)
57
58
  - FAPI 2.0 Message Signing (Private Key JWT, PAR, DPoP, JAR, JARM) - [source](examples/fapi2-message-signing.ts) | [diff](examples/fapi2-message-signing.diff)
58
59
 
59
60
  ## Supported Runtimes
@@ -74,6 +75,5 @@ The following features are currently out of scope:
74
75
 
75
76
  - CommonJS
76
77
  - Implicit, Hybrid, and Resource Owner Password Credentials Flows
77
- - Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
78
78
  - JSON Web Encryption (JWE)
79
79
  - Automatic polyfills of any kind
package/build/index.d.ts CHANGED
@@ -129,6 +129,166 @@ export type ClientAuthenticationMethod = 'client_secret_basic' | 'client_secret_
129
129
  export type JWSAlgorithm = 'PS256' | 'ES256' | 'RS256' | 'EdDSA' | 'ES384' | 'PS384' | 'RS384' | 'ES512' | 'PS512' | 'RS512';
130
130
  export declare const clockSkew: unique symbol;
131
131
  export declare const clockTolerance: unique symbol;
132
+ /**
133
+ * This is an experimental feature, it is not subject to semantic versioning rules. Non-backward
134
+ * compatible changes or removal may occur in any future release.
135
+ *
136
+ * When configured on an interface that extends {@link HttpRequestOptions}, that's every `options`
137
+ * parameter for functions that trigger HTTP Requests, this replaces the use of global fetch. As a
138
+ * fetch replacement the arguments and expected return are the same as fetch.
139
+ *
140
+ * In theory any module that claims to be compatible with the Fetch API can be used but your mileage
141
+ * may vary. No workarounds to allow use of non-conform {@link Response}s will be considered.
142
+ *
143
+ * If you only need to update the {@link Request} properties you do not need to use a Fetch API
144
+ * module, just change what you need and pass it to globalThis.fetch just like this module would
145
+ * normally do.
146
+ *
147
+ * Its intended use cases are:
148
+ *
149
+ * - {@link Request}/{@link Response} tracing and logging
150
+ * - Custom caching strategies for responses of Authorization Server Metadata and JSON Web Key Set
151
+ * (JWKS) endpoints
152
+ * - Changing the {@link Request} properties like headers, body, credentials, mode before it is passed
153
+ * to fetch
154
+ *
155
+ * Known caveats:
156
+ *
157
+ * - Expect Type-related issues when passing the inputs through to fetch-like modules, they hardly
158
+ * ever get their typings inline with actual fetch, you should `@ts-expect-error` them.
159
+ * - Returning self-constructed {@link Response} instances prohibits AS-signalled DPoP Nonce caching.
160
+ *
161
+ * @example
162
+ *
163
+ * Using [sindresorhus/ky](https://github.com/sindresorhus/ky) hooks feature for logging outgoing
164
+ * requests and their responses.
165
+ *
166
+ * ```js
167
+ * import ky from 'ky'
168
+ * import * as oauth from 'oauth4webapi'
169
+ *
170
+ * // example use
171
+ * await oauth.discoveryRequest(new URL('https://as.example.com'), {
172
+ * [oauth.experimental_customFetch]: (...args) =>
173
+ * ky(args[0], {
174
+ * ...args[1],
175
+ * hooks: {
176
+ * beforeRequest: [
177
+ * (request) => {
178
+ * logRequest(request)
179
+ * },
180
+ * ],
181
+ * beforeRetry: [
182
+ * ({ request, error, retryCount }) => {
183
+ * logRetry(request, error, retryCount)
184
+ * },
185
+ * ],
186
+ * afterResponse: [
187
+ * (request, _, response) => {
188
+ * logResponse(request, response)
189
+ * },
190
+ * ],
191
+ * },
192
+ * }),
193
+ * })
194
+ * ```
195
+ *
196
+ * @example
197
+ *
198
+ * Using [nodejs/undici](https://github.com/nodejs/undici) for mocking.
199
+ *
200
+ * ```js
201
+ * import * as undici from 'undici'
202
+ * import * as oauth from 'oauth4webapi'
203
+ *
204
+ * const mockAgent = new undici.MockAgent()
205
+ * mockAgent.disableNetConnect()
206
+ * undici.setGlobalDispatcher(mockAgent)
207
+ *
208
+ * // continue as per undici documentation
209
+ * // https://github.com/nodejs/undici/blob/v6.2.1/docs/api/MockAgent.md#example---basic-mocked-request
210
+ *
211
+ * // example use
212
+ * await oauth.discoveryRequest(new URL('https://as.example.com'), {
213
+ * [oauth.experimental_customFetch]: undici.fetch,
214
+ * })
215
+ * ```
216
+ *
217
+ * @group Experimental
218
+ */
219
+ export declare const experimental_customFetch: unique symbol;
220
+ /** @ignore */
221
+ export declare const experimentalCustomFetch: symbol;
222
+ /**
223
+ * This is an experimental feature, it is not subject to semantic versioning rules. Non-backward
224
+ * compatible changes or removal may occur in any future release.
225
+ *
226
+ * When combined with {@link experimental_customFetch} (to use a Fetch API implementation that
227
+ * supports client certificates) this can be used to target FAPI 2.0 profiles that utilize
228
+ * Mutual-TLS for either client authentication or sender constraining. FAPI 1.0 Advanced profiles
229
+ * that use PAR and JARM can also be targetted.
230
+ *
231
+ * When configured on an interface that extends {@link ExperimentalUseMTLSAliasOptions} this makes
232
+ * the client prioritize an endpoint URL present in
233
+ * {@link AuthorizationServer.mtls_endpoint_aliases `as.mtls_endpoint_aliases`}.
234
+ *
235
+ * @example
236
+ *
237
+ * (Node.js) Using [nodejs/undici](https://github.com/nodejs/undici) for Mutual-TLS Client
238
+ * Authentication and Certificate-Bound Access Tokens support.
239
+ *
240
+ * ```js
241
+ * import * as undici from 'undici'
242
+ * import * as oauth from 'oauth4webapi'
243
+ *
244
+ * const response = await oauth.pushedAuthorizationRequest(as, client, params, {
245
+ * [oauth.experimental_useMtlsAlias]: true,
246
+ * [oauth.experimental_customFetch]: (...args) => {
247
+ * return undici.fetch(args[0], {
248
+ * ...args[1],
249
+ * dispatcher: new undici.Agent({
250
+ * connect: {
251
+ * key: clientKey,
252
+ * cert: clientCertificate,
253
+ * },
254
+ * }),
255
+ * })
256
+ * },
257
+ * })
258
+ * ```
259
+ *
260
+ * @example
261
+ *
262
+ * (Deno) Using Deno.createHttpClient API for Mutual-TLS Client Authentication and Certificate-Bound
263
+ * Access Tokens support. This is currently (Jan 2023) locked behind the --unstable command line
264
+ * flag.
265
+ *
266
+ * ```js
267
+ * import * as oauth from 'oauth4webapi'
268
+ *
269
+ * const agent = Deno.createHttpClient({
270
+ * certChain: clientCertificate,
271
+ * privateKey: clientKey,
272
+ * })
273
+ *
274
+ * const response = await oauth.pushedAuthorizationRequest(as, client, params, {
275
+ * [oauth.experimental_useMtlsAlias]: true,
276
+ * [oauth.experimental_customFetch]: (...args) => {
277
+ * return fetch(args[0], {
278
+ * ...args[1],
279
+ * client: agent,
280
+ * })
281
+ * },
282
+ * })
283
+ * ```
284
+ *
285
+ * @group Experimental
286
+ *
287
+ * @see [RFC 8705 - OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens](https://www.rfc-editor.org/rfc/rfc8705.html)
288
+ */
289
+ export declare const experimental_useMtlsAlias: unique symbol;
290
+ /** @ignore */
291
+ export declare const experimentalUseMtlsAlias: symbol;
132
292
  /**
133
293
  * Authorization Server Metadata
134
294
  *
@@ -522,11 +682,17 @@ export interface HttpRequestOptions {
522
682
  * ```
523
683
  */
524
684
  signal?: (() => AbortSignal) | AbortSignal;
685
+ /** Headers to additionally send with the HTTP Request(s) triggered by this function's invocation. */
686
+ headers?: [string, string][] | Record<string, string> | Headers;
525
687
  /**
526
- * A Headers instance to additionally send with the HTTP Request(s) triggered by this function's
527
- * invocation.
688
+ * This is an experimental feature, it is not subject to semantic versioning rules. Non-backward
689
+ * compatible changes or removal may occur in any future release.
690
+ *
691
+ * See {@link experimental_customFetch} for its documentation.
692
+ *
693
+ * @group Experimental
528
694
  */
529
- headers?: Headers;
695
+ [experimental_customFetch]?: typeof fetch;
530
696
  }
531
697
  export interface DiscoveryRequestOptions extends HttpRequestOptions {
532
698
  /** The issuer transformation algorithm to use. */
@@ -625,7 +791,18 @@ export interface DPoPRequestOptions {
625
791
  /** DPoP-related options. */
626
792
  DPoP?: DPoPOptions;
627
793
  }
628
- export interface AuthenticatedRequestOptions {
794
+ export interface ExperimentalUseMTLSAliasOptions {
795
+ /**
796
+ * This is an experimental feature, it is not subject to semantic versioning rules. Non-backward
797
+ * compatible changes or removal may occur in any future release.
798
+ *
799
+ * See {@link experimental_useMtlsAlias} for its documentation.
800
+ *
801
+ * @group Experimental
802
+ */
803
+ [experimental_useMtlsAlias]?: boolean;
804
+ }
805
+ export interface AuthenticatedRequestOptions extends ExperimentalUseMTLSAliasOptions {
629
806
  /**
630
807
  * Private key to use for `private_key_jwt`
631
808
  * {@link ClientAuthenticationMethod client authentication}. Its algorithm must be compatible with
@@ -767,7 +944,7 @@ export interface ProtectedResourceRequestOptions extends Omit<HttpRequestOptions
767
944
  * @see [RFC 9449 - OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP)](https://www.rfc-editor.org/rfc/rfc9449.html#name-protected-resource-access)
768
945
  */
769
946
  export declare function protectedResourceRequest(accessToken: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | string, url: URL, headers: Headers, body?: ReadableStream | Blob | ArrayBufferView | ArrayBuffer | FormData | URLSearchParams | string | null, options?: ProtectedResourceRequestOptions): Promise<Response>;
770
- export interface UserInfoRequestOptions extends HttpRequestOptions, DPoPRequestOptions {
947
+ export interface UserInfoRequestOptions extends HttpRequestOptions, DPoPRequestOptions, ExperimentalUseMTLSAliasOptions {
771
948
  }
772
949
  /**
773
950
  * Performs a UserInfo Request at the
@@ -1184,6 +1361,32 @@ export declare function processIntrospectionResponse(as: AuthorizationServer, cl
1184
1361
  * @see [JWT Secured Authorization Response Mode for OAuth 2.0 (JARM)](https://openid.net/specs/openid-financial-api-jarm.html)
1185
1362
  */
1186
1363
  export declare function validateJwtAuthResponse(as: AuthorizationServer, client: Client, parameters: URLSearchParams | URL, expectedState?: string | typeof expectNoState | typeof skipStateCheck, options?: HttpRequestOptions): Promise<URLSearchParams | OAuth2Error>;
1364
+ /**
1365
+ * This is an experimental feature, it is not subject to semantic versioning rules. Non-backward
1366
+ * compatible changes or removal may occur in any future release.
1367
+ *
1368
+ * Same as {@link validateAuthResponse} but for FAPI 1.0 Advanced Detached Signature authorization
1369
+ * responses.
1370
+ *
1371
+ * @param as Authorization Server Metadata.
1372
+ * @param client Client Metadata.
1373
+ * @param parameters Authorization Response.
1374
+ * @param expectedNonce Expected ID Token `nonce` claim value.
1375
+ * @param expectedState Expected `state` parameter value. Default is {@link expectNoState}.
1376
+ * @param maxAge ID Token {@link IDToken.auth_time `auth_time`} claim value will be checked to be
1377
+ * present and conform to the `maxAge` value. Use of this option is required if you sent a
1378
+ * `max_age` parameter in an authorization request. Default is
1379
+ * {@link Client.default_max_age `client.default_max_age`} and falls back to
1380
+ * {@link skipAuthTimeCheck}.
1381
+ *
1382
+ * @returns Validated Authorization Response parameters or Authorization Error Response.
1383
+ *
1384
+ * @group FAPI 1.0 Advanced
1385
+ * @group Experimental
1386
+ *
1387
+ * @see [Financial-grade API Security Profile 1.0 - Part 2: Advanced](https://openid.net/specs/openid-financial-api-part-2-1_0.html#id-token-as-detached-signature)
1388
+ */
1389
+ export declare function experimental_validateDetachedSignatureResponse(as: AuthorizationServer, client: Client, parameters: URLSearchParams, expectedNonce: string, expectedState?: string | typeof expectNoState, maxAge?: number | typeof skipAuthTimeCheck, options?: HttpRequestOptions): Promise<URLSearchParams | OAuth2Error>;
1187
1390
  /**
1188
1391
  * DANGER ZONE
1189
1392
  *
package/build/index.js CHANGED
@@ -1,11 +1,27 @@
1
1
  let USER_AGENT;
2
2
  if (typeof navigator === 'undefined' || !navigator.userAgent?.startsWith?.('Mozilla/5.0 ')) {
3
3
  const NAME = 'oauth4webapi';
4
- const VERSION = 'v2.4.5';
4
+ const VERSION = 'v2.6.0';
5
5
  USER_AGENT = `${NAME}/${VERSION}`;
6
6
  }
7
+ function looseInstanceOf(input, expected) {
8
+ if (input == null) {
9
+ return false;
10
+ }
11
+ try {
12
+ return (input instanceof expected ||
13
+ Object.getPrototypeOf(input)[Symbol.toStringTag] === expected.prototype[Symbol.toStringTag]);
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
7
19
  export const clockSkew = Symbol();
8
20
  export const clockTolerance = Symbol();
21
+ export const experimental_customFetch = Symbol();
22
+ export const experimentalCustomFetch = experimental_customFetch;
23
+ export const experimental_useMtlsAlias = Symbol();
24
+ export const experimentalUseMtlsAlias = experimental_useMtlsAlias;
9
25
  const encoder = new TextEncoder();
10
26
  const decoder = new TextDecoder();
11
27
  function buf(input) {
@@ -148,8 +164,8 @@ function isJsonObject(input) {
148
164
  return true;
149
165
  }
150
166
  function prepareHeaders(input) {
151
- if (input !== undefined && !(input instanceof Headers)) {
152
- throw new TypeError('"options.headers" must be an instance of Headers');
167
+ if (looseInstanceOf(input, Headers)) {
168
+ input = Object.fromEntries(input.entries());
153
169
  }
154
170
  const headers = new Headers(input);
155
171
  if (USER_AGENT && !headers.has('user-agent')) {
@@ -198,13 +214,12 @@ export async function discoveryRequest(issuerIdentifier, options) {
198
214
  }
199
215
  const headers = prepareHeaders(options?.headers);
200
216
  headers.set('accept', 'application/json');
201
- const request = new Request(url.href, {
202
- headers,
217
+ return (options?.[experimental_customFetch] || fetch)(url.href, {
218
+ headers: Object.fromEntries(headers.entries()),
203
219
  method: 'GET',
204
220
  redirect: 'manual',
205
221
  signal: options?.signal ? signal(options.signal) : null,
206
- });
207
- return fetch(request).then(processDpopNonce);
222
+ }).then(processDpopNonce);
208
223
  }
209
224
  function validateString(input) {
210
225
  return typeof input === 'string' && input.length !== 0;
@@ -213,7 +228,7 @@ export async function processDiscoveryResponse(expectedIssuerIdentifier, respons
213
228
  if (!(expectedIssuerIdentifier instanceof URL)) {
214
229
  throw new TypeError('"expectedIssuer" must be an instance of URL');
215
230
  }
216
- if (!(response instanceof Response)) {
231
+ if (!looseInstanceOf(response, Response)) {
217
232
  throw new TypeError('"response" must be an instance of Response');
218
233
  }
219
234
  if (response.status !== 200) {
@@ -427,9 +442,11 @@ async function clientAuthentication(as, client, body, headers, clientPrivateKey)
427
442
  body.set('client_assertion', await privateKeyJwt(as, client, key, kid));
428
443
  break;
429
444
  }
445
+ case 'tls_client_auth':
446
+ case 'self_signed_tls_client_auth':
430
447
  case 'none': {
431
- assertNoClientSecret('none', client.client_secret);
432
- assertNoClientPrivateKey('none', clientPrivateKey);
448
+ assertNoClientSecret(client.token_endpoint_auth_method, client.client_secret);
449
+ assertNoClientPrivateKey(client.token_endpoint_auth_method, clientPrivateKey);
433
450
  body.set('client_id', client.client_id);
434
451
  break;
435
452
  }
@@ -531,13 +548,29 @@ async function publicJwk(key) {
531
548
  jwkCache.set(key, jwk);
532
549
  return jwk;
533
550
  }
551
+ function validateEndpoint(value, endpoint, options) {
552
+ if (typeof value !== 'string') {
553
+ if (options?.[experimental_useMtlsAlias]) {
554
+ throw new TypeError(`"as.mtls_endpoint_aliases.${endpoint}" must be a string`);
555
+ }
556
+ else {
557
+ throw new TypeError(`"as.${endpoint}" must be a string`);
558
+ }
559
+ }
560
+ return new URL(value);
561
+ }
562
+ function resolveEndpoint(as, endpoint, options) {
563
+ if (options?.[experimental_useMtlsAlias] &&
564
+ as.mtls_endpoint_aliases &&
565
+ endpoint in as.mtls_endpoint_aliases) {
566
+ return validateEndpoint(as.mtls_endpoint_aliases[endpoint], endpoint, options);
567
+ }
568
+ return validateEndpoint(as[endpoint], endpoint);
569
+ }
534
570
  export async function pushedAuthorizationRequest(as, client, parameters, options) {
535
571
  assertAs(as);
536
572
  assertClient(client);
537
- if (typeof as.pushed_authorization_request_endpoint !== 'string') {
538
- throw new TypeError('"as.pushed_authorization_request_endpoint" must be a string');
539
- }
540
- const url = new URL(as.pushed_authorization_request_endpoint);
573
+ const url = resolveEndpoint(as, 'pushed_authorization_request_endpoint', options);
541
574
  const body = new URLSearchParams(parameters);
542
575
  body.set('client_id', client.client_id);
543
576
  const headers = prepareHeaders(options?.headers);
@@ -585,7 +618,7 @@ function wwwAuth(scheme, params) {
585
618
  };
586
619
  }
587
620
  export function parseWwwAuthenticateChallenges(response) {
588
- if (!(response instanceof Response)) {
621
+ if (!looseInstanceOf(response, Response)) {
589
622
  throw new TypeError('"response" must be an instance of Response');
590
623
  }
591
624
  if (!response.headers.has('www-authenticate')) {
@@ -615,7 +648,7 @@ export function parseWwwAuthenticateChallenges(response) {
615
648
  export async function processPushedAuthorizationResponse(as, client, response) {
616
649
  assertAs(as);
617
650
  assertClient(client);
618
- if (!(response instanceof Response)) {
651
+ if (!looseInstanceOf(response, Response)) {
619
652
  throw new TypeError('"response" must be an instance of Response');
620
653
  }
621
654
  if (response.status !== 201) {
@@ -659,22 +692,18 @@ export async function protectedResourceRequest(accessToken, method, url, headers
659
692
  await dpopProofJwt(headers, options.DPoP, url, 'GET', getClockSkew({ [clockSkew]: options?.[clockSkew] }), accessToken);
660
693
  headers.set('authorization', `DPoP ${accessToken}`);
661
694
  }
662
- const request = new Request(url.href, {
695
+ return (options?.[experimental_customFetch] || fetch)(url.href, {
663
696
  body,
664
- headers,
697
+ headers: Object.fromEntries(headers.entries()),
665
698
  method,
666
699
  redirect: 'manual',
667
700
  signal: options?.signal ? signal(options.signal) : null,
668
- });
669
- return fetch(request).then(processDpopNonce);
701
+ }).then(processDpopNonce);
670
702
  }
671
703
  export async function userInfoRequest(as, client, accessToken, options) {
672
704
  assertAs(as);
673
705
  assertClient(client);
674
- if (typeof as.userinfo_endpoint !== 'string') {
675
- throw new TypeError('"as.userinfo_endpoint" must be a string');
676
- }
677
- const url = new URL(as.userinfo_endpoint);
706
+ const url = resolveEndpoint(as, 'userinfo_endpoint', options);
678
707
  const headers = prepareHeaders(options?.headers);
679
708
  if (client.userinfo_signed_response_alg) {
680
709
  headers.set('accept', 'application/jwt');
@@ -778,7 +807,7 @@ function getContentType(response) {
778
807
  export async function processUserInfoResponse(as, client, expectedSubject, response) {
779
808
  assertAs(as);
780
809
  assertClient(client);
781
- if (!(response instanceof Response)) {
810
+ if (!looseInstanceOf(response, Response)) {
782
811
  throw new TypeError('"response" must be an instance of Response');
783
812
  }
784
813
  if (response.status !== 200) {
@@ -826,20 +855,16 @@ export async function processUserInfoResponse(as, client, expectedSubject, respo
826
855
  async function authenticatedRequest(as, client, method, url, body, headers, options) {
827
856
  await clientAuthentication(as, client, body, headers, options?.clientPrivateKey);
828
857
  headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
829
- const request = new Request(url.href, {
858
+ return (options?.[experimental_customFetch] || fetch)(url.href, {
830
859
  body,
831
- headers,
860
+ headers: Object.fromEntries(headers.entries()),
832
861
  method,
833
862
  redirect: 'manual',
834
863
  signal: options?.signal ? signal(options.signal) : null,
835
- });
836
- return fetch(request).then(processDpopNonce);
864
+ }).then(processDpopNonce);
837
865
  }
838
866
  async function tokenEndpointRequest(as, client, grantType, parameters, options) {
839
- if (typeof as.token_endpoint !== 'string') {
840
- throw new TypeError('"as.token_endpoint" must be a string');
841
- }
842
- const url = new URL(as.token_endpoint);
867
+ const url = resolveEndpoint(as, 'token_endpoint', options);
843
868
  parameters.set('grant_type', grantType);
844
869
  const headers = prepareHeaders(options?.headers);
845
870
  headers.set('accept', 'application/json');
@@ -872,7 +897,7 @@ export function getValidatedIdTokenClaims(ref) {
872
897
  async function processGenericAccessTokenResponse(as, client, response, ignoreIdToken = false, ignoreRefreshToken = false) {
873
898
  assertAs(as);
874
899
  assertClient(client);
875
- if (!(response instanceof Response)) {
900
+ if (!looseInstanceOf(response, Response)) {
876
901
  throw new TypeError('"response" must be an instance of Response');
877
902
  }
878
903
  if (response.status !== 200) {
@@ -994,17 +1019,20 @@ export async function authorizationCodeGrantRequest(as, client, callbackParamete
994
1019
  parameters.set('code', code);
995
1020
  return tokenEndpointRequest(as, client, 'authorization_code', parameters, options);
996
1021
  }
997
- const claimNames = {
1022
+ const idTokenClaimNames = {
998
1023
  aud: 'audience',
999
1024
  exp: 'expiration time',
1000
1025
  iat: 'issued at',
1001
1026
  iss: 'issuer',
1002
1027
  sub: 'subject',
1028
+ nonce: 'nonce',
1029
+ s_hash: 'state hash',
1030
+ c_hash: 'code hash',
1003
1031
  };
1004
1032
  function validatePresence(required, result) {
1005
1033
  for (const claim of required) {
1006
1034
  if (result.claims[claim] === undefined) {
1007
- throw new OPE(`JWT "${claim}" (${claimNames[claim]}) claim missing`);
1035
+ throw new OPE(`JWT "${claim}" (${idTokenClaimNames[claim]}) claim missing`);
1008
1036
  }
1009
1037
  }
1010
1038
  return result;
@@ -1092,10 +1120,7 @@ export async function revocationRequest(as, client, token, options) {
1092
1120
  if (!validateString(token)) {
1093
1121
  throw new TypeError('"token" must be a non-empty string');
1094
1122
  }
1095
- if (typeof as.revocation_endpoint !== 'string') {
1096
- throw new TypeError('"as.revocation_endpoint" must be a string');
1097
- }
1098
- const url = new URL(as.revocation_endpoint);
1123
+ const url = resolveEndpoint(as, 'revocation_endpoint', options);
1099
1124
  const body = new URLSearchParams(options?.additionalParameters);
1100
1125
  body.set('token', token);
1101
1126
  const headers = prepareHeaders(options?.headers);
@@ -1103,7 +1128,7 @@ export async function revocationRequest(as, client, token, options) {
1103
1128
  return authenticatedRequest(as, client, 'POST', url, body, headers, options);
1104
1129
  }
1105
1130
  export async function processRevocationResponse(response) {
1106
- if (!(response instanceof Response)) {
1131
+ if (!looseInstanceOf(response, Response)) {
1107
1132
  throw new TypeError('"response" must be an instance of Response');
1108
1133
  }
1109
1134
  if (response.status !== 200) {
@@ -1126,10 +1151,7 @@ export async function introspectionRequest(as, client, token, options) {
1126
1151
  if (!validateString(token)) {
1127
1152
  throw new TypeError('"token" must be a non-empty string');
1128
1153
  }
1129
- if (typeof as.introspection_endpoint !== 'string') {
1130
- throw new TypeError('"as.introspection_endpoint" must be a string');
1131
- }
1132
- const url = new URL(as.introspection_endpoint);
1154
+ const url = resolveEndpoint(as, 'introspection_endpoint', options);
1133
1155
  const body = new URLSearchParams(options?.additionalParameters);
1134
1156
  body.set('token', token);
1135
1157
  const headers = prepareHeaders(options?.headers);
@@ -1144,7 +1166,7 @@ export async function introspectionRequest(as, client, token, options) {
1144
1166
  export async function processIntrospectionResponse(as, client, response) {
1145
1167
  assertAs(as);
1146
1168
  assertClient(client);
1147
- if (!(response instanceof Response)) {
1169
+ if (!looseInstanceOf(response, Response)) {
1148
1170
  throw new TypeError('"response" must be an instance of Response');
1149
1171
  }
1150
1172
  if (response.status !== 200) {
@@ -1186,23 +1208,19 @@ export async function processIntrospectionResponse(as, client, response) {
1186
1208
  }
1187
1209
  async function jwksRequest(as, options) {
1188
1210
  assertAs(as);
1189
- if (typeof as.jwks_uri !== 'string') {
1190
- throw new TypeError('"as.jwks_uri" must be a string');
1191
- }
1192
- const url = new URL(as.jwks_uri);
1211
+ const url = resolveEndpoint(as, 'jwks_uri');
1193
1212
  const headers = prepareHeaders(options?.headers);
1194
1213
  headers.set('accept', 'application/json');
1195
1214
  headers.append('accept', 'application/jwk-set+json');
1196
- const request = new Request(url.href, {
1197
- headers,
1215
+ return (options?.[experimental_customFetch] || fetch)(url.href, {
1216
+ headers: Object.fromEntries(headers.entries()),
1198
1217
  method: 'GET',
1199
1218
  redirect: 'manual',
1200
1219
  signal: options?.signal ? signal(options.signal) : null,
1201
- });
1202
- return fetch(request).then(processDpopNonce);
1220
+ }).then(processDpopNonce);
1203
1221
  }
1204
1222
  async function processJwksResponse(response) {
1205
- if (!(response instanceof Response)) {
1223
+ if (!looseInstanceOf(response, Response)) {
1206
1224
  throw new TypeError('"response" must be an instance of Response');
1207
1225
  }
1208
1226
  if (response.status !== 200) {
@@ -1329,8 +1347,9 @@ async function validateJwt(jws, checkAlg, getKey, clockSkew, clockTolerance) {
1329
1347
  throw new OPE('unexpected JWT "crit" header parameter');
1330
1348
  }
1331
1349
  const signature = b64u(encodedSignature);
1350
+ let key;
1332
1351
  if (getKey !== noSignatureCheck) {
1333
- const key = await getKey(header);
1352
+ key = await getKey(header);
1334
1353
  const input = `${protectedHeader}.${payload}`;
1335
1354
  const verified = await crypto.subtle.verify(keyToSubtle(key), key, signature, buf(input));
1336
1355
  if (!verified) {
@@ -1379,7 +1398,7 @@ async function validateJwt(jws, checkAlg, getKey, clockSkew, clockTolerance) {
1379
1398
  throw new OPE('unexpected JWT "aud" (audience) claim type');
1380
1399
  }
1381
1400
  }
1382
- return { header, claims, signature };
1401
+ return { header, claims, signature, key };
1383
1402
  }
1384
1403
  export async function validateJwtAuthResponse(as, client, parameters, expectedState, options) {
1385
1404
  assertAs(as);
@@ -1409,6 +1428,137 @@ export async function validateJwtAuthResponse(as, client, parameters, expectedSt
1409
1428
  }
1410
1429
  return validateAuthResponse(as, client, result, expectedState);
1411
1430
  }
1431
+ async function idTokenHash(alg, data, key) {
1432
+ let algorithm;
1433
+ switch (alg) {
1434
+ case 'RS256':
1435
+ case 'PS256':
1436
+ case 'ES256':
1437
+ algorithm = 'SHA-256';
1438
+ break;
1439
+ case 'RS384':
1440
+ case 'PS384':
1441
+ case 'ES384':
1442
+ algorithm = 'SHA-384';
1443
+ break;
1444
+ case 'RS512':
1445
+ case 'PS512':
1446
+ case 'ES512':
1447
+ algorithm = 'SHA-512';
1448
+ break;
1449
+ case 'EdDSA':
1450
+ if (key.algorithm.name === 'Ed25519') {
1451
+ algorithm = 'SHA-512';
1452
+ break;
1453
+ }
1454
+ throw new UnsupportedOperationError();
1455
+ default:
1456
+ throw new UnsupportedOperationError();
1457
+ }
1458
+ const digest = await crypto.subtle.digest(algorithm, buf(data));
1459
+ return b64u(digest.slice(0, digest.byteLength / 2));
1460
+ }
1461
+ async function idTokenHashMatches(data, actual, alg, key) {
1462
+ const expected = await idTokenHash(alg, data, key);
1463
+ return actual === expected;
1464
+ }
1465
+ export async function experimental_validateDetachedSignatureResponse(as, client, parameters, expectedNonce, expectedState, maxAge, options) {
1466
+ assertAs(as);
1467
+ assertClient(client);
1468
+ if (!(parameters instanceof URLSearchParams)) {
1469
+ throw new TypeError('"parameters" must be an instance of URLSearchParams');
1470
+ }
1471
+ parameters = new URLSearchParams(parameters);
1472
+ const id_token = getURLSearchParameter(parameters, 'id_token');
1473
+ parameters.delete('id_token');
1474
+ switch (expectedState) {
1475
+ case undefined:
1476
+ case expectNoState:
1477
+ break;
1478
+ default:
1479
+ if (!validateString(expectedState)) {
1480
+ throw new TypeError('"expectedState" must be a non-empty string');
1481
+ }
1482
+ }
1483
+ const result = validateAuthResponse({
1484
+ ...as,
1485
+ authorization_response_iss_parameter_supported: false,
1486
+ }, client, parameters, expectedState);
1487
+ if (isOAuth2Error(result)) {
1488
+ return result;
1489
+ }
1490
+ if (!id_token) {
1491
+ throw new OPE('"parameters" does not contain an ID Token');
1492
+ }
1493
+ const code = getURLSearchParameter(parameters, 'code');
1494
+ if (!code) {
1495
+ throw new OPE('"parameters" does not contain an Authorization Code');
1496
+ }
1497
+ if (typeof as.jwks_uri !== 'string') {
1498
+ throw new TypeError('"as.jwks_uri" must be a string');
1499
+ }
1500
+ const requiredClaims = [
1501
+ 'aud',
1502
+ 'exp',
1503
+ 'iat',
1504
+ 'iss',
1505
+ 'sub',
1506
+ 'nonce',
1507
+ 'c_hash',
1508
+ ];
1509
+ if (typeof expectedState === 'string') {
1510
+ requiredClaims.push('s_hash');
1511
+ }
1512
+ const { claims, header, key } = await validateJwt(id_token, checkSigningAlgorithm.bind(undefined, client.id_token_signed_response_alg, as.id_token_signing_alg_values_supported), getPublicSigKeyFromIssuerJwksUri.bind(undefined, as, options), getClockSkew(client), getClockTolerance(client))
1513
+ .then(validatePresence.bind(undefined, requiredClaims))
1514
+ .then(validateIssuer.bind(undefined, as.issuer))
1515
+ .then(validateAudience.bind(undefined, client.client_id));
1516
+ const clockSkew = getClockSkew(client);
1517
+ const now = epochTime() + clockSkew;
1518
+ if (claims.iat < now - 3600) {
1519
+ throw new OPE('unexpected JWT "iat" (issued at) claim value, it is too far in the past');
1520
+ }
1521
+ if (typeof claims.c_hash !== 'string' ||
1522
+ (await idTokenHashMatches(code, claims.c_hash, header.alg, key)) !== true) {
1523
+ throw new OPE('invalid ID Token "c_hash" (code hash) claim value');
1524
+ }
1525
+ if (claims.s_hash !== undefined && typeof expectedState !== 'string') {
1526
+ throw new OPE('could not verify ID Token "s_hash" (state hash) claim value');
1527
+ }
1528
+ if (typeof expectedState === 'string' &&
1529
+ (typeof claims.s_hash !== 'string' ||
1530
+ (await idTokenHashMatches(expectedState, claims.s_hash, header.alg, key)) !== true)) {
1531
+ throw new OPE('invalid ID Token "s_hash" (state hash) claim value');
1532
+ }
1533
+ if (client.require_auth_time !== undefined && typeof claims.auth_time !== 'number') {
1534
+ throw new OPE('unexpected ID Token "auth_time" (authentication time) claim value');
1535
+ }
1536
+ maxAge ?? (maxAge = client.default_max_age ?? skipAuthTimeCheck);
1537
+ if ((client.require_auth_time || maxAge !== skipAuthTimeCheck) &&
1538
+ claims.auth_time === undefined) {
1539
+ throw new OPE('ID Token "auth_time" (authentication time) claim missing');
1540
+ }
1541
+ if (maxAge !== skipAuthTimeCheck) {
1542
+ if (typeof maxAge !== 'number' || maxAge < 0) {
1543
+ throw new TypeError('"options.max_age" must be a non-negative number');
1544
+ }
1545
+ const now = epochTime() + getClockSkew(client);
1546
+ const tolerance = getClockTolerance(client);
1547
+ if (claims.auth_time + maxAge < now - tolerance) {
1548
+ throw new OPE('too much time has elapsed since the last End-User authentication');
1549
+ }
1550
+ }
1551
+ if (!validateString(expectedNonce)) {
1552
+ throw new TypeError('"expectedNonce" must be a non-empty string');
1553
+ }
1554
+ if (claims.nonce !== expectedNonce) {
1555
+ throw new OPE('unexpected ID Token "nonce" claim value');
1556
+ }
1557
+ if (Array.isArray(claims.aud) && claims.aud.length !== 1 && claims.azp !== client.client_id) {
1558
+ throw new OPE('unexpected ID Token "azp" (authorized party) claim value');
1559
+ }
1560
+ return result;
1561
+ }
1412
1562
  function checkSigningAlgorithm(client, issuer, header) {
1413
1563
  if (client !== undefined) {
1414
1564
  if (header.alg !== client) {
@@ -1525,10 +1675,7 @@ async function importJwk(alg, jwk) {
1525
1675
  export async function deviceAuthorizationRequest(as, client, parameters, options) {
1526
1676
  assertAs(as);
1527
1677
  assertClient(client);
1528
- if (typeof as.device_authorization_endpoint !== 'string') {
1529
- throw new TypeError('"as.device_authorization_endpoint" must be a string');
1530
- }
1531
- const url = new URL(as.device_authorization_endpoint);
1678
+ const url = resolveEndpoint(as, 'device_authorization_endpoint', options);
1532
1679
  const body = new URLSearchParams(parameters);
1533
1680
  body.set('client_id', client.client_id);
1534
1681
  const headers = prepareHeaders(options?.headers);
@@ -1538,7 +1685,7 @@ export async function deviceAuthorizationRequest(as, client, parameters, options
1538
1685
  export async function processDeviceAuthorizationResponse(as, client, response) {
1539
1686
  assertAs(as);
1540
1687
  assertClient(client);
1541
- if (!(response instanceof Response)) {
1688
+ if (!looseInstanceOf(response, Response)) {
1542
1689
  throw new TypeError('"response" must be an instance of Response');
1543
1690
  }
1544
1691
  if (response.status !== 200) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oauth4webapi",
3
- "version": "2.4.5",
3
+ "version": "2.6.0",
4
4
  "description": "OAuth 2 / OpenID Connect for JavaScript Runtimes",
5
5
  "keywords": [
6
6
  "auth",
@@ -68,6 +68,7 @@
68
68
  "@types/node": "^20.10.8",
69
69
  "@types/oidc-provider": "^8.4.3",
70
70
  "@types/qunit": "^2.19.9",
71
+ "archiver": "^6.0.1",
71
72
  "ava": "^5.3.1",
72
73
  "chrome-launcher": "^1.1.0",
73
74
  "edge-runtime": "^2.5.7",
@@ -80,6 +81,7 @@
80
81
  "puppeteer-core": "^21.7.0",
81
82
  "qunit": "^2.20.0",
82
83
  "raw-body": "^2.5.2",
84
+ "selfsigned": "^2.4.1",
83
85
  "timekeeper": "^2.3.1",
84
86
  "tsx": "^4.7.0",
85
87
  "typedoc": "^0.25.7",