@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/oauth-client.ts
CHANGED
@@ -11,16 +11,18 @@ import {
|
|
11
11
|
HandleResolver,
|
12
12
|
} from '@atproto-labs/handle-resolver'
|
13
13
|
import { IdentityResolver } from '@atproto-labs/identity-resolver'
|
14
|
-
import { SimpleStore } from '@atproto-labs/simple-store'
|
15
14
|
import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
|
16
15
|
import { Key, Keyset } from '@atproto/jwk'
|
17
16
|
import {
|
17
|
+
OAuthClientIdDiscoverable,
|
18
18
|
OAuthClientMetadata,
|
19
19
|
OAuthClientMetadataInput,
|
20
|
+
oauthClientMetadataSchema,
|
20
21
|
OAuthResponseMode,
|
21
22
|
} from '@atproto/oauth-types'
|
22
23
|
|
23
24
|
import { FALLBACK_ALG } from './constants.js'
|
25
|
+
import { TokenRevokedError } from './errors/token-revoked-error.js'
|
24
26
|
import { OAuthAgent } from './oauth-agent.js'
|
25
27
|
import {
|
26
28
|
AuthorizationServerMetadataCache,
|
@@ -36,30 +38,26 @@ import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
|
|
36
38
|
import { OAuthServerFactory } from './oauth-server-factory.js'
|
37
39
|
import { RuntimeImplementation } from './runtime-implementation.js'
|
38
40
|
import { Runtime } from './runtime.js'
|
39
|
-
import {
|
41
|
+
import {
|
42
|
+
SessionEventMap,
|
43
|
+
SessionGetter,
|
44
|
+
SessionStore,
|
45
|
+
} from './session-getter.js'
|
46
|
+
import { InternalStateData, StateStore } from './state-store.js'
|
40
47
|
import { AuthorizeOptions, ClientMetadata } from './types.js'
|
48
|
+
import { CustomEventTarget } from './util.js'
|
41
49
|
import { validateClientMetadata } from './validate-client-metadata.js'
|
42
50
|
|
43
|
-
export type InternalStateData = {
|
44
|
-
iss: string
|
45
|
-
nonce: string
|
46
|
-
dpopKey: Key
|
47
|
-
verifier?: string
|
48
|
-
|
49
|
-
/**
|
50
|
-
* @note This could be parametrized to be of any type. This wasn't done for
|
51
|
-
* the sake of simplicity but could be added in a later development.
|
52
|
-
*/
|
53
|
-
appState?: string
|
54
|
-
}
|
55
|
-
|
56
|
-
export type StateStore = SimpleStore<string, InternalStateData>
|
57
|
-
|
58
51
|
// Export all types needed to construct OAuthClientOptions
|
59
52
|
export type {
|
60
53
|
AuthorizationServerMetadataCache,
|
54
|
+
DidCache,
|
61
55
|
DpopNonceCache,
|
62
56
|
Fetch,
|
57
|
+
HandleCache,
|
58
|
+
HandleResolver,
|
59
|
+
InternalStateData,
|
60
|
+
Key,
|
63
61
|
Keyset,
|
64
62
|
OAuthClientMetadata,
|
65
63
|
OAuthClientMetadataInput,
|
@@ -67,6 +65,7 @@ export type {
|
|
67
65
|
ProtectedResourceMetadataCache,
|
68
66
|
RuntimeImplementation,
|
69
67
|
SessionStore,
|
68
|
+
StateStore,
|
70
69
|
}
|
71
70
|
|
72
71
|
export type OAuthClientOptions = {
|
@@ -91,7 +90,47 @@ export type OAuthClientOptions = {
|
|
91
90
|
fetch?: Fetch
|
92
91
|
}
|
93
92
|
|
94
|
-
export
|
93
|
+
export type OAuthClientEventMap = SessionEventMap
|
94
|
+
|
95
|
+
export type OAuthClientFetchMetadataOptions = {
|
96
|
+
clientId: OAuthClientIdDiscoverable
|
97
|
+
fetch?: Fetch
|
98
|
+
signal?: AbortSignal
|
99
|
+
}
|
100
|
+
|
101
|
+
export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
102
|
+
static async fetchMetadata({
|
103
|
+
clientId,
|
104
|
+
fetch = globalThis.fetch,
|
105
|
+
signal,
|
106
|
+
}: OAuthClientFetchMetadataOptions) {
|
107
|
+
signal?.throwIfAborted()
|
108
|
+
|
109
|
+
const request = new Request(clientId, {
|
110
|
+
redirect: 'error',
|
111
|
+
signal: signal,
|
112
|
+
})
|
113
|
+
const response = await fetch(request)
|
114
|
+
|
115
|
+
if (response.status !== 200) {
|
116
|
+
response.body?.cancel?.()
|
117
|
+
throw new TypeError(`Failed to fetch client metadata: ${response.status}`)
|
118
|
+
}
|
119
|
+
|
120
|
+
// https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-4.1
|
121
|
+
const mime = response.headers.get('content-type')?.split(';')[0].trim()
|
122
|
+
if (mime !== 'application/json') {
|
123
|
+
response.body?.cancel?.()
|
124
|
+
throw new TypeError(`Invalid client metadata content type: ${mime}`)
|
125
|
+
}
|
126
|
+
|
127
|
+
const json: unknown = await response.json()
|
128
|
+
|
129
|
+
signal?.throwIfAborted()
|
130
|
+
|
131
|
+
return oauthClientMetadataSchema.parse(json)
|
132
|
+
}
|
133
|
+
|
95
134
|
// Config
|
96
135
|
readonly clientMetadata: ClientMetadata
|
97
136
|
readonly responseMode: OAuthResponseMode
|
@@ -132,6 +171,8 @@ export class OAuthClient {
|
|
132
171
|
runtimeImplementation,
|
133
172
|
keyset,
|
134
173
|
}: OAuthClientOptions) {
|
174
|
+
super()
|
175
|
+
|
135
176
|
this.keyset = keyset
|
136
177
|
? keyset instanceof Keyset
|
137
178
|
? keyset
|
@@ -177,6 +218,15 @@ export class OAuthClient {
|
|
177
218
|
this.runtime,
|
178
219
|
)
|
179
220
|
this.stateStore = stateStore
|
221
|
+
|
222
|
+
// Proxy sessionGetter events
|
223
|
+
for (const type of ['deleted', 'updated'] as const) {
|
224
|
+
this.sessionGetter.addEventListener(type, (event) => {
|
225
|
+
if (!this.dispatchCustomEvent(type, event.detail)) {
|
226
|
+
event.preventDefault()
|
227
|
+
}
|
228
|
+
})
|
229
|
+
}
|
180
230
|
}
|
181
231
|
|
182
232
|
// Exposed as public API for convenience
|
@@ -194,10 +244,11 @@ export class OAuthClient {
|
|
194
244
|
return this.identityResolver.handleResolver
|
195
245
|
}
|
196
246
|
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
247
|
+
get jwks() {
|
248
|
+
return this.keyset?.publicJwks ?? ({ keys: [] as const } as const)
|
249
|
+
}
|
250
|
+
|
251
|
+
async authorize(input: string, options?: AuthorizeOptions): Promise<URL> {
|
201
252
|
const redirectUri =
|
202
253
|
options?.redirect_uri ?? this.clientMetadata.redirect_uris[0]
|
203
254
|
if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
|
@@ -239,7 +290,9 @@ export class OAuthClient {
|
|
239
290
|
code_challenge_method: pkce?.method,
|
240
291
|
nonce,
|
241
292
|
state,
|
242
|
-
login_hint: identity
|
293
|
+
login_hint: identity
|
294
|
+
? input // If input is a handle or a DID, use it as a login_hint
|
295
|
+
: undefined,
|
243
296
|
response_mode: this.responseMode,
|
244
297
|
response_type:
|
245
298
|
// Negotiate by using the order in the client metadata
|
@@ -297,6 +350,22 @@ export class OAuthClient {
|
|
297
350
|
)
|
298
351
|
}
|
299
352
|
|
353
|
+
/**
|
354
|
+
* This method allows the client to proactively revoke the request_uri it
|
355
|
+
* created through PAR.
|
356
|
+
*/
|
357
|
+
async abortRequest(authorizeUrl: URL) {
|
358
|
+
const requestUri = authorizeUrl.searchParams.get('request_uri')
|
359
|
+
if (!requestUri) return
|
360
|
+
|
361
|
+
// @NOTE This is not implemented here because, 1) the request server should
|
362
|
+
// invalidate the request_uri after some delay anyways, and 2) I am not sure
|
363
|
+
// that the revocation endpoint is even supposed to support this (and I
|
364
|
+
// don't want to spend the time checking now).
|
365
|
+
|
366
|
+
// @TODO investigate actual necessity & feasibility of this feature
|
367
|
+
}
|
368
|
+
|
300
369
|
async callback(params: URLSearchParams): Promise<{
|
301
370
|
agent: OAuthAgent
|
302
371
|
state: string | null
|
@@ -424,17 +493,30 @@ export class OAuthClient {
|
|
424
493
|
}
|
425
494
|
|
426
495
|
async revoke(sub: string) {
|
427
|
-
const { dpopKey, tokenSet } = await this.sessionGetter.
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
|
496
|
+
const { dpopKey, tokenSet } = await this.sessionGetter.getSession(
|
497
|
+
sub,
|
498
|
+
false,
|
499
|
+
)
|
432
500
|
|
433
|
-
await
|
434
|
-
|
501
|
+
// NOT using `;(await this.restore(sub, false)).signOut()` because we want
|
502
|
+
// the tokens to be deleted even if it was not possible to fetch the issuer
|
503
|
+
// data.
|
504
|
+
try {
|
505
|
+
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
|
506
|
+
await server.revoke(tokenSet.access_token)
|
507
|
+
} finally {
|
508
|
+
await this.sessionGetter.delStored(sub, new TokenRevokedError(sub))
|
509
|
+
}
|
435
510
|
}
|
436
511
|
|
437
512
|
createAgent(server: OAuthServerAgent, sub: string): OAuthAgent {
|
438
|
-
|
513
|
+
const oauthAgent = new OAuthAgent(
|
514
|
+
server,
|
515
|
+
sub,
|
516
|
+
this.sessionGetter,
|
517
|
+
this.fetch,
|
518
|
+
)
|
519
|
+
|
520
|
+
return oauthAgent
|
439
521
|
}
|
440
522
|
}
|
package/src/oauth-resolver.ts
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
import {
|
2
|
-
|
2
|
+
ResolveIdentityOptions,
|
3
3
|
IdentityResolver,
|
4
4
|
ResolvedIdentity,
|
5
5
|
} from '@atproto-labs/identity-resolver'
|
@@ -13,7 +13,7 @@ import {
|
|
13
13
|
import { OAuthProtectedResourceMetadataResolver } from './oauth-protected-resource-metadata-resolver.js'
|
14
14
|
|
15
15
|
export type { GetCachedOptions }
|
16
|
-
export type
|
16
|
+
export type ResolveOAuthOptions = GetCachedOptions & ResolveIdentityOptions
|
17
17
|
|
18
18
|
export class OAuthResolver {
|
19
19
|
constructor(
|
@@ -24,7 +24,7 @@ export class OAuthResolver {
|
|
24
24
|
|
25
25
|
public async resolveIdentity(
|
26
26
|
input: string,
|
27
|
-
options?:
|
27
|
+
options?: ResolveIdentityOptions,
|
28
28
|
): Promise<ResolvedIdentity> {
|
29
29
|
try {
|
30
30
|
return await this.identityResolver.resolve(input, options)
|
@@ -93,7 +93,7 @@ export class OAuthResolver {
|
|
93
93
|
|
94
94
|
public async resolve(
|
95
95
|
input: string,
|
96
|
-
options?:
|
96
|
+
options?: ResolveOAuthOptions,
|
97
97
|
): Promise<{
|
98
98
|
identity: ResolvedIdentity
|
99
99
|
metadata: OAuthAuthorizationServerMetadata
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import { Fetch, Json,
|
1
|
+
import { Fetch, Json, bindFetch, fetchJsonProcessor } from '@atproto-labs/fetch'
|
2
2
|
import { SimpleStore } from '@atproto-labs/simple-store'
|
3
3
|
import { Key, Keyset, SignedJwt } from '@atproto/jwk'
|
4
4
|
import {
|
@@ -14,13 +14,13 @@ import {
|
|
14
14
|
} from '@atproto/oauth-types'
|
15
15
|
|
16
16
|
import { FALLBACK_ALG } from './constants.js'
|
17
|
+
import { TokenRefreshError } from './errors/token-refresh-error.js'
|
17
18
|
import { dpopFetchWrapper } from './fetch-dpop.js'
|
18
19
|
import { OAuthResolver } from './oauth-resolver.js'
|
19
20
|
import { OAuthResponseError } from './oauth-response-error.js'
|
20
|
-
import { RefreshError } from './refresh-error.js'
|
21
21
|
import { Runtime } from './runtime.js'
|
22
22
|
import { ClientMetadata } from './types.js'
|
23
|
-
import {
|
23
|
+
import { timeoutSignal } from './util.js'
|
24
24
|
|
25
25
|
export type TokenSet = {
|
26
26
|
iss: string
|
@@ -89,7 +89,7 @@ export class OAuthServerAgent {
|
|
89
89
|
|
90
90
|
async refresh(tokenSet: TokenSet): Promise<TokenSet> {
|
91
91
|
if (!tokenSet.refresh_token) {
|
92
|
-
throw new
|
92
|
+
throw new TokenRefreshError(tokenSet.sub, 'No refresh token available')
|
93
93
|
}
|
94
94
|
|
95
95
|
const tokenResponse = await this.request('token', {
|
@@ -99,13 +99,13 @@ export class OAuthServerAgent {
|
|
99
99
|
|
100
100
|
try {
|
101
101
|
if (tokenSet.sub !== tokenResponse.sub) {
|
102
|
-
throw new
|
102
|
+
throw new TokenRefreshError(
|
103
103
|
tokenSet.sub,
|
104
104
|
`Unexpected "sub" in token response (${tokenResponse.sub})`,
|
105
105
|
)
|
106
106
|
}
|
107
107
|
if (tokenSet.iss !== this.serverMetadata.issuer) {
|
108
|
-
throw new
|
108
|
+
throw new TokenRefreshError(tokenSet.sub, 'Issuer mismatch')
|
109
109
|
}
|
110
110
|
|
111
111
|
return this.processTokenResponse(tokenResponse)
|
@@ -132,9 +132,9 @@ export class OAuthServerAgent {
|
|
132
132
|
if (!sub) throw new TypeError(`Missing "sub" in token response`)
|
133
133
|
|
134
134
|
// @TODO (?) make timeout configurable
|
135
|
-
|
136
|
-
|
137
|
-
)
|
135
|
+
using signal = timeoutSignal(10e3)
|
136
|
+
|
137
|
+
const resolved = await this.oauthResolver.resolve(sub, { signal })
|
138
138
|
|
139
139
|
if (resolved.metadata.issuer !== this.serverMetadata.issuer) {
|
140
140
|
// Best case scenario; the user switched PDS. Worst case scenario; a bad
|
@@ -1,17 +1,25 @@
|
|
1
1
|
import { Key } from '@atproto/jwk'
|
2
|
-
|
3
|
-
export type DigestAlgorithm = {
|
4
|
-
name: 'sha256' | 'sha384' | 'sha512'
|
5
|
-
}
|
2
|
+
import { Awaitable } from './util.js'
|
6
3
|
|
7
4
|
export type { Key }
|
5
|
+
export type RuntimeKeyFactory = (algs: string[]) => Key | PromiseLike<Key>
|
6
|
+
|
7
|
+
export type RuntimeRandomValues = (length: number) => Awaitable<Uint8Array>
|
8
|
+
|
9
|
+
export type DigestAlgorithm = { name: 'sha256' | 'sha384' | 'sha512' }
|
10
|
+
export type RuntimeDigest = (
|
11
|
+
data: Uint8Array,
|
12
|
+
alg: DigestAlgorithm,
|
13
|
+
) => Awaitable<Uint8Array>
|
14
|
+
|
15
|
+
export type RuntimeLock = <T>(
|
16
|
+
name: string,
|
17
|
+
fn: () => Awaitable<T>,
|
18
|
+
) => Awaitable<T>
|
8
19
|
|
9
20
|
export interface RuntimeImplementation {
|
10
|
-
createKey
|
11
|
-
getRandomValues:
|
12
|
-
digest:
|
13
|
-
|
14
|
-
algorithm: DigestAlgorithm,
|
15
|
-
) => Uint8Array | PromiseLike<Uint8Array>
|
16
|
-
requestLock?: <T>(name: string, fn: () => T | PromiseLike<T>) => Promise<T>
|
21
|
+
createKey: RuntimeKeyFactory
|
22
|
+
getRandomValues: RuntimeRandomValues
|
23
|
+
digest: RuntimeDigest
|
24
|
+
requestLock?: RuntimeLock
|
17
25
|
}
|
package/src/runtime.ts
CHANGED
@@ -5,10 +5,22 @@ import { requestLocalLock } from './lock.js'
|
|
5
5
|
import {
|
6
6
|
DigestAlgorithm,
|
7
7
|
RuntimeImplementation,
|
8
|
+
RuntimeLock,
|
8
9
|
} from './runtime-implementation.js'
|
9
10
|
|
10
11
|
export class Runtime {
|
11
|
-
|
12
|
+
readonly hasImplementationLock: boolean
|
13
|
+
readonly usingLock: RuntimeLock
|
14
|
+
|
15
|
+
constructor(protected implementation: RuntimeImplementation) {
|
16
|
+
const { requestLock } = implementation
|
17
|
+
|
18
|
+
this.hasImplementationLock = requestLock != null
|
19
|
+
this.usingLock =
|
20
|
+
requestLock?.bind(implementation) ||
|
21
|
+
// Falling back to a local lock
|
22
|
+
requestLocalLock
|
23
|
+
}
|
12
24
|
|
13
25
|
public async generateKey(algs: string[]): Promise<Key> {
|
14
26
|
const algsSorted = Array.from(algs).sort(compareAlgos)
|
@@ -26,22 +38,6 @@ export class Runtime {
|
|
26
38
|
return base64url.baseEncode(bytes)
|
27
39
|
}
|
28
40
|
|
29
|
-
get hasLock() {
|
30
|
-
return !!this.implementation.requestLock
|
31
|
-
}
|
32
|
-
|
33
|
-
public async withLock<T>(
|
34
|
-
name: string,
|
35
|
-
fn: () => T | PromiseLike<T>,
|
36
|
-
): Promise<T> {
|
37
|
-
if (this.implementation.requestLock) {
|
38
|
-
return this.implementation.requestLock(name, fn)
|
39
|
-
} else {
|
40
|
-
// Falling back to a local lock
|
41
|
-
return requestLocalLock(name, fn)
|
42
|
-
}
|
43
|
-
}
|
44
|
-
|
45
41
|
public async validateIdTokenClaims(
|
46
42
|
token: string,
|
47
43
|
state: string,
|