@atproto/oauth-client 0.1.0
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/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
|
+
}
|