@atproto/oauth-provider 0.7.10 → 0.8.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/customization/branding.d.ts +7 -7
  3. package/dist/customization/customization.d.ts +10 -10
  4. package/dist/customization/links.d.ts +4 -4
  5. package/dist/dpop/dpop-manager.d.ts +2 -10
  6. package/dist/dpop/dpop-manager.d.ts.map +1 -1
  7. package/dist/dpop/dpop-manager.js +107 -65
  8. package/dist/dpop/dpop-manager.js.map +1 -1
  9. package/dist/dpop/dpop-proof.d.ts +7 -0
  10. package/dist/dpop/dpop-proof.d.ts.map +1 -0
  11. package/dist/dpop/dpop-proof.js +3 -0
  12. package/dist/dpop/dpop-proof.js.map +1 -0
  13. package/dist/lib/hcaptcha.d.ts +3 -3
  14. package/dist/lib/util/authorization-header.d.ts +1 -1
  15. package/dist/lib/util/authorization-header.d.ts.map +1 -1
  16. package/dist/lib/util/authorization-header.js +1 -1
  17. package/dist/lib/util/authorization-header.js.map +1 -1
  18. package/dist/lib/util/cast.d.ts +6 -0
  19. package/dist/lib/util/cast.d.ts.map +1 -1
  20. package/dist/lib/util/cast.js +13 -0
  21. package/dist/lib/util/cast.js.map +1 -1
  22. package/dist/oauth-provider.d.ts +6 -6
  23. package/dist/oauth-provider.d.ts.map +1 -1
  24. package/dist/oauth-provider.js +14 -14
  25. package/dist/oauth-provider.js.map +1 -1
  26. package/dist/oauth-verifier.d.ts +5 -7
  27. package/dist/oauth-verifier.d.ts.map +1 -1
  28. package/dist/oauth-verifier.js +15 -17
  29. package/dist/oauth-verifier.js.map +1 -1
  30. package/dist/request/request-manager.d.ts +3 -2
  31. package/dist/request/request-manager.d.ts.map +1 -1
  32. package/dist/request/request-manager.js +12 -7
  33. package/dist/request/request-manager.js.map +1 -1
  34. package/dist/router/create-oauth-middleware.js +4 -4
  35. package/dist/router/create-oauth-middleware.js.map +1 -1
  36. package/dist/signer/api-token-payload.d.ts +3 -3
  37. package/dist/signer/api-token-payload.d.ts.map +1 -1
  38. package/dist/signer/signed-token-payload.d.ts +3 -3
  39. package/dist/signer/signed-token-payload.d.ts.map +1 -1
  40. package/dist/token/token-manager.d.ts +4 -3
  41. package/dist/token/token-manager.d.ts.map +1 -1
  42. package/dist/token/token-manager.js +14 -11
  43. package/dist/token/token-manager.js.map +1 -1
  44. package/dist/token/verify-token-claims.d.ts +4 -2
  45. package/dist/token/verify-token-claims.d.ts.map +1 -1
  46. package/dist/token/verify-token-claims.js +29 -14
  47. package/dist/token/verify-token-claims.js.map +1 -1
  48. package/package.json +8 -8
  49. package/src/dpop/dpop-manager.ts +129 -74
  50. package/src/dpop/dpop-proof.ts +6 -0
  51. package/src/lib/util/authorization-header.ts +2 -2
  52. package/src/lib/util/cast.ts +14 -0
  53. package/src/oauth-provider.ts +20 -16
  54. package/src/oauth-verifier.ts +35 -32
  55. package/src/request/request-manager.ts +11 -9
  56. package/src/router/create-oauth-middleware.ts +6 -6
  57. package/src/token/token-manager.ts +14 -11
  58. package/src/token/verify-token-claims.ts +46 -17
  59. package/tsconfig.build.tsbuildinfo +1 -1
