/** * Shared Google OAuth utilities for all templates. * * Handles platform detection (desktop/mobile), state encoding, * session token creation, and deep-link responses — the logic * that was previously copy-pasted across every template's * google-auth.ts handler. */ import crypto from "node:crypto"; import { getHeader, getQuery, setResponseStatus, setResponseHeader, type H3Event, } from "h3"; import { getAppBasePathFromViteEnv } from "./app-base-path.js"; import { getAppName } from "./app-name.js"; import { signupAttributionFromCookieHeader } from "./attribution.js"; import { addSession, getSession, getSessionMaxAge, hasLegacySessionForEmail, setFrameworkSessionCookie, } from "./auth.js"; import { hasBetterAuthUserEmail, trackSignupEvent, } from "./better-auth-instance.js"; import { getWorkspaceA2ADerivedSecret } from "./derived-secret.js"; import { writeDesktopSso } from "./desktop-sso.js"; import { appendSessionToOAuthReturnUrl } from "./oauth-return-url.js"; import { isWorkspaceOAuthCallbackRelayEnabled } from "./workspace-oauth.js"; // ─── Platform Detection ───────────────────────────────────────────────────── /** Return an HTML response with the correct Content-Type. * Uses a web-standard Response to ensure the header survives * Nitro dev mode's mock-node-response pipeline. */ function htmlResponse(html: string, status = 200): Response { return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } /** Shared markup for OAuth success "close this tab" pages. Renders a green * check icon above the message, with a little breathing room between the * headline and secondary line. Used by every template that goes through the * shared Google OAuth flow. */ function oauthDebugFlowId(flowId?: string): string | undefined { return flowId ? flowId.slice(-10) : undefined; } function oauthSuccessCloseTabHtml( headline: string, footnote: string, debugFlowId?: string, ): string { const debug = debugFlowId ? `

Debug flow: ${escapeHtml(debugFlowId)}

` : ""; return `Connected

${headline}

${footnote}

