@atproto/oauth-client 0.5.14 → 0.6.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 (45) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/oauth-authorization-server-metadata-resolver.d.ts +1 -1
  3. package/dist/oauth-authorization-server-metadata-resolver.d.ts.map +1 -1
  4. package/dist/oauth-authorization-server-metadata-resolver.js +1 -1
  5. package/dist/oauth-authorization-server-metadata-resolver.js.map +1 -1
  6. package/dist/oauth-client.d.ts +7 -8
  7. package/dist/oauth-client.d.ts.map +1 -1
  8. package/dist/oauth-client.js +27 -26
  9. package/dist/oauth-client.js.map +1 -1
  10. package/dist/oauth-protected-resource-metadata-resolver.d.ts +3 -3
  11. package/dist/oauth-protected-resource-metadata-resolver.d.ts.map +1 -1
  12. package/dist/oauth-protected-resource-metadata-resolver.js +4 -0
  13. package/dist/oauth-protected-resource-metadata-resolver.js.map +1 -1
  14. package/dist/oauth-resolver.d.ts +1 -1
  15. package/dist/oauth-resolver.d.ts.map +1 -1
  16. package/dist/oauth-resolver.js +3 -0
  17. package/dist/oauth-resolver.js.map +1 -1
  18. package/dist/oauth-server-factory.d.ts +1 -1
  19. package/dist/oauth-server-factory.d.ts.map +1 -1
  20. package/dist/oauth-server-factory.js +0 -7
  21. package/dist/oauth-server-factory.js.map +1 -1
  22. package/dist/oauth-session.d.ts.map +1 -1
  23. package/dist/oauth-session.js +1 -4
  24. package/dist/oauth-session.js.map +1 -1
  25. package/dist/session-getter.d.ts +16 -21
  26. package/dist/session-getter.d.ts.map +1 -1
  27. package/dist/session-getter.js +65 -60
  28. package/dist/session-getter.js.map +1 -1
  29. package/dist/state-store.d.ts +13 -3
  30. package/dist/state-store.d.ts.map +1 -1
  31. package/dist/state-store.js.map +1 -1
  32. package/dist/util.d.ts +0 -10
  33. package/dist/util.d.ts.map +1 -1
  34. package/dist/util.js +1 -64
  35. package/dist/util.js.map +1 -1
  36. package/package.json +11 -11
  37. package/src/oauth-authorization-server-metadata-resolver.ts +2 -2
  38. package/src/oauth-client.ts +47 -50
  39. package/src/oauth-protected-resource-metadata-resolver.ts +9 -4
  40. package/src/oauth-resolver.ts +5 -1
  41. package/src/oauth-server-factory.ts +2 -16
  42. package/src/oauth-session.ts +1 -4
  43. package/src/session-getter.ts +85 -102
  44. package/src/state-store.ts +13 -3
  45. package/src/util.ts +0 -67
@@ -3,6 +3,7 @@ import { Key } from '@atproto/jwk'
3
3
  import {
4
4
  CachedGetter,
5
5
  GetCachedOptions,
6
+ GetOptions,
6
7
  SimpleStore,
7
8
  } from '@atproto-labs/simple-store'
8
9
  import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
@@ -14,32 +15,35 @@ import { OAuthResponseError } from './oauth-response-error.js'
14
15
  import { TokenSet } from './oauth-server-agent.js'
15
16
  import { OAuthServerFactory } from './oauth-server-factory.js'
16
17
  import { Runtime } from './runtime.js'
17
- import { CustomEventTarget, combineSignals } from './util.js'
18
+ import { combineSignals } from './util.js'
18
19
 
19
20
  export type Session = {
20
21
  dpopKey: Key
21
- /**
22
- * Previous implementation of this lib did not define an `authMethod`
23
- */
24
- authMethod?: ClientAuthMethod
22
+ authMethod: ClientAuthMethod
25
23
  tokenSet: TokenSet
26
24
  }
27
25
 
28
26
  export type SessionStore = SimpleStore<string, Session>
29
27
 
