@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.
- package/CHANGELOG.md +35 -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 +22 -13
- 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 +1 -1
- package/dist/oauth-server-agent.d.ts +14 -11
- 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.js +1 -2
- package/dist/validate-client-metadata.js.map +1 -1
- package/package.json +8 -8
- package/src/atproto-token-response.ts +22 -0
- package/src/oauth-authorization-server-metadata-resolver.ts +22 -8
- package/src/oauth-client.ts +61 -27
- package/src/oauth-protected-resource-metadata-resolver.ts +22 -12
- package/src/oauth-server-agent.ts +87 -68
- 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/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:
|
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
|
+
}
|
@@ -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"}
|