@atproto/oauth-client 0.1.0 → 0.1.1

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 (76) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +162 -32
  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 +8 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +8 -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-client.d.ts +250 -20
  27. package/dist/oauth-client.d.ts.map +1 -1
  28. package/dist/oauth-client.js +67 -9
  29. package/dist/oauth-client.js.map +1 -1
  30. package/dist/oauth-resolver.d.ts +5 -4
  31. package/dist/oauth-resolver.d.ts.map +1 -1
  32. package/dist/oauth-resolver.js.map +1 -1
  33. package/dist/oauth-server-agent.d.ts.map +1 -1
  34. package/dist/oauth-server-agent.js +85 -29
  35. package/dist/oauth-server-agent.js.map +1 -1
  36. package/dist/runtime-implementation.d.ts +10 -5
  37. package/dist/runtime-implementation.d.ts.map +1 -1
  38. package/dist/runtime.d.ts +3 -3
  39. package/dist/runtime.d.ts.map +1 -1
  40. package/dist/runtime.js +18 -12
  41. package/dist/runtime.js.map +1 -1
  42. package/dist/session-getter.d.ts +19 -0
  43. package/dist/session-getter.d.ts.map +1 -1
  44. package/dist/session-getter.js +134 -42
  45. package/dist/session-getter.js.map +1 -1
  46. package/dist/state-store.d.ts +11 -0
  47. package/dist/state-store.d.ts.map +1 -0
  48. package/dist/state-store.js +3 -0
  49. package/dist/state-store.js.map +1 -0
  50. package/dist/types.d.ts +3 -2
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js.map +1 -1
  53. package/dist/util.d.ts +10 -3
  54. package/dist/util.d.ts.map +1 -1
  55. package/dist/util.js +43 -23
  56. package/dist/util.js.map +1 -1
  57. package/dist/validate-client-metadata.d.ts.map +1 -1
  58. package/dist/validate-client-metadata.js +17 -0
  59. package/dist/validate-client-metadata.js.map +1 -1
  60. package/package.json +8 -8
  61. package/src/errors/token-invalid-error.ts +9 -0
  62. package/src/errors/token-refresh-error.ts +9 -0
  63. package/src/errors/token-revoked-error.ts +9 -0
  64. package/src/index.ts +11 -1
  65. package/src/lock.ts +3 -4
  66. package/src/oauth-agent.ts +20 -9
  67. package/src/oauth-client.ts +113 -31
  68. package/src/oauth-resolver.ts +4 -4
  69. package/src/oauth-server-agent.ts +9 -9
  70. package/src/runtime-implementation.ts +19 -11
  71. package/src/runtime.ts +13 -17
  72. package/src/session-getter.ts +135 -71
  73. package/src/state-store.ts +12 -0
  74. package/src/types.ts +5 -2
  75. package/src/util.ts +63 -32
  76. package/src/validate-client-metadata.ts +18 -0
