@atproto/oauth-client 0.2.2 → 0.3.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 (57) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +12 -6
  3. package/dist/atproto-token-response.d.ts +110 -0
  4. package/dist/atproto-token-response.d.ts.map +1 -0
  5. package/dist/atproto-token-response.js +20 -0
  6. package/dist/atproto-token-response.js.map +1 -0
  7. package/dist/fetch-dpop.js +1 -2
  8. package/dist/fetch-dpop.js.map +1 -1
  9. package/dist/oauth-authorization-server-metadata-resolver.d.ts +6 -2
  10. package/dist/oauth-authorization-server-metadata-resolver.d.ts.map +1 -1
  11. package/dist/oauth-authorization-server-metadata-resolver.js +18 -9
  12. package/dist/oauth-authorization-server-metadata-resolver.js.map +1 -1
  13. package/dist/oauth-callback-error.d.ts.map +1 -1
  14. package/dist/oauth-client.d.ts +30 -15
  15. package/dist/oauth-client.d.ts.map +1 -1
  16. package/dist/oauth-client.js +22 -13
  17. package/dist/oauth-client.js.map +1 -1
  18. package/dist/oauth-protected-resource-metadata-resolver.d.ts +5 -1
  19. package/dist/oauth-protected-resource-metadata-resolver.d.ts.map +1 -1
  20. package/dist/oauth-protected-resource-metadata-resolver.js +18 -11
  21. package/dist/oauth-protected-resource-metadata-resolver.js.map +1 -1
  22. package/dist/oauth-resolver.d.ts +1 -1
  23. package/dist/oauth-server-agent.d.ts +14 -11
  24. package/dist/oauth-server-agent.d.ts.map +1 -1
  25. package/dist/oauth-server-agent.js +66 -47
  26. package/dist/oauth-server-agent.js.map +1 -1
  27. package/dist/oauth-session.d.ts +13 -8
  28. package/dist/oauth-session.d.ts.map +1 -1
  29. package/dist/oauth-session.js +12 -7
  30. package/dist/oauth-session.js.map +1 -1
  31. package/dist/runtime.d.ts +1 -1
  32. package/dist/runtime.js.map +1 -1
  33. package/dist/session-getter.d.ts +5 -4
  34. package/dist/session-getter.d.ts.map +1 -1
  35. package/dist/session-getter.js +52 -32
  36. package/dist/session-getter.js.map +1 -1
  37. package/dist/types.d.ts +98 -102
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js.map +1 -1
  40. package/dist/util.d.ts +6 -1
  41. package/dist/util.d.ts.map +1 -1
  42. package/dist/util.js +56 -2
  43. package/dist/util.js.map +1 -1
  44. package/dist/validate-client-metadata.js +1 -2
  45. package/dist/validate-client-metadata.js.map +1 -1
  46. package/package.json +8 -8
  47. package/src/atproto-token-response.ts +22 -0
  48. package/src/oauth-authorization-server-metadata-resolver.ts +22 -8
  49. package/src/oauth-client.ts +61 -27
  50. package/src/oauth-protected-resource-metadata-resolver.ts +22 -12
  51. package/src/oauth-server-agent.ts +87 -68
  52. package/src/oauth-session.ts +21 -13
  53. package/src/runtime.ts +1 -1
  54. package/src/session-getter.ts +53 -33
  55. package/src/types.ts +16 -11
  56. package/src/util.ts +78 -0
  57. package/tsconfig.build.tsbuildinfo +1 -0
@@ -1,18 +1,23 @@
1
1
  import { Fetch, Json, bindFetch, fetchJsonProcessor } from '@atproto-labs/fetch'
2
2
  import { SimpleStore } from '@atproto-labs/simple-store'
3
+ import { AtprotoDid } from '@atproto/did'
3
4
  import { Key, Keyset } from '@atproto/jwk'
