@atproto/oauth-client 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. package/CHANGELOG.md +42 -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 +24 -17
  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 +2 -2
  23. package/dist/oauth-server-agent.d.ts +15 -12
  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.d.ts.map +1 -1
  45. package/dist/validate-client-metadata.js +17 -7
  46. package/dist/validate-client-metadata.js.map +1 -1
  47. package/package.json +9 -9
  48. package/src/atproto-token-response.ts +22 -0
  49. package/src/oauth-authorization-server-metadata-resolver.ts +22 -8
  50. package/src/oauth-client.ts +62 -32
  51. package/src/oauth-protected-resource-metadata-resolver.ts +22 -12
  52. package/src/oauth-server-agent.ts +89 -70
  53. package/src/oauth-session.ts +21 -13
  54. package/src/runtime.ts +1 -1
  55. package/src/session-getter.ts +53 -33
  56. package/src/types.ts +16 -11
  57. package/src/util.ts +78 -0
  58. package/src/validate-client-metadata.ts +23 -6
  59. 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
- OAuthClientIdentification,
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:
@@ -211,7 +230,7 @@ export class OAuthServerAgent {
211
230
 
212
231
  async buildClientAuth(endpoint: OAuthEndpointName): Promise<{
213
232
  headers?: Record<string, string>
214
- payload: OAuthClientIdentification
233
+ payload: OAuthClientCredentials
215
234
  }> {
216
235
  const methodSupported =
217
236
  this.serverMetadata[`token_endpoint_auth_methods_supported`]
@@ -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
+ }
@@ -1,5 +1,9 @@
1
1
  import { Keyset } from '@atproto/jwk'
2
- import { OAuthClientMetadataInput } from '@atproto/oauth-types'
2
+ import {
3
+ OAuthClientMetadataInput,
4
+ assertOAuthDiscoverableClientId,
5
+ assertOAuthLoopbackClientId,
6
+ } from '@atproto/oauth-types'
3
7
 
4
8
  import { ClientMetadata, clientMetadataSchema } from './types.js'
5
9
 
@@ -30,11 +34,24 @@ export function validateClientMetadata(
30
34
 
31
35
  const metadata = clientMetadataSchema.parse(input)
32
36
 
33
- // ATPROTO uses client metadata discovery
34
- try {
35
- new URL(metadata.client_id)
36
- } catch (cause) {
37
- throw new TypeError(`client_id must be a valid URL`, { cause })
37
+ // Validate client ID
38
+ if (metadata.client_id.startsWith('http:')) {
39
+ assertOAuthLoopbackClientId(metadata.client_id)
40
+ } else {
41
+ assertOAuthDiscoverableClientId(metadata.client_id)
42
+ }
43
+
44
+ const scopes = metadata.scope?.split(' ')
45
+ if (!scopes?.includes('atproto')) {
46
+ throw new TypeError(`Client metadata must include the "atproto" scope`)
47
+ }
48
+
49
+ if (!metadata.response_types.includes('code')) {
50
+ throw new TypeError(`"response_types" must include "code"`)
51
+ }
52
+
53
+ if (!metadata.grant_types.includes('authorization_code')) {
54
+ throw new TypeError(`"grant_types" must include "authorization_code"`)
38
55
  }
39
56
 
40
57
  const method = metadata[TOKEN_ENDPOINT_AUTH_METHOD]