@bskyprism/atproto-oauth-client-cloudflare-workers 0.2.2

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.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/lib/did-cache-kv.d.ts +18 -0
  4. package/lib/did-cache-kv.js +26 -0
  5. package/lib/did-resolver/did-cache-memory.d.ts +7 -0
  6. package/lib/did-resolver/did-cache-memory.js +10 -0
  7. package/lib/did-resolver/did-cache.d.ts +14 -0
  8. package/lib/did-resolver/did-cache.js +10 -0
  9. package/lib/did-resolver/did-method.d.ts +11 -0
  10. package/lib/did-resolver/did-method.js +1 -0
  11. package/lib/did-resolver/did-resolver-base.d.ts +9 -0
  12. package/lib/did-resolver/did-resolver-base.js +36 -0
  13. package/lib/did-resolver/did-resolver-common.d.ts +8 -0
  14. package/lib/did-resolver/did-resolver-common.js +11 -0
  15. package/lib/did-resolver/did-resolver.d.ts +6 -0
  16. package/lib/did-resolver/did-resolver.js +1 -0
  17. package/lib/did-resolver/index.d.ts +6 -0
  18. package/lib/did-resolver/index.js +7 -0
  19. package/lib/did-resolver/methods/plc.d.ts +43 -0
  20. package/lib/did-resolver/methods/plc.js +22 -0
  21. package/lib/did-resolver/methods/web.d.ts +43 -0
  22. package/lib/did-resolver/methods/web.js +42 -0
  23. package/lib/did-resolver/methods.d.ts +2 -0
  24. package/lib/did-resolver/methods.js +2 -0
  25. package/lib/did-resolver/util.d.ts +3 -0
  26. package/lib/did-resolver/util.js +1 -0
  27. package/lib/dpop-store.d.ts +21 -0
  28. package/lib/dpop-store.js +25 -0
  29. package/lib/handle-cache-kv.d.ts +17 -0
  30. package/lib/handle-cache-kv.js +31 -0
  31. package/lib/handle-resolver/atproto-doh-handle-resolver.d.ts +8 -0
  32. package/lib/handle-resolver/atproto-doh-handle-resolver.js +94 -0
  33. package/lib/handle-resolver/atproto-handle-resolver.d.ts +21 -0
  34. package/lib/handle-resolver/atproto-handle-resolver.js +46 -0
  35. package/lib/handle-resolver/cached-handle-resolver.d.ts +12 -0
  36. package/lib/handle-resolver/cached-handle-resolver.js +17 -0
  37. package/lib/handle-resolver/handle-resolver-error.d.ts +3 -0
  38. package/lib/handle-resolver/handle-resolver-error.js +6 -0
  39. package/lib/handle-resolver/index.d.ts +6 -0
  40. package/lib/handle-resolver/index.js +8 -0
  41. package/lib/handle-resolver/internal-resolvers/dns-handle-resolver.d.ts +11 -0
  42. package/lib/handle-resolver/internal-resolvers/dns-handle-resolver.js +28 -0
  43. package/lib/handle-resolver/internal-resolvers/well-known-handler-resolver.d.ts +17 -0
  44. package/lib/handle-resolver/internal-resolvers/well-known-handler-resolver.js +28 -0
  45. package/lib/handle-resolver/types.d.ts +25 -0
  46. package/lib/handle-resolver/types.js +10 -0
  47. package/lib/handle-resolver/xrpc-handle-resolver.d.ts +31 -0
  48. package/lib/handle-resolver/xrpc-handle-resolver.js +45 -0
  49. package/lib/handle-resolver.d.ts +20 -0
  50. package/lib/handle-resolver.js +19 -0
  51. package/lib/identity-resolver/atproto-identity-resolver.d.ts +20 -0
  52. package/lib/identity-resolver/atproto-identity-resolver.js +72 -0
  53. package/lib/identity-resolver/constants.d.ts +1 -0
  54. package/lib/identity-resolver/constants.js +1 -0
  55. package/lib/identity-resolver/identity-resolver-error.d.ts +3 -0
  56. package/lib/identity-resolver/identity-resolver-error.js +6 -0
  57. package/lib/identity-resolver/identity-resolver.d.ts +19 -0
  58. package/lib/identity-resolver/identity-resolver.js +1 -0
  59. package/lib/identity-resolver/index.d.ts +5 -0
  60. package/lib/identity-resolver/index.js +5 -0
  61. package/lib/identity-resolver/util.d.ts +12 -0
  62. package/lib/identity-resolver/util.js +35 -0
  63. package/lib/index.d.ts +7 -0
  64. package/lib/index.js +6 -0
  65. package/lib/oauth-client/atproto-token-response.d.ts +100 -0
  66. package/lib/oauth-client/atproto-token-response.js +15 -0
  67. package/lib/oauth-client/constants.d.ts +4 -0
  68. package/lib/oauth-client/constants.js +4 -0
  69. package/lib/oauth-client/errors/auth-method-unsatisfiable-error.d.ts +2 -0
  70. package/lib/oauth-client/errors/auth-method-unsatisfiable-error.js +2 -0
  71. package/lib/oauth-client/errors/token-invalid-error.d.ts +6 -0
  72. package/lib/oauth-client/errors/token-invalid-error.js +6 -0
  73. package/lib/oauth-client/errors/token-refresh-error.d.ts +6 -0
  74. package/lib/oauth-client/errors/token-refresh-error.js +6 -0
  75. package/lib/oauth-client/errors/token-revoked-error.d.ts +6 -0
  76. package/lib/oauth-client/errors/token-revoked-error.js +6 -0
  77. package/lib/oauth-client/fetch-dpop.d.ts +19 -0
  78. package/lib/oauth-client/fetch-dpop.js +176 -0
  79. package/lib/oauth-client/identity-resolver.d.ts +15 -0
  80. package/lib/oauth-client/identity-resolver.js +33 -0
  81. package/lib/oauth-client/index.d.ts +17 -0
  82. package/lib/oauth-client/index.js +17 -0
  83. package/lib/oauth-client/lock.d.ts +2 -0
  84. package/lib/oauth-client/lock.js +28 -0
  85. package/lib/oauth-client/oauth-authorization-server-metadata-resolver.d.ts +18 -0
  86. package/lib/oauth-client/oauth-authorization-server-metadata-resolver.js +53 -0
  87. package/lib/oauth-client/oauth-callback-error.d.ts +6 -0
  88. package/lib/oauth-client/oauth-callback-error.js +13 -0
  89. package/lib/oauth-client/oauth-client-auth.d.ts +22 -0
  90. package/lib/oauth-client/oauth-client-auth.js +127 -0
  91. package/lib/oauth-client/oauth-client.d.ts +311 -0
  92. package/lib/oauth-client/oauth-client.js +276 -0
  93. package/lib/oauth-client/oauth-protected-resource-metadata-resolver.d.ts +18 -0
  94. package/lib/oauth-client/oauth-protected-resource-metadata-resolver.js +49 -0
  95. package/lib/oauth-client/oauth-resolver-error.d.ts +6 -0
  96. package/lib/oauth-client/oauth-resolver-error.js +18 -0
  97. package/lib/oauth-client/oauth-resolver.d.ts +71 -0
  98. package/lib/oauth-client/oauth-resolver.js +117 -0
  99. package/lib/oauth-client/oauth-response-error.d.ts +10 -0
  100. package/lib/oauth-client/oauth-response-error.js +22 -0
  101. package/lib/oauth-client/oauth-server-agent.d.ts +54 -0
  102. package/lib/oauth-client/oauth-server-agent.js +250 -0
  103. package/lib/oauth-client/oauth-server-factory.d.ts +32 -0
  104. package/lib/oauth-client/oauth-server-factory.js +37 -0
  105. package/lib/oauth-client/oauth-session.d.ts +33 -0
  106. package/lib/oauth-client/oauth-session.js +122 -0
  107. package/lib/oauth-client/runtime-implementation.d.ts +16 -0
  108. package/lib/oauth-client/runtime-implementation.js +1 -0
  109. package/lib/oauth-client/runtime.d.ts +25 -0
  110. package/lib/oauth-client/runtime.js +99 -0
  111. package/lib/oauth-client/session-getter.d.ts +54 -0
  112. package/lib/oauth-client/session-getter.js +260 -0
  113. package/lib/oauth-client/state-store.d.ts +12 -0
  114. package/lib/oauth-client/state-store.js +1 -0
  115. package/lib/oauth-client/types.d.ts +1365 -0
  116. package/lib/oauth-client/types.js +8 -0
  117. package/lib/oauth-client/util.d.ts +25 -0
  118. package/lib/oauth-client/util.js +139 -0
  119. package/lib/oauth-client/validate-client-metadata.d.ts +4 -0
  120. package/lib/oauth-client/validate-client-metadata.js +68 -0
  121. package/lib/oauth-client.d.ts +27 -0
  122. package/lib/oauth-client.js +30 -0
  123. package/lib/resolve-txt-factory.d.ts +3 -0
  124. package/lib/resolve-txt-factory.js +80 -0
  125. package/lib/session-store-kv.d.ts +9 -0
  126. package/lib/session-store-kv.js +20 -0
  127. package/lib/state-store-kv.d.ts +9 -0
  128. package/lib/state-store-kv.js +20 -0
  129. package/lib/util.d.ts +18 -0
  130. package/lib/util.js +5 -0
  131. package/package.json +58 -0
