@atproto/oauth-client 0.1.0 → 0.1.1

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