@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
@@ -4,12 +4,15 @@ import {
4
4
  SimpleStore,
5
5
  } from '@atproto-labs/simple-store'
6
6
  import { Key } from '@atproto/jwk'
7
+
8
+ import { TokenInvalidError } from './errors/token-invalid-error.js'
9
+ import { TokenRefreshError } from './errors/token-refresh-error.js'
10
+ import { TokenRevokedError } from './errors/token-revoked-error.js'
7
11
  import { OAuthResponseError } from './oauth-response-error.js'
8
12
  import { TokenSet } from './oauth-server-agent.js'
9
13
  import { OAuthServerFactory } from './oauth-server-factory.js'
10
- import { RefreshError } from './refresh-error.js'
11
14
  import { Runtime } from './runtime.js'
12
- import { withSignal } from './util.js'
15
+ import { CustomEventTarget, timeoutSignal } from './util.js'
13
16
 
14
17
  export type Session = {
15
18
  dpopKey: Key
@@ -18,6 +21,20 @@ export type Session = {
18
21
 
19
22
  export type SessionStore = SimpleStore<string, Session>
20
23
 
24
+ export type SessionEventMap = {
25
+ updated: {
26
+ sub: string
27
+ } & Session
28
+ deleted: {
29
+ sub: string
30
+ cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown
31
+ }
32
+ }
33
+
34
+ export type SessionEventListener<
35
+ T extends keyof SessionEventMap = keyof SessionEventMap,
36
+ > = (event: CustomEvent<SessionEventMap[T]>) => void
37
+
21
38
  /**
22
39
  * There are several advantages to wrapping the sessionStore in a (single)
23
40
  * CachedGetter, the main of which is that the cached getter will ensure that at
@@ -26,33 +43,39 @@ export type SessionStore = SimpleStore<string, Session>
26
43
  * localStorage/indexedDB, will sync across multiple tabs (for a given sub).
27
44
  */
28
45
  export class SessionGetter extends CachedGetter<string, Session> {
46
+ private readonly eventTarget = new CustomEventTarget<SessionEventMap>()
47
+
29
48
  constructor(
30
49
  sessionStore: SessionStore,
31
50
  serverFactory: OAuthServerFactory,
32
51
  private readonly runtime: Runtime,
33
52
  ) {
34
53
  super(
35
- async (sub, options, storedSession) => {
54
+ async (sub, options, storedSession): Promise<Session> => {
36
55
  // There needs to be a previous session to be able to refresh. If
37
56
  // storedSession is undefined, it means that the store does not contain
38
- // a session for the given sub. Since this might have been caused by the
39
- // value being cleared in another process (e.g. another tab), we will
40
- // give a chance to the process running this code to detect that the
41
- // session was revoked. This should allow processes not implementing a
42
- // subscribe/notify between instances to still be "notified" that the
43
- // session was revoked.
57
+ // a session for the given sub.
44
58
  if (storedSession === undefined) {
45
- // Because the session is not in the store, the sessionStore.del
46
- // function will not be called, even if the "deleteOnError" callback
47
- // returns true when the error is an "OAuthRefreshError". Let's
48
- // call it here manually.
49
- await sessionStore.del(sub)
50
- throw new RefreshError(sub, 'The session was revoked')
59
+ // Because the session is not in the store, this.delStored() method
60
+ // will not be called by the CachedGetter class (because there is
61
+ // nothing to delete). This would typically happen if there is no
62
+ // synchronization mechanism between instances of this class. Let's
63
+ // make sure an event is dispatched here if this occurs.
64
+ const msg = 'The session was deleted by another process'
65
+ const cause = new TokenRefreshError(sub, msg)
66
+ this.dispatchEvent('deleted', { sub, cause })
67
+ throw cause
51
68
  }
52
69
 
70
+ // From this point forward, throwing a TokenRefreshError will result in
71
+ // this.delStored() being called, resulting in an event being
72
+ // dispatched, even if the session was removed from the store through a
73
+ // concurrent access (which, normally, should not happen if a proper
74
+ // runtime lock was provided).
75
+
53
76
  if (sub !== storedSession.tokenSet.sub) {
54
77
  // Fool-proofing (e.g. against invalid session storage)
55
- throw new RefreshError(sub, 'Stored session sub mismatch')
78
+ throw new TokenRefreshError(sub, 'Stored session sub mismatch')
56
79
  }
57
80
 
58
81
  // Since refresh tokens can only be used once, we might run into
@@ -70,61 +93,67 @@ export class SessionGetter extends CachedGetter<string, Session> {
70
93
  const { tokenSet, dpopKey } = storedSession
71
94
  const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
72
95
 
73
- // We must not use the "signal" to cancel the refresh or its storage in
74
- // case of successful refresh. If we obtain a new refresh token, we must
75
- // ensure that is gets stored in the session store (by returning the new
76
- // session object). Failing to do so would result in the new credentials
77
- // being lost.
96
+ // Because refresh tokens can only be used once, we must not use the
97
+ // "signal" to abort the refresh, or throw any abort error beyond this
98
+ // point. Any thrown error beyond this point will prevent the
99
+ // SessionGetter from obtaining, and storing, the new token set,
100
+ // effectively rendering the currently saved session unusable.
78
101
  options?.signal?.throwIfAborted()
79
102
 
80
- const newTokenSet = await server
81
- .refresh(tokenSet)
82
- .catch(async (cause) => {
83
- if (
84
- cause instanceof OAuthResponseError &&
85
- cause.status === 400 &&
86
- cause.error === 'invalid_grant'
87
- ) {
88
- // In case there is no lock implementation in the runtime, we will
89
- // wait for a short time to give the other concurrent instances a
90
- // chance to finish their refreshing of the token. If a concurrent
91
- // refresh did occur, we will pretend that this one succeeded.
92
- if (!runtime.hasLock) {
93
- await new Promise((r) => setTimeout(r, 1000))
94
-
95
- const stored = await this.getStored(sub)
96
- if (stored === undefined) {
97
- // Using a distinct error message mainly for debugging
98
- // purposes
99
- const msg = 'The session was revoked by another process'
100
- throw new RefreshError(sub, msg, { cause })
101
- } else if (
102
- stored.tokenSet.access_token !== tokenSet.access_token ||
103
- stored.tokenSet.refresh_token !== tokenSet.refresh_token
104
- ) {
105
- // A concurrent refresh occurred. Pretend this one succeeded.
106
- return stored.tokenSet
107
- } else {
108
- // There were no concurrent refresh. The token is (likely)
109
- // simply no longer valid.
110
- }
103
+ try {
104
+ const newTokenSet = await server.refresh(tokenSet)
105
+
106
+ if (sub !== newTokenSet.sub) {
107
+ // The server returned another sub. Was the tokenSet manipulated?
108
+ throw new TokenRefreshError(sub, 'Token set sub mismatch')
109
+ }
110
+
111
+ return { dpopKey, tokenSet: newTokenSet }
112
+ } catch (cause) {
113
+ // If the refresh token is invalid, let's try to recover from
114
+ // concurrency issues, or make sure the session is deleted by throwing
115
+ // a TokenRefreshError.
116
+ if (
117
+ cause instanceof OAuthResponseError &&
118
+ cause.status === 400 &&
119
+ cause.error === 'invalid_grant'
120
+ ) {
121
+ // In case there is no lock implementation in the runtime, we will
122
+ // wait for a short time to give the other concurrent instances a
123
+ // chance to finish their refreshing of the token. If a concurrent
124
+ // refresh did occur, we will pretend that this one succeeded.
125
+ if (!runtime.hasImplementationLock) {
126
+ await new Promise((r) => setTimeout(r, 1000))
127
+
128
+ const stored = await this.getStored(sub)
129
+ if (stored === undefined) {
130
+ // A concurrent refresh occurred and caused the session to be
131
+ // deleted (for a reason we can't know at this point).
132
+
133
+ // Using a distinct error message mainly for debugging
134
+ // purposes. Also, throwing a TokenRefreshError to trigger
135
+ // deletion through the deleteOnError callback.
136
+ const msg = 'The session was deleted by another process'
137
+ throw new TokenRefreshError(sub, msg, { cause })
138
+ } else if (
139
+ stored.tokenSet.access_token !== tokenSet.access_token ||
140
+ stored.tokenSet.refresh_token !== tokenSet.refresh_token
141
+ ) {
142
+ // A concurrent refresh occurred. Pretend this one succeeded.
143
+ return { dpopKey, tokenSet: stored.tokenSet }
144
+ } else {
145
+ // There were no concurrent refresh. The token is (likely)
146
+ // simply no longer valid.
111
147
  }
112
-
113
- // Throwing an RefreshError to trigger deletion through the
114
- // deleteOnError callback.
115
- const msg = cause.errorDescription ?? 'The session was revoked'
116
- throw new RefreshError(sub, msg, { cause })
117
148
  }
118
149
 
119
- throw cause
120
- })
150
+ // Make sure the session gets deleted from the store
151
+ const msg = cause.errorDescription ?? 'The session was revoked'
152
+ throw new TokenRefreshError(sub, msg, { cause })
153
+ }
121
154
 
122
- if (sub !== newTokenSet.sub) {
123
- // The server returned another sub. Was the tokenSet manipulated?
124
- throw new RefreshError(sub, 'Token set sub mismatch')
155
+ throw cause
125
156
  }
126
-
127
- return { ...storedSession, tokenSet: newTokenSet }
128
157
  },
129
158
  sessionStore,
130
159
  {
@@ -143,13 +172,48 @@ export class SessionGetter extends CachedGetter<string, Session> {
143
172
  await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token)
144
173
  throw err
145
174
  },
146
- deleteOnError: async (err) => {
147
- return err instanceof RefreshError
148
- },
175
+ deleteOnError: async (err) =>
176
+ // Optimization: More likely to happen first
177
+ err instanceof TokenRefreshError ||
178
+ err instanceof TokenRevokedError ||
179
+ err instanceof TokenInvalidError,
149
180
  },
150
181
  )
151
182
  }
152
183
 
184
+ addEventListener<T extends keyof SessionEventMap>(
185
+ type: T,
186
+ callback: SessionEventListener<T>,
187
+ options?: AddEventListenerOptions | boolean,
188
+ ) {
189
+ this.eventTarget.addEventListener(type, callback, options)
190
+ }
191
+
192
+ removeEventListener<T extends keyof SessionEventMap>(
193
+ type: T,
194
+ callback: SessionEventListener<T>,
195
+ options?: EventListenerOptions | boolean,
196
+ ) {
197
+ this.eventTarget.removeEventListener(type, callback, options)
198
+ }
199
+
200
+ dispatchEvent<T extends keyof SessionEventMap>(
201
+ type: T,
202
+ detail: SessionEventMap[T],
203
+ ): boolean {
204
+ return this.eventTarget.dispatchCustomEvent(type, detail)
205
+ }
206
+
207
+ async setStored(sub: string, session: Session) {
208
+ await super.setStored(sub, session)
209
+ this.dispatchEvent('updated', { sub, ...session })
210
+ }
211
+
212
+ async delStored(sub: string, cause?: unknown): Promise<void> {
213
+ await super.delStored(sub, cause)
214
+ this.dispatchEvent('deleted', { sub, cause })
215
+ }
216
+
153
217
  /**
154
218
  * @param refresh When `true`, the credentials will be refreshed even if they
155
219
  * are not expired. When `false`, the credentials will not be refreshed even
@@ -171,12 +235,12 @@ export class SessionGetter extends CachedGetter<string, Session> {
171
235
  }
172
236
 
173
237
  async get(sub: string, options?: GetCachedOptions): Promise<Session> {
174
- return this.runtime.withLock(`@atproto-oauth-client-${sub}`, async () => {
238
+ return this.runtime.usingLock(`@atproto-oauth-client-${sub}`, async () => {
175
239
  // Make sure, even if there is no signal in the options, that the request
176
240
  // will be cancelled after at most 30 seconds.
177
- return withSignal({ signal: options?.signal, timeout: 30e3 }, (signal) =>
178
- super.get(sub, { ...options, signal }),
179
- )
241
+ using signal = timeoutSignal(30e3, options)
242
+
243
+ return await super.get(sub, { ...options, signal })
180
244
  })
181
245
  }
182
246
  }
@@ -0,0 +1,12 @@
1
+ import { SimpleStore } from '@atproto-labs/simple-store'
2
+ import { Key } from '@atproto/jwk'
3
+
4
+ export type InternalStateData = {
5
+ iss: string
6
+ nonce: string
7
+ dpopKey: Key
8
+ verifier?: string
9
+ appState?: string
10
+ }
11
+
12
+ export type StateStore = SimpleStore<string, InternalStateData>
package/src/types.ts CHANGED
@@ -11,12 +11,15 @@ import z from 'zod'
11
11
  export type AuthorizeOptions = {
12
12
  display?: 'page' | 'popup' | 'touch' | 'wap'
13
13
  redirect_uri?: string
14
- id_token_hint?: string
15
- max_age?: number
16
14
  prompt?: 'login' | 'none' | 'consent' | 'select_account'
17
15
  scope?: string
18
16
  state?: string
17
+ signal?: AbortSignal
18
+
19
+ // Only for OIDC compatible
19
20
  ui_locales?: string
21
+ id_token_hint?: string
22
+ max_age?: number
20
23
  }
21
24
 
22
25
  export const clientMetadataSchema = oauthClientMetadataSchema.extend({
package/src/util.ts CHANGED
@@ -1,51 +1,82 @@
1
+ export type Awaitable<T> = T | PromiseLike<T>
2
+
3
+ // @ts-expect-error
4
+ Symbol.dispose ??= Symbol('@@dispose')
5
+
1
6
  /**
2
7
  * @todo (?) move to common package
3
8
  */
4
- export const withSignal = async <T>(
5
- options:
6
- | undefined
7
- | {
8
- signal?: AbortSignal
9
- timeout: number
10
- },
11
- fn: (signal: AbortSignal) => T | PromiseLike<T>,
12
- ): Promise<T> => {
9
+ export const timeoutSignal = (
10
+ timeout: number,
11
+ options?: { signal?: AbortSignal },
12
+ ): AbortSignal & Disposable => {
13
+ if (!Number.isInteger(timeout) || timeout < 0) {
14
+ throw new TypeError('Expected a positive integer')
15
+ }
16
+
13
17
  options?.signal?.throwIfAborted()
14
18
 
15
- const abortController = new AbortController()
16
- const { signal } = abortController
19
+ const controller = new AbortController()
20
+ const { signal } = controller
17
21
 
18
22
  options?.signal?.addEventListener(
19
23
  'abort',
20
- (reason) => abortController.abort(reason),
24
+ (reason) => controller.abort(reason),
21
25
  { once: true, signal },
22
26
  )
23
27
 
24
- if (options?.timeout != null) {
25
- const timeoutId = setTimeout(
26
- (err) => abortController.abort(err),
27
- options.timeout,
28
- new Error('Timeout'),
29
- )
28
+ const timeoutId = setTimeout(
29
+ (err) => controller.abort(err),
30
+ timeout,
31
+ // create Error here to keep original stack trace
32
+ new Error('Timeout'),
33
+ )
30
34
 
31
- timeoutId.unref?.() // NodeJS only
35
+ timeoutId?.unref?.() // NodeJS only
32
36
 
33
- signal.addEventListener('abort', () => clearTimeout(timeoutId), {
34
- once: true,
35
- signal,
36
- })
37
- }
37
+ signal.addEventListener('abort', () => clearTimeout(timeoutId), {
38
+ once: true,
39
+ signal,
40
+ })
38
41
 
39
- try {
40
- return await fn(signal)
41
- } finally {
42
- // - Remove listener on incoming signal
43
- // - Cancel timeout
44
- // - Cancel pending (async) tasks
45
- abortController.abort()
46
- }
42
+ Object.defineProperty(signal, Symbol.dispose, {
43
+ value: () => controller.abort(),
44
+ })
45
+
46
+ return signal as AbortSignal & Disposable
47
47
  }
48
48
 
49
49
  export function contentMime(headers: Headers): string | undefined {
50
50
  return headers.get('content-type')?.split(';')[0]!.trim()
51
51
  }
52
+
53
+ export class CustomEventTarget<EventDetailMap extends Record<string, unknown>> {
54
+ readonly eventTarget = new EventTarget()
55
+
56
+ addEventListener<T extends Extract<keyof EventDetailMap, string>>(
57
+ type: T,
58
+ callback: (event: CustomEvent<EventDetailMap[T]>) => void,
59
+ options?: AddEventListenerOptions | boolean,
60
+ ): void {
61
+ this.eventTarget.addEventListener(type, callback as EventListener, options)
62
+ }
63
+
64
+ removeEventListener<T extends Extract<keyof EventDetailMap, string>>(
65
+ type: T,
66
+ callback: (event: CustomEvent<EventDetailMap[T]>) => void,
67
+ options?: EventListenerOptions | boolean,
68
+ ): void {
69
+ this.eventTarget.removeEventListener(
70
+ type,
71
+ callback as EventListener,
72
+ options,
73
+ )
74
+ }
75
+
76
+ dispatchCustomEvent<T extends Extract<keyof EventDetailMap, string>>(
77
+ type: T,
78
+ detail: EventDetailMap[T],
79
+ ): boolean {
80
+ return this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }))
81
+ }
82
+ }
@@ -16,6 +16,24 @@ export function validateClientMetadata(
16
16
  input: OAuthClientMetadataInput,
17
17
  keyset?: Keyset,
18
18
  ): ClientMetadata {
19
+ if (input.jwks) {
20
+ if (!keyset) {
21
+ throw new TypeError(`Keyset must not be provided when jwks is provided`)
22
+ }
23
+ for (const key of input.jwks.keys) {
24
+ if (!key.kid) {
25
+ throw new TypeError(`Key must have a "kid" property`)
26
+ } else if (!keyset.has(key.kid)) {
27
+ throw new TypeError(`Key with kid "${key.kid}" not found in keyset`)
28
+ }
29
+ }
30
+ }
31
+
32
+ // Allow to pass a keyset and omit the jwks/jwks_uri properties
33
+ if (!input.jwks && !input.jwks_uri && keyset?.size) {
34
+ input = { ...input, jwks: keyset.toJSON() }
35
+ }
36
+
19
37
  const metadata = clientMetadataSchema.parse(input)
20
38
 
21
39
  // ATPROTO uses client metadata discovery