30
- export type SessionEventMap = {
31
- updated: {
32
- sub: string
33
- } & Session
34
- deleted: {
35
- sub: string
36
- cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown
37
- }
28
+ export type SessionHooks = {
29
+ onUpdate?: (sub: AtprotoDid, session: Session) => void
30
+ onDelete?: (
31
+ sub: AtprotoDid,
32
+ cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown,
33
+ ) => void
38
34
  }
39
35
 
40
- export type SessionEventListener<
41
- T extends keyof SessionEventMap = keyof SessionEventMap,
42
- > = (event: CustomEvent<SessionEventMap[T]>) => void
36
+ export function isExpectedSessionError(err: unknown) {
37
+ return (
38
+ err instanceof TokenRefreshError ||
39
+ err instanceof TokenRevokedError ||
40
+ err instanceof TokenInvalidError ||
41
+ err instanceof AuthMethodUnsatisfiableError ||
42
+ // The stored session is invalid (e.g. missing properties) and cannot
43
+ // be used properly
44
+ err instanceof TypeError
45
+ )
46
+ }
43
47
 
44
48
  /**
45
49
  * There are several advantages to wrapping the sessionStore in a (single)
@@ -49,15 +53,14 @@ export type SessionEventListener<
49
53
  * localStorage/indexedDB, will sync across multiple tabs (for a given sub).
50
54
  */
51
55
  export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
52
- private readonly eventTarget = new CustomEventTarget<SessionEventMap>()
53
-
54
56
  constructor(
55
57
  sessionStore: SessionStore,
56
58
  serverFactory: OAuthServerFactory,
57
59
  private readonly runtime: Runtime,
60
+ private readonly hooks: SessionHooks = {},
58
61
  ) {
59
62
  super(
60
- async (sub, options, storedSession) => {
63
+ async (sub, { signal }, storedSession) => {
61
64
  // There needs to be a previous session to be able to refresh. If
62
65
  // storedSession is undefined, it means that the store does not contain
63
66
  // a session for the given sub.
@@ -69,17 +72,15 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
69
72
  // make sure an event is dispatched here if this occurs.
70
73
  const msg = 'The session was deleted by another process'
71
74
  const cause = new TokenRefreshError(sub, msg)
72
- this.dispatchEvent('deleted', { sub, cause })
75
+ await hooks.onDelete?.call(null, sub, cause)
73
76
  throw cause
74
77
  }
75
78
 
76
- // From this point forward, throwing a TokenRefreshError will result in
77
- // this.delStored() being called, resulting in an event being
78
- // dispatched, even if the session was removed from the store through a
79
- // concurrent access (which, normally, should not happen if a proper
80
- // runtime lock was provided).
79
+ // @NOTE Throwing a TokenRefreshError (or any other error class defined
80
+ // in the deleteOnError options) will result in this.delStored() being
81
+ // called.
81
82
 
82
- const { dpopKey, authMethod = 'legacy', tokenSet } = storedSession
83
+ const { dpopKey, authMethod, tokenSet } = storedSession
83
84
 
84
85
  if (sub !== tokenSet.sub) {
85
86
  // Fool-proofing (e.g. against invalid session storage)
@@ -90,16 +91,6 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
90
91
  throw new TokenRefreshError(sub, 'No refresh token available')
91
92
  }
92
93
 
93
- // Since refresh tokens can only be used once, we might run into
94
- // concurrency issues if multiple instances (e.g. browser tabs) are
95
- // trying to refresh the same token simultaneously. The chances of this
96
- // happening when multiple instances are started simultaneously is
97
- // reduced by randomizing the expiry time (see isStale() below). The
98
- // best solution is to use a mutex/lock to ensure that only one instance
99
- // is refreshing the token at a time (runtime.usingLock) but that is not
100
- // always possible. If no lock implementation is provided, we will use
101
- // the store to check if a concurrent refresh occurred.
102
-
103
94
  const server = await serverFactory.fromIssuer(
104
95
  tokenSet.iss,
105
96
  authMethod,
@@ -111,7 +102,7 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
111
102
  // point. Any thrown error beyond this point will prevent the
112
103
  // TokenGetter from obtaining, and storing, the new token set,
113
104
  // effectively rendering the currently saved session unusable.
114
- options?.signal?.throwIfAborted()
105
+ signal?.throwIfAborted()
115
106
 
116
107
  try {
117
108
  const newTokenSet = await server.refresh(tokenSet)
@@ -127,9 +118,16 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
127
118
  authMethod: server.authMethod,
128
119
  }
129
120
  } catch (cause) {
130
- // If the refresh token is invalid, let's try to recover from
131
- // concurrency issues, or make sure the session is deleted by throwing
132
- // a TokenRefreshError.
121
+ // Since refresh tokens can only be used once, we might run into
122
+ // concurrency issues if multiple instances (e.g. browser tabs) are
123
+ // trying to refresh the same token simultaneously. The chances of
124
+ // this happening when multiple instances are started simultaneously
125
+ // is reduced by randomizing the expiry time (see isStale() below).
126
+ // The best solution is to use a mutex/lock to ensure that only one
127
+ // instance is refreshing the token at a time (runtime.usingLock) but
128
+ // that is not always possible. Let's try to recover from concurrency
129
+ // issues, or force the session to be deleted by throwing a
130
+ // TokenRefreshError.
133
131
  if (
134
132
  cause instanceof OAuthResponseError &&
135
133
  cause.status === 400 &&
@@ -187,91 +185,63 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
187
185
  30e3 * Math.random()
188
186
  )
189
187
  },
190
- onStoreError: async (
191
- err,
192
- sub,
193
- { tokenSet, dpopKey, authMethod = 'legacy' as const },
194
- ) => {
195
- if (!(err instanceof AuthMethodUnsatisfiableError)) {
196
- // If the error was an AuthMethodUnsatisfiableError, there is no
197
- // point in trying to call `fromIssuer`.
198
- try {
199
- // If the token data cannot be stored, let's revoke it
200
- const server = await serverFactory.fromIssuer(
201
- tokenSet.iss,
202
- authMethod,
203
- dpopKey,
204
- )
205
- await server.revoke(
206
- tokenSet.refresh_token ?? tokenSet.access_token,
207
- )
208
- } catch {
209
- // Let the original error propagate
210
- }
188
+ onStoreError: async (err, sub, { tokenSet, dpopKey, authMethod }) => {
189
+ // If the token data cannot be stored, let's revoke it
190
+ try {
191
+ const server = await serverFactory.fromIssuer(
192
+ tokenSet.iss,
193
+ authMethod,
194
+ dpopKey,
195
+ )
196
+ await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token)
197
+ } catch {
198
+ // At least we tried...
199
+ }
200
+
201
+ // Attempt to delete the session from the store. Note that this might
202
+ // fail if the store is not available, which is fine.
203
+ try {
204
+ await this.delStored(sub, err)
205
+ } catch {
206
+ // Ignore (better to propagate the original storage error)
211
207
  }
212
208
 
213
209
  throw err
214
210
  },
215
- deleteOnError: async (err) =>
216
- err instanceof TokenRefreshError ||
217
- err instanceof TokenRevokedError ||
218
- err instanceof TokenInvalidError ||
219
- err instanceof AuthMethodUnsatisfiableError,
211
+ deleteOnError: isExpectedSessionError,
220
212
  },
221
213
  )
222
214
  }
223
215
 
224
- addEventListener<T extends keyof SessionEventMap>(
225
- type: T,
226
- callback: SessionEventListener<T>,
227
- options?: AddEventListenerOptions | boolean,
228
- ) {
229
- this.eventTarget.addEventListener(type, callback, options)
230
- }
231
-
232
- removeEventListener<T extends keyof SessionEventMap>(
233
- type: T,
234
- callback: SessionEventListener<T>,
235
- options?: EventListenerOptions | boolean,
236
- ) {
237
- this.eventTarget.removeEventListener(type, callback, options)
238
- }
239
-
240
- dispatchEvent<T extends keyof SessionEventMap>(
241
- type: T,
242
- detail: SessionEventMap[T],
243
- ): boolean {
244
- return this.eventTarget.dispatchCustomEvent(type, detail)
216
+ override async getStored(
217
+ sub: AtprotoDid,
218
+ options?: GetOptions,
219
+ ): Promise<Session | undefined> {
220
+ return super.getStored(sub, options)
245
221
  }
246
222
 
247
- async setStored(sub: string, session: Session) {
223
+ override async setStored(sub: AtprotoDid, session: Session) {
248
224
  // Prevent tampering with the stored value
249
225
  if (sub !== session.tokenSet.sub) {
250
226
  throw new TypeError('Token set does not match the expected sub')
251
227
  }
252
228
  await super.setStored(sub, session)
253
- this.dispatchEvent('updated', { sub, ...session })
229
+ await this.hooks.onUpdate?.call(null, sub, session)
254
230
  }
255
231
 
256
232
  override async delStored(sub: AtprotoDid, cause?: unknown): Promise<void> {
257
233
  await super.delStored(sub, cause)
258
- this.dispatchEvent('deleted', { sub, cause })
234
+ await this.hooks.onDelete?.call(null, sub, cause)
259
235
  }
260
236
 
261
237
  /**
262
- * @param refresh When `true`, the credentials will be refreshed even if they
263
- * are not expired. When `false`, the credentials will not be refreshed even
264
- * if they are expired. When `undefined`, the credentials will be refreshed
265
- * if, and only if, they are (about to be) expired. Defaults to `undefined`.
238
+ * @deprecated Use {@link getSession} instead
239
+ * @internal (not really deprecated)
266
240
  */
267
- async getSession(sub: AtprotoDid, refresh: boolean | 'auto' = 'auto') {
268
- return this.get(sub, {
269
- noCache: refresh === true,
270
- allowStale: refresh === false,
271
- })
272
- }
273
-
274
- async get(sub: AtprotoDid, options?: GetCachedOptions): Promise<Session> {
241
+ override async get(
242
+ sub: AtprotoDid,
243
+ options?: GetCachedOptions,
244
+ ): Promise<Session> {
275
245
  const session = await this.runtime.usingLock(
276
246
  `@atproto-oauth-client-${sub}`,
277
247
  async () => {
@@ -295,4 +265,17 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
295
265
 
296
266
  return session
297
267
  }
268
+
269
+ /**
270
+ * @param refresh When `true`, the credentials will be refreshed even if they
271
+ * are not expired. When `false`, the credentials will not be refreshed even
272
+ * if they are expired. When `undefined`, the credentials will be refreshed
273
+ * if, and only if, they are (about to be) expired. Defaults to `undefined`.
274
+ */
275
+ async getSession(sub: AtprotoDid, refresh: boolean | 'auto' = 'auto') {
276
+ return this.get(sub, {
277
+ noCache: refresh === true,
278
+ allowStale: refresh === false,
279
+ })
280
+ }
298
281
  }
@@ -5,10 +5,20 @@ import { ClientAuthMethod } from './oauth-client-auth.js'
5
5
  export type InternalStateData = {
6
6
  iss: string
7
7
  dpopKey: Key
8
- /** @note optional for legacy reasons */
9
- authMethod?: ClientAuthMethod
10
- verifier?: string
8
+ authMethod: ClientAuthMethod
9
+ verifier: string
11
10
  appState?: string
12
11
  }
13
12
 
13
+ /**
14
+ * A store pending oauth authorization flows. The key is the "state" parameter
15
+ * used in the authorization request, and the value is an object containing the
16
+ * necessary information to complete the flow once the user is redirected back
17
+ * to the client.
18
+ *
19
+ * @note The data stored in this store is typically short-lived. It should be
20
+ * automatically cleared after a certain period of time (e.g. 1 hour) to prevent
21
+ * the store from growing indefinitely. It is up to the implementation to
22
+ * implement this cleanup mechanism.
23
+ */
14
24
  export type StateStore = SimpleStore<string, InternalStateData>
package/src/util.ts CHANGED
@@ -7,73 +7,6 @@ export function contentMime(headers: Headers): string | undefined {
7
7
  return headers.get('content-type')?.split(';')[0]!.trim()
8
8
  }
9
9
 
10
- /**
11
- * Ponyfill for `CustomEvent` constructor.
12
- */
13
- export const CustomEvent: typeof globalThis.CustomEvent =
14
- globalThis.CustomEvent ??
15
- (() => {
16
- class CustomEvent<T> extends Event {
17
- #detail: T | null
18
- constructor(type: string, options?: CustomEventInit<T>) {
19
- if (!arguments.length) throw new TypeError('type argument is required')
20
- super(type, options)
21
- this.#detail = options?.detail ?? null
22
- }
23
- get detail() {
24
- return this.#detail
25
- }
26
- }
27
-
28
- Object.defineProperties(CustomEvent.prototype, {
29
- [Symbol.toStringTag]: {
30
- writable: false,
31
- enumerable: false,
32
- configurable: true,
33
- value: 'CustomEvent',
34
- },
35
- detail: {
36
- enumerable: true,
37
- },
38
- })
39
-
40
- return CustomEvent
41
- })()
42
-
43
- export class CustomEventTarget<EventDetailMap extends Record<string, unknown>> {
44
- readonly eventTarget = new EventTarget()
45
-
46
- addEventListener<T extends Extract<keyof EventDetailMap, string>>(
47
- type: T,
48
- callback: (event: CustomEvent<EventDetailMap[T]>) => void,
49
- options?: AddEventListenerOptions | boolean,
50
- ): void {
51
- this.eventTarget.addEventListener(type, callback as EventListener, options)
52
- }
53
-
54
- removeEventListener<T extends Extract<keyof EventDetailMap, string>>(
55
- type: T,
56
- callback: (event: CustomEvent<EventDetailMap[T]>) => void,
57
- options?: EventListenerOptions | boolean,
58
- ): void {
59
- this.eventTarget.removeEventListener(
60
- type,
61
- callback as EventListener,
62
- options,
63
- )
64
- }
65
-
66
- dispatchCustomEvent<T extends Extract<keyof EventDetailMap, string>>(
67
- type: T,
68
- detail: EventDetailMap[T],
69
- init?: EventInit,
70
- ): boolean {
71
- return this.eventTarget.dispatchEvent(
72
- new CustomEvent(type, { ...init, detail }),
73
- )
74
- }
75
- }
76
-
77
10
  export function combineSignals(
78
11
  signals: readonly (AbortSignal | undefined)[],
79
12
  ): AbortController & Disposable {