4
5
  import {
5
6
  CLIENT_ASSERTION_TYPE_JWT_BEARER,
7
+ OAuthAuthorizationRequestPar,
6
8
  OAuthAuthorizationServerMetadata,
7
9
  OAuthClientCredentials,
8
10
  OAuthEndpointName,
9
11
  OAuthParResponse,
10
- OAuthTokenResponse,
11
- OAuthTokenType,
12
+ OAuthTokenRequest,
12
13
  oauthParResponseSchema,
13
- oauthTokenResponseSchema,
14
14
  } from '@atproto/oauth-types'
15
15
 
16
+ import {
17
+ AtprotoScope,
18
+ AtprotoTokenResponse,
19
+ atprotoTokenResponseSchema,
20
+ } from './atproto-token-response.js'
16
21
  import { FALLBACK_ALG } from './constants.js'
17
22
  import { TokenRefreshError } from './errors/token-refresh-error.js'
18
23
  import { dpopFetchWrapper } from './fetch-dpop.js'
@@ -24,13 +29,13 @@ import { timeoutSignal } from './util.js'
24
29
 
25
30
  export type TokenSet = {
26
31
  iss: string
27
- sub: string
32
+ sub: AtprotoDid
28
33
  aud: string
29
- scope: string
34
+ scope: AtprotoScope
30
35
 
31
36
  refresh_token?: string
32
37
  access_token: string
33
- token_type: OAuthTokenType
38
+ token_type: 'DPoP'
34
39
  /** ISO Date */
35
40
  expires_at?: string
36
41
  }
@@ -61,6 +66,10 @@ export class OAuthServerAgent {
61
66
  })
62
67
  }
63
68
 
