oauth4webapi 2.5.0 → 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.5.0/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.5.0/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
package/build/index.d.ts CHANGED
@@ -169,7 +169,7 @@ export declare const clockTolerance: unique symbol;
169
169
  *
170
170
  * // example use
171
171
  * await oauth.discoveryRequest(new URL('https://as.example.com'), {
172
- * [oauth.experimentalCustomFetch]: (...args) =>
172
+ * [oauth.experimental_customFetch]: (...args) =>
173
173
  * ky(args[0], {
174
174
  * ...args[1],
175
175
  * hooks: {
@@ -210,16 +210,20 @@ export declare const clockTolerance: unique symbol;
210
210
  *
211
211
  * // example use
212
212
  * await oauth.discoveryRequest(new URL('https://as.example.com'), {
213
- * [oauth.experimentalCustomFetch]: undici.fetch,
213
+ * [oauth.experimental_customFetch]: undici.fetch,
214
214
  * })
215
215
  * ```
216
+ *
217
+ * @group Experimental
216
218
  */
217
- export declare const experimentalCustomFetch: unique symbol;
219
+ export declare const experimental_customFetch: unique symbol;
220
+ /** @ignore */
221
+ export declare const experimentalCustomFetch: symbol;
218
222
  /**
219
223
  * This is an experimental feature, it is not subject to semantic versioning rules. Non-backward
220
224
  * compatible changes or removal may occur in any future release.
221
225
  *
222
- * When combined with {@link experimentalCustomFetch} (to use a Fetch API implementation that
226
+ * When combined with {@link experimental_customFetch} (to use a Fetch API implementation that
223
227
  * supports client certificates) this can be used to target FAPI 2.0 profiles that utilize
224
228
  * Mutual-TLS for either client authentication or sender constraining. FAPI 1.0 Advanced profiles
225
229
  * that use PAR and JARM can also be targetted.
@@ -238,8 +242,8 @@ export declare const experimentalCustomFetch: unique symbol;
238
242
  * import * as oauth from 'oauth4webapi'
239
243
  *
240
244
  * const response = await oauth.pushedAuthorizationRequest(as, client, params, {
241
- * [oauth.experimentalUseMtlsAlias]: true,
242
- * [oauth.experimentalCustomFetch]: (...args) => {
245
+ * [oauth.experimental_useMtlsAlias]: true,
246
+ * [oauth.experimental_customFetch]: (...args) => {
243
247
  * return undici.fetch(args[0], {
244
248
  * ...args[1],
245
249
  * dispatcher: new undici.Agent({
@@ -268,8 +272,8 @@ export declare const experimentalCustomFetch: unique symbol;
268
272
  * })
269
273
  *
270
274
  * const response = await oauth.pushedAuthorizationRequest(as, client, params, {
271
- * [oauth.experimentalUseMtlsAlias]: true,
272
- * [oauth.experimentalCustomFetch]: (...args) => {
275
+ * [oauth.experimental_useMtlsAlias]: true,
276
+ * [oauth.experimental_customFetch]: (...args) => {
273
277
  * return fetch(args[0], {
274
278
  * ...args[1],
275
279
  * client: agent,
@@ -278,9 +282,13 @@ export declare const experimentalCustomFetch: unique symbol;
278
282
  * })
279
283
  * ```
280
284
  *
285
+ * @group Experimental
286
+ *
281
287
  * @see [RFC 8705 - OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens](https://www.rfc-editor.org/rfc/rfc8705.html)
282
288
  */
283
- export declare const experimentalUseMtlsAlias: unique symbol;
289
+ export declare const experimental_useMtlsAlias: unique symbol;
290
+ /** @ignore */
291
+ export declare const experimentalUseMtlsAlias: symbol;
284
292
  /**
285
293
  * Authorization Server Metadata
286
294
  *
@@ -680,9 +688,11 @@ export interface HttpRequestOptions {
680
688
  * This is an experimental feature, it is not subject to semantic versioning rules. Non-backward
681
689
  * compatible changes or removal may occur in any future release.
682
690
  *
683
- * See {@link experimentalCustomFetch} for its documentation.
691
+ * See {@link experimental_customFetch} for its documentation.
692
+ *
693
+ * @group Experimental
684
694
  */
685
- [experimentalCustomFetch]?: typeof fetch;
695
+ [experimental_customFetch]?: typeof fetch;
686
696
  }
687
697
  export interface DiscoveryRequestOptions extends HttpRequestOptions {
688
698
  /** The issuer transformation algorithm to use. */
@@ -786,9 +796,11 @@ export interface ExperimentalUseMTLSAliasOptions {
786
796
  * This is an experimental feature, it is not subject to semantic versioning rules. Non-backward
787
797
  * compatible changes or removal may occur in any future release.
788
798
  *
789
- * See {@link experimentalUseMtlsAlias} for its documentation.
799
+ * See {@link experimental_useMtlsAlias} for its documentation.
800
+ *
801
+ * @group Experimental
790
802
  */
791
- [experimentalUseMtlsAlias]?: boolean;
803
+ [experimental_useMtlsAlias]?: boolean;
792
804
  }
793
805
  export interface AuthenticatedRequestOptions extends ExperimentalUseMTLSAliasOptions {
794
806
  /**
@@ -1349,6 +1361,32 @@ export declare function processIntrospectionResponse(as: AuthorizationServer, cl
1349
1361
  * @see [JWT Secured Authorization Response Mode for OAuth 2.0 (JARM)](https://openid.net/specs/openid-financial-api-jarm.html)
1350
1362
  */
1351
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>;
1352
1390
  /**
1353
1391
  * DANGER ZONE
1354
1392
  *
package/build/index.js CHANGED
@@ -1,7 +1,7 @@
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.5.0';
4
+ const VERSION = 'v2.6.0';
5
5
  USER_AGENT = `${NAME}/${VERSION}`;
6
6
  }
7
7
  function looseInstanceOf(input, expected) {
@@ -18,8 +18,10 @@ function looseInstanceOf(input, expected) {
18
18
  }
19
19
  export const clockSkew = Symbol();
20
20
  export const clockTolerance = Symbol();
21
- export const experimentalCustomFetch = Symbol();
22
- export const experimentalUseMtlsAlias = 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;
23
25
  const encoder = new TextEncoder();
24
26
  const decoder = new TextDecoder();
25
27
  function buf(input) {
@@ -212,7 +214,7 @@ export async function discoveryRequest(issuerIdentifier, options) {
212
214
  }
213
215
  const headers = prepareHeaders(options?.headers);
214
216
  headers.set('accept', 'application/json');
215
- return (options?.[experimentalCustomFetch] || fetch)(url.href, {
217
+ return (options?.[experimental_customFetch] || fetch)(url.href, {
216
218
  headers: Object.fromEntries(headers.entries()),
217
219
  method: 'GET',
218
220
  redirect: 'manual',
@@ -548,7 +550,7 @@ async function publicJwk(key) {
548
550
  }
549
551
  function validateEndpoint(value, endpoint, options) {
550
552
  if (typeof value !== 'string') {
551
- if (options?.[experimentalUseMtlsAlias]) {
553
+ if (options?.[experimental_useMtlsAlias]) {
552
554
  throw new TypeError(`"as.mtls_endpoint_aliases.${endpoint}" must be a string`);
553
555
  }
554
556
  else {
@@ -558,7 +560,7 @@ function validateEndpoint(value, endpoint, options) {
558
560
  return new URL(value);
559
561
  }
560
562
  function resolveEndpoint(as, endpoint, options) {
561
- if (options?.[experimentalUseMtlsAlias] &&
563
+ if (options?.[experimental_useMtlsAlias] &&
562
564
  as.mtls_endpoint_aliases &&
563
565
  endpoint in as.mtls_endpoint_aliases) {
564
566
  return validateEndpoint(as.mtls_endpoint_aliases[endpoint], endpoint, options);
@@ -690,7 +692,7 @@ export async function protectedResourceRequest(accessToken, method, url, headers
690
692
  await dpopProofJwt(headers, options.DPoP, url, 'GET', getClockSkew({ [clockSkew]: options?.[clockSkew] }), accessToken);
691
693
  headers.set('authorization', `DPoP ${accessToken}`);
692
694
  }
693
- return (options?.[experimentalCustomFetch] || fetch)(url.href, {
695
+ return (options?.[experimental_customFetch] || fetch)(url.href, {
694
696
  body,
695
697
  headers: Object.fromEntries(headers.entries()),
696
698
  method,
@@ -853,7 +855,7 @@ export async function processUserInfoResponse(as, client, expectedSubject, respo
853
855
  async function authenticatedRequest(as, client, method, url, body, headers, options) {
854
856
  await clientAuthentication(as, client, body, headers, options?.clientPrivateKey);
855
857
  headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
856
- return (options?.[experimentalCustomFetch] || fetch)(url.href, {
858
+ return (options?.[experimental_customFetch] || fetch)(url.href, {
857
859
  body,
858
860
  headers: Object.fromEntries(headers.entries()),
859
861
  method,
@@ -1017,17 +1019,20 @@ export async function authorizationCodeGrantRequest(as, client, callbackParamete
1017
1019
  parameters.set('code', code);
1018
1020
  return tokenEndpointRequest(as, client, 'authorization_code', parameters, options);
1019
1021
  }
1020
- const claimNames = {
1022
+ const idTokenClaimNames = {
1021
1023
  aud: 'audience',
1022
1024
  exp: 'expiration time',
1023
1025
  iat: 'issued at',
1024
1026
  iss: 'issuer',
1025
1027
  sub: 'subject',
1028
+ nonce: 'nonce',
1029
+ s_hash: 'state hash',
1030
+ c_hash: 'code hash',
1026
1031
  };
1027
1032
  function validatePresence(required, result) {
1028
1033
  for (const claim of required) {
1029
1034
  if (result.claims[claim] === undefined) {
1030
- throw new OPE(`JWT "${claim}" (${claimNames[claim]}) claim missing`);
1035
+ throw new OPE(`JWT "${claim}" (${idTokenClaimNames[claim]}) claim missing`);
1031
1036
  }
1032
1037
  }
1033
1038
  return result;
@@ -1207,7 +1212,7 @@ async function jwksRequest(as, options) {
1207
1212
  const headers = prepareHeaders(options?.headers);
1208
1213
  headers.set('accept', 'application/json');
1209
1214
  headers.append('accept', 'application/jwk-set+json');
1210
- return (options?.[experimentalCustomFetch] || fetch)(url.href, {
1215
+ return (options?.[experimental_customFetch] || fetch)(url.href, {
1211
1216
  headers: Object.fromEntries(headers.entries()),
1212
1217
  method: 'GET',
1213
1218
  redirect: 'manual',
@@ -1342,8 +1347,9 @@ async function validateJwt(jws, checkAlg, getKey, clockSkew, clockTolerance) {
1342
1347
  throw new OPE('unexpected JWT "crit" header parameter');
1343
1348
  }
1344
1349
  const signature = b64u(encodedSignature);
1350
+ let key;
1345
1351
  if (getKey !== noSignatureCheck) {
1346
- const key = await getKey(header);
1352
+ key = await getKey(header);
1347
1353
  const input = `${protectedHeader}.${payload}`;
1348
1354
  const verified = await crypto.subtle.verify(keyToSubtle(key), key, signature, buf(input));
1349
1355
  if (!verified) {
@@ -1392,7 +1398,7 @@ async function validateJwt(jws, checkAlg, getKey, clockSkew, clockTolerance) {
1392
1398
  throw new OPE('unexpected JWT "aud" (audience) claim type');
1393
1399
  }
1394
1400
  }
1395
- return { header, claims, signature };
1401
+ return { header, claims, signature, key };
1396
1402
  }
1397
1403
  export async function validateJwtAuthResponse(as, client, parameters, expectedState, options) {
1398
1404
  assertAs(as);
@@ -1422,6 +1428,137 @@ export async function validateJwtAuthResponse(as, client, parameters, expectedSt
1422
1428
  }
1423
1429
  return validateAuthResponse(as, client, result, expectedState);
1424
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
+ }
1425
1562
  function checkSigningAlgorithm(client, issuer, header) {
1426
1563
  if (client !== undefined) {
1427
1564
  if (header.alg !== client) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oauth4webapi",
3
- "version": "2.5.0",
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",