@atproto/oauth-client 0.1.0 → 0.1.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 +15 -0
- package/README.md +162 -32
- package/dist/errors/token-invalid-error.d.ts +7 -0
- package/dist/errors/token-invalid-error.d.ts.map +1 -0
- package/dist/errors/token-invalid-error.js +16 -0
- package/dist/errors/token-invalid-error.js.map +1 -0
- package/dist/errors/token-refresh-error.d.ts +7 -0
- package/dist/errors/token-refresh-error.d.ts.map +1 -0
- package/dist/errors/token-refresh-error.js +16 -0
- package/dist/errors/token-refresh-error.js.map +1 -0
- package/dist/errors/token-revoked-error.d.ts +7 -0
- package/dist/errors/token-revoked-error.d.ts.map +1 -0
- package/dist/errors/token-revoked-error.js +16 -0
- package/dist/errors/token-revoked-error.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/lock.d.ts +2 -1
- package/dist/lock.d.ts.map +1 -1
- package/dist/lock.js +2 -2
- package/dist/lock.js.map +1 -1
- package/dist/oauth-agent.d.ts.map +1 -1
- package/dist/oauth-agent.js +14 -9
- package/dist/oauth-agent.js.map +1 -1
- package/dist/oauth-client.d.ts +250 -20
- package/dist/oauth-client.d.ts.map +1 -1
- package/dist/oauth-client.js +67 -9
- package/dist/oauth-client.js.map +1 -1
- package/dist/oauth-resolver.d.ts +5 -4
- package/dist/oauth-resolver.d.ts.map +1 -1
- package/dist/oauth-resolver.js.map +1 -1
- package/dist/oauth-server-agent.d.ts.map +1 -1
- package/dist/oauth-server-agent.js +85 -29
- package/dist/oauth-server-agent.js.map +1 -1
- package/dist/runtime-implementation.d.ts +10 -5
- package/dist/runtime-implementation.d.ts.map +1 -1
- package/dist/runtime.d.ts +3 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +18 -12
- package/dist/runtime.js.map +1 -1
- package/dist/session-getter.d.ts +19 -0
- package/dist/session-getter.d.ts.map +1 -1
- package/dist/session-getter.js +134 -42
- package/dist/session-getter.js.map +1 -1
- package/dist/state-store.d.ts +11 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +3 -0
- package/dist/state-store.js.map +1 -0
- package/dist/types.d.ts +3 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +10 -3
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +43 -23
- package/dist/util.js.map +1 -1
- package/dist/validate-client-metadata.d.ts.map +1 -1
- package/dist/validate-client-metadata.js +17 -0
- package/dist/validate-client-metadata.js.map +1 -1
- package/package.json +8 -8
- package/src/errors/token-invalid-error.ts +9 -0
- package/src/errors/token-refresh-error.ts +9 -0
- package/src/errors/token-revoked-error.ts +9 -0
- package/src/index.ts +11 -1
- package/src/lock.ts +3 -4
- package/src/oauth-agent.ts +20 -9
- package/src/oauth-client.ts +113 -31
- package/src/oauth-resolver.ts +4 -4
- package/src/oauth-server-agent.ts +9 -9
- package/src/runtime-implementation.ts +19 -11
- package/src/runtime.ts +13 -17
- package/src/session-getter.ts +135 -71
- package/src/state-store.ts +12 -0
- package/src/types.ts +5 -2
- package/src/util.ts +63 -32
- package/src/validate-client-metadata.ts +18 -0
package/src/session-getter.ts
CHANGED
@@ -4,12 +4,15 @@ import {
|
|
4
4
|
SimpleStore,
|
5
5
|
} from '@atproto-labs/simple-store'
|
6
6
|
import { Key } from '@atproto/jwk'
|
7
|
+
|
8
|
+
import { TokenInvalidError } from './errors/token-invalid-error.js'
|
9
|
+
import { TokenRefreshError } from './errors/token-refresh-error.js'
|
10
|
+
import { TokenRevokedError } from './errors/token-revoked-error.js'
|
7
11
|
import { OAuthResponseError } from './oauth-response-error.js'
|
8
12
|
import { TokenSet } from './oauth-server-agent.js'
|
9
13
|
import { OAuthServerFactory } from './oauth-server-factory.js'
|
10
|
-
import { RefreshError } from './refresh-error.js'
|
11
14
|
import { Runtime } from './runtime.js'
|
12
|
-
import {
|
15
|
+
import { CustomEventTarget, timeoutSignal } from './util.js'
|
13
16
|
|
14
17
|
export type Session = {
|
15
18
|
dpopKey: Key
|
@@ -18,6 +21,20 @@ export type Session = {
|
|
18
21
|
|
19
22
|
export type SessionStore = SimpleStore<string, Session>
|
20
23
|
|
24
|
+
export type SessionEventMap = {
|
25
|
+
updated: {
|
26
|
+
sub: string
|
27
|
+
} & Session
|
28
|
+
deleted: {
|
29
|
+
sub: string
|
30
|
+
cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown
|
31
|
+
}
|
32
|
+
}
|
33
|
+
|
34
|
+
export type SessionEventListener<
|
35
|
+
T extends keyof SessionEventMap = keyof SessionEventMap,
|
36
|
+
> = (event: CustomEvent<SessionEventMap[T]>) => void
|
37
|
+
|
21
38
|
/**
|
22
39
|
* There are several advantages to wrapping the sessionStore in a (single)
|
23
40
|
* CachedGetter, the main of which is that the cached getter will ensure that at
|
@@ -26,33 +43,39 @@ export type SessionStore = SimpleStore<string, Session>
|
|
26
43
|
* localStorage/indexedDB, will sync across multiple tabs (for a given sub).
|
27
44
|
*/
|
28
45
|
export class SessionGetter extends CachedGetter<string, Session> {
|
46
|
+
private readonly eventTarget = new CustomEventTarget<SessionEventMap>()
|
47
|
+
|
29
48
|
constructor(
|
30
49
|
sessionStore: SessionStore,
|
31
50
|
serverFactory: OAuthServerFactory,
|
32
51
|
private readonly runtime: Runtime,
|
33
52
|
) {
|
34
53
|
super(
|
35
|
-
async (sub, options, storedSession) => {
|
54
|
+
async (sub, options, storedSession): Promise<Session> => {
|
36
55
|
// There needs to be a previous session to be able to refresh. If
|
37
56
|
// storedSession is undefined, it means that the store does not contain
|
38
|
-
// a session for the given sub.
|
39
|
-
// value being cleared in another process (e.g. another tab), we will
|
40
|
-
// give a chance to the process running this code to detect that the
|
41
|
-
// session was revoked. This should allow processes not implementing a
|
42
|
-
// subscribe/notify between instances to still be "notified" that the
|
43
|
-
// session was revoked.
|
57
|
+
// a session for the given sub.
|
44
58
|
if (storedSession === undefined) {
|
45
|
-
// Because the session is not in the store,
|
46
|
-
//
|
47
|
-
//
|
48
|
-
//
|
49
|
-
|
50
|
-
|
59
|
+
// Because the session is not in the store, this.delStored() method
|
60
|
+
// will not be called by the CachedGetter class (because there is
|
61
|
+
// nothing to delete). This would typically happen if there is no
|
62
|
+
// synchronization mechanism between instances of this class. Let's
|
63
|
+
// make sure an event is dispatched here if this occurs.
|
64
|
+
const msg = 'The session was deleted by another process'
|
65
|
+
const cause = new TokenRefreshError(sub, msg)
|
66
|
+
this.dispatchEvent('deleted', { sub, cause })
|
67
|
+
throw cause
|
51
68
|
}
|
52
69
|
|
70
|
+
// From this point forward, throwing a TokenRefreshError will result in
|
71
|
+
// this.delStored() being called, resulting in an event being
|
72
|
+
// dispatched, even if the session was removed from the store through a
|
73
|
+
// concurrent access (which, normally, should not happen if a proper
|
74
|
+
// runtime lock was provided).
|
75
|
+
|
53
76
|
if (sub !== storedSession.tokenSet.sub) {
|
54
77
|
// Fool-proofing (e.g. against invalid session storage)
|
55
|
-
throw new
|
78
|
+
throw new TokenRefreshError(sub, 'Stored session sub mismatch')
|
56
79
|
}
|
57
80
|
|
58
81
|
// Since refresh tokens can only be used once, we might run into
|
@@ -70,61 +93,67 @@ export class SessionGetter extends CachedGetter<string, Session> {
|
|
70
93
|
const { tokenSet, dpopKey } = storedSession
|
71
94
|
const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
|
72
95
|
|
73
|
-
//
|
74
|
-
//
|
75
|
-
//
|
76
|
-
//
|
77
|
-
//
|
96
|
+
// Because refresh tokens can only be used once, we must not use the
|
97
|
+
// "signal" to abort the refresh, or throw any abort error beyond this
|
98
|
+
// point. Any thrown error beyond this point will prevent the
|
99
|
+
// SessionGetter from obtaining, and storing, the new token set,
|
100
|
+
// effectively rendering the currently saved session unusable.
|
78
101
|
options?.signal?.throwIfAborted()
|
79
102
|
|
80
|
-
|
81
|
-
.refresh(tokenSet)
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
103
|
+
try {
|
104
|
+
const newTokenSet = await server.refresh(tokenSet)
|
105
|
+
|
106
|
+
if (sub !== newTokenSet.sub) {
|
107
|
+
// The server returned another sub. Was the tokenSet manipulated?
|
108
|
+
throw new TokenRefreshError(sub, 'Token set sub mismatch')
|
109
|
+
}
|
110
|
+
|
111
|
+
return { dpopKey, tokenSet: newTokenSet }
|
112
|
+
} catch (cause) {
|
113
|
+
// If the refresh token is invalid, let's try to recover from
|
114
|
+
// concurrency issues, or make sure the session is deleted by throwing
|
115
|
+
// a TokenRefreshError.
|
116
|
+
if (
|
117
|
+
cause instanceof OAuthResponseError &&
|
118
|
+
cause.status === 400 &&
|
119
|
+
cause.error === 'invalid_grant'
|
120
|
+
) {
|
121
|
+
// In case there is no lock implementation in the runtime, we will
|
122
|
+
// wait for a short time to give the other concurrent instances a
|
123
|
+
// chance to finish their refreshing of the token. If a concurrent
|
124
|
+
// refresh did occur, we will pretend that this one succeeded.
|
125
|
+
if (!runtime.hasImplementationLock) {
|
126
|
+
await new Promise((r) => setTimeout(r, 1000))
|
127
|
+
|
128
|
+
const stored = await this.getStored(sub)
|
129
|
+
if (stored === undefined) {
|
130
|
+
// A concurrent refresh occurred and caused the session to be
|
131
|
+
// deleted (for a reason we can't know at this point).
|
132
|
+
|
133
|
+
// Using a distinct error message mainly for debugging
|
134
|
+
// purposes. Also, throwing a TokenRefreshError to trigger
|
135
|
+
// deletion through the deleteOnError callback.
|
136
|
+
const msg = 'The session was deleted by another process'
|
137
|
+
throw new TokenRefreshError(sub, msg, { cause })
|
138
|
+
} else if (
|
139
|
+
stored.tokenSet.access_token !== tokenSet.access_token ||
|
140
|
+
stored.tokenSet.refresh_token !== tokenSet.refresh_token
|
141
|
+
) {
|
142
|
+
// A concurrent refresh occurred. Pretend this one succeeded.
|
143
|
+
return { dpopKey, tokenSet: stored.tokenSet }
|
144
|
+
} else {
|
145
|
+
// There were no concurrent refresh. The token is (likely)
|
146
|
+
// simply no longer valid.
|
111
147
|
}
|
112
|
-
|
113
|
-
// Throwing an RefreshError to trigger deletion through the
|
114
|
-
// deleteOnError callback.
|
115
|
-
const msg = cause.errorDescription ?? 'The session was revoked'
|
116
|
-
throw new RefreshError(sub, msg, { cause })
|
117
148
|
}
|
118
149
|
|
119
|
-
|
120
|
-
|
150
|
+
// Make sure the session gets deleted from the store
|
151
|
+
const msg = cause.errorDescription ?? 'The session was revoked'
|
152
|
+
throw new TokenRefreshError(sub, msg, { cause })
|
153
|
+
}
|
121
154
|
|
122
|
-
|
123
|
-
// The server returned another sub. Was the tokenSet manipulated?
|
124
|
-
throw new RefreshError(sub, 'Token set sub mismatch')
|
155
|
+
throw cause
|
125
156
|
}
|
126
|
-
|
127
|
-
return { ...storedSession, tokenSet: newTokenSet }
|
128
157
|
},
|
129
158
|
sessionStore,
|
130
159
|
{
|
@@ -143,13 +172,48 @@ export class SessionGetter extends CachedGetter<string, Session> {
|
|
143
172
|
await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token)
|
144
173
|
throw err
|
145
174
|
},
|
146
|
-
deleteOnError: async (err) =>
|
147
|
-
|
148
|
-
|
175
|
+
deleteOnError: async (err) =>
|
176
|
+
// Optimization: More likely to happen first
|
177
|
+
err instanceof TokenRefreshError ||
|
178
|
+
err instanceof TokenRevokedError ||
|
179
|
+
err instanceof TokenInvalidError,
|
149
180
|
},
|
150
181
|
)
|
151
182
|
}
|
152
183
|
|
184
|
+
addEventListener<T extends keyof SessionEventMap>(
|
185
|
+
type: T,
|
186
|
+
callback: SessionEventListener<T>,
|
187
|
+
options?: AddEventListenerOptions | boolean,
|
188
|
+
) {
|
189
|
+
this.eventTarget.addEventListener(type, callback, options)
|
190
|
+
}
|
191
|
+
|
192
|
+
removeEventListener<T extends keyof SessionEventMap>(
|
193
|
+
type: T,
|
194
|
+
callback: SessionEventListener<T>,
|
195
|
+
options?: EventListenerOptions | boolean,
|
196
|
+
) {
|
197
|
+
this.eventTarget.removeEventListener(type, callback, options)
|
198
|
+
}
|
199
|
+
|
200
|
+
dispatchEvent<T extends keyof SessionEventMap>(
|
201
|
+
type: T,
|
202
|
+
detail: SessionEventMap[T],
|
203
|
+
): boolean {
|
204
|
+
return this.eventTarget.dispatchCustomEvent(type, detail)
|
205
|
+
}
|
206
|
+
|
207
|
+
async setStored(sub: string, session: Session) {
|
208
|
+
await super.setStored(sub, session)
|
209
|
+
this.dispatchEvent('updated', { sub, ...session })
|
210
|
+
}
|
211
|
+
|
212
|
+
async delStored(sub: string, cause?: unknown): Promise<void> {
|
213
|
+
await super.delStored(sub, cause)
|
214
|
+
this.dispatchEvent('deleted', { sub, cause })
|
215
|
+
}
|
216
|
+
|
153
217
|
/**
|
154
218
|
* @param refresh When `true`, the credentials will be refreshed even if they
|
155
219
|
* are not expired. When `false`, the credentials will not be refreshed even
|
@@ -171,12 +235,12 @@ export class SessionGetter extends CachedGetter<string, Session> {
|
|
171
235
|
}
|
172
236
|
|
173
237
|
async get(sub: string, options?: GetCachedOptions): Promise<Session> {
|
174
|
-
return this.runtime.
|
238
|
+
return this.runtime.usingLock(`@atproto-oauth-client-${sub}`, async () => {
|
175
239
|
// Make sure, even if there is no signal in the options, that the request
|
176
240
|
// will be cancelled after at most 30 seconds.
|
177
|
-
|
178
|
-
|
179
|
-
)
|
241
|
+
using signal = timeoutSignal(30e3, options)
|
242
|
+
|
243
|
+
return await super.get(sub, { ...options, signal })
|
180
244
|
})
|
181
245
|
}
|
182
246
|
}
|
@@ -0,0 +1,12 @@
|
|
1
|
+
import { SimpleStore } from '@atproto-labs/simple-store'
|
2
|
+
import { Key } from '@atproto/jwk'
|
3
|
+
|
4
|
+
export type InternalStateData = {
|
5
|
+
iss: string
|
6
|
+
nonce: string
|
7
|
+
dpopKey: Key
|
8
|
+
verifier?: string
|
9
|
+
appState?: string
|
10
|
+
}
|
11
|
+
|
12
|
+
export type StateStore = SimpleStore<string, InternalStateData>
|
package/src/types.ts
CHANGED
@@ -11,12 +11,15 @@ import z from 'zod'
|
|
11
11
|
export type AuthorizeOptions = {
|
12
12
|
display?: 'page' | 'popup' | 'touch' | 'wap'
|
13
13
|
redirect_uri?: string
|
14
|
-
id_token_hint?: string
|
15
|
-
max_age?: number
|
16
14
|
prompt?: 'login' | 'none' | 'consent' | 'select_account'
|
17
15
|
scope?: string
|
18
16
|
state?: string
|
17
|
+
signal?: AbortSignal
|
18
|
+
|
19
|
+
// Only for OIDC compatible
|
19
20
|
ui_locales?: string
|
21
|
+
id_token_hint?: string
|
22
|
+
max_age?: number
|
20
23
|
}
|
21
24
|
|
22
25
|
export const clientMetadataSchema = oauthClientMetadataSchema.extend({
|
package/src/util.ts
CHANGED
@@ -1,51 +1,82 @@
|
|
1
|
+
export type Awaitable<T> = T | PromiseLike<T>
|
2
|
+
|
3
|
+
// @ts-expect-error
|
4
|
+
Symbol.dispose ??= Symbol('@@dispose')
|
5
|
+
|
1
6
|
/**
|
2
7
|
* @todo (?) move to common package
|
3
8
|
*/
|
4
|
-
export const
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
): Promise<T> => {
|
9
|
+
export const timeoutSignal = (
|
10
|
+
timeout: number,
|
11
|
+
options?: { signal?: AbortSignal },
|
12
|
+
): AbortSignal & Disposable => {
|
13
|
+
if (!Number.isInteger(timeout) || timeout < 0) {
|
14
|
+
throw new TypeError('Expected a positive integer')
|
15
|
+
}
|
16
|
+
|
13
17
|
options?.signal?.throwIfAborted()
|
14
18
|
|
15
|
-
const
|
16
|
-
const { signal } =
|
19
|
+
const controller = new AbortController()
|
20
|
+
const { signal } = controller
|
17
21
|
|
18
22
|
options?.signal?.addEventListener(
|
19
23
|
'abort',
|
20
|
-
(reason) =>
|
24
|
+
(reason) => controller.abort(reason),
|
21
25
|
{ once: true, signal },
|
22
26
|
)
|
23
27
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
28
|
+
const timeoutId = setTimeout(
|
29
|
+
(err) => controller.abort(err),
|
30
|
+
timeout,
|
31
|
+
// create Error here to keep original stack trace
|
32
|
+
new Error('Timeout'),
|
33
|
+
)
|
30
34
|
|
31
|
-
|
35
|
+
timeoutId?.unref?.() // NodeJS only
|
32
36
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
}
|
37
|
+
signal.addEventListener('abort', () => clearTimeout(timeoutId), {
|
38
|
+
once: true,
|
39
|
+
signal,
|
40
|
+
})
|
38
41
|
|
39
|
-
|
40
|
-
|
41
|
-
}
|
42
|
-
|
43
|
-
|
44
|
-
// - Cancel pending (async) tasks
|
45
|
-
abortController.abort()
|
46
|
-
}
|
42
|
+
Object.defineProperty(signal, Symbol.dispose, {
|
43
|
+
value: () => controller.abort(),
|
44
|
+
})
|
45
|
+
|
46
|
+
return signal as AbortSignal & Disposable
|
47
47
|
}
|
48
48
|
|
49
49
|
export function contentMime(headers: Headers): string | undefined {
|
50
50
|
return headers.get('content-type')?.split(';')[0]!.trim()
|
51
51
|
}
|
52
|
+
|
53
|
+
export class CustomEventTarget<EventDetailMap extends Record<string, unknown>> {
|
54
|
+
readonly eventTarget = new EventTarget()
|
55
|
+
|
56
|
+
addEventListener<T extends Extract<keyof EventDetailMap, string>>(
|
57
|
+
type: T,
|
58
|
+
callback: (event: CustomEvent<EventDetailMap[T]>) => void,
|
59
|
+
options?: AddEventListenerOptions | boolean,
|
60
|
+
): void {
|
61
|
+
this.eventTarget.addEventListener(type, callback as EventListener, options)
|
62
|
+
}
|
63
|
+
|
64
|
+
removeEventListener<T extends Extract<keyof EventDetailMap, string>>(
|
65
|
+
type: T,
|
66
|
+
callback: (event: CustomEvent<EventDetailMap[T]>) => void,
|
67
|
+
options?: EventListenerOptions | boolean,
|
68
|
+
): void {
|
69
|
+
this.eventTarget.removeEventListener(
|
70
|
+
type,
|
71
|
+
callback as EventListener,
|
72
|
+
options,
|
73
|
+
)
|
74
|
+
}
|
75
|
+
|
76
|
+
dispatchCustomEvent<T extends Extract<keyof EventDetailMap, string>>(
|
77
|
+
type: T,
|
78
|
+
detail: EventDetailMap[T],
|
79
|
+
): boolean {
|
80
|
+
return this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }))
|
81
|
+
}
|
82
|
+
}
|
@@ -16,6 +16,24 @@ export function validateClientMetadata(
|
|
16
16
|
input: OAuthClientMetadataInput,
|
17
17
|
keyset?: Keyset,
|
18
18
|
): ClientMetadata {
|
19
|
+
if (input.jwks) {
|
20
|
+
if (!keyset) {
|
21
|
+
throw new TypeError(`Keyset must not be provided when jwks is provided`)
|
22
|
+
}
|
23
|
+
for (const key of input.jwks.keys) {
|
24
|
+
if (!key.kid) {
|
25
|
+
throw new TypeError(`Key must have a "kid" property`)
|
26
|
+
} else if (!keyset.has(key.kid)) {
|
27
|
+
throw new TypeError(`Key with kid "${key.kid}" not found in keyset`)
|
28
|
+
}
|
29
|
+
}
|
30
|
+
}
|
31
|
+
|
32
|
+
// Allow to pass a keyset and omit the jwks/jwks_uri properties
|
33
|
+
if (!input.jwks && !input.jwks_uri && keyset?.size) {
|
34
|
+
input = { ...input, jwks: keyset.toJSON() }
|
35
|
+
}
|
36
|
+
|
19
37
|
const metadata = clientMetadataSchema.parse(input)
|
20
38
|
|
21
39
|
// ATPROTO uses client metadata discovery
|