@atproto/oauth-client 0.3.21 → 0.4.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/errors/auth-method-unsatisfiable-error.d.ts +3 -0
- package/dist/errors/auth-method-unsatisfiable-error.d.ts.map +1 -0
- package/dist/errors/auth-method-unsatisfiable-error.js +7 -0
- package/dist/errors/auth-method-unsatisfiable-error.js.map +1 -0
- package/dist/fetch-dpop.d.ts +1 -2
- package/dist/fetch-dpop.d.ts.map +1 -1
- package/dist/fetch-dpop.js +4 -5
- package/dist/fetch-dpop.js.map +1 -1
- package/dist/oauth-client-auth.d.ts +23 -0
- package/dist/oauth-client-auth.d.ts.map +1 -0
- package/dist/oauth-client-auth.js +131 -0
- package/dist/oauth-client-auth.js.map +1 -0
- package/dist/oauth-client.d.ts +4 -4
- package/dist/oauth-client.d.ts.map +1 -1
- package/dist/oauth-client.js +26 -13
- package/dist/oauth-client.js.map +1 -1
- package/dist/oauth-resolver.d.ts +1 -1
- package/dist/oauth-server-agent.d.ts +8 -6
- package/dist/oauth-server-agent.d.ts.map +1 -1
- package/dist/oauth-server-agent.js +19 -51
- package/dist/oauth-server-agent.js.map +1 -1
- package/dist/oauth-server-factory.d.ts +15 -2
- package/dist/oauth-server-factory.d.ts.map +1 -1
- package/dist/oauth-server-factory.js +23 -4
- package/dist/oauth-server-factory.js.map +1 -1
- package/dist/oauth-session.d.ts.map +1 -1
- package/dist/oauth-session.js +0 -1
- package/dist/oauth-session.js.map +1 -1
- package/dist/session-getter.d.ts +5 -0
- package/dist/session-getter.d.ts.map +1 -1
- package/dist/session-getter.js +24 -11
- package/dist/session-getter.js.map +1 -1
- package/dist/state-store.d.ts +3 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/types.d.ts +8 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-client-metadata.d.ts.map +1 -1
- package/dist/validate-client-metadata.js +32 -26
- package/dist/validate-client-metadata.js.map +1 -1
- package/package.json +4 -4
- package/src/errors/auth-method-unsatisfiable-error.ts +1 -0
- package/src/fetch-dpop.ts +2 -6
- package/src/oauth-client-auth.ts +182 -0
- package/src/oauth-client.ts +50 -12
- package/src/oauth-server-agent.ts +19 -72
- package/src/oauth-server-factory.ts +37 -2
- package/src/oauth-session.ts +0 -1
- package/src/session-getter.ts +43 -10
- package/src/state-store.ts +3 -0
- package/src/validate-client-metadata.ts +40 -27
- package/tsconfig.build.tsbuildinfo +1 -1
@@ -2,23 +2,9 @@
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
3
3
|
exports.validateClientMetadata = validateClientMetadata;
|
4
4
|
const oauth_types_1 = require("@atproto/oauth-types");
|
5
|
+
const constants_js_1 = require("./constants.js");
|
5
6
|
const types_js_1 = require("./types.js");
|
6
|
-
const TOKEN_ENDPOINT_AUTH_METHOD = `token_endpoint_auth_method`;
|
7
|
-
const TOKEN_ENDPOINT_AUTH_SIGNING_ALG = `token_endpoint_auth_signing_alg`;
|
8
7
|
function validateClientMetadata(input, keyset) {
|
9
|
-
if (input.jwks) {
|
10
|
-
if (!keyset) {
|
11
|
-
throw new TypeError(`Keyset must not be provided when jwks is provided`);
|
12
|
-
}
|
13
|
-
for (const key of input.jwks.keys) {
|
14
|
-
if (!key.kid) {
|
15
|
-
throw new TypeError(`Key must have a "kid" property`);
|
16
|
-
}
|
17
|
-
else if (!keyset.has(key.kid)) {
|
18
|
-
throw new TypeError(`Key with kid "${key.kid}" not found in keyset`);
|
19
|
-
}
|
20
|
-
}
|
21
|
-
}
|
22
8
|
// Allow to pass a keyset and omit the jwks/jwks_uri properties
|
23
9
|
if (!input.jwks && !input.jwks_uri && keyset?.size) {
|
24
10
|
input = { ...input, jwks: keyset.toJSON() };
|
@@ -41,25 +27,45 @@ function validateClientMetadata(input, keyset) {
|
|
41
27
|
if (!metadata.grant_types.includes('authorization_code')) {
|
42
28
|
throw new TypeError(`"grant_types" must include "authorization_code"`);
|
43
29
|
}
|
44
|
-
const method = metadata
|
30
|
+
const method = metadata.token_endpoint_auth_method;
|
31
|
+
const methodAlg = metadata.token_endpoint_auth_signing_alg;
|
45
32
|
switch (method) {
|
46
|
-
case undefined:
|
47
|
-
throw new TypeError(`${TOKEN_ENDPOINT_AUTH_METHOD} must be provided`);
|
48
33
|
case 'none':
|
49
|
-
if (
|
50
|
-
throw new TypeError(
|
34
|
+
if (methodAlg) {
|
35
|
+
throw new TypeError(`"token_endpoint_auth_signing_alg" must not be provided when "token_endpoint_auth_method" is "${method}"`);
|
51
36
|
}
|
52
37
|
break;
|
53
|
-
case 'private_key_jwt':
|
54
|
-
if (!
|
55
|
-
throw new TypeError(`
|
38
|
+
case 'private_key_jwt': {
|
39
|
+
if (!methodAlg) {
|
40
|
+
throw new TypeError(`"token_endpoint_auth_signing_alg" must be provided when "token_endpoint_auth_method" is "${method}"`);
|
56
41
|
}
|
57
|
-
|
58
|
-
|
42
|
+
const signingKeys = keyset
|
43
|
+
? Array.from(keyset.list({ use: 'sig' })).filter((key) => key.isPrivate && key.kid)
|
44
|
+
: null;
|
45
|
+
if (!signingKeys?.some((key) => key.algorithms.includes(constants_js_1.FALLBACK_ALG))) {
|
46
|
+
throw new TypeError(`Client authentication method "${method}" requires at least one "${constants_js_1.FALLBACK_ALG}" signing key with a "kid" property`);
|
47
|
+
}
|
48
|
+
if (metadata.jwks) {
|
49
|
+
// Ensure that all the signing keys that could end-up being used are
|
50
|
+
// advertised in the JWKS.
|
51
|
+
for (const key of signingKeys) {
|
52
|
+
if (!metadata.jwks.keys.some((k) => k.kid === key.kid)) {
|
53
|
+
throw new TypeError(`Key with kid "${key.kid}" not found in jwks`);
|
54
|
+
}
|
55
|
+
}
|
56
|
+
}
|
57
|
+
else if (metadata.jwks_uri) {
|
58
|
+
// @NOTE we only ensure that all the signing keys are referenced in JWKS
|
59
|
+
// when it is available (see previous "if") as we don't want to download
|
60
|
+
// that file here (for efficiency reasons).
|
61
|
+
}
|
62
|
+
else {
|
63
|
+
throw new TypeError(`Client authentication method "${method}" requires a JWKS`);
|
59
64
|
}
|
60
65
|
break;
|
66
|
+
}
|
61
67
|
default:
|
62
|
-
throw new TypeError(`
|
68
|
+
throw new TypeError(`Unsupported "token_endpoint_auth_method" value: ${method}`);
|
63
69
|
}
|
64
70
|
return metadata;
|
65
71
|
}
|
@@ -1 +1 @@
|
|
1
|
-
{"version":3,"file":"validate-client-metadata.js","sourceRoot":"","sources":["../src/validate-client-metadata.ts"],"names":[],"mappings":";;
|
1
|
+
{"version":3,"file":"validate-client-metadata.js","sourceRoot":"","sources":["../src/validate-client-metadata.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@atproto/oauth-client",
|
3
|
-
"version": "0.
|
3
|
+
"version": "0.4.0",
|
4
4
|
"license": "MIT",
|
5
5
|
"description": "OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).",
|
6
6
|
"keywords": [
|
@@ -30,12 +30,12 @@
|
|
30
30
|
"@atproto-labs/did-resolver": "0.1.13",
|
31
31
|
"@atproto-labs/fetch": "0.2.3",
|
32
32
|
"@atproto-labs/handle-resolver": "0.1.8",
|
33
|
-
"@atproto-labs/identity-resolver": "0.1.
|
33
|
+
"@atproto-labs/identity-resolver": "0.1.18",
|
34
34
|
"@atproto-labs/simple-store": "0.2.0",
|
35
35
|
"@atproto-labs/simple-store-memory": "0.1.3",
|
36
36
|
"@atproto/did": "0.1.5",
|
37
|
-
"@atproto/jwk": "0.
|
38
|
-
"@atproto/oauth-types": "0.
|
37
|
+
"@atproto/jwk": "0.3.0",
|
38
|
+
"@atproto/oauth-types": "0.3.0",
|
39
39
|
"@atproto/xrpc": "0.7.0"
|
40
40
|
},
|
41
41
|
"devDependencies": {
|
@@ -0,0 +1 @@
|
|
1
|
+
export class AuthMethodUnsatisfiableError extends Error {}
|
package/src/fetch-dpop.ts
CHANGED
@@ -12,7 +12,6 @@ const ReadableStream = globalThis.ReadableStream as
|
|
12
12
|
|
13
13
|
export type DpopFetchWrapperOptions<C = FetchContext> = {
|
14
14
|
key: Key
|
15
|
-
iss: string
|
16
15
|
nonces: SimpleStore<string, string>
|
17
16
|
supportedAlgs?: string[]
|
18
17
|
sha256?: (input: string) => Promise<string>
|
@@ -30,7 +29,6 @@ export type DpopFetchWrapperOptions<C = FetchContext> = {
|
|
30
29
|
|
31
30
|
export function dpopFetchWrapper<C = FetchContext>({
|
32
31
|
key,
|
33
|
-
iss,
|
34
32
|
// @TODO we should provide a default based on specs
|
35
33
|
supportedAlgs,
|
36
34
|
nonces,
|
@@ -70,7 +68,7 @@ export function dpopFetchWrapper<C = FetchContext>({
|
|
70
68
|
// Ignore get errors, we will just not send a nonce
|
71
69
|
}
|
72
70
|
|
73
|
-
const initProof = await buildProof(key, alg,
|
71
|
+
const initProof = await buildProof(key, alg, htm, htu, initNonce, ath)
|
74
72
|
request.headers.set('DPoP', initProof)
|
75
73
|
|
76
74
|
const initResponse = await fetch.call(this, request)
|
@@ -118,7 +116,7 @@ export function dpopFetchWrapper<C = FetchContext>({
|
|
118
116
|
// The initial response body must be consumed (see cancelBody's doc).
|
119
117
|
await cancelBody(initResponse, 'log')
|
120
118
|
|
121
|
-
const nextProof = await buildProof(key, alg,
|
119
|
+
const nextProof = await buildProof(key, alg, htm, htu, nextNonce, ath)
|
122
120
|
const nextRequest = new Request(input, init)
|
123
121
|
nextRequest.headers.set('DPoP', nextProof)
|
124
122
|
|
@@ -163,7 +161,6 @@ function buildHtu(url: string): string {
|
|
163
161
|
async function buildProof(
|
164
162
|
key: Key,
|
165
163
|
alg: string,
|
166
|
-
iss: string,
|
167
164
|
htm: string,
|
168
165
|
htu: string,
|
169
166
|
nonce?: string,
|
@@ -184,7 +181,6 @@ async function buildProof(
|
|
184
181
|
jwk,
|
185
182
|
},
|
186
183
|
{
|
187
|
-
iss,
|
188
184
|
iat: now,
|
189
185
|
// Any collision will cause the request to be rejected by the server. no biggie.
|
190
186
|
jti: Math.random().toString(36).slice(2),
|
@@ -0,0 +1,182 @@
|
|
1
|
+
import { Keyset } from '@atproto/jwk'
|
2
|
+
import {
|
3
|
+
CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
4
|
+
OAuthAuthorizationServerMetadata,
|
5
|
+
OAuthClientCredentials,
|
6
|
+
} from '@atproto/oauth-types'
|
7
|
+
import { FALLBACK_ALG } from './constants.js'
|
8
|
+
import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
|
9
|
+
import { Runtime } from './runtime.js'
|
10
|
+
import { ClientMetadata } from './types.js'
|
11
|
+
import { Awaitable } from './util.js'
|
12
|
+
|
13
|
+
export type ClientAuthMethod =
|
14
|
+
| { method: 'none' }
|
15
|
+
| { method: 'private_key_jwt'; kid: string }
|
16
|
+
|
17
|
+
export function negotiateClientAuthMethod(
|
18
|
+
serverMetadata: OAuthAuthorizationServerMetadata,
|
19
|
+
clientMetadata: ClientMetadata,
|
20
|
+
keyset?: Keyset,
|
21
|
+
): ClientAuthMethod {
|
22
|
+
const method = clientMetadata.token_endpoint_auth_method
|
23
|
+
|
24
|
+
// @NOTE ATproto spec requires that AS support both "none" and
|
25
|
+
// "private_key_jwt", and that clients use one of the other. The following
|
26
|
+
// check ensures that the AS is indeed compliant with this client's
|
27
|
+
// configuration.
|
28
|
+
const methods = supportedMethods(serverMetadata)
|
29
|
+
if (!methods.includes(method)) {
|
30
|
+
throw new Error(
|
31
|
+
`The server does not support "${method}" authentication. Supported methods are: ${methods.join(
|
32
|
+
', ',
|
33
|
+
)}.`,
|
34
|
+
)
|
35
|
+
}
|
36
|
+
|
37
|
+
if (method === 'private_key_jwt') {
|
38
|
+
// Invalid client configuration. This should not happen as
|
39
|
+
// "validateClientMetadata" already check this.
|
40
|
+
if (!keyset) throw new Error('A keyset is required for private_key_jwt')
|
41
|
+
|
42
|
+
const alg = supportedAlgs(serverMetadata)
|
43
|
+
|
44
|
+
// @NOTE we can't use `keyset.findPrivateKey` here because we can't enforce
|
45
|
+
// that the returned key contains a "kid". The following implementation is
|
46
|
+
// more robust against keysets containing keys without a "kid" property.
|
47
|
+
for (const key of keyset.list({ use: 'sig', alg })) {
|
48
|
+
// Return the first key from the key set that matches the server's
|
49
|
+
// supported algorithms.
|
50
|
+
if (key.isPrivate && key.kid) {
|
51
|
+
return { method: 'private_key_jwt', kid: key.kid }
|
52
|
+
}
|
53
|
+
}
|
54
|
+
|
55
|
+
throw new Error(
|
56
|
+
alg.includes(FALLBACK_ALG)
|
57
|
+
? `Client authentication method "${method}" requires at least one "${FALLBACK_ALG}" signing key with a "kid" property`
|
58
|
+
: // AS is not compliant with the ATproto OAuth spec.
|
59
|
+
`Authorization server requires "${method}" authentication method, but does not support "${FALLBACK_ALG}" algorithm.`,
|
60
|
+
)
|
61
|
+
}
|
62
|
+
|
63
|
+
if (method === 'none') {
|
64
|
+
return { method: 'none' }
|
65
|
+
}
|
66
|
+
|
67
|
+
throw new Error(
|
68
|
+
`The ATProto OAuth spec requires that client use either "none" or "private_key_jwt" authentication method.` +
|
69
|
+
(method === 'client_secret_basic'
|
70
|
+
? ' You might want to explicitly set "token_endpoint_auth_method" to one of those values in the client metadata document.'
|
71
|
+
: ` You set "${method}" which is not allowed.`),
|
72
|
+
)
|
73
|
+
}
|
74
|
+
|
75
|
+
export type ClientCredentialsFactory = () => Awaitable<{
|
76
|
+
headers?: Record<string, string>
|
77
|
+
payload?: OAuthClientCredentials
|
78
|
+
}>
|
79
|
+
|
80
|
+
/**
|
81
|
+
* @throws {AuthMethodUnsatisfiableError} if the authentication method is no
|
82
|
+
* long usable (either because the AS changed, of because the key is no longer
|
83
|
+
* available in the keyset).
|
84
|
+
*/
|
85
|
+
export function createClientCredentialsFactory(
|
86
|
+
authMethod: ClientAuthMethod,
|
87
|
+
serverMetadata: OAuthAuthorizationServerMetadata,
|
88
|
+
clientMetadata: ClientMetadata,
|
89
|
+
runtime: Runtime,
|
90
|
+
keyset?: Keyset,
|
91
|
+
): ClientCredentialsFactory {
|
92
|
+
// Ensure the AS still supports the auth method.
|
93
|
+
if (!supportedMethods(serverMetadata).includes(authMethod.method)) {
|
94
|
+
throw new AuthMethodUnsatisfiableError(
|
95
|
+
`Client authentication method "${authMethod.method}" no longer supported`,
|
96
|
+
)
|
97
|
+
}
|
98
|
+
|
99
|
+
if (authMethod.method === 'none') {
|
100
|
+
return () => ({
|
101
|
+
payload: {
|
102
|
+
client_id: clientMetadata.client_id,
|
103
|
+
},
|
104
|
+
})
|
105
|
+
}
|
106
|
+
|
107
|
+
if (authMethod.method === 'private_key_jwt') {
|
108
|
+
try {
|
109
|
+
// The client used to be a confidential client but no longer has a keyset.
|
110
|
+
if (!keyset) throw new Error('A keyset is required for private_key_jwt')
|
111
|
+
|
112
|
+
// @NOTE throws if no matching key can be found
|
113
|
+
const [key, alg] = keyset.findPrivateKey({
|
114
|
+
use: 'sig',
|
115
|
+
kid: authMethod.kid,
|
116
|
+
alg: supportedAlgs(serverMetadata),
|
117
|
+
})
|
118
|
+
|
119
|
+
// https://www.rfc-editor.org/rfc/rfc7523.html#section-3
|
120
|
+
return async () => ({
|
121
|
+
payload: {
|
122
|
+
client_id: clientMetadata.client_id,
|
123
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
124
|
+
client_assertion: await key.createJwt(
|
125
|
+
{ alg },
|
126
|
+
{
|
127
|
+
// > The JWT MUST contain an "iss" (issuer) claim that contains a
|
128
|
+
// > unique identifier for the entity that issued the JWT.
|
129
|
+
iss: clientMetadata.client_id,
|
130
|
+
// > For client authentication, the subject MUST be the
|
131
|
+
// > "client_id" of the OAuth client.
|
132
|
+
sub: clientMetadata.client_id,
|
133
|
+
// > The JWT MUST contain an "aud" (audience) claim containing a value
|
134
|
+
// > that identifies the authorization server as an intended audience.
|
135
|
+
// > The token endpoint URL of the authorization server MAY be used as a
|
136
|
+
// > value for an "aud" element to identify the authorization server as an
|
137
|
+
// > intended audience of the JWT.
|
138
|
+
aud: serverMetadata.issuer,
|
139
|
+
// > The JWT MAY contain a "jti" (JWT ID) claim that provides a
|
140
|
+
// > unique identifier for the token.
|
141
|
+
jti: await runtime.generateNonce(),
|
142
|
+
// > The JWT MAY contain an "iat" (issued at) claim that
|
143
|
+
// > identifies the time at which the JWT was issued.
|
144
|
+
iat: Math.floor(Date.now() / 1000),
|
145
|
+
// > The JWT MUST contain an "exp" (expiration time) claim that
|
146
|
+
// > limits the time window during which the JWT can be used.
|
147
|
+
exp: Math.floor(Date.now() / 1000) + 60, // 1 minute
|
148
|
+
},
|
149
|
+
),
|
150
|
+
},
|
151
|
+
})
|
152
|
+
} catch (cause) {
|
153
|
+
throw new AuthMethodUnsatisfiableError('Failed to load private key', {
|
154
|
+
cause,
|
155
|
+
})
|
156
|
+
}
|
157
|
+
}
|
158
|
+
|
159
|
+
throw new AuthMethodUnsatisfiableError(
|
160
|
+
// @ts-expect-error
|
161
|
+
`Unsupported auth method ${authMethod.method}`,
|
162
|
+
)
|
163
|
+
}
|
164
|
+
|
165
|
+
function supportedMethods(serverMetadata: OAuthAuthorizationServerMetadata) {
|
166
|
+
return serverMetadata['token_endpoint_auth_methods_supported']
|
167
|
+
}
|
168
|
+
|
169
|
+
function supportedAlgs(serverMetadata: OAuthAuthorizationServerMetadata) {
|
170
|
+
return (
|
171
|
+
serverMetadata['token_endpoint_auth_signing_alg_values_supported'] ?? [
|
172
|
+
// @NOTE If not specified, assume that the server supports the ES256
|
173
|
+
// algorithm, as prescribed by the spec:
|
174
|
+
//
|
175
|
+
// > Clients and Authorization Servers currently must support the ES256
|
176
|
+
// > cryptographic system [for client authentication].
|
177
|
+
//
|
178
|
+
// https://atproto.com/specs/oauth#confidential-client-authentication
|
179
|
+
FALLBACK_ALG,
|
180
|
+
]
|
181
|
+
)
|
182
|
+
}
|
package/src/oauth-client.ts
CHANGED
@@ -26,12 +26,14 @@ import {
|
|
26
26
|
import { IdentityResolver } from '@atproto-labs/identity-resolver'
|
27
27
|
import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
|
28
28
|
import { FALLBACK_ALG } from './constants.js'
|
29
|
+
import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
|
29
30
|
import { TokenRevokedError } from './errors/token-revoked-error.js'
|
30
31
|
import {
|
31
32
|
AuthorizationServerMetadataCache,
|
32
33
|
OAuthAuthorizationServerMetadataResolver,
|
33
34
|
} from './oauth-authorization-server-metadata-resolver.js'
|
34
35
|
import { OAuthCallbackError } from './oauth-callback-error.js'
|
36
|
+
import { negotiateClientAuthMethod } from './oauth-client-auth.js'
|
35
37
|
import {
|
36
38
|
OAuthProtectedResourceMetadataResolver,
|
37
39
|
ProtectedResourceMetadataCache,
|
@@ -290,11 +292,17 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
290
292
|
metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG],
|
291
293
|
)
|
292
294
|
|
295
|
+
const authMethod = negotiateClientAuthMethod(
|
296
|
+
metadata,
|
297
|
+
this.clientMetadata,
|
298
|
+
this.keyset,
|
299
|
+
)
|
293
300
|
const state = await this.runtime.generateNonce()
|
294
301
|
|
295
302
|
await this.stateStore.set(state, {
|
296
303
|
iss: metadata.issuer,
|
297
304
|
dpopKey,
|
305
|
+
authMethod,
|
298
306
|
verifier: pkce.verifier,
|
299
307
|
appState: options?.state,
|
300
308
|
})
|
@@ -307,9 +315,7 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
307
315
|
code_challenge: pkce.challenge,
|
308
316
|
code_challenge_method: pkce.method,
|
309
317
|
state,
|
310
|
-
login_hint: identity
|
311
|
-
? input // If input is a handle or a DID, use it as a login_hint
|
312
|
-
: undefined,
|
318
|
+
login_hint: identity?.handle ?? identity?.did,
|
313
319
|
response_mode: this.responseMode,
|
314
320
|
response_type: 'code' as const,
|
315
321
|
scope: options?.scope ?? this.clientMetadata.scope,
|
@@ -329,7 +335,11 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
329
335
|
}
|
330
336
|
|
331
337
|
if (metadata.pushed_authorization_request_endpoint) {
|
332
|
-
const server = await this.serverFactory.fromMetadata(
|
338
|
+
const server = await this.serverFactory.fromMetadata(
|
339
|
+
metadata,
|
340
|
+
authMethod,
|
341
|
+
dpopKey,
|
342
|
+
)
|
333
343
|
const parResponse = await server.request(
|
334
344
|
'pushed_authorization_request',
|
335
345
|
parameters,
|
@@ -425,6 +435,8 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
425
435
|
|
426
436
|
const server = await this.serverFactory.fromIssuer(
|
427
437
|
stateData.iss,
|
438
|
+
// Using the literal 'legacy' if the authMethod is not defined (because stateData was created through an old version of this lib)
|
439
|
+
stateData.authMethod ?? 'legacy',
|
428
440
|
stateData.dpopKey,
|
429
441
|
)
|
430
442
|
|
@@ -457,6 +469,7 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
457
469
|
try {
|
458
470
|
await this.sessionGetter.setStored(tokenSet.sub, {
|
459
471
|
dpopKey: stateData.dpopKey,
|
472
|
+
authMethod: server.authMethod,
|
460
473
|
tokenSet,
|
461
474
|
})
|
462
475
|
|
@@ -488,24 +501,45 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
488
501
|
// sub arg is lightly typed for convenience of library user
|
489
502
|
assertAtprotoDid(sub)
|
490
503
|
|
491
|
-
const {
|
504
|
+
const {
|
505
|
+
dpopKey,
|
506
|
+
authMethod = 'legacy',
|
507
|
+
tokenSet,
|
508
|
+
} = await this.sessionGetter.get(sub, {
|
492
509
|
noCache: refresh === true,
|
493
510
|
allowStale: refresh === false,
|
494
511
|
})
|
495
512
|
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
513
|
+
try {
|
514
|
+
const server = await this.serverFactory.fromIssuer(
|
515
|
+
tokenSet.iss,
|
516
|
+
authMethod,
|
517
|
+
dpopKey,
|
518
|
+
{
|
519
|
+
noCache: refresh === true,
|
520
|
+
allowStale: refresh === false,
|
521
|
+
},
|
522
|
+
)
|
523
|
+
|
524
|
+
return this.createSession(server, sub)
|
525
|
+
} catch (err) {
|
526
|
+
if (err instanceof AuthMethodUnsatisfiableError) {
|
527
|
+
await this.sessionGetter.delStored(sub, err)
|
528
|
+
}
|
500
529
|
|
501
|
-
|
530
|
+
throw err
|
531
|
+
}
|
502
532
|
}
|
503
533
|
|
504
534
|
async revoke(sub: string) {
|
505
535
|
// sub arg is lightly typed for convenience of library user
|
506
536
|
assertAtprotoDid(sub)
|
507
537
|
|
508
|
-
const {
|
538
|
+
const {
|
539
|
+
dpopKey,
|
540
|
+
authMethod = 'legacy',
|
541
|
+
tokenSet,
|
542
|
+
} = await this.sessionGetter.get(sub, {
|
509
543
|
allowStale: true,
|
510
544
|
})
|
511
545
|
|
@@ -513,7 +547,11 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
513
547
|
// the tokens to be deleted even if it was not possible to fetch the issuer
|
514
548
|
// data.
|
515
549
|
try {
|
516
|
-
const server = await this.serverFactory.fromIssuer(
|
550
|
+
const server = await this.serverFactory.fromIssuer(
|
551
|
+
tokenSet.iss,
|
552
|
+
authMethod,
|
553
|
+
dpopKey,
|
554
|
+
)
|
517
555
|
await server.revoke(tokenSet.access_token)
|
518
556
|
} finally {
|
519
557
|
await this.sessionGetter.delStored(sub, new TokenRevokedError(sub))
|
@@ -1,10 +1,8 @@
|
|
1
1
|
import { AtprotoDid } from '@atproto/did'
|
2
2
|
import { Key, Keyset } from '@atproto/jwk'
|
3
3
|
import {
|
4
|
-
CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
5
4
|
OAuthAuthorizationRequestPar,
|
6
5
|
OAuthAuthorizationServerMetadata,
|
7
|
-
OAuthClientCredentials,
|
8
6
|
OAuthEndpointName,
|
9
7
|
OAuthParResponse,
|
10
8
|
OAuthTokenRequest,
|
@@ -17,9 +15,13 @@ import {
|
|
17
15
|
AtprotoTokenResponse,
|
18
16
|
atprotoTokenResponseSchema,
|
19
17
|
} from './atproto-token-response.js'
|
20
|
-
import { FALLBACK_ALG } from './constants.js'
|
21
18
|
import { TokenRefreshError } from './errors/token-refresh-error.js'
|
22
19
|
import { dpopFetchWrapper } from './fetch-dpop.js'
|
20
|
+
import {
|
21
|
+
ClientAuthMethod,
|
22
|
+
ClientCredentialsFactory,
|
23
|
+
createClientCredentialsFactory,
|
24
|
+
} from './oauth-client-auth.js'
|
23
25
|
import { OAuthResolver } from './oauth-resolver.js'
|
24
26
|
import { OAuthResponseError } from './oauth-response-error.js'
|
25
27
|
import { Runtime } from './runtime.js'
|
@@ -43,8 +45,13 @@ export type DpopNonceCache = SimpleStore<string, string>
|
|
43
45
|
|
44
46
|
export class OAuthServerAgent {
|
45
47
|
protected dpopFetch: Fetch<unknown>
|
48
|
+
protected clientCredentialsFactory: ClientCredentialsFactory
|
46
49
|
|
50
|
+
/**
|
51
|
+
* @throws see {@link createClientCredentialsFactory}
|
52
|
+
*/
|
47
53
|
constructor(
|
54
|
+
readonly authMethod: ClientAuthMethod,
|
48
55
|
readonly dpopKey: Key,
|
49
56
|
readonly serverMetadata: OAuthAuthorizationServerMetadata,
|
50
57
|
readonly clientMetadata: ClientMetadata,
|
@@ -54,9 +61,16 @@ export class OAuthServerAgent {
|
|
54
61
|
readonly keyset?: Keyset,
|
55
62
|
fetch?: Fetch,
|
56
63
|
) {
|
64
|
+
this.clientCredentialsFactory = createClientCredentialsFactory(
|
65
|
+
authMethod,
|
66
|
+
serverMetadata,
|
67
|
+
clientMetadata,
|
68
|
+
runtime,
|
69
|
+
keyset,
|
70
|
+
)
|
71
|
+
|
57
72
|
this.dpopFetch = dpopFetchWrapper<void>({
|
58
73
|
fetch: bindFetch(fetch),
|
59
|
-
iss: clientMetadata.client_id,
|
60
74
|
key: dpopKey,
|
61
75
|
supportedAlgs: serverMetadata.dpop_signing_alg_values_supported,
|
62
76
|
sha256: async (v) => runtime.sha256(v),
|
@@ -205,7 +219,7 @@ export class OAuthServerAgent {
|
|
205
219
|
const url = this.serverMetadata[`${endpoint}_endpoint`]
|
206
220
|
if (!url) throw new Error(`No ${endpoint} endpoint available`)
|
207
221
|
|
208
|
-
const auth = await this.
|
222
|
+
const auth = await this.clientCredentialsFactory()
|
209
223
|
|
210
224
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-3.2.2
|
211
225
|
// https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
|
@@ -233,73 +247,6 @@ export class OAuthServerAgent {
|
|
233
247
|
throw new OAuthResponseError(response, json)
|
234
248
|
}
|
235
249
|
}
|
236
|
-
|
237
|
-
async buildClientAuth(endpoint: OAuthEndpointName): Promise<{
|
238
|
-
headers?: Record<string, string>
|
239
|
-
payload: OAuthClientCredentials
|
240
|
-
}> {
|
241
|
-
const methodSupported =
|
242
|
-
this.serverMetadata[`token_endpoint_auth_methods_supported`]
|
243
|
-
|
244
|
-
const method = this.clientMetadata[`token_endpoint_auth_method`]
|
245
|
-
|
246
|
-
if (
|
247
|
-
method === 'private_key_jwt' ||
|
248
|
-
(this.keyset &&
|
249
|
-
!method &&
|
250
|
-
(methodSupported?.includes('private_key_jwt') ?? false))
|
251
|
-
) {
|
252
|
-
if (!this.keyset) throw new Error('No keyset available')
|
253
|
-
|
254
|
-
try {
|
255
|
-
const alg =
|
256
|
-
this.serverMetadata[
|
257
|
-
`token_endpoint_auth_signing_alg_values_supported`
|
258
|
-
] ?? FALLBACK_ALG
|
259
|
-
|
260
|
-
// If jwks is defined, make sure to only sign using a key that exists in
|
261
|
-
// the jwks. If jwks_uri is defined, we can't be sure that the key we're
|
262
|
-
// looking for is in there so we will just assume it is.
|
263
|
-
const kid = this.clientMetadata.jwks?.keys
|
264
|
-
.map(({ kid }) => kid)
|
265
|
-
.filter((v): v is string => typeof v === 'string')
|
266
|
-
|
267
|
-
return {
|
268
|
-
payload: {
|
269
|
-
client_id: this.clientMetadata.client_id,
|
270
|
-
client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
271
|
-
client_assertion: await this.keyset.createJwt(
|
272
|
-
{ alg, kid },
|
273
|
-
{
|
274
|
-
iss: this.clientMetadata.client_id,
|
275
|
-
sub: this.clientMetadata.client_id,
|
276
|
-
aud: this.serverMetadata.issuer,
|
277
|
-
jti: await this.runtime.generateNonce(),
|
278
|
-
iat: Math.floor(Date.now() / 1000),
|
279
|
-
},
|
280
|
-
),
|
281
|
-
},
|
282
|
-
}
|
283
|
-
} catch (err) {
|
284
|
-
if (method === 'private_key_jwt') throw err
|
285
|
-
|
286
|
-
// Else try next method
|
287
|
-
}
|
288
|
-
}
|
289
|
-
|
290
|
-
if (
|
291
|
-
method === 'none' ||
|
292
|
-
(!method && (methodSupported?.includes('none') ?? true))
|
293
|
-
) {
|
294
|
-
return {
|
295
|
-
payload: {
|
296
|
-
client_id: this.clientMetadata.client_id,
|
297
|
-
},
|
298
|
-
}
|
299
|
-
}
|
300
|
-
|
301
|
-
throw new Error(`Unsupported ${endpoint} authentication method`)
|
302
|
-
}
|
303
250
|
}
|
304
251
|
|
305
252
|
function wwwFormUrlEncode(payload: Record<string, undefined | unknown>) {
|