@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
@@ -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