69
+ get issuer() {
70
+ return this.serverMetadata.issuer
71
+ }
72
+
64
73
  async revoke(token: string) {
65
74
  try {
66
75
  await this.request('revocation', { token })
@@ -69,16 +78,38 @@ export class OAuthServerAgent {
69
78
  }
70
79
  }
71
80
 
72
- async exchangeCode(code: string, verifier?: string): Promise<TokenSet> {
81
+ async exchangeCode(code: string, codeVerifier?: string): Promise<TokenSet> {
82
+ const now = Date.now()
83
+
73
84
  const tokenResponse = await this.request('token', {
74
85
  grant_type: 'authorization_code',
75
86
  redirect_uri: this.clientMetadata.redirect_uris[0]!,
76
87
  code,
77
- code_verifier: verifier,
88
+ code_verifier: codeVerifier,
78
89
  })
79
90
 
80
91
  try {
81
- return this.processTokenResponse(tokenResponse)
92
+ // /!\ IMPORTANT /!\
93
+ //
94
+ // The tokenResponse MUST always be valid before the "sub" it contains
95
+ // can be trusted (see Atproto's OAuth spec for details).
96
+ const aud = await this.verifyIssuer(tokenResponse.sub)
97
+
98
+ return {
99
+ aud,
100
+ sub: tokenResponse.sub,
101
+ iss: this.issuer,
102
+
103
+ scope: tokenResponse.scope,
104
+ refresh_token: tokenResponse.refresh_token,
105
+ access_token: tokenResponse.access_token,
106
+ token_type: tokenResponse.token_type,
107
+
108
+ expires_at:
109
+ typeof tokenResponse.expires_in === 'number'
110
+ ? new Date(now + tokenResponse.expires_in * 1000).toISOString()
111
+ : undefined,
112
+ }
82
113
  } catch (err) {
83
114
  await this.revoke(tokenResponse.access_token)
84
115
 
@@ -91,27 +122,37 @@ export class OAuthServerAgent {
91
122
  throw new TokenRefreshError(tokenSet.sub, 'No refresh token available')
92
123
  }
93
124
 
125
+ // /!\ IMPORTANT /!\
126
+ //
127
+ // The "sub" MUST be a DID, whose issuer authority is indeed the server we
128
+ // are trying to obtain credentials from. Note that we are doing this
129
+ // *before* we actually try to refresh the token:
130
+ // 1) To avoid unnecessary refresh
131
+ // 2) So that the refresh is the last async operation, ensuring as few
132
+ // async operations happen before the result gets a chance to be stored.
133
+ const aud = await this.verifyIssuer(tokenSet.sub)
134
+
135
+ const now = Date.now()
136
+
94
137
  const tokenResponse = await this.request('token', {
95
138
  grant_type: 'refresh_token',
96
139
  refresh_token: tokenSet.refresh_token,
97
140
  })
98
141
 
99
- try {
100
- if (tokenSet.sub !== tokenResponse.sub) {
101
- throw new TokenRefreshError(
102
- tokenSet.sub,
103
- `Unexpected "sub" in token response (${tokenResponse.sub})`,
104
- )
105
- }
106
- if (tokenSet.iss !== this.serverMetadata.issuer) {
107
- throw new TokenRefreshError(tokenSet.sub, 'Issuer mismatch')
108
- }
142
+ return {
143
+ aud,
144
+ sub: tokenSet.sub,
145
+ iss: this.issuer,
109
146
 
110
- return this.processTokenResponse(tokenResponse)
111
- } catch (err) {
112
- await this.revoke(tokenResponse.access_token)
147
+ scope: tokenResponse.scope,
148
+ refresh_token: tokenResponse.refresh_token,
149
+ access_token: tokenResponse.access_token,
150
+ token_type: tokenResponse.token_type,
113
151
 
114
- throw err
152
+ expires_at:
153
+ typeof tokenResponse.expires_in === 'number'
154
+ ? new Date(now + tokenResponse.expires_in * 1000).toISOString()
155
+ : undefined,
115
156
  }
116
157
  }
117
158
 
@@ -122,68 +163,46 @@ export class OAuthServerAgent {
122
163
  * "sub" is a DID, whose issuer authority is indeed the server we just
123
164
  * obtained credentials from. This check is a critical step to actually be
124
165
  * able to use the "sub" (DID) as being the actual user's identifier.
166
+ *
167
+ * @returns The user's PDS URL (the resource server for the user)
125
168
  */
126
- private async processTokenResponse(
127
- tokenResponse: OAuthTokenResponse,
128
- ): Promise<TokenSet> {
129
- const { sub } = tokenResponse
130
-
131
- if (!sub || typeof sub !== 'string') {
132
- throw new TypeError(`Unexpected ${typeof sub} "sub" in token response`)
133
- }
134
-
135
- // Using an array to check for the presence of the "atproto" scope (we don't
136
- // want atproto to be a substring of another scope)
137
- const scopes = tokenResponse.scope?.split(' ')
138
- if (!scopes?.includes('atproto')) {
139
- throw new TypeError('Missing "atproto" scope in token response')
140
- }
141
-
142
- // @TODO (?) make timeout configurable
169
+ protected async verifyIssuer(sub: AtprotoDid) {
143
170
  using signal = timeoutSignal(10e3)
144
171
 
145
172
  const resolved = await this.oauthResolver.resolveFromIdentity(sub, {
173
+ noCache: true,
174
+ allowStale: false,
146
175
  signal,
147
176
  })
148
177
 
149
- if (this.serverMetadata.issuer !== resolved.metadata.issuer) {
178
+ if (this.issuer !== resolved.metadata.issuer) {
150
179
  // Best case scenario; the user switched PDS. Worst case scenario; a bad
151
180
  // actor is trying to impersonate a user. In any case, we must not allow
152
181
  // this token to be used.
153
182
  throw new TypeError('Issuer mismatch')
154
183
  }
155
184
 
156
- return {
157
- aud: resolved.identity.pds.href,
158
- iss: resolved.metadata.issuer,
159
-
160
- sub,
161
-
162
- scope: tokenResponse.scope!,
163
- refresh_token: tokenResponse.refresh_token,
164
- access_token: tokenResponse.access_token,
165
- token_type: tokenResponse.token_type ?? 'Bearer',
166
- expires_at:
167
- typeof tokenResponse.expires_in === 'number'
168
- ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
169
- : undefined,
170
- }
185
+ return resolved.identity.pds.href
171
186
  }
172
187
 
173
- async request(
174
- endpoint: 'token',
175
- payload: Record<string, unknown>,
176
- ): Promise<OAuthTokenResponse>
177
- async request(
178
- endpoint: 'pushed_authorization_request',
179
- payload: Record<string, unknown>,
180
- ): Promise<OAuthParResponse>
188
+ async request<Endpoint extends OAuthEndpointName>(
189
+ endpoint: Endpoint,
190
+ payload: Endpoint extends 'token'
191
+ ? OAuthTokenRequest
192
+ : Endpoint extends 'pushed_authorization_request'
193
+ ? OAuthAuthorizationRequestPar
194
+ : Record<string, unknown>,
195
+ ): Promise<
196
+ Endpoint extends 'token'
197
+ ? AtprotoTokenResponse
198
+ : Endpoint extends 'pushed_authorization_request'
199
+ ? OAuthParResponse
200
+ : Json
201
+ >
181
202
  async request(
182
203
  endpoint: OAuthEndpointName,
183
204
  payload: Record<string, unknown>,
184
- ): Promise<Json>
185
-
186
- async request(endpoint: OAuthEndpointName, payload: Record<string, unknown>) {
205
+ ): Promise<unknown> {
187
206
  const url = this.serverMetadata[`${endpoint}_endpoint`]
188
207
  if (!url) throw new Error(`No ${endpoint} endpoint available`)
189
208
 
@@ -198,7 +217,7 @@ export class OAuthServerAgent {
198
217
  if (response.ok) {
199
218
  switch (endpoint) {
200
219
  case 'token':
201
- return oauthTokenResponseSchema.parse(json)
220
+ return atprotoTokenResponseSchema.parse(json)
202
221
  case 'pushed_authorization_request':
203
222
  return oauthParResponseSchema.parse(json)
204
223
  default:
@@ -1,7 +1,8 @@
1
- import { asDid } from '@atproto/did'
2
- import { Fetch, bindFetch } from '@atproto-labs/fetch'
1
+ import { bindFetch, Fetch } from '@atproto-labs/fetch'
2
+ import { AtprotoDid } from '@atproto/did'
3
3
  import { OAuthAuthorizationServerMetadata } from '@atproto/oauth-types'
4
4
 
5
+ import { AtprotoScope } from './atproto-token-response.js'
5
6
  import { TokenInvalidError } from './errors/token-invalid-error.js'
6
7
  import { TokenRevokedError } from './errors/token-revoked-error.js'
7
8
  import { dpopFetchWrapper } from './fetch-dpop.js'
@@ -15,10 +16,10 @@ const ReadableStream = globalThis.ReadableStream as
15
16
  export type TokenInfo = {
16
17
  expiresAt?: Date
17
18
  expired?: boolean
18
- scope?: string
19
+ scope: AtprotoScope
19
20
  iss: string
20
21
  aud: string
21
- sub: string
22
+ sub: AtprotoDid
22
23
  }
23
24
 
24
25
  export class OAuthSession {
@@ -26,7 +27,7 @@ export class OAuthSession {
26
27
 
27
28
  constructor(
28
29
  public readonly server: OAuthServerAgent,
29
- public readonly sub: string,
30
+ public readonly sub: AtprotoDid,
30
31
  private readonly sessionGetter: SessionGetter,
31
32
  fetch: Fetch = globalThis.fetch,
32
33
  ) {
@@ -41,8 +42,8 @@ export class OAuthSession {
41
42
  })
42
43
  }
43
44
 
44
- get did() {
45
- return asDid(this.sub)
45
+ get did(): AtprotoDid {
46
+ return this.sub
46
47
  }
47
48
 
48
49
  get serverMetadata(): Readonly<OAuthAuthorizationServerMetadata> {
@@ -50,14 +51,21 @@ export class OAuthSession {
50
51
  }
51
52
 
52
53
  /**
53
- * @param refresh See {@link SessionGetter.getSession}
54
+ * @param refresh When `true`, the credentials will be refreshed even if they
55
+ * are not expired. When `false`, the credentials will not be refreshed even
56
+ * if they are expired. When `undefined`, the credentials will be refreshed
57
+ * if, and only if, they are (about to be) expired. Defaults to `undefined`.
54
58
  */
55
- public async getTokenSet(refresh?: boolean): Promise<TokenSet> {
56
- const { tokenSet } = await this.sessionGetter.getSession(this.sub, refresh)
59
+ protected async getTokenSet(refresh: boolean | 'auto'): Promise<TokenSet> {
60
+ const { tokenSet } = await this.sessionGetter.get(this.sub, {
61
+ noCache: refresh === true,
62
+ allowStale: refresh === false,
63
+ })
64
+
57
65
  return tokenSet
58
66
  }
59
67
 
60
- async getTokenInfo(refresh?: boolean): Promise<TokenInfo> {
68
+ async getTokenInfo(refresh: boolean | 'auto' = 'auto'): Promise<TokenInfo> {
61
69
  const tokenSet = await this.getTokenSet(refresh)
62
70
  const expiresAt =
63
71
  tokenSet.expires_at == null ? undefined : new Date(tokenSet.expires_at)
@@ -78,7 +86,7 @@ export class OAuthSession {
78
86
 
79
87
  async signOut(): Promise<void> {
80
88
  try {
81
- const { tokenSet } = await this.sessionGetter.getSession(this.sub, false)
89
+ const tokenSet = await this.getTokenSet(false)
82
90
  await this.server.revoke(tokenSet.access_token)
83
91
  } finally {
84
92
  await this.sessionGetter.delStored(
@@ -90,7 +98,7 @@ export class OAuthSession {
90
98
 
91
99
  async fetchHandler(pathname: string, init?: RequestInit): Promise<Response> {
92
100
  // This will try and refresh the token if it is known to be expired
93
- const tokenSet = await this.getTokenSet(undefined)
101
+ const tokenSet = await this.getTokenSet('auto')
94
102
 
95
103
  const initialUrl = new URL(pathname, tokenSet.aud)
96
104
  const initialAuth = `${tokenSet.token_type} ${tokenSet.access_token}`
package/src/runtime.ts CHANGED
@@ -39,7 +39,7 @@ export class Runtime {
39
39
  return {
40
40
  verifier,
41
41
  challenge: await this.sha256(verifier),
42
- method: 'S256',
42
+ method: 'S256' as const,
43
43
  }
44
44
  }
45
45
 
@@ -3,6 +3,7 @@ import {
3
3
  GetCachedOptions,
4
4
  SimpleStore,
5
5
  } from '@atproto-labs/simple-store'
6
+ import { AtprotoDid } from '@atproto/did'
6
7
  import { Key } from '@atproto/jwk'
7
8
 
8
9
  import { TokenInvalidError } from './errors/token-invalid-error.js'
@@ -12,7 +13,7 @@ import { OAuthResponseError } from './oauth-response-error.js'
12
13
  import { TokenSet } from './oauth-server-agent.js'
13
14
  import { OAuthServerFactory } from './oauth-server-factory.js'
14
15
  import { Runtime } from './runtime.js'
15
- import { CustomEventTarget, timeoutSignal } from './util.js'
16
+ import { combineSignals, CustomEventTarget, timeoutSignal } from './util.js'
16
17
 
17
18
  export type Session = {
18
19
  dpopKey: Key
@@ -42,7 +43,7 @@ export type SessionEventListener<
42
43
  * contains the logic for reading from the cache which, if the cache is based on
43
44
  * localStorage/indexedDB, will sync across multiple tabs (for a given sub).
44
45
  */
45
- export class SessionGetter extends CachedGetter<string, Session> {
46
+ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
46
47
  private readonly eventTarget = new CustomEventTarget<SessionEventMap>()
47
48
 
48
49
  constructor(
@@ -73,30 +74,33 @@ export class SessionGetter extends CachedGetter<string, Session> {
73
74
  // concurrent access (which, normally, should not happen if a proper
74
75
  // runtime lock was provided).
75
76
 
76
- if (sub !== storedSession.tokenSet.sub) {
77
+ const { dpopKey, tokenSet } = storedSession
78
+
79
+ if (sub !== tokenSet.sub) {
77
80
  // Fool-proofing (e.g. against invalid session storage)
78
81
  throw new TokenRefreshError(sub, 'Stored session sub mismatch')
79
82
  }
80
83
 
84
+ if (!tokenSet.refresh_token) {
85
+ throw new TokenRefreshError(sub, 'No refresh token available')
86
+ }
87
+
81
88
  // Since refresh tokens can only be used once, we might run into
82
- // concurrency issues if multiple tabs/instances are trying to refresh
83
- // the same token. The chances of this happening when multiple instances
84
- // are started simultaneously is reduced by randomizing the expiry time
85
- // (see isStale() below). Even so, There still exist chances that
86
- // multiple tabs will try to refresh the token at the same time. The
87
- // best solution would be to use a mutex/lock to ensure that only one
88
- // instance is refreshing the token at a time. A simpler workaround is
89
- // to check if the value stored in the session store is the same as the
90
- // one in memory. If it isn't, then another instance has already
91
- // refreshed the token.
92
-
93
- const { tokenSet, dpopKey } = storedSession
89
+ // concurrency issues if multiple instances (e.g. browser tabs) are
90
+ // trying to refresh the same token simultaneously. The chances of this
91
+ // happening when multiple instances are started simultaneously is
92
+ // reduced by randomizing the expiry time (see isStale() below). The
93
+ // best solution is to use a mutex/lock to ensure that only one instance
94
+ // is refreshing the token at a time (runtime.usingLock) but that is not
95
+ // always possible. If no lock implementation is provided, we will use
96
+ // the store to check if a concurrent refresh occurred.
97
+
94
98
  const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
95
99
 
96
100
  // Because refresh tokens can only be used once, we must not use the
97
101
  // "signal" to abort the refresh, or throw any abort error beyond this
98
102
  // point. Any thrown error beyond this point will prevent the
99
- // SessionGetter from obtaining, and storing, the new token set,
103
+ // TokenGetter from obtaining, and storing, the new token set,
100
104
  // effectively rendering the currently saved session unusable.
101
105
  options?.signal?.throwIfAborted()
102
106
 
@@ -140,7 +144,7 @@ export class SessionGetter extends CachedGetter<string, Session> {
140
144
  stored.tokenSet.refresh_token !== tokenSet.refresh_token
141
145
  ) {
142
146
  // A concurrent refresh occurred. Pretend this one succeeded.
143
- return { dpopKey, tokenSet: stored.tokenSet }
147
+ return stored
144
148
  } else {
145
149
  // There were no concurrent refresh. The token is (likely)
146
150
  // simply no longer valid.
@@ -161,9 +165,13 @@ export class SessionGetter extends CachedGetter<string, Session> {
161
165
  return (
162
166
  tokenSet.expires_at != null &&
163
167
  new Date(tokenSet.expires_at).getTime() <
164
- // Add some lee way to ensure the token is not expired when it
165
- // reaches the server.
166
- Date.now() + 60e3
168
+ Date.now() +
169
+ // Add some lee way to ensure the token is not expired when it
170
+ // reaches the server.
171
+ 10e3 +
172
+ // Add some randomness to reduce the chances of multiple
173
+ // instances trying to refresh the token at the same.
174
+ 30e3 * Math.random()
167
175
  )
168
176
  },
169
177
  onStoreError: async (err, sub, { tokenSet, dpopKey }) => {
@@ -205,11 +213,15 @@ export class SessionGetter extends CachedGetter<string, Session> {
205
213
  }
206
214
 
207
215
  async setStored(sub: string, session: Session) {
216
+ // Prevent tampering with the stored value
217
+ if (sub !== session.tokenSet.sub) {
218
+ throw new TypeError('Token set does not match the expected sub')
219
+ }
208
220
  await super.setStored(sub, session)
209
221
  this.dispatchEvent('updated', { sub, ...session })
210
222
  }
211
223
 
212
- async delStored(sub: string, cause?: unknown): Promise<void> {
224
+ override async delStored(sub: AtprotoDid, cause?: unknown): Promise<void> {
213
225
  await super.delStored(sub, cause)
214
226
  this.dispatchEvent('deleted', { sub, cause })
215
227
  }
@@ -220,11 +232,29 @@ export class SessionGetter extends CachedGetter<string, Session> {
220
232
  * if they are expired. When `undefined`, the credentials will be refreshed
221
233
  * if, and only if, they are (about to be) expired. Defaults to `undefined`.
222
234
  */
223
- async getSession(sub: string, refresh?: boolean) {
224
- const session = await this.get(sub, {
235
+ async getSession(sub: AtprotoDid, refresh?: boolean) {
236
+ return this.get(sub, {
225
237
  noCache: refresh === true,
226
238
  allowStale: refresh === false,
227
239
  })
240
+ }
241
+
242
+ async get(sub: AtprotoDid, options?: GetCachedOptions): Promise<Session> {
243
+ const session = await this.runtime.usingLock(
244
+ `@atproto-oauth-client-${sub}`,
245
+ async () => {
246
+ // Make sure, even if there is no signal in the options, that the
247
+ // request will be cancelled after at most 30 seconds.
248
+ using signal = timeoutSignal(30e3, options)
249
+
250
+ using abortController = combineSignals([options?.signal, signal])
251
+
252
+ return await super.get(sub, {
253
+ ...options,
254
+ signal: abortController.signal,
255
+ })
256
+ },
257
+ )
228
258
 
229
259
  if (sub !== session.tokenSet.sub) {
230
260
  // Fool-proofing (e.g. against invalid session storage)
@@ -233,14 +263,4 @@ export class SessionGetter extends CachedGetter<string, Session> {
233
263
 
234
264
  return session
235
265
  }
236
-
237
- async get(sub: string, options?: GetCachedOptions): Promise<Session> {
238
- return this.runtime.usingLock(`@atproto-oauth-client-${sub}`, async () => {
239
- // Make sure, even if there is no signal in the options, that the request
240
- // will be cancelled after at most 30 seconds.
241
- using signal = timeoutSignal(30e3, options)
242
-
243
- return await super.get(sub, { ...options, signal })
244
- })
245
- }
246
266
  }
package/src/types.ts CHANGED
@@ -1,24 +1,29 @@
1
1
  import {
2
+ OAuthAuthorizationRequestParameters,
2
3
  oauthClientIdSchema,
3
4
  oauthClientMetadataSchema,
4
5
  } from '@atproto/oauth-types'
5
6
  import z from 'zod'
6
7
 
8
+ import { Simplify } from './util.js'
9
+
7
10
  // Note: These types are not prefixed with `OAuth` because they are not specific
8
11
  // to OAuth. They are specific to this packages. OAuth specific types are in
9
12
  // `@atproto/oauth-types`.
10
13
 
11
- export type AuthorizeOptions = {
12
- display?: 'page' | 'popup' | 'touch' | 'wap'
13
- redirect_uri?: string
14
- prompt?: 'login' | 'none' | 'consent' | 'select_account'
15
- scope?: string
16
- state?: string
17
- signal?: AbortSignal
18
-
19
- // Borrowed from OIDC
20
- ui_locales?: string
21
- }
14
+ export type AuthorizeOptions = Simplify<
15
+ Omit<
16
+ OAuthAuthorizationRequestParameters,
17
+ | 'client_id'
18
+ | 'response_mode'
19
+ | 'response_type'
20
+ | 'login_hint'
21
+ | 'code_challenge'
22
+ | 'code_challenge_method'
23
+ > & {
24
+ signal?: AbortSignal
25
+ }
26
+ >
22
27
 
23
28
  export const clientMetadataSchema = oauthClientMetadataSchema.extend({
24
29
  client_id: oauthClientIdSchema.url(),
package/src/util.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type Awaitable<T> = T | PromiseLike<T>
2
+ export type Simplify<T> = { [K in keyof T]: T[K] } & NonNullable<unknown>
2
3
 
3
4
  // @ts-expect-error
4
5
  Symbol.dispose ??= Symbol('@@dispose')
@@ -116,3 +117,80 @@ export class CustomEventTarget<EventDetailMap extends Record<string, unknown>> {
116
117
  )
117
118
  }
118
119
  }
120
+
121
+ export type SpaceSeparatedValue<Value extends string> =
122
+ | `${Value}`
123
+ | `${Value} ${string}`
124
+ | `${string} ${Value}`
125
+ | `${string} ${Value} ${string}`
126
+
127
+ export const includesSpaceSeparatedValue = <Value extends string>(
128
+ input: string,
129
+ value: Value,
130
+ ): input is SpaceSeparatedValue<Value> => {
131
+ if (value.length === 0) throw new TypeError('Value cannot be empty')
132
+ if (value.includes(' ')) throw new TypeError('Value cannot contain spaces')
133
+
134
+ // Optimized version of:
135
+ // return input.split(' ').includes(value)
136
+
137
+ const inputLength = input.length
138
+ const valueLength = value.length
139
+
140
+ if (inputLength < valueLength) return false
141
+
142
+ let idx = input.indexOf(value)
143
+ let idxEnd: number
144
+
145
+ while (idx !== -1) {
146
+ idxEnd = idx + valueLength
147
+
148
+ if (
149
+ // at beginning or preceded by space
150
+ (idx === 0 || input[idx - 1] === ' ') &&
151
+ // at end or followed by space
152
+ (idxEnd === inputLength || input[idxEnd] === ' ')
153
+ ) {
154
+ return true
155
+ }
156
+
157
+ idx = input.indexOf(value, idxEnd + 1)
158
+ }
159
+
160
+ return false
161
+ }
162
+
163
+ export function combineSignals(signals: readonly (AbortSignal | undefined)[]) {
164
+ const controller = new AbortController()
165
+
166
+ const onAbort = function (this: AbortSignal, _event: Event) {
167
+ const reason = new Error('This operation was aborted', {
168
+ cause: this.reason,
169
+ })
170
+
171
+ controller.abort(reason)
172
+ }
173
+
174
+ for (const sig of signals) {
175
+ if (!sig) continue
176
+
177
+ if (sig.aborted) {
178
+ // Remove "abort" listener that was added to sig in previous iterations
179
+ controller.abort()
180
+
181
+ throw new Error('One of the signals is already aborted', {
182
+ cause: sig.reason,
183
+ })
184
+ }
185
+
186
+ sig.addEventListener('abort', onAbort, { signal: controller.signal })
187
+ }
188
+
189
+ controller[Symbol.dispose] = () => {
190
+ const reason = new Error('AbortController was disposed')
191
+
192
+ controller.abort(reason)
193
+ }
194
+
195
+ return controller as AbortController & Disposable
196
+ }
@@ -0,0 +1 @@
1
+ {"root":["./src/atproto-token-response.ts","./src/constants.ts","./src/fetch-dpop.ts","./src/index.ts","./src/lock.ts","./src/oauth-authorization-server-metadata-resolver.ts","./src/oauth-callback-error.ts","./src/oauth-client.ts","./src/oauth-protected-resource-metadata-resolver.ts","./src/oauth-resolver-error.ts","./src/oauth-resolver.ts","./src/oauth-response-error.ts","./src/oauth-server-agent.ts","./src/oauth-server-factory.ts","./src/oauth-session.ts","./src/runtime-implementation.ts","./src/runtime.ts","./src/session-getter.ts","./src/state-store.ts","./src/types.ts","./src/util.ts","./src/validate-client-metadata.ts","./src/errors/token-invalid-error.ts","./src/errors/token-refresh-error.ts","./src/errors/token-revoked-error.ts"],"version":"5.6.3"}