@@ -0,0 +1,276 @@
1
+ import { Key, Keyset } from "@atproto/jwk";
2
+ import { oauthClientMetadataSchema, } from "@atproto/oauth-types";
3
+ import { assertAtprotoDid } from "@atproto/did";
4
+ import { HANDLE_INVALID } from "#identity-resolver";
5
+ import { SimpleStoreMemory } from "@atproto-labs/simple-store-memory";
6
+ import { FALLBACK_ALG } from "./constants.js";
7
+ import { AuthMethodUnsatisfiableError } from "./errors/auth-method-unsatisfiable-error.js";
8
+ import { TokenRevokedError } from "./errors/token-revoked-error.js";
9
+ import { createIdentityResolver, } from "./identity-resolver.js";
10
+ import { OAuthAuthorizationServerMetadataResolver, } from "./oauth-authorization-server-metadata-resolver.js";
11
+ import { OAuthCallbackError } from "./oauth-callback-error.js";
12
+ import { negotiateClientAuthMethod } from "./oauth-client-auth.js";
13
+ import { OAuthProtectedResourceMetadataResolver, } from "./oauth-protected-resource-metadata-resolver.js";
14
+ import { OAuthResolver } from "./oauth-resolver.js";
15
+ import { OAuthServerFactory } from "./oauth-server-factory.js";
16
+ import { OAuthSession } from "./oauth-session.js";
17
+ import { Runtime } from "./runtime.js";
18
+ import { SessionGetter, } from "./session-getter.js";
19
+ import { CustomEventTarget } from "./util.js";
20
+ import { validateClientMetadata } from "./validate-client-metadata.js";
21
+ // Export all types needed to construct OAuthClientOptions
22
+ export { Key, Keyset, };
23
+ export class OAuthClient extends CustomEventTarget {
24
+ static async fetchMetadata({ clientId, fetch = globalThis.fetch, signal, }) {
25
+ signal?.throwIfAborted();
26
+ const request = new Request(clientId, {
27
+ redirect: "follow",
28
+ signal: signal,
29
+ });
30
+ const response = await fetch(request);
31
+ if (response.status !== 200) {
32
+ response.body?.cancel?.();
33
+ throw new TypeError(`Failed to fetch client metadata: ${response.status}`);
34
+ }
35
+ // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-4.1
36
+ const mime = response.headers.get("content-type")?.split(";")[0].trim();
37
+ if (mime !== "application/json") {
38
+ response.body?.cancel?.();
39
+ throw new TypeError(`Invalid client metadata content type: ${mime}`);
40
+ }
41
+ const json = await response.json();
42
+ signal?.throwIfAborted();
43
+ return oauthClientMetadataSchema.parse(json);
44
+ }
45
+ constructor(options) {
46
+ const { stateStore, sessionStore, dpopNonceCache = new SimpleStoreMemory({ ttl: 60e3, max: 100 }), authorizationServerMetadataCache = new SimpleStoreMemory({
47
+ ttl: 60e3,
48
+ max: 100,
49
+ }), protectedResourceMetadataCache = new SimpleStoreMemory({
50
+ ttl: 60e3,
51
+ max: 100,
52
+ }), responseMode, clientMetadata, runtimeImplementation, keyset, } = options;
53
+ super();
54
+ this.keyset = keyset
55
+ ? keyset instanceof Keyset
56
+ ? keyset
57
+ : new Keyset(keyset)
58
+ : undefined;
59
+ this.clientMetadata = validateClientMetadata(clientMetadata, this.keyset);
60
+ this.responseMode = responseMode;
61
+ this.runtime = new Runtime(runtimeImplementation);
62
+ this.fetch = options.fetch ?? globalThis.fetch;
63
+ this.oauthResolver = new OAuthResolver(createIdentityResolver(options), new OAuthProtectedResourceMetadataResolver(protectedResourceMetadataCache, this.fetch, { allowHttpResource: options.allowHttp }), new OAuthAuthorizationServerMetadataResolver(authorizationServerMetadataCache, this.fetch, { allowHttpIssuer: options.allowHttp }));
64
+ this.serverFactory = new OAuthServerFactory(this.clientMetadata, this.runtime, this.oauthResolver, this.fetch, this.keyset, dpopNonceCache);
65
+ this.sessionGetter = new SessionGetter(sessionStore, this.serverFactory, this.runtime);
66
+ this.stateStore = stateStore;
67
+ // Proxy sessionGetter events
68
+ for (const type of ["deleted", "updated"]) {
69
+ this.sessionGetter.addEventListener(type, (event) => {
70
+ if (!this.dispatchCustomEvent(type, event.detail)) {
71
+ event.preventDefault();
72
+ }
73
+ });
74
+ }
75
+ }
76
+ // Exposed as public API for convenience
77
+ get identityResolver() {
78
+ return this.oauthResolver.identityResolver;
79
+ }
80
+ get jwks() {
81
+ return this.keyset?.publicJwks ?? { keys: [] };
82
+ }
83
+ async authorize(input, { signal, ...options } = {}) {
84
+ const redirectUri = options?.redirect_uri ?? this.clientMetadata.redirect_uris[0];
85
+ if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
86
+ // The server will enforce this, but let's catch it early
87
+ throw new TypeError("Invalid redirect_uri");
88
+ }
89
+ const { identityInfo, metadata } = await this.oauthResolver.resolve(input, {
90
+ signal,
91
+ });
92
+ const pkce = await this.runtime.generatePKCE();
93
+ const dpopKey = await this.runtime.generateKey(metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG]);
94
+ const authMethod = negotiateClientAuthMethod(metadata, this.clientMetadata, this.keyset);
95
+ const state = await this.runtime.generateNonce();
96
+ await this.stateStore.set(state, {
97
+ iss: metadata.issuer,
98
+ dpopKey,
99
+ authMethod,
100
+ verifier: pkce.verifier,
101
+ appState: options?.state,
102
+ });
103
+ const parameters = {
104
+ ...options,
105
+ client_id: this.clientMetadata.client_id,
106
+ redirect_uri: redirectUri,
107
+ code_challenge: pkce.challenge,
108
+ code_challenge_method: pkce.method,
109
+ state,
110
+ login_hint: identityInfo
111
+ ? identityInfo.handle !== HANDLE_INVALID
112
+ ? identityInfo.handle
113
+ : identityInfo.did
114
+ : undefined,
115
+ response_mode: this.responseMode,
116
+ response_type: "code",
117
+ scope: options?.scope ?? this.clientMetadata.scope,
118
+ };
119
+ const authorizationUrl = new URL(metadata.authorization_endpoint);
120
+ // Since the user will be redirected to the authorization_endpoint url using
121
+ // a browser, we need to make sure that the url is valid.
122
+ if (authorizationUrl.protocol !== "https:" &&
123
+ authorizationUrl.protocol !== "http:") {
124
+ throw new TypeError(`Invalid authorization endpoint protocol: ${authorizationUrl.protocol}`);
125
+ }
126
+ if (metadata.pushed_authorization_request_endpoint) {
127
+ const server = await this.serverFactory.fromMetadata(metadata, authMethod, dpopKey);
128
+ const parResponse = await server.request("pushed_authorization_request", parameters);
129
+ authorizationUrl.searchParams.set("client_id", this.clientMetadata.client_id);
130
+ authorizationUrl.searchParams.set("request_uri", parResponse.request_uri);
131
+ return authorizationUrl;
132
+ }
133
+ else if (metadata.require_pushed_authorization_requests) {
134
+ throw new Error("Server requires pushed authorization requests (PAR) but no PAR endpoint is available");
135
+ }
136
+ else {
137
+ for (const [key, value] of Object.entries(parameters)) {
138
+ if (value)
139
+ authorizationUrl.searchParams.set(key, String(value));
140
+ }
141
+ // Length of the URL that will be sent to the server
142
+ const urlLength = authorizationUrl.pathname.length + authorizationUrl.search.length;
143
+ if (urlLength < 2048) {
144
+ return authorizationUrl;
145
+ }
146
+ else if (!metadata.pushed_authorization_request_endpoint) {
147
+ throw new Error("Login URL too long");
148
+ }
149
+ }
150
+ throw new Error("Server does not support pushed authorization requests (PAR)");
151
+ }
152
+ /**
153
+ * This method allows the client to proactively revoke the request_uri it
154
+ * created through PAR.
155
+ */
156
+ async abortRequest(authorizeUrl) {
157
+ const requestUri = authorizeUrl.searchParams.get("request_uri");
158
+ if (!requestUri)
159
+ return;
160
+ // @NOTE This is not implemented here because, 1) the request server should
161
+ // invalidate the request_uri after some delay anyways, and 2) I am not sure
162
+ // that the revocation endpoint is even supposed to support this (and I
163
+ // don't want to spend the time checking now).
164
+ // @TODO investigate actual necessity & feasibility of this feature
165
+ }
166
+ async callback(params) {
167
+ const responseJwt = params.get("response");
168
+ if (responseJwt != null) {
169
+ // https://openid.net/specs/oauth-v2-jarm.html
170
+ throw new OAuthCallbackError(params, "JARM not supported");
171
+ }
172
+ const issuerParam = params.get("iss");
173
+ const stateParam = params.get("state");
174
+ const errorParam = params.get("error");
175
+ const codeParam = params.get("code");
176
+ if (!stateParam) {
177
+ throw new OAuthCallbackError(params, 'Missing "state" parameter');
178
+ }
179
+ const stateData = await this.stateStore.get(stateParam);
180
+ if (stateData) {
181
+ // Prevent any kind of replay
182
+ await this.stateStore.del(stateParam);
183
+ }
184
+ else {
185
+ throw new OAuthCallbackError(params, `Unknown authorization session "${stateParam}"`);
186
+ }
187
+ try {
188
+ if (errorParam != null) {
189
+ throw new OAuthCallbackError(params, undefined, stateData.appState);
190
+ }
191
+ if (!codeParam) {
192
+ throw new OAuthCallbackError(params, 'Missing "code" query param', stateData.appState);
193
+ }
194
+ const server = await this.serverFactory.fromIssuer(stateData.iss,
195
+ // Using the literal 'legacy' if the authMethod is not defined (because stateData was created through an old version of this lib)
196
+ stateData.authMethod ?? "legacy", stateData.dpopKey);
197
+ if (issuerParam != null) {
198
+ if (!server.issuer) {
199
+ throw new OAuthCallbackError(params, "Issuer not found in metadata", stateData.appState);
200
+ }
201
+ if (server.issuer !== issuerParam) {
202
+ throw new OAuthCallbackError(params, "Issuer mismatch", stateData.appState);
203
+ }
204
+ }
205
+ else if (server.serverMetadata.authorization_response_iss_parameter_supported) {
206
+ throw new OAuthCallbackError(params, "iss missing from the response", stateData.appState);
207
+ }
208
+ const tokenSet = await server.exchangeCode(codeParam, stateData.verifier);
209
+ try {
210
+ await this.sessionGetter.setStored(tokenSet.sub, {
211
+ dpopKey: stateData.dpopKey,
212
+ authMethod: server.authMethod,
213
+ tokenSet,
214
+ });
215
+ const session = this.createSession(server, tokenSet.sub);
216
+ return { session, state: stateData.appState ?? null };
217
+ }
218
+ catch (err) {
219
+ await server.revoke(tokenSet.refresh_token || tokenSet.access_token);
220
+ throw err;
221
+ }
222
+ }
223
+ catch (err) {
224
+ // Make sure, whatever the underlying error, that the appState is
225
+ // available in the calling code
226
+ throw OAuthCallbackError.from(err, params, stateData.appState);
227
+ }
228
+ }
229
+ /**
230
+ * Load a stored session. This will refresh the token only if needed (about to
231
+ * expire) by default.
232
+ *
233
+ * @param refresh See {@link SessionGetter.getSession}
234
+ */
235
+ async restore(sub, refresh = "auto") {
236
+ // sub arg is lightly typed for convenience of library user
237
+ assertAtprotoDid(sub);
238
+ const { dpopKey, authMethod = "legacy", tokenSet, } = await this.sessionGetter.get(sub, {
239
+ noCache: refresh === true,
240
+ allowStale: refresh === false,
241
+ });
242
+ try {
243
+ const server = await this.serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey, {
244
+ noCache: refresh === true,
245
+ allowStale: refresh === false,
246
+ });
247
+ return this.createSession(server, sub);
248
+ }
249
+ catch (err) {
250
+ if (err instanceof AuthMethodUnsatisfiableError) {
251
+ await this.sessionGetter.delStored(sub, err);
252
+ }
253
+ throw err;
254
+ }
255
+ }
256
+ async revoke(sub) {
257
+ // sub arg is lightly typed for convenience of library user
258
+ assertAtprotoDid(sub);
259
+ const { dpopKey, authMethod = "legacy", tokenSet, } = await this.sessionGetter.get(sub, {
260
+ allowStale: true,
261
+ });
262
+ // NOT using `;(await this.restore(sub, false)).signOut()` because we want
263
+ // the tokens to be deleted even if it was not possible to fetch the issuer
264
+ // data.
265
+ try {
266
+ const server = await this.serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey);
267
+ await server.revoke(tokenSet.access_token);
268
+ }
269
+ finally {
270
+ await this.sessionGetter.delStored(sub, new TokenRevokedError(sub));
271
+ }
272
+ }
273
+ createSession(server, sub) {
274
+ return new OAuthSession(server, sub, this.sessionGetter, this.fetch);
275
+ }
276
+ }
@@ -0,0 +1,18 @@
1
+ import { OAuthProtectedResourceMetadata } from "@atproto/oauth-types";
2
+ import { Fetch } from "@atproto-labs/fetch";
3
+ import { CachedGetter, GetCachedOptions, SimpleStore } from "@atproto-labs/simple-store";
4
+ export type { GetCachedOptions, OAuthProtectedResourceMetadata };
5
+ export type ProtectedResourceMetadataCache = SimpleStore<string, OAuthProtectedResourceMetadata>;
6
+ export type OAuthProtectedResourceMetadataResolverConfig = {
7
+ allowHttpResource?: boolean;
8
+ };
9
+ /**
10
+ * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05}
11
+ */
12
+ export declare class OAuthProtectedResourceMetadataResolver extends CachedGetter<string, OAuthProtectedResourceMetadata> {
13
+ private readonly fetch;
14
+ private readonly allowHttpResource;
15
+ constructor(cache: ProtectedResourceMetadataCache, fetch?: Fetch, config?: OAuthProtectedResourceMetadataResolverConfig);
16
+ get(resource: string | URL, options?: GetCachedOptions): Promise<OAuthProtectedResourceMetadata>;
17
+ private fetchMetadata;
18
+ }
@@ -0,0 +1,49 @@
1
+ import { oauthProtectedResourceMetadataSchema, } from "@atproto/oauth-types";
2
+ import { FetchResponseError, bindFetch, cancelBody, } from "@atproto-labs/fetch";
3
+ import { CachedGetter, } from "@atproto-labs/simple-store";
4
+ import { contentMime } from "./util.js";
5
+ /**
6
+ * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05}
7
+ */
8
+ export class OAuthProtectedResourceMetadataResolver extends CachedGetter {
9
+ constructor(cache, fetch = globalThis.fetch, config) {
10
+ super(async (origin, options) => this.fetchMetadata(origin, options), cache);
11
+ this.fetch = bindFetch(fetch);
12
+ this.allowHttpResource = config?.allowHttpResource === true;
13
+ }
14
+ async get(resource, options) {
15
+ const { protocol, origin } = new URL(resource);
16
+ if (protocol !== "https:" && protocol !== "http:") {
17
+ throw new TypeError(`Invalid protected resource metadata URL protocol: ${protocol}`);
18
+ }
19
+ if (protocol === "http:" && !this.allowHttpResource) {
20
+ throw new TypeError(`Unsecure resource metadata URL (${protocol}) only allowed in development and test environments`);
21
+ }
22
+ return super.get(origin, options);
23
+ }
24
+ async fetchMetadata(origin, options) {
25
+ const url = new URL(`/.well-known/oauth-protected-resource`, origin);
26
+ const request = new Request(url, {
27
+ signal: options?.signal,
28
+ headers: { accept: "application/json", "cache-control": "no-cache" },
29
+ // cache: options?.noCache ? "no-cache" : undefined,
30
+ redirect: "manual", // response must be 200 OK
31
+ });
32
+ const response = await this.fetch(request);
33
+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-3.2
34
+ if (response.status !== 200) {
35
+ await cancelBody(response, "log");
36
+ throw await FetchResponseError.from(response, `Unexpected status code ${response.status} for "${url}"`, undefined, { cause: request });
37
+ }
38
+ if (contentMime(response.headers) !== "application/json") {
39
+ await cancelBody(response, "log");
40
+ throw await FetchResponseError.from(response, `Unexpected content type for "${url}"`, undefined, { cause: request });
41
+ }
42
+ const metadata = oauthProtectedResourceMetadataSchema.parse(await response.json());
43
+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-3.3
44
+ if (metadata.resource !== origin) {
45
+ throw new TypeError(`Invalid issuer ${metadata.resource}`);
46
+ }
47
+ return metadata;
48
+ }
49
+ }
@@ -0,0 +1,6 @@
1
+ export declare class OAuthResolverError extends Error {
2
+ constructor(message: string, options?: {
3
+ cause?: unknown;
4
+ });
5
+ static from(cause: unknown, message?: string): OAuthResolverError;
6
+ }
@@ -0,0 +1,18 @@
1
+ import { ZodError } from "zod";
2
+ export class OAuthResolverError extends Error {
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ }
6
+ static from(cause, message) {
7
+ if (cause instanceof OAuthResolverError)
8
+ return cause;
9
+ const validationReason = cause instanceof ZodError
10
+ ? `${cause.errors[0].path} ${cause.errors[0].message}`
11
+ : null;
12
+ const fullMessage = (message ?? `Unable to resolve identity`) +
13
+ (validationReason ? ` (${validationReason})` : "");
14
+ return new OAuthResolverError(fullMessage, {
15
+ cause,
16
+ });
17
+ }
18
+ }
@@ -0,0 +1,71 @@
1
+ import { OAuthAuthorizationServerMetadata } from "@atproto/oauth-types";
2
+ import { IdentityInfo, IdentityResolver, ResolveIdentityOptions } from "#identity-resolver";
3
+ import { GetCachedOptions, OAuthAuthorizationServerMetadataResolver } from "./oauth-authorization-server-metadata-resolver.js";
4
+ import { OAuthProtectedResourceMetadataResolver } from "./oauth-protected-resource-metadata-resolver.js";
5
+ export type { GetCachedOptions };
6
+ export type ResolveOAuthOptions = GetCachedOptions & ResolveIdentityOptions;
7
+ export declare class OAuthResolver {
8
+ readonly identityResolver: IdentityResolver;
9
+ readonly protectedResourceMetadataResolver: OAuthProtectedResourceMetadataResolver;
10
+ readonly authorizationServerMetadataResolver: OAuthAuthorizationServerMetadataResolver;
11
+ constructor(identityResolver: IdentityResolver, protectedResourceMetadataResolver: OAuthProtectedResourceMetadataResolver, authorizationServerMetadataResolver: OAuthAuthorizationServerMetadataResolver);
12
+ /**
13
+ * @param input - A handle, DID, PDS URL or Entryway URL
14
+ */
15
+ resolve(input: string, options?: ResolveOAuthOptions): Promise<{
16
+ identityInfo?: IdentityInfo;
17
+ metadata: OAuthAuthorizationServerMetadata;
18
+ }>;
19
+ /**
20
+ * @note this method can be used to verify if a particular uri supports OAuth
21
+ * based sign-in (for compatibility with legacy implementation).
22
+ */
23
+ resolveFromService(input: string, options?: ResolveOAuthOptions): Promise<{
24
+ metadata: OAuthAuthorizationServerMetadata;
25
+ }>;
26
+ resolveFromIdentity(input: string, options?: ResolveOAuthOptions): Promise<{
27
+ identityInfo: IdentityInfo;
28
+ metadata: OAuthAuthorizationServerMetadata;
29
+ pds: URL;
30
+ }>;
31
+ resolveIdentity(input: string, options?: ResolveIdentityOptions): Promise<IdentityInfo>;
32
+ getAuthorizationServerMetadata(issuer: string, options?: GetCachedOptions): Promise<OAuthAuthorizationServerMetadata>;
33
+ getResourceServerMetadata(pdsUrl: string | URL, options?: GetCachedOptions): Promise<{
34
+ issuer: `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}`;
35
+ authorization_endpoint: `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}`;
36
+ token_endpoint: `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}`;
37
+ token_endpoint_auth_methods_supported: string[];
38
+ jwks_uri?: `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}` | undefined;
39
+ claims_supported?: string[] | undefined;
40
+ claims_locales_supported?: string[] | undefined;
41
+ claims_parameter_supported?: boolean | undefined;
42
+ request_parameter_supported?: boolean | undefined;
43
+ request_uri_parameter_supported?: boolean | undefined;
44
+ require_request_uri_registration?: boolean | undefined;
45
+ scopes_supported?: string[] | undefined;
46
+ subject_types_supported?: string[] | undefined;
47
+ response_types_supported?: string[] | undefined;
48
+ response_modes_supported?: string[] | undefined;
49
+ grant_types_supported?: string[] | undefined;
50
+ code_challenge_methods_supported?: ("S256" | "plain")[] | undefined;
51
+ ui_locales_supported?: string[] | undefined;
52
+ id_token_signing_alg_values_supported?: string[] | undefined;
53
+ display_values_supported?: string[] | undefined;
54
+ request_object_signing_alg_values_supported?: string[] | undefined;
55
+ authorization_response_iss_parameter_supported?: boolean | undefined;
56
+ authorization_details_types_supported?: string[] | undefined;
57
+ request_object_encryption_alg_values_supported?: string[] | undefined;
58
+ request_object_encryption_enc_values_supported?: string[] | undefined;
59
+ token_endpoint_auth_signing_alg_values_supported?: string[] | undefined;
60
+ revocation_endpoint?: `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}` | undefined;
61
+ introspection_endpoint?: `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}` | undefined;
62
+ pushed_authorization_request_endpoint?: `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}` | undefined;
63
+ require_pushed_authorization_requests?: boolean | undefined;
64
+ userinfo_endpoint?: `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}` | undefined;
65
+ end_session_endpoint?: `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}` | undefined;
66
+ registration_endpoint?: `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}` | undefined;
67
+ dpop_signing_alg_values_supported?: string[] | undefined;
68
+ protected_resources?: (`http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}`)[] | undefined;
69
+ client_id_metadata_document_supported?: boolean | undefined;
70
+ }>;
71
+ }
@@ -0,0 +1,117 @@
1
+ import { oauthIssuerIdentifierSchema, } from "@atproto/oauth-types";
2
+ import { OAuthResolverError } from "./oauth-resolver-error.js";
3
+ export class OAuthResolver {
4
+ constructor(identityResolver, protectedResourceMetadataResolver, authorizationServerMetadataResolver) {
5
+ this.identityResolver = identityResolver;
6
+ this.protectedResourceMetadataResolver = protectedResourceMetadataResolver;
7
+ this.authorizationServerMetadataResolver = authorizationServerMetadataResolver;
8
+ }
9
+ /**
10
+ * @param input - A handle, DID, PDS URL or Entryway URL
11
+ */
12
+ async resolve(input, options) {
13
+ // Allow using an entryway, or PDS url, directly as login input (e.g.
14
+ // when the user forgot their handle, or when the handle does not
15
+ // resolve to a DID)
16
+ return /^https?:\/\//.test(input)
17
+ ? this.resolveFromService(input, options)
18
+ : this.resolveFromIdentity(input, options);
19
+ }
20
+ /**
21
+ * @note this method can be used to verify if a particular uri supports OAuth
22
+ * based sign-in (for compatibility with legacy implementation).
23
+ */
24
+ async resolveFromService(input, options) {
25
+ try {
26
+ // Assume first that input is a PDS URL (as required by ATPROTO)
27
+ const metadata = await this.getResourceServerMetadata(input, options);
28
+ return { metadata };
29
+ }
30
+ catch (err) {
31
+ if (!options?.signal?.aborted && err instanceof OAuthResolverError) {
32
+ try {
33
+ // Fallback to trying to fetch as an issuer (Entryway)
34
+ const result = oauthIssuerIdentifierSchema.safeParse(input);
35
+ if (result.success) {
36
+ const metadata = await this.getAuthorizationServerMetadata(result.data, options);
37
+ return { metadata };
38
+ }
39
+ }
40
+ catch {
41
+ // Fallback failed, throw original error
42
+ }
43
+ }
44
+ throw err;
45
+ }
46
+ }
47
+ async resolveFromIdentity(input, options) {
48
+ const identityInfo = await this.resolveIdentity(input, options);
49
+ options?.signal?.throwIfAborted();
50
+ const pds = extractPdsUrl(identityInfo.didDoc);
51
+ const metadata = await this.getResourceServerMetadata(pds, options);
52
+ return { identityInfo, metadata, pds };
53
+ }
54
+ async resolveIdentity(input, options) {
55
+ try {
56
+ return await this.identityResolver.resolve(input, options);
57
+ }
58
+ catch (cause) {
59
+ console.error(cause);
60
+ throw OAuthResolverError.from(cause, `Failed to resolve identity: ${input}`);
61
+ }
62
+ }
63
+ async getAuthorizationServerMetadata(issuer, options) {
64
+ try {
65
+ return await this.authorizationServerMetadataResolver.get(issuer, options);
66
+ }
67
+ catch (cause) {
68
+ console.error(cause);
69
+ throw OAuthResolverError.from(cause, `Failed to resolve OAuth server metadata for issuer: ${issuer}`);
70
+ }
71
+ }
72
+ async getResourceServerMetadata(pdsUrl, options) {
73
+ try {
74
+ const rsMetadata = await this.protectedResourceMetadataResolver.get(pdsUrl, options);
75
+ // ATPROTO requires one, and only one, authorization server entry
76
+ if (rsMetadata.authorization_servers?.length !== 1) {
77
+ throw new OAuthResolverError(rsMetadata.authorization_servers?.length
78
+ ? `Unable to determine authorization server for PDS: ${pdsUrl}`
79
+ : `No authorization servers found for PDS: ${pdsUrl}`);
80
+ }
81
+ const issuer = rsMetadata.authorization_servers[0];
82
+ options?.signal?.throwIfAborted();
83
+ const asMetadata = await this.getAuthorizationServerMetadata(issuer, options);
84
+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-4
85
+ if (asMetadata.protected_resources) {
86
+ if (!asMetadata.protected_resources.includes(rsMetadata.resource)) {
87
+ throw new OAuthResolverError(`PDS "${pdsUrl}" not protected by issuer "${issuer}"`);
88
+ }
89
+ }
90
+ return asMetadata;
91
+ }
92
+ catch (cause) {
93
+ console.error(cause);
94
+ throw OAuthResolverError.from(cause, `Failed to resolve OAuth server metadata for resource: ${pdsUrl}`);
95
+ }
96
+ }
97
+ }
98
+ function isAtprotoPersonalDataServerService(s) {
99
+ return (typeof s.serviceEndpoint === "string" &&
100
+ s.type === "AtprotoPersonalDataServer" &&
101
+ (s.id.startsWith("#")
102
+ ? s.id === "#atproto_pds"
103
+ : s.id === `${this.id}#atproto_pds`));
104
+ }
105
+ function extractPdsUrl(document) {
106
+ const service = document.service?.find((isAtprotoPersonalDataServerService), document);
107
+ if (!service) {
108
+ throw new OAuthResolverError(`Identity "${document.id}" does not have a PDS URL`);
109
+ }
110
+ try {
111
+ return new URL(service.serviceEndpoint);
112
+ }
113
+ catch (cause) {
114
+ console.error(cause);
115
+ throw new OAuthResolverError(`Invalid PDS URL in DID document: ${service.serviceEndpoint}`, { cause });
116
+ }
117
+ }
@@ -0,0 +1,10 @@
1
+ import { Json } from "@atproto-labs/fetch";
2
+ export declare class OAuthResponseError extends Error {
3
+ readonly response: Response;
4
+ readonly payload: Json;
5
+ readonly error?: string;
6
+ readonly errorDescription?: string;
7
+ constructor(response: Response, payload: Json);
8
+ get status(): number;
9
+ get headers(): Headers;
10
+ }
@@ -0,0 +1,22 @@
1
+ import { ifString } from "./util.js";
2
+ export class OAuthResponseError extends Error {
3
+ constructor(response, payload) {
4
+ const objPayload = typeof payload === "object" ? payload : undefined;
5
+ const error = ifString(objPayload?.["error"]);
6
+ const errorDescription = ifString(objPayload?.["error_description"]);
7
+ const messageError = error ? `"${error}"` : "unknown";
8
+ const messageDesc = errorDescription ? `: ${errorDescription}` : "";
9
+ const message = `OAuth ${messageError} error${messageDesc}`;
10
+ super(message);
11
+ this.response = response;
12
+ this.payload = payload;
13
+ this.error = error;
14
+ this.errorDescription = errorDescription;
15
+ }
16
+ get status() {
17
+ return this.response.status;
18
+ }
19
+ get headers() {
20
+ return this.response.headers;
21
+ }
22
+ }
@@ -0,0 +1,54 @@
1
+ import { AtprotoDid } from "@atproto/did";
2
+ import { Key, Keyset } from "@atproto/jwk";
3
+ import { OAuthAuthorizationRequestPar, OAuthAuthorizationServerMetadata, OAuthEndpointName, OAuthParResponse, OAuthTokenRequest } from "@atproto/oauth-types";
4
+ import { Fetch, Json } from "@atproto-labs/fetch";
5
+ import { SimpleStore } from "@atproto-labs/simple-store";
6
+ import { AtprotoScope, AtprotoTokenResponse } from "./atproto-token-response.js";
7
+ import { ClientAuthMethod, ClientCredentialsFactory } from "./oauth-client-auth.js";
8
+ import { OAuthResolver } from "./oauth-resolver.js";
9
+ import { Runtime } from "./runtime.js";
10
+ import { ClientMetadata } from "./types.js";
11
+ export type TokenSet = {
12
+ iss: string;
13
+ sub: AtprotoDid;
14
+ aud: string;
15
+ scope: AtprotoScope;
16
+ refresh_token?: string;
17
+ access_token: string;
18
+ token_type: "DPoP";
19
+ /** ISO Date */
20
+ expires_at?: string;
21
+ };
22
+ export type DpopNonceCache = SimpleStore<string, string>;
23
+ export declare class OAuthServerAgent {
24
+ readonly authMethod: ClientAuthMethod;
25
+ readonly dpopKey: Key;
26
+ readonly serverMetadata: OAuthAuthorizationServerMetadata;
27
+ readonly clientMetadata: ClientMetadata;
28
+ readonly dpopNonces: DpopNonceCache;
29
+ readonly oauthResolver: OAuthResolver;
30
+ readonly runtime: Runtime;
31
+ readonly keyset?: Keyset | undefined;
32
+ protected dpopFetch: Fetch<unknown>;
33
+ protected clientCredentialsFactory: ClientCredentialsFactory;
34
+ /**
35
+ * @throws see {@link createClientCredentialsFactory}
36
+ */
37
+ constructor(authMethod: ClientAuthMethod, dpopKey: Key, serverMetadata: OAuthAuthorizationServerMetadata, clientMetadata: ClientMetadata, dpopNonces: DpopNonceCache, oauthResolver: OAuthResolver, runtime: Runtime, keyset?: Keyset | undefined, fetch?: Fetch);
38
+ get issuer(): `http://[::1]${string}` | "http://localhost" | `http://localhost#${string}` | `http://localhost?${string}` | `http://localhost/${string}` | `http://localhost:${string}` | "http://127.0.0.1" | `http://127.0.0.1#${string}` | `http://127.0.0.1?${string}` | `http://127.0.0.1/${string}` | `http://127.0.0.1:${string}` | `https://${string}`;
39
+ revoke(token: string): Promise<void>;
40
+ exchangeCode(code: string, codeVerifier?: string): Promise<TokenSet>;
41
+ refresh(tokenSet: TokenSet): Promise<TokenSet>;
42
+ /**
43
+ * VERY IMPORTANT ! Always call this to process token responses.
44
+ *
45
+ * Whenever an OAuth token response is received, we **MUST** verify that the
46
+ * "sub" is a DID, whose issuer authority is indeed the server we just
47
+ * obtained credentials from. This check is a critical step to actually be
48
+ * able to use the "sub" (DID) as being the actual user's identifier.
49
+ *
50
+ * @returns The user's PDS URL (the resource server for the user)
51
+ */
52
+ protected verifyIssuer(sub: AtprotoDid): Promise<string>;
53
+ request<Endpoint extends OAuthEndpointName>(endpoint: Endpoint, payload: Endpoint extends "token" ? OAuthTokenRequest : Endpoint extends "pushed_authorization_request" ? OAuthAuthorizationRequestPar : Record<string, unknown>): Promise<Endpoint extends "token" ? AtprotoTokenResponse : Endpoint extends "pushed_authorization_request" ? OAuthParResponse : Json>;
54
+ }