@atproto/oauth-client 0.1.0 → 0.1.2-rc.0

Sign up to get free protection for your applications and to get access to all the features.
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,