@opengeni/api-router 0.14.4 → 0.15.4

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.
@@ -0,0 +1,745 @@
1
+ import { parseSocialOauthClientsJson, type Settings } from "@opengeni/config";
2
+ import {
3
+ OAuthStartResponse,
4
+ type SocialConnection,
5
+ type SocialOAuthProviderId,
6
+ type SocialOAuthStartRequest,
7
+ } from "@opengeni/contracts";
8
+ import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
9
+ import type { Observability } from "@opengeni/observability";
10
+ import {
11
+ consumeIntegrationOAuthStateNonce,
12
+ decryptEnvironmentValue,
13
+ encryptEnvironmentValue,
14
+ getWorkspaceGrant,
15
+ loadSocialConnectionCredential,
16
+ updateSocialConnectionCredential,
17
+ upsertSocialOAuthConnection,
18
+ type Database,
19
+ } from "@opengeni/db";
20
+ import { createSignedState, readSignedState } from "@opengeni/github";
21
+ import { OAUTH_MAX_RESPONSE_BYTES, pinnedFetch, readResponseJsonBounded } from "@opengeni/network";
22
+ import { Buffer } from "node:buffer";
23
+ import { createHash, randomBytes } from "node:crypto";
24
+ import { HTTPException } from "hono/http-exception";
25
+ import {
26
+ integrationBaseUrl,
27
+ oauthStateTtlMs,
28
+ requireIntegrationsStateSecret,
29
+ } from "./oauth-client";
30
+
31
+ // Reddit requires a descriptive, stable User-Agent on every request (token
32
+ // endpoint included) and throttles generic ones; X tolerates any.
33
+ export const SOCIAL_USER_AGENT = "opengeni:social-connector:v0.1.0 (self-hosted)";
34
+
35
+ // Same bound as the Slack connector: a hung provider socket must not stall an
36
+ // agent turn indefinitely.
37
+ export const SOCIAL_TIMEOUT_MS = 10_000;
38
+
39
+ /** Token-endpoint failure that carries enough to tell invalid_grant from a blip. */
40
+ export class SocialTokenRequestError extends Error {
41
+ constructor(
42
+ message: string,
43
+ readonly status: number | null,
44
+ readonly oauthError: string | null,
45
+ ) {
46
+ super(message);
47
+ this.name = "SocialTokenRequestError";
48
+ }
49
+
50
+ /** True only for definitive authorization-server rejections of the grant. */
51
+ get definitive(): boolean {
52
+ // Reddit reports invalid_grant with HTTP 200; X uses 400/401.
53
+ return this.status === 400 || this.status === 401 || this.oauthError === "invalid_grant";
54
+ }
55
+ }
56
+
57
+ type SocialProviderDefinition = {
58
+ id: SocialOAuthProviderId;
59
+ authorizationEndpoint: string;
60
+ tokenEndpoint: string;
61
+ defaultScopes: string[];
62
+ // X mandates PKCE S256; Reddit's authorization server does not support it,
63
+ // so Reddit relies on the signed single-use state alone.
64
+ pkce: boolean;
65
+ extraAuthorizeParams: Record<string, string>;
66
+ };
67
+
68
+ export const SOCIAL_OAUTH_PROVIDERS: Record<SocialOAuthProviderId, SocialProviderDefinition> = {
69
+ x: {
70
+ id: "x",
71
+ authorizationEndpoint: "https://x.com/i/oauth2/authorize",
72
+ tokenEndpoint: "https://api.x.com/2/oauth2/token",
73
+ defaultScopes: ["tweet.read", "tweet.write", "users.read", "offline.access"],
74
+ pkce: true,
75
+ extraAuthorizeParams: {},
76
+ },
77
+ reddit: {
78
+ id: "reddit",
79
+ authorizationEndpoint: "https://www.reddit.com/api/v1/authorize",
80
+ tokenEndpoint: "https://www.reddit.com/api/v1/access_token",
81
+ defaultScopes: ["identity", "read", "submit", "privatemessages", "history"],
82
+ pkce: false,
83
+ // permanent => Reddit issues a refresh_token instead of a 1h-only grant.
84
+ extraAuthorizeParams: { duration: "permanent" },
85
+ },
86
+ };
87
+
88
+ export type SocialCredentialBundle = {
89
+ provider: SocialOAuthProviderId;
90
+ accessToken: string;
91
+ refreshToken?: string;
92
+ tokenType: string;
93
+ expiresAt?: string;
94
+ scope?: string;
95
+ };
96
+
97
+ /**
98
+ * Provider-transport seam (Slack-connector pattern): production always goes
99
+ * through pinnedFetch; tests inject an in-process provider to exercise the
100
+ * full callback/refresh/tool loop functionally.
101
+ */
102
+ export type SocialProviderFetch = (
103
+ url: string,
104
+ init: RequestInit,
105
+ label: string,
106
+ ) => Promise<Response>;
107
+
108
+ type SocialOAuthDeps = {
109
+ db: Database;
110
+ settings: Settings;
111
+ observability?: Observability | undefined;
112
+ providerFetch?: SocialProviderFetch | undefined;
113
+ };
114
+
115
+ function socialProviderFetch(
116
+ deps: Pick<SocialOAuthDeps, "settings" | "providerFetch">,
117
+ url: string,
118
+ init: RequestInit,
119
+ label: string,
120
+ ): Promise<Response> {
121
+ if (deps.providerFetch) {
122
+ return deps.providerFetch(url, init, label);
123
+ }
124
+ return pinnedFetch(url, init, deps.settings, {
125
+ label,
126
+ requireHttpsOutsideLocalTest: true,
127
+ });
128
+ }
129
+
130
+ export type SocialOAuthStartContext = {
131
+ accountId: string;
132
+ workspaceId: string;
133
+ subjectId: string;
134
+ requestUrl: string;
135
+ payload: SocialOAuthStartRequest;
136
+ };
137
+
138
+ type SocialOAuthStatePayload = {
139
+ kind: "social_oauth";
140
+ accountId: string;
141
+ workspaceId: string;
142
+ subjectId: string;
143
+ provider: SocialOAuthProviderId;
144
+ scopes: string[];
145
+ encryptedPkceVerifier?: string;
146
+ returnPath: string;
147
+ nonce: string;
148
+ iat: number;
149
+ };
150
+
151
+ export function socialOAuthClientFor(
152
+ settings: Settings,
153
+ provider: SocialOAuthProviderId,
154
+ ): { clientId: string; clientSecret?: string | undefined } {
155
+ const configured = parseSocialOauthClientsJson(settings.socialOauthClientsJson)[provider];
156
+ if (!configured) {
157
+ throw new HTTPException(503, {
158
+ message: `social provider ${provider} requires an operator OAuth app in OPENGENI_SOCIAL_OAUTH_CLIENTS_JSON`,
159
+ });
160
+ }
161
+ return configured;
162
+ }
163
+
164
+ export function socialOAuthRedirectUri(settings: Settings, requestUrl: string): string {
165
+ return `${integrationBaseUrl(settings.publicBaseUrl, requestUrl)}/v1/social/oauth/callback`;
166
+ }
167
+
168
+ export async function startSocialOAuth(
169
+ deps: SocialOAuthDeps,
170
+ context: SocialOAuthStartContext,
171
+ ): Promise<OAuthStartResponse> {
172
+ const { settings } = deps;
173
+ const provider = SOCIAL_OAUTH_PROVIDERS[context.payload.provider];
174
+ const client = socialOAuthClientFor(settings, provider.id);
175
+ const redirectUri = socialOAuthRedirectUri(settings, context.requestUrl);
176
+ const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
177
+ const scopes = uniqueScopes(context.payload.scopes) ?? provider.defaultScopes;
178
+ const verifier = provider.pkce ? randomBytes(32).toString("base64url") : null;
179
+ // Require the encryption key for every provider (not just PKCE ones) so a
180
+ // misconfigured deployment fails here with 503 instead of after the user
181
+ // has already consented at the provider.
182
+ const key = requireEnvironmentEncryption(settings);
183
+ const state = createSignedState(requireIntegrationsStateSecret(settings), {
184
+ kind: "social_oauth",
185
+ accountId: context.accountId,
186
+ workspaceId: context.workspaceId,
187
+ subjectId: context.subjectId,
188
+ provider: provider.id,
189
+ scopes,
190
+ ...(verifier && key ? { encryptedPkceVerifier: encryptEnvironmentValue(key, verifier) } : {}),
191
+ returnPath,
192
+ });
193
+ const url = new URL(provider.authorizationEndpoint);
194
+ url.searchParams.set("response_type", "code");
195
+ url.searchParams.set("client_id", client.clientId);
196
+ url.searchParams.set("redirect_uri", redirectUri);
197
+ url.searchParams.set("state", state);
198
+ url.searchParams.set("scope", scopes.join(" "));
199
+ for (const [param, value] of Object.entries(provider.extraAuthorizeParams)) {
200
+ url.searchParams.set(param, value);
201
+ }
202
+ if (verifier) {
203
+ url.searchParams.set("code_challenge_method", "S256");
204
+ url.searchParams.set(
205
+ "code_challenge",
206
+ createHash("sha256").update(verifier).digest("base64url"),
207
+ );
208
+ }
209
+ return OAuthStartResponse.parse({
210
+ state,
211
+ authorizationUrl: url.toString(),
212
+ expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
213
+ });
214
+ }
215
+
216
+ export async function completeSocialOAuthCallback(
217
+ deps: SocialOAuthDeps,
218
+ input: {
219
+ code?: string | undefined;
220
+ state?: string | undefined;
221
+ error?: string | undefined;
222
+ requestUrl: string;
223
+ },
224
+ ): Promise<{ redirectTo: string }> {
225
+ const { db, settings, observability } = deps;
226
+ let state: SocialOAuthStatePayload | null = null;
227
+ try {
228
+ state = readSocialOAuthState(input.state, settings);
229
+ } catch (error) {
230
+ logSocialOAuthFailure(observability, "state_verify", state, error);
231
+ return {
232
+ redirectTo: callbackReturnPath("/integrations", "error", { reason: "state_invalid" }),
233
+ };
234
+ }
235
+ // Provider denial / missing code: report before burning the single-use
236
+ // nonce so a user who cancelled at the provider can retry the same flow
237
+ // within the state TTL.
238
+ if (input.error || !input.code) {
239
+ return {
240
+ redirectTo: callbackReturnPath(state.returnPath, "error", {
241
+ // The error param arrives on an unauthenticated request; bound it to
242
+ // known OAuth error-code shape before reflecting it anywhere.
243
+ reason: input.error ? (boundedErrorCode(input.error) ?? "provider_error") : "missing_code",
244
+ }),
245
+ };
246
+ }
247
+ try {
248
+ const consumed = await consumeIntegrationOAuthStateNonce(db, {
249
+ accountId: state.accountId,
250
+ workspaceId: state.workspaceId,
251
+ subjectId: state.subjectId,
252
+ nonce: state.nonce,
253
+ expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
254
+ now: new Date(),
255
+ });
256
+ if (!consumed) {
257
+ throw new HTTPException(400, { message: "OAuth state has already been used" });
258
+ }
259
+ } catch (error) {
260
+ logSocialOAuthFailure(observability, "state_verify", state, error);
261
+ return {
262
+ redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "state_invalid" }),
263
+ };
264
+ }
265
+ // The signed state proves who STARTED the flow; re-check that the subject
266
+ // still holds the admin grant now, mirroring requireOAuthCallbackGrant in
267
+ // the MCP OAuth client — a grant revoked inside the state TTL must not be
268
+ // able to land a workspace credential.
269
+ try {
270
+ const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
271
+ if (
272
+ !grant ||
273
+ grant.accountId !== state.accountId ||
274
+ !hasPermission(grant.permissions, "workspace:admin")
275
+ ) {
276
+ throw new HTTPException(403, {
277
+ message: "OAuth subject no longer has permission to connect social accounts",
278
+ });
279
+ }
280
+ } catch (error) {
281
+ logSocialOAuthFailure(observability, "grant_recheck", state, error);
282
+ return {
283
+ redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "not_authorized" }),
284
+ };
285
+ }
286
+ try {
287
+ const provider = SOCIAL_OAUTH_PROVIDERS[state.provider];
288
+ const client = socialOAuthClientFor(settings, provider.id);
289
+ const key = requireEnvironmentEncryption(settings);
290
+ const verifier = state.encryptedPkceVerifier
291
+ ? decryptEnvironmentValue(key, state.encryptedPkceVerifier)
292
+ : null;
293
+ const token = await socialTokenRequest(deps, provider, client, {
294
+ grant_type: "authorization_code",
295
+ code: input.code,
296
+ redirect_uri: socialOAuthRedirectUri(settings, input.requestUrl),
297
+ ...(verifier ? { code_verifier: verifier } : {}),
298
+ });
299
+ const identity = await fetchSocialIdentity(deps, provider.id, token.accessToken);
300
+ const bundle: SocialCredentialBundle = {
301
+ provider: provider.id,
302
+ accessToken: token.accessToken,
303
+ tokenType: token.tokenType,
304
+ ...(token.refreshToken ? { refreshToken: token.refreshToken } : {}),
305
+ ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}),
306
+ ...(token.scope ? { scope: token.scope } : {}),
307
+ };
308
+ const grantedScopes = token.scope ? token.scope.split(/[\s,]+/).filter(Boolean) : state.scopes;
309
+ const connection = await upsertSocialOAuthConnection(db, {
310
+ accountId: state.accountId,
311
+ workspaceId: state.workspaceId,
312
+ provider: provider.id,
313
+ accountHandle: identity.handle,
314
+ accountName: identity.name ?? null,
315
+ externalAccountId: identity.externalAccountId,
316
+ scopes: grantedScopes,
317
+ credentialEncrypted: encryptEnvironmentValue(key, JSON.stringify(bundle)),
318
+ tokenMetadata: publicTokenMetadata(bundle),
319
+ });
320
+ return {
321
+ redirectTo: callbackReturnPath(state.returnPath, "success", {
322
+ connectionId: connection.id,
323
+ provider: provider.id,
324
+ accountHandle: identity.handle,
325
+ }),
326
+ };
327
+ } catch (error) {
328
+ logSocialOAuthFailure(observability, "token_exchange", state, error);
329
+ return {
330
+ redirectTo: callbackReturnPath(state.returnPath, "error", {
331
+ reason: "token_exchange_failed",
332
+ }),
333
+ };
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Resolves a usable access token for a social connection, refreshing (and
339
+ * persisting the rotated bundle) when the stored token is near expiry. Marks
340
+ * the connection needs_reauth and throws when refresh is impossible so agents
341
+ * surface an actionable error instead of opaque 401s.
342
+ */
343
+ export async function freshSocialAccessToken(
344
+ deps: SocialOAuthDeps,
345
+ ref: { workspaceId: string; connectionId: string },
346
+ ): Promise<{ connection: SocialConnection; bundle: SocialCredentialBundle }> {
347
+ const { db, settings } = deps;
348
+ const loaded = await loadSocialConnectionCredential(db, ref.workspaceId, ref.connectionId);
349
+ if (!loaded) {
350
+ throw new Error(`Social connection not found: ${ref.connectionId}`);
351
+ }
352
+ if (loaded.connection.status === "disabled") {
353
+ throw new Error(`Social connection ${ref.connectionId} is disabled`);
354
+ }
355
+ if (!loaded.credentialEncrypted) {
356
+ throw new Error(
357
+ `Social connection ${ref.connectionId} has no stored OAuth credential; reconnect it via the social OAuth flow`,
358
+ );
359
+ }
360
+ const key = requireEnvironmentEncryption(settings);
361
+ const bundle = parseSocialCredentialBundle(
362
+ decryptEnvironmentValue(key, loaded.credentialEncrypted),
363
+ );
364
+ if (!socialTokenNeedsRefresh(bundle, new Date())) {
365
+ return { connection: loaded.connection, bundle };
366
+ }
367
+ if (!bundle.refreshToken) {
368
+ await markNeedsReauth(deps, ref);
369
+ throw new Error(
370
+ `Social connection ${ref.connectionId} token expired and no refresh token is stored; reconnect it`,
371
+ );
372
+ }
373
+ const provider = SOCIAL_OAUTH_PROVIDERS[bundle.provider];
374
+ const client = socialOAuthClientFor(settings, provider.id);
375
+ let token: NormalizedTokenResponse;
376
+ try {
377
+ token = await socialTokenRequest(deps, provider, client, {
378
+ grant_type: "refresh_token",
379
+ refresh_token: bundle.refreshToken,
380
+ });
381
+ } catch (error) {
382
+ const definitive = error instanceof SocialTokenRequestError && error.definitive;
383
+ if (!definitive) {
384
+ // Token-endpoint 5xx/429/timeout: the stored grant may be fine. Fail
385
+ // this call without poisoning durable status.
386
+ throw new Error(
387
+ `Social connection ${ref.connectionId} token refresh hit a transient provider error; retry later (${errorMessage(error)})`,
388
+ { cause: error },
389
+ );
390
+ }
391
+ // invalid_grant can also mean we LOST a concurrent refresh race: X
392
+ // rotates refresh tokens per use, so the loser's token is already spent.
393
+ // Re-read before declaring the connection dead — if another writer
394
+ // persisted a newer bundle, use that instead of flipping needs_reauth.
395
+ const reloaded = await loadSocialConnectionCredential(db, ref.workspaceId, ref.connectionId);
396
+ if (
397
+ reloaded?.credentialEncrypted &&
398
+ reloaded.credentialEncrypted !== loaded.credentialEncrypted
399
+ ) {
400
+ const winner = parseSocialCredentialBundle(
401
+ decryptEnvironmentValue(key, reloaded.credentialEncrypted),
402
+ );
403
+ if (!socialTokenNeedsRefresh(winner, new Date())) {
404
+ return { connection: reloaded.connection, bundle: winner };
405
+ }
406
+ }
407
+ await markNeedsReauth(deps, ref);
408
+ throw new Error(
409
+ `Social connection ${ref.connectionId} token refresh was rejected; reconnect it (${errorMessage(error)})`,
410
+ { cause: error },
411
+ );
412
+ }
413
+ const refreshed: SocialCredentialBundle = {
414
+ provider: bundle.provider,
415
+ accessToken: token.accessToken,
416
+ tokenType: token.tokenType,
417
+ // X rotates refresh tokens on every use; keep the previous one only when
418
+ // the provider omits a replacement (Reddit re-uses the original).
419
+ refreshToken: token.refreshToken ?? bundle.refreshToken,
420
+ ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}),
421
+ ...(token.scope ? { scope: token.scope } : bundle.scope ? { scope: bundle.scope } : {}),
422
+ };
423
+ const connection =
424
+ (await updateSocialConnectionCredential(db, {
425
+ workspaceId: ref.workspaceId,
426
+ connectionId: ref.connectionId,
427
+ credentialEncrypted: encryptEnvironmentValue(key, JSON.stringify(refreshed)),
428
+ status: "connected",
429
+ tokenMetadata: publicTokenMetadata(refreshed),
430
+ })) ?? loaded.connection;
431
+ return { connection, bundle: refreshed };
432
+ }
433
+
434
+ export async function markNeedsReauth(
435
+ deps: SocialOAuthDeps,
436
+ ref: { workspaceId: string; connectionId: string },
437
+ ): Promise<void> {
438
+ await updateSocialConnectionCredential(deps.db, {
439
+ workspaceId: ref.workspaceId,
440
+ connectionId: ref.connectionId,
441
+ status: "needs_reauth",
442
+ });
443
+ }
444
+
445
+ export function parseSocialCredentialBundle(raw: string): SocialCredentialBundle {
446
+ let parsed: unknown;
447
+ try {
448
+ parsed = JSON.parse(raw);
449
+ } catch {
450
+ throw new Error("stored social credential is not valid JSON");
451
+ }
452
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
453
+ throw new Error("stored social credential has an unexpected shape");
454
+ }
455
+ const bundle = parsed as Record<string, unknown>;
456
+ const provider = bundle.provider;
457
+ const accessToken = bundle.accessToken;
458
+ if (
459
+ (provider !== "x" && provider !== "reddit") ||
460
+ typeof accessToken !== "string" ||
461
+ accessToken.length === 0
462
+ ) {
463
+ throw new Error("stored social credential has an unexpected shape");
464
+ }
465
+ return {
466
+ provider,
467
+ accessToken,
468
+ tokenType: typeof bundle.tokenType === "string" ? bundle.tokenType : "Bearer",
469
+ ...(typeof bundle.refreshToken === "string" ? { refreshToken: bundle.refreshToken } : {}),
470
+ ...(typeof bundle.expiresAt === "string" ? { expiresAt: bundle.expiresAt } : {}),
471
+ ...(typeof bundle.scope === "string" ? { scope: bundle.scope } : {}),
472
+ };
473
+ }
474
+
475
+ const REFRESH_SKEW_MS = 120 * 1000;
476
+
477
+ export function socialTokenNeedsRefresh(bundle: SocialCredentialBundle, now: Date): boolean {
478
+ if (!bundle.expiresAt) {
479
+ return false;
480
+ }
481
+ const expiresAt = new Date(bundle.expiresAt);
482
+ if (Number.isNaN(expiresAt.getTime())) {
483
+ return false;
484
+ }
485
+ return expiresAt.getTime() - now.getTime() <= REFRESH_SKEW_MS;
486
+ }
487
+
488
+ type NormalizedTokenResponse = {
489
+ accessToken: string;
490
+ refreshToken?: string;
491
+ tokenType: string;
492
+ expiresAt?: string;
493
+ scope?: string;
494
+ };
495
+
496
+ async function socialTokenRequest(
497
+ deps: Pick<SocialOAuthDeps, "settings" | "providerFetch">,
498
+ provider: SocialProviderDefinition,
499
+ client: { clientId: string; clientSecret?: string | undefined },
500
+ params: Record<string, string>,
501
+ ): Promise<NormalizedTokenResponse> {
502
+ const body = new URLSearchParams(params);
503
+ const headers: Record<string, string> = {
504
+ "content-type": "application/x-www-form-urlencoded",
505
+ accept: "application/json",
506
+ "user-agent": SOCIAL_USER_AGENT,
507
+ };
508
+ // Reddit always authenticates the token endpoint with HTTP basic (installed
509
+ // apps use an empty secret). X does the same for confidential clients and
510
+ // falls back to a public-client body param otherwise.
511
+ if (provider.id === "reddit" || client.clientSecret) {
512
+ headers.authorization = `Basic ${Buffer.from(
513
+ `${client.clientId}:${client.clientSecret ?? ""}`,
514
+ ).toString("base64")}`;
515
+ }
516
+ if (!headers.authorization || provider.id === "x") {
517
+ body.set("client_id", client.clientId);
518
+ }
519
+ const response = await socialProviderFetch(
520
+ deps,
521
+ provider.tokenEndpoint,
522
+ { method: "POST", headers, body, signal: AbortSignal.timeout(SOCIAL_TIMEOUT_MS) },
523
+ "social OAuth token exchange",
524
+ );
525
+ if (!response.ok) {
526
+ const oauthError = await boundedOAuthErrorCode(response);
527
+ throw new SocialTokenRequestError(
528
+ `${provider.id} token endpoint returned HTTP ${response.status}${oauthError ? ` (${oauthError})` : ""}`,
529
+ response.status,
530
+ oauthError,
531
+ );
532
+ }
533
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
534
+ response,
535
+ OAUTH_MAX_RESPONSE_BYTES,
536
+ "social OAuth token response",
537
+ );
538
+ const accessToken = typeof payload.access_token === "string" ? payload.access_token : null;
539
+ // Reddit reports errors with HTTP 200 + {"error": "..."} on some paths.
540
+ if (!accessToken) {
541
+ const reason = boundedErrorCode(payload.error) ?? "missing_access_token";
542
+ throw new SocialTokenRequestError(
543
+ `${provider.id} token response was invalid: ${reason}`,
544
+ null,
545
+ reason,
546
+ );
547
+ }
548
+ const expiresIn =
549
+ typeof payload.expires_in === "number" ? payload.expires_in : Number(payload.expires_in);
550
+ return {
551
+ accessToken,
552
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : "Bearer",
553
+ ...(typeof payload.refresh_token === "string" && payload.refresh_token
554
+ ? { refreshToken: payload.refresh_token }
555
+ : {}),
556
+ ...(Number.isFinite(expiresIn) && expiresIn > 0
557
+ ? { expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() }
558
+ : {}),
559
+ ...(typeof payload.scope === "string" && payload.scope ? { scope: payload.scope } : {}),
560
+ };
561
+ }
562
+
563
+ async function boundedOAuthErrorCode(response: Response): Promise<string | null> {
564
+ const contentType = response.headers.get("content-type") ?? "";
565
+ if (!contentType.toLowerCase().includes("application/json")) {
566
+ await response.body?.cancel().catch(() => undefined);
567
+ return null;
568
+ }
569
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
570
+ response,
571
+ OAUTH_MAX_RESPONSE_BYTES,
572
+ "social OAuth token error response",
573
+ ).catch(() => null);
574
+ return boundedErrorCode(payload?.error);
575
+ }
576
+
577
+ function boundedErrorCode(value: unknown): string | null {
578
+ return typeof value === "string" && /^[a-zA-Z0-9_.-]{1,80}$/.test(value) ? value : null;
579
+ }
580
+
581
+ async function fetchSocialIdentity(
582
+ deps: Pick<SocialOAuthDeps, "settings" | "providerFetch">,
583
+ provider: SocialOAuthProviderId,
584
+ accessToken: string,
585
+ ): Promise<{ handle: string; name?: string; externalAccountId: string }> {
586
+ const endpoint =
587
+ provider === "x" ? "https://api.x.com/2/users/me" : "https://oauth.reddit.com/api/v1/me";
588
+ const response = await socialProviderFetch(
589
+ deps,
590
+ endpoint,
591
+ {
592
+ headers: {
593
+ authorization: `Bearer ${accessToken}`,
594
+ accept: "application/json",
595
+ "user-agent": SOCIAL_USER_AGENT,
596
+ },
597
+ signal: AbortSignal.timeout(SOCIAL_TIMEOUT_MS),
598
+ },
599
+ "social identity lookup",
600
+ );
601
+ if (!response.ok) {
602
+ await response.body?.cancel().catch(() => undefined);
603
+ throw new Error(`${provider} identity lookup returned HTTP ${response.status}`);
604
+ }
605
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
606
+ response,
607
+ OAUTH_MAX_RESPONSE_BYTES,
608
+ "social identity response",
609
+ );
610
+ if (provider === "x") {
611
+ const data = payload.data as Record<string, unknown> | undefined;
612
+ const username = typeof data?.username === "string" ? data.username : null;
613
+ const id = typeof data?.id === "string" ? data.id : null;
614
+ if (!username || !id) {
615
+ throw new Error("x identity response was missing data.username or data.id");
616
+ }
617
+ return {
618
+ handle: username,
619
+ externalAccountId: id,
620
+ ...(typeof data?.name === "string" ? { name: data.name } : {}),
621
+ };
622
+ }
623
+ const name = typeof payload.name === "string" ? payload.name : null;
624
+ const id = typeof payload.id === "string" ? payload.id : null;
625
+ if (!name || !id) {
626
+ throw new Error("reddit identity response was missing name or id");
627
+ }
628
+ return { handle: name, externalAccountId: id };
629
+ }
630
+
631
+ function publicTokenMetadata(bundle: SocialCredentialBundle): Record<string, unknown> {
632
+ return {
633
+ tokenType: bundle.tokenType,
634
+ hasRefreshToken: Boolean(bundle.refreshToken),
635
+ ...(bundle.expiresAt ? { expiresAt: bundle.expiresAt } : {}),
636
+ ...(bundle.scope ? { scope: bundle.scope } : {}),
637
+ obtainedAt: new Date().toISOString(),
638
+ };
639
+ }
640
+
641
+ function readSocialOAuthState(
642
+ raw: string | undefined,
643
+ settings: Settings,
644
+ ): SocialOAuthStatePayload {
645
+ if (!raw) {
646
+ throw new HTTPException(400, { message: "missing OAuth state" });
647
+ }
648
+ const payload = readSignedState(raw, requireIntegrationsStateSecret(settings)) as Record<
649
+ string,
650
+ unknown
651
+ > | null;
652
+ if (!payload || payload.kind !== "social_oauth") {
653
+ throw new HTTPException(400, { message: "invalid or expired OAuth state" });
654
+ }
655
+ const nowSeconds = Math.floor(Date.now() / 1000);
656
+ const iat = typeof payload.iat === "number" ? payload.iat : NaN;
657
+ if (!Number.isFinite(iat) || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
658
+ throw new HTTPException(400, { message: "invalid or expired OAuth state" });
659
+ }
660
+ const provider = payload.provider;
661
+ if (provider !== "x" && provider !== "reddit") {
662
+ throw new HTTPException(400, { message: "invalid OAuth state: provider" });
663
+ }
664
+ return {
665
+ kind: "social_oauth",
666
+ accountId: requiredStateString(payload.accountId, "accountId"),
667
+ workspaceId: requiredStateString(payload.workspaceId, "workspaceId"),
668
+ subjectId: requiredStateString(payload.subjectId, "subjectId"),
669
+ provider,
670
+ scopes: Array.isArray(payload.scopes)
671
+ ? payload.scopes.filter((scope): scope is string => typeof scope === "string")
672
+ : [],
673
+ ...(typeof payload.encryptedPkceVerifier === "string"
674
+ ? { encryptedPkceVerifier: payload.encryptedPkceVerifier }
675
+ : {}),
676
+ returnPath: safeReturnPath(
677
+ typeof payload.returnPath === "string" ? payload.returnPath : "/integrations",
678
+ ),
679
+ nonce: requiredStateString(payload.nonce, "nonce"),
680
+ iat,
681
+ };
682
+ }
683
+
684
+ function requiredStateString(value: unknown, field: string): string {
685
+ if (typeof value !== "string" || value.length === 0) {
686
+ throw new HTTPException(400, { message: `invalid OAuth state: missing ${field}` });
687
+ }
688
+ return value;
689
+ }
690
+
691
+ function uniqueScopes(scopes: string[] | undefined): string[] | null {
692
+ const cleaned = [...new Set((scopes ?? []).map((scope) => scope.trim()).filter(Boolean))];
693
+ return cleaned.length > 0 ? cleaned : null;
694
+ }
695
+
696
+ function safeReturnPath(value: string): string {
697
+ if (!value.startsWith("/") || value.startsWith("//")) {
698
+ throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
699
+ }
700
+ const parsed = new URL(value, "https://opengeni.local");
701
+ // `..` segments can normalize back into a `//host` prefix, which browsers
702
+ // resolve as a protocol-relative absolute URL — an open redirect from the
703
+ // unauthenticated callback. Reject the NORMALIZED path, not just the input.
704
+ if (parsed.origin !== "https://opengeni.local" || parsed.pathname.startsWith("//")) {
705
+ throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
706
+ }
707
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
708
+ }
709
+
710
+ function callbackReturnPath(
711
+ returnPath: string,
712
+ status: "success" | "error",
713
+ params: Record<string, string>,
714
+ ): string {
715
+ const url = new URL(returnPath, "https://opengeni.local");
716
+ url.searchParams.set("social_oauth", status);
717
+ for (const [key, value] of Object.entries(params)) {
718
+ url.searchParams.set(key, value);
719
+ }
720
+ // Defense in depth against protocol-relative Location values; state payloads
721
+ // are signed but this function must stay safe for any caller.
722
+ if (url.pathname.startsWith("//")) {
723
+ const fallback = new URL("/integrations", "https://opengeni.local");
724
+ fallback.search = url.search;
725
+ return `${fallback.pathname}${fallback.search}`;
726
+ }
727
+ return `${url.pathname}${url.search}${url.hash}`;
728
+ }
729
+
730
+ function logSocialOAuthFailure(
731
+ observability: Observability | undefined,
732
+ stage: string,
733
+ state: SocialOAuthStatePayload | null,
734
+ error: unknown,
735
+ ): void {
736
+ observability?.error("social OAuth callback failed", {
737
+ "opengeni.social_oauth.stage": stage,
738
+ "opengeni.social_oauth.provider": state?.provider,
739
+ error: errorMessage(error),
740
+ });
741
+ }
742
+
743
+ function errorMessage(error: unknown): string {
744
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
745
+ }