@@ -11,16 +11,18 @@ 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'
25
27
  import {
26
28
  AuthorizationServerMetadataCache,
@@ -36,30 +38,26 @@ import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
36
38
  import { OAuthServerFactory } from './oauth-server-factory.js'
37
39
  import { RuntimeImplementation } from './runtime-implementation.js'
38
40
  import { Runtime } from './runtime.js'
39
- import { SessionGetter, SessionStore } from './session-getter.js'
41
+ import {
42
+ SessionEventMap,
43
+ SessionGetter,
44
+ SessionStore,
45
+ } from './session-getter.js'
46
+ import { InternalStateData, StateStore } from './state-store.js'
40
47
  import { AuthorizeOptions, ClientMetadata } from './types.js'
48
+ import { CustomEventTarget } from './util.js'
41
49
  import { validateClientMetadata } from './validate-client-metadata.js'
42
50
 
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
51
  // Export all types needed to construct OAuthClientOptions
59
52
  export type {
60
53
  AuthorizationServerMetadataCache,
54
+ DidCache,
61
55
  DpopNonceCache,
62
56
  Fetch,
57
+ HandleCache,
58
+ HandleResolver,
59
+ InternalStateData,
60
+ Key,
63
61
  Keyset,
64
62
  OAuthClientMetadata,
65
63
  OAuthClientMetadataInput,
@@ -67,6 +65,7 @@ export type {
67
65
  ProtectedResourceMetadataCache,
68
66
  RuntimeImplementation,
69
67
  SessionStore,
68
+ StateStore,
70
69
  }
71
70
 
72
71
  export type OAuthClientOptions = {
@@ -91,7 +90,47 @@ export type OAuthClientOptions = {
91
90
  fetch?: Fetch
92
91
  }
93
92
 
94
- export class OAuthClient {
93
+ export type OAuthClientEventMap = SessionEventMap
94
+
95
+ export type OAuthClientFetchMetadataOptions = {
96
+ clientId: OAuthClientIdDiscoverable
97
+ fetch?: Fetch
98
+ signal?: AbortSignal
99
+ }
100
+
101
+ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
102
+ static async fetchMetadata({
103
+ clientId,
104
+ fetch = globalThis.fetch,
105
+ signal,
106
+ }: OAuthClientFetchMetadataOptions) {
107
+ signal?.throwIfAborted()
108
+
109
+ const request = new Request(clientId, {
110
+ redirect: 'error',
111
+ signal: signal,
112
+ })
113
+ const response = await fetch(request)
114
+
115
+ if (response.status !== 200) {
116
+ response.body?.cancel?.()
117
+ throw new TypeError(`Failed to fetch client metadata: ${response.status}`)
118
+ }
119
+
120
+ // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-4.1
121
+ const mime = response.headers.get('content-type')?.split(';')[0].trim()
122
+ if (mime !== 'application/json') {
123
+ response.body?.cancel?.()
124
+ throw new TypeError(`Invalid client metadata content type: ${mime}`)
125
+ }
126
+
127
+ const json: unknown = await response.json()
128
+
129
+ signal?.throwIfAborted()
130
+
131
+ return oauthClientMetadataSchema.parse(json)
132
+ }
133
+
95
134
  // Config
96
135
  readonly clientMetadata: ClientMetadata
97
136
  readonly responseMode: OAuthResponseMode
@@ -132,6 +171,8 @@ export class OAuthClient {
132
171
  runtimeImplementation,
133
172
  keyset,
134
173
  }: OAuthClientOptions) {
174
+ super()
175
+
135
176
  this.keyset = keyset
136
177
  ? keyset instanceof Keyset
137
178
  ? keyset
@@ -177,6 +218,15 @@ export class OAuthClient {
177
218
  this.runtime,
178
219
  )
179
220
  this.stateStore = stateStore
221
+
222
+ // Proxy sessionGetter events
223
+ for (const type of ['deleted', 'updated'] as const) {
224
+ this.sessionGetter.addEventListener(type, (event) => {
225
+ if (!this.dispatchCustomEvent(type, event.detail)) {
226
+ event.preventDefault()
227
+ }
228
+ })
229
+ }
180
230
  }
181
231
 
182
232
  // Exposed as public API for convenience
@@ -194,10 +244,11 @@ export class OAuthClient {
194
244
  return this.identityResolver.handleResolver
195
245
  }
196
246
 
197
- async authorize(
198
- input: string,
199
- options?: AuthorizeOptions & { signal?: AbortSignal },
200
- ): Promise<URL> {
247
+ get jwks() {
248
+ return this.keyset?.publicJwks ?? ({ keys: [] as const } as const)
249
+ }
250
+
251
+ async authorize(input: string, options?: AuthorizeOptions): Promise<URL> {
201
252
  const redirectUri =
202
253
  options?.redirect_uri ?? this.clientMetadata.redirect_uris[0]
203
254
  if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
@@ -239,7 +290,9 @@ export class OAuthClient {
239
290
  code_challenge_method: pkce?.method,
240
291
  nonce,
241
292
  state,
242
- login_hint: identity?.did || undefined,
293
+ login_hint: identity
294
+ ? input // If input is a handle or a DID, use it as a login_hint
295
+ : undefined,
243
296
  response_mode: this.responseMode,
244
297
  response_type:
245
298
  // Negotiate by using the order in the client metadata
@@ -297,6 +350,22 @@ export class OAuthClient {
297
350
  )
298
351
  }
299
352
 
353
+ /**
354
+ * This method allows the client to proactively revoke the request_uri it
355
+ * created through PAR.
356
+ */
357
+ async abortRequest(authorizeUrl: URL) {
358
+ const requestUri = authorizeUrl.searchParams.get('request_uri')
359
+ if (!requestUri) return
360
+
361
+ // @NOTE This is not implemented here because, 1) the request server should
362
+ // invalidate the request_uri after some delay anyways, and 2) I am not sure
363
+ // that the revocation endpoint is even supposed to support this (and I
364
+ // don't want to spend the time checking now).
365
+
366
+ // @TODO investigate actual necessity & feasibility of this feature
367
+ }
368
+
300
369
  async callback(params: URLSearchParams): Promise<{
301
370
  agent: OAuthAgent
302
371
  state: string | null
@@ -424,17 +493,30 @@ export class OAuthClient {
424
493
  }
425
494
 
426
495
  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)
496
+ const { dpopKey, tokenSet } = await this.sessionGetter.getSession(
497
+ sub,
498
+ false,
499
+ )
432
500
 
433
- await server.revoke(tokenSet.access_token)
434
- await this.sessionGetter.delStored(sub)
501
+ // NOT using `;(await this.restore(sub, false)).signOut()` because we want
502
+ // the tokens to be deleted even if it was not possible to fetch the issuer
503
+ // data.
504
+ try {
505
+ const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
506
+ await server.revoke(tokenSet.access_token)
507
+ } finally {
508
+ await this.sessionGetter.delStored(sub, new TokenRevokedError(sub))
509
+ }
435
510
  }
436
511
 
437
512
  createAgent(server: OAuthServerAgent, sub: string): OAuthAgent {
438
- return new OAuthAgent(server, sub, this.sessionGetter, this.fetch)
513
+ const oauthAgent = new OAuthAgent(
514
+ server,
515
+ sub,
516
+ this.sessionGetter,
517
+ this.fetch,
518
+ )
519
+
520
+ return oauthAgent
439
521
  }
440
522
  }
@@ -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,