${debug}`; } /** * HTML escape — minimal but covers the cases that matter when interpolating * user-controlled values into our OAuth callback HTML. Mirrors the helper in * email-template.ts; kept inline here to avoid a circular import. */ function escapeHtml(s: string): string { return String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * Detect requests from the Agent Native desktop app specifically. * * The desktop app appends `AgentNativeDesktop/` to its user-agent * (see `packages/desktop-app/src/main/index.ts`). We check for that marker * rather than matching generic `Electron`, which would also match other * Electron-based webviews like Builder.io's Fusion, Slack desktop, Discord, * etc. Falsely treating those as "the desktop app" sends users to the * `agentnative://oauth-complete` deep-link success page after Google sign-in, * where the protocol handler can't fire and the "Open Agent Native" button * does nothing. * * Kept exported as `isElectron` for backwards compatibility with consumers. */ export function isElectron(event: H3Event): boolean { return /AgentNativeDesktop/i.test(getHeader(event, "user-agent") || ""); } /** Detect requests from a mobile browser (iOS/Android). */ export function isMobile(event: H3Event): boolean { return /iPhone|iPad|iPod|Android/i.test(getHeader(event, "user-agent") || ""); } /** * Build the static allowlist of origins we trust for `getOrigin`. Reads * deployment-known public URLs. Each entry is normalised to * `${proto}://${host}` (no path). Duplicates collapse, invalid entries are * dropped silently. */ const EXPLICIT_PUBLIC_ORIGIN_ENV_KEYS = [ "WORKSPACE_OAUTH_ORIGIN", "VITE_WORKSPACE_OAUTH_ORIGIN", "APP_URL", "VITE_APP_URL", "BETTER_AUTH_URL", "VITE_BETTER_AUTH_URL", "URL", "DEPLOY_URL", ] as const; const WORKSPACE_GATEWAY_ORIGIN_ENV_KEYS = [ "WORKSPACE_GATEWAY_URL", "VITE_WORKSPACE_GATEWAY_URL", ] as const; function normalizeOrigin(raw: string | undefined): string | undefined { if (!raw) return undefined; try { const u = new URL(raw); return `${u.protocol}//${u.host}`; } catch { return undefined; } } function addNormalizedOrigin( out: Set, raw: string | undefined, options: { allowLoopback: boolean }, ): void { const origin = normalizeOrigin(raw); if (!origin) return; if (!options.allowLoopback && isLoopbackOrigin(origin)) return; out.add(origin); } function firstOriginFromEnv( keys: readonly string[], options: { allowLoopback: boolean }, ): string | undefined { for (const key of keys) { const origin = normalizeOrigin(process.env[key]); if (!origin) continue; if (!options.allowLoopback && isLoopbackOrigin(origin)) continue; return origin; } return undefined; } function getConfiguredOriginAllowlist(): Set { const out = new Set(); for (const key of EXPLICIT_PUBLIC_ORIGIN_ENV_KEYS) { addNormalizedOrigin(out, process.env[key], { allowLoopback: true }); } for (const key of WORKSPACE_GATEWAY_ORIGIN_ENV_KEYS) { addNormalizedOrigin(out, process.env[key], { allowLoopback: false }); } return out; } /** Return whether a candidate is one of this deployment's configured origins. */ export function isConfiguredAppOrigin(value: string | undefined): boolean { const origin = normalizeOrigin(value); return !!origin && getConfiguredOriginAllowlist().has(origin); } function getWorkspaceCallbackOrigin(): string | undefined { const publicAuthOrigin = firstOriginFromEnv(EXPLICIT_PUBLIC_ORIGIN_ENV_KEYS, { allowLoopback: true, }); if (publicAuthOrigin) return publicAuthOrigin; return firstOriginFromEnv(WORKSPACE_GATEWAY_ORIGIN_ENV_KEYS, { allowLoopback: false, }); } function isLoopbackHost(host: string | undefined): boolean { if (!host) return false; try { const parsed = new URL(`http://${host}`); return ( parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1" || parsed.hostname === "[::1]" ); } catch { return false; } } function isLoopbackOrigin(origin: string | undefined): boolean { if (!origin) return false; try { return isLoopbackHost(new URL(origin).host); } catch { return false; } } function isBuilderPreviewHost(host: string | undefined): boolean { if (!host) return false; try { const parsed = new URL(`http://${host}`); const hostname = parsed.hostname.toLowerCase(); return ( hostname === "builderio.xyz" || hostname.endsWith(".builderio.xyz") || hostname === "builderio.dev" || hostname.endsWith(".builderio.dev") || hostname === "builder.codes" || hostname.endsWith(".builder.codes") || hostname === "builder.io" || hostname.endsWith(".builder.io") || hostname === "builder.my" || hostname.endsWith(".builder.my") ); } catch { return false; } } /** * Get the origin from forwarded headers or Host. * * Defends against Host-header injection: in production we require the resolved * origin to match `APP_URL` / `BETTER_AUTH_URL` / `WORKSPACE_GATEWAY_URL`, * falling back to those values when inbound headers are missing or don't match. * In dev we accept inbound `Host` so localhost / ngrok / preview hosts keep * working without configuration, except workspace OAuth requests from loopback * or Builder preview hosts use the configured gateway origin when one exists. * The protocol defaults to `https` in production (so a TLS-terminating proxy * that drops `x-forwarded-proto` doesn't downgrade us to plain HTTP). */ export function getOrigin(event: H3Event): string { const headerHost = getHeader(event, "x-forwarded-host") || getHeader(event, "host"); const isProd = process.env.NODE_ENV === "production"; const headerProto = getHeader(event, "x-forwarded-proto") || (isProd ? "https" : "http"); const workspaceCallbackOrigin = isWorkspaceOAuthCallbackRelayEnabled() ? getWorkspaceCallbackOrigin() : undefined; if ( workspaceCallbackOrigin && (isLoopbackHost(headerHost) || isBuilderPreviewHost(headerHost)) ) { return workspaceCallbackOrigin; } if (isProd) { const allow = getConfiguredOriginAllowlist(); // If the deploy declares its public URL, prefer it over inbound headers. if (allow.size > 0) { const inbound = headerHost ? `${headerProto}://${headerHost}` : ""; if (inbound && allow.has(inbound)) return inbound; // Inbound didn't match — fall back to the first configured origin. return [...allow][0]; } // No allowlist configured: still default to https, but accept the // inbound Host (best we can do without a configured base URL). return `${headerProto}://${headerHost ?? ""}`; } return `${headerProto}://${headerHost ?? "localhost"}`; } /** App mount prefix, if the template is served under APP_BASE_PATH. */ export function getAppBasePath(): string { // Vite statically replaces VITE_* values in the server bundle during the // build, but Netlify/Nitro does not necessarily expose those build vars at // runtime. Keep auth and OAuth path matching aligned with the SSR handler by // falling back to import.meta.env (including BASE_URL). return getAppBasePathFromViteEnv(); } /** Build an absolute same-origin URL that preserves APP_BASE_PATH. */ export function getAppUrl(event: H3Event, path = "/"): string { const cleanPath = path.startsWith("/") ? path : `/${path}`; return `${getOrigin(event)}${getAppBasePath()}${cleanPath}`; } function isFrameworkOAuthCallbackPath(pathname: string): boolean { return ( pathname.startsWith("/_agent-native/") && (pathname.endsWith("/callback") || pathname.includes("/callback/")) ); } function getOriginalRequestPath(event: H3Event): string { const mountedPathname = (event as any).context?._mountedPathname; if (typeof mountedPathname === "string" && mountedPathname) { return mountedPathname; } const urlPathname = (event as any).url?.pathname; if (typeof urlPathname === "string" && urlPathname) return urlPathname; const nodeUrl = event.node?.req?.url; if (typeof nodeUrl === "string" && nodeUrl) { const queryStart = nodeUrl.indexOf("?"); return queryStart >= 0 ? nodeUrl.slice(0, queryStart) : nodeUrl; } const eventPath = (event as any).path; if (typeof eventPath === "string" && eventPath) { const queryStart = eventPath.indexOf("?"); return queryStart >= 0 ? eventPath.slice(0, queryStart) : eventPath; } return "/"; } function isRequestUnderAppBasePath(event: H3Event): boolean { const basePath = getAppBasePath(); if (!basePath) return false; const requestPath = getOriginalRequestPath(event); return ( requestPath === `${basePath}/_agent-native` || requestPath.startsWith(`${basePath}/_agent-native/`) ); } function getDefaultOAuthRedirectUrl(event: H3Event, path: string): string { const cleanPath = path.startsWith("/") ? path : `/${path}`; if ( isWorkspaceOAuthCallbackRelayEnabled() && isFrameworkOAuthCallbackPath(cleanPath) ) { return `${getOrigin(event)}${cleanPath}`; } const basePath = isRequestUnderAppBasePath(event) ? getAppBasePath() : ""; return `${getOrigin(event)}${basePath}${cleanPath}`; } // ─── redirect_uri Allowlist ────────────────────────────────────────────────── /** * Validate a user-supplied `redirect_uri` for OAuth flows. * * Defends against authorization-code interception (RFC 6819 §4.4.1.7): * even though the upstream provider (Google/Atlassian/Zoom) refuses * unregistered redirect URIs, prefix-style registrations and side * registrations on the same host let a malicious caller swap in an * attacker-controlled URI that the provider still accepts. We reject any * candidate that isn't on this server's own origin AND under the * framework's `/_agent-native/` namespace. Returns the validated URI on * success, or `undefined` on rejection — callers must treat `undefined` * as a 400. * * The intentional shape is exact-prefix: * - Origin must equal `getOrigin(event)` — no Host-header injection * reusing somebody else's registered redirect URI. * - Path must start with `${appBasePath}/_agent-native/` so we never * hand auth codes to a public marketing or open-redirect endpoint * on the same registered host. * * For desktop / native flows that need ephemeral `http://127.0.0.1:` * loopback URIs, callers should validate those at the template level * with a dedicated allowlist — this helper rejects them by design. */ export function isAllowedOAuthRedirectUri( candidate: string, event: H3Event, ): boolean { if (typeof candidate !== "string" || candidate.length === 0) return false; let url: URL; try { url = new URL(candidate); } catch { return false; } // Must be same origin as our server. const expectedOrigin = getOrigin(event); let expectedUrl: URL; try { expectedUrl = new URL(expectedOrigin); } catch { return false; } if (url.protocol !== expectedUrl.protocol) return false; if (url.host !== expectedUrl.host) return false; // Must live under the framework's namespace. Workspace deploys can route // root /_agent-native/* to Dispatch even when Dispatch itself is mounted at // /dispatch, but app-prefixed requests should not be able to swap their // callback to that root namespace. const basePath = getAppBasePath(); const allowedPrefixes = basePath && isRequestUnderAppBasePath(event) ? [ `${basePath}/_agent-native/`, ...(isWorkspaceOAuthCallbackRelayEnabled() && isFrameworkOAuthCallbackPath(url.pathname) ? ["/_agent-native/"] : []), ] : ["/_agent-native/"]; if (!allowedPrefixes.some((prefix) => url.pathname.startsWith(prefix))) { return false; } return true; } /** * Resolve the `redirect_uri` for an outbound OAuth `auth-url` request. * * Reads `?redirect_uri=` from the query and validates it via * `isAllowedOAuthRedirectUri`. Returns: * - the validated URI when supplied and allowed, OR * - the framework default when no override was supplied, OR * - `null` when an override was supplied but rejected — callers must * respond with 400 in that case. * * Templates that need a non-default redirect path can pass it via * `defaultPath` (e.g. `"/_agent-native/google/desktop-callback"` for * desktop flows). */ export function resolveOAuthRedirectUri( event: H3Event, defaultPath = "/_agent-native/google/callback", ): string | null { const supplied = getQuery(event).redirect_uri; if (typeof supplied === "string" && supplied.length > 0) { return isAllowedOAuthRedirectUri(supplied, event) ? supplied : null; } return getDefaultOAuthRedirectUrl(event, defaultPath); } // ─── OAuth State ───────────────────────────────────────────────────────────── export interface OAuthStatePayload { redirectUri: string; owner?: string; orgId?: string; desktop?: boolean; addAccount?: boolean; app?: string; /** * Same-origin path to redirect to after a successful web-flow sign-in. * Threaded through the (HMAC-signed) state so it survives the round trip * to Google. Validated again on decode via safeReturnPath as defence in * depth. Has no effect on desktop / mobile / add-account flows, which * use their own deep-link / close-tab handling. */ returnUrl?: string; flowId?: string; signupAttribution?: Record; } /** * Ephemeral in-memory state-signing key for development. Generated lazily * on first read so dev sessions don't depend on filesystem writability or * env-var configuration. Sessions reset on each restart, which is fine * for dev — no real users / production data are involved. */ let _devStateSigningKey: string | undefined; /** * Derive a server-only signing key for HMAC verification of OAuth state. * * Uses a dedicated secret — never an OAuth client secret. Reusing a * client_secret (which is shared with Google / GitHub / Atlassian) as our * own HMAC key conflates two trust domains: rotating the client secret * silently invalidates every in-flight OAuth state, and any leak of the * client secret also lets an attacker forge our state envelopes. * * Resolution order: * 1. OAUTH_STATE_SECRET (preferred — dedicated to this purpose) * 2. BETTER_AUTH_SECRET (already used by Better Auth as a server secret) * 3. Hosted workspace deploys derive a per-purpose key from A2A_SECRET * 4. In dev only, an ephemeral random key (per-process) * * In production, throws if no usable server secret is set. */ function getStateSigningKey(): string { const secret = process.env.OAUTH_STATE_SECRET || process.env.BETTER_AUTH_SECRET || getWorkspaceA2ADerivedSecret("oauth-state"); if (secret) return secret; const isProd = process.env.NODE_ENV === "production"; if (isProd) { throw new Error( "OAuth state signing requires a server secret. " + "Set OAUTH_STATE_SECRET, BETTER_AUTH_SECRET, or A2A_SECRET in production workspace deploys.", ); } if (!_devStateSigningKey) { _devStateSigningKey = crypto.randomBytes(32).toString("hex"); } return _devStateSigningKey; } /** * Options for the named-argument form of {@link encodeOAuthState}. * Prefer this form — the positional overload is easy to misuse (the mail * and calendar templates historically passed `flowId` in the `returnUrl` * slot, smuggling state into a defence-in-depth path). */ export interface EncodeOAuthStateOptions { redirectUri: string; owner?: string; orgId?: string; desktop?: boolean; addAccount?: boolean; app?: string; returnUrl?: string; flowId?: string; signupAttribution?: Record; } function sanitizeStateAttribution( value: unknown, ): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } const out: Record = {}; for (const [key, raw] of Object.entries(value)) { if (typeof raw === "string") out[key] = raw; } return Object.keys(out).length > 0 ? out : undefined; } /** * Encode OAuth state into a signed base64url string. * The state is HMAC-signed so the callback can verify it wasn't forged, * preventing CSRF attacks on the OAuth flow. * * Two call shapes are supported: * - Recommended: pass an options object — clear, mismatch-proof. * `encodeOAuthState({ redirectUri, owner, desktop, ... })` * - Legacy positional form (kept working for backward compatibility): * `encodeOAuthState(redirectUri, owner, desktop, addAccount, app, returnUrl, flowId)`. * Callers should migrate to the options form — see the audit on * templates/mail and templates/calendar where the positional shape * led to `flowId` being smuggled in via the `returnUrl` slot. */ export function encodeOAuthState(opts: EncodeOAuthStateOptions): string; export function encodeOAuthState( redirectUri: string, owner?: string, desktop?: boolean, addAccount?: boolean, app?: string, returnUrl?: string, flowId?: string, ): string; export function encodeOAuthState( redirectUriOrOpts: string | EncodeOAuthStateOptions, owner?: string, desktop?: boolean, addAccount?: boolean, app?: string, returnUrl?: string, flowId?: string, ): string { const opts: EncodeOAuthStateOptions = typeof redirectUriOrOpts === "string" ? { redirectUri: redirectUriOrOpts, owner, desktop, addAccount, app, returnUrl, flowId, } : redirectUriOrOpts; const nonce = crypto.randomBytes(8).toString("hex"); const payload: Record = { n: nonce, r: opts.redirectUri, }; if (opts.owner) payload.o = opts.owner; if (opts.orgId) payload.g = opts.orgId; if (opts.desktop) payload.d = true; if (opts.addAccount) payload.a = true; if (opts.app) payload.app = opts.app; if (opts.returnUrl) payload.r2 = opts.returnUrl; if (opts.flowId) payload.f = opts.flowId; if (opts.signupAttribution) payload.ft = opts.signupAttribution; const data = Buffer.from(JSON.stringify(payload)).toString("base64url"); const sig = crypto .createHmac("sha256", getStateSigningKey()) .update(data) .digest("base64url"); return `${data}.${sig}`; } /** * Decode and verify OAuth state from the callback's state query parameter. * Rejects forged or tampered state by checking the HMAC signature. * Falls back to the provided URI if decoding or verification fails. */ export function decodeOAuthState( stateParam: string | undefined, fallbackUri: string, ): OAuthStatePayload { if (stateParam) { try { const dotIdx = stateParam.lastIndexOf("."); if (dotIdx === -1) return { redirectUri: fallbackUri }; const data = stateParam.slice(0, dotIdx); const sig = stateParam.slice(dotIdx + 1); const expected = crypto .createHmac("sha256", getStateSigningKey()) .update(data) .digest("base64url"); if ( sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ) { return { redirectUri: fallbackUri }; } const parsed = JSON.parse(Buffer.from(data, "base64url").toString()); return { redirectUri: parsed.r || fallbackUri, owner: parsed.o || undefined, orgId: typeof parsed.g === "string" ? parsed.g : undefined, desktop: !!parsed.d, addAccount: !!parsed.a, app: typeof parsed.app === "string" ? parsed.app : undefined, // Pass returnUrl through as-is — same-origin validation runs at the // consumer (oauthCallbackResponse → safeReturnPath). The state is // HMAC-signed, but we still validate at consumption as defence in // depth in case the signing key ever leaks. returnUrl: typeof parsed.r2 === "string" ? parsed.r2 : undefined, flowId: parsed.f || undefined, signupAttribution: sanitizeStateAttribution(parsed.ft), }; } catch {} } return { redirectUri: fallbackUri }; } // ─── Session Creation ──────────────────────────────────────────────────────── export interface OAuthOwnerResult { owner: string | undefined; hasProductionSession: boolean; } /** * Determine the token owner from the current session and OAuth state. * Call this BEFORE exchangeCode to get the owner parameter. */ export async function resolveOAuthOwner( event: H3Event, stateOwner?: string, ): Promise { const existingSession = await getSession(event); const hasProductionSession = !!existingSession?.email; const owner = hasProductionSession ? existingSession!.email : stateOwner || undefined; return { owner, hasProductionSession }; } export interface OAuthSessionResult { sessionToken: string | undefined; } /** * Create a session token after a successful OAuth exchange. * * Desktop and mobile apps have separate cookie jars from the system * browser, so they always get a fresh session token (even if the browser * already has one). The token is then passed via deep link so the native * app can inject it. */ export async function createOAuthSession( event: H3Event, email: string, opts: { hasProductionSession: boolean; desktop?: boolean; trackSignup?: { authProvider: string; authUserId?: string; name?: string | null; attribution?: Record; }; }, ): Promise { const mobile = isMobile(event); const needsDeepLink = opts.desktop || mobile; const maxAge = getSessionMaxAge(); let sessionToken: string | undefined; let shouldTrackSignup = false; if (!opts.hasProductionSession || needsDeepLink) { if (opts.trackSignup && !opts.hasProductionSession) { const [hasLegacySession, hasBetterAuthUser] = await Promise.all([ hasLegacySessionForEmail(email).catch(() => true), hasBetterAuthUserEmail(email).catch(() => true), ]); shouldTrackSignup = !hasLegacySession && !hasBetterAuthUser; } sessionToken = crypto.randomBytes(32).toString("hex"); await addSession(sessionToken, email); setFrameworkSessionCookie(event, sessionToken); if (shouldTrackSignup && opts.trackSignup) { const attribution = opts.trackSignup.attribution ?? signupAttributionFromCookieHeader(getHeader(event, "cookie") ?? null); await trackSignupEvent({ authProvider: opts.trackSignup.authProvider, authUserId: opts.trackSignup.authUserId, email, name: opts.trackSignup.name, attribution, }); } // Desktop SSO: record this session in the home-dir broker file so // sibling templates (each with its own database) can resolve the // same token without a DB row of their own. Only the PRIMARY // sign-in writes the broker — if a production session already // exists, this is an add-account flow (connecting a secondary // Google account for scraping) and must never switch the active // user across sibling templates. if (opts.desktop && !opts.hasProductionSession) { await writeDesktopSso({ email, token: sessionToken, expiresAt: Date.now() + maxAge * 1000, }); } } return { sessionToken }; } // ─── Callback Responses ────────────────────────────────────────────────────── /** * Return the appropriate response after a successful OAuth callback. * * Handles mobile deep links, desktop deep links, add-account close-tab * pages, and plain web redirects — so templates don't have to. */ export function oauthCallbackResponse( event: H3Event, email: string, opts: { sessionToken?: string; desktop?: boolean; addAccount?: boolean; /** * Same-origin path to return the viewer to after a successful web * sign-in. Validated via safeReturnPath; falls back to "/" for any * shape that escapes same-origin. Has no effect on desktop / mobile * / add-account flows — those use their own deep-link handling. */ returnUrl?: string; flowId?: string; appName?: string; }, ): Response | string | unknown | Promise { const mobile = isMobile(event); const query = getQuery(event); const callbackState = typeof query.state === "string" && query.state.length > 0 ? query.state : undefined; // Mobile: deep link back to the native app. `isMobile` is UA-only, so this // also fires for a plain mobile web browser with no app to handle the deep // link — there it no-ops, so the fallback must return to the post-login URL, // not the app root (else signed-out visitors land on the homepage). if (mobile) { const deepLink = buildOAuthCompleteDeepLink( opts.sessionToken, callbackState, ); const webFallback = appendSessionToOAuthReturnUrl( opts.returnUrl, opts.sessionToken, ); return htmlResponse( `Connected

