@atproto/oauth-client 0.1.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 +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
|
+
}
|