@atproto/oauth-client 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (111) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE.txt +7 -0
  3. package/README.md +124 -0
  4. package/dist/constants.d.ts +5 -0
  5. package/dist/constants.d.ts.map +1 -0
  6. package/dist/constants.js +8 -0
  7. package/dist/constants.js.map +1 -0
  8. package/dist/fetch-dpop.d.ts +21 -0
  9. package/dist/fetch-dpop.d.ts.map +1 -0
  10. package/dist/fetch-dpop.js +149 -0
  11. package/dist/fetch-dpop.js.map +1 -0
  12. package/dist/index.d.ts +15 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +35 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/lock.d.ts +2 -0
  17. package/dist/lock.d.ts.map +1 -0
  18. package/dist/lock.js +33 -0
  19. package/dist/lock.js.map +1 -0
  20. package/dist/oauth-agent.d.ts +29 -0
  21. package/dist/oauth-agent.d.ts.map +1 -0
  22. package/dist/oauth-agent.js +138 -0
  23. package/dist/oauth-agent.js.map +1 -0
  24. package/dist/oauth-authorization-server-metadata-resolver.d.ts +15 -0
  25. package/dist/oauth-authorization-server-metadata-resolver.d.ts.map +1 -0
  26. package/dist/oauth-authorization-server-metadata-resolver.js +56 -0
  27. package/dist/oauth-authorization-server-metadata-resolver.js.map +1 -0
  28. package/dist/oauth-callback-error.d.ts +7 -0
  29. package/dist/oauth-callback-error.d.ts.map +1 -0
  30. package/dist/oauth-callback-error.js +28 -0
  31. package/dist/oauth-callback-error.js.map +1 -0
  32. package/dist/oauth-client.d.ts +78 -0
  33. package/dist/oauth-client.d.ts.map +1 -0
  34. package/dist/oauth-client.js +278 -0
  35. package/dist/oauth-client.js.map +1 -0
  36. package/dist/oauth-protected-resource-metadata-resolver.d.ts +15 -0
  37. package/dist/oauth-protected-resource-metadata-resolver.d.ts.map +1 -0
  38. package/dist/oauth-protected-resource-metadata-resolver.js +58 -0
  39. package/dist/oauth-protected-resource-metadata-resolver.js.map +1 -0
  40. package/dist/oauth-resolver-error.d.ts +7 -0
  41. package/dist/oauth-resolver-error.d.ts.map +1 -0
  42. package/dist/oauth-resolver-error.js +17 -0
  43. package/dist/oauth-resolver-error.js.map +1 -0
  44. package/dist/oauth-resolver.d.ts +62 -0
  45. package/dist/oauth-resolver.d.ts.map +1 -0
  46. package/dist/oauth-resolver.js +73 -0
  47. package/dist/oauth-resolver.js.map +1 -0
  48. package/dist/oauth-response-error.d.ts +11 -0
  49. package/dist/oauth-response-error.d.ts.map +1 -0
  50. package/dist/oauth-response-error.js +48 -0
  51. package/dist/oauth-response-error.js.map +1 -0
  52. package/dist/oauth-server-agent.d.ts +51 -0
  53. package/dist/oauth-server-agent.d.ts.map +1 -0
  54. package/dist/oauth-server-agent.js +228 -0
  55. package/dist/oauth-server-agent.js.map +1 -0
  56. package/dist/oauth-server-factory.d.ts +20 -0
  57. package/dist/oauth-server-factory.d.ts.map +1 -0
  58. package/dist/oauth-server-factory.js +53 -0
  59. package/dist/oauth-server-factory.js.map +1 -0
  60. package/dist/refresh-error.d.ts +7 -0
  61. package/dist/refresh-error.d.ts.map +1 -0
  62. package/dist/refresh-error.js +16 -0
  63. package/dist/refresh-error.js.map +1 -0
  64. package/dist/runtime-implementation.d.ts +12 -0
  65. package/dist/runtime-implementation.d.ts.map +1 -0
  66. package/dist/runtime-implementation.js +3 -0
  67. package/dist/runtime-implementation.js.map +1 -0
  68. package/dist/runtime.d.ts +35 -0
  69. package/dist/runtime.d.ts.map +1 -0
  70. package/dist/runtime.js +185 -0
  71. package/dist/runtime.js.map +1 -0
  72. package/dist/session-getter.d.ts +30 -0
  73. package/dist/session-getter.d.ts.map +1 -0
  74. package/dist/session-getter.js +149 -0
  75. package/dist/session-getter.js.map +1 -0
  76. package/dist/types.d.ts +1580 -0
  77. package/dist/types.d.ts.map +1 -0
  78. package/dist/types.js +8 -0
  79. package/dist/types.js.map +1 -0
  80. package/dist/util.d.ts +9 -0
  81. package/dist/util.d.ts.map +1 -0
  82. package/dist/util.js +35 -0
  83. package/dist/util.js.map +1 -0
  84. package/dist/validate-client-metadata.d.ts +5 -0
  85. package/dist/validate-client-metadata.d.ts.map +1 -0
  86. package/dist/validate-client-metadata.js +46 -0
  87. package/dist/validate-client-metadata.js.map +1 -0
  88. package/package.json +46 -0
  89. package/src/constants.ts +4 -0
  90. package/src/fetch-dpop.ts +235 -0
  91. package/src/index.ts +18 -0
  92. package/src/lock.ts +34 -0
  93. package/src/oauth-agent.ts +150 -0
  94. package/src/oauth-authorization-server-metadata-resolver.ts +98 -0
  95. package/src/oauth-callback-error.ts +16 -0
  96. package/src/oauth-client.ts +440 -0
  97. package/src/oauth-protected-resource-metadata-resolver.ts +102 -0
  98. package/src/oauth-resolver-error.ts +12 -0
  99. package/src/oauth-resolver.ts +111 -0
  100. package/src/oauth-response-error.ts +31 -0
  101. package/src/oauth-server-agent.ts +275 -0
  102. package/src/oauth-server-factory.ts +41 -0
  103. package/src/refresh-error.ts +9 -0
  104. package/src/runtime-implementation.ts +17 -0
  105. package/src/runtime.ts +211 -0
  106. package/src/session-getter.ts +182 -0
  107. package/src/types.ts +26 -0
  108. package/src/util.ts +51 -0
  109. package/src/validate-client-metadata.ts +61 -0
  110. package/tsconfig.build.json +8 -0
  111. 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,9 @@
1
+ export class RefreshError extends Error {
2
+ constructor(
3
+ public readonly sub: string,
4
+ message: string,
5
+ options?: { cause?: unknown },
6
+ ) {
7
+ super(message, options)
8
+ }
9
+ }
@@ -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
+ }