Connected! Returning to app…

`, ); } // Desktop add-account: close-tab page (must come before general desktop check // to ensure no deep link fires and the existing session is never switched). if (opts.desktop && opts.addAccount) { const safeEmail = email ? escapeHtml(email) : ""; const safeAppName = escapeHtml(resolveOAuthAppName(opts.appName)); const msg = safeEmail ? `Connected ${safeEmail}!` : "Connected!"; return htmlResponse( oauthSuccessCloseTabHtml( msg, `You can close this tab and return to ${safeAppName}.`, oauthDebugFlowId(opts.flowId), ), ); } // Electron desktop exchange flow: mail/calendar still pass a flow id so the // renderer can poll as a fallback, but the main handoff should use the // protocol deep link so the popup returns focus to the desktop app. if (opts.desktop && opts.flowId && isElectron(event) && opts.sessionToken) { return desktopSuccessPage(event, email, opts.sessionToken, callbackState); } // Desktop exchange flow (non-Electron tray app): the tray app polls the // desktop-exchange endpoint for the token — no deep link needed. if (opts.desktop && opts.flowId) { const safeEmail = email ? escapeHtml(email) : ""; const safeAppName = escapeHtml(resolveOAuthAppName(opts.appName)); const msg = safeEmail ? `Signed in as ${safeEmail}!` : "Signed in!"; return htmlResponse( oauthSuccessCloseTabHtml( msg, `You can close this tab and return to ${safeAppName}.`, oauthDebugFlowId(opts.flowId), ), ); } // Desktop login: deep link back to Electron app — only when the callback // request actually carries the AgentNativeDesktop UA marker. Without this // check, any client whose OAuth state was minted with `desktop=true` (e.g. // a stale link, or an upstream that wrongly set `?desktop=1`) would land // on the `agentnative://` page where the deep link can't fire and the // "Open Agent Native" button does nothing — surfaces inside Builder.io's // Fusion webview hit this exact dead-end. Fall through to the web flow // for non-Agent-Native-Desktop clients so they get a real redirect. if (opts.desktop && isElectron(event)) { return desktopSuccessPage(event, email, opts.sessionToken, callbackState); } // Add-account web flow: close-tab page. The email is rendered into the // page via DOM `textContent` (safe), but we still JSON-stringify so a // payload containing `` can't break out of the script tag — // and explicitly assert it's a string so a callbacks like `null` or // an object won't end up serialised into the page. if (opts.addAccount) { const safeEmail = JSON.stringify(typeof email === "string" ? email : ""); return htmlResponse(``); } // Web: redirect to the requested return target. Path-only returns stay // same-origin; Builder desktop workspace returns may point back to the // local loopback gateway and carry the short-lived `_session` bridge so // the local app can promote the newly created hosted OAuth session. const location = appendSessionToOAuthReturnUrl( opts.returnUrl, opts.sessionToken, ); setResponseStatus(event, 302); setResponseHeader(event, "Location", location); setResponseHeader(event, "Referrer-Policy", "no-referrer"); // Return a real 302 so the browser lands on the clean return URL instead of // lingering on the provider callback URL with its `code`/`state` query // params. But h3 hands a non-2xx web `Response` straight back WITHOUT merging // the `Set-Cookie` staged earlier in the callback (the framework session // cookie), so mirror those staged cookies onto the redirect Response — // otherwise the sign-in succeeds but the browser arrives back logged out. const headers = new Headers({ Location: location, "Referrer-Policy": "no-referrer", }); for (const cookie of event.res?.headers?.getSetCookie?.() ?? []) { headers.append("set-cookie", cookie); } return new Response(null, { status: 302, headers }); } /** HTML error page for OAuth failures. The message is HTML-escaped — most * callers pass `error.message` from a token-exchange or userinfo failure, * which can echo upstream provider strings (and historically attacker- * controlled query params via the `error_description` field). */ export function oauthErrorPage(message: string): Response { const safe = escapeHtml(message); return htmlResponse( `Connection failed

