@atproto/oauth-client 0.1.0 → 0.1.2-rc.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 (81) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +165 -31
  3. package/dist/errors/token-invalid-error.d.ts +7 -0
  4. package/dist/errors/token-invalid-error.d.ts.map +1 -0
  5. package/dist/errors/token-invalid-error.js +16 -0
  6. package/dist/errors/token-invalid-error.js.map +1 -0
  7. package/dist/errors/token-refresh-error.d.ts +7 -0
  8. package/dist/errors/token-refresh-error.d.ts.map +1 -0
  9. package/dist/errors/token-refresh-error.js +16 -0
  10. package/dist/errors/token-refresh-error.js.map +1 -0
  11. package/dist/errors/token-revoked-error.d.ts +7 -0
  12. package/dist/errors/token-revoked-error.d.ts.map +1 -0
  13. package/dist/errors/token-revoked-error.js +16 -0
  14. package/dist/errors/token-revoked-error.js.map +1 -0
  15. package/dist/index.d.ts +9 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +9 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/lock.d.ts +2 -1
  20. package/dist/lock.d.ts.map +1 -1
  21. package/dist/lock.js +2 -2
  22. package/dist/lock.js.map +1 -1
  23. package/dist/oauth-agent.d.ts.map +1 -1
  24. package/dist/oauth-agent.js +14 -9
  25. package/dist/oauth-agent.js.map +1 -1
  26. package/dist/oauth-atp-agent.d.ts +11 -0
  27. package/dist/oauth-atp-agent.d.ts.map +1 -0
  28. package/dist/oauth-atp-agent.js +52 -0
  29. package/dist/oauth-atp-agent.js.map +1 -0
  30. package/dist/oauth-client.d.ts +254 -24
  31. package/dist/oauth-client.d.ts.map +1 -1
  32. package/dist/oauth-client.js +68 -9
  33. package/dist/oauth-client.js.map +1 -1
  34. package/dist/oauth-resolver.d.ts +5 -4
  35. package/dist/oauth-resolver.d.ts.map +1 -1
  36. package/dist/oauth-resolver.js.map +1 -1
  37. package/dist/oauth-server-agent.d.ts.map +1 -1
  38. package/dist/oauth-server-agent.js +85 -29
  39. package/dist/oauth-server-agent.js.map +1 -1
  40. package/dist/runtime-implementation.d.ts +10 -5
  41. package/dist/runtime-implementation.d.ts.map +1 -1
  42. package/dist/runtime.d.ts +3 -3
  43. package/dist/runtime.d.ts.map +1 -1
  44. package/dist/runtime.js +18 -12
  45. package/dist/runtime.js.map +1 -1
  46. package/dist/session-getter.d.ts +19 -0
  47. package/dist/session-getter.d.ts.map +1 -1
  48. package/dist/session-getter.js +134 -42
  49. package/dist/session-getter.js.map +1 -1
  50. package/dist/state-store.d.ts +11 -0
  51. package/dist/state-store.d.ts.map +1 -0
  52. package/dist/state-store.js +3 -0
  53. package/dist/state-store.js.map +1 -0
  54. package/dist/types.d.ts +3 -2
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/util.d.ts +10 -3
  58. package/dist/util.d.ts.map +1 -1
  59. package/dist/util.js +43 -23
  60. package/dist/util.js.map +1 -1
  61. package/dist/validate-client-metadata.d.ts.map +1 -1
  62. package/dist/validate-client-metadata.js +17 -0
  63. package/dist/validate-client-metadata.js.map +1 -1
  64. package/package.json +10 -8
  65. package/src/errors/token-invalid-error.ts +9 -0
  66. package/src/{refresh-error.ts → errors/token-refresh-error.ts} +1 -1
  67. package/src/errors/token-revoked-error.ts +9 -0
  68. package/src/index.ts +12 -1
  69. package/src/lock.ts +3 -4
  70. package/src/oauth-agent.ts +20 -9
  71. package/src/oauth-atp-agent.ts +49 -0
  72. package/src/oauth-client.ts +117 -34
  73. package/src/oauth-resolver.ts +4 -4
  74. package/src/oauth-server-agent.ts +9 -9
  75. package/src/runtime-implementation.ts +19 -11
  76. package/src/runtime.ts +13 -17
  77. package/src/session-getter.ts +135 -71
  78. package/src/state-store.ts +12 -0
  79. package/src/types.ts +5 -2
  80. package/src/util.ts +63 -32
  81. package/src/validate-client-metadata.ts +18 -0
