@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
|
@@ -15,6 +15,10 @@ export interface ConnectionAttempt {
|
|
|
15
15
|
}
|
|
16
16
|
export interface ConnectionStart {
|
|
17
17
|
readonly authorizationUrl: string;
|
|
18
|
+
/**
|
|
19
|
+
* Public attempt fields. The PKCE verifier always stays in the store, and so
|
|
20
|
+
* does `providerState` when the provider marks it secret.
|
|
21
|
+
*/
|
|
18
22
|
readonly attempt: Omit<ConnectionAttempt, "state" | "codeVerifier"> & {
|
|
19
23
|
readonly state: string;
|
|
20
24
|
};
|
|
@@ -30,9 +34,17 @@ export interface ConnectionProvider {
|
|
|
30
34
|
readonly redirectUri: string;
|
|
31
35
|
readonly state: string;
|
|
32
36
|
readonly codeChallenge: string;
|
|
37
|
+
/** Account or server hint typed by the user, such as a Bluesky handle. */
|
|
38
|
+
readonly loginHint?: string;
|
|
33
39
|
}): Promise<{
|
|
34
40
|
readonly authorizationUrl: string;
|
|
35
41
|
readonly providerState?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Set when `providerState` holds secrets, such as a DPoP private key. The
|
|
44
|
+
* manager then keeps it in the store and leaves it out of the attempt
|
|
45
|
+
* returned by `begin`.
|
|
46
|
+
*/
|
|
47
|
+
readonly providerStateSecret?: boolean;
|
|
36
48
|
}>;
|
|
37
49
|
complete(input: {
|
|
38
50
|
readonly callbackUrl: string;
|
|
@@ -85,6 +97,8 @@ export declare class ConnectionManager {
|
|
|
85
97
|
readonly redirectUri: string;
|
|
86
98
|
readonly allowedRedirectUris: readonly string[];
|
|
87
99
|
readonly provider: ConnectionProvider;
|
|
100
|
+
/** Passed to the provider unchanged. Bluesky uses it for a handle, DID, or server URL. */
|
|
101
|
+
readonly loginHint?: string;
|
|
88
102
|
}): Promise<ConnectionStart>;
|
|
89
103
|
discover(input: {
|
|
90
104
|
readonly attemptId: string;
|
|
@@ -103,13 +103,14 @@ export class ConnectionManager {
|
|
|
103
103
|
const codeVerifier = base64Url(this.#options.randomBytes(48));
|
|
104
104
|
const attemptId = base64Url(this.#options.randomBytes(18));
|
|
105
105
|
const expiresAt = new Date(now.getTime() + this.#options.ttlMs).toISOString();
|
|
106
|
-
const
|
|
106
|
+
const startInput = {
|
|
107
107
|
platforms: input.platforms,
|
|
108
108
|
capabilities: input.capabilities ?? [],
|
|
109
109
|
redirectUri: input.redirectUri,
|
|
110
110
|
state,
|
|
111
111
|
codeChallenge: await challenge(codeVerifier),
|
|
112
|
-
}
|
|
112
|
+
};
|
|
113
|
+
const started = await input.provider.start(input.loginHint === undefined ? startInput : { ...startInput, loginHint: input.loginHint });
|
|
113
114
|
const attemptBase = {
|
|
114
115
|
id: attemptId,
|
|
115
116
|
backend: input.backend,
|
|
@@ -128,6 +129,13 @@ export class ConnectionManager {
|
|
|
128
129
|
: { ...attemptBase, providerState: started.providerState };
|
|
129
130
|
await this.#options.store.save(attempt);
|
|
130
131
|
const { state: publicState, codeVerifier: _privateVerifier, ...publicAttempt } = attempt;
|
|
132
|
+
if (started.providerStateSecret === true) {
|
|
133
|
+
const { providerState: _secretProviderState, ...withoutProviderState } = publicAttempt;
|
|
134
|
+
return {
|
|
135
|
+
authorizationUrl: started.authorizationUrl,
|
|
136
|
+
attempt: { ...withoutProviderState, state: publicState },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
131
139
|
return {
|
|
132
140
|
authorizationUrl: started.authorizationUrl,
|
|
133
141
|
attempt: { ...publicAttempt, state: publicState },
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static egress checks for URLs that come from untrusted identity documents and
|
|
3
|
+
* server metadata. They only look at the URL itself: the scheme, `localhost`
|
|
4
|
+
* names, and IP-literal hosts. A hostname that resolves through DNS to a private
|
|
5
|
+
* address is not caught here, because `fetch` gives no portable way to see or pin
|
|
6
|
+
* the resolved address across Node.js, Bun, and edge runtimes. Server callers
|
|
7
|
+
* that need that guarantee inject a `fetch` that pins DNS results.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Return why a URL must not be fetched, or `undefined` when the static checks
|
|
11
|
+
* pass. Only HTTPS is allowed, `localhost` names are rejected, and IP-literal
|
|
12
|
+
* hosts must be public unicast addresses.
|
|
13
|
+
*/
|
|
14
|
+
export declare function egressBlockReason(url: URL): string | undefined;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static egress checks for URLs that come from untrusted identity documents and
|
|
3
|
+
* server metadata. They only look at the URL itself: the scheme, `localhost`
|
|
4
|
+
* names, and IP-literal hosts. A hostname that resolves through DNS to a private
|
|
5
|
+
* address is not caught here, because `fetch` gives no portable way to see or pin
|
|
6
|
+
* the resolved address across Node.js, Bun, and edge runtimes. Server callers
|
|
7
|
+
* that need that guarantee inject a `fetch` that pins DNS results.
|
|
8
|
+
*/
|
|
9
|
+
/** IPv4 ranges that are never valid public destinations (RFC 6890 and successors). */
|
|
10
|
+
const BLOCKED_V4 = [
|
|
11
|
+
[0x00000000, 8], // 0.0.0.0/8 "this network"
|
|
12
|
+
[0x0a000000, 8], // 10.0.0.0/8 private
|
|
13
|
+
[0x64400000, 10], // 100.64.0.0/10 carrier-grade NAT
|
|
14
|
+
[0x7f000000, 8], // 127.0.0.0/8 loopback
|
|
15
|
+
[0xa9fe0000, 16], // 169.254.0.0/16 link-local
|
|
16
|
+
[0xac100000, 12], // 172.16.0.0/12 private
|
|
17
|
+
[0xc0000000, 24], // 192.0.0.0/24 IETF protocol assignments
|
|
18
|
+
[0xc0000200, 24], // 192.0.2.0/24 documentation
|
|
19
|
+
[0xc0586300, 24], // 192.88.99.0/24 6to4 relay anycast
|
|
20
|
+
[0xc0a80000, 16], // 192.168.0.0/16 private
|
|
21
|
+
[0xc6120000, 15], // 198.18.0.0/15 benchmarking
|
|
22
|
+
[0xc6336400, 24], // 198.51.100.0/24 documentation
|
|
23
|
+
[0xcb007100, 24], // 203.0.113.0/24 documentation
|
|
24
|
+
[0xe0000000, 4], // 224.0.0.0/4 multicast
|
|
25
|
+
[0xf0000000, 4], // 240.0.0.0/4 reserved and broadcast
|
|
26
|
+
];
|
|
27
|
+
const V4_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
28
|
+
function parseV4(host) {
|
|
29
|
+
const match = V4_PATTERN.exec(host);
|
|
30
|
+
if (match === null)
|
|
31
|
+
return undefined;
|
|
32
|
+
let value = 0;
|
|
33
|
+
for (const part of match.slice(1)) {
|
|
34
|
+
const octet = Number(part);
|
|
35
|
+
if (octet > 255)
|
|
36
|
+
return undefined;
|
|
37
|
+
value = value * 256 + octet;
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function blockedV4(address) {
|
|
42
|
+
return BLOCKED_V4.some(([base, bits]) => {
|
|
43
|
+
const size = 2 ** (32 - bits);
|
|
44
|
+
return address >= base && address < base + size;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Expand an IPv6 literal (without brackets) into eight hextets. The WHATWG URL
|
|
49
|
+
* parser always serializes IPv6 hosts in compressed hexadecimal form, so a
|
|
50
|
+
* dotted IPv4 tail never reaches this function; anything unexpected is rejected.
|
|
51
|
+
*/
|
|
52
|
+
function parseV6(host) {
|
|
53
|
+
const halves = host.split("::");
|
|
54
|
+
if (halves.length > 2)
|
|
55
|
+
return undefined;
|
|
56
|
+
const parse = (part) => {
|
|
57
|
+
if (part === undefined || part === "")
|
|
58
|
+
return [];
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const group of part.split(":")) {
|
|
61
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group))
|
|
62
|
+
return undefined;
|
|
63
|
+
out.push(Number.parseInt(group, 16));
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
};
|
|
67
|
+
const head = parse(halves[0]);
|
|
68
|
+
const rest = halves.length === 2 ? parse(halves[1]) : [];
|
|
69
|
+
if (head === undefined || rest === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
const explicit = head.length + rest.length;
|
|
72
|
+
if (halves.length === 1 ? explicit !== 8 : explicit > 7)
|
|
73
|
+
return undefined;
|
|
74
|
+
return [...head, ...Array(8 - explicit).fill(0), ...rest];
|
|
75
|
+
}
|
|
76
|
+
function blockedV6(groups) {
|
|
77
|
+
const [a = 0, b = 0, c = 0, d = 0, e = 0, f = 0, g = 0, h = 0] = groups;
|
|
78
|
+
// IPv4-mapped (::ffff:0:0/96) and NAT64 (64:ff9b::/96): judge the embedded IPv4 address.
|
|
79
|
+
if ((a === 0 && b === 0 && c === 0 && d === 0 && e === 0 && f === 0xffff) ||
|
|
80
|
+
(a === 0x64 && b === 0xff9b && c === 0 && d === 0 && e === 0 && f === 0))
|
|
81
|
+
return blockedV4(g * 0x10000 + h);
|
|
82
|
+
// Only global unicast (2000::/3) can be public. This rejects ::, ::1, fc00::/7,
|
|
83
|
+
// fe80::/10, fec0::/10, ff00::/8, and IPv4-compatible addresses.
|
|
84
|
+
if ((a & 0xe000) !== 0x2000)
|
|
85
|
+
return true;
|
|
86
|
+
return ((a === 0x2001 && b < 0x0200) || // 2001::/23 IETF protocol assignments, including Teredo
|
|
87
|
+
(a === 0x2001 && b === 0x0db8) || // 2001:db8::/32 documentation
|
|
88
|
+
a === 0x2002 || // 2002::/16 6to4, which embeds an arbitrary IPv4 address
|
|
89
|
+
(a === 0x3fff && b < 0x1000) // 3fff::/20 documentation
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Return why a URL must not be fetched, or `undefined` when the static checks
|
|
94
|
+
* pass. Only HTTPS is allowed, `localhost` names are rejected, and IP-literal
|
|
95
|
+
* hosts must be public unicast addresses.
|
|
96
|
+
*/
|
|
97
|
+
export function egressBlockReason(url) {
|
|
98
|
+
if (url.protocol !== "https:")
|
|
99
|
+
return "only HTTPS URLs are allowed";
|
|
100
|
+
if (url.username || url.password)
|
|
101
|
+
return "URLs with credentials are not allowed";
|
|
102
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
103
|
+
if (host === "localhost" || host.endsWith(".localhost"))
|
|
104
|
+
return "localhost is not allowed";
|
|
105
|
+
if (host.startsWith("[") && host.endsWith("]")) {
|
|
106
|
+
const groups = parseV6(host.slice(1, -1));
|
|
107
|
+
return groups === undefined || blockedV6(groups)
|
|
108
|
+
? "non-public IP addresses are not allowed"
|
|
109
|
+
: undefined;
|
|
110
|
+
}
|
|
111
|
+
const v4 = parseV4(host);
|
|
112
|
+
if (v4 !== undefined && blockedV4(v4))
|
|
113
|
+
return "non-public IP addresses are not allowed";
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ConnectionAttempt } from "./connections.js";
|
|
2
|
+
export declare function readBounded(response: Response, maxBytes: number, operation: string): Promise<string>;
|
|
3
|
+
export declare function validateCallback(input: {
|
|
4
|
+
readonly callbackUrl: string;
|
|
5
|
+
readonly attempt: ConnectionAttempt;
|
|
6
|
+
}): URL;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { SocialError } from "../core/errors.js";
|
|
2
|
+
// Internal helpers shared by the direct OAuth providers. Not exported from the package.
|
|
3
|
+
function fail(operation, message, code) {
|
|
4
|
+
throw new SocialError({ code, operation, message });
|
|
5
|
+
}
|
|
6
|
+
export async function readBounded(response, maxBytes, operation) {
|
|
7
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
8
|
+
fail("oauth.config", "maxResponseBytes must be a positive safe integer", "invalid_input");
|
|
9
|
+
if (!response.body)
|
|
10
|
+
return "";
|
|
11
|
+
const reader = response.body.getReader();
|
|
12
|
+
const chunks = [];
|
|
13
|
+
let total = 0;
|
|
14
|
+
try {
|
|
15
|
+
for (;;) {
|
|
16
|
+
const part = await reader.read();
|
|
17
|
+
if (part.done)
|
|
18
|
+
break;
|
|
19
|
+
total += part.value.byteLength;
|
|
20
|
+
if (total > maxBytes) {
|
|
21
|
+
await reader.cancel();
|
|
22
|
+
fail(operation, "OAuth provider response exceeded the configured size limit", "upstream_failure");
|
|
23
|
+
}
|
|
24
|
+
chunks.push(part.value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
reader.releaseLock();
|
|
29
|
+
}
|
|
30
|
+
const merged = new Uint8Array(total);
|
|
31
|
+
let offset = 0;
|
|
32
|
+
for (const chunk of chunks) {
|
|
33
|
+
merged.set(chunk, offset);
|
|
34
|
+
offset += chunk.byteLength;
|
|
35
|
+
}
|
|
36
|
+
return new TextDecoder().decode(merged);
|
|
37
|
+
}
|
|
38
|
+
export function validateCallback(input) {
|
|
39
|
+
let callback;
|
|
40
|
+
try {
|
|
41
|
+
callback = new URL(input.callbackUrl);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
fail("connections.complete", "OAuth callback URL is invalid", "invalid_input");
|
|
45
|
+
}
|
|
46
|
+
let expected;
|
|
47
|
+
try {
|
|
48
|
+
expected = new URL(input.attempt.redirectUri);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
fail("connections.complete", "OAuth attempt redirect URI is invalid", "invalid_input");
|
|
52
|
+
}
|
|
53
|
+
if (callback.origin !== expected.origin ||
|
|
54
|
+
callback.pathname !== expected.pathname ||
|
|
55
|
+
callback.username ||
|
|
56
|
+
callback.password ||
|
|
57
|
+
callback.hash)
|
|
58
|
+
fail("connections.complete", "OAuth callback does not match the registered redirect", "unauthorized");
|
|
59
|
+
for (const [key, value] of expected.searchParams)
|
|
60
|
+
if (callback.searchParams.getAll(key).length !== 1 || callback.searchParams.get(key) !== value)
|
|
61
|
+
fail("connections.complete", "OAuth callback does not match the registered redirect", "unauthorized");
|
|
62
|
+
const states = callback.searchParams.getAll("state");
|
|
63
|
+
if (states.length !== 1 || states[0] !== input.attempt.state)
|
|
64
|
+
fail("connections.complete", "OAuth callback state did not match the authenticated attempt", "unauthorized");
|
|
65
|
+
return callback;
|
|
66
|
+
}
|
package/dist/server/oauth.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConnectionAccount, ConnectionAttempt, ConnectionProvider } from "./connections.js";
|
|
2
|
+
export * from "./bluesky-oauth.js";
|
|
2
3
|
export interface OAuthTokenSet {
|
|
3
4
|
readonly accessToken: string;
|
|
4
5
|
readonly refreshToken?: string;
|
|
@@ -49,4 +50,3 @@ export declare const tiktokOAuth: (options: OAuthProviderOptions) => ConnectionP
|
|
|
49
50
|
export declare const instagramOAuth: (options: OAuthProviderOptions) => ConnectionProvider;
|
|
50
51
|
export declare const linkedinOAuth: (options: OAuthProviderOptions) => ConnectionProvider;
|
|
51
52
|
export declare function refreshOAuthToken(kind: ProviderKind, options: OAuthProviderOptions, current: OAuthTokenSet): Promise<OAuthTokenSet>;
|
|
52
|
-
export {};
|
package/dist/server/oauth.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
/* oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type, anti-slop/require-safety-comment-for-type-assertion, anti-slop/require-readable-spacing -- OAuth responses are unknown by contract and validated at this boundary. */
|
|
2
1
|
import { SocialError } from "../core/errors.js";
|
|
3
|
-
import {
|
|
2
|
+
import { definedFields } from "../core/fields.js";
|
|
3
|
+
import { connectedAccountRef, } from "../core/types.js";
|
|
4
|
+
import { isJsonValue } from "../transport/json.js";
|
|
5
|
+
import { isFiniteNumber, isJsonObject, isString } from "../transport/validation.js";
|
|
6
|
+
import { readBounded, validateCallback } from "./oauth-internal.js";
|
|
7
|
+
export * from "./bluesky-oauth.js";
|
|
4
8
|
const configs = {
|
|
5
9
|
youtube: {
|
|
6
10
|
auth: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
@@ -63,17 +67,36 @@ function fail(operation, message, code = "upstream_failure", cause) {
|
|
|
63
67
|
throw new SocialError({ code, operation, message, cause });
|
|
64
68
|
}
|
|
65
69
|
function asRecord(value, operation = "oauth.response") {
|
|
66
|
-
if (
|
|
70
|
+
if (!isJsonObject(value))
|
|
67
71
|
fail(operation, "OAuth provider returned an invalid response");
|
|
68
72
|
return value;
|
|
69
73
|
}
|
|
70
74
|
function requiredString(value, field, operation = "oauth.response") {
|
|
71
|
-
if (
|
|
75
|
+
if (!isString(value) || value.length === 0 || value.length > 8192)
|
|
72
76
|
fail(operation, `OAuth provider response is missing ${field}`);
|
|
73
77
|
return value;
|
|
74
78
|
}
|
|
75
79
|
function optionalString(value) {
|
|
76
|
-
return
|
|
80
|
+
return isString(value) && value.length > 0 && value.length <= 8192 ? value : undefined;
|
|
81
|
+
}
|
|
82
|
+
/** Any string, including an empty one, otherwise the fallback. */
|
|
83
|
+
function stringOr(value, fallback) {
|
|
84
|
+
return isString(value) ? value : fallback;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Plain `JSON.parse`, checked against the JSON grammar. OAuth bodies keep native
|
|
88
|
+
* number parsing rather than the lossless integer handling in transport/json.
|
|
89
|
+
*/
|
|
90
|
+
function parseJsonText(raw) {
|
|
91
|
+
const parsed = JSON.parse(raw);
|
|
92
|
+
if (!isJsonValue(parsed))
|
|
93
|
+
throw new SyntaxError("Expected a JSON value");
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
function isAbortError(error) {
|
|
97
|
+
if (error instanceof DOMException)
|
|
98
|
+
return error.name === "AbortError";
|
|
99
|
+
return (typeof error === "object" && error !== null && "name" in error && error.name === "AbortError");
|
|
77
100
|
}
|
|
78
101
|
function errorForResponse(status, operation, providerCode) {
|
|
79
102
|
if (status === 400 && providerCode === "invalid_grant")
|
|
@@ -97,38 +120,6 @@ function errorForResponse(status, operation, providerCode) {
|
|
|
97
120
|
upstreamStatus: status,
|
|
98
121
|
});
|
|
99
122
|
}
|
|
100
|
-
async function readBounded(response, maxBytes, operation) {
|
|
101
|
-
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
102
|
-
fail("oauth.config", "maxResponseBytes must be a positive safe integer", "invalid_input");
|
|
103
|
-
if (!response.body)
|
|
104
|
-
return "";
|
|
105
|
-
const reader = response.body.getReader();
|
|
106
|
-
const chunks = [];
|
|
107
|
-
let total = 0;
|
|
108
|
-
try {
|
|
109
|
-
for (;;) {
|
|
110
|
-
const part = await reader.read();
|
|
111
|
-
if (part.done)
|
|
112
|
-
break;
|
|
113
|
-
total += part.value.byteLength;
|
|
114
|
-
if (total > maxBytes) {
|
|
115
|
-
await reader.cancel();
|
|
116
|
-
fail(operation, "OAuth provider response exceeded the configured size limit", "upstream_failure");
|
|
117
|
-
}
|
|
118
|
-
chunks.push(part.value);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
finally {
|
|
122
|
-
reader.releaseLock();
|
|
123
|
-
}
|
|
124
|
-
const merged = new Uint8Array(total);
|
|
125
|
-
let offset = 0;
|
|
126
|
-
for (const chunk of chunks) {
|
|
127
|
-
merged.set(chunk, offset);
|
|
128
|
-
offset += chunk.byteLength;
|
|
129
|
-
}
|
|
130
|
-
return new TextDecoder().decode(merged);
|
|
131
|
-
}
|
|
132
123
|
async function body(response, operation, maxBytes) {
|
|
133
124
|
const raw = await readBounded(response, maxBytes, operation);
|
|
134
125
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
@@ -136,11 +127,11 @@ async function body(response, operation, maxBytes) {
|
|
|
136
127
|
let providerCode;
|
|
137
128
|
try {
|
|
138
129
|
const parsed = contentType.includes("json") || raw.trimStart().startsWith("{")
|
|
139
|
-
?
|
|
130
|
+
? parseJsonText(raw)
|
|
140
131
|
: Object.fromEntries(new URLSearchParams(raw).entries());
|
|
141
|
-
if (
|
|
132
|
+
if (isJsonObject(parsed)) {
|
|
142
133
|
const error = parsed["error"];
|
|
143
|
-
providerCode =
|
|
134
|
+
providerCode = isString(error) ? error : undefined;
|
|
144
135
|
}
|
|
145
136
|
}
|
|
146
137
|
catch {
|
|
@@ -154,7 +145,7 @@ async function body(response, operation, maxBytes) {
|
|
|
154
145
|
contentType.includes("+json") ||
|
|
155
146
|
raw.trimStart().startsWith("{")) {
|
|
156
147
|
try {
|
|
157
|
-
return asRecord(
|
|
148
|
+
return asRecord(parseJsonText(raw), operation);
|
|
158
149
|
}
|
|
159
150
|
catch {
|
|
160
151
|
fail(operation, "OAuth provider returned malformed JSON");
|
|
@@ -177,9 +168,9 @@ function tokenSet(data, operation = "oauth.token") {
|
|
|
177
168
|
const expires = data["expires_in"];
|
|
178
169
|
let expiresAt;
|
|
179
170
|
if (expires !== undefined) {
|
|
180
|
-
const seconds =
|
|
171
|
+
const seconds = isFiniteNumber(expires)
|
|
181
172
|
? expires
|
|
182
|
-
:
|
|
173
|
+
: isString(expires) && /^\d+(?:\.\d+)?$/.test(expires)
|
|
183
174
|
? Number(expires)
|
|
184
175
|
: Number.NaN;
|
|
185
176
|
if (!Number.isFinite(seconds) || seconds < 0 || seconds > 31_536_000_000)
|
|
@@ -189,17 +180,7 @@ function tokenSet(data, operation = "oauth.token") {
|
|
|
189
180
|
const refreshToken = optionalString(data["refresh_token"]);
|
|
190
181
|
const scopes = parseScopes(data["scope"]);
|
|
191
182
|
const tokenType = optionalString(data["token_type"]);
|
|
192
|
-
|
|
193
|
-
const result = { accessToken };
|
|
194
|
-
if (refreshToken !== undefined)
|
|
195
|
-
result.refreshToken = refreshToken;
|
|
196
|
-
if (expiresAt !== undefined)
|
|
197
|
-
result.expiresAt = expiresAt;
|
|
198
|
-
if (scopes !== undefined)
|
|
199
|
-
result.scopes = scopes;
|
|
200
|
-
if (tokenType !== undefined)
|
|
201
|
-
result.tokenType = tokenType;
|
|
202
|
-
return result;
|
|
183
|
+
return { accessToken, ...definedFields({ refreshToken, expiresAt, scopes, tokenType }) };
|
|
203
184
|
}
|
|
204
185
|
function tokenResult(data, kind) {
|
|
205
186
|
let nested = data;
|
|
@@ -238,11 +219,7 @@ async function request(fetcher, url, init, operation, options) {
|
|
|
238
219
|
catch (error) {
|
|
239
220
|
if (error instanceof SocialError)
|
|
240
221
|
throw error;
|
|
241
|
-
if ((error
|
|
242
|
-
(typeof error === "object" &&
|
|
243
|
-
error !== null &&
|
|
244
|
-
"name" in error &&
|
|
245
|
-
error.name === "AbortError"))
|
|
222
|
+
if (isAbortError(error))
|
|
246
223
|
fail(operation, "OAuth provider request timed out", "timeout");
|
|
247
224
|
fail(operation, "OAuth provider request failed", "upstream_failure", error);
|
|
248
225
|
}
|
|
@@ -261,35 +238,6 @@ function validateLinkedInVersion(value) {
|
|
|
261
238
|
fail("oauth.config", "LinkedIn OAuth requires an explicit YYYYMM API version", "invalid_input");
|
|
262
239
|
return value;
|
|
263
240
|
}
|
|
264
|
-
function validateCallback(input) {
|
|
265
|
-
let callback;
|
|
266
|
-
try {
|
|
267
|
-
callback = new URL(input.callbackUrl);
|
|
268
|
-
}
|
|
269
|
-
catch {
|
|
270
|
-
fail("connections.complete", "OAuth callback URL is invalid", "invalid_input");
|
|
271
|
-
}
|
|
272
|
-
let expected;
|
|
273
|
-
try {
|
|
274
|
-
expected = new URL(input.attempt.redirectUri);
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
fail("connections.complete", "OAuth attempt redirect URI is invalid", "invalid_input");
|
|
278
|
-
}
|
|
279
|
-
if (callback.origin !== expected.origin ||
|
|
280
|
-
callback.pathname !== expected.pathname ||
|
|
281
|
-
callback.username ||
|
|
282
|
-
callback.password ||
|
|
283
|
-
callback.hash)
|
|
284
|
-
fail("connections.complete", "OAuth callback does not match the registered redirect", "unauthorized");
|
|
285
|
-
for (const [key, value] of expected.searchParams)
|
|
286
|
-
if (callback.searchParams.getAll(key).length !== 1 || callback.searchParams.get(key) !== value)
|
|
287
|
-
fail("connections.complete", "OAuth callback does not match the registered redirect", "unauthorized");
|
|
288
|
-
const states = callback.searchParams.getAll("state");
|
|
289
|
-
if (states.length !== 1 || states[0] !== input.attempt.state)
|
|
290
|
-
fail("connections.complete", "OAuth callback state did not match the authenticated attempt", "unauthorized");
|
|
291
|
-
return callback;
|
|
292
|
-
}
|
|
293
241
|
function providerIdentity(kind, hint, discovered) {
|
|
294
242
|
if (hint === undefined)
|
|
295
243
|
return;
|
|
@@ -416,14 +364,14 @@ async function discover(kind, token, hint, backend, fetcher, options) {
|
|
|
416
364
|
return items.map((item) => {
|
|
417
365
|
const row = asRecord(item, "youtube.account");
|
|
418
366
|
const snippet = asRecord(row["snippet"] ?? {}, "youtube.account");
|
|
419
|
-
return account("youtube", backend, requiredString(row["id"], "channel id", "youtube.account"),
|
|
367
|
+
return account("youtube", backend, requiredString(row["id"], "channel id", "youtube.account"), stringOr(snippet["title"], "YouTube channel"));
|
|
420
368
|
});
|
|
421
369
|
}
|
|
422
370
|
if (kind === "x") {
|
|
423
371
|
const data = await request(fetcher, "https://api.x.com/2/users/me", { headers: auth }, "x.account", options);
|
|
424
372
|
const row = asRecord(data["data"], "x.account");
|
|
425
373
|
return [
|
|
426
|
-
account("x", backend, requiredString(row["id"], "user id", "x.account"),
|
|
374
|
+
account("x", backend, requiredString(row["id"], "user id", "x.account"), stringOr(row["name"], "X account")),
|
|
427
375
|
];
|
|
428
376
|
}
|
|
429
377
|
if (kind === "threads") {
|
|
@@ -432,7 +380,7 @@ async function discover(kind, token, hint, backend, fetcher, options) {
|
|
|
432
380
|
fail("oauth.config", "Threads API version is invalid", "invalid_input");
|
|
433
381
|
const data = await request(fetcher, `https://graph.threads.net/${version}/me?fields=id,username`, { headers: auth }, "threads.account", options);
|
|
434
382
|
return [
|
|
435
|
-
account("threads", backend, requiredString(data["id"], "user id", "threads.account"),
|
|
383
|
+
account("threads", backend, requiredString(data["id"], "user id", "threads.account"), stringOr(data["username"], "Threads account")),
|
|
436
384
|
];
|
|
437
385
|
}
|
|
438
386
|
if (kind === "tiktok") {
|
|
@@ -440,7 +388,7 @@ async function discover(kind, token, hint, backend, fetcher, options) {
|
|
|
440
388
|
const row = asRecord(data["data"], "tiktok.account");
|
|
441
389
|
const user = asRecord(row["user"], "tiktok.account");
|
|
442
390
|
return [
|
|
443
|
-
account("tiktok", backend, requiredString(user["open_id"], "open_id", "tiktok.account"),
|
|
391
|
+
account("tiktok", backend, requiredString(user["open_id"], "open_id", "tiktok.account"), stringOr(user["display_name"], "TikTok account")),
|
|
444
392
|
];
|
|
445
393
|
}
|
|
446
394
|
if (kind === "instagram") {
|
|
@@ -454,13 +402,13 @@ async function discover(kind, token, hint, backend, fetcher, options) {
|
|
|
454
402
|
? hint
|
|
455
403
|
: discoveredId;
|
|
456
404
|
return [
|
|
457
|
-
account("instagram", backend, discoveredAccountId,
|
|
405
|
+
account("instagram", backend, discoveredAccountId, stringOr(data["username"], "Instagram account")),
|
|
458
406
|
];
|
|
459
407
|
}
|
|
460
408
|
const version = validateLinkedInVersion(options.linkedinApiVersion);
|
|
461
409
|
const data = await request(fetcher, "https://api.linkedin.com/v2/userinfo", { headers: { ...auth, "LinkedIn-Version": version } }, "linkedin.account", options);
|
|
462
410
|
const subject = requiredString(data["sub"], "member id", "linkedin.account");
|
|
463
|
-
const member = account("linkedin", backend, `urn:li:person:${subject}`,
|
|
411
|
+
const member = account("linkedin", backend, `urn:li:person:${subject}`, stringOr(data["name"], "LinkedIn member"));
|
|
464
412
|
let acl;
|
|
465
413
|
try {
|
|
466
414
|
acl = await request(fetcher, "https://api.linkedin.com/rest/organizationAcls?q=roleAssignee", { headers: { ...auth, "LinkedIn-Version": version } }, "linkedin.organizations", options);
|
|
@@ -475,11 +423,10 @@ async function discover(kind, token, hint, backend, fetcher, options) {
|
|
|
475
423
|
const elements = Array.isArray(acl["elements"]) ? acl["elements"] : [];
|
|
476
424
|
const organizations = elements.flatMap((entry) => {
|
|
477
425
|
const row = asRecord(entry, "linkedin.organizations");
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
: "";
|
|
426
|
+
const organizationTarget = row["organizationTarget"];
|
|
427
|
+
const target = isString(organizationTarget)
|
|
428
|
+
? organizationTarget
|
|
429
|
+
: stringOr(row["organizationalTarget"], "");
|
|
483
430
|
const role = row["role"];
|
|
484
431
|
if ((role !== "ADMINISTRATOR" &&
|
|
485
432
|
role !== "CONTENT_ADMINISTRATOR" &&
|