${safe}

Back to login

`, 400, ); } export function oauthDesktopExchangePage( message = "Returning to the app...", ): Response { const safe = escapeHtml(message); return htmlResponse( `Returning

${safe}

`, ); } // ─── Internal ──────────────────────────────────────────────────────────────── function resolveOAuthAppName(explicit?: string): string { const raw = explicit || getAppName() || "Agent Native"; if (!/^[a-z0-9_-]+$/.test(raw)) return raw; return raw .split(/[-_]+/) .filter(Boolean) .map((word) => word[0].toUpperCase() + word.slice(1)) .join(" "); } function buildOAuthCompleteDeepLink( sessionToken?: string, state?: string, ): string { const params = new URLSearchParams(); if (sessionToken) params.set("token", sessionToken); if (state) params.set("state", state); const suffix = params.toString(); return suffix ? `agentnative://oauth-complete?${suffix}` : "agentnative://oauth-complete"; } function desktopSuccessPage( _event: H3Event, email?: string, sessionToken?: string, state?: string, ): Response { const safeEmail = email ? escapeHtml(email) : ""; const msg = safeEmail ? `Connected ${safeEmail}!` : "Connected!"; if (sessionToken) { const deepLink = buildOAuthCompleteDeepLink(sessionToken, state); const deepLinkJson = JSON.stringify(deepLink); // Defence in depth: if this page somehow gets served to a UA that isn't // the Agent Native desktop app (server gate bypassed, stale link, etc.), // skip the `agentnative://` deep link entirely and bounce to the app // root. The deep link silently fails outside the desktop app and the // "Open Agent Native" button is a dead end in a generic browser/webview. return htmlResponse( `Connected

${msg}

Open Agent Native

If the app didn\u2019t open automatically, click the button above.

`, ); } return htmlResponse( oauthSuccessCloseTabHtml( msg, "You can close this tab and return to Agent Native.", ), ); }