@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.
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
+ }