@atproto/oauth-client 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- package/CHANGELOG.md +20 -0
- package/LICENSE.txt +7 -0
- package/README.md +124 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +8 -0
- package/dist/constants.js.map +1 -0
- package/dist/fetch-dpop.d.ts +21 -0
- package/dist/fetch-dpop.d.ts.map +1 -0
- package/dist/fetch-dpop.js +149 -0
- package/dist/fetch-dpop.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/lock.d.ts +2 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +33 -0
- package/dist/lock.js.map +1 -0
- package/dist/oauth-agent.d.ts +29 -0
- package/dist/oauth-agent.d.ts.map +1 -0
- package/dist/oauth-agent.js +138 -0
- package/dist/oauth-agent.js.map +1 -0
- package/dist/oauth-authorization-server-metadata-resolver.d.ts +15 -0
- package/dist/oauth-authorization-server-metadata-resolver.d.ts.map +1 -0
- package/dist/oauth-authorization-server-metadata-resolver.js +56 -0
- package/dist/oauth-authorization-server-metadata-resolver.js.map +1 -0
- package/dist/oauth-callback-error.d.ts +7 -0
- package/dist/oauth-callback-error.d.ts.map +1 -0
- package/dist/oauth-callback-error.js +28 -0
- package/dist/oauth-callback-error.js.map +1 -0
- package/dist/oauth-client.d.ts +78 -0
- package/dist/oauth-client.d.ts.map +1 -0
- package/dist/oauth-client.js +278 -0
- package/dist/oauth-client.js.map +1 -0
- package/dist/oauth-protected-resource-metadata-resolver.d.ts +15 -0
- package/dist/oauth-protected-resource-metadata-resolver.d.ts.map +1 -0
- package/dist/oauth-protected-resource-metadata-resolver.js +58 -0
- package/dist/oauth-protected-resource-metadata-resolver.js.map +1 -0
- package/dist/oauth-resolver-error.d.ts +7 -0
- package/dist/oauth-resolver-error.d.ts.map +1 -0
- package/dist/oauth-resolver-error.js +17 -0
- package/dist/oauth-resolver-error.js.map +1 -0
- package/dist/oauth-resolver.d.ts +62 -0
- package/dist/oauth-resolver.d.ts.map +1 -0
- package/dist/oauth-resolver.js +73 -0
- package/dist/oauth-resolver.js.map +1 -0
- package/dist/oauth-response-error.d.ts +11 -0
- package/dist/oauth-response-error.d.ts.map +1 -0
- package/dist/oauth-response-error.js +48 -0
- package/dist/oauth-response-error.js.map +1 -0
- package/dist/oauth-server-agent.d.ts +51 -0
- package/dist/oauth-server-agent.d.ts.map +1 -0
- package/dist/oauth-server-agent.js +228 -0
- package/dist/oauth-server-agent.js.map +1 -0
- package/dist/oauth-server-factory.d.ts +20 -0
- package/dist/oauth-server-factory.d.ts.map +1 -0
- package/dist/oauth-server-factory.js +53 -0
- package/dist/oauth-server-factory.js.map +1 -0
- package/dist/refresh-error.d.ts +7 -0
- package/dist/refresh-error.d.ts.map +1 -0
- package/dist/refresh-error.js +16 -0
- package/dist/refresh-error.js.map +1 -0
- package/dist/runtime-implementation.d.ts +12 -0
- package/dist/runtime-implementation.d.ts.map +1 -0
- package/dist/runtime-implementation.js +3 -0
- package/dist/runtime-implementation.js.map +1 -0
- package/dist/runtime.d.ts +35 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +185 -0
- package/dist/runtime.js.map +1 -0
- package/dist/session-getter.d.ts +30 -0
- package/dist/session-getter.d.ts.map +1 -0
- package/dist/session-getter.js +149 -0
- package/dist/session-getter.js.map +1 -0
- package/dist/types.d.ts +1580 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +9 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +35 -0
- package/dist/util.js.map +1 -0
- package/dist/validate-client-metadata.d.ts +5 -0
- package/dist/validate-client-metadata.d.ts.map +1 -0
- package/dist/validate-client-metadata.js +46 -0
- package/dist/validate-client-metadata.js.map +1 -0
- package/package.json +46 -0
- package/src/constants.ts +4 -0
- package/src/fetch-dpop.ts +235 -0
- package/src/index.ts +18 -0
- package/src/lock.ts +34 -0
- package/src/oauth-agent.ts +150 -0
- package/src/oauth-authorization-server-metadata-resolver.ts +98 -0
- package/src/oauth-callback-error.ts +16 -0
- package/src/oauth-client.ts +440 -0
- package/src/oauth-protected-resource-metadata-resolver.ts +102 -0
- package/src/oauth-resolver-error.ts +12 -0
- package/src/oauth-resolver.ts +111 -0
- package/src/oauth-response-error.ts +31 -0
- package/src/oauth-server-agent.ts +275 -0
- package/src/oauth-server-factory.ts +41 -0
- package/src/refresh-error.ts +9 -0
- package/src/runtime-implementation.ts +17 -0
- package/src/runtime.ts +211 -0
- package/src/session-getter.ts +182 -0
- package/src/types.ts +26 -0
- package/src/util.ts +51 -0
- package/src/validate-client-metadata.ts +61 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +4 -0
@@ -0,0 +1,98 @@
|
|
1
|
+
import {
|
2
|
+
bindFetch,
|
3
|
+
cancelBody,
|
4
|
+
Fetch,
|
5
|
+
FetchResponseError,
|
6
|
+
} from '@atproto-labs/fetch'
|
7
|
+
import {
|
8
|
+
CachedGetter,
|
9
|
+
GetCachedOptions,
|
10
|
+
SimpleStore,
|
11
|
+
} from '@atproto-labs/simple-store'
|
12
|
+
import {
|
13
|
+
OAuthAuthorizationServerMetadata,
|
14
|
+
oauthAuthorizationServerMetadataValidator,
|
15
|
+
oauthIssuerIdentifierSchema,
|
16
|
+
} from '@atproto/oauth-types'
|
17
|
+
import { contentMime } from './util'
|
18
|
+
|
19
|
+
export type { GetCachedOptions, OAuthAuthorizationServerMetadata }
|
20
|
+
|
21
|
+
export type AuthorizationServerMetadataCache = SimpleStore<
|
22
|
+
string,
|
23
|
+
OAuthAuthorizationServerMetadata
|
24
|
+
>
|
25
|
+
|
26
|
+
/**
|
27
|
+
* @see {@link https://datatracker.ietf.org/doc/html/rfc8414}
|
28
|
+
*/
|
29
|
+
export class OAuthAuthorizationServerMetadataResolver extends CachedGetter<
|
30
|
+
string,
|
31
|
+
OAuthAuthorizationServerMetadata
|
32
|
+
> {
|
33
|
+
private readonly fetch: Fetch<unknown>
|
34
|
+
|
35
|
+
constructor(cache: AuthorizationServerMetadataCache, fetch?: Fetch) {
|
36
|
+
super(async (issuer, options) => this.fetchMetadata(issuer, options), cache)
|
37
|
+
|
38
|
+
this.fetch = bindFetch(fetch)
|
39
|
+
}
|
40
|
+
|
41
|
+
async get(
|
42
|
+
issuer: string,
|
43
|
+
options?: GetCachedOptions,
|
44
|
+
): Promise<OAuthAuthorizationServerMetadata> {
|
45
|
+
return super.get(oauthIssuerIdentifierSchema.parse(issuer), options)
|
46
|
+
}
|
47
|
+
|
48
|
+
private async fetchMetadata(
|
49
|
+
issuer: string,
|
50
|
+
options?: GetCachedOptions,
|
51
|
+
): Promise<OAuthAuthorizationServerMetadata> {
|
52
|
+
const headers = new Headers([['accept', 'application/json']])
|
53
|
+
if (options?.noCache) headers.set('cache-control', 'no-cache')
|
54
|
+
|
55
|
+
const url = new URL(`/.well-known/oauth-authorization-server`, issuer)
|
56
|
+
const request = new Request(url, {
|
57
|
+
signal: options?.signal,
|
58
|
+
headers,
|
59
|
+
redirect: 'manual', // response must be 200 OK
|
60
|
+
})
|
61
|
+
|
62
|
+
const response = await this.fetch(request)
|
63
|
+
|
64
|
+
// https://datatracker.ietf.org/doc/html/rfc8414#section-3.2
|
65
|
+
if (response.status !== 200) {
|
66
|
+
await cancelBody(response, 'log')
|
67
|
+
throw await FetchResponseError.from(
|
68
|
+
response,
|
69
|
+
`Unexpected status code ${response.status} for "${url}"`,
|
70
|
+
undefined,
|
71
|
+
{ cause: request },
|
72
|
+
)
|
73
|
+
}
|
74
|
+
|
75
|
+
if (contentMime(response.headers) !== 'application/json') {
|
76
|
+
await cancelBody(response, 'log')
|
77
|
+
throw await FetchResponseError.from(
|
78
|
+
response,
|
79
|
+
`Unexpected content type for "${url}"`,
|
80
|
+
undefined,
|
81
|
+
{ cause: request },
|
82
|
+
)
|
83
|
+
}
|
84
|
+
|
85
|
+
const metadata = oauthAuthorizationServerMetadataValidator.parse(
|
86
|
+
await response.json(),
|
87
|
+
)
|
88
|
+
|
89
|
+
// Validate the issuer (MIX-UP attacks)
|
90
|
+
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-mix-up-attacks
|
91
|
+
// https://datatracker.ietf.org/doc/html/rfc8414#section-2
|
92
|
+
if (metadata.issuer !== issuer) {
|
93
|
+
throw new TypeError(`Invalid issuer ${metadata.issuer}`)
|
94
|
+
}
|
95
|
+
|
96
|
+
return metadata
|
97
|
+
}
|
98
|
+
}
|
@@ -0,0 +1,16 @@
|
|
1
|
+
export class OAuthCallbackError extends Error {
|
2
|
+
static from(err: unknown, params: URLSearchParams, state?: string) {
|
3
|
+
if (err instanceof OAuthCallbackError) return err
|
4
|
+
const message = err instanceof Error ? err.message : undefined
|
5
|
+
return new OAuthCallbackError(params, message, state, err)
|
6
|
+
}
|
7
|
+
|
8
|
+
constructor(
|
9
|
+
public readonly params: URLSearchParams,
|
10
|
+
message = params.get('error_description') || 'OAuth callback error',
|
11
|
+
public readonly state?: string,
|
12
|
+
cause?: unknown,
|
13
|
+
) {
|
14
|
+
super(message, { cause })
|
15
|
+
}
|
16
|
+
}
|
@@ -0,0 +1,440 @@
|
|
1
|
+
import {
|
2
|
+
DidCache,
|
3
|
+
DidResolverCached,
|
4
|
+
DidResolverCommon,
|
5
|
+
} from '@atproto-labs/did-resolver'
|
6
|
+
import { Fetch } from '@atproto-labs/fetch'
|
7
|
+
import {
|
8
|
+
AppViewHandleResolver,
|
9
|
+
CachedHandleResolver,
|
10
|
+
HandleCache,
|
11
|
+
HandleResolver,
|
12
|
+
} from '@atproto-labs/handle-resolver'
|
13
|
+
import { IdentityResolver } from '@atproto-labs/identity-resolver'
|
14
|
+
import { SimpleStore } from '@atproto-labs/simple-store'
|
15
|
+
import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
|
16
|
+
import { Key, Keyset } from '@atproto/jwk'
|
17
|
+
import {
|
18
|
+
OAuthClientMetadata,
|
19
|
+
OAuthClientMetadataInput,
|
20
|
+
OAuthResponseMode,
|
21
|
+
} from '@atproto/oauth-types'
|
22
|
+
|
23
|
+
import { FALLBACK_ALG } from './constants.js'
|
24
|
+
import { OAuthAgent } from './oauth-agent.js'
|
25
|
+
import {
|
26
|
+
AuthorizationServerMetadataCache,
|
27
|
+
OAuthAuthorizationServerMetadataResolver,
|
28
|
+
} from './oauth-authorization-server-metadata-resolver.js'
|
29
|
+
import { OAuthCallbackError } from './oauth-callback-error.js'
|
30
|
+
import {
|
31
|
+
OAuthProtectedResourceMetadataResolver,
|
32
|
+
ProtectedResourceMetadataCache,
|
33
|
+
} from './oauth-protected-resource-metadata-resolver.js'
|
34
|
+
import { OAuthResolver } from './oauth-resolver.js'
|
35
|
+
import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
|
36
|
+
import { OAuthServerFactory } from './oauth-server-factory.js'
|
37
|
+
import { RuntimeImplementation } from './runtime-implementation.js'
|
38
|
+
import { Runtime } from './runtime.js'
|
39
|
+
import { SessionGetter, SessionStore } from './session-getter.js'
|
40
|
+
import { AuthorizeOptions, ClientMetadata } from './types.js'
|
41
|
+
import { validateClientMetadata } from './validate-client-metadata.js'
|
42
|
+
|
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
|
+
// Export all types needed to construct OAuthClientOptions
|
59
|
+
export type {
|
60
|
+
AuthorizationServerMetadataCache,
|
61
|
+
DpopNonceCache,
|
62
|
+
Fetch,
|
63
|
+
Keyset,
|
64
|
+
OAuthClientMetadata,
|
65
|
+
OAuthClientMetadataInput,
|
66
|
+
OAuthResponseMode,
|
67
|
+
ProtectedResourceMetadataCache,
|
68
|
+
RuntimeImplementation,
|
69
|
+
SessionStore,
|
70
|
+
}
|
71
|
+
|
72
|
+
export type OAuthClientOptions = {
|
73
|
+
// Config
|
74
|
+
responseMode: OAuthResponseMode
|
75
|
+
clientMetadata: Readonly<OAuthClientMetadataInput>
|
76
|
+
keyset?: Keyset | Iterable<Key | undefined | null | false>
|
77
|
+
|
78
|
+
// Stores
|
79
|
+
stateStore: StateStore
|
80
|
+
sessionStore: SessionStore
|
81
|
+
didCache?: DidCache
|
82
|
+
handleCache?: HandleCache
|
83
|
+
authorizationServerMetadataCache?: AuthorizationServerMetadataCache
|
84
|
+
protectedResourceMetadataCache?: ProtectedResourceMetadataCache
|
85
|
+
dpopNonceCache?: DpopNonceCache
|
86
|
+
|
87
|
+
// Services
|
88
|
+
handleResolver: HandleResolver | URL | string
|
89
|
+
plcDirectoryUrl?: URL | string
|
90
|
+
runtimeImplementation: RuntimeImplementation
|
91
|
+
fetch?: Fetch
|
92
|
+
}
|
93
|
+
|
94
|
+
export class OAuthClient {
|
95
|
+
// Config
|
96
|
+
readonly clientMetadata: ClientMetadata
|
97
|
+
readonly responseMode: OAuthResponseMode
|
98
|
+
readonly keyset?: Keyset
|
99
|
+
|
100
|
+
// Services
|
101
|
+
readonly runtime: Runtime
|
102
|
+
readonly fetch: Fetch
|
103
|
+
readonly oauthResolver: OAuthResolver
|
104
|
+
readonly serverFactory: OAuthServerFactory
|
105
|
+
|
106
|
+
// Stores
|
107
|
+
readonly sessionGetter: SessionGetter
|
108
|
+
readonly stateStore: StateStore
|
109
|
+
|
110
|
+
constructor({
|
111
|
+
fetch = globalThis.fetch,
|
112
|
+
|
113
|
+
stateStore,
|
114
|
+
sessionStore,
|
115
|
+
|
116
|
+
didCache = undefined,
|
117
|
+
dpopNonceCache = new SimpleStoreMemory({ ttl: 60e3, max: 100 }),
|
118
|
+
handleCache = undefined,
|
119
|
+
authorizationServerMetadataCache = new SimpleStoreMemory({
|
120
|
+
ttl: 60e3,
|
121
|
+
max: 100,
|
122
|
+
}),
|
123
|
+
protectedResourceMetadataCache = new SimpleStoreMemory({
|
124
|
+
ttl: 60e3,
|
125
|
+
max: 100,
|
126
|
+
}),
|
127
|
+
|
128
|
+
responseMode,
|
129
|
+
clientMetadata,
|
130
|
+
handleResolver,
|
131
|
+
plcDirectoryUrl,
|
132
|
+
runtimeImplementation,
|
133
|
+
keyset,
|
134
|
+
}: OAuthClientOptions) {
|
135
|
+
this.keyset = keyset
|
136
|
+
? keyset instanceof Keyset
|
137
|
+
? keyset
|
138
|
+
: new Keyset(keyset)
|
139
|
+
: undefined
|
140
|
+
this.clientMetadata = validateClientMetadata(clientMetadata, this.keyset)
|
141
|
+
this.responseMode = responseMode
|
142
|
+
|
143
|
+
this.runtime = new Runtime(runtimeImplementation)
|
144
|
+
this.fetch = fetch
|
145
|
+
this.oauthResolver = new OAuthResolver(
|
146
|
+
new IdentityResolver(
|
147
|
+
new DidResolverCached(
|
148
|
+
new DidResolverCommon({ fetch, plcDirectoryUrl }),
|
149
|
+
didCache,
|
150
|
+
),
|
151
|
+
new CachedHandleResolver(
|
152
|
+
AppViewHandleResolver.from(handleResolver, { fetch }),
|
153
|
+
handleCache,
|
154
|
+
),
|
155
|
+
),
|
156
|
+
new OAuthProtectedResourceMetadataResolver(
|
157
|
+
protectedResourceMetadataCache,
|
158
|
+
fetch,
|
159
|
+
),
|
160
|
+
new OAuthAuthorizationServerMetadataResolver(
|
161
|
+
authorizationServerMetadataCache,
|
162
|
+
fetch,
|
163
|
+
),
|
164
|
+
)
|
165
|
+
this.serverFactory = new OAuthServerFactory(
|
166
|
+
this.clientMetadata,
|
167
|
+
this.runtime,
|
168
|
+
this.oauthResolver,
|
169
|
+
this.fetch,
|
170
|
+
this.keyset,
|
171
|
+
dpopNonceCache,
|
172
|
+
)
|
173
|
+
|
174
|
+
this.sessionGetter = new SessionGetter(
|
175
|
+
sessionStore,
|
176
|
+
this.serverFactory,
|
177
|
+
this.runtime,
|
178
|
+
)
|
179
|
+
this.stateStore = stateStore
|
180
|
+
}
|
181
|
+
|
182
|
+
// Exposed as public API for convenience
|
183
|
+
get identityResolver() {
|
184
|
+
return this.oauthResolver.identityResolver
|
185
|
+
}
|
186
|
+
|
187
|
+
// Exposed as public API for convenience
|
188
|
+
get didResolver() {
|
189
|
+
return this.identityResolver.didResolver
|
190
|
+
}
|
191
|
+
|
192
|
+
// Exposed as public API for convenience
|
193
|
+
get handleResolver() {
|
194
|
+
return this.identityResolver.handleResolver
|
195
|
+
}
|
196
|
+
|
197
|
+
async authorize(
|
198
|
+
input: string,
|
199
|
+
options?: AuthorizeOptions & { signal?: AbortSignal },
|
200
|
+
): Promise<URL> {
|
201
|
+
const redirectUri =
|
202
|
+
options?.redirect_uri ?? this.clientMetadata.redirect_uris[0]
|
203
|
+
if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
|
204
|
+
// The server will enforce this, but let's catch it early
|
205
|
+
throw new TypeError('Invalid redirect_uri')
|
206
|
+
}
|
207
|
+
|
208
|
+
const signal = options?.signal
|
209
|
+
const { identity, metadata } = /^https?:\/\//.test(input)
|
210
|
+
? // Allow using an entryway url directly as login input (e.g. when the
|
211
|
+
// user forgot their handle, or when the handle does not resolve to a
|
212
|
+
// DID)
|
213
|
+
{
|
214
|
+
identity: undefined,
|
215
|
+
metadata: await this.oauthResolver.resolveMetadata(input, { signal }),
|
216
|
+
}
|
217
|
+
: await this.oauthResolver.resolve(input, { signal })
|
218
|
+
|
219
|
+
const nonce = await this.runtime.generateNonce()
|
220
|
+
const pkce = await this.runtime.generatePKCE()
|
221
|
+
const dpopKey = await this.runtime.generateKey(
|
222
|
+
metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG],
|
223
|
+
)
|
224
|
+
|
225
|
+
const state = await this.runtime.generateNonce()
|
226
|
+
|
227
|
+
await this.stateStore.set(state, {
|
228
|
+
iss: metadata.issuer,
|
229
|
+
dpopKey,
|
230
|
+
nonce,
|
231
|
+
verifier: pkce?.verifier,
|
232
|
+
appState: options?.state,
|
233
|
+
})
|
234
|
+
|
235
|
+
const parameters = {
|
236
|
+
client_id: this.clientMetadata.client_id,
|
237
|
+
redirect_uri: redirectUri,
|
238
|
+
code_challenge: pkce?.challenge,
|
239
|
+
code_challenge_method: pkce?.method,
|
240
|
+
nonce,
|
241
|
+
state,
|
242
|
+
login_hint: identity?.did || undefined,
|
243
|
+
response_mode: this.responseMode,
|
244
|
+
response_type:
|
245
|
+
// Negotiate by using the order in the client metadata
|
246
|
+
this.clientMetadata.response_types?.find((t) =>
|
247
|
+
metadata['response_types_supported']?.includes(t),
|
248
|
+
) ?? 'code',
|
249
|
+
|
250
|
+
display: options?.display,
|
251
|
+
id_token_hint: options?.id_token_hint,
|
252
|
+
max_age: options?.max_age, // this.clientMetadata.default_max_age
|
253
|
+
prompt: options?.prompt,
|
254
|
+
scope: options?.scope
|
255
|
+
?.split(' ')
|
256
|
+
.filter((s) => metadata.scopes_supported?.includes(s))
|
257
|
+
.join(' '),
|
258
|
+
ui_locales: options?.ui_locales,
|
259
|
+
}
|
260
|
+
|
261
|
+
if (metadata.pushed_authorization_request_endpoint) {
|
262
|
+
const server = await this.serverFactory.fromMetadata(metadata, dpopKey)
|
263
|
+
const parResponse = await server.request(
|
264
|
+
'pushed_authorization_request',
|
265
|
+
parameters,
|
266
|
+
)
|
267
|
+
|
268
|
+
const authorizationUrl = new URL(metadata.authorization_endpoint)
|
269
|
+
authorizationUrl.searchParams.set(
|
270
|
+
'client_id',
|
271
|
+
this.clientMetadata.client_id,
|
272
|
+
)
|
273
|
+
authorizationUrl.searchParams.set('request_uri', parResponse.request_uri)
|
274
|
+
return authorizationUrl
|
275
|
+
} else if (metadata.require_pushed_authorization_requests) {
|
276
|
+
throw new Error(
|
277
|
+
'Server requires pushed authorization requests (PAR) but no PAR endpoint is available',
|
278
|
+
)
|
279
|
+
} else {
|
280
|
+
const authorizationUrl = new URL(metadata.authorization_endpoint)
|
281
|
+
for (const [key, value] of Object.entries(parameters)) {
|
282
|
+
if (value) authorizationUrl.searchParams.set(key, String(value))
|
283
|
+
}
|
284
|
+
|
285
|
+
// Length of the URL that will be sent to the server
|
286
|
+
const urlLength =
|
287
|
+
authorizationUrl.pathname.length + authorizationUrl.search.length
|
288
|
+
if (urlLength < 2048) {
|
289
|
+
return authorizationUrl
|
290
|
+
} else if (!metadata.pushed_authorization_request_endpoint) {
|
291
|
+
throw new Error('Login URL too long')
|
292
|
+
}
|
293
|
+
}
|
294
|
+
|
295
|
+
throw new Error(
|
296
|
+
'Server does not support pushed authorization requests (PAR)',
|
297
|
+
)
|
298
|
+
}
|
299
|
+
|
300
|
+
async callback(params: URLSearchParams): Promise<{
|
301
|
+
agent: OAuthAgent
|
302
|
+
state: string | null
|
303
|
+
}> {
|
304
|
+
const responseJwt = params.get('response')
|
305
|
+
if (responseJwt != null) {
|
306
|
+
// https://openid.net/specs/oauth-v2-jarm.html
|
307
|
+
throw new OAuthCallbackError(params, 'JARM not supported')
|
308
|
+
}
|
309
|
+
|
310
|
+
const issuerParam = params.get('iss')
|
311
|
+
const stateParam = params.get('state')
|
312
|
+
const errorParam = params.get('error')
|
313
|
+
const codeParam = params.get('code')
|
314
|
+
|
315
|
+
if (!stateParam) {
|
316
|
+
throw new OAuthCallbackError(params, 'Missing "state" parameter')
|
317
|
+
}
|
318
|
+
const stateData = await this.stateStore.get(stateParam)
|
319
|
+
if (stateData) {
|
320
|
+
// Prevent any kind of replay
|
321
|
+
await this.stateStore.del(stateParam)
|
322
|
+
} else {
|
323
|
+
throw new OAuthCallbackError(
|
324
|
+
params,
|
325
|
+
`Unknown authorization session "${stateParam}"`,
|
326
|
+
)
|
327
|
+
}
|
328
|
+
|
329
|
+
try {
|
330
|
+
if (errorParam != null) {
|
331
|
+
throw new OAuthCallbackError(params, undefined, stateData.appState)
|
332
|
+
}
|
333
|
+
|
334
|
+
if (!codeParam) {
|
335
|
+
throw new OAuthCallbackError(
|
336
|
+
params,
|
337
|
+
'Missing "code" query param',
|
338
|
+
stateData.appState,
|
339
|
+
)
|
340
|
+
}
|
341
|
+
|
342
|
+
const server = await this.serverFactory.fromIssuer(
|
343
|
+
stateData.iss,
|
344
|
+
stateData.dpopKey,
|
345
|
+
)
|
346
|
+
|
347
|
+
if (issuerParam != null) {
|
348
|
+
if (!server.serverMetadata.issuer) {
|
349
|
+
throw new OAuthCallbackError(
|
350
|
+
params,
|
351
|
+
'Issuer not found in metadata',
|
352
|
+
stateData.appState,
|
353
|
+
)
|
354
|
+
}
|
355
|
+
if (server.serverMetadata.issuer !== issuerParam) {
|
356
|
+
throw new OAuthCallbackError(
|
357
|
+
params,
|
358
|
+
'Issuer mismatch',
|
359
|
+
stateData.appState,
|
360
|
+
)
|
361
|
+
}
|
362
|
+
} else if (
|
363
|
+
server.serverMetadata.authorization_response_iss_parameter_supported
|
364
|
+
) {
|
365
|
+
throw new OAuthCallbackError(
|
366
|
+
params,
|
367
|
+
'iss missing from the response',
|
368
|
+
stateData.appState,
|
369
|
+
)
|
370
|
+
}
|
371
|
+
|
372
|
+
const tokenSet = await server.exchangeCode(codeParam, stateData.verifier)
|
373
|
+
try {
|
374
|
+
if (tokenSet.id_token) {
|
375
|
+
await this.runtime.validateIdTokenClaims(
|
376
|
+
tokenSet.id_token,
|
377
|
+
stateParam,
|
378
|
+
stateData.nonce,
|
379
|
+
codeParam,
|
380
|
+
tokenSet.access_token,
|
381
|
+
)
|
382
|
+
}
|
383
|
+
|
384
|
+
const { sub } = tokenSet
|
385
|
+
|
386
|
+
await this.sessionGetter.setStored(sub, {
|
387
|
+
dpopKey: stateData.dpopKey,
|
388
|
+
tokenSet,
|
389
|
+
})
|
390
|
+
|
391
|
+
const agent = this.createAgent(server, sub)
|
392
|
+
|
393
|
+
return { agent, state: stateData.appState ?? null }
|
394
|
+
} catch (err) {
|
395
|
+
await server.revoke(tokenSet.access_token)
|
396
|
+
|
397
|
+
throw err
|
398
|
+
}
|
399
|
+
} catch (err) {
|
400
|
+
// Make sure, whatever the underlying error, that the appState is
|
401
|
+
// available in the calling code
|
402
|
+
throw OAuthCallbackError.from(err, params, stateData.appState)
|
403
|
+
}
|
404
|
+
}
|
405
|
+
|
406
|
+
/**
|
407
|
+
* Build an agent from a stored session. This will refresh the token only if
|
408
|
+
* needed (about to expire) by default.
|
409
|
+
*
|
410
|
+
* @param refresh See {@link SessionGetter.getSession}
|
411
|
+
*/
|
412
|
+
async restore(sub: string, refresh?: boolean): Promise<OAuthAgent> {
|
413
|
+
const { dpopKey, tokenSet } = await this.sessionGetter.getSession(
|
414
|
+
sub,
|
415
|
+
refresh,
|
416
|
+
)
|
417
|
+
|
418
|
+
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey, {
|
419
|
+
noCache: refresh === true,
|
420
|
+
allowStale: refresh === false,
|
421
|
+
})
|
422
|
+
|
423
|
+
return this.createAgent(server, sub)
|
424
|
+
}
|
425
|
+
|
426
|
+
async revoke(sub: string) {
|
427
|
+
const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
|
428
|
+
allowStale: true,
|
429
|
+
})
|
430
|
+
|
431
|
+
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
|
432
|
+
|
433
|
+
await server.revoke(tokenSet.access_token)
|
434
|
+
await this.sessionGetter.delStored(sub)
|
435
|
+
}
|
436
|
+
|
437
|
+
createAgent(server: OAuthServerAgent, sub: string): OAuthAgent {
|
438
|
+
return new OAuthAgent(server, sub, this.sessionGetter, this.fetch)
|
439
|
+
}
|
440
|
+
}
|
@@ -0,0 +1,102 @@
|
|
1
|
+
import {
|
2
|
+
Fetch,
|
3
|
+
FetchResponseError,
|
4
|
+
bindFetch,
|
5
|
+
cancelBody,
|
6
|
+
} from '@atproto-labs/fetch'
|
7
|
+
import {
|
8
|
+
CachedGetter,
|
9
|
+
GetCachedOptions,
|
10
|
+
SimpleStore,
|
11
|
+
} from '@atproto-labs/simple-store'
|
12
|
+
import {
|
13
|
+
OAuthProtectedResourceMetadata,
|
14
|
+
oauthProtectedResourceMetadataSchema,
|
15
|
+
} from '@atproto/oauth-types'
|
16
|
+
import { contentMime } from './util'
|
17
|
+
|
18
|
+
export type { GetCachedOptions, OAuthProtectedResourceMetadata }
|
19
|
+
|
20
|
+
export type ProtectedResourceMetadataCache = SimpleStore<
|
21
|
+
string,
|
22
|
+
OAuthProtectedResourceMetadata
|
23
|
+
>
|
24
|
+
|
25
|
+
/**
|
26
|
+
* @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05}
|
27
|
+
*/
|
28
|
+
export class OAuthProtectedResourceMetadataResolver extends CachedGetter<
|
29
|
+
string,
|
30
|
+
OAuthProtectedResourceMetadata
|
31
|
+
> {
|
32
|
+
private readonly fetch: Fetch<unknown>
|
33
|
+
|
34
|
+
constructor(
|
35
|
+
cache: ProtectedResourceMetadataCache,
|
36
|
+
fetch: Fetch = globalThis.fetch,
|
37
|
+
) {
|
38
|
+
super(async (origin, options) => this.fetchMetadata(origin, options), cache)
|
39
|
+
|
40
|
+
this.fetch = bindFetch(fetch)
|
41
|
+
}
|
42
|
+
|
43
|
+
async get(
|
44
|
+
resource: string | URL,
|
45
|
+
options?: GetCachedOptions,
|
46
|
+
): Promise<OAuthProtectedResourceMetadata> {
|
47
|
+
const { protocol, origin } = new URL(resource)
|
48
|
+
if (protocol !== 'https:' && protocol !== 'http:') {
|
49
|
+
throw new TypeError(`Invalid resource server ${protocol}`)
|
50
|
+
}
|
51
|
+
return super.get(origin, options)
|
52
|
+
}
|
53
|
+
|
54
|
+
private async fetchMetadata(
|
55
|
+
origin: string,
|
56
|
+
options?: GetCachedOptions,
|
57
|
+
): Promise<OAuthProtectedResourceMetadata> {
|
58
|
+
const headers = new Headers([['accept', 'application/json']])
|
59
|
+
if (options?.noCache) headers.set('cache-control', 'no-cache')
|
60
|
+
|
61
|
+
const url = new URL(`/.well-known/oauth-protected-resource`, origin)
|
62
|
+
const request = new Request(url, {
|
63
|
+
signal: options?.signal,
|
64
|
+
headers,
|
65
|
+
redirect: 'error', // response must be 200 OK
|
66
|
+
})
|
67
|
+
|
68
|
+
const response = await this.fetch(request)
|
69
|
+
|
70
|
+
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-3.2
|
71
|
+
if (response.status !== 200) {
|
72
|
+
await cancelBody(response, 'log')
|
73
|
+
throw await FetchResponseError.from(
|
74
|
+
response,
|
75
|
+
`Unexpected status code ${response.status} for "${url}"`,
|
76
|
+
undefined,
|
77
|
+
{ cause: request },
|
78
|
+
)
|
79
|
+
}
|
80
|
+
|
81
|
+
if (contentMime(response.headers) !== 'application/json') {
|
82
|
+
await cancelBody(response, 'log')
|
83
|
+
throw await FetchResponseError.from(
|
84
|
+
response,
|
85
|
+
`Unexpected content type for "${url}"`,
|
86
|
+
undefined,
|
87
|
+
{ cause: request },
|
88
|
+
)
|
89
|
+
}
|
90
|
+
|
91
|
+
const metadata = oauthProtectedResourceMetadataSchema.parse(
|
92
|
+
await response.json(),
|
93
|
+
)
|
94
|
+
|
95
|
+
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-3.3
|
96
|
+
if (metadata.resource !== origin) {
|
97
|
+
throw new TypeError(`Invalid issuer ${metadata.resource}`)
|
98
|
+
}
|
99
|
+
|
100
|
+
return metadata
|
101
|
+
}
|
102
|
+
}
|
@@ -0,0 +1,12 @@
|
|
1
|
+
export class OAuthResolverError extends Error {
|
2
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
3
|
+
super(message, options)
|
4
|
+
}
|
5
|
+
|
6
|
+
static from(cause: unknown, message?: string): OAuthResolverError {
|
7
|
+
if (cause instanceof OAuthResolverError) return cause
|
8
|
+
return new OAuthResolverError(message ?? `Unable to resolve identity`, {
|
9
|
+
cause,
|
10
|
+
})
|
11
|
+
}
|
12
|
+
}
|