oauth4webapi 2.1.0 → 2.2.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
@@ -39,7 +39,7 @@ import * as oauth2 from 'oauth4webapi'
39
39
  **`example`** Deno import
40
40
 
41
41
  ```js
42
- import * as oauth2 from 'https://deno.land/x/oauth4webapi@v2.1.0/mod.ts'
42
+ import * as oauth2 from 'https://deno.land/x/oauth4webapi@v2.2.0/mod.ts'
43
43
  ```
44
44
 
45
45
  - Authorization Code Flow - OpenID Connect [source](examples/code.ts), or plain OAuth 2 [source](examples/oauth.ts)
package/build/index.d.ts CHANGED
@@ -121,6 +121,10 @@ export type ClientAuthenticationMethod = 'client_secret_basic' | 'client_secret_
121
121
  * ```
122
122
  */
123
123
  export type JWSAlgorithm = 'PS256' | 'ES256' | 'RS256' | 'EdDSA' | 'ES384' | 'PS384' | 'RS384' | 'ES512' | 'PS512' | 'RS512';
124
+ /** @ignore during Documentation generation but part of the public API */
125
+ export declare const clockSkew: unique symbol;
126
+ /** @ignore during Documentation generation but part of the public API */
127
+ export declare const clockTolerance: unique symbol;
124
128
  /**
125
129
  * Authorization Server Metadata
126
130
  *
@@ -442,6 +446,50 @@ export interface Client {
442
446
  introspection_signed_response_alg?: string;
443
447
  /** Default Maximum Authentication Age. */
444
448
  default_max_age?: number;
449
+ /**
450
+ * Use to adjust the client's assumed current time. Positive and negative finite values
451
+ * representing seconds are allowed. Default is `0` (Date.now() + 0 seconds is used).
452
+ *
453
+ * @ignore during Documentation generation but part of the public API
454
+ *
455
+ * @example Client's local clock is mistakenly 1 hour in the past
456
+ *
457
+ * ```ts
458
+ * const client: oauth.Client = {
459
+ * client_id: 'abc4ba37-4ab8-49b5-99d4-9441ba35d428',
460
+ * // ... other metadata
461
+ * [oauth.clockSkew]: +(60 * 60),
462
+ * }
463
+ * ```
464
+ *
465
+ * @example Client's local clock is mistakenly 1 hour in the future
466
+ *
467
+ * ```ts
468
+ * const client: oauth.Client = {
469
+ * client_id: 'abc4ba37-4ab8-49b5-99d4-9441ba35d428',
470
+ * // ... other metadata
471
+ * [oauth.clockSkew]: -(60 * 60),
472
+ * }
473
+ * ```
474
+ */
475
+ [clockSkew]?: number;
476
+ /**
477
+ * Use to set allowed client's clock tolerance when checking DateTime JWT Claims. Only positive
478
+ * finite values representing seconds are allowed. Default is `30` (30 seconds).
479
+ *
480
+ * @ignore during Documentation generation but part of the public API
481
+ *
482
+ * @example Tolerate 30 seconds clock skew when validating JWT claims like `exp` or `nbf`.
483
+ *
484
+ * ```ts
485
+ * const client: oauth.Client = {
486
+ * client_id: 'abc4ba37-4ab8-49b5-99d4-9441ba35d428',
487
+ * // ... other metadata
488
+ * [oauth.clockTolerance]: 30,
489
+ * }
490
+ * ```
491
+ */
492
+ [clockTolerance]?: number;
445
493
  [metadata: string]: JsonValue | undefined;
446
494
  }
447
495
  export declare class UnsupportedOperationError extends Error {
@@ -629,6 +677,16 @@ export declare function parseWwwAuthenticateChallenges(response: Response): WWWA
629
677
  */
630
678
  export declare function processPushedAuthorizationResponse(as: AuthorizationServer, client: Client, response: Response): Promise<PushedAuthorizationResponse | OAuth2Error>;
631
679
  export interface ProtectedResourceRequestOptions extends Omit<HttpRequestOptions, 'headers'>, DPoPRequestOptions {
680
+ /**
681
+ * Use to adjust the client's assumed current time. Positive and negative finite values
682
+ * representing seconds are allowed. Default is `0` (Date.now() + 0 seconds is used).
683
+ *
684
+ * This option only affects the request if the {@link ProtectedResourceRequestOptions.DPoP DPoP}
685
+ * option is also used.
686
+ *
687
+ * @ignore during Documentation generation but part of the public API
688
+ */
689
+ clockSkew?: number;
632
690
  }
633
691
  /**
634
692
  * Performs a protected resource request at an arbitrary URL.
package/build/index.js CHANGED
@@ -1,9 +1,11 @@
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.1.0';
4
+ const VERSION = 'v2.2.0';
5
5
  USER_AGENT = `${NAME}/${VERSION}`;
6
6
  }
7
+ export const clockSkew = Symbol();
8
+ export const clockTolerance = Symbol();
7
9
  const encoder = new TextEncoder();
8
10
  const decoder = new TextDecoder();
9
11
  function buf(input) {
@@ -321,11 +323,24 @@ function keyToJws(key) {
321
323
  throw new UnsupportedOperationError('unsupported CryptoKey algorithm name');
322
324
  }
323
325
  }
326
+ function getClockSkew(client) {
327
+ if (Number.isFinite(client[clockSkew])) {
328
+ return client[clockSkew];
329
+ }
330
+ return 0;
331
+ }
332
+ function getClockTolerance(client) {
333
+ const tolerance = client[clockTolerance];
334
+ if (Number.isFinite(tolerance) && Math.sign(tolerance) !== -1) {
335
+ return tolerance;
336
+ }
337
+ return 30;
338
+ }
324
339
  function epochTime() {
325
340
  return Math.floor(Date.now() / 1000);
326
341
  }
327
342
  function clientAssertion(as, client) {
328
- const now = epochTime();
343
+ const now = epochTime() + getClockSkew(client);
329
344
  return {
330
345
  jti: randomBytes(),
331
346
  aud: [as.issuer, as.token_endpoint],
@@ -437,7 +452,7 @@ export async function issueRequestObject(as, client, parameters, privateKey) {
437
452
  throw new TypeError('"privateKey.key" must be a private CryptoKey');
438
453
  }
439
454
  parameters.set('client_id', client.client_id);
440
- const now = epochTime();
455
+ const now = epochTime() + getClockSkew(client);
441
456
  const claims = {
442
457
  ...Object.fromEntries(parameters.entries()),
443
458
  jti: randomBytes(),
@@ -474,7 +489,7 @@ export async function issueRequestObject(as, client, parameters, privateKey) {
474
489
  kid,
475
490
  }, claims, key);
476
491
  }
477
- async function dpopProofJwt(headers, options, url, htm, accessToken) {
492
+ async function dpopProofJwt(headers, options, url, htm, clockSkew, accessToken) {
478
493
  const { privateKey, publicKey, nonce = dpopNonces.get(url.origin) } = options;
479
494
  if (!isPrivateKey(privateKey)) {
480
495
  throw new TypeError('"DPoP.privateKey" must be a private CryptoKey');
@@ -488,7 +503,7 @@ async function dpopProofJwt(headers, options, url, htm, accessToken) {
488
503
  if (!publicKey.extractable) {
489
504
  throw new TypeError('"DPoP.publicKey.extractable" must be true');
490
505
  }
491
- const now = epochTime();
506
+ const now = epochTime() + clockSkew;
492
507
  const proof = await jwt({
493
508
  alg: keyToJws(privateKey),
494
509
  typ: 'dpop+jwt',
@@ -531,7 +546,7 @@ export async function pushedAuthorizationRequest(as, client, parameters, options
531
546
  const headers = prepareHeaders(options?.headers);
532
547
  headers.set('accept', 'application/json');
533
548
  if (options?.DPoP !== undefined) {
534
- await dpopProofJwt(headers, options.DPoP, url, 'POST');
549
+ await dpopProofJwt(headers, options.DPoP, url, 'POST', getClockSkew(client));
535
550
  }
536
551
  return authenticatedRequest(as, client, 'POST', url, body, headers, options);
537
552
  }
@@ -644,7 +659,7 @@ export async function protectedResourceRequest(accessToken, method, url, headers
644
659
  headers.set('authorization', `Bearer ${accessToken}`);
645
660
  }
646
661
  else {
647
- await dpopProofJwt(headers, options.DPoP, url, 'GET', accessToken);
662
+ await dpopProofJwt(headers, options.DPoP, url, 'GET', getClockSkew({ [clockSkew]: options?.clockSkew }), accessToken);
648
663
  headers.set('authorization', `DPoP ${accessToken}`);
649
664
  }
650
665
  return fetch(url.href, {
@@ -670,7 +685,10 @@ export async function userInfoRequest(as, client, accessToken, options) {
670
685
  headers.set('accept', 'application/json');
671
686
  headers.append('accept', 'application/jwt');
672
687
  }
673
- return protectedResourceRequest(accessToken, 'GET', url, headers, null, options);
688
+ return protectedResourceRequest(accessToken, 'GET', url, headers, null, {
689
+ ...options,
690
+ clockSkew: getClockSkew(client),
691
+ });
674
692
  }
675
693
  let jwksCache;
676
694
  async function getPublicSigKeyFromIssuerJwksUri(as, options, header) {
@@ -771,7 +789,7 @@ export async function processUserInfoResponse(as, client, expectedSubject, respo
771
789
  let json;
772
790
  if (getContentType(response) === 'application/jwt') {
773
791
  assertReadableResponse(response);
774
- const { claims } = await validateJwt(await response.text(), checkSigningAlgorithm.bind(undefined, client.userinfo_signed_response_alg, as.userinfo_signing_alg_values_supported), noSignatureCheck)
792
+ const { claims } = await validateJwt(await response.text(), checkSigningAlgorithm.bind(undefined, client.userinfo_signed_response_alg, as.userinfo_signing_alg_values_supported), noSignatureCheck, getClockSkew(client), getClockTolerance(client))
775
793
  .then(validateOptionalAudience.bind(undefined, client.client_id))
776
794
  .then(validateOptionalIssuer.bind(undefined, as.issuer));
777
795
  json = claims;
@@ -827,7 +845,7 @@ async function tokenEndpointRequest(as, client, grantType, parameters, options)
827
845
  const headers = prepareHeaders(options?.headers);
828
846
  headers.set('accept', 'application/json');
829
847
  if (options?.DPoP !== undefined) {
830
- await dpopProofJwt(headers, options.DPoP, url, 'POST');
848
+ await dpopProofJwt(headers, options.DPoP, url, 'POST', getClockSkew(client));
831
849
  }
832
850
  return authenticatedRequest(as, client, 'POST', url, parameters, headers, options);
833
851
  }
@@ -899,7 +917,7 @@ async function processGenericAccessTokenResponse(as, client, response, ignoreIdT
899
917
  throw new OPE('"response" body "id_token" property must be a non-empty string');
900
918
  }
901
919
  if (json.id_token) {
902
- const { claims } = await validateJwt(json.id_token, checkSigningAlgorithm.bind(undefined, client.id_token_signed_response_alg, as.id_token_signing_alg_values_supported), noSignatureCheck)
920
+ const { claims } = await validateJwt(json.id_token, checkSigningAlgorithm.bind(undefined, client.id_token_signed_response_alg, as.id_token_signing_alg_values_supported), noSignatureCheck, getClockSkew(client), getClockTolerance(client))
903
921
  .then(validatePresence.bind(undefined, ['aud', 'exp', 'iat', 'iss', 'sub']))
904
922
  .then(validateIssuer.bind(undefined, as.issuer))
905
923
  .then(validateAudience.bind(undefined, client.client_id));
@@ -1003,8 +1021,8 @@ export async function processAuthorizationCodeOpenIDResponse(as, client, respons
1003
1021
  if (typeof maxAge !== 'number' || maxAge < 0) {
1004
1022
  throw new TypeError('"options.max_age" must be a non-negative number');
1005
1023
  }
1006
- const now = epochTime();
1007
- const tolerance = 30;
1024
+ const now = epochTime() + getClockSkew(client);
1025
+ const tolerance = getClockTolerance(client);
1008
1026
  if (claims.auth_time + maxAge < now - tolerance) {
1009
1027
  throw new OPE('too much time has elapsed since the last End-User authentication');
1010
1028
  }
@@ -1131,7 +1149,7 @@ export async function processIntrospectionResponse(as, client, response) {
1131
1149
  let json;
1132
1150
  if (getContentType(response) === 'application/token-introspection+jwt') {
1133
1151
  assertReadableResponse(response);
1134
- const { claims } = await validateJwt(await response.text(), checkSigningAlgorithm.bind(undefined, client.introspection_signed_response_alg, as.introspection_signing_alg_values_supported), noSignatureCheck)
1152
+ const { claims } = await validateJwt(await response.text(), checkSigningAlgorithm.bind(undefined, client.introspection_signed_response_alg, as.introspection_signing_alg_values_supported), noSignatureCheck, getClockSkew(client), getClockTolerance(client))
1135
1153
  .then(checkJwtType.bind(undefined, 'token-introspection+jwt'))
1136
1154
  .then(validatePresence.bind(undefined, ['aud', 'iat', 'iss']))
1137
1155
  .then(validateIssuer.bind(undefined, as.issuer))
@@ -1279,7 +1297,7 @@ function keyToSubtle(key) {
1279
1297
  throw new UnsupportedOperationError();
1280
1298
  }
1281
1299
  const noSignatureCheck = Symbol();
1282
- async function validateJwt(jws, checkAlg, getKey) {
1300
+ async function validateJwt(jws, checkAlg, getKey, clockSkew, clockTolerance) {
1283
1301
  const { 0: protectedHeader, 1: payload, 2: encodedSignature, length } = jws.split('.');
1284
1302
  if (length === 5) {
1285
1303
  throw new UnsupportedOperationError('JWE structure JWTs are not supported');
@@ -1320,13 +1338,12 @@ async function validateJwt(jws, checkAlg, getKey) {
1320
1338
  if (!isJsonObject(claims)) {
1321
1339
  throw new OPE('JWT Payload must be a top level object');
1322
1340
  }
1323
- const now = epochTime();
1324
- const tolerance = 30;
1341
+ const now = epochTime() + clockSkew;
1325
1342
  if (claims.exp !== undefined) {
1326
1343
  if (typeof claims.exp !== 'number') {
1327
1344
  throw new OPE('unexpected JWT "exp" (expiration time) claim type');
1328
1345
  }
1329
- if (claims.exp <= now - tolerance) {
1346
+ if (claims.exp <= now - clockTolerance) {
1330
1347
  throw new OPE('unexpected JWT "exp" (expiration time) claim value, timestamp is <= now()');
1331
1348
  }
1332
1349
  }
@@ -1344,7 +1361,7 @@ async function validateJwt(jws, checkAlg, getKey) {
1344
1361
  if (typeof claims.nbf !== 'number') {
1345
1362
  throw new OPE('unexpected JWT "nbf" (not before) claim type');
1346
1363
  }
1347
- if (claims.nbf > now + tolerance) {
1364
+ if (claims.nbf > now + clockTolerance) {
1348
1365
  throw new OPE('unexpected JWT "nbf" (not before) claim value, timestamp is > now()');
1349
1366
  }
1350
1367
  }
@@ -1371,7 +1388,7 @@ export async function validateJwtAuthResponse(as, client, parameters, expectedSt
1371
1388
  if (typeof as.jwks_uri !== 'string') {
1372
1389
  throw new TypeError('"as.jwks_uri" must be a string');
1373
1390
  }
1374
- const { claims } = await validateJwt(response, checkSigningAlgorithm.bind(undefined, client.authorization_signed_response_alg, as.authorization_signing_alg_values_supported), getPublicSigKeyFromIssuerJwksUri.bind(undefined, as, options))
1391
+ const { claims } = await validateJwt(response, checkSigningAlgorithm.bind(undefined, client.authorization_signed_response_alg, as.authorization_signing_alg_values_supported), getPublicSigKeyFromIssuerJwksUri.bind(undefined, as, options), getClockSkew(client), getClockTolerance(client))
1375
1392
  .then(validatePresence.bind(undefined, ['aud', 'exp', 'iss']))
1376
1393
  .then(validateIssuer.bind(undefined, as.issuer))
1377
1394
  .then(validateAudience.bind(undefined, client.client_id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oauth4webapi",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "OAuth 2 / OpenID Connect for Web Platform API JavaScript runtimes",
5
5
  "keywords": [
6
6
  "auth",
@@ -62,21 +62,21 @@
62
62
  "test": "bash -c 'source .node_flags.sh && ava'"
63
63
  },
64
64
  "devDependencies": {
65
- "@esbuild-kit/esm-loader": "^2.5.1",
66
- "@types/node": "^18.11.9",
67
- "@types/qunit": "^2.19.3",
68
- "ava": "^5.1.0",
69
- "edge-runtime": "^2.0.4",
70
- "esbuild": "^0.17.0",
71
- "jose": "^4.11.1",
72
- "patch-package": "^6.5.0",
73
- "prettier": "^2.8.0",
65
+ "@esbuild-kit/esm-loader": "^2.5.5",
66
+ "@types/node": "^18.15.0",
67
+ "@types/qunit": "^2.19.4",
68
+ "ava": "^5.2.0",
69
+ "edge-runtime": "^2.1.2",
70
+ "esbuild": "^0.17.11",
71
+ "jose": "^4.13.1",
72
+ "patch-package": "^6.5.1",
73
+ "prettier": "^2.8.4",
74
74
  "prettier-plugin-jsdoc": "^0.4.2",
75
- "qunit": "^2.19.3",
75
+ "qunit": "^2.19.4",
76
76
  "timekeeper": "^2.2.0",
77
- "typedoc": "^0.23.21",
78
- "typedoc-plugin-markdown": "^3.13.6",
79
- "typescript": "^4.9.3",
80
- "undici": "^5.13.0"
77
+ "typedoc": "^0.23.26",
78
+ "typedoc-plugin-markdown": "^3.14.0",
79
+ "typescript": "^4.9.5",
80
+ "undici": "^5.20.0"
81
81
  }
82
82
  }