@atproto/oauth-client 0.5.14 → 0.6.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 +20 -0
- package/dist/oauth-authorization-server-metadata-resolver.d.ts +1 -1
- package/dist/oauth-authorization-server-metadata-resolver.d.ts.map +1 -1
- package/dist/oauth-authorization-server-metadata-resolver.js +1 -1
- package/dist/oauth-authorization-server-metadata-resolver.js.map +1 -1
- package/dist/oauth-client.d.ts +7 -8
- package/dist/oauth-client.d.ts.map +1 -1
- package/dist/oauth-client.js +27 -26
- package/dist/oauth-client.js.map +1 -1
- package/dist/oauth-protected-resource-metadata-resolver.d.ts +3 -3
- package/dist/oauth-protected-resource-metadata-resolver.d.ts.map +1 -1
- package/dist/oauth-protected-resource-metadata-resolver.js +4 -0
- package/dist/oauth-protected-resource-metadata-resolver.js.map +1 -1
- package/dist/oauth-resolver.d.ts +1 -1
- package/dist/oauth-resolver.d.ts.map +1 -1
- package/dist/oauth-resolver.js +3 -0
- package/dist/oauth-resolver.js.map +1 -1
- package/dist/oauth-server-factory.d.ts +1 -1
- package/dist/oauth-server-factory.d.ts.map +1 -1
- package/dist/oauth-server-factory.js +0 -7
- package/dist/oauth-server-factory.js.map +1 -1
- package/dist/oauth-session.d.ts.map +1 -1
- package/dist/oauth-session.js +1 -4
- package/dist/oauth-session.js.map +1 -1
- package/dist/session-getter.d.ts +16 -21
- package/dist/session-getter.d.ts.map +1 -1
- package/dist/session-getter.js +65 -60
- package/dist/session-getter.js.map +1 -1
- package/dist/state-store.d.ts +13 -3
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js.map +1 -1
- package/dist/util.d.ts +0 -10
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +1 -64
- package/dist/util.js.map +1 -1
- package/package.json +11 -11
- package/src/oauth-authorization-server-metadata-resolver.ts +2 -2
- package/src/oauth-client.ts +47 -50
- package/src/oauth-protected-resource-metadata-resolver.ts +9 -4
- package/src/oauth-resolver.ts +5 -1
- package/src/oauth-server-factory.ts +2 -16
- package/src/oauth-session.ts +1 -4
- package/src/session-getter.ts +85 -102
- package/src/state-store.ts +13 -3
- package/src/util.ts +0 -67
package/src/session-getter.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Key } from '@atproto/jwk'
|
|
|
3
3
|
import {
|
|
4
4
|
CachedGetter,
|
|
5
5
|
GetCachedOptions,
|
|
6
|
+
GetOptions,
|
|
6
7
|
SimpleStore,
|
|
7
8
|
} from '@atproto-labs/simple-store'
|
|
8
9
|
import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
|
|
@@ -14,32 +15,35 @@ import { OAuthResponseError } from './oauth-response-error.js'
|
|
|
14
15
|
import { TokenSet } from './oauth-server-agent.js'
|
|
15
16
|
import { OAuthServerFactory } from './oauth-server-factory.js'
|
|
16
17
|
import { Runtime } from './runtime.js'
|
|
17
|
-
import {
|
|
18
|
+
import { combineSignals } from './util.js'
|
|
18
19
|
|
|
19
20
|
export type Session = {
|
|
20
21
|
dpopKey: Key
|
|
21
|
-
|
|
22
|
-
* Previous implementation of this lib did not define an `authMethod`
|
|
23
|
-
*/
|
|
24
|
-
authMethod?: ClientAuthMethod
|
|
22
|
+
authMethod: ClientAuthMethod
|
|
25
23
|
tokenSet: TokenSet
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
export type SessionStore = SimpleStore<string, Session>
|
|
29
27
|
|
|
30
|
-
export type
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown
|
|
37
|
-
}
|
|
28
|
+
export type SessionHooks = {
|
|
29
|
+
onUpdate?: (sub: AtprotoDid, session: Session) => void
|
|
30
|
+
onDelete?: (
|
|
31
|
+
sub: AtprotoDid,
|
|
32
|
+
cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown,
|
|
33
|
+
) => void
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
export function isExpectedSessionError(err: unknown) {
|
|
37
|
+
return (
|
|
38
|
+
err instanceof TokenRefreshError ||
|
|
39
|
+
err instanceof TokenRevokedError ||
|
|
40
|
+
err instanceof TokenInvalidError ||
|
|
41
|
+
err instanceof AuthMethodUnsatisfiableError ||
|
|
42
|
+
// The stored session is invalid (e.g. missing properties) and cannot
|
|
43
|
+
// be used properly
|
|
44
|
+
err instanceof TypeError
|
|
45
|
+
)
|
|
46
|
+
}
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
49
|
* There are several advantages to wrapping the sessionStore in a (single)
|
|
@@ -49,15 +53,14 @@ export type SessionEventListener<
|
|
|
49
53
|
* localStorage/indexedDB, will sync across multiple tabs (for a given sub).
|
|
50
54
|
*/
|
|
51
55
|
export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
52
|
-
private readonly eventTarget = new CustomEventTarget<SessionEventMap>()
|
|
53
|
-
|
|
54
56
|
constructor(
|
|
55
57
|
sessionStore: SessionStore,
|
|
56
58
|
serverFactory: OAuthServerFactory,
|
|
57
59
|
private readonly runtime: Runtime,
|
|
60
|
+
private readonly hooks: SessionHooks = {},
|
|
58
61
|
) {
|
|
59
62
|
super(
|
|
60
|
-
async (sub,
|
|
63
|
+
async (sub, { signal }, storedSession) => {
|
|
61
64
|
// There needs to be a previous session to be able to refresh. If
|
|
62
65
|
// storedSession is undefined, it means that the store does not contain
|
|
63
66
|
// a session for the given sub.
|
|
@@ -69,17 +72,15 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
|
69
72
|
// make sure an event is dispatched here if this occurs.
|
|
70
73
|
const msg = 'The session was deleted by another process'
|
|
71
74
|
const cause = new TokenRefreshError(sub, msg)
|
|
72
|
-
|
|
75
|
+
await hooks.onDelete?.call(null, sub, cause)
|
|
73
76
|
throw cause
|
|
74
77
|
}
|
|
75
78
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// concurrent access (which, normally, should not happen if a proper
|
|
80
|
-
// runtime lock was provided).
|
|
79
|
+
// @NOTE Throwing a TokenRefreshError (or any other error class defined
|
|
80
|
+
// in the deleteOnError options) will result in this.delStored() being
|
|
81
|
+
// called.
|
|
81
82
|
|
|
82
|
-
const { dpopKey, authMethod
|
|
83
|
+
const { dpopKey, authMethod, tokenSet } = storedSession
|
|
83
84
|
|
|
84
85
|
if (sub !== tokenSet.sub) {
|
|
85
86
|
// Fool-proofing (e.g. against invalid session storage)
|
|
@@ -90,16 +91,6 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
|
90
91
|
throw new TokenRefreshError(sub, 'No refresh token available')
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
// Since refresh tokens can only be used once, we might run into
|
|
94
|
-
// concurrency issues if multiple instances (e.g. browser tabs) are
|
|
95
|
-
// trying to refresh the same token simultaneously. The chances of this
|
|
96
|
-
// happening when multiple instances are started simultaneously is
|
|
97
|
-
// reduced by randomizing the expiry time (see isStale() below). The
|
|
98
|
-
// best solution is to use a mutex/lock to ensure that only one instance
|
|
99
|
-
// is refreshing the token at a time (runtime.usingLock) but that is not
|
|
100
|
-
// always possible. If no lock implementation is provided, we will use
|
|
101
|
-
// the store to check if a concurrent refresh occurred.
|
|
102
|
-
|
|
103
94
|
const server = await serverFactory.fromIssuer(
|
|
104
95
|
tokenSet.iss,
|
|
105
96
|
authMethod,
|
|
@@ -111,7 +102,7 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
|
111
102
|
// point. Any thrown error beyond this point will prevent the
|
|
112
103
|
// TokenGetter from obtaining, and storing, the new token set,
|
|
113
104
|
// effectively rendering the currently saved session unusable.
|
|
114
|
-
|
|
105
|
+
signal?.throwIfAborted()
|
|
115
106
|
|
|
116
107
|
try {
|
|
117
108
|
const newTokenSet = await server.refresh(tokenSet)
|
|
@@ -127,9 +118,16 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
|
127
118
|
authMethod: server.authMethod,
|
|
128
119
|
}
|
|
129
120
|
} catch (cause) {
|
|
130
|
-
//
|
|
131
|
-
// concurrency issues
|
|
132
|
-
//
|
|
121
|
+
// Since refresh tokens can only be used once, we might run into
|
|
122
|
+
// concurrency issues if multiple instances (e.g. browser tabs) are
|
|
123
|
+
// trying to refresh the same token simultaneously. The chances of
|
|
124
|
+
// this happening when multiple instances are started simultaneously
|
|
125
|
+
// is reduced by randomizing the expiry time (see isStale() below).
|
|
126
|
+
// The best solution is to use a mutex/lock to ensure that only one
|
|
127
|
+
// instance is refreshing the token at a time (runtime.usingLock) but
|
|
128
|
+
// that is not always possible. Let's try to recover from concurrency
|
|
129
|
+
// issues, or force the session to be deleted by throwing a
|
|
130
|
+
// TokenRefreshError.
|
|
133
131
|
if (
|
|
134
132
|
cause instanceof OAuthResponseError &&
|
|
135
133
|
cause.status === 400 &&
|
|
@@ -187,91 +185,63 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
|
187
185
|
30e3 * Math.random()
|
|
188
186
|
)
|
|
189
187
|
},
|
|
190
|
-
onStoreError: async (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// Let the original error propagate
|
|
210
|
-
}
|
|
188
|
+
onStoreError: async (err, sub, { tokenSet, dpopKey, authMethod }) => {
|
|
189
|
+
// If the token data cannot be stored, let's revoke it
|
|
190
|
+
try {
|
|
191
|
+
const server = await serverFactory.fromIssuer(
|
|
192
|
+
tokenSet.iss,
|
|
193
|
+
authMethod,
|
|
194
|
+
dpopKey,
|
|
195
|
+
)
|
|
196
|
+
await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token)
|
|
197
|
+
} catch {
|
|
198
|
+
// At least we tried...
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Attempt to delete the session from the store. Note that this might
|
|
202
|
+
// fail if the store is not available, which is fine.
|
|
203
|
+
try {
|
|
204
|
+
await this.delStored(sub, err)
|
|
205
|
+
} catch {
|
|
206
|
+
// Ignore (better to propagate the original storage error)
|
|
211
207
|
}
|
|
212
208
|
|
|
213
209
|
throw err
|
|
214
210
|
},
|
|
215
|
-
deleteOnError:
|
|
216
|
-
err instanceof TokenRefreshError ||
|
|
217
|
-
err instanceof TokenRevokedError ||
|
|
218
|
-
err instanceof TokenInvalidError ||
|
|
219
|
-
err instanceof AuthMethodUnsatisfiableError,
|
|
211
|
+
deleteOnError: isExpectedSessionError,
|
|
220
212
|
},
|
|
221
213
|
)
|
|
222
214
|
}
|
|
223
215
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
this.eventTarget.addEventListener(type, callback, options)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
removeEventListener<T extends keyof SessionEventMap>(
|
|
233
|
-
type: T,
|
|
234
|
-
callback: SessionEventListener<T>,
|
|
235
|
-
options?: EventListenerOptions | boolean,
|
|
236
|
-
) {
|
|
237
|
-
this.eventTarget.removeEventListener(type, callback, options)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
dispatchEvent<T extends keyof SessionEventMap>(
|
|
241
|
-
type: T,
|
|
242
|
-
detail: SessionEventMap[T],
|
|
243
|
-
): boolean {
|
|
244
|
-
return this.eventTarget.dispatchCustomEvent(type, detail)
|
|
216
|
+
override async getStored(
|
|
217
|
+
sub: AtprotoDid,
|
|
218
|
+
options?: GetOptions,
|
|
219
|
+
): Promise<Session | undefined> {
|
|
220
|
+
return super.getStored(sub, options)
|
|
245
221
|
}
|
|
246
222
|
|
|
247
|
-
async setStored(sub:
|
|
223
|
+
override async setStored(sub: AtprotoDid, session: Session) {
|
|
248
224
|
// Prevent tampering with the stored value
|
|
249
225
|
if (sub !== session.tokenSet.sub) {
|
|
250
226
|
throw new TypeError('Token set does not match the expected sub')
|
|
251
227
|
}
|
|
252
228
|
await super.setStored(sub, session)
|
|
253
|
-
this.
|
|
229
|
+
await this.hooks.onUpdate?.call(null, sub, session)
|
|
254
230
|
}
|
|
255
231
|
|
|
256
232
|
override async delStored(sub: AtprotoDid, cause?: unknown): Promise<void> {
|
|
257
233
|
await super.delStored(sub, cause)
|
|
258
|
-
this.
|
|
234
|
+
await this.hooks.onDelete?.call(null, sub, cause)
|
|
259
235
|
}
|
|
260
236
|
|
|
261
237
|
/**
|
|
262
|
-
* @
|
|
263
|
-
*
|
|
264
|
-
* if they are expired. When `undefined`, the credentials will be refreshed
|
|
265
|
-
* if, and only if, they are (about to be) expired. Defaults to `undefined`.
|
|
238
|
+
* @deprecated Use {@link getSession} instead
|
|
239
|
+
* @internal (not really deprecated)
|
|
266
240
|
*/
|
|
267
|
-
async
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
})
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
async get(sub: AtprotoDid, options?: GetCachedOptions): Promise<Session> {
|
|
241
|
+
override async get(
|
|
242
|
+
sub: AtprotoDid,
|
|
243
|
+
options?: GetCachedOptions,
|
|
244
|
+
): Promise<Session> {
|
|
275
245
|
const session = await this.runtime.usingLock(
|
|
276
246
|
`@atproto-oauth-client-${sub}`,
|
|
277
247
|
async () => {
|
|
@@ -295,4 +265,17 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
|
295
265
|
|
|
296
266
|
return session
|
|
297
267
|
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @param refresh When `true`, the credentials will be refreshed even if they
|
|
271
|
+
* are not expired. When `false`, the credentials will not be refreshed even
|
|
272
|
+
* if they are expired. When `undefined`, the credentials will be refreshed
|
|
273
|
+
* if, and only if, they are (about to be) expired. Defaults to `undefined`.
|
|
274
|
+
*/
|
|
275
|
+
async getSession(sub: AtprotoDid, refresh: boolean | 'auto' = 'auto') {
|
|
276
|
+
return this.get(sub, {
|
|
277
|
+
noCache: refresh === true,
|
|
278
|
+
allowStale: refresh === false,
|
|
279
|
+
})
|
|
280
|
+
}
|
|
298
281
|
}
|
package/src/state-store.ts
CHANGED
|
@@ -5,10 +5,20 @@ import { ClientAuthMethod } from './oauth-client-auth.js'
|
|
|
5
5
|
export type InternalStateData = {
|
|
6
6
|
iss: string
|
|
7
7
|
dpopKey: Key
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
verifier?: string
|
|
8
|
+
authMethod: ClientAuthMethod
|
|
9
|
+
verifier: string
|
|
11
10
|
appState?: string
|
|
12
11
|
}
|
|
13
12
|
|
|
13
|
+
/**
|
|
14
|
+
* A store pending oauth authorization flows. The key is the "state" parameter
|
|
15
|
+
* used in the authorization request, and the value is an object containing the
|
|
16
|
+
* necessary information to complete the flow once the user is redirected back
|
|
17
|
+
* to the client.
|
|
18
|
+
*
|
|
19
|
+
* @note The data stored in this store is typically short-lived. It should be
|
|
20
|
+
* automatically cleared after a certain period of time (e.g. 1 hour) to prevent
|
|
21
|
+
* the store from growing indefinitely. It is up to the implementation to
|
|
22
|
+
* implement this cleanup mechanism.
|
|
23
|
+
*/
|
|
14
24
|
export type StateStore = SimpleStore<string, InternalStateData>
|
package/src/util.ts
CHANGED
|
@@ -7,73 +7,6 @@ export function contentMime(headers: Headers): string | undefined {
|
|
|
7
7
|
return headers.get('content-type')?.split(';')[0]!.trim()
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
* Ponyfill for `CustomEvent` constructor.
|
|
12
|
-
*/
|
|
13
|
-
export const CustomEvent: typeof globalThis.CustomEvent =
|
|
14
|
-
globalThis.CustomEvent ??
|
|
15
|
-
(() => {
|
|
16
|
-
class CustomEvent<T> extends Event {
|
|
17
|
-
#detail: T | null
|
|
18
|
-
constructor(type: string, options?: CustomEventInit<T>) {
|
|
19
|
-
if (!arguments.length) throw new TypeError('type argument is required')
|
|
20
|
-
super(type, options)
|
|
21
|
-
this.#detail = options?.detail ?? null
|
|
22
|
-
}
|
|
23
|
-
get detail() {
|
|
24
|
-
return this.#detail
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
Object.defineProperties(CustomEvent.prototype, {
|
|
29
|
-
[Symbol.toStringTag]: {
|
|
30
|
-
writable: false,
|
|
31
|
-
enumerable: false,
|
|
32
|
-
configurable: true,
|
|
33
|
-
value: 'CustomEvent',
|
|
34
|
-
},
|
|
35
|
-
detail: {
|
|
36
|
-
enumerable: true,
|
|
37
|
-
},
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
return CustomEvent
|
|
41
|
-
})()
|
|
42
|
-
|
|
43
|
-
export class CustomEventTarget<EventDetailMap extends Record<string, unknown>> {
|
|
44
|
-
readonly eventTarget = new EventTarget()
|
|
45
|
-
|
|
46
|
-
addEventListener<T extends Extract<keyof EventDetailMap, string>>(
|
|
47
|
-
type: T,
|
|
48
|
-
callback: (event: CustomEvent<EventDetailMap[T]>) => void,
|
|
49
|
-
options?: AddEventListenerOptions | boolean,
|
|
50
|
-
): void {
|
|
51
|
-
this.eventTarget.addEventListener(type, callback as EventListener, options)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
removeEventListener<T extends Extract<keyof EventDetailMap, string>>(
|
|
55
|
-
type: T,
|
|
56
|
-
callback: (event: CustomEvent<EventDetailMap[T]>) => void,
|
|
57
|
-
options?: EventListenerOptions | boolean,
|
|
58
|
-
): void {
|
|
59
|
-
this.eventTarget.removeEventListener(
|
|
60
|
-
type,
|
|
61
|
-
callback as EventListener,
|
|
62
|
-
options,
|
|
63
|
-
)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
dispatchCustomEvent<T extends Extract<keyof EventDetailMap, string>>(
|
|
67
|
-
type: T,
|
|
68
|
-
detail: EventDetailMap[T],
|
|
69
|
-
init?: EventInit,
|
|
70
|
-
): boolean {
|
|
71
|
-
return this.eventTarget.dispatchEvent(
|
|
72
|
-
new CustomEvent(type, { ...init, detail }),
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
10
|
export function combineSignals(
|
|
78
11
|
signals: readonly (AbortSignal | undefined)[],
|
|
79
12
|
): AbortController & Disposable {
|