@opencoredev/social-sdk 0.3.0 → 0.4.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/dist/cli-request.d.ts +15 -0
- package/dist/cli-request.js +193 -0
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +19 -21
- package/dist/cloud/common.d.ts +7 -6
- package/dist/cloud/common.js +35 -54
- package/dist/cloud/lifecycle.js +31 -35
- package/dist/cloud/media.d.ts +2 -2
- package/dist/cloud/media.js +13 -3
- package/dist/cloud/outcomes.d.ts +4 -3
- package/dist/cloud/outcomes.js +8 -15
- package/dist/cloud/post-for-me.js +41 -49
- package/dist/cloud/zernio.js +58 -98
- package/dist/core/client.js +79 -99
- package/dist/core/fields.d.ts +14 -0
- package/dist/core/fields.js +14 -0
- package/dist/core/idempotency.d.ts +7 -2
- package/dist/core/idempotency.js +37 -20
- package/dist/core/pagination.js +8 -7
- package/dist/core/types.d.ts +3 -2
- package/dist/platforms/bluesky.d.ts +65 -1
- package/dist/platforms/bluesky.js +675 -276
- package/dist/platforms/instagram.d.ts +2 -0
- package/dist/platforms/instagram.js +130 -105
- package/dist/platforms/linkedin.d.ts +58 -1
- package/dist/platforms/linkedin.js +877 -107
- package/dist/platforms/threads.d.ts +13 -1
- package/dist/platforms/threads.js +204 -302
- package/dist/platforms/tiktok.d.ts +4 -0
- package/dist/platforms/tiktok.js +140 -124
- package/dist/platforms/webhook-adapter.d.ts +9 -0
- package/dist/platforms/webhook-adapter.js +24 -0
- package/dist/platforms/x-engagement.js +7 -12
- package/dist/platforms/x-stream.d.ts +83 -0
- package/dist/platforms/x-stream.js +350 -0
- package/dist/platforms/x.d.ts +72 -0
- package/dist/platforms/x.js +328 -119
- package/dist/platforms/youtube-upload.d.ts +1 -1
- package/dist/platforms/youtube-upload.js +6 -2
- package/dist/platforms/youtube.d.ts +28 -4
- package/dist/platforms/youtube.js +291 -133
- package/dist/server/bluesky-oauth.d.ts +177 -0
- package/dist/server/bluesky-oauth.js +1229 -0
- package/dist/server/connections.d.ts +14 -0
- package/dist/server/connections.js +10 -2
- package/dist/server/egress.d.ts +14 -0
- package/dist/server/egress.js +115 -0
- package/dist/server/oauth-internal.d.ts +6 -0
- package/dist/server/oauth-internal.js +66 -0
- package/dist/server/oauth.d.ts +1 -1
- package/dist/server/oauth.js +46 -99
- package/dist/server/webhooks.d.ts +136 -3
- package/dist/server/webhooks.js +639 -25
- package/dist/testing/index.js +14 -28
- package/dist/transport/http.d.ts +1 -1
- package/dist/transport/http.js +0 -1
- package/dist/transport/json.d.ts +7 -0
- package/dist/transport/json.js +32 -4
- package/dist/transport/upload.d.ts +1 -1
- package/dist/transport/upload.js +46 -38
- package/dist/transport/validation.d.ts +16 -5
- package/dist/transport/validation.js +29 -7
- package/package.json +2 -2
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
import { SocialError } from "../core/errors.js";
|
|
2
|
+
import { definedFields } from "../core/fields.js";
|
|
3
|
+
import { connectedAccountRef } from "../core/types.js";
|
|
4
|
+
import { isJsonValue } from "../transport/json.js";
|
|
5
|
+
import { isFiniteNumber, isJsonArray, isJsonObject, isString, } from "../transport/validation.js";
|
|
6
|
+
import { egressBlockReason } from "./egress.js";
|
|
7
|
+
import { readBounded, validateCallback } from "./oauth-internal.js";
|
|
8
|
+
// AT Protocol OAuth profile: https://atproto.com/specs/oauth (accessed 2026-09-24).
|
|
9
|
+
// Handle resolution: https://atproto.com/specs/handle (accessed 2026-09-24).
|
|
10
|
+
// DID resolution: https://atproto.com/specs/did (accessed 2026-09-24).
|
|
11
|
+
const DEFAULT_SCOPE = "atproto transition:generic";
|
|
12
|
+
const DEFAULT_PLC_DIRECTORY = "https://plc.directory";
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
14
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
15
|
+
/** Redirect hops allowed for the HTTPS handle method. Metadata requests allow none. */
|
|
16
|
+
const MAX_HANDLE_REDIRECTS = 3;
|
|
17
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
18
|
+
/** Members that only appear in private or symmetric JWKs. */
|
|
19
|
+
const SECRET_JWK_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
|
|
20
|
+
const P256_COORDINATE = /^[A-Za-z0-9_-]{43}$/;
|
|
21
|
+
const JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
|
22
|
+
const EC_KEY = { name: "ECDSA", namedCurve: "P-256" };
|
|
23
|
+
const EC_SIGN = { name: "ECDSA", hash: "SHA-256" };
|
|
24
|
+
const HANDLE_PATTERN = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
25
|
+
const DID_PATTERN = /^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/;
|
|
26
|
+
const PLC_PATTERN = /^did:plc:[a-z2-7]{24}$/;
|
|
27
|
+
/** Reserved TLDs that pass handle syntax but must fail resolution. */
|
|
28
|
+
const DISALLOWED_TLDS = new Set([
|
|
29
|
+
"alt",
|
|
30
|
+
"arpa",
|
|
31
|
+
"example",
|
|
32
|
+
"internal",
|
|
33
|
+
"invalid",
|
|
34
|
+
"local",
|
|
35
|
+
"localhost",
|
|
36
|
+
"onion",
|
|
37
|
+
]);
|
|
38
|
+
function fail(operation, message, code = "upstream_failure") {
|
|
39
|
+
throw new SocialError({ code, operation, message });
|
|
40
|
+
}
|
|
41
|
+
function isOptionalString(value) {
|
|
42
|
+
return value === undefined || isString(value);
|
|
43
|
+
}
|
|
44
|
+
function stringList(value) {
|
|
45
|
+
return isJsonArray(value) ? value.filter(isString) : [];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Plain `JSON.parse`, checked against the JSON grammar. Throws a `SyntaxError`
|
|
49
|
+
* for malformed text; callers map it to their own error.
|
|
50
|
+
*/
|
|
51
|
+
function parseJsonValue(text) {
|
|
52
|
+
const parsed = JSON.parse(text);
|
|
53
|
+
if (!isJsonValue(parsed))
|
|
54
|
+
throw new SyntaxError("Expected a JSON value");
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
function scopeTokens(scope) {
|
|
58
|
+
return scope.split(" ").filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
function checkScope(scope, operation) {
|
|
61
|
+
const tokens = scopeTokens(scope);
|
|
62
|
+
if (tokens.length === 0 || scope !== tokens.join(" ") || !tokens.includes("atproto"))
|
|
63
|
+
fail(operation, "Bluesky OAuth scope must be space-separated and include atproto", "invalid_config");
|
|
64
|
+
return scope;
|
|
65
|
+
}
|
|
66
|
+
// Encoding and signing ----------------------------------------------------------------
|
|
67
|
+
function base64Url(bytes) {
|
|
68
|
+
let binary = "";
|
|
69
|
+
for (const byte of bytes)
|
|
70
|
+
binary += String.fromCharCode(byte);
|
|
71
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
72
|
+
}
|
|
73
|
+
function base64UrlJson(value) {
|
|
74
|
+
return base64Url(new TextEncoder().encode(JSON.stringify(value)));
|
|
75
|
+
}
|
|
76
|
+
function randomId() {
|
|
77
|
+
const bytes = new Uint8Array(16);
|
|
78
|
+
crypto.getRandomValues(bytes);
|
|
79
|
+
return base64Url(bytes);
|
|
80
|
+
}
|
|
81
|
+
async function sha256Base64Url(value) {
|
|
82
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
83
|
+
return base64Url(new Uint8Array(digest));
|
|
84
|
+
}
|
|
85
|
+
function isPrivateEcJwk(value) {
|
|
86
|
+
return (typeof value === "object" &&
|
|
87
|
+
value !== null &&
|
|
88
|
+
!Array.isArray(value) &&
|
|
89
|
+
"kty" in value &&
|
|
90
|
+
value.kty === "EC" &&
|
|
91
|
+
"crv" in value &&
|
|
92
|
+
value.crv === "P-256" &&
|
|
93
|
+
"x" in value &&
|
|
94
|
+
typeof value.x === "string" &&
|
|
95
|
+
"y" in value &&
|
|
96
|
+
typeof value.y === "string" &&
|
|
97
|
+
"d" in value &&
|
|
98
|
+
typeof value.d === "string");
|
|
99
|
+
}
|
|
100
|
+
function publicPart(jwk) {
|
|
101
|
+
return { kty: "EC", crv: "P-256", x: jwk.x ?? "", y: jwk.y ?? "" };
|
|
102
|
+
}
|
|
103
|
+
async function importPrivateKey(jwk, operation) {
|
|
104
|
+
if (!isPrivateEcJwk(jwk))
|
|
105
|
+
fail(operation, "Bluesky OAuth keys must be private P-256 JWKs", "invalid_config");
|
|
106
|
+
try {
|
|
107
|
+
return await crypto.subtle.importKey("jwk", { kty: "EC", crv: "P-256", x: jwk.x ?? "", y: jwk.y ?? "", d: jwk.d ?? "" }, EC_KEY, false, ["sign"]);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
fail(operation, "Bluesky OAuth key could not be imported", "invalid_config");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function signJwt(header, payload, jwk, operation) {
|
|
114
|
+
const key = await importPrivateKey(jwk, operation);
|
|
115
|
+
const input = `${base64UrlJson(header)}.${base64UrlJson(payload)}`;
|
|
116
|
+
const signature = await crypto.subtle.sign(EC_SIGN, key, new TextEncoder().encode(input));
|
|
117
|
+
return `${input}.${base64Url(new Uint8Array(signature))}`;
|
|
118
|
+
}
|
|
119
|
+
async function generateDpopKey() {
|
|
120
|
+
const pair = await crypto.subtle.generateKey(EC_KEY, true, ["sign", "verify"]);
|
|
121
|
+
const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
|
|
122
|
+
return { kty: "EC", crv: "P-256", x: jwk.x ?? "", y: jwk.y ?? "", d: jwk.d ?? "" };
|
|
123
|
+
}
|
|
124
|
+
/** Strip query and fragment, as RFC 9449 requires for `htu`. */
|
|
125
|
+
function htu(url) {
|
|
126
|
+
return `${url.origin}${url.pathname}`;
|
|
127
|
+
}
|
|
128
|
+
async function dpopProof(input) {
|
|
129
|
+
const ath = input.accessToken === undefined ? undefined : await sha256Base64Url(input.accessToken);
|
|
130
|
+
const payload = {
|
|
131
|
+
jti: randomId(),
|
|
132
|
+
htm: input.method.toUpperCase(),
|
|
133
|
+
htu: htu(input.url),
|
|
134
|
+
iat: Math.floor(Date.now() / 1000),
|
|
135
|
+
...definedFields({ nonce: input.nonce, ath }),
|
|
136
|
+
};
|
|
137
|
+
return signJwt({ typ: "dpop+jwt", alg: "ES256", jwk: publicPart(input.key) }, payload, input.key, "bluesky.oauth.dpop");
|
|
138
|
+
}
|
|
139
|
+
/** Public JWK for the client metadata `jwks` document. Never publish the private key. */
|
|
140
|
+
export function blueskyOAuthPublicJwk(key) {
|
|
141
|
+
if (!isPrivateEcJwk(key.privateJwk) || !key.kid.trim())
|
|
142
|
+
fail("bluesky.oauth.config", "Bluesky OAuth client key must be a P-256 JWK with a kid", "invalid_config");
|
|
143
|
+
return { ...publicPart(key.privateJwk), kid: key.kid, alg: "ES256", use: "sig" };
|
|
144
|
+
}
|
|
145
|
+
// HTTP ---------------------------------------------------------------------------------
|
|
146
|
+
async function assertEgress(url, operation, hook) {
|
|
147
|
+
const reason = egressBlockReason(url);
|
|
148
|
+
if (reason !== undefined)
|
|
149
|
+
fail(operation, `Bluesky OAuth request target is not allowed: ${reason}`, "unauthorized");
|
|
150
|
+
if (hook === undefined)
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
await hook(new URL(url));
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof SocialError)
|
|
157
|
+
throw error;
|
|
158
|
+
throw new SocialError({
|
|
159
|
+
code: "unauthorized",
|
|
160
|
+
operation,
|
|
161
|
+
message: "Bluesky OAuth request target was rejected by assertEgressAllowed",
|
|
162
|
+
cause: error,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Send one request through the egress guard. Redirects are never followed by
|
|
168
|
+
* `fetch`; up to `maxRedirects` GET redirects are followed here, and every hop
|
|
169
|
+
* passes the same guard. With `maxRedirects` 0 a redirect is returned as is and
|
|
170
|
+
* the caller rejects it as a non-success status.
|
|
171
|
+
*/
|
|
172
|
+
async function send(url, init, operation, options, maxRedirects = 0) {
|
|
173
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
174
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
|
|
175
|
+
fail("bluesky.oauth.config", "timeoutMs must be a positive safe integer", "invalid_config");
|
|
176
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
let timer;
|
|
179
|
+
const timeout = new Promise((_, reject) => {
|
|
180
|
+
timer = setTimeout(() => {
|
|
181
|
+
controller.abort();
|
|
182
|
+
reject(new DOMException("Bluesky OAuth request timed out", "AbortError"));
|
|
183
|
+
}, timeoutMs);
|
|
184
|
+
});
|
|
185
|
+
const race = (work) => Promise.race([work, timeout]);
|
|
186
|
+
try {
|
|
187
|
+
let target = url;
|
|
188
|
+
for (let hop = 0;; hop++) {
|
|
189
|
+
await race(assertEgress(target, operation, options.assertEgressAllowed));
|
|
190
|
+
const response = await race(fetcher(target.toString(), { ...init, redirect: "manual", signal: controller.signal }));
|
|
191
|
+
if (maxRedirects > 0 && REDIRECT_STATUSES.has(response.status)) {
|
|
192
|
+
await response.body?.cancel().catch(() => undefined);
|
|
193
|
+
if (hop >= maxRedirects)
|
|
194
|
+
fail(operation, "Bluesky OAuth request redirected too many times");
|
|
195
|
+
const location = response.headers.get("location");
|
|
196
|
+
if (location === null)
|
|
197
|
+
fail(operation, "Bluesky OAuth redirect has no Location header");
|
|
198
|
+
try {
|
|
199
|
+
target = new URL(location, target);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
fail(operation, "Bluesky OAuth redirect has an invalid Location header");
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const text = await race(readBounded(response, options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, operation));
|
|
207
|
+
return { status: response.status, headers: response.headers, text };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (error instanceof SocialError)
|
|
212
|
+
throw error;
|
|
213
|
+
if (error instanceof DOMException && error.name === "AbortError")
|
|
214
|
+
fail(operation, "Bluesky OAuth request timed out", "timeout");
|
|
215
|
+
throw new SocialError({
|
|
216
|
+
code: "upstream_failure",
|
|
217
|
+
operation,
|
|
218
|
+
message: "Bluesky OAuth request failed",
|
|
219
|
+
cause: error,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
if (timer !== undefined)
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function parseJson(text, operation) {
|
|
228
|
+
let parsed;
|
|
229
|
+
try {
|
|
230
|
+
parsed = parseJsonValue(text);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
fail(operation, "Bluesky OAuth server returned malformed JSON");
|
|
234
|
+
}
|
|
235
|
+
if (!isJsonObject(parsed))
|
|
236
|
+
fail(operation, "Bluesky OAuth server returned an invalid response");
|
|
237
|
+
return parsed;
|
|
238
|
+
}
|
|
239
|
+
/** Metadata and identity documents must be exactly HTTP 200 JSON objects. */
|
|
240
|
+
async function getJson(url, operation, options) {
|
|
241
|
+
const result = await send(url, { headers: { accept: "application/json" } }, operation, options);
|
|
242
|
+
if (result.status !== 200)
|
|
243
|
+
throw new SocialError({
|
|
244
|
+
code: result.status === 404 ? "not_found" : "upstream_failure",
|
|
245
|
+
operation,
|
|
246
|
+
message: "Bluesky OAuth discovery request failed",
|
|
247
|
+
upstreamStatus: result.status,
|
|
248
|
+
});
|
|
249
|
+
return parseJson(result.text, operation);
|
|
250
|
+
}
|
|
251
|
+
function httpsOrigin(value, operation, message) {
|
|
252
|
+
if (!isString(value))
|
|
253
|
+
fail(operation, message);
|
|
254
|
+
let url;
|
|
255
|
+
try {
|
|
256
|
+
url = new URL(value);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
fail(operation, message);
|
|
260
|
+
}
|
|
261
|
+
// Compare the raw value so a default port, path, query, or trailing slash is rejected.
|
|
262
|
+
if (url.protocol !== "https:" || url.username || url.password || value !== url.origin)
|
|
263
|
+
fail(operation, message);
|
|
264
|
+
return url.origin;
|
|
265
|
+
}
|
|
266
|
+
function httpsUrl(value, operation, field) {
|
|
267
|
+
if (!isString(value))
|
|
268
|
+
fail(operation, `Authorization server metadata is missing ${field}`);
|
|
269
|
+
let url;
|
|
270
|
+
try {
|
|
271
|
+
url = new URL(value);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
fail(operation, `Authorization server metadata has an invalid ${field}`);
|
|
275
|
+
}
|
|
276
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash)
|
|
277
|
+
fail(operation, `Authorization server metadata has an invalid ${field}`);
|
|
278
|
+
return url.toString();
|
|
279
|
+
}
|
|
280
|
+
// Identity ---------------------------------------------------------------------------------
|
|
281
|
+
function isHandle(value) {
|
|
282
|
+
if (value.length > 253 || !HANDLE_PATTERN.test(value))
|
|
283
|
+
return false;
|
|
284
|
+
const tld = value.slice(value.lastIndexOf(".") + 1);
|
|
285
|
+
return !DISALLOWED_TLDS.has(tld);
|
|
286
|
+
}
|
|
287
|
+
function validDid(value) {
|
|
288
|
+
if (value.length > 2048 || !DID_PATTERN.test(value))
|
|
289
|
+
return false;
|
|
290
|
+
if (value.startsWith("did:plc:"))
|
|
291
|
+
return PLC_PATTERN.test(value);
|
|
292
|
+
if (value.startsWith("did:web:"))
|
|
293
|
+
return isHandle(value.slice("did:web:".length));
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
function parseLoginTarget(raw) {
|
|
297
|
+
const hint = raw.trim();
|
|
298
|
+
if (hint.startsWith("https://")) {
|
|
299
|
+
let url;
|
|
300
|
+
try {
|
|
301
|
+
url = new URL(hint);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
fail("connections.begin", "Bluesky server URL is invalid", "invalid_input");
|
|
305
|
+
}
|
|
306
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== "/")
|
|
307
|
+
fail("connections.begin", "Bluesky server URL must be an HTTPS origin", "invalid_input");
|
|
308
|
+
return { kind: "server", origin: url.origin };
|
|
309
|
+
}
|
|
310
|
+
if (hint.startsWith("did:")) {
|
|
311
|
+
if (!validDid(hint))
|
|
312
|
+
fail("connections.begin", "Bluesky DID must be a valid did:plc or did:web", "invalid_input");
|
|
313
|
+
return { kind: "did", did: hint, hint };
|
|
314
|
+
}
|
|
315
|
+
const handle = (hint.startsWith("@") ? hint.slice(1) : hint).toLowerCase();
|
|
316
|
+
if (!isHandle(handle))
|
|
317
|
+
fail("connections.begin", "Bluesky login hint must be a handle, DID, or HTTPS server URL", "invalid_input");
|
|
318
|
+
return { kind: "handle", handle, hint };
|
|
319
|
+
}
|
|
320
|
+
async function defaultResolveTxt(hostname) {
|
|
321
|
+
const dns = await import("node:dns/promises");
|
|
322
|
+
return dns.resolveTxt(hostname);
|
|
323
|
+
}
|
|
324
|
+
async function withTimeout(work, options) {
|
|
325
|
+
let timer;
|
|
326
|
+
try {
|
|
327
|
+
return await Promise.race([
|
|
328
|
+
work,
|
|
329
|
+
new Promise((_, reject) => {
|
|
330
|
+
timer = setTimeout(() => reject(new DOMException("Handle lookup timed out", "AbortError")), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
331
|
+
}),
|
|
332
|
+
]);
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
if (timer !== undefined)
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** Resolve a handle with DNS TXT first, then the HTTPS well-known endpoint. */
|
|
340
|
+
async function resolveHandle(handle, options) {
|
|
341
|
+
const operation = "bluesky.oauth.identity";
|
|
342
|
+
let records = [];
|
|
343
|
+
try {
|
|
344
|
+
records = await withTimeout((options.resolveTxt ?? defaultResolveTxt)(`_atproto.${handle}`), options);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// No usable DNS record; fall through to the HTTPS method.
|
|
348
|
+
}
|
|
349
|
+
const dnsDids = [
|
|
350
|
+
...new Set(records
|
|
351
|
+
.map((chunks) => chunks.join(""))
|
|
352
|
+
.filter((value) => value.startsWith("did="))
|
|
353
|
+
.map((value) => value.slice(4))),
|
|
354
|
+
];
|
|
355
|
+
if (dnsDids.length > 1)
|
|
356
|
+
fail(operation, "Bluesky handle has conflicting DNS records");
|
|
357
|
+
const [dnsDid] = dnsDids;
|
|
358
|
+
if (dnsDid !== undefined) {
|
|
359
|
+
if (!validDid(dnsDid))
|
|
360
|
+
fail(operation, "Bluesky handle resolved to an unsupported DID");
|
|
361
|
+
return dnsDid;
|
|
362
|
+
}
|
|
363
|
+
const result = await send(new URL(`https://${handle}/.well-known/atproto-did`), { headers: { accept: "text/plain" } }, operation, options,
|
|
364
|
+
// The handle spec allows redirects here; every hop passes the egress guard.
|
|
365
|
+
// Metadata documents must not redirect.
|
|
366
|
+
MAX_HANDLE_REDIRECTS);
|
|
367
|
+
const did = result.text.trim();
|
|
368
|
+
if (result.status < 200 || result.status > 299 || !validDid(did))
|
|
369
|
+
fail(operation, "Bluesky handle could not be resolved");
|
|
370
|
+
return did;
|
|
371
|
+
}
|
|
372
|
+
function matchesFragment(id, did, fragment) {
|
|
373
|
+
return id === fragment || id === `${did}${fragment}`;
|
|
374
|
+
}
|
|
375
|
+
async function resolveDidDocument(did, options) {
|
|
376
|
+
const operation = "bluesky.oauth.identity";
|
|
377
|
+
let url;
|
|
378
|
+
if (did.startsWith("did:plc:")) {
|
|
379
|
+
const directory = httpsOrigin(options.plcDirectoryUrl ?? DEFAULT_PLC_DIRECTORY, "bluesky.oauth.config", "plcDirectoryUrl must be an HTTPS origin");
|
|
380
|
+
url = new URL(`/${did}`, directory);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
url = new URL(`https://${did.slice("did:web:".length)}/.well-known/did.json`);
|
|
384
|
+
}
|
|
385
|
+
const doc = await getJson(url, operation, options);
|
|
386
|
+
if (doc["id"] !== did)
|
|
387
|
+
fail(operation, "DID document does not match the requested DID", "unauthorized");
|
|
388
|
+
const services = doc["service"];
|
|
389
|
+
const service = (isJsonArray(services) ? services : [])
|
|
390
|
+
.filter(isJsonObject)
|
|
391
|
+
.find((entry) => matchesFragment(entry["id"], did, "#atproto_pds") &&
|
|
392
|
+
entry["type"] === "AtprotoPersonalDataServer");
|
|
393
|
+
if (service === undefined)
|
|
394
|
+
fail(operation, "DID document does not name a PDS");
|
|
395
|
+
const endpoint = service["serviceEndpoint"];
|
|
396
|
+
const pds = httpsOrigin(isString(endpoint) ? endpoint.replace(/\/$/, "") : endpoint, operation, "DID document has an invalid PDS endpoint");
|
|
397
|
+
const handle = stringList(doc["alsoKnownAs"])
|
|
398
|
+
.filter((aka) => aka.startsWith("at://"))
|
|
399
|
+
.map((aka) => aka.slice("at://".length).toLowerCase())
|
|
400
|
+
.find(isHandle);
|
|
401
|
+
return handle === undefined ? { pds } : { pds, handle };
|
|
402
|
+
}
|
|
403
|
+
/** Read the PDS's protected-resource metadata and return its single authorization server. */
|
|
404
|
+
async function authorizationServerForPds(pds, options) {
|
|
405
|
+
const operation = "bluesky.oauth.discovery";
|
|
406
|
+
const metadata = await getJson(new URL("/.well-known/oauth-protected-resource", pds), operation, options);
|
|
407
|
+
const resource = metadata["resource"];
|
|
408
|
+
if (resource !== undefined && (!isString(resource) || resource.replace(/\/$/, "") !== pds))
|
|
409
|
+
fail(operation, "Protected resource metadata does not match the PDS", "unauthorized");
|
|
410
|
+
const servers = metadata["authorization_servers"];
|
|
411
|
+
if (!Array.isArray(servers) || servers.length !== 1)
|
|
412
|
+
fail(operation, "Protected resource metadata must name exactly one authorization server");
|
|
413
|
+
return httpsOrigin(servers[0], operation, "Protected resource metadata has an invalid issuer");
|
|
414
|
+
}
|
|
415
|
+
function includes(metadata, field, value) {
|
|
416
|
+
return stringList(metadata[field]).includes(value);
|
|
417
|
+
}
|
|
418
|
+
async function authorizationServerMetadata(issuer, authMethod, options) {
|
|
419
|
+
const operation = "bluesky.oauth.discovery";
|
|
420
|
+
const metadata = await getJson(new URL("/.well-known/oauth-authorization-server", issuer), operation, options);
|
|
421
|
+
if (metadata["issuer"] !== issuer)
|
|
422
|
+
fail(operation, "Authorization server issuer does not match its metadata URL", "unauthorized");
|
|
423
|
+
const signingAlgs = metadata["token_endpoint_auth_signing_alg_values_supported"];
|
|
424
|
+
const compliant = includes(metadata, "response_types_supported", "code") &&
|
|
425
|
+
includes(metadata, "grant_types_supported", "authorization_code") &&
|
|
426
|
+
includes(metadata, "grant_types_supported", "refresh_token") &&
|
|
427
|
+
includes(metadata, "code_challenge_methods_supported", "S256") &&
|
|
428
|
+
includes(metadata, "token_endpoint_auth_methods_supported", authMethod) &&
|
|
429
|
+
(authMethod === "none" ||
|
|
430
|
+
signingAlgs === undefined ||
|
|
431
|
+
includes(metadata, "token_endpoint_auth_signing_alg_values_supported", "ES256")) &&
|
|
432
|
+
includes(metadata, "scopes_supported", "atproto") &&
|
|
433
|
+
includes(metadata, "dpop_signing_alg_values_supported", "ES256") &&
|
|
434
|
+
metadata["authorization_response_iss_parameter_supported"] === true &&
|
|
435
|
+
metadata["require_pushed_authorization_requests"] === true &&
|
|
436
|
+
metadata["client_id_metadata_document_supported"] === true &&
|
|
437
|
+
metadata["require_request_uri_registration"] !== false;
|
|
438
|
+
if (!compliant)
|
|
439
|
+
fail(operation, "Authorization server metadata does not meet the AT Protocol OAuth profile");
|
|
440
|
+
const revocationEndpoint = metadata["revocation_endpoint"];
|
|
441
|
+
return {
|
|
442
|
+
issuer,
|
|
443
|
+
authorizationEndpoint: httpsUrl(metadata["authorization_endpoint"], operation, "authorization_endpoint"),
|
|
444
|
+
tokenEndpoint: httpsUrl(metadata["token_endpoint"], operation, "token_endpoint"),
|
|
445
|
+
parEndpoint: httpsUrl(metadata["pushed_authorization_request_endpoint"], operation, "pushed_authorization_request_endpoint"),
|
|
446
|
+
...definedFields({
|
|
447
|
+
revocationEndpoint: revocationEndpoint === undefined
|
|
448
|
+
? undefined
|
|
449
|
+
: httpsUrl(revocationEndpoint, operation, "revocation_endpoint"),
|
|
450
|
+
}),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/** Resolve a DID to its PDS and confirm the PDS names the expected issuer. */
|
|
454
|
+
async function verifyIssuerForDid(did, issuer, options) {
|
|
455
|
+
const doc = await resolveDidDocument(did, options);
|
|
456
|
+
const actual = await authorizationServerForPds(doc.pds, options);
|
|
457
|
+
if (actual !== issuer)
|
|
458
|
+
fail("bluesky.oauth.identity", "Account's authorization server does not match the token issuer", "unauthorized");
|
|
459
|
+
return doc;
|
|
460
|
+
}
|
|
461
|
+
// Client configuration -------------------------------------------------------------------
|
|
462
|
+
function authMethodFor(options) {
|
|
463
|
+
const operation = "bluesky.oauth.config";
|
|
464
|
+
let url;
|
|
465
|
+
try {
|
|
466
|
+
url = new URL(options.clientId);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
fail(operation, "Bluesky OAuth clientId must be a URL", "invalid_config");
|
|
470
|
+
}
|
|
471
|
+
const loopback = url.protocol === "http:" && url.hostname === "localhost";
|
|
472
|
+
if (url.username ||
|
|
473
|
+
url.password ||
|
|
474
|
+
url.hash ||
|
|
475
|
+
(loopback ? url.port !== "" || url.pathname !== "/" : url.protocol !== "https:"))
|
|
476
|
+
fail(operation, "Bluesky OAuth clientId must be an HTTPS metadata URL or an http://localhost development client", "invalid_config");
|
|
477
|
+
if (options.clientKey === undefined)
|
|
478
|
+
return "none";
|
|
479
|
+
if (loopback)
|
|
480
|
+
fail(operation, "Localhost development clients are always public clients", "invalid_config");
|
|
481
|
+
blueskyOAuthPublicJwk(options.clientKey);
|
|
482
|
+
return "private_key_jwt";
|
|
483
|
+
}
|
|
484
|
+
async function clientAuthentication(options, issuer) {
|
|
485
|
+
if (options.clientKey === undefined)
|
|
486
|
+
return { client_id: options.clientId };
|
|
487
|
+
const now = Math.floor(Date.now() / 1000);
|
|
488
|
+
const assertion = await signJwt({ typ: "JWT", alg: "ES256", kid: options.clientKey.kid }, {
|
|
489
|
+
iss: options.clientId,
|
|
490
|
+
sub: options.clientId,
|
|
491
|
+
aud: issuer,
|
|
492
|
+
jti: randomId(),
|
|
493
|
+
iat: now,
|
|
494
|
+
exp: now + 60,
|
|
495
|
+
}, options.clientKey.privateJwk, "bluesky.oauth.client-auth");
|
|
496
|
+
return {
|
|
497
|
+
client_id: options.clientId,
|
|
498
|
+
client_assertion_type: JWT_BEARER,
|
|
499
|
+
client_assertion: assertion,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function oauthError(status, text, operation) {
|
|
503
|
+
let upstreamCode;
|
|
504
|
+
try {
|
|
505
|
+
const parsed = parseJsonValue(text);
|
|
506
|
+
const error = isJsonObject(parsed) ? parsed["error"] : undefined;
|
|
507
|
+
if (isString(error) && error.length <= 64)
|
|
508
|
+
upstreamCode = error;
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// Keep the HTTP classification when the error body is not JSON.
|
|
512
|
+
}
|
|
513
|
+
const code = upstreamCode === "invalid_grant" || status === 401
|
|
514
|
+
? "reconnect_required"
|
|
515
|
+
: upstreamCode === "access_denied"
|
|
516
|
+
? "cancelled"
|
|
517
|
+
: upstreamCode === "invalid_client"
|
|
518
|
+
? "invalid_config"
|
|
519
|
+
: status === 403
|
|
520
|
+
? "missing_permission"
|
|
521
|
+
: status === 429
|
|
522
|
+
? "rate_limited"
|
|
523
|
+
: "upstream_failure";
|
|
524
|
+
const message = "Bluesky authorization server rejected the request";
|
|
525
|
+
if (code === "rate_limited")
|
|
526
|
+
throw new SocialError({
|
|
527
|
+
code,
|
|
528
|
+
operation,
|
|
529
|
+
message,
|
|
530
|
+
upstreamStatus: status,
|
|
531
|
+
...definedFields({ upstreamCode }),
|
|
532
|
+
retryDisposition: { kind: "after-delay", delayMs: 1000 },
|
|
533
|
+
});
|
|
534
|
+
throw new SocialError({
|
|
535
|
+
code,
|
|
536
|
+
operation,
|
|
537
|
+
message,
|
|
538
|
+
upstreamStatus: status,
|
|
539
|
+
...definedFields({ upstreamCode }),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
function isUseDpopNonce(result) {
|
|
543
|
+
if (result.status !== 400)
|
|
544
|
+
return false;
|
|
545
|
+
try {
|
|
546
|
+
const parsed = parseJsonValue(result.text);
|
|
547
|
+
return isJsonObject(parsed) && parsed["error"] === "use_dpop_nonce";
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* The atproto OAuth profile says clients must reject responses without a
|
|
555
|
+
* `DPoP-Nonce` header when the request carried DPoP. Token callers run this
|
|
556
|
+
* inside the revoke-on-failure path, because the tokens were already issued.
|
|
557
|
+
*/
|
|
558
|
+
function requireServerNonce(response, operation) {
|
|
559
|
+
if (!response.nonceReturned)
|
|
560
|
+
fail(operation, "Bluesky authorization server response is missing a DPoP-Nonce header");
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* POST a form to an authorization server endpoint with DPoP. When the server
|
|
564
|
+
* answers `use_dpop_nonce`, the request is sent once more with the new nonce, as
|
|
565
|
+
* RFC 9449 section 8 requires. The server rejects that first request before
|
|
566
|
+
* acting on it, so the retry cannot duplicate a grant.
|
|
567
|
+
*/
|
|
568
|
+
async function authorizationServerPost(input) {
|
|
569
|
+
const url = new URL(input.endpoint);
|
|
570
|
+
let nonce = input.nonce;
|
|
571
|
+
for (let attempt = 0;; attempt++) {
|
|
572
|
+
const body = new URLSearchParams({
|
|
573
|
+
...input.form,
|
|
574
|
+
...(await clientAuthentication(input.options, input.issuer)),
|
|
575
|
+
});
|
|
576
|
+
const result = await send(url, {
|
|
577
|
+
method: "POST",
|
|
578
|
+
headers: {
|
|
579
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
580
|
+
accept: "application/json",
|
|
581
|
+
DPoP: await dpopProof({ key: input.dpopKey, method: "POST", url, nonce }),
|
|
582
|
+
},
|
|
583
|
+
body,
|
|
584
|
+
}, input.operation, input.options);
|
|
585
|
+
const next = result.headers.get("DPoP-Nonce") ?? undefined;
|
|
586
|
+
const changed = next !== undefined && next !== nonce;
|
|
587
|
+
if (next !== undefined)
|
|
588
|
+
nonce = next;
|
|
589
|
+
if (attempt === 0 && changed && isUseDpopNonce(result))
|
|
590
|
+
continue;
|
|
591
|
+
if (result.status < 200 || result.status > 299)
|
|
592
|
+
oauthError(result.status, result.text, input.operation);
|
|
593
|
+
return {
|
|
594
|
+
json: parseJson(result.text, input.operation),
|
|
595
|
+
nonce,
|
|
596
|
+
nonceReturned: next !== undefined && next !== "",
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Best-effort revocation after tokens were issued but then rejected, for example
|
|
602
|
+
* when identity verification fails. It sends one request and never retries,
|
|
603
|
+
* including on a DPoP nonce challenge. It revokes the refresh token when there is
|
|
604
|
+
* one, which ends the whole grant, and otherwise the access token. Every failure
|
|
605
|
+
* is swallowed so the caller can throw its original error; the tokens never
|
|
606
|
+
* appear in an error or log.
|
|
607
|
+
*/
|
|
608
|
+
async function revokeRejectedTokens(input) {
|
|
609
|
+
const { accessToken, refreshToken } = input.tokens;
|
|
610
|
+
if (input.endpoint === undefined || (accessToken === undefined && refreshToken === undefined))
|
|
611
|
+
return;
|
|
612
|
+
try {
|
|
613
|
+
const url = new URL(input.endpoint);
|
|
614
|
+
const form = refreshToken === undefined
|
|
615
|
+
? { token: accessToken ?? "", token_type_hint: "access_token" }
|
|
616
|
+
: { token: refreshToken, token_type_hint: "refresh_token" };
|
|
617
|
+
await send(url, {
|
|
618
|
+
method: "POST",
|
|
619
|
+
headers: {
|
|
620
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
621
|
+
accept: "application/json",
|
|
622
|
+
DPoP: await dpopProof({
|
|
623
|
+
key: input.dpopKey,
|
|
624
|
+
method: "POST",
|
|
625
|
+
url,
|
|
626
|
+
nonce: input.nonce,
|
|
627
|
+
}),
|
|
628
|
+
},
|
|
629
|
+
body: new URLSearchParams({
|
|
630
|
+
...form,
|
|
631
|
+
...(await clientAuthentication(input.options, input.issuer)),
|
|
632
|
+
}),
|
|
633
|
+
}, "bluesky.oauth.revoke", input.options);
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
// Best effort only. The original verification error is what the caller reports.
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Run every check that follows a token response, including parsing it; if any
|
|
641
|
+
* fails, revoke the issued tokens once and rethrow the original error.
|
|
642
|
+
*/
|
|
643
|
+
async function verifyOrRevoke(check, revoke) {
|
|
644
|
+
try {
|
|
645
|
+
return await check();
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
await revokeRejectedTokens(revoke);
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function nonEmptyString(value) {
|
|
653
|
+
return isString(value) && value.length > 0 ? value : undefined;
|
|
654
|
+
}
|
|
655
|
+
function issuedTokens(data) {
|
|
656
|
+
return definedFields({
|
|
657
|
+
accessToken: nonEmptyString(data["access_token"]),
|
|
658
|
+
refreshToken: nonEmptyString(data["refresh_token"]),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
function tokenResponse(data, operation) {
|
|
662
|
+
const accessToken = data["access_token"];
|
|
663
|
+
const tokenType = data["token_type"];
|
|
664
|
+
const sub = data["sub"];
|
|
665
|
+
const scope = data["scope"];
|
|
666
|
+
if (!isString(accessToken) || accessToken.length === 0 || accessToken.length > 16_384)
|
|
667
|
+
fail(operation, "Token response is missing access_token");
|
|
668
|
+
if (!isString(tokenType) || tokenType.toLowerCase() !== "dpop")
|
|
669
|
+
fail(operation, "Token response is not DPoP-bound", "unauthorized");
|
|
670
|
+
if (!isString(sub) || !validDid(sub))
|
|
671
|
+
fail(operation, "Token response does not name a valid account DID", "unauthorized");
|
|
672
|
+
if (!isString(scope) || !scopeTokens(scope).includes("atproto"))
|
|
673
|
+
fail(operation, "Token response does not grant the atproto scope", "unauthorized");
|
|
674
|
+
const refreshToken = data["refresh_token"];
|
|
675
|
+
if (refreshToken !== undefined && (!isString(refreshToken) || refreshToken.length === 0))
|
|
676
|
+
fail(operation, "Token response has an invalid refresh_token");
|
|
677
|
+
const expiresIn = data["expires_in"];
|
|
678
|
+
if (expiresIn !== undefined &&
|
|
679
|
+
(!isFiniteNumber(expiresIn) || expiresIn < 0 || expiresIn > 31_536_000))
|
|
680
|
+
fail(operation, "Token response has an invalid lifetime");
|
|
681
|
+
return {
|
|
682
|
+
did: sub,
|
|
683
|
+
accessToken,
|
|
684
|
+
scopes: scopeTokens(scope),
|
|
685
|
+
...definedFields({
|
|
686
|
+
refreshToken,
|
|
687
|
+
expiresAt: expiresIn === undefined ? undefined : new Date(Date.now() + expiresIn * 1000).toISOString(),
|
|
688
|
+
}),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
function encodeProviderState(state) {
|
|
692
|
+
return JSON.stringify(state);
|
|
693
|
+
}
|
|
694
|
+
function decodeProviderState(value) {
|
|
695
|
+
const operation = "connections.complete";
|
|
696
|
+
if (value === undefined)
|
|
697
|
+
fail(operation, "Bluesky OAuth attempt has no provider state", "invalid_input");
|
|
698
|
+
let parsed;
|
|
699
|
+
try {
|
|
700
|
+
parsed = parseJsonValue(value);
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
fail(operation, "Bluesky OAuth provider state is malformed", "invalid_input");
|
|
704
|
+
}
|
|
705
|
+
if (!isJsonObject(parsed))
|
|
706
|
+
fail(operation, "Bluesky OAuth provider state is malformed", "invalid_input");
|
|
707
|
+
const issuer = parsed["issuer"];
|
|
708
|
+
const tokenEndpoint = parsed["tokenEndpoint"];
|
|
709
|
+
const revocationEndpoint = parsed["revocationEndpoint"];
|
|
710
|
+
const dpopKey = parsed["dpopKey"];
|
|
711
|
+
const dpopNonce = parsed["dpopNonce"];
|
|
712
|
+
const did = parsed["did"];
|
|
713
|
+
const handle = parsed["handle"];
|
|
714
|
+
if (parsed["version"] !== 1 ||
|
|
715
|
+
!isString(issuer) ||
|
|
716
|
+
!isString(tokenEndpoint) ||
|
|
717
|
+
!isOptionalString(revocationEndpoint) ||
|
|
718
|
+
!isPrivateEcJwk(dpopKey) ||
|
|
719
|
+
!isOptionalString(dpopNonce) ||
|
|
720
|
+
!isOptionalString(did) ||
|
|
721
|
+
!isOptionalString(handle))
|
|
722
|
+
fail(operation, "Bluesky OAuth provider state is malformed", "invalid_input");
|
|
723
|
+
return {
|
|
724
|
+
version: 1,
|
|
725
|
+
issuer,
|
|
726
|
+
tokenEndpoint,
|
|
727
|
+
dpopKey,
|
|
728
|
+
...definedFields({ revocationEndpoint, dpopNonce, did, handle }),
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
async function verifiedHandle(did, claimed, alreadyVerified, options) {
|
|
732
|
+
if (claimed === undefined)
|
|
733
|
+
return undefined;
|
|
734
|
+
if (claimed === alreadyVerified)
|
|
735
|
+
return claimed;
|
|
736
|
+
try {
|
|
737
|
+
return (await resolveHandle(claimed, options)) === did ? claimed : undefined;
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
// An unverified handle is only omitted; the DID remains the account identifier.
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* AT Protocol OAuth for Bluesky accounts, driven by `ConnectionManager`.
|
|
746
|
+
*
|
|
747
|
+
* `start` resolves the login hint to an authorization server, generates a DPoP
|
|
748
|
+
* key, and sends a pushed authorization request with the manager's PKCE
|
|
749
|
+
* challenge and state. `complete` checks `iss`, exchanges the code with DPoP,
|
|
750
|
+
* and verifies that the returned DID's PDS names the same issuer before the
|
|
751
|
+
* account is returned or the session reaches `sessionSink`.
|
|
752
|
+
*/
|
|
753
|
+
export function blueskyOAuth(options) {
|
|
754
|
+
const authMethod = authMethodFor(options);
|
|
755
|
+
const scope = checkScope(options.scope ?? DEFAULT_SCOPE, "bluesky.oauth.config");
|
|
756
|
+
return {
|
|
757
|
+
async start(input) {
|
|
758
|
+
if (!input.platforms.includes("bluesky"))
|
|
759
|
+
fail("connections.begin", "Bluesky OAuth cannot authorize the requested platforms", "invalid_input");
|
|
760
|
+
if (options.redirectUri !== undefined && options.redirectUri !== input.redirectUri)
|
|
761
|
+
fail("connections.begin", "redirectUri does not match the configured OAuth callback", "invalid_input");
|
|
762
|
+
const hint = input.loginHint ?? options.defaultServer;
|
|
763
|
+
if (hint === undefined || !hint.trim())
|
|
764
|
+
fail("connections.begin", "Bluesky OAuth needs a handle, DID, or server URL as loginHint", "invalid_input");
|
|
765
|
+
const target = parseLoginTarget(hint);
|
|
766
|
+
let issuer;
|
|
767
|
+
let did;
|
|
768
|
+
let handle;
|
|
769
|
+
if (target.kind === "server") {
|
|
770
|
+
try {
|
|
771
|
+
issuer = await authorizationServerForPds(target.origin, options);
|
|
772
|
+
}
|
|
773
|
+
catch (error) {
|
|
774
|
+
// An entryway such as https://bsky.social is itself the authorization server.
|
|
775
|
+
if (!(error instanceof SocialError) || error.code !== "not_found")
|
|
776
|
+
throw error;
|
|
777
|
+
issuer = target.origin;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
did = target.kind === "did" ? target.did : await resolveHandle(target.handle, options);
|
|
782
|
+
const doc = await resolveDidDocument(did, options);
|
|
783
|
+
if (target.kind === "handle") {
|
|
784
|
+
if (doc.handle !== target.handle)
|
|
785
|
+
fail("bluesky.oauth.identity", "Bluesky handle is not confirmed by its DID document", "unauthorized");
|
|
786
|
+
handle = target.handle;
|
|
787
|
+
}
|
|
788
|
+
issuer = await authorizationServerForPds(doc.pds, options);
|
|
789
|
+
}
|
|
790
|
+
const server = await authorizationServerMetadata(issuer, authMethod, options);
|
|
791
|
+
const dpopKey = await generateDpopKey();
|
|
792
|
+
const par = await authorizationServerPost({
|
|
793
|
+
endpoint: server.parEndpoint,
|
|
794
|
+
issuer,
|
|
795
|
+
form: {
|
|
796
|
+
response_type: "code",
|
|
797
|
+
code_challenge: input.codeChallenge,
|
|
798
|
+
code_challenge_method: "S256",
|
|
799
|
+
state: input.state,
|
|
800
|
+
redirect_uri: input.redirectUri,
|
|
801
|
+
scope,
|
|
802
|
+
...definedFields({ login_hint: target.kind === "server" ? undefined : target.hint }),
|
|
803
|
+
},
|
|
804
|
+
dpopKey,
|
|
805
|
+
nonce: undefined,
|
|
806
|
+
operation: "bluesky.oauth.par",
|
|
807
|
+
options,
|
|
808
|
+
});
|
|
809
|
+
requireServerNonce(par, "bluesky.oauth.par");
|
|
810
|
+
const requestUri = par.json["request_uri"];
|
|
811
|
+
if (!isString(requestUri) || requestUri.length === 0)
|
|
812
|
+
fail("bluesky.oauth.par", "Pushed authorization response is missing request_uri");
|
|
813
|
+
const url = new URL(server.authorizationEndpoint);
|
|
814
|
+
url.searchParams.set("client_id", options.clientId);
|
|
815
|
+
url.searchParams.set("request_uri", requestUri);
|
|
816
|
+
return {
|
|
817
|
+
authorizationUrl: url.toString(),
|
|
818
|
+
providerState: encodeProviderState({
|
|
819
|
+
version: 1,
|
|
820
|
+
issuer,
|
|
821
|
+
tokenEndpoint: server.tokenEndpoint,
|
|
822
|
+
dpopKey,
|
|
823
|
+
...definedFields({
|
|
824
|
+
revocationEndpoint: server.revocationEndpoint,
|
|
825
|
+
dpopNonce: par.nonce,
|
|
826
|
+
did,
|
|
827
|
+
handle,
|
|
828
|
+
}),
|
|
829
|
+
}),
|
|
830
|
+
// Holds the DPoP private key, so it never leaves the connection store.
|
|
831
|
+
providerStateSecret: true,
|
|
832
|
+
};
|
|
833
|
+
},
|
|
834
|
+
async complete(input) {
|
|
835
|
+
const callback = validateCallback(input);
|
|
836
|
+
const denied = callback.searchParams.get("error");
|
|
837
|
+
if (denied !== null)
|
|
838
|
+
fail("connections.complete", "OAuth authorization was cancelled or denied", denied === "access_denied" ? "cancelled" : "unauthorized");
|
|
839
|
+
if (!input.attempt.platforms.includes("bluesky"))
|
|
840
|
+
fail("connections.complete", "Connection attempt does not include Bluesky", "invalid_input");
|
|
841
|
+
const state = decodeProviderState(input.attempt.providerState);
|
|
842
|
+
const issuers = callback.searchParams.getAll("iss");
|
|
843
|
+
if (issuers.length !== 1 || issuers[0] !== state.issuer)
|
|
844
|
+
fail("connections.complete", "OAuth callback issuer does not match this attempt", "unauthorized");
|
|
845
|
+
const code = callback.searchParams.get("code");
|
|
846
|
+
if (!code)
|
|
847
|
+
fail("connections.complete", "OAuth callback did not include an authorization code", "invalid_input");
|
|
848
|
+
const exchanged = await authorizationServerPost({
|
|
849
|
+
endpoint: state.tokenEndpoint,
|
|
850
|
+
issuer: state.issuer,
|
|
851
|
+
form: {
|
|
852
|
+
grant_type: "authorization_code",
|
|
853
|
+
code,
|
|
854
|
+
redirect_uri: input.attempt.redirectUri,
|
|
855
|
+
code_verifier: input.attempt.codeVerifier,
|
|
856
|
+
},
|
|
857
|
+
dpopKey: state.dpopKey,
|
|
858
|
+
nonce: state.dpopNonce,
|
|
859
|
+
operation: "bluesky.oauth.token",
|
|
860
|
+
options,
|
|
861
|
+
});
|
|
862
|
+
const { token, doc } = await verifyOrRevoke(async () => {
|
|
863
|
+
requireServerNonce(exchanged, "bluesky.oauth.token");
|
|
864
|
+
const parsed = tokenResponse(exchanged.json, "bluesky.oauth.token");
|
|
865
|
+
if (state.did !== undefined && parsed.did !== state.did)
|
|
866
|
+
fail("bluesky.oauth.identity", "Token subject does not match the requested account", "unauthorized");
|
|
867
|
+
return {
|
|
868
|
+
token: parsed,
|
|
869
|
+
doc: await verifyIssuerForDid(parsed.did, state.issuer, options),
|
|
870
|
+
};
|
|
871
|
+
}, {
|
|
872
|
+
endpoint: state.revocationEndpoint,
|
|
873
|
+
issuer: state.issuer,
|
|
874
|
+
tokens: issuedTokens(exchanged.json),
|
|
875
|
+
dpopKey: state.dpopKey,
|
|
876
|
+
nonce: exchanged.nonce,
|
|
877
|
+
options,
|
|
878
|
+
});
|
|
879
|
+
const handle = await verifiedHandle(token.did, doc.handle, state.handle, options);
|
|
880
|
+
const account = {
|
|
881
|
+
ref: connectedAccountRef({
|
|
882
|
+
backend: input.attempt.backend,
|
|
883
|
+
platform: "bluesky",
|
|
884
|
+
accountId: token.did,
|
|
885
|
+
}),
|
|
886
|
+
displayName: handle ?? token.did,
|
|
887
|
+
};
|
|
888
|
+
if (options.sessionSink !== undefined) {
|
|
889
|
+
const session = {
|
|
890
|
+
version: 1,
|
|
891
|
+
did: token.did,
|
|
892
|
+
pdsUrl: doc.pds,
|
|
893
|
+
issuer: state.issuer,
|
|
894
|
+
clientId: options.clientId,
|
|
895
|
+
authMethod,
|
|
896
|
+
accessToken: token.accessToken,
|
|
897
|
+
scopes: token.scopes,
|
|
898
|
+
dpopKey: state.dpopKey,
|
|
899
|
+
...definedFields({
|
|
900
|
+
handle,
|
|
901
|
+
clientKeyId: options.clientKey?.kid,
|
|
902
|
+
refreshToken: token.refreshToken,
|
|
903
|
+
expiresAt: token.expiresAt,
|
|
904
|
+
}),
|
|
905
|
+
};
|
|
906
|
+
await options.sessionSink.save({ account, session, attempt: input.attempt });
|
|
907
|
+
}
|
|
908
|
+
return [account];
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Validate a session loaded from storage before using it. Pass the decoded JSON
|
|
914
|
+
* document, for example the result of `JSON.parse` on the decrypted session.
|
|
915
|
+
*/
|
|
916
|
+
export function parseBlueskyOAuthSession(value) {
|
|
917
|
+
const operation = "bluesky.oauth.session";
|
|
918
|
+
const invalid = () => fail(operation, "Stored Bluesky OAuth session is invalid", "invalid_config");
|
|
919
|
+
if (!isJsonValue(value) || !isJsonObject(value) || value["version"] !== 1)
|
|
920
|
+
return invalid();
|
|
921
|
+
const did = value["did"];
|
|
922
|
+
const handle = value["handle"];
|
|
923
|
+
const pdsUrl = value["pdsUrl"];
|
|
924
|
+
const issuer = value["issuer"];
|
|
925
|
+
const clientId = value["clientId"];
|
|
926
|
+
const authMethod = value["authMethod"];
|
|
927
|
+
const clientKeyId = value["clientKeyId"];
|
|
928
|
+
const accessToken = value["accessToken"];
|
|
929
|
+
const refreshToken = value["refreshToken"];
|
|
930
|
+
const expiresAt = value["expiresAt"];
|
|
931
|
+
const scopes = value["scopes"];
|
|
932
|
+
const dpopKey = value["dpopKey"];
|
|
933
|
+
if (!isString(did) ||
|
|
934
|
+
!validDid(did) ||
|
|
935
|
+
(handle !== undefined && (!isString(handle) || !isHandle(handle))) ||
|
|
936
|
+
!isString(clientId) ||
|
|
937
|
+
(authMethod !== "none" && authMethod !== "private_key_jwt") ||
|
|
938
|
+
(authMethod === "private_key_jwt") !== isString(clientKeyId) ||
|
|
939
|
+
!isOptionalString(clientKeyId) ||
|
|
940
|
+
!isString(accessToken) ||
|
|
941
|
+
accessToken.length === 0 ||
|
|
942
|
+
(refreshToken !== undefined && (!isString(refreshToken) || refreshToken.length === 0)) ||
|
|
943
|
+
(expiresAt !== undefined && (!isString(expiresAt) || Number.isNaN(Date.parse(expiresAt)))) ||
|
|
944
|
+
!isJsonArray(scopes) ||
|
|
945
|
+
!scopes.every(isString) ||
|
|
946
|
+
!scopes.includes("atproto") ||
|
|
947
|
+
!isPrivateEcJwk(dpopKey))
|
|
948
|
+
return invalid();
|
|
949
|
+
return {
|
|
950
|
+
version: 1,
|
|
951
|
+
did,
|
|
952
|
+
pdsUrl: httpsOrigin(pdsUrl, operation, "Stored Bluesky OAuth session is invalid"),
|
|
953
|
+
issuer: httpsOrigin(issuer, operation, "Stored Bluesky OAuth session is invalid"),
|
|
954
|
+
clientId,
|
|
955
|
+
authMethod,
|
|
956
|
+
accessToken,
|
|
957
|
+
...definedFields({ handle, clientKeyId, refreshToken, expiresAt }),
|
|
958
|
+
scopes: stringList(scopes),
|
|
959
|
+
dpopKey: {
|
|
960
|
+
kty: "EC",
|
|
961
|
+
crv: "P-256",
|
|
962
|
+
x: dpopKey.x ?? "",
|
|
963
|
+
y: dpopKey.y ?? "",
|
|
964
|
+
d: dpopKey.d ?? "",
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
function replayable(body) {
|
|
969
|
+
return !(typeof ReadableStream !== "undefined" && body instanceof ReadableStream);
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* DPoP transport for the Bluesky adapter's `session` option. Every request gets
|
|
973
|
+
* `Authorization: DPoP <token>` and a fresh proof with `ath`. When the PDS
|
|
974
|
+
* answers 401 with `use_dpop_nonce`, a replayable request is sent once more with
|
|
975
|
+
* the new nonce (RFC 9449 section 9). It never refreshes tokens; call
|
|
976
|
+
* `refreshBlueskyOAuthSession` before `expiresAt`.
|
|
977
|
+
*
|
|
978
|
+
* A PDS response without a `DPoP-Nonce` header is returned rather than rejected:
|
|
979
|
+
* by then the PDS has already processed the request, and turning a completed
|
|
980
|
+
* write into an error would hide its outcome.
|
|
981
|
+
*/
|
|
982
|
+
export function blueskyOAuthTransport(session, options = {}) {
|
|
983
|
+
const verified = parseBlueskyOAuthSession(session);
|
|
984
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
985
|
+
let nonce;
|
|
986
|
+
return {
|
|
987
|
+
did: verified.did,
|
|
988
|
+
service: verified.pdsUrl,
|
|
989
|
+
async fetchHandler(pathname, init = {}) {
|
|
990
|
+
if (!pathname.startsWith("/") || pathname.startsWith("//"))
|
|
991
|
+
fail("bluesky.oauth.transport", "Bluesky OAuth transport accepts only relative XRPC paths", "invalid_input");
|
|
992
|
+
const url = new URL(pathname, verified.pdsUrl);
|
|
993
|
+
if (url.origin !== verified.pdsUrl)
|
|
994
|
+
fail("bluesky.oauth.transport", "Bluesky OAuth transport must target the session PDS", "invalid_input");
|
|
995
|
+
await assertEgress(url, "bluesky.oauth.transport", options.assertEgressAllowed);
|
|
996
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
997
|
+
const request = async (proofNonce) => {
|
|
998
|
+
const headers = new Headers(init.headers);
|
|
999
|
+
headers.set("Authorization", `DPoP ${verified.accessToken}`);
|
|
1000
|
+
headers.set("DPoP", await dpopProof({
|
|
1001
|
+
key: verified.dpopKey,
|
|
1002
|
+
method,
|
|
1003
|
+
url,
|
|
1004
|
+
nonce: proofNonce,
|
|
1005
|
+
accessToken: verified.accessToken,
|
|
1006
|
+
}));
|
|
1007
|
+
return fetcher(url.toString(), { ...init, method, headers });
|
|
1008
|
+
};
|
|
1009
|
+
const sent = nonce;
|
|
1010
|
+
const response = await request(sent);
|
|
1011
|
+
const next = response.headers.get("DPoP-Nonce") ?? undefined;
|
|
1012
|
+
if (next !== undefined)
|
|
1013
|
+
nonce = next;
|
|
1014
|
+
const challenge = response.headers.get("WWW-Authenticate") ?? "";
|
|
1015
|
+
if (response.status !== 401 ||
|
|
1016
|
+
next === undefined ||
|
|
1017
|
+
next === sent ||
|
|
1018
|
+
!challenge.startsWith("DPoP") ||
|
|
1019
|
+
!challenge.includes('error="use_dpop_nonce"') ||
|
|
1020
|
+
!replayable(init.body))
|
|
1021
|
+
return response;
|
|
1022
|
+
await response.body?.cancel();
|
|
1023
|
+
const retried = await request(next);
|
|
1024
|
+
const latest = retried.headers.get("DPoP-Nonce");
|
|
1025
|
+
if (latest !== null)
|
|
1026
|
+
nonce = latest;
|
|
1027
|
+
return retried;
|
|
1028
|
+
},
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Refresh a session with its own DPoP key and client authentication. The
|
|
1033
|
+
* account's PDS is resolved again first, and the refresh fails unless it still
|
|
1034
|
+
* names the session issuer. Refresh tokens rotate, so store the result with a
|
|
1035
|
+
* compare-and-set write (for example `CredentialManager.rotate`).
|
|
1036
|
+
*/
|
|
1037
|
+
export async function refreshBlueskyOAuthSession(session, options) {
|
|
1038
|
+
const operation = "bluesky.oauth.refresh";
|
|
1039
|
+
const current = parseBlueskyOAuthSession(session);
|
|
1040
|
+
const authMethod = authMethodFor(options);
|
|
1041
|
+
if (options.clientId !== current.clientId ||
|
|
1042
|
+
authMethod !== current.authMethod ||
|
|
1043
|
+
options.clientKey?.kid !== current.clientKeyId)
|
|
1044
|
+
fail(operation, "Refresh must use the session's client ID and signing key", "invalid_config");
|
|
1045
|
+
if (current.refreshToken === undefined)
|
|
1046
|
+
fail(operation, "No refresh token is available; reconnect the account", "reconnect_required");
|
|
1047
|
+
const doc = await verifyIssuerForDid(current.did, current.issuer, options);
|
|
1048
|
+
const server = await authorizationServerMetadata(current.issuer, authMethod, options);
|
|
1049
|
+
const refreshed = await authorizationServerPost({
|
|
1050
|
+
endpoint: server.tokenEndpoint,
|
|
1051
|
+
issuer: current.issuer,
|
|
1052
|
+
form: { grant_type: "refresh_token", refresh_token: current.refreshToken },
|
|
1053
|
+
dpopKey: current.dpopKey,
|
|
1054
|
+
nonce: undefined,
|
|
1055
|
+
operation,
|
|
1056
|
+
options,
|
|
1057
|
+
});
|
|
1058
|
+
const token = await verifyOrRevoke(async () => {
|
|
1059
|
+
requireServerNonce(refreshed, operation);
|
|
1060
|
+
const parsed = tokenResponse(refreshed.json, operation);
|
|
1061
|
+
if (parsed.did !== current.did)
|
|
1062
|
+
fail(operation, "Refreshed token belongs to a different account", "unauthorized");
|
|
1063
|
+
return parsed;
|
|
1064
|
+
}, {
|
|
1065
|
+
endpoint: server.revocationEndpoint,
|
|
1066
|
+
issuer: current.issuer,
|
|
1067
|
+
tokens: issuedTokens(refreshed.json),
|
|
1068
|
+
dpopKey: current.dpopKey,
|
|
1069
|
+
nonce: refreshed.nonce,
|
|
1070
|
+
options,
|
|
1071
|
+
});
|
|
1072
|
+
const { refreshToken: _previousRefresh, expiresAt: _previousExpiry, ...rest } = current;
|
|
1073
|
+
return {
|
|
1074
|
+
...rest,
|
|
1075
|
+
pdsUrl: doc.pds,
|
|
1076
|
+
accessToken: token.accessToken,
|
|
1077
|
+
...definedFields({ refreshToken: token.refreshToken, expiresAt: token.expiresAt }),
|
|
1078
|
+
scopes: token.scopes,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
// Client metadata ---------------------------------------------------------------------
|
|
1082
|
+
function metadataUrl(value, field) {
|
|
1083
|
+
let url;
|
|
1084
|
+
try {
|
|
1085
|
+
url = new URL(value);
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
fail("bluesky.oauth.metadata", `${field} must be a URL`, "invalid_config");
|
|
1089
|
+
}
|
|
1090
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash)
|
|
1091
|
+
fail("bluesky.oauth.metadata", `${field} must be an HTTPS URL`, "invalid_config");
|
|
1092
|
+
return url;
|
|
1093
|
+
}
|
|
1094
|
+
function checkRedirect(value, clientId, applicationType) {
|
|
1095
|
+
const operation = "bluesky.oauth.metadata";
|
|
1096
|
+
let url;
|
|
1097
|
+
try {
|
|
1098
|
+
url = new URL(value);
|
|
1099
|
+
}
|
|
1100
|
+
catch {
|
|
1101
|
+
fail(operation, "redirect_uris entries must be URLs", "invalid_config");
|
|
1102
|
+
}
|
|
1103
|
+
if (url.username || url.password || url.hash)
|
|
1104
|
+
fail(operation, "redirect_uris entries must not contain credentials or fragments", "invalid_config");
|
|
1105
|
+
if (url.protocol === "https:") {
|
|
1106
|
+
if (applicationType === "native" && url.origin !== clientId.origin)
|
|
1107
|
+
fail(operation, "Native HTTPS redirect URIs must share the client_id origin", "invalid_config");
|
|
1108
|
+
return value;
|
|
1109
|
+
}
|
|
1110
|
+
const scheme = clientId.hostname.split(".").reverse().join(".");
|
|
1111
|
+
if (applicationType !== "native" ||
|
|
1112
|
+
url.protocol !== `${scheme}:` ||
|
|
1113
|
+
!value.startsWith(`${scheme}:/`) ||
|
|
1114
|
+
value.startsWith(`${scheme}://`))
|
|
1115
|
+
fail(operation, "Web redirect URIs must use HTTPS; native custom schemes must be the reversed client_id hostname followed by :/", "invalid_config");
|
|
1116
|
+
return value;
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Check one `jwks` entry and copy only its public members. Client authentication
|
|
1120
|
+
* uses ES256, so only EC P-256 public keys are accepted. Any private or symmetric
|
|
1121
|
+
* member, including an `oct` key's `k`, is rejected rather than stripped so that
|
|
1122
|
+
* a misplaced secret is noticed.
|
|
1123
|
+
*/
|
|
1124
|
+
/** Any non-array object is structurally a JWK, since every JWK member is optional. */
|
|
1125
|
+
function isJwkObject(value) {
|
|
1126
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1127
|
+
}
|
|
1128
|
+
function publishableJwk(value, operation) {
|
|
1129
|
+
if (!isJwkObject(value))
|
|
1130
|
+
fail(operation, "jwks entries must be JWK objects", "invalid_config");
|
|
1131
|
+
if (SECRET_JWK_MEMBERS.some((member) => member in value))
|
|
1132
|
+
fail(operation, "jwks must contain public keys only", "invalid_config");
|
|
1133
|
+
const { kty, crv, x, y, kid, alg, use } = value;
|
|
1134
|
+
if (kty !== "EC" ||
|
|
1135
|
+
crv !== "P-256" ||
|
|
1136
|
+
!isString(x) ||
|
|
1137
|
+
!isString(y) ||
|
|
1138
|
+
!P256_COORDINATE.test(x) ||
|
|
1139
|
+
!P256_COORDINATE.test(y))
|
|
1140
|
+
fail(operation, "jwks keys must be EC P-256 public keys", "invalid_config");
|
|
1141
|
+
if ((kid !== undefined && (!isString(kid) || kid.length === 0)) ||
|
|
1142
|
+
(alg !== undefined && alg !== "ES256") ||
|
|
1143
|
+
(use !== undefined && use !== "sig"))
|
|
1144
|
+
fail(operation, "jwks keys must be ES256 signing keys", "invalid_config");
|
|
1145
|
+
return {
|
|
1146
|
+
kty,
|
|
1147
|
+
crv,
|
|
1148
|
+
x,
|
|
1149
|
+
y,
|
|
1150
|
+
...definedFields({ kid, alg, use }),
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Build and validate the client metadata document to serve at `clientId`.
|
|
1155
|
+
* Adding `jwks` or `jwksUri` makes the client confidential (`private_key_jwt`).
|
|
1156
|
+
*/
|
|
1157
|
+
export function blueskyOAuthClientMetadata(input) {
|
|
1158
|
+
const operation = "bluesky.oauth.metadata";
|
|
1159
|
+
const clientId = metadataUrl(input.clientId, "client_id");
|
|
1160
|
+
if (clientId.port !== "" || clientId.pathname === "/")
|
|
1161
|
+
fail(operation, "client_id must be the full HTTPS URL of the metadata document, without a port", "invalid_config");
|
|
1162
|
+
const applicationType = input.applicationType ?? "web";
|
|
1163
|
+
if (input.redirectUris.length === 0)
|
|
1164
|
+
fail(operation, "At least one redirect URI is required", "invalid_config");
|
|
1165
|
+
const redirectUris = input.redirectUris.map((uri) => checkRedirect(uri, clientId, applicationType));
|
|
1166
|
+
const scope = checkScope(input.scope, operation);
|
|
1167
|
+
if (input.jwks !== undefined && input.jwksUri !== undefined)
|
|
1168
|
+
fail(operation, "Use jwks or jwks_uri, not both", "invalid_config");
|
|
1169
|
+
if (input.jwks !== undefined && input.jwks.keys.length === 0)
|
|
1170
|
+
fail(operation, "jwks must contain at least one public key", "invalid_config");
|
|
1171
|
+
const jwks = input.jwks?.keys.map((key) => publishableJwk(key, operation));
|
|
1172
|
+
if (input.clientUri !== undefined &&
|
|
1173
|
+
metadataUrl(input.clientUri, "client_uri").hostname !== clientId.hostname)
|
|
1174
|
+
fail(operation, "client_uri must share the client_id hostname", "invalid_config");
|
|
1175
|
+
for (const [field, value] of [
|
|
1176
|
+
["logo_uri", input.logoUri],
|
|
1177
|
+
["tos_uri", input.tosUri],
|
|
1178
|
+
["policy_uri", input.policyUri],
|
|
1179
|
+
])
|
|
1180
|
+
if (value !== undefined)
|
|
1181
|
+
metadataUrl(value, field);
|
|
1182
|
+
const jwksUri = input.jwksUri === undefined ? undefined : metadataUrl(input.jwksUri, "jwks_uri").toString();
|
|
1183
|
+
const confidential = input.jwks !== undefined || jwksUri !== undefined;
|
|
1184
|
+
const signingAlg = confidential ? "ES256" : undefined;
|
|
1185
|
+
return {
|
|
1186
|
+
client_id: input.clientId,
|
|
1187
|
+
application_type: applicationType,
|
|
1188
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1189
|
+
response_types: ["code"],
|
|
1190
|
+
scope,
|
|
1191
|
+
redirect_uris: redirectUris,
|
|
1192
|
+
dpop_bound_access_tokens: true,
|
|
1193
|
+
token_endpoint_auth_method: confidential ? "private_key_jwt" : "none",
|
|
1194
|
+
...definedFields({
|
|
1195
|
+
token_endpoint_auth_signing_alg: signingAlg,
|
|
1196
|
+
jwks_uri: jwksUri,
|
|
1197
|
+
jwks: jwks === undefined ? undefined : { keys: jwks },
|
|
1198
|
+
client_name: input.clientName,
|
|
1199
|
+
client_uri: input.clientUri,
|
|
1200
|
+
logo_uri: input.logoUri,
|
|
1201
|
+
tos_uri: input.tosUri,
|
|
1202
|
+
policy_uri: input.policyUri,
|
|
1203
|
+
}),
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Development client ID for a public client running on a loopback address. The
|
|
1208
|
+
* authorization server derives its metadata from these query parameters.
|
|
1209
|
+
*/
|
|
1210
|
+
export function blueskyLoopbackClientId(input = {}) {
|
|
1211
|
+
const operation = "bluesky.oauth.metadata";
|
|
1212
|
+
const url = new URL("http://localhost");
|
|
1213
|
+
for (const uri of input.redirectUris ?? []) {
|
|
1214
|
+
let redirect;
|
|
1215
|
+
try {
|
|
1216
|
+
redirect = new URL(uri);
|
|
1217
|
+
}
|
|
1218
|
+
catch {
|
|
1219
|
+
fail(operation, "Loopback redirect URIs must be URLs", "invalid_config");
|
|
1220
|
+
}
|
|
1221
|
+
if (redirect.protocol !== "http:" ||
|
|
1222
|
+
(redirect.hostname !== "127.0.0.1" && redirect.hostname !== "[::1]"))
|
|
1223
|
+
fail(operation, "Loopback redirect URIs must use http://127.0.0.1 or http://[::1]", "invalid_config");
|
|
1224
|
+
url.searchParams.append("redirect_uri", uri);
|
|
1225
|
+
}
|
|
1226
|
+
if (input.scope !== undefined)
|
|
1227
|
+
url.searchParams.set("scope", checkScope(input.scope, operation));
|
|
1228
|
+
return url.toString();
|
|
1229
|
+
}
|