@@ -5,34 +5,49 @@ const invalid_dpop_key_binding_error_js_1 = require("../errors/invalid-dpop-key-
5
5
  const invalid_dpop_proof_error_js_1 = require("../errors/invalid-dpop-proof-error.js");
6
6
  const cast_js_1 = require("../lib/util/cast.js");
7
7
  const oauth_errors_js_1 = require("../oauth-errors.js");
8
- function verifyTokenClaims(token, tokenId, tokenType, dpopJkt, claims, options) {
8
+ const BEARER = 'Bearer';
9
+ const DPOP = 'DPoP';
10
+ function verifyTokenClaims(token, tokenId, tokenType, tokenClaims, dpopProof, options) {
9
11
  const dateReference = Date.now();
10
- const claimsJkt = claims.cnf?.jkt ?? null;
11
- const expectedTokenType = claimsJkt ? 'DPoP' : 'Bearer';
12
- if (expectedTokenType !== tokenType) {
13
- throw new oauth_errors_js_1.InvalidTokenError(expectedTokenType, `Invalid token type`);
14
- }
15
- if (tokenType === 'DPoP' && !dpopJkt) {
16
- throw new invalid_dpop_proof_error_js_1.InvalidDpopProofError(`jkt is required for DPoP tokens`);
12
+ if (tokenClaims.cnf?.jkt) {
13
+ // An access token with a cnf.jkt claim must be a DPoP token
14
+ if (tokenType !== DPOP) {
15
+ throw new oauth_errors_js_1.InvalidTokenError(DPOP, `Access token is bound to a DPoP proof, but token type is ${tokenType}`);
16
+ }
17
+ // DPoP token type must be used with a DPoP proof
18
+ if (!dpopProof) {
19
+ throw new invalid_dpop_proof_error_js_1.InvalidDpopProofError(`DPoP proof required`);
20
+ }
21
+ // DPoP proof must be signed with the key that matches the "cnf" claim
22
+ if (tokenClaims.cnf.jkt !== dpopProof.jkt) {
23
+ throw new invalid_dpop_key_binding_error_js_1.InvalidDpopKeyBindingError();
24
+ }
17
25
  }
18
- if (claimsJkt !== dpopJkt) {
19
- throw new invalid_dpop_key_binding_error_js_1.InvalidDpopKeyBindingError();
26
+ else {
27
+ // An access token without a cnf.jkt claim must be a Bearer token
28
+ if (tokenType !== BEARER) {
29
+ throw new oauth_errors_js_1.InvalidTokenError(BEARER, `Bearer token type must be used without a DPoP proof`);
30
+ }
31
+ // Unexpected DPoP proof received for a Bearer token
32
+ if (dpopProof) {
33
+ throw new oauth_errors_js_1.InvalidTokenError(BEARER, `DPoP proof not expected for Bearer token type`);
34
+ }
20
35
  }
21
36
  if (options?.audience) {
22
- const aud = (0, cast_js_1.asArray)(claims.aud);
37
+ const aud = (0, cast_js_1.asArray)(tokenClaims.aud);
23
38
  if (!options.audience.some((v) => aud.includes(v))) {
24
39
  throw new oauth_errors_js_1.InvalidTokenError(tokenType, `Invalid audience`);
25
40
  }
26
41
  }
27
42
  if (options?.scope) {
28
- const scopes = claims.scope?.split(' ');
43
+ const scopes = tokenClaims.scope?.split(' ');
29
44
  if (!scopes || !options.scope.some((v) => scopes.includes(v))) {
30
45
  throw new oauth_errors_js_1.InvalidTokenError(tokenType, `Invalid scope`);
31
46
  }
32
47
  }
33
- if (claims.exp != null && claims.exp * 1000 <= dateReference) {
48
+ if (tokenClaims.exp != null && tokenClaims.exp * 1000 <= dateReference) {
34
49
  throw new oauth_errors_js_1.InvalidTokenError(tokenType, `Token expired`);
35
50
  }
36
- return { token, tokenId, tokenType, claims };
51
+ return { token, tokenId, tokenType, tokenClaims, dpopProof };
37
52
  }
38
53
  //# sourceMappingURL=verify-token-claims.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"verify-token-claims.js","sourceRoot":"","sources":["../../src/token/verify-token-claims.ts"],"names":[],"mappings":";;AAsBA,8CAyCC;AA9DD,mGAAwF;AACxF,uFAA6E;AAC7E,iDAA6C;AAC7C,wDAAsD;AAkBtD,SAAgB,iBAAiB,CAC/B,KAAuB,EACvB,OAAgB,EAChB,SAAyB,EACzB,OAAsB,EACtB,MAA0B,EAC1B,OAAkC;IAElC,MAAM,aAAa,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;IAChC,MAAM,SAAS,GAAG,MAAM,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,CAAA;IAEzC,MAAM,iBAAiB,GAAmB,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAA;IACvE,IAAI,iBAAiB,KAAK,SAAS,EAAE,CAAC;QACpC,MAAM,IAAI,mCAAiB,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAA;IACtE,CAAC;IACD,IAAI,SAAS,KAAK,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;QACrC,MAAM,IAAI,mDAAqB,CAAC,iCAAiC,CAAC,CAAA;IACpE,CAAC;IACD,IAAI,SAAS,KAAK,OAAO,EAAE,CAAC;QAC1B,MAAM,IAAI,8DAA0B,EAAE,CAAA;IACxC,CAAC;IAED,IAAI,OAAO,EAAE,QAAQ,EAAE,CAAC;QACtB,MAAM,GAAG,GAAG,IAAA,iBAAO,EAAC,MAAM,CAAC,GAAG,CAAC,CAAA;QAC/B,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACnD,MAAM,IAAI,mCAAiB,CAAC,SAAS,EAAE,kBAAkB,CAAC,CAAA;QAC5D,CAAC;IACH,CAAC;IAED,IAAI,OAAO,EAAE,KAAK,EAAE,CAAC;QACnB,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;QACvC,IAAI,CAAC,MAAM,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YAC9D,MAAM,IAAI,mCAAiB,CAAC,SAAS,EAAE,eAAe,CAAC,CAAA;QACzD,CAAC;IACH,CAAC;IAED,IAAI,MAAM,CAAC,GAAG,IAAI,IAAI,IAAI,MAAM,CAAC,GAAG,GAAG,IAAI,IAAI,aAAa,EAAE,CAAC;QAC7D,MAAM,IAAI,mCAAiB,CAAC,SAAS,EAAE,eAAe,CAAC,CAAA;IACzD,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,CAAA;AAC9C,CAAC"}
1
+ {"version":3,"file":"verify-token-claims.js","sourceRoot":"","sources":["../../src/token/verify-token-claims.ts"],"names":[],"mappings":";;AA2BA,8CAiEC;AA3FD,mGAAwF;AACxF,uFAA6E;AAC7E,iDAA6C;AAC7C,wDAAsD;AAKtD,MAAM,MAAM,GAAG,QAAiC,CAAA;AAChD,MAAM,IAAI,GAAG,MAA+B,CAAA;AAiB5C,SAAgB,iBAAiB,CAC/B,KAAuB,EACvB,OAAgB,EAChB,SAAyB,EACzB,WAA+B,EAC/B,SAA2B,EAC3B,OAAkC;IAElC,MAAM,aAAa,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;IAEhC,IAAI,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC;QACzB,4DAA4D;QAC5D,IAAI,SAAS,KAAK,IAAI,EAAE,CAAC;YACvB,MAAM,IAAI,mCAAiB,CACzB,IAAI,EACJ,4DAA4D,SAAS,EAAE,CACxE,CAAA;QACH,CAAC;QAED,iDAAiD;QACjD,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,MAAM,IAAI,mDAAqB,CAAC,qBAAqB,CAAC,CAAA;QACxD,CAAC;QAED,sEAAsE;QACtE,IAAI,WAAW,CAAC,GAAG,CAAC,GAAG,KAAK,SAAS,CAAC,GAAG,EAAE,CAAC;YAC1C,MAAM,IAAI,8DAA0B,EAAE,CAAA;QACxC,CAAC;IACH,CAAC;SAAM,CAAC;QACN,iEAAiE;QACjE,IAAI,SAAS,KAAK,MAAM,EAAE,CAAC;YACzB,MAAM,IAAI,mCAAiB,CACzB,MAAM,EACN,qDAAqD,CACtD,CAAA;QACH,CAAC;QAED,oDAAoD;QACpD,IAAI,SAAS,EAAE,CAAC;YACd,MAAM,IAAI,mCAAiB,CACzB,MAAM,EACN,+CAA+C,CAChD,CAAA;QACH,CAAC;IACH,CAAC;IAED,IAAI,OAAO,EAAE,QAAQ,EAAE,CAAC;QACtB,MAAM,GAAG,GAAG,IAAA,iBAAO,EAAC,WAAW,CAAC,GAAG,CAAC,CAAA;QACpC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACnD,MAAM,IAAI,mCAAiB,CAAC,SAAS,EAAE,kBAAkB,CAAC,CAAA;QAC5D,CAAC;IACH,CAAC;IAED,IAAI,OAAO,EAAE,KAAK,EAAE,CAAC;QACnB,MAAM,MAAM,GAAG,WAAW,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;QAC5C,IAAI,CAAC,MAAM,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YAC9D,MAAM,IAAI,mCAAiB,CAAC,SAAS,EAAE,eAAe,CAAC,CAAA;QACzD,CAAC;IACH,CAAC;IAED,IAAI,WAAW,CAAC,GAAG,IAAI,IAAI,IAAI,WAAW,CAAC,GAAG,GAAG,IAAI,IAAI,aAAa,EAAE,CAAC;QACvE,MAAM,IAAI,mCAAiB,CAAC,SAAS,EAAE,eAAe,CAAC,CAAA;IACzD,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,SAAS,EAAE,CAAA;AAC9D,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/oauth-provider",
3
- "version": "0.7.10",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "description": "Generic OAuth2 and OpenID Connect provider for Node.js. Currently only supports features needed for Atproto.",
6
6
  "keywords": [
@@ -42,18 +42,18 @@
42
42
  "ioredis": "^5.3.2",
43
43
  "jose": "^5.2.0",
44
44
  "zod": "^3.23.8",
45
- "@atproto-labs/fetch-node": "0.1.9",
46
45
  "@atproto-labs/fetch": "0.2.3",
46
+ "@atproto-labs/fetch-node": "0.1.9",
47
47
  "@atproto-labs/pipe": "0.1.1",
48
48
  "@atproto-labs/simple-store": "0.2.0",
49
49
  "@atproto-labs/simple-store-memory": "0.1.3",
50
50
  "@atproto/common": "^0.4.11",
51
- "@atproto/jwk": "0.1.5",
52
- "@atproto/jwk-jose": "0.1.6",
53
- "@atproto/oauth-types": "0.2.7",
54
- "@atproto/oauth-provider-api": "0.1.2",
55
- "@atproto/oauth-provider-frontend": "0.1.5",
56
- "@atproto/oauth-provider-ui": "0.1.7",
51
+ "@atproto/jwk": "0.2.0",
52
+ "@atproto/jwk-jose": "0.1.7",
53
+ "@atproto/oauth-types": "0.2.8",
54
+ "@atproto/oauth-provider-api": "0.1.3",
55
+ "@atproto/oauth-provider-frontend": "0.1.6",
56
+ "@atproto/oauth-provider-ui": "0.1.8",
57
57
  "@atproto/syntax": "0.4.0"
58
58
  },
59
59
  "devDependencies": {
@@ -1,15 +1,18 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { EmbeddedJWK, calculateJwkThumbprint, errors, jwtVerify } from 'jose'
3
3
  import { z } from 'zod'
4
+ import { ValidationError } from '@atproto/jwk'
4
5
  import { DPOP_NONCE_MAX_AGE } from '../constants.js'
5
6
  import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js'
6
7
  import { UseDpopNonceError } from '../errors/use-dpop-nonce-error.js'
8
+ import { ifURL } from '../lib/util/cast.js'
7
9
  import {
8
10
  DpopNonce,
9
11
  DpopSecret,
10
12
  dpopSecretSchema,
11
13
  rotationIntervalSchema,
12
14
  } from './dpop-nonce.js'
15
+ import { DpopProof } from './dpop-proof.js'
13
16
 
14
17
  const { JOSEError } = errors
15
18
 
@@ -47,111 +50,163 @@ export class DpopManager {
47
50
  * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3}
48
51
  */
49
52
  async checkProof(
50
- proof: unknown,
51
- htm: string, // HTTP Method
52
- htu: string | URL, // HTTP URL
53
- accessToken?: string, // Access Token
54
- ) {
55
- if (Array.isArray(proof) && proof.length === 1) {
56
- proof = proof[0]
53
+ httpMethod: string,
54
+ httpUrl: Readonly<URL>,
55
+ httpHeaders: Record<string, undefined | string | string[]>,
56
+ accessToken?: string,
57
+ ): Promise<null | DpopProof> {
58
+ // Fool proofing against use of empty string
59
+ if (!httpMethod) {
60
+ throw new TypeError('HTTP method is required')
57
61
  }
58
62
 
59
- if (!proof || typeof proof !== 'string') {
60
- throw new InvalidDpopProofError('DPoP proof required')
61
- }
63
+ const proof = extractProof(httpHeaders)
64
+ if (!proof) return null
62
65
 
63
- const { protectedHeader, payload } = await jwtVerify<{
64
- iat: number
65
- jti: string
66
- }>(proof, EmbeddedJWK, {
66
+ const { protectedHeader, payload } = await jwtVerify(proof, EmbeddedJWK, {
67
67
  typ: 'dpop+jwt',
68
- maxTokenAge: 10,
68
+ maxTokenAge: 10, // Will ensure presence & validity of "iat" claim
69
69
  clockTolerance: DPOP_NONCE_MAX_AGE / 1e3,
70
- requiredClaims: ['iat', 'jti'],
71
70
  }).catch((err) => {
72
- const message =
73
- err instanceof JOSEError
74
- ? `Invalid DPoP proof (${err.message})`
75
- : 'Invalid DPoP proof'
76
- throw new InvalidDpopProofError(message, err)
71
+ throw newInvalidDpopProofError('Failed to verify DPoP proof', err)
77
72
  })
78
73
 
79
- if (!payload.jti || typeof payload.jti !== 'string') {
80
- throw new InvalidDpopProofError('Invalid or missing jti property')
74
+ // @NOTE For legacy & backwards compatibility reason, we cannot use
75
+ // `jwtPayloadSchema` here as it will reject DPoP proofs containing a query
76
+ // or fragment component in the "htu" claim.
77
+
78
+ // const { ath, htm, htu, jti, nonce } = await jwtPayloadSchema
79
+ // .parseAsync(payload)
80
+ // .catch((err) => {
81
+ // throw buildInvalidDpopProofError('Invalid DPoP proof', err)
82
+ // })
83
+
84
+ // @TODO Uncomment previous lines (and remove redundant checks bellow) once
85
+ // we decide to drop legacy support.
86
+ const { ath, htm, htu, jti, nonce } = payload
87
+
88
+ if (nonce !== undefined && typeof nonce !== 'string') {
89
+ throw newInvalidDpopProofError('Invalid DPoP "nonce" type')
81
90
  }
82
91
 
83
- // Note rfc9110#section-9.1 states that the method name is case-sensitive
84
- if (!htm || htm !== payload['htm']) {
85
- throw new InvalidDpopProofError('DPoP htm mismatch')
92
+ if (!jti || typeof jti !== 'string') {
93
+ throw newInvalidDpopProofError('DPoP "jti" missing')
86
94
  }
87
95
 
88
- if (
89
- payload['nonce'] !== undefined &&
90
- typeof payload['nonce'] !== 'string'
91
- ) {
92
- throw new InvalidDpopProofError('DPoP nonce must be a string')
96
+ // Note rfc9110#section-9.1 states that the method name is case-sensitive
97
+ if (!htm || htm !== httpMethod) {
98
+ throw newInvalidDpopProofError('DPoP "htm" mismatch')
93
99
  }
94
100
 
95
- if (!payload['nonce'] && this.dpopNonce) {
96
- throw new UseDpopNonceError()
101
+ if (!htu || typeof htu !== 'string') {
102
+ throw newInvalidDpopProofError('Invalid DPoP "htu" type')
97
103
  }
98
104
 
99
- if (payload['nonce'] && !this.dpopNonce?.check(payload['nonce'])) {
100
- throw new UseDpopNonceError('DPoP nonce mismatch')
105
+ // > To reduce the likelihood of false negatives, servers SHOULD employ
106
+ // > syntax-based normalization (Section 6.2.2 of [RFC3986]) and
107
+ // > scheme-based normalization (Section 6.2.3 of [RFC3986]) before
108
+ // > comparing the htu claim.
109
+ //
110
+ // RFC9449 section 4.3. Checking DPoP Proofs - https://datatracker.ietf.org/doc/html/rfc9449#section-4.3
111
+ if (!htu || parseHtu(htu) !== normalizeHtuUrl(httpUrl)) {
112
+ throw newInvalidDpopProofError('DPoP "htu" mismatch')
101
113
  }
102
114
 
103
- const htuNorm = normalizeHtu(htu)
104
- if (!htuNorm) {
105
- throw new TypeError('Invalid "htu" argument')
115
+ if (!nonce && this.dpopNonce) {
116
+ throw new UseDpopNonceError()
106
117
  }
107
118
 
108
- if (htuNorm !== normalizeHtu(payload['htu'])) {
109
- throw new InvalidDpopProofError('DPoP htu mismatch')
119
+ if (nonce && !this.dpopNonce?.check(nonce)) {
120
+ throw new UseDpopNonceError('DPoP "nonce" mismatch')
110
121
  }
111
122
 
112
123
  if (accessToken) {
113
- const athBuffer = createHash('sha256').update(accessToken).digest()
114
- if (payload['ath'] !== athBuffer.toString('base64url')) {
115
- throw new InvalidDpopProofError('DPoP ath mismatch')
124
+ const accessTokenHash = createHash('sha256').update(accessToken).digest()
125
+ if (ath !== accessTokenHash.toString('base64url')) {
126
+ throw newInvalidDpopProofError('DPoP "ath" mismatch')
116
127
  }
117
- } else if (payload['ath']) {
118
- throw new InvalidDpopProofError('DPoP ath not allowed')
128
+ } else if (ath !== undefined) {
129
+ throw newInvalidDpopProofError('DPoP "ath" claim not allowed')
119
130
  }
120
131
 
121
- try {
122
- return {
123
- protectedHeader,
124
- payload,
125
- jkt: await calculateJwkThumbprint(protectedHeader['jwk']!, 'sha256'), // EmbeddedJWK
126
- }
127
- } catch (err) {
128
- const message =
129
- err instanceof JOSEError ? err.message : 'Failed to calculate jkt'
130
- throw new InvalidDpopProofError(message, err)
131
- }
132
+ // @NOTE we can assert there is a jwk because the jwtVerify used the
133
+ // EmbeddedJWK key getter mechanism.
134
+ const jwk = protectedHeader.jwk!
135
+ const jkt = await calculateJwkThumbprint(jwk, 'sha256').catch((err) => {
136
+ throw newInvalidDpopProofError('Failed to calculate jkt', err)
137
+ })
138
+
139
+ return { jti, jkt, htm, htu }
140
+ }
141
+ }
142
+
143
+ function extractProof(
144
+ httpHeaders: Record<string, undefined | string | string[]>,
145
+ ): string | null {
146
+ const dpopHeader = httpHeaders['dpop']
147
+ switch (typeof dpopHeader) {
148
+ case 'string':
149
+ if (dpopHeader) return dpopHeader
150
+ throw newInvalidDpopProofError('DPoP header cannot be empty')
151
+ case 'object':
152
+ // @NOTE the "0" case should never happen a node.js HTTP server will only
153
+ // return an array if the header is set multiple times.
154
+ if (dpopHeader.length === 1 && dpopHeader[0]) return dpopHeader[0]!
155
+ throw newInvalidDpopProofError('DPoP header must contain a single proof')
156
+ default:
157
+ return null
132
158
  }
133
159
  }
134
160
 
135
161
  /**
136
- * @note
137
- * > The htu claim matches the HTTP URI value for the HTTP request in which the
138
- * > JWT was received, ignoring any query and fragment parts.
162
+ * Constructs the HTTP URI (htu) claim as defined in RFC9449.
163
+ *
164
+ * The htu claim is the normalized URL of the HTTP request, excluding the query
165
+ * string and fragment. This function ensures that the URL is normalized by
166
+ * removing the search and hash components, as well as by using an URL object to
167
+ * simplify the pathname (e.g. removing dot segments).
139
168
  *
140
- * > To reduce the likelihood of false negatives, servers SHOULD employ
141
- * > syntax-based normalization (Section 6.2.2 of [RFC3986]) and scheme-based
142
- * > normalization (Section 6.2.3 of [RFC3986]) before comparing the htu claim.
143
- * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3 | RFC9449 section 4.3. Checking DPoP Proofs}
169
+ * @returns The normalized URL as a string.
170
+ * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3}
144
171
  */
145
- function normalizeHtu(htu: unknown): string | null {
146
- // Optimization
147
- if (!htu) return null
148
-
149
- try {
150
- const url = new URL(String(htu))
151
- url.hash = ''
152
- url.search = ''
153
- return url.href
154
- } catch {
155
- return null
172
+ function normalizeHtuUrl(url: Readonly<URL>): string {
173
+ // NodeJS's `URL` normalizes the pathname, so we can just use that.
174
+ return url.origin + url.pathname
175
+ }
176
+
177
+ function parseHtu(htu: string): string {
178
+ const url = ifURL(htu)
179
+ if (!url) {
180
+ throw newInvalidDpopProofError('DPoP "htu" is not a valid URL')
181
+ }
182
+
183
+ // @NOTE the checks bellow can be removed once once jwtPayloadSchema is used
184
+ // to validate the DPoP proof payload as it already performs these checks
185
+ // (though the htuSchema).
186
+
187
+ if (url.password || url.username) {
188
+ throw newInvalidDpopProofError('DPoP "htu" must not contain credentials')
156
189
  }
190
+
191
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
192
+ throw newInvalidDpopProofError('DPoP "htu" must be http or https')
193
+ }
194
+
195
+ // @NOTE For legacy & backwards compatibility reason, we allow a query and
196
+ // fragment in the DPoP proof's htu. This is not a standard behavior as the
197
+ // htu is not supposed to contain query or fragment.
198
+
199
+ // NodeJS's `URL` normalizes the pathname.
200
+ return normalizeHtuUrl(url)
201
+ }
202
+
203
+ function newInvalidDpopProofError(
204
+ title: string,
205
+ err?: unknown,
206
+ ): InvalidDpopProofError {
207
+ const msg =
208
+ err instanceof JOSEError || err instanceof ValidationError
209
+ ? `${title}: ${err.message}`
210
+ : title
211
+ return new InvalidDpopProofError(msg, err)
157
212
  }
@@ -0,0 +1,6 @@
1
+ export type DpopProof = {
2
+ jti: string
3
+ jkt: string
4
+ htm: string
5
+ htu: string
6
+ }
@@ -11,8 +11,8 @@ export const authorizationHeaderSchema = z.tuple([
11
11
  oauthAccessTokenSchema,
12
12
  ])
13
13
 
14
- export const parseAuthorizationHeader = (header?: string) => {
15
- if (header == null) {
14
+ export const parseAuthorizationHeader = (header: unknown) => {
15
+ if (typeof header !== 'string') {
16
16
  throw new WWWAuthenticateError(
17
17
  'invalid_request',
18
18
  'Authorization header required',
@@ -2,3 +2,17 @@ export function asArray<T>(value: T | T[]): T[] {
2
2
  if (value == null) return []
3
3
  return Array.isArray(value) ? value : [value]
4
4
  }
5
+
6
+ export function asURL(value: string | { toString: () => string }): URL {
7
+ return new URL(value)
8
+ }
9
+
10
+ export function ifURL(
11
+ value: string | { toString: () => string },
12
+ ): URL | undefined {
13
+ try {
14
+ return asURL(value)
15
+ } catch {
16
+ return undefined
17
+ }
18
+ }
@@ -69,7 +69,11 @@ import { LocalizedString, MultiLangString } from './lib/util/locale.js'
69
69
  import { extractZodErrorMessage } from './lib/util/zod-error.js'
70
70
  import { CustomMetadata, buildMetadata } from './metadata/build-metadata.js'
71
71
  import { OAuthHooks } from './oauth-hooks.js'
72
- import { OAuthVerifier, OAuthVerifierOptions } from './oauth-verifier.js'
72
+ import {
73
+ DpopProof,
74
+ OAuthVerifier,
75
+ OAuthVerifierOptions,
76
+ } from './oauth-verifier.js'
73
77
  import { ReplayStore, ifReplayStore } from './replay/replay-store.js'
74
78
  import { codeSchema } from './request/code.js'
75
79
  import { RequestInfo } from './request/request-info.js'
@@ -458,7 +462,7 @@ export class OAuthProvider extends OAuthVerifier {
458
462
  public async pushedAuthorizationRequest(
459
463
  credentials: OAuthClientCredentials,
460
464
  authorizationRequest: OAuthAuthorizationRequestPar,
461
- dpopJkt: null | string,
465
+ dpopProof: null | DpopProof,
462
466
  ): Promise<OAuthParResponse> {
463
467
  try {
464
468
  const [client, clientAuth] = await this.authenticateClient(credentials)
@@ -474,7 +478,7 @@ export class OAuthProvider extends OAuthVerifier {
474
478
  clientAuth,
475
479
  parameters,
476
480
  null,
477
- dpopJkt,
481
+ dpopProof,
478
482
  )
479
483
 
480
484
  return {
@@ -717,7 +721,7 @@ export class OAuthProvider extends OAuthVerifier {
717
721
  clientCredentials: OAuthClientCredentials,
718
722
  clientMetadata: RequestMetadata,
719
723
  request: OAuthTokenRequest,
720
- dpopJkt: null | string,
724
+ dpopProof: null | DpopProof,
721
725
  ): Promise<OAuthTokenResponse> {
722
726
  const [client, clientAuth] =
723
727
  await this.authenticateClient(clientCredentials)
@@ -740,7 +744,7 @@ export class OAuthProvider extends OAuthVerifier {
740
744
  clientAuth,
741
745
  clientMetadata,
742
746
  request,
743
- dpopJkt,
747
+ dpopProof,
744
748
  )
745
749
  }
746
750
 
@@ -750,7 +754,7 @@ export class OAuthProvider extends OAuthVerifier {
750
754
  clientAuth,
751
755
  clientMetadata,
752
756
  request,
753
- dpopJkt,
757
+ dpopProof,
754
758
  )
755
759
  }
756
760
 
@@ -764,7 +768,7 @@ export class OAuthProvider extends OAuthVerifier {
764
768
  clientAuth: ClientAuth,
765
769
  clientMetadata: RequestMetadata,
766
770
  input: OAuthAuthorizationCodeGrantTokenRequest,
767
- dpopJkt: null | string,
771
+ dpopProof: null | DpopProof,
768
772
  ): Promise<OAuthTokenResponse> {
769
773
  const code = codeSchema.parse(input.code)
770
774
  try {
@@ -807,7 +811,7 @@ export class OAuthProvider extends OAuthVerifier {
807
811
  deviceId,
808
812
  parameters,
809
813
  input,
810
- dpopJkt,
814
+ dpopProof,
811
815
  )
812
816
  } catch (err) {
813
817
  // If a token is replayed, requestManager.findCode will throw. In that
@@ -835,14 +839,14 @@ export class OAuthProvider extends OAuthVerifier {
835
839
  clientAuth: ClientAuth,
836
840
  clientMetadata: RequestMetadata,
837
841
  input: OAuthRefreshTokenGrantTokenRequest,
838
- dpopJkt: null | string,
842
+ dpopProof: null | DpopProof,
839
843
  ): Promise<OAuthTokenResponse> {
840
844
  return this.tokenManager.refresh(
841
845
  client,
842
846
  clientAuth,
843
847
  clientMetadata,
844
848
  input,
845
- dpopJkt,
849
+ dpopProof,
846
850
  )
847
851
  }
848
852
 
@@ -874,24 +878,24 @@ export class OAuthProvider extends OAuthVerifier {
874
878
  protected override async verifyToken(
875
879
  tokenType: OAuthTokenType,
876
880
  token: OAuthAccessToken,
877
- dpopJkt: string | null,
881
+ dpopProof: null | DpopProof,
878
882
  verifyOptions?: VerifyTokenClaimsOptions,
879
883
  ): Promise<VerifyTokenClaimsResult> {
880
884
  if (this.accessTokenMode === AccessTokenMode.stateless) {
881
- return super.verifyToken(tokenType, token, dpopJkt, verifyOptions)
885
+ return super.verifyToken(tokenType, token, dpopProof, verifyOptions)
882
886
  }
883
887
 
884
888
  if (this.accessTokenMode === AccessTokenMode.light) {
885
- const { claims } = await super.verifyToken(
889
+ const { tokenClaims } = await super.verifyToken(
886
890
  tokenType,
887
891
  token,
888
- dpopJkt,
892
+ dpopProof,
889
893
  // Do not verify the scope and audience in case of "light" tokens.
890
894
  // these will be checked through the tokenManager hereafter.
891
895
  undefined,
892
896
  )
893
897
 
894
- const tokenId = claims.jti
898
+ const tokenId = tokenClaims.jti
895
899
 
896
900
  // In addition to verifying the signature (through the verifier above), we
897
901
  // also verify the tokenId is still valid using a database to fetch
@@ -900,7 +904,7 @@ export class OAuthProvider extends OAuthVerifier {
900
904
  token,
901
905
  tokenType,
902
906
  tokenId,
903
- dpopJkt,
907
+ dpopProof,
904
908
  verifyOptions,
905
909
  )
906
910
  }
@@ -8,6 +8,7 @@ import {
8
8
  } from '@atproto/oauth-types'
9
9
  import { DpopManager, DpopManagerOptions } from './dpop/dpop-manager.js'
10
10
  import { DpopNonce } from './dpop/dpop-nonce.js'
11
+ import { DpopProof } from './dpop/dpop-proof.js'
11
12
  import { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js'
12
13
  import { InvalidTokenError } from './errors/invalid-token-error.js'
13
14
  import { UseDpopNonceError } from './errors/use-dpop-nonce-error.js'
@@ -50,7 +51,7 @@ export type OAuthVerifierOptions = Override<
50
51
  >
51
52
 
52
53
  export { DpopNonce, Key, Keyset }
53
- export type { RedisOptions, ReplayStore, VerifyTokenClaimsOptions }
54
+ export type { DpopProof, RedisOptions, ReplayStore, VerifyTokenClaimsOptions }
54
55
 
55
56
  export class OAuthVerifier {
56
57
  public readonly issuer: OAuthIssuerIdentifier
@@ -95,30 +96,30 @@ export class OAuthVerifier {
95
96
  }
96
97
 
97
98
  public async checkDpopProof(
98
- proof: unknown,
99
- htm: string,
100
- htu: string | URL,
99
+ httpMethod: string,
100
+ httpUrl: Readonly<URL>,
101
+ httpHeaders: Record<string, undefined | string | string[]>,
101
102
  accessToken?: string,
102
- ): Promise<string | null> {
103
- if (proof === undefined) return null
104
-
105
- const { payload, jkt } = await this.dpopManager.checkProof(
106
- proof,
107
- htm,
108
- htu,
103
+ ): Promise<null | DpopProof> {
104
+ const dpopProof = await this.dpopManager.checkProof(
105
+ httpMethod,
106
+ httpUrl,
107
+ httpHeaders,
109
108
  accessToken,
110
109
  )
111
110
 
112
- const unique = await this.replayManager.uniqueDpop(payload.jti)
113
- if (!unique) throw new InvalidDpopProofError('DPoP proof jti is not unique')
111
+ if (dpopProof) {
112
+ const unique = await this.replayManager.uniqueDpop(dpopProof.jti)
113
+ if (!unique) throw new InvalidDpopProofError('DPoP proof replayed')
114
+ }
114
115
 
115
- return jkt
116
+ return dpopProof
116
117
  }
117
118
 
118
119
  protected async verifyToken(
119
120
  tokenType: OAuthTokenType,
120
121
  token: OAuthAccessToken,
121
- dpopJkt: string | null,
122
+ dpopProof: null | DpopProof,
122
123
  verifyOptions?: VerifyTokenClaimsOptions,
123
124
  ): Promise<VerifyTokenClaimsResult> {
124
125
  if (!isSignedJwt(token)) {
@@ -135,35 +136,37 @@ export class OAuthVerifier {
135
136
  token,
136
137
  payload.jti,
137
138
  tokenType,
138
- dpopJkt,
139
139
  payload,
140
+ dpopProof,
140
141
  verifyOptions,
141
142
  )
142
143
  }
143
144
 
144
145
  public async authenticateRequest(
145
- method: string,
146
- url: URL,
147
- headers: {
148
- authorization?: string
149
- dpop?: unknown
150
- },
146
+ httpMethod: string,
147
+ httpUrl: Readonly<URL>,
148
+ httpHeaders: Record<string, undefined | string | string[]>,
151
149
  verifyOptions?: VerifyTokenClaimsOptions,
152
- ) {
153
- const [tokenType, token] = parseAuthorizationHeader(headers.authorization)
150
+ ): Promise<VerifyTokenClaimsResult> {
151
+ const [tokenType, token] = parseAuthorizationHeader(
152
+ httpHeaders['authorization'],
153
+ )
154
154
  try {
155
- const dpopJkt = await this.checkDpopProof(
156
- headers.dpop,
157
- method,
158
- url,
155
+ const dpopProof = await this.checkDpopProof(
156
+ httpMethod,
157
+ httpUrl,
158
+ httpHeaders,
159
159
  token,
160
160
  )
161
161
 
162
- if (tokenType === 'DPoP' && !dpopJkt) {
163
- throw new InvalidDpopProofError(`DPoP proof required`)
164
- }
162
+ const tokenResult = await this.verifyToken(
163
+ tokenType,
164
+ token,
165
+ dpopProof,
166
+ verifyOptions,
167
+ )
165
168
 
166
- return await this.verifyToken(tokenType, token, dpopJkt, verifyOptions)
169
+ return tokenResult
167
170
  } catch (err) {
168
171
  if (err instanceof UseDpopNonceError) throw err.toWwwAuthenticateError()
169
172
  if (err instanceof WWWAuthenticateError) throw err