@atproto/oauth-provider 0.7.9 → 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.
- package/CHANGELOG.md +25 -0
- package/dist/customization/branding.d.ts +7 -7
- package/dist/customization/customization.d.ts +10 -10
- package/dist/customization/links.d.ts +4 -4
- package/dist/dpop/dpop-manager.d.ts +2 -10
- package/dist/dpop/dpop-manager.d.ts.map +1 -1
- package/dist/dpop/dpop-manager.js +107 -65
- package/dist/dpop/dpop-manager.js.map +1 -1
- package/dist/dpop/dpop-proof.d.ts +7 -0
- package/dist/dpop/dpop-proof.d.ts.map +1 -0
- package/dist/dpop/dpop-proof.js +3 -0
- package/dist/dpop/dpop-proof.js.map +1 -0
- package/dist/lib/hcaptcha.d.ts +3 -3
- package/dist/lib/util/authorization-header.d.ts +1 -1
- package/dist/lib/util/authorization-header.d.ts.map +1 -1
- package/dist/lib/util/authorization-header.js +1 -1
- package/dist/lib/util/authorization-header.js.map +1 -1
- package/dist/lib/util/cast.d.ts +6 -0
- package/dist/lib/util/cast.d.ts.map +1 -1
- package/dist/lib/util/cast.js +13 -0
- package/dist/lib/util/cast.js.map +1 -1
- package/dist/oauth-provider.d.ts +6 -6
- package/dist/oauth-provider.d.ts.map +1 -1
- package/dist/oauth-provider.js +14 -14
- package/dist/oauth-provider.js.map +1 -1
- package/dist/oauth-verifier.d.ts +5 -7
- package/dist/oauth-verifier.d.ts.map +1 -1
- package/dist/oauth-verifier.js +15 -17
- package/dist/oauth-verifier.js.map +1 -1
- package/dist/request/request-manager.d.ts +3 -2
- package/dist/request/request-manager.d.ts.map +1 -1
- package/dist/request/request-manager.js +12 -7
- package/dist/request/request-manager.js.map +1 -1
- package/dist/router/create-oauth-middleware.js +4 -4
- package/dist/router/create-oauth-middleware.js.map +1 -1
- package/dist/signer/api-token-payload.d.ts +3 -3
- package/dist/signer/api-token-payload.d.ts.map +1 -1
- package/dist/signer/signed-token-payload.d.ts +3 -3
- package/dist/signer/signed-token-payload.d.ts.map +1 -1
- package/dist/token/token-manager.d.ts +4 -3
- package/dist/token/token-manager.d.ts.map +1 -1
- package/dist/token/token-manager.js +14 -11
- package/dist/token/token-manager.js.map +1 -1
- package/dist/token/verify-token-claims.d.ts +4 -2
- package/dist/token/verify-token-claims.d.ts.map +1 -1
- package/dist/token/verify-token-claims.js +29 -14
- package/dist/token/verify-token-claims.js.map +1 -1
- package/package.json +7 -7
- package/src/dpop/dpop-manager.ts +129 -74
- package/src/dpop/dpop-proof.ts +6 -0
- package/src/lib/util/authorization-header.ts +2 -2
- package/src/lib/util/cast.ts +14 -0
- package/src/oauth-provider.ts +20 -16
- package/src/oauth-verifier.ts +35 -32
- package/src/request/request-manager.ts +11 -9
- package/src/router/create-oauth-middleware.ts +6 -6
- package/src/token/token-manager.ts +14 -11
- package/src/token/verify-token-claims.ts +46 -17
- 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
|
-
|
8
|
+
const BEARER = 'Bearer';
|
9
|
+
const DPOP = 'DPoP';
|
10
|
+
function verifyTokenClaims(token, tokenId, tokenType, tokenClaims, dpopProof, options) {
|
9
11
|
const dateReference = Date.now();
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
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
|
-
|
19
|
-
|
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)(
|
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 =
|
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 (
|
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,
|
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":";;
|
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.
|
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": [
|
@@ -48,12 +48,12 @@
|
|
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.
|
52
|
-
"@atproto/jwk-jose": "0.1.
|
53
|
-
"@atproto/oauth-types": "0.2.
|
54
|
-
"@atproto/oauth-provider-api": "0.1.
|
55
|
-
"@atproto/oauth-provider-frontend": "0.1.
|
56
|
-
"@atproto/oauth-provider-ui": "0.1.
|
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": {
|
package/src/dpop/dpop-manager.ts
CHANGED
@@ -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
|
-
|
51
|
-
|
52
|
-
|
53
|
-
accessToken?: string,
|
54
|
-
) {
|
55
|
-
|
56
|
-
|
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
|
-
|
60
|
-
|
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
|
-
|
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
|
-
|
80
|
-
|
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
|
-
|
84
|
-
|
85
|
-
throw new InvalidDpopProofError('DPoP htm mismatch')
|
92
|
+
if (!jti || typeof jti !== 'string') {
|
93
|
+
throw newInvalidDpopProofError('DPoP "jti" missing')
|
86
94
|
}
|
87
95
|
|
88
|
-
|
89
|
-
|
90
|
-
|
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 (!
|
96
|
-
throw
|
101
|
+
if (!htu || typeof htu !== 'string') {
|
102
|
+
throw newInvalidDpopProofError('Invalid DPoP "htu" type')
|
97
103
|
}
|
98
104
|
|
99
|
-
|
100
|
-
|
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
|
-
|
104
|
-
|
105
|
-
throw new TypeError('Invalid "htu" argument')
|
115
|
+
if (!nonce && this.dpopNonce) {
|
116
|
+
throw new UseDpopNonceError()
|
106
117
|
}
|
107
118
|
|
108
|
-
if (
|
109
|
-
throw new
|
119
|
+
if (nonce && !this.dpopNonce?.check(nonce)) {
|
120
|
+
throw new UseDpopNonceError('DPoP "nonce" mismatch')
|
110
121
|
}
|
111
122
|
|
112
123
|
if (accessToken) {
|
113
|
-
const
|
114
|
-
if (
|
115
|
-
throw
|
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 (
|
118
|
-
throw
|
128
|
+
} else if (ath !== undefined) {
|
129
|
+
throw newInvalidDpopProofError('DPoP "ath" claim not allowed')
|
119
130
|
}
|
120
131
|
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
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
|
-
*
|
137
|
-
*
|
138
|
-
*
|
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
|
-
*
|
141
|
-
*
|
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
|
146
|
-
//
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
}
|
155
|
-
|
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
|
}
|
@@ -11,8 +11,8 @@ export const authorizationHeaderSchema = z.tuple([
|
|
11
11
|
oauthAccessTokenSchema,
|
12
12
|
])
|
13
13
|
|
14
|
-
export const parseAuthorizationHeader = (header
|
15
|
-
if (header
|
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',
|
package/src/lib/util/cast.ts
CHANGED
@@ -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
|
+
}
|
package/src/oauth-provider.ts
CHANGED
@@ -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 {
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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,
|
885
|
+
return super.verifyToken(tokenType, token, dpopProof, verifyOptions)
|
882
886
|
}
|
883
887
|
|
884
888
|
if (this.accessTokenMode === AccessTokenMode.light) {
|
885
|
-
const {
|
889
|
+
const { tokenClaims } = await super.verifyToken(
|
886
890
|
tokenType,
|
887
891
|
token,
|
888
|
-
|
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 =
|
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
|
-
|
907
|
+
dpopProof,
|
904
908
|
verifyOptions,
|
905
909
|
)
|
906
910
|
}
|
package/src/oauth-verifier.ts
CHANGED
@@ -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
|
-
|
99
|
-
|
100
|
-
|
99
|
+
httpMethod: string,
|
100
|
+
httpUrl: Readonly<URL>,
|
101
|
+
httpHeaders: Record<string, undefined | string | string[]>,
|
101
102
|
accessToken?: string,
|
102
|
-
): Promise<
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
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
|
-
|
113
|
-
|
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
|
116
|
+
return dpopProof
|
116
117
|
}
|
117
118
|
|
118
119
|
protected async verifyToken(
|
119
120
|
tokenType: OAuthTokenType,
|
120
121
|
token: OAuthAccessToken,
|
121
|
-
|
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
|
-
|
146
|
-
|
147
|
-
|
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(
|
150
|
+
): Promise<VerifyTokenClaimsResult> {
|
151
|
+
const [tokenType, token] = parseAuthorizationHeader(
|
152
|
+
httpHeaders['authorization'],
|
153
|
+
)
|
154
154
|
try {
|
155
|
-
const
|
156
|
-
|
157
|
-
|
158
|
-
|
155
|
+
const dpopProof = await this.checkDpopProof(
|
156
|
+
httpMethod,
|
157
|
+
httpUrl,
|
158
|
+
httpHeaders,
|
159
159
|
token,
|
160
160
|
)
|
161
161
|
|
162
|
-
|
163
|
-
|
164
|
-
|
162
|
+
const tokenResult = await this.verifyToken(
|
163
|
+
tokenType,
|
164
|
+
token,
|
165
|
+
dpopProof,
|
166
|
+
verifyOptions,
|
167
|
+
)
|
165
168
|
|
166
|
-
return
|
169
|
+
return tokenResult
|
167
170
|
} catch (err) {
|
168
171
|
if (err instanceof UseDpopNonceError) throw err.toWwwAuthenticateError()
|
169
172
|
if (err instanceof WWWAuthenticateError) throw err
|