@@ -11,17 +11,20 @@ import {
11
11
  HandleResolver,
12
12
  } from '@atproto-labs/handle-resolver'
13
13
  import { IdentityResolver } from '@atproto-labs/identity-resolver'
14
- import { SimpleStore } from '@atproto-labs/simple-store'
15
14
  import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
16
15
  import { Key, Keyset } from '@atproto/jwk'
17
16
  import {
17
+ OAuthClientIdDiscoverable,
18
18
  OAuthClientMetadata,
19
19
  OAuthClientMetadataInput,
20
+ oauthClientMetadataSchema,
20
21
  OAuthResponseMode,
21
22
  } from '@atproto/oauth-types'
22
23
 
23
24
  import { FALLBACK_ALG } from './constants.js'
25
+ import { TokenRevokedError } from './errors/token-revoked-error.js'
24
26
  import { OAuthAgent } from './oauth-agent.js'
27
+ import { OAuthAtpAgent } from './oauth-atp-agent.js'
25
28
  import {
26
29
  AuthorizationServerMetadataCache,
27
30
  OAuthAuthorizationServerMetadataResolver,
@@ -36,30 +39,26 @@ import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
36
39
  import { OAuthServerFactory } from './oauth-server-factory.js'
37
40
  import { RuntimeImplementation } from './runtime-implementation.js'
38
41
  import { Runtime } from './runtime.js'
39
- import { SessionGetter, SessionStore } from './session-getter.js'
42
+ import {
43
+ SessionEventMap,
44
+ SessionGetter,
45
+ SessionStore,
46
+ } from './session-getter.js'
47
+ import { InternalStateData, StateStore } from './state-store.js'
40
48
  import { AuthorizeOptions, ClientMetadata } from './types.js'
49
+ import { CustomEventTarget } from './util.js'
41
50
  import { validateClientMetadata } from './validate-client-metadata.js'
42
51
 
43
- export type InternalStateData = {
44
- iss: string
45
- nonce: string
46
- dpopKey: Key
47
- verifier?: string
48
-
49
- /**
50
- * @note This could be parametrized to be of any type. This wasn't done for
51
- * the sake of simplicity but could be added in a later development.
52
- */
53
- appState?: string
54
- }
55
-
56
- export type StateStore = SimpleStore<string, InternalStateData>
57
-
58
52
  // Export all types needed to construct OAuthClientOptions
59
53
  export type {
60
54
  AuthorizationServerMetadataCache,
55
+ DidCache,
61
56
  DpopNonceCache,
62
57
  Fetch,
58
+ HandleCache,
59
+ HandleResolver,
60
+ InternalStateData,
61
+ Key,
63
62
  Keyset,
64
63
  OAuthClientMetadata,
65
64
  OAuthClientMetadataInput,
@@ -67,6 +66,7 @@ export type {
67
66
  ProtectedResourceMetadataCache,
68
67
  RuntimeImplementation,
69
68
  SessionStore,
69
+ StateStore,
70
70
  }
71
71
 
72
72
  export type OAuthClientOptions = {
@@ -91,7 +91,47 @@ export type OAuthClientOptions = {
91
91
  fetch?: Fetch
92
92
  }
93
93
 
94
- export class OAuthClient {
94
+ export type OAuthClientEventMap = SessionEventMap
95
+
96
+ export type OAuthClientFetchMetadataOptions = {
97
+ clientId: OAuthClientIdDiscoverable
98
+ fetch?: Fetch
99
+ signal?: AbortSignal
100
+ }
101
+
102
+ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
103
+ static async fetchMetadata({
104
+ clientId,
105
+ fetch = globalThis.fetch,
106
+ signal,
107
+ }: OAuthClientFetchMetadataOptions) {
108
+ signal?.throwIfAborted()
109
+
110
+ const request = new Request(clientId, {
111
+ redirect: 'error',
112
+ signal: signal,
113
+ })
114
+ const response = await fetch(request)
115
+
116
+ if (response.status !== 200) {
117
+ response.body?.cancel?.()
118
+ throw new TypeError(`Failed to fetch client metadata: ${response.status}`)
119
+ }
120
+
121
+ // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-4.1
122
+ const mime = response.headers.get('content-type')?.split(';')[0].trim()
123
+ if (mime !== 'application/json') {
124
+ response.body?.cancel?.()
125
+ throw new TypeError(`Invalid client metadata content type: ${mime}`)
126
+ }
127
+
128
+ const json: unknown = await response.json()
129
+
130
+ signal?.throwIfAborted()
131
+
132
+ return oauthClientMetadataSchema.parse(json)
133
+ }
134
+
95
135
  // Config
96
136
  readonly clientMetadata: ClientMetadata
97
137
  readonly responseMode: OAuthResponseMode
@@ -132,6 +172,8 @@ export class OAuthClient {
132
172
  runtimeImplementation,
133
173
  keyset,
134
174
  }: OAuthClientOptions) {
175
+ super()
176
+
135
177
  this.keyset = keyset
136
178
  ? keyset instanceof Keyset
137
179
  ? keyset
@@ -177,6 +219,15 @@ export class OAuthClient {
177
219
  this.runtime,
178
220
  )
179
221
  this.stateStore = stateStore
222
+
223
+ // Proxy sessionGetter events
224
+ for (const type of ['deleted', 'updated'] as const) {
225
+ this.sessionGetter.addEventListener(type, (event) => {
226
+ if (!this.dispatchCustomEvent(type, event.detail)) {
227
+ event.preventDefault()
228
+ }
229
+ })
230
+ }
180
231
  }
181
232
 
182
233
  // Exposed as public API for convenience
@@ -194,10 +245,11 @@ export class OAuthClient {
194
245
  return this.identityResolver.handleResolver
195
246
  }
196
247
 
197
- async authorize(
198
- input: string,
199
- options?: AuthorizeOptions & { signal?: AbortSignal },
200
- ): Promise<URL> {
248
+ get jwks() {
249
+ return this.keyset?.publicJwks ?? ({ keys: [] as const } as const)
250
+ }
251
+
252
+ async authorize(input: string, options?: AuthorizeOptions): Promise<URL> {
201
253
  const redirectUri =
202
254
  options?.redirect_uri ?? this.clientMetadata.redirect_uris[0]
203
255
  if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
@@ -239,7 +291,9 @@ export class OAuthClient {
239
291
  code_challenge_method: pkce?.method,
240
292
  nonce,
241
293
  state,
242
- login_hint: identity?.did || undefined,
294
+ login_hint: identity
295
+ ? input // If input is a handle or a DID, use it as a login_hint
296
+ : undefined,
243
297
  response_mode: this.responseMode,
244
298
  response_type:
245
299
  // Negotiate by using the order in the client metadata
@@ -297,8 +351,24 @@ export class OAuthClient {
297
351
  )
298
352
  }
299
353
 
354
+ /**
355
+ * This method allows the client to proactively revoke the request_uri it
356
+ * created through PAR.
357
+ */
358
+ async abortRequest(authorizeUrl: URL) {
359
+ const requestUri = authorizeUrl.searchParams.get('request_uri')
360
+ if (!requestUri) return
361
+
362
+ // @NOTE This is not implemented here because, 1) the request server should
363
+ // invalidate the request_uri after some delay anyways, and 2) I am not sure
364
+ // that the revocation endpoint is even supposed to support this (and I
365
+ // don't want to spend the time checking now).
366
+
367
+ // @TODO investigate actual necessity & feasibility of this feature
368
+ }
369
+
300
370
  async callback(params: URLSearchParams): Promise<{
301
- agent: OAuthAgent
371
+ agent: OAuthAtpAgent
302
372
  state: string | null
303
373
  }> {
304
374
  const responseJwt = params.get('response')
@@ -409,7 +479,7 @@ export class OAuthClient {
409
479
  *
410
480
  * @param refresh See {@link SessionGetter.getSession}
411
481
  */
412
- async restore(sub: string, refresh?: boolean): Promise<OAuthAgent> {
482
+ async restore(sub: string, refresh?: boolean): Promise<OAuthAtpAgent> {
413
483
  const { dpopKey, tokenSet } = await this.sessionGetter.getSession(
414
484
  sub,
415
485
  refresh,
@@ -424,17 +494,30 @@ export class OAuthClient {
424
494
  }
425
495
 
426
496
  async revoke(sub: string) {
427
- const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
428
- allowStale: true,
429
- })
430
-
431
- const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
497
+ const { dpopKey, tokenSet } = await this.sessionGetter.getSession(
498
+ sub,
499
+ false,
500
+ )
432
501
 
433
- await server.revoke(tokenSet.access_token)
434
- await this.sessionGetter.delStored(sub)
502
+ // NOT using `;(await this.restore(sub, false)).signOut()` because we want
503
+ // the tokens to be deleted even if it was not possible to fetch the issuer
504
+ // data.
505
+ try {
506
+ const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
507
+ await server.revoke(tokenSet.access_token)
508
+ } finally {
509
+ await this.sessionGetter.delStored(sub, new TokenRevokedError(sub))
510
+ }
435
511
  }
436
512
 
437
- createAgent(server: OAuthServerAgent, sub: string): OAuthAgent {
438
- return new OAuthAgent(server, sub, this.sessionGetter, this.fetch)
513
+ createAgent(server: OAuthServerAgent, sub: string): OAuthAtpAgent {
514
+ const oauthAgent = new OAuthAgent(
515
+ server,
516
+ sub,
517
+ this.sessionGetter,
518
+ this.fetch,
519
+ )
520
+
521
+ return new OAuthAtpAgent(oauthAgent)
439
522
  }
440
523
  }
@@ -1,5 +1,5 @@
1
1
  import {
2
- ResolveOptions as IdentityResolveOptions,
2
+ ResolveIdentityOptions,
3
3
  IdentityResolver,
4
4
  ResolvedIdentity,
5
5
  } from '@atproto-labs/identity-resolver'
@@ -13,7 +13,7 @@ import {
13
13
  import { OAuthProtectedResourceMetadataResolver } from './oauth-protected-resource-metadata-resolver.js'
14
14
 
15
15
  export type { GetCachedOptions }
16
- export type ResolveOptions = GetCachedOptions & IdentityResolveOptions
16
+ export type ResolveOAuthOptions = GetCachedOptions & ResolveIdentityOptions
17
17
 
18
18
  export class OAuthResolver {
19
19
  constructor(
@@ -24,7 +24,7 @@ export class OAuthResolver {
24
24
 
25
25
  public async resolveIdentity(
26
26
  input: string,
27
- options?: IdentityResolveOptions,
27
+ options?: ResolveIdentityOptions,
28
28
  ): Promise<ResolvedIdentity> {
29
29
  try {
30
30
  return await this.identityResolver.resolve(input, options)
@@ -93,7 +93,7 @@ export class OAuthResolver {
93
93
 
94
94
  public async resolve(
95
95
  input: string,
96
- options?: ResolveOptions,
96
+ options?: ResolveOAuthOptions,
97
97
  ): Promise<{
98
98
  identity: ResolvedIdentity
99
99
  metadata: OAuthAuthorizationServerMetadata
@@ -1,4 +1,4 @@
1
- import { Fetch, Json, fetchJsonProcessor, bindFetch } from '@atproto-labs/fetch'
1
+ import { Fetch, Json, bindFetch, fetchJsonProcessor } from '@atproto-labs/fetch'
2
2
  import { SimpleStore } from '@atproto-labs/simple-store'
3
3
  import { Key, Keyset, SignedJwt } from '@atproto/jwk'
4
4
  import {
@@ -14,13 +14,13 @@ import {
14
14
  } from '@atproto/oauth-types'
15
15
 
16
16
  import { FALLBACK_ALG } from './constants.js'
17
+ import { TokenRefreshError } from './errors/token-refresh-error.js'
17
18
  import { dpopFetchWrapper } from './fetch-dpop.js'
18
19
  import { OAuthResolver } from './oauth-resolver.js'
19
20
  import { OAuthResponseError } from './oauth-response-error.js'
20
- import { RefreshError } from './refresh-error.js'
21
21
  import { Runtime } from './runtime.js'
22
22
  import { ClientMetadata } from './types.js'
23
- import { withSignal } from './util.js'
23
+ import { timeoutSignal } from './util.js'
24
24
 
25
25
  export type TokenSet = {
26
26
  iss: string
@@ -89,7 +89,7 @@ export class OAuthServerAgent {
89
89
 
90
90
  async refresh(tokenSet: TokenSet): Promise<TokenSet> {
91
91
  if (!tokenSet.refresh_token) {
92
- throw new RefreshError(tokenSet.sub, 'No refresh token available')
92
+ throw new TokenRefreshError(tokenSet.sub, 'No refresh token available')
93
93
  }
94
94
 
95
95
  const tokenResponse = await this.request('token', {
@@ -99,13 +99,13 @@ export class OAuthServerAgent {
99
99
 
100
100
  try {
101
101
  if (tokenSet.sub !== tokenResponse.sub) {
102
- throw new RefreshError(
102
+ throw new TokenRefreshError(
103
103
  tokenSet.sub,
104
104
  `Unexpected "sub" in token response (${tokenResponse.sub})`,
105
105
  )
106
106
  }
107
107
  if (tokenSet.iss !== this.serverMetadata.issuer) {
108
- throw new RefreshError(tokenSet.sub, 'Issuer mismatch')
108
+ throw new TokenRefreshError(tokenSet.sub, 'Issuer mismatch')
109
109
  }
110
110
 
111
111
  return this.processTokenResponse(tokenResponse)
@@ -132,9 +132,9 @@ export class OAuthServerAgent {
132
132
  if (!sub) throw new TypeError(`Missing "sub" in token response`)
133
133
 
134
134
  // @TODO (?) make timeout configurable
135
- const resolved = await withSignal({ timeout: 10e3 }, (signal) =>
136
- this.oauthResolver.resolve(sub, { signal }),
137
- )
135
+ using signal = timeoutSignal(10e3)
136
+
137
+ const resolved = await this.oauthResolver.resolve(sub, { signal })
138
138
 
139
139
  if (resolved.metadata.issuer !== this.serverMetadata.issuer) {
140
140
  // Best case scenario; the user switched PDS. Worst case scenario; a bad
@@ -1,17 +1,25 @@
1
1
  import { Key } from '@atproto/jwk'
2
-
3
- export type DigestAlgorithm = {
4
- name: 'sha256' | 'sha384' | 'sha512'
5
- }
2
+ import { Awaitable } from './util.js'
6
3
 
7
4
  export type { Key }
5
+ export type RuntimeKeyFactory = (algs: string[]) => Key | PromiseLike<Key>
6
+
7
+ export type RuntimeRandomValues = (length: number) => Awaitable<Uint8Array>
8
+
9
+ export type DigestAlgorithm = { name: 'sha256' | 'sha384' | 'sha512' }
10
+ export type RuntimeDigest = (
11
+ data: Uint8Array,
12
+ alg: DigestAlgorithm,
13
+ ) => Awaitable<Uint8Array>
14
+
15
+ export type RuntimeLock = <T>(
16
+ name: string,
17
+ fn: () => Awaitable<T>,
18
+ ) => Awaitable<T>
8
19
 
9
20
  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>
21
+ createKey: RuntimeKeyFactory
22
+ getRandomValues: RuntimeRandomValues
23
+ digest: RuntimeDigest
24
+ requestLock?: RuntimeLock
17
25
  }
package/src/runtime.ts CHANGED
@@ -5,10 +5,22 @@ import { requestLocalLock } from './lock.js'
5
5
  import {
6
6
  DigestAlgorithm,
7
7
  RuntimeImplementation,
8
+ RuntimeLock,
8
9
  } from './runtime-implementation.js'
9
10
 
10
11
  export class Runtime {
11
- constructor(protected implementation: RuntimeImplementation) {}
12
+ readonly hasImplementationLock: boolean
13
+ readonly usingLock: RuntimeLock
14
+
15
+ constructor(protected implementation: RuntimeImplementation) {
16
+ const { requestLock } = implementation
17
+
18
+ this.hasImplementationLock = requestLock != null
19
+ this.usingLock =
20
+ requestLock?.bind(implementation) ||
21
+ // Falling back to a local lock
22
+ requestLocalLock
23
+ }
12
24
 
13
25
  public async generateKey(algs: string[]): Promise<Key> {
14
26
  const algsSorted = Array.from(algs).sort(compareAlgos)
@@ -26,22 +38,6 @@ export class Runtime {
26
38
  return base64url.baseEncode(bytes)
27
39
  }
28
40
 
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
41
  public async validateIdTokenClaims(
46
42
  token: string,
47
43
  state: string,