@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.
- package/CHANGELOG.md +42 -0
- package/README.md +12 -6
- package/dist/atproto-token-response.d.ts +110 -0
- package/dist/atproto-token-response.d.ts.map +1 -0
- package/dist/atproto-token-response.js +20 -0
- package/dist/atproto-token-response.js.map +1 -0
- package/dist/fetch-dpop.js +1 -2
- package/dist/fetch-dpop.js.map +1 -1
- package/dist/oauth-authorization-server-metadata-resolver.d.ts +6 -2
- package/dist/oauth-authorization-server-metadata-resolver.d.ts.map +1 -1
- package/dist/oauth-authorization-server-metadata-resolver.js +18 -9
- package/dist/oauth-authorization-server-metadata-resolver.js.map +1 -1
- package/dist/oauth-callback-error.d.ts.map +1 -1
- package/dist/oauth-client.d.ts +30 -15
- package/dist/oauth-client.d.ts.map +1 -1
- package/dist/oauth-client.js +24 -17
- package/dist/oauth-client.js.map +1 -1
- package/dist/oauth-protected-resource-metadata-resolver.d.ts +5 -1
- package/dist/oauth-protected-resource-metadata-resolver.d.ts.map +1 -1
- package/dist/oauth-protected-resource-metadata-resolver.js +18 -11
- package/dist/oauth-protected-resource-metadata-resolver.js.map +1 -1
- package/dist/oauth-resolver.d.ts +2 -2
- package/dist/oauth-server-agent.d.ts +15 -12
- package/dist/oauth-server-agent.d.ts.map +1 -1
- package/dist/oauth-server-agent.js +66 -47
- package/dist/oauth-server-agent.js.map +1 -1
- package/dist/oauth-session.d.ts +13 -8
- package/dist/oauth-session.d.ts.map +1 -1
- package/dist/oauth-session.js +12 -7
- package/dist/oauth-session.js.map +1 -1
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/session-getter.d.ts +5 -4
- package/dist/session-getter.d.ts.map +1 -1
- package/dist/session-getter.js +52 -32
- package/dist/session-getter.js.map +1 -1
- package/dist/types.d.ts +98 -102
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +6 -1
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +56 -2
- package/dist/util.js.map +1 -1
- package/dist/validate-client-metadata.d.ts.map +1 -1
- package/dist/validate-client-metadata.js +17 -7
- package/dist/validate-client-metadata.js.map +1 -1
- package/package.json +9 -9
- package/src/atproto-token-response.ts +22 -0
- package/src/oauth-authorization-server-metadata-resolver.ts +22 -8
- package/src/oauth-client.ts +62 -32
- package/src/oauth-protected-resource-metadata-resolver.ts +22 -12
- package/src/oauth-server-agent.ts +89 -70
- package/src/oauth-session.ts +21 -13
- package/src/runtime.ts +1 -1
- package/src/session-getter.ts +53 -33
- package/src/types.ts +16 -11
- package/src/util.ts +78 -0
- package/src/validate-client-metadata.ts +23 -6
- 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
|
-
|
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:
|
32
|
+
sub: AtprotoDid
|
28
33
|
aud: string
|
29
|
-
scope:
|
34
|
+
scope: AtprotoScope
|
30
35
|
|
31
36
|
refresh_token?: string
|
32
37
|
access_token: string
|
33
|
-
token_type:
|
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,
|
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:
|
88
|
+
code_verifier: codeVerifier,
|
78
89
|
})
|
79
90
|
|
80
91
|
try {
|
81
|
-
|
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
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
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
|
-
|
111
|
-
|
112
|
-
|
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
|
-
|
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
|
-
|
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.
|
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:
|
175
|
-
payload:
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
): Promise<
|
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<
|
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
|
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:
|
233
|
+
payload: OAuthClientCredentials
|
215
234
|
}> {
|
216
235
|
const methodSupported =
|
217
236
|
this.serverMetadata[`token_endpoint_auth_methods_supported`]
|
package/src/oauth-session.ts
CHANGED
@@ -1,7 +1,8 @@
|
|
1
|
-
import {
|
2
|
-
import {
|
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
|
19
|
+
scope: AtprotoScope
|
19
20
|
iss: string
|
20
21
|
aud: string
|
21
|
-
sub:
|
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:
|
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
|
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
|
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
|
-
|
56
|
-
const { tokenSet } = await this.sessionGetter.
|
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
|
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
|
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(
|
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
package/src/session-getter.ts
CHANGED
@@ -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<
|
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
|
-
|
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
|
83
|
-
// the same token. The chances of this
|
84
|
-
// are started simultaneously is
|
85
|
-
// (see isStale() below).
|
86
|
-
//
|
87
|
-
//
|
88
|
-
//
|
89
|
-
// to check if
|
90
|
-
|
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
|
-
//
|
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
|
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
|
-
|
165
|
-
|
166
|
-
|
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:
|
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:
|
224
|
-
|
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
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
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 {
|
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
|
-
//
|
34
|
-
|
35
|
-
|
36
|
-
}
|
37
|
-
|
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]
|