@atproto/oauth-client 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- package/CHANGELOG.md +20 -0
- package/LICENSE.txt +7 -0
- package/README.md +124 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +8 -0
- package/dist/constants.js.map +1 -0
- package/dist/fetch-dpop.d.ts +21 -0
- package/dist/fetch-dpop.d.ts.map +1 -0
- package/dist/fetch-dpop.js +149 -0
- package/dist/fetch-dpop.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/lock.d.ts +2 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +33 -0
- package/dist/lock.js.map +1 -0
- package/dist/oauth-agent.d.ts +29 -0
- package/dist/oauth-agent.d.ts.map +1 -0
- package/dist/oauth-agent.js +138 -0
- package/dist/oauth-agent.js.map +1 -0
- package/dist/oauth-authorization-server-metadata-resolver.d.ts +15 -0
- package/dist/oauth-authorization-server-metadata-resolver.d.ts.map +1 -0
- package/dist/oauth-authorization-server-metadata-resolver.js +56 -0
- package/dist/oauth-authorization-server-metadata-resolver.js.map +1 -0
- package/dist/oauth-callback-error.d.ts +7 -0
- package/dist/oauth-callback-error.d.ts.map +1 -0
- package/dist/oauth-callback-error.js +28 -0
- package/dist/oauth-callback-error.js.map +1 -0
- package/dist/oauth-client.d.ts +78 -0
- package/dist/oauth-client.d.ts.map +1 -0
- package/dist/oauth-client.js +278 -0
- package/dist/oauth-client.js.map +1 -0
- package/dist/oauth-protected-resource-metadata-resolver.d.ts +15 -0
- package/dist/oauth-protected-resource-metadata-resolver.d.ts.map +1 -0
- package/dist/oauth-protected-resource-metadata-resolver.js +58 -0
- package/dist/oauth-protected-resource-metadata-resolver.js.map +1 -0
- package/dist/oauth-resolver-error.d.ts +7 -0
- package/dist/oauth-resolver-error.d.ts.map +1 -0
- package/dist/oauth-resolver-error.js +17 -0
- package/dist/oauth-resolver-error.js.map +1 -0
- package/dist/oauth-resolver.d.ts +62 -0
- package/dist/oauth-resolver.d.ts.map +1 -0
- package/dist/oauth-resolver.js +73 -0
- package/dist/oauth-resolver.js.map +1 -0
- package/dist/oauth-response-error.d.ts +11 -0
- package/dist/oauth-response-error.d.ts.map +1 -0
- package/dist/oauth-response-error.js +48 -0
- package/dist/oauth-response-error.js.map +1 -0
- package/dist/oauth-server-agent.d.ts +51 -0
- package/dist/oauth-server-agent.d.ts.map +1 -0
- package/dist/oauth-server-agent.js +228 -0
- package/dist/oauth-server-agent.js.map +1 -0
- package/dist/oauth-server-factory.d.ts +20 -0
- package/dist/oauth-server-factory.d.ts.map +1 -0
- package/dist/oauth-server-factory.js +53 -0
- package/dist/oauth-server-factory.js.map +1 -0
- package/dist/refresh-error.d.ts +7 -0
- package/dist/refresh-error.d.ts.map +1 -0
- package/dist/refresh-error.js +16 -0
- package/dist/refresh-error.js.map +1 -0
- package/dist/runtime-implementation.d.ts +12 -0
- package/dist/runtime-implementation.d.ts.map +1 -0
- package/dist/runtime-implementation.js +3 -0
- package/dist/runtime-implementation.js.map +1 -0
- package/dist/runtime.d.ts +35 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +185 -0
- package/dist/runtime.js.map +1 -0
- package/dist/session-getter.d.ts +30 -0
- package/dist/session-getter.d.ts.map +1 -0
- package/dist/session-getter.js +149 -0
- package/dist/session-getter.js.map +1 -0
- package/dist/types.d.ts +1580 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +9 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +35 -0
- package/dist/util.js.map +1 -0
- package/dist/validate-client-metadata.d.ts +5 -0
- package/dist/validate-client-metadata.d.ts.map +1 -0
- package/dist/validate-client-metadata.js +46 -0
- package/dist/validate-client-metadata.js.map +1 -0
- package/package.json +46 -0
- package/src/constants.ts +4 -0
- package/src/fetch-dpop.ts +235 -0
- package/src/index.ts +18 -0
- package/src/lock.ts +34 -0
- package/src/oauth-agent.ts +150 -0
- package/src/oauth-authorization-server-metadata-resolver.ts +98 -0
- package/src/oauth-callback-error.ts +16 -0
- package/src/oauth-client.ts +440 -0
- package/src/oauth-protected-resource-metadata-resolver.ts +102 -0
- package/src/oauth-resolver-error.ts +12 -0
- package/src/oauth-resolver.ts +111 -0
- package/src/oauth-response-error.ts +31 -0
- package/src/oauth-server-agent.ts +275 -0
- package/src/oauth-server-factory.ts +41 -0
- package/src/refresh-error.ts +9 -0
- package/src/runtime-implementation.ts +17 -0
- package/src/runtime.ts +211 -0
- package/src/session-getter.ts +182 -0
- package/src/types.ts +26 -0
- package/src/util.ts +51 -0
- package/src/validate-client-metadata.ts +61 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +4 -0
@@ -0,0 +1,111 @@
|
|
1
|
+
import {
|
2
|
+
ResolveOptions as IdentityResolveOptions,
|
3
|
+
IdentityResolver,
|
4
|
+
ResolvedIdentity,
|
5
|
+
} from '@atproto-labs/identity-resolver'
|
6
|
+
import { OAuthAuthorizationServerMetadata } from '@atproto/oauth-types'
|
7
|
+
|
8
|
+
import { OAuthResolverError } from './oauth-resolver-error.js'
|
9
|
+
import {
|
10
|
+
GetCachedOptions,
|
11
|
+
OAuthAuthorizationServerMetadataResolver,
|
12
|
+
} from './oauth-authorization-server-metadata-resolver.js'
|
13
|
+
import { OAuthProtectedResourceMetadataResolver } from './oauth-protected-resource-metadata-resolver.js'
|
14
|
+
|
15
|
+
export type { GetCachedOptions }
|
16
|
+
export type ResolveOptions = GetCachedOptions & IdentityResolveOptions
|
17
|
+
|
18
|
+
export class OAuthResolver {
|
19
|
+
constructor(
|
20
|
+
readonly identityResolver: IdentityResolver,
|
21
|
+
readonly protectedResourceMetadataResolver: OAuthProtectedResourceMetadataResolver,
|
22
|
+
readonly authorizationServerMetadataResolver: OAuthAuthorizationServerMetadataResolver,
|
23
|
+
) {}
|
24
|
+
|
25
|
+
public async resolveIdentity(
|
26
|
+
input: string,
|
27
|
+
options?: IdentityResolveOptions,
|
28
|
+
): Promise<ResolvedIdentity> {
|
29
|
+
try {
|
30
|
+
return await this.identityResolver.resolve(input, options)
|
31
|
+
} catch (cause) {
|
32
|
+
throw OAuthResolverError.from(
|
33
|
+
cause,
|
34
|
+
`Failed to resolve identity: ${input}`,
|
35
|
+
)
|
36
|
+
}
|
37
|
+
}
|
38
|
+
|
39
|
+
public async resolveMetadata(
|
40
|
+
issuer: string,
|
41
|
+
options?: GetCachedOptions,
|
42
|
+
): Promise<OAuthAuthorizationServerMetadata> {
|
43
|
+
try {
|
44
|
+
return await this.authorizationServerMetadataResolver.get(issuer, options)
|
45
|
+
} catch (cause) {
|
46
|
+
throw OAuthResolverError.from(
|
47
|
+
cause,
|
48
|
+
`Failed to resolve OAuth server metadata for issuer: ${issuer}`,
|
49
|
+
)
|
50
|
+
}
|
51
|
+
}
|
52
|
+
|
53
|
+
public async resolvePdsMetadata(
|
54
|
+
pds: string | URL,
|
55
|
+
options?: GetCachedOptions,
|
56
|
+
) {
|
57
|
+
try {
|
58
|
+
const rsMetadata = await this.protectedResourceMetadataResolver.get(
|
59
|
+
pds,
|
60
|
+
options,
|
61
|
+
)
|
62
|
+
|
63
|
+
const issuer = rsMetadata.authorization_servers?.[0]
|
64
|
+
if (!issuer) {
|
65
|
+
throw new OAuthResolverError(
|
66
|
+
`No authorization servers found for PDS: ${pds}`,
|
67
|
+
)
|
68
|
+
}
|
69
|
+
|
70
|
+
options?.signal?.throwIfAborted()
|
71
|
+
|
72
|
+
const asMetadata = await this.resolveMetadata(issuer, options)
|
73
|
+
|
74
|
+
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-4
|
75
|
+
if (asMetadata.protected_resources) {
|
76
|
+
if (!asMetadata.protected_resources.includes(rsMetadata.resource)) {
|
77
|
+
throw new OAuthResolverError(
|
78
|
+
`PDS "${pds}" not protected by issuer "${issuer}"`,
|
79
|
+
)
|
80
|
+
}
|
81
|
+
}
|
82
|
+
|
83
|
+
return asMetadata
|
84
|
+
} catch (cause) {
|
85
|
+
options?.signal?.throwIfAborted()
|
86
|
+
|
87
|
+
throw OAuthResolverError.from(
|
88
|
+
cause,
|
89
|
+
`Failed to resolve OAuth server metadata for resource: ${pds}`,
|
90
|
+
)
|
91
|
+
}
|
92
|
+
}
|
93
|
+
|
94
|
+
public async resolve(
|
95
|
+
input: string,
|
96
|
+
options?: ResolveOptions,
|
97
|
+
): Promise<{
|
98
|
+
identity: ResolvedIdentity
|
99
|
+
metadata: OAuthAuthorizationServerMetadata
|
100
|
+
}> {
|
101
|
+
options?.signal?.throwIfAborted()
|
102
|
+
|
103
|
+
const identity = await this.resolveIdentity(input, options)
|
104
|
+
|
105
|
+
options?.signal?.throwIfAborted()
|
106
|
+
|
107
|
+
const metadata = await this.resolvePdsMetadata(identity.pds, options)
|
108
|
+
|
109
|
+
return { identity, metadata }
|
110
|
+
}
|
111
|
+
}
|
@@ -0,0 +1,31 @@
|
|
1
|
+
import { Json, ifString, ifObject } from '@atproto-labs/fetch'
|
2
|
+
|
3
|
+
export class OAuthResponseError extends Error {
|
4
|
+
readonly error?: string
|
5
|
+
readonly errorDescription?: string
|
6
|
+
|
7
|
+
constructor(
|
8
|
+
public readonly response: Response,
|
9
|
+
public readonly payload: Json,
|
10
|
+
) {
|
11
|
+
const error = ifString(ifObject(payload)?.['error'])
|
12
|
+
const errorDescription = ifString(ifObject(payload)?.['error_description'])
|
13
|
+
|
14
|
+
const messageError = error ? `"${error}"` : 'unknown'
|
15
|
+
const messageDesc = errorDescription ? `: ${errorDescription}` : ''
|
16
|
+
const message = `OAuth ${messageError} error${messageDesc}`
|
17
|
+
|
18
|
+
super(message)
|
19
|
+
|
20
|
+
this.error = error
|
21
|
+
this.errorDescription = errorDescription
|
22
|
+
}
|
23
|
+
|
24
|
+
get status() {
|
25
|
+
return this.response.status
|
26
|
+
}
|
27
|
+
|
28
|
+
get headers() {
|
29
|
+
return this.response.headers
|
30
|
+
}
|
31
|
+
}
|
@@ -0,0 +1,275 @@
|
|
1
|
+
import { Fetch, Json, fetchJsonProcessor, bindFetch } from '@atproto-labs/fetch'
|
2
|
+
import { SimpleStore } from '@atproto-labs/simple-store'
|
3
|
+
import { Key, Keyset, SignedJwt } from '@atproto/jwk'
|
4
|
+
import {
|
5
|
+
CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
6
|
+
OAuthAuthorizationServerMetadata,
|
7
|
+
OAuthClientIdentification,
|
8
|
+
OAuthEndpointName,
|
9
|
+
OAuthParResponse,
|
10
|
+
OAuthTokenResponse,
|
11
|
+
OAuthTokenType,
|
12
|
+
oauthParResponseSchema,
|
13
|
+
oauthTokenResponseSchema,
|
14
|
+
} from '@atproto/oauth-types'
|
15
|
+
|
16
|
+
import { FALLBACK_ALG } from './constants.js'
|
17
|
+
import { dpopFetchWrapper } from './fetch-dpop.js'
|
18
|
+
import { OAuthResolver } from './oauth-resolver.js'
|
19
|
+
import { OAuthResponseError } from './oauth-response-error.js'
|
20
|
+
import { RefreshError } from './refresh-error.js'
|
21
|
+
import { Runtime } from './runtime.js'
|
22
|
+
import { ClientMetadata } from './types.js'
|
23
|
+
import { withSignal } from './util.js'
|
24
|
+
|
25
|
+
export type TokenSet = {
|
26
|
+
iss: string
|
27
|
+
sub: string
|
28
|
+
aud: string
|
29
|
+
scope?: string
|
30
|
+
|
31
|
+
id_token?: SignedJwt
|
32
|
+
refresh_token?: string
|
33
|
+
access_token: string
|
34
|
+
token_type: OAuthTokenType
|
35
|
+
/** ISO Date */
|
36
|
+
expires_at?: string
|
37
|
+
}
|
38
|
+
|
39
|
+
export type DpopNonceCache = SimpleStore<string, string>
|
40
|
+
|
41
|
+
export class OAuthServerAgent {
|
42
|
+
protected dpopFetch: Fetch<unknown>
|
43
|
+
|
44
|
+
constructor(
|
45
|
+
readonly dpopKey: Key,
|
46
|
+
readonly serverMetadata: OAuthAuthorizationServerMetadata,
|
47
|
+
readonly clientMetadata: ClientMetadata,
|
48
|
+
readonly dpopNonces: DpopNonceCache,
|
49
|
+
readonly oauthResolver: OAuthResolver,
|
50
|
+
readonly runtime: Runtime,
|
51
|
+
readonly keyset?: Keyset,
|
52
|
+
fetch?: Fetch,
|
53
|
+
) {
|
54
|
+
this.dpopFetch = dpopFetchWrapper<void>({
|
55
|
+
fetch: bindFetch(fetch),
|
56
|
+
iss: clientMetadata.client_id,
|
57
|
+
key: dpopKey,
|
58
|
+
supportedAlgs: serverMetadata.dpop_signing_alg_values_supported,
|
59
|
+
sha256: async (v) => runtime.sha256(v),
|
60
|
+
nonces: dpopNonces,
|
61
|
+
isAuthServer: true,
|
62
|
+
})
|
63
|
+
}
|
64
|
+
|
65
|
+
async revoke(token: string) {
|
66
|
+
try {
|
67
|
+
await this.request('revocation', { token })
|
68
|
+
} catch {
|
69
|
+
// Don't care
|
70
|
+
}
|
71
|
+
}
|
72
|
+
|
73
|
+
async exchangeCode(code: string, verifier?: string): Promise<TokenSet> {
|
74
|
+
const tokenResponse = await this.request('token', {
|
75
|
+
grant_type: 'authorization_code',
|
76
|
+
redirect_uri: this.clientMetadata.redirect_uris[0]!,
|
77
|
+
code,
|
78
|
+
code_verifier: verifier,
|
79
|
+
})
|
80
|
+
|
81
|
+
try {
|
82
|
+
return this.processTokenResponse(tokenResponse)
|
83
|
+
} catch (err) {
|
84
|
+
await this.revoke(tokenResponse.access_token)
|
85
|
+
|
86
|
+
throw err
|
87
|
+
}
|
88
|
+
}
|
89
|
+
|
90
|
+
async refresh(tokenSet: TokenSet): Promise<TokenSet> {
|
91
|
+
if (!tokenSet.refresh_token) {
|
92
|
+
throw new RefreshError(tokenSet.sub, 'No refresh token available')
|
93
|
+
}
|
94
|
+
|
95
|
+
const tokenResponse = await this.request('token', {
|
96
|
+
grant_type: 'refresh_token',
|
97
|
+
refresh_token: tokenSet.refresh_token,
|
98
|
+
})
|
99
|
+
|
100
|
+
try {
|
101
|
+
if (tokenSet.sub !== tokenResponse.sub) {
|
102
|
+
throw new RefreshError(
|
103
|
+
tokenSet.sub,
|
104
|
+
`Unexpected "sub" in token response (${tokenResponse.sub})`,
|
105
|
+
)
|
106
|
+
}
|
107
|
+
if (tokenSet.iss !== this.serverMetadata.issuer) {
|
108
|
+
throw new RefreshError(tokenSet.sub, 'Issuer mismatch')
|
109
|
+
}
|
110
|
+
|
111
|
+
return this.processTokenResponse(tokenResponse)
|
112
|
+
} catch (err) {
|
113
|
+
await this.revoke(tokenResponse.access_token)
|
114
|
+
|
115
|
+
throw err
|
116
|
+
}
|
117
|
+
}
|
118
|
+
|
119
|
+
/**
|
120
|
+
* VERY IMPORTANT ! Always call this to process token responses.
|
121
|
+
*
|
122
|
+
* Whenever an OAuth token response is received, we **MUST** verify that the
|
123
|
+
* "sub" is a DID, whose issuer authority is indeed the server we just
|
124
|
+
* obtained credentials from. This check is a critical step to actually be
|
125
|
+
* able to use the "sub" (DID) as being the actual user's identifier.
|
126
|
+
*/
|
127
|
+
private async processTokenResponse(
|
128
|
+
tokenResponse: OAuthTokenResponse,
|
129
|
+
): Promise<TokenSet> {
|
130
|
+
const { sub } = tokenResponse
|
131
|
+
// ATPROTO requires that the "sub" is always present in the token response.
|
132
|
+
if (!sub) throw new TypeError(`Missing "sub" in token response`)
|
133
|
+
|
134
|
+
// @TODO (?) make timeout configurable
|
135
|
+
const resolved = await withSignal({ timeout: 10e3 }, (signal) =>
|
136
|
+
this.oauthResolver.resolve(sub, { signal }),
|
137
|
+
)
|
138
|
+
|
139
|
+
if (resolved.metadata.issuer !== this.serverMetadata.issuer) {
|
140
|
+
// Best case scenario; the user switched PDS. Worst case scenario; a bad
|
141
|
+
// actor is trying to impersonate a user. In any case, we must not allow
|
142
|
+
// this token to be used.
|
143
|
+
throw new TypeError('Issuer mismatch')
|
144
|
+
}
|
145
|
+
|
146
|
+
return {
|
147
|
+
sub,
|
148
|
+
aud: resolved.identity.pds.href,
|
149
|
+
iss: resolved.metadata.issuer,
|
150
|
+
|
151
|
+
scope: tokenResponse.scope,
|
152
|
+
id_token: tokenResponse.id_token,
|
153
|
+
refresh_token: tokenResponse.refresh_token,
|
154
|
+
access_token: tokenResponse.access_token,
|
155
|
+
token_type: tokenResponse.token_type ?? 'Bearer',
|
156
|
+
expires_at:
|
157
|
+
typeof tokenResponse.expires_in === 'number'
|
158
|
+
? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
|
159
|
+
: undefined,
|
160
|
+
}
|
161
|
+
}
|
162
|
+
|
163
|
+
async request(
|
164
|
+
endpoint: 'token',
|
165
|
+
payload: Record<string, unknown>,
|
166
|
+
): Promise<OAuthTokenResponse>
|
167
|
+
async request(
|
168
|
+
endpoint: 'pushed_authorization_request',
|
169
|
+
payload: Record<string, unknown>,
|
170
|
+
): Promise<OAuthParResponse>
|
171
|
+
async request(
|
172
|
+
endpoint: OAuthEndpointName,
|
173
|
+
payload: Record<string, unknown>,
|
174
|
+
): Promise<Json>
|
175
|
+
|
176
|
+
async request(endpoint: OAuthEndpointName, payload: Record<string, unknown>) {
|
177
|
+
const url = this.serverMetadata[`${endpoint}_endpoint`]
|
178
|
+
if (!url) throw new Error(`No ${endpoint} endpoint available`)
|
179
|
+
|
180
|
+
const auth = await this.buildClientAuth(endpoint)
|
181
|
+
|
182
|
+
const { response, json } = await this.dpopFetch(url, {
|
183
|
+
method: 'POST',
|
184
|
+
headers: { ...auth.headers, 'Content-Type': 'application/json' },
|
185
|
+
body: JSON.stringify({ ...payload, ...auth.payload }),
|
186
|
+
}).then(fetchJsonProcessor())
|
187
|
+
|
188
|
+
if (response.ok) {
|
189
|
+
switch (endpoint) {
|
190
|
+
case 'token':
|
191
|
+
return oauthTokenResponseSchema.parse(json)
|
192
|
+
case 'pushed_authorization_request':
|
193
|
+
return oauthParResponseSchema.parse(json)
|
194
|
+
default:
|
195
|
+
return json
|
196
|
+
}
|
197
|
+
} else {
|
198
|
+
throw new OAuthResponseError(response, json)
|
199
|
+
}
|
200
|
+
}
|
201
|
+
|
202
|
+
async buildClientAuth(endpoint: OAuthEndpointName): Promise<{
|
203
|
+
headers?: Record<string, string>
|
204
|
+
payload: OAuthClientIdentification
|
205
|
+
}> {
|
206
|
+
const methodSupported =
|
207
|
+
this.serverMetadata[`${endpoint}_endpoint_auth_methods_supported`] ||
|
208
|
+
this.serverMetadata[`token_endpoint_auth_methods_supported`]
|
209
|
+
|
210
|
+
const method =
|
211
|
+
this.clientMetadata[`${endpoint}_endpoint_auth_method`] ||
|
212
|
+
this.clientMetadata[`token_endpoint_auth_method`]
|
213
|
+
|
214
|
+
if (
|
215
|
+
method === 'private_key_jwt' ||
|
216
|
+
(this.keyset &&
|
217
|
+
!method &&
|
218
|
+
(methodSupported?.includes('private_key_jwt') ?? false))
|
219
|
+
) {
|
220
|
+
if (!this.keyset) throw new Error('No keyset available')
|
221
|
+
|
222
|
+
try {
|
223
|
+
const alg =
|
224
|
+
this.serverMetadata[
|
225
|
+
`${endpoint}_endpoint_auth_signing_alg_values_supported`
|
226
|
+
] ??
|
227
|
+
this.serverMetadata[
|
228
|
+
`token_endpoint_auth_signing_alg_values_supported`
|
229
|
+
] ??
|
230
|
+
FALLBACK_ALG
|
231
|
+
|
232
|
+
// If jwks is defined, make sure to only sign using a key that exists in
|
233
|
+
// the jwks. If jwks_uri is defined, we can't be sure that the key we're
|
234
|
+
// looking for is in there so we will just assume it is.
|
235
|
+
const kid = this.clientMetadata.jwks?.keys
|
236
|
+
.map(({ kid }) => kid)
|
237
|
+
.filter((v): v is string => typeof v === 'string')
|
238
|
+
|
239
|
+
return {
|
240
|
+
payload: {
|
241
|
+
client_id: this.clientMetadata.client_id,
|
242
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
243
|
+
client_assertion: await this.keyset.createJwt(
|
244
|
+
{ alg, kid },
|
245
|
+
{
|
246
|
+
iss: this.clientMetadata.client_id,
|
247
|
+
sub: this.clientMetadata.client_id,
|
248
|
+
aud: this.serverMetadata.issuer,
|
249
|
+
jti: await this.runtime.generateNonce(),
|
250
|
+
iat: Math.floor(Date.now() / 1000),
|
251
|
+
},
|
252
|
+
),
|
253
|
+
},
|
254
|
+
}
|
255
|
+
} catch (err) {
|
256
|
+
if (method === 'private_key_jwt') throw err
|
257
|
+
|
258
|
+
// Else try next method
|
259
|
+
}
|
260
|
+
}
|
261
|
+
|
262
|
+
if (
|
263
|
+
method === 'none' ||
|
264
|
+
(!method && (methodSupported?.includes('none') ?? true))
|
265
|
+
) {
|
266
|
+
return {
|
267
|
+
payload: {
|
268
|
+
client_id: this.clientMetadata.client_id,
|
269
|
+
},
|
270
|
+
}
|
271
|
+
}
|
272
|
+
|
273
|
+
throw new Error(`Unsupported ${endpoint} authentication method`)
|
274
|
+
}
|
275
|
+
}
|
@@ -0,0 +1,41 @@
|
|
1
|
+
import { Fetch } from '@atproto-labs/fetch'
|
2
|
+
import { Key, Keyset } from '@atproto/jwk'
|
3
|
+
import { OAuthAuthorizationServerMetadata } from '@atproto/oauth-types'
|
4
|
+
|
5
|
+
import { GetCachedOptions } from './oauth-authorization-server-metadata-resolver.js'
|
6
|
+
import { OAuthResolver } from './oauth-resolver.js'
|
7
|
+
import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
|
8
|
+
import { Runtime } from './runtime.js'
|
9
|
+
import { ClientMetadata } from './types.js'
|
10
|
+
|
11
|
+
export class OAuthServerFactory {
|
12
|
+
constructor(
|
13
|
+
readonly clientMetadata: ClientMetadata,
|
14
|
+
readonly runtime: Runtime,
|
15
|
+
readonly resolver: OAuthResolver,
|
16
|
+
readonly fetch: Fetch,
|
17
|
+
readonly keyset: Keyset | undefined,
|
18
|
+
readonly dpopNonceCache: DpopNonceCache,
|
19
|
+
) {}
|
20
|
+
|
21
|
+
async fromIssuer(issuer: string, dpopKey: Key, options?: GetCachedOptions) {
|
22
|
+
const serverMetadata = await this.resolver.resolveMetadata(issuer, options)
|
23
|
+
return this.fromMetadata(serverMetadata, dpopKey)
|
24
|
+
}
|
25
|
+
|
26
|
+
async fromMetadata(
|
27
|
+
serverMetadata: OAuthAuthorizationServerMetadata,
|
28
|
+
dpopKey: Key,
|
29
|
+
) {
|
30
|
+
return new OAuthServerAgent(
|
31
|
+
dpopKey,
|
32
|
+
serverMetadata,
|
33
|
+
this.clientMetadata,
|
34
|
+
this.dpopNonceCache,
|
35
|
+
this.resolver,
|
36
|
+
this.runtime,
|
37
|
+
this.keyset,
|
38
|
+
this.fetch,
|
39
|
+
)
|
40
|
+
}
|
41
|
+
}
|
@@ -0,0 +1,17 @@
|
|
1
|
+
import { Key } from '@atproto/jwk'
|
2
|
+
|
3
|
+
export type DigestAlgorithm = {
|
4
|
+
name: 'sha256' | 'sha384' | 'sha512'
|
5
|
+
}
|
6
|
+
|
7
|
+
export type { Key }
|
8
|
+
|
9
|
+
export interface RuntimeImplementation {
|
10
|
+
createKey(algs: string[]): Key | PromiseLike<Key>
|
11
|
+
getRandomValues: (length: number) => Uint8Array | PromiseLike<Uint8Array>
|
12
|
+
digest: (
|
13
|
+
bytes: Uint8Array,
|
14
|
+
algorithm: DigestAlgorithm,
|
15
|
+
) => Uint8Array | PromiseLike<Uint8Array>
|
16
|
+
requestLock?: <T>(name: string, fn: () => T | PromiseLike<T>) => Promise<T>
|
17
|
+
}
|
package/src/runtime.ts
ADDED
@@ -0,0 +1,211 @@
|
|
1
|
+
import { JwtHeader, JwtPayload, Key, unsafeDecodeJwt } from '@atproto/jwk'
|
2
|
+
import { base64url } from 'multiformats/bases/base64'
|
3
|
+
|
4
|
+
import { requestLocalLock } from './lock.js'
|
5
|
+
import {
|
6
|
+
DigestAlgorithm,
|
7
|
+
RuntimeImplementation,
|
8
|
+
} from './runtime-implementation.js'
|
9
|
+
|
10
|
+
export class Runtime {
|
11
|
+
constructor(protected implementation: RuntimeImplementation) {}
|
12
|
+
|
13
|
+
public async generateKey(algs: string[]): Promise<Key> {
|
14
|
+
const algsSorted = Array.from(algs).sort(compareAlgos)
|
15
|
+
return this.implementation.createKey(algsSorted)
|
16
|
+
}
|
17
|
+
|
18
|
+
public async sha256(text: string): Promise<string> {
|
19
|
+
const bytes = new TextEncoder().encode(text)
|
20
|
+
const digest = await this.implementation.digest(bytes, { name: 'sha256' })
|
21
|
+
return base64url.baseEncode(digest)
|
22
|
+
}
|
23
|
+
|
24
|
+
public async generateNonce(length = 16): Promise<string> {
|
25
|
+
const bytes = await this.implementation.getRandomValues(length)
|
26
|
+
return base64url.baseEncode(bytes)
|
27
|
+
}
|
28
|
+
|
29
|
+
get hasLock() {
|
30
|
+
return !!this.implementation.requestLock
|
31
|
+
}
|
32
|
+
|
33
|
+
public async withLock<T>(
|
34
|
+
name: string,
|
35
|
+
fn: () => T | PromiseLike<T>,
|
36
|
+
): Promise<T> {
|
37
|
+
if (this.implementation.requestLock) {
|
38
|
+
return this.implementation.requestLock(name, fn)
|
39
|
+
} else {
|
40
|
+
// Falling back to a local lock
|
41
|
+
return requestLocalLock(name, fn)
|
42
|
+
}
|
43
|
+
}
|
44
|
+
|
45
|
+
public async validateIdTokenClaims(
|
46
|
+
token: string,
|
47
|
+
state: string,
|
48
|
+
nonce: string,
|
49
|
+
code?: string,
|
50
|
+
accessToken?: string,
|
51
|
+
): Promise<{
|
52
|
+
header: JwtHeader
|
53
|
+
payload: JwtPayload
|
54
|
+
}> {
|
55
|
+
// It's fine to use unsafeDecodeJwt here because the token was received from
|
56
|
+
// the server's token endpoint. The following checks are to ensure that the
|
57
|
+
// oauth flow was indeed initiated by the client.
|
58
|
+
const { header, payload } = unsafeDecodeJwt(token)
|
59
|
+
if (!payload.nonce || payload.nonce !== nonce) {
|
60
|
+
throw new TypeError('Nonce mismatch')
|
61
|
+
}
|
62
|
+
if (payload.c_hash) {
|
63
|
+
await this.validateHashClaim(payload.c_hash, code, header)
|
64
|
+
}
|
65
|
+
if (payload.s_hash) {
|
66
|
+
await this.validateHashClaim(payload.s_hash, state, header)
|
67
|
+
}
|
68
|
+
if (payload.at_hash) {
|
69
|
+
await this.validateHashClaim(payload.at_hash, accessToken, header)
|
70
|
+
}
|
71
|
+
return { header, payload }
|
72
|
+
}
|
73
|
+
|
74
|
+
private async validateHashClaim(
|
75
|
+
claim: unknown,
|
76
|
+
source: unknown,
|
77
|
+
header: { alg: string; crv?: string },
|
78
|
+
): Promise<void> {
|
79
|
+
if (typeof claim !== 'string' || !claim) {
|
80
|
+
throw new TypeError(`string "_hash" claim expected`)
|
81
|
+
}
|
82
|
+
if (typeof source !== 'string' || !source) {
|
83
|
+
throw new TypeError(`string value expected`)
|
84
|
+
}
|
85
|
+
const expected = await this.generateHashClaim(source, header)
|
86
|
+
if (expected !== claim) {
|
87
|
+
throw new TypeError(`"_hash" does not match`)
|
88
|
+
}
|
89
|
+
}
|
90
|
+
|
91
|
+
protected async generateHashClaim(
|
92
|
+
source: string,
|
93
|
+
header: { alg: string; crv?: string },
|
94
|
+
) {
|
95
|
+
const algo = getHashAlgo(header)
|
96
|
+
const bytes = new TextEncoder().encode(source)
|
97
|
+
const digest = await this.implementation.digest(bytes, algo)
|
98
|
+
if (digest.length % 2 !== 0) throw new TypeError('Invalid digest length')
|
99
|
+
const digestHalf = digest.slice(0, digest.length / 2)
|
100
|
+
return base64url.baseEncode(digestHalf)
|
101
|
+
}
|
102
|
+
|
103
|
+
public async generatePKCE(byteLength?: number) {
|
104
|
+
const verifier = await this.generateVerifier(byteLength)
|
105
|
+
return {
|
106
|
+
verifier,
|
107
|
+
challenge: await this.sha256(verifier),
|
108
|
+
method: 'S256',
|
109
|
+
}
|
110
|
+
}
|
111
|
+
|
112
|
+
public async calculateJwkThumbprint(jwk) {
|
113
|
+
const components = extractJktComponents(jwk)
|
114
|
+
const data = JSON.stringify(components)
|
115
|
+
return this.sha256(data)
|
116
|
+
}
|
117
|
+
|
118
|
+
/**
|
119
|
+
* @see {@link https://datatracker.ietf.org/doc/html/rfc7636#section-4.1}
|
120
|
+
* @note It is RECOMMENDED that the output of a suitable random number generator
|
121
|
+
* be used to create a 32-octet sequence. The octet sequence is then
|
122
|
+
* base64url-encoded to produce a 43-octet URL safe string to use as the code
|
123
|
+
* verifier.
|
124
|
+
*/
|
125
|
+
protected async generateVerifier(byteLength = 32) {
|
126
|
+
if (byteLength < 32 || byteLength > 96) {
|
127
|
+
throw new TypeError('Invalid code_verifier length')
|
128
|
+
}
|
129
|
+
const bytes = await this.implementation.getRandomValues(byteLength)
|
130
|
+
return base64url.baseEncode(bytes)
|
131
|
+
}
|
132
|
+
}
|
133
|
+
|
134
|
+
function getHashAlgo(header: { alg: string; crv?: string }): DigestAlgorithm {
|
135
|
+
switch (header.alg) {
|
136
|
+
case 'HS256':
|
137
|
+
case 'RS256':
|
138
|
+
case 'PS256':
|
139
|
+
case 'ES256':
|
140
|
+
case 'ES256K':
|
141
|
+
return { name: 'sha256' }
|
142
|
+
case 'HS384':
|
143
|
+
case 'RS384':
|
144
|
+
case 'PS384':
|
145
|
+
case 'ES384':
|
146
|
+
return { name: 'sha384' }
|
147
|
+
case 'HS512':
|
148
|
+
case 'RS512':
|
149
|
+
case 'PS512':
|
150
|
+
case 'ES512':
|
151
|
+
return { name: 'sha512' }
|
152
|
+
case 'EdDSA':
|
153
|
+
switch (header.crv) {
|
154
|
+
case 'Ed25519':
|
155
|
+
return { name: 'sha512' }
|
156
|
+
default:
|
157
|
+
throw new TypeError('unrecognized or invalid EdDSA curve provided')
|
158
|
+
}
|
159
|
+
default:
|
160
|
+
throw new TypeError('unrecognized or invalid JWS algorithm provided')
|
161
|
+
}
|
162
|
+
}
|
163
|
+
|
164
|
+
function extractJktComponents(jwk) {
|
165
|
+
const get = (field) => {
|
166
|
+
const value = jwk[field]
|
167
|
+
if (typeof value !== 'string' || !value) {
|
168
|
+
throw new TypeError(`"${field}" Parameter missing or invalid`)
|
169
|
+
}
|
170
|
+
return value
|
171
|
+
}
|
172
|
+
|
173
|
+
switch (jwk.kty) {
|
174
|
+
case 'EC':
|
175
|
+
return { crv: get('crv'), kty: get('kty'), x: get('x'), y: get('y') }
|
176
|
+
case 'OKP':
|
177
|
+
return { crv: get('crv'), kty: get('kty'), x: get('x') }
|
178
|
+
case 'RSA':
|
179
|
+
return { e: get('e'), kty: get('kty'), n: get('n') }
|
180
|
+
case 'oct':
|
181
|
+
return { k: get('k'), kty: get('kty') }
|
182
|
+
default:
|
183
|
+
throw new TypeError('"kty" (Key Type) Parameter missing or unsupported')
|
184
|
+
}
|
185
|
+
}
|
186
|
+
|
187
|
+
/**
|
188
|
+
* 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order)
|
189
|
+
*/
|
190
|
+
function compareAlgos(a: string, b: string): number {
|
191
|
+
if (a === 'ES256K') return -1
|
192
|
+
if (b === 'ES256K') return 1
|
193
|
+
|
194
|
+
for (const prefix of ['ES', 'PS', 'RS']) {
|
195
|
+
if (a.startsWith(prefix)) {
|
196
|
+
if (b.startsWith(prefix)) {
|
197
|
+
const aLen = parseInt(a.slice(2, 5))
|
198
|
+
const bLen = parseInt(b.slice(2, 5))
|
199
|
+
|
200
|
+
// Prefer shorter key lengths
|
201
|
+
return aLen - bLen
|
202
|
+
}
|
203
|
+
return -1
|
204
|
+
} else if (b.startsWith(prefix)) {
|
205
|
+
return 1
|
206
|
+
}
|
207
|
+
}
|
208
|
+
|
209
|
+
// Don't know how to compare, keep original order
|
210
|
+
return 0
|
211
|
+
}
|