Self-hosted Google Search Console (#236)

This commit is contained in:
Ben Senescu 2026-06-04 18:06:16 -04:00 committed by GitHub
parent 23c38ca4e5
commit 7a26be46cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 3393 additions and 96 deletions

View File

@ -37,3 +37,10 @@
# LOOPS_API_KEY=replace-with-your-loops-api-key # LOOPS_API_KEY=replace-with-your-loops-api-key
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id # LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-template-id # LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-template-id
# Optional in self-hosted modes. Required if you want Google Search Console
# integration and MCP tools. BETTER_AUTH_SECRET is also required for GSC (it
# encrypts the stored OAuth tokens). See docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md.
# GOOGLE_CLIENT_ID=replace-with-your-google-oauth-client-id
# GOOGLE_CLIENT_SECRET=replace-with-your-google-oauth-client-secret
# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters

View File

@ -29,6 +29,7 @@ Easy to self-host, fork and extend, but we have a managed version too:
- [Community](#community) - [Community](#community)
- [Pricing / Costs (Free + API costs)](#pricing--costs) - [Pricing / Costs (Free + API costs)](#pricing--costs)
- [DataForSEO API Key Setup](#dataforseo-api-key-setup) - [DataForSEO API Key Setup](#dataforseo-api-key-setup)
- [Google Search Console](#google-search-console)
- [Self-hosting](#self-hosting) - [Self-hosting](#self-hosting)
- [Docker Self Hosting](#docker-self-hosting) - [Docker Self Hosting](#docker-self-hosting)
- [Cloudflare Self-Hosting](#cloudflare-self-hosting) - [Cloudflare Self-Hosting](#cloudflare-self-hosting)
@ -203,6 +204,12 @@ printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64
- Cloudflare: Set it in the workers UI - Cloudflare: Set it in the workers UI
- Local development: `.env.local` - Local development: `.env.local`
## Google Search Console
Search Console is optional and works in self-hosted deployments using your own
Google OAuth client. It takes ~10 minutes of one-time setup — see
[`docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md`](./docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md).
## Self-hosting ## Self-hosting
OpenSEO supports two self-hosting paths: OpenSEO supports two self-hosting paths:

View File

@ -9,6 +9,11 @@ services:
- ALLOWED_HOST=${ALLOWED_HOST:-} - ALLOWED_HOST=${ALLOWED_HOST:-}
- AUTH_MODE=local_noauth - AUTH_MODE=local_noauth
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY} - DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY}
# Optional: Google Search Console. See
# docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-}
- VITE_SHOW_DEVTOOLS=false - VITE_SHOW_DEVTOOLS=false
ports: ports:
- "127.0.0.1:${PORT:-3001}:${PORT:-3001}" - "127.0.0.1:${PORT:-3001}:${PORT:-3001}"

View File

@ -0,0 +1,118 @@
# Self-hosted Google Search Console
Connecting Google Search Console (GSC) lets OpenSEO pull your real clicks,
impressions, positions, and URL inspection data, straight from Google.
It's **optional**: OpenSEO runs fine without it, just without Search Console data.
## What you'll need
- A Google account with access to your verified Search Console property.
- ~10 minutes in the [Google Cloud Console](https://console.cloud.google.com/).
- Three environment variables set on your deployment (see [step 4](#4-set-environment-variables)).
## 1) Create a Google Cloud project and enable the API
1. Open the [Google Cloud Console](https://console.cloud.google.com/) and create
a project (or pick an existing one).
2. Enable the
[Google Search Console API](https://console.cloud.google.com/apis/library/searchconsole.googleapis.com)
for that project.
## 2) Configure the OAuth consent screen
Under **APIs & Services → OAuth consent screen**:
- Pick **External** (unless everyone using it is in your Google Workspace org).
- Fill in the app name, support email, and developer contact email.
- While the app is in **Testing**, add the Google accounts that will connect as
**test users** — otherwise Google blocks the sign-in with `access_denied`.
For personal or internal use you don't need to submit for verification; testing
mode is enough.
## 3) Create an OAuth client ID
Under **APIs & Services → Credentials → Create credentials → OAuth client ID**:
1. Application type: **Web application**.
2. Add an **Authorized redirect URI** that exactly matches your deployment's
origin plus `/api/gsc/oauth/callback`:
| Deployment | Redirect URI |
| ------------ | -------------------------------------------------------- |
| Deployed | `https://your-openseo-domain.com/api/gsc/oauth/callback` |
| Local Docker | `http://localhost:3001/api/gsc/oauth/callback` |
The scheme, host, and port must match exactly, with no trailing slash.
3. Save, then copy the **Client ID** and **Client secret**.
## 4) Set environment variables
Set these three values, then restart OpenSEO:
| Variable | Value |
| ---------------------- | ----------------------------------------------------------------------- |
| `GOOGLE_CLIENT_ID` | Client ID from step 3. |
| `GOOGLE_CLIENT_SECRET` | Client secret from step 3. |
| `BETTER_AUTH_SECRET` | A random string of **at least 32 characters** (encrypts stored tokens). |
`BETTER_AUTH_SECRET` is not needed for normal self-hosting — only for Search
Console, because the stored OAuth tokens are encrypted at rest with it. Generate
one with:
```sh
openssl rand -base64 32
```
Where to set them:
- **Docker self-hosting:** `.env`
- **Cloudflare:** the Workers dashboard (as secrets)
- **Local development:** `.env.local`
## 5) Restart and connect
Restart OpenSEO so it picks up the new variables. For Docker, changing `.env`
means Compose has to recreate the container to reapply it:
```bash
docker compose up -d --force-recreate open-seo
```
Then open **Integrations**, click **Connect with Google**, authorize the Google
account that owns your verified property, and pick the property to bind to your
project.
## How it works
- OpenSEO uses your Google client to run the OAuth flow and stores the resulting
grant in its database, with the access and refresh tokens **encrypted at rest**
(keyed by `BETTER_AUTH_SECRET`).
- Access tokens are minted and refreshed on demand — you only authorize once.
- Search Console data comes from your own Google account, so OpenSEO never meters credits for it.
## Troubleshooting
**`redirect_uri_mismatch` from Google** — the redirect URI in your OAuth client
must exactly equal `<your-origin>/api/gsc/oauth/callback`. Re-check scheme
(`http` vs `https`), host, port, and that there's no trailing slash.
**"Google OAuth client not configured" / "not configured for Search Console yet"**
(in the app or via the MCP tools) — one of `GOOGLE_CLIENT_ID`,
`GOOGLE_CLIENT_SECRET`, or `BETTER_AUTH_SECRET` is missing, or the secret is
shorter than 32 characters. Set all three and restart. On Docker, recreate the
container so Compose reapplies `.env`:
```bash
docker compose up -d --force-recreate open-seo
```
**`access_denied` during sign-in** — the Google account isn't listed as a test
user on the OAuth consent screen (while the app is in Testing mode). Add it under
**OAuth consent screen → Test users**.
**Connected, but no properties to pick** — the Google account you authorized
doesn't have a verified property in Search Console. Verify the site in
[Search Console](https://search.google.com/search-console) first, then reconnect.

View File

@ -0,0 +1,11 @@
INSERT OR IGNORE INTO `user` (`id`, `name`, `email`, `email_verified`, `created_at`, `updated_at`)
SELECT
`id`,
coalesce(nullif(substr(`email`, 1, instr(`email`, '@') - 1), ''), `email`),
`email`,
1,
cast(unixepoch(`created_at`) * 1000 as integer),
cast(unixepoch(`created_at`) * 1000 as integer)
FROM `delegated_users`;
--> statement-breakpoint
DROP TABLE `delegated_users`;

File diff suppressed because it is too large Load Diff

View File

@ -141,6 +141,13 @@
"when": 1780519331717, "when": 1780519331717,
"tag": "0019_true_absorbing_man", "tag": "0019_true_absorbing_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1780599087400,
"tag": "0020_drop_delegated_users",
"breakpoints": true
} }
] ]
} }

View File

@ -11,9 +11,10 @@ import { dismissGscNudge } from "@/serverFunctions/onboarding";
/** /**
* One-time re-engagement prompt nudging users who finished onboarding *before* * One-time re-engagement prompt nudging users who finished onboarding *before*
* the Search Console step existed to connect GSC. Hosted-only (the connect flow * the Search Console step existed to connect GSC. Hosted-only because this is
* needs Better Auth). Shows once server-persisted dismissal means it never * a hosted onboarding re-engagement nudge. Shows once server-persisted
* reappears after the user connects or dismisses, on any device. * dismissal means it never reappears after the user connects or dismisses, on
* any device.
* *
* `suppressed` lets the layout hide this when another modal (e.g. the missing * `suppressed` lets the layout hide this when another modal (e.g. the missing
* DataForSEO key prompt) is already showing so the two never stack. * DataForSEO key prompt) is already showing so the two never stack.

View File

@ -5,6 +5,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph"; import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
import { SitePicker } from "@/client/features/gsc/SitePicker"; import { SitePicker } from "@/client/features/gsc/SitePicker";
import { startGscLink } from "@/client/features/gsc/startGscLink"; import { startGscLink } from "@/client/features/gsc/startGscLink";
import { import {
@ -30,16 +31,17 @@ export function SearchConsoleConnectionCard({
const connectionQuery = useQuery({ const connectionQuery = useQuery({
queryKey: connectionKey, queryKey: connectionKey,
queryFn: () => getGscConnection({ data: { projectId } }), queryFn: () => getGscConnection({ data: { projectId } }),
enabled: hosted,
}); });
const connection = connectionQuery.data; const connection = connectionQuery.data;
const connected = Boolean(connection?.connected); const connected = Boolean(connection?.connected);
const selfHostedNeedsSetup =
!hosted && connectionQuery.isSuccess && !connection?.googleOAuthConfigured;
const showPicker = picking || (connection?.currentUserHasGrant && !connected); const showPicker = picking || (connection?.currentUserHasGrant && !connected);
const sitesQuery = useQuery({ const sitesQuery = useQuery({
queryKey: ["gscSites", projectId], queryKey: ["gscSites", projectId],
queryFn: () => listGscSites({ data: { projectId } }), queryFn: () => listGscSites({ data: { projectId } }),
enabled: Boolean(showPicker), enabled: Boolean(showPicker && !selfHostedNeedsSetup),
}); });
const setSiteMutation = useMutation({ const setSiteMutation = useMutation({
@ -70,21 +72,13 @@ export function SearchConsoleConnectionCard({
const handleConnect = () => void startGscLink(window.location.href); const handleConnect = () => void startGscLink(window.location.href);
if (!hosted) {
return (
<IntegrationCard>
<p className="text-sm text-base-content/60">
Available on hosted OpenSEO. Self-hosted? Use a CSV export.
</p>
</IntegrationCard>
);
}
return ( return (
<IntegrationCard <IntegrationCard
status={ status={
connectionQuery.isLoading connectionQuery.isLoading
? undefined ? undefined
: selfHostedNeedsSetup
? "setup_required"
: connected : connected
? "connected" ? "connected"
: "disconnected" : "disconnected"
@ -95,6 +89,8 @@ export function SearchConsoleConnectionCard({
<span className="loading loading-spinner loading-sm" /> <span className="loading loading-spinner loading-sm" />
Checking Checking
</div> </div>
) : selfHostedNeedsSetup ? (
<SelfHostedSetupWarning />
) : connected && !picking ? ( ) : connected && !picking ? (
<ConnectedState <ConnectedState
siteUrl={connection?.siteUrl ?? ""} siteUrl={connection?.siteUrl ?? ""}
@ -156,7 +152,7 @@ function IntegrationCard({
status, status,
children, children,
}: { }: {
status?: "connected" | "disconnected"; status?: "connected" | "disconnected" | "setup_required";
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
@ -177,24 +173,39 @@ function IntegrationCard({
); );
} }
function StatusPill({ status }: { status: "connected" | "disconnected" }) { function StatusPill({
status,
}: {
status: "connected" | "disconnected" | "setup_required";
}) {
const connected = status === "connected"; const connected = status === "connected";
const setupRequired = status === "setup_required";
return ( return (
<span <span
className={[ className={[
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium", "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
connected connected
? "border-success/30 bg-success/10 text-success" ? "border-success/30 bg-success/10 text-success"
: setupRequired
? "border-warning/30 bg-warning/10 text-warning"
: "border-base-300 bg-base-200 text-base-content/60", : "border-base-300 bg-base-200 text-base-content/60",
].join(" ")} ].join(" ")}
> >
<span <span
className={[ className={[
"size-1.5 rounded-full", "size-1.5 rounded-full",
connected ? "bg-success" : "bg-base-content/40", connected
? "bg-success"
: setupRequired
? "bg-warning"
: "bg-base-content/40",
].join(" ")} ].join(" ")}
/> />
{connected ? "Connected" : "Not connected"} {connected
? "Connected"
: setupRequired
? "Setup required"
: "Not connected"}
</span> </span>
); );
} }

View File

@ -0,0 +1,27 @@
import { AlertTriangle } from "lucide-react";
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc";
/**
* Shown in self-hosted deployments that haven't set GOOGLE_CLIENT_ID/SECRET yet
* in both the Integrations card and the onboarding step.
*/
export function SelfHostedSetupWarning() {
return (
<div className="alert alert-warning items-start text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="space-y-1">
<p className="font-medium">Google OAuth client not configured</p>
<p className="text-base-content/70">
Add your Google client ID and secret to this OpenSEO deployment before
connecting Search Console.
</p>
<SafeExternalLink
url={GSC_SELF_HOSTED_SETUP_DOCS_URL}
label="Open setup guide"
className="inline-flex items-center gap-1 font-medium underline underline-offset-2"
/>
</div>
</div>
);
}

View File

@ -1,6 +1,8 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { startSelfHostedGscLink } from "@/serverFunctions/gsc";
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc"; import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
/** /**
@ -12,6 +14,12 @@ import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
*/ */
export async function startGscLink(callbackURL: string): Promise<void> { export async function startGscLink(callbackURL: string): Promise<void> {
try { try {
if (!isHostedClientAuthMode()) {
const res = await startSelfHostedGscLink({ data: { callbackURL } });
window.location.href = res.url;
return;
}
const res = await authClient.oauth2.link({ const res = await authClient.oauth2.link({
providerId: GSC_OAUTH_PROVIDER_ID, providerId: GSC_OAUTH_PROVIDER_ID,
callbackURL, callbackURL,

View File

@ -3,11 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph"; import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
import { SitePicker } from "@/client/features/gsc/SitePicker"; import { SitePicker } from "@/client/features/gsc/SitePicker";
import { startGscLink } from "@/client/features/gsc/startGscLink"; import { startGscLink } from "@/client/features/gsc/startGscLink";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { import {
getGscConnection, getGscConnection,
listGscSites, listGscSites,
@ -19,14 +19,12 @@ import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
* Onboarding step for connecting Google Search Console: link the account-level * Onboarding step for connecting Google Search Console: link the account-level
* OAuth grant, then bind a verified property to the user's default project * OAuth grant, then bind a verified property to the user's default project
* the same binding the project's Integrations page does — so it's done in one * the same binding the project's Integrations page does — so it's done in one
* place. Hosted-only (the connect flow needs Better Auth). * place.
*/ */
export function SearchConsoleOnboardingStep() { export function SearchConsoleOnboardingStep() {
const hosted = isHostedClientAuthMode();
const projectQuery = useQuery({ const projectQuery = useQuery({
queryKey: ["defaultProject"], queryKey: ["defaultProject"],
queryFn: () => getOrCreateDefaultProject(), queryFn: () => getOrCreateDefaultProject(),
enabled: hosted,
}); });
return ( return (
@ -35,11 +33,7 @@ export function SearchConsoleOnboardingStep() {
Connect with Google Search Console now? Connect with Google Search Console now?
</h2> </h2>
{!hosted ? ( {projectQuery.data ? (
<p className="rounded-lg border border-base-300 bg-base-200/40 px-3 py-2.5 text-sm text-base-content/60">
Available on hosted OpenSEO. Self-hosted? Use a CSV export.
</p>
) : projectQuery.data ? (
<GscConnect projectId={projectQuery.data.id} /> <GscConnect projectId={projectQuery.data.id} />
) : ( ) : (
<Checking /> <Checking />
@ -66,11 +60,13 @@ function GscConnect({ projectId }: { projectId: string }) {
const connection = connectionQuery.data; const connection = connectionQuery.data;
const connected = Boolean(connection?.connected); const connected = Boolean(connection?.connected);
const hasGrant = Boolean(connection?.currentUserHasGrant); const hasGrant = Boolean(connection?.currentUserHasGrant);
const needsSetup =
connectionQuery.isSuccess && !connection?.googleOAuthConfigured;
const sitesQuery = useQuery({ const sitesQuery = useQuery({
queryKey: ["gscSites", projectId], queryKey: ["gscSites", projectId],
queryFn: () => listGscSites({ data: { projectId } }), queryFn: () => listGscSites({ data: { projectId } }),
enabled: hasGrant && !connected, enabled: hasGrant && !connected && !needsSetup,
}); });
const setSiteMutation = useMutation({ const setSiteMutation = useMutation({
@ -90,6 +86,10 @@ function GscConnect({ projectId }: { projectId: string }) {
if (connectionQuery.isLoading) return <Checking />; if (connectionQuery.isLoading) return <Checking />;
if (needsSetup) {
return <SelfHostedSetupWarning />;
}
if (connected) { if (connected) {
return ( return (
<div className="flex items-center gap-3 rounded-lg border border-success/30 bg-success/10 p-3.5 text-sm"> <div className="flex items-center gap-3 rounded-lg border border-success/30 bg-success/10 p-3.5 text-sm">

View File

@ -9,16 +9,6 @@ import {
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { organization, user } from "./better-auth-schema"; import { organization, user } from "./better-auth-schema";
// This stores users for Cloudflare Access and local_noauth mode
// since they don't map to better-auth's user schema
export const delegatedUsers = sqliteTable("delegated_users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
export const userOnboardingAnswers = sqliteTable( export const userOnboardingAnswers = sqliteTable(
"user_onboarding_answers", "user_onboarding_answers",
{ {

View File

@ -3,7 +3,7 @@ import { sql } from "drizzle-orm";
import { organization } from "./better-auth-schema"; import { organization } from "./better-auth-schema";
import { projects } from "./app.schema"; import { projects } from "./app.schema";
// Connected Google Search Console property per project (hosted-only). // Connected Google Search Console property per project.
// OAuth tokens live in the better-auth `account` table under providerId // OAuth tokens live in the better-auth `account` table under providerId
// "google-search-console"; this row only records which verified property maps // "google-search-console"; this row only records which verified property maps
// to a project and whose grant to use when calling the GSC API. // to a project and whose grant to use when calling the GSC API.

View File

@ -1,16 +1,7 @@
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { genericOAuth, organization } from "better-auth/plugins"; import { genericOAuth, organization } from "better-auth/plugins";
import { baseAuthOptions } from "@/lib/auth-options"; import { baseAuthOptions } from "@/lib/auth-options";
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc"; import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
/** Read-only Search Console scope. openid/email/profile are also required
* the genericOAuth callback rejects with `name_is_missing` without a name claim. */
const GSC_OAUTH_SCOPES = [
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/webmasters.readonly",
];
export function createBaseAuthConfig() { export function createBaseAuthConfig() {
return { return {
@ -35,7 +26,7 @@ export function createBaseAuthConfig() {
clientSecret: env.GOOGLE_CLIENT_SECRET?.trim() ?? "", clientSecret: env.GOOGLE_CLIENT_SECRET?.trim() ?? "",
discoveryUrl: discoveryUrl:
"https://accounts.google.com/.well-known/openid-configuration", "https://accounts.google.com/.well-known/openid-configuration",
scopes: GSC_OAUTH_SCOPES, scopes: [...GSC_OAUTH_SCOPES],
accessType: "offline", // request a refresh token accessType: "offline", // request a refresh token
prompt: "consent", // force refresh-token issuance on re-consent prompt: "consent", // force refresh-token issuance on re-consent
pkce: true, pkce: true,

View File

@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start"; import { tanstackStartCookies } from "better-auth/tanstack-start";
import { db } from "@/db"; import { db } from "@/db";
import { z } from "zod"; import { z } from "zod";
import { isHostedAuthMode } from "@/lib/auth-mode";
import { createBaseAuthConfig } from "@/lib/auth-config"; import { createBaseAuthConfig } from "@/lib/auth-config";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization"; import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import { import {
@ -24,7 +25,12 @@ const hostedBaseUrlSchema = z
}, "BETTER_AUTH_URL must use https or localhost"); }, "BETTER_AUTH_URL must use https or localhost");
function createAuth() { function createAuth() {
const baseUrl = getHostedBaseUrl(); // Hosted needs the real configured URL (cookies, callbacks, /api/auth routes
// all use it). Self-hosted only builds this instance to mint/refresh Search
// Console tokens, which never read baseURL — so a placeholder is fine there.
const baseUrl = isHostedAuthMode(env.AUTH_MODE)
? getHostedBaseUrl()
: "http://localhost";
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
const baseAuthConfig = createBaseAuthConfig(); const baseAuthConfig = createBaseAuthConfig();
@ -142,11 +148,14 @@ export function getHostedBaseUrl() {
return hostedBaseUrlSchema.parse(baseUrl); return hostedBaseUrlSchema.parse(baseUrl);
} }
// Required in hosted mode, and in self-hosted mode when Search Console is
// enabled (it keys the OAuth-token encryption and is needed to build the auth
// instance that mints/refreshes Search Console tokens).
function getHostedSecret() { function getHostedSecret() {
const secret = env.BETTER_AUTH_SECRET?.trim(); const secret = env.BETTER_AUTH_SECRET?.trim();
if (!secret) { if (!secret) {
throw new Error("BETTER_AUTH_SECRET is required in hosted mode"); throw new Error("BETTER_AUTH_SECRET is required");
} }
if (secret.length < 32) { if (secret.length < 32) {
@ -157,6 +166,15 @@ function getHostedSecret() {
} }
function getSocialProviders() { function getSocialProviders() {
// Google social login is hosted-only. Self-hosted builds the auth instance
// solely for Search Console token ops, which use the genericOAuth provider
// (createBaseAuthConfig) with its own creds — so it must NOT require the
// social-login config here, otherwise getAuth() construction would be coupled
// to GSC creds rather than just BETTER_AUTH_SECRET.
if (!isHostedAuthMode(env.AUTH_MODE)) {
return {};
}
return { return {
google: getGoogleSocialProviderConfig(), google: getGoogleSocialProviderConfig(),
}; };

View File

@ -1,5 +1,5 @@
import { db } from "@/db"; import { db } from "@/db";
import { delegatedUsers } from "@/db/schema"; import { user } from "@/db/schema";
import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization"; import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { EnsuredUserContext } from "./types"; import type { EnsuredUserContext } from "./types";
@ -7,30 +7,49 @@ import type { EnsuredUserContext } from "./types";
const LOCAL_ADMIN_USER_ID = "local-admin"; const LOCAL_ADMIN_USER_ID = "local-admin";
const LOCAL_ADMIN_EMAIL = "admin@localhost"; const LOCAL_ADMIN_EMAIL = "admin@localhost";
// Externally-authenticated users (Cloudflare Access, local_noauth) are stored
// in better-auth's `user` table just like hosted users — only the way we
// authenticate them differs (per-request, no better-auth session). Keeping a
// single user table means the OAuth `account` grant and every app table that
// references `user.id` resolve the same way in all auth modes.
function deriveUserName(email: string) {
return email.split("@")[0] || "OpenSEO";
}
async function ensureUserRecord(userId: string, userEmail: string) { async function ensureUserRecord(userId: string, userEmail: string) {
const existingUser = await db.query.delegatedUsers.findFirst({ const existing = await db.query.user.findFirst({
where: eq(delegatedUsers.id, userId), columns: { email: true },
where: eq(user.id, userId),
}); });
if (!existingUser) { if (!existing) {
await db.insert(delegatedUsers).values({ // Concurrent first-load requests can all see "no row" and race to insert
id: userId, // the same id; onConflictDoNothing on the PK makes the losers no-ops instead
email: userEmail, // of failing. Scoped to the id so a genuine email-unique collision (two
}); // distinct ids sharing an email) still surfaces loudly.
return userEmail;
}
if (existingUser.email !== userEmail) {
await db await db
.update(delegatedUsers) .insert(user)
.set({ email: userEmail }) .values({
.where(eq(delegatedUsers.id, userId)); id: userId,
name: deriveUserName(userEmail),
email: userEmail,
emailVerified: true,
})
.onConflictDoNothing({ target: user.id });
return userEmail; return userEmail;
} }
return existingUser.email; if (existing.email !== userEmail) {
await db
.update(user)
.set({ email: userEmail, name: deriveUserName(userEmail) })
.where(eq(user.id, userId));
return userEmail;
}
return existing.email;
} }
export async function resolveDelegatedContext( export async function resolveDelegatedContext(

View File

@ -29,6 +29,7 @@ import { Route as AppAiRouteImport } from './routes/_app/ai'
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge' import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
@ -144,6 +145,11 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
path: '/api/auth/$', path: '/api/auth/$',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
id: '/api/gsc/oauth/callback',
path: '/api/gsc/oauth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
id: '/help/dataforseo-api-key', id: '/help/dataforseo-api-key',
path: '/help/dataforseo-api-key', path: '/help/dataforseo-api-key',
@ -254,6 +260,7 @@ export interface FileRoutesByFullPath {
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
@ -287,6 +294,7 @@ export interface FileRoutesByTo {
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
@ -324,6 +332,7 @@ export interface FileRoutesById {
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
@ -360,6 +369,7 @@ export interface FileRouteTypes {
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/gsc/oauth/callback'
| '/p/$projectId/audit' | '/p/$projectId/audit'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
@ -393,6 +403,7 @@ export interface FileRouteTypes {
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/gsc/oauth/callback'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
| '/p/$projectId/domain' | '/p/$projectId/domain'
@ -429,6 +440,7 @@ export interface FileRouteTypes {
| '/_app/help/dataforseo-api-key' | '/_app/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/gsc/oauth/callback'
| '/_project/p/$projectId/audit' | '/_project/p/$projectId/audit'
| '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/backlinks'
| '/_project/p/$projectId/brand-lookup' | '/_project/p/$projectId/brand-lookup'
@ -456,6 +468,7 @@ export interface RootRouteChildren {
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -600,6 +613,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthSplatRouteImport preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/gsc/oauth/callback': {
id: '/api/gsc/oauth/callback'
path: '/api/gsc/oauth/callback'
fullPath: '/api/gsc/oauth/callback'
preLoaderRoute: typeof ApiGscOauthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_app/help/dataforseo-api-key': { '/_app/help/dataforseo-api-key': {
id: '/_app/help/dataforseo-api-key' id: '/_app/help/dataforseo-api-key'
path: '/help/dataforseo-api-key' path: '/help/dataforseo-api-key'
@ -857,6 +877,7 @@ const rootRouteChildren: RootRouteChildren = {
Char91DotwellKnownChar93OpenaiAppsChallengeRoute, Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute,
ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@ -0,0 +1,62 @@
import { createFileRoute } from "@tanstack/react-router";
import { env } from "cloudflare:workers";
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
import { AppError } from "@/server/lib/errors";
import { handleSelfHostedGscOAuthCallback } from "@/server/features/gsc/selfHostedOAuth";
import { getPublicOrigin } from "@/server/mcp/public-origin";
async function resolveSelfHostedContext(request: Request) {
const authMode = getAuthMode(env.AUTH_MODE);
if (isHostedAuthMode(authMode)) return null;
return authMode === "local_noauth"
? resolveLocalNoAuthContext()
: resolveCloudflareAccessContext(request.headers);
}
function responseForError(error: unknown) {
if (error instanceof AppError) {
const status =
error.code === "UNAUTHENTICATED"
? 401
: error.code === "FORBIDDEN"
? 403
: error.code === "VALIDATION_ERROR"
? 400
: 500;
return new Response(error.message, { status });
}
return new Response("Search Console OAuth failed", { status: 500 });
}
async function handleCallbackRequest(request: Request) {
try {
const context = await resolveSelfHostedContext(request);
if (!context) return new Response("Not found", { status: 404 });
return await handleSelfHostedGscOAuthCallback({
request,
user: {
userId: context.userId,
userEmail: context.userEmail,
},
publicOrigin: getPublicOrigin(request),
});
} catch (error) {
return responseForError(error);
}
}
export const Route = createFileRoute("/api/gsc/oauth/callback")({
server: {
handlers: {
GET: async ({ request }: { request: Request }) => {
return handleCallbackRequest(request);
},
},
},
});

View File

@ -0,0 +1,28 @@
import { getOptionalEnvValue } from "@/server/lib/runtime-env";
type GscOAuthClientConfig = {
clientId: string;
clientSecret: string;
};
export async function getGscOAuthClientConfig(): Promise<GscOAuthClientConfig | null> {
const clientId = (await getOptionalEnvValue("GOOGLE_CLIENT_ID"))?.trim();
const clientSecret = (
await getOptionalEnvValue("GOOGLE_CLIENT_SECRET")
)?.trim();
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
// Self-hosted Search Console needs the Google OAuth client AND BETTER_AUTH_SECRET
// (>=32 chars): the secret keys OAuth-token encryption and lets us build the
// Better Auth instance that mints/refreshes tokens. Both must be set before we
// surface the connect flow.
export async function hasSelfHostedGscConfig(): Promise<boolean> {
if (!(await getGscOAuthClientConfig())) return false;
const secret = (await getOptionalEnvValue("BETTER_AUTH_SECRET"))?.trim();
return Boolean(secret && secret.length >= 32);
}

View File

@ -0,0 +1,334 @@
import { symmetricEncrypt } from "better-auth/crypto";
import { and, eq } from "drizzle-orm";
import { decodeJwt } from "jose";
import { z } from "zod";
import { db } from "@/db";
import { account } from "@/db/schema";
import { getAuth } from "@/lib/auth";
import { AppError } from "@/server/lib/errors";
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
import {
getGscOAuthClientConfig,
hasSelfHostedGscConfig,
} from "./oauth-config";
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
type SelfHostedGscUser = {
userId: string;
userEmail: string;
};
const oauthStateSchema = z.object({
userId: z.string().min(1),
callbackPath: z.string().min(1),
exp: z.number().int(),
});
const googleTokenResponseSchema = z.object({
access_token: z.string().min(1),
expires_in: z.number().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
id_token: z.string().optional(),
token_type: z.string().optional(),
});
const googleIdTokenSchema = z.object({
sub: z.string().min(1),
});
type GoogleTokenResponse = z.infer<typeof googleTokenResponseSchema>;
function bytesToBase64Url(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
function base64UrlToBytes(value: string) {
const padded = `${value}${"=".repeat((4 - (value.length % 4)) % 4)}`;
const binary = atob(padded.replaceAll("-", "+").replaceAll("_", "/"));
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
async function getStateKey(clientSecret: string) {
return crypto.subtle.importKey(
"raw",
new TextEncoder().encode(`openseo:gsc:${clientSecret}`),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
async function signState(payload: string, clientSecret: string) {
const signature = await crypto.subtle.sign(
"HMAC",
await getStateKey(clientSecret),
new TextEncoder().encode(payload),
);
return bytesToBase64Url(new Uint8Array(signature));
}
function getSafeCallbackPath(callbackURL: string, publicOrigin: string) {
try {
const url = new URL(callbackURL, publicOrigin);
if (url.origin !== publicOrigin) return "/";
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return "/";
}
}
async function createState(input: {
clientSecret: string;
userId: string;
callbackURL: string;
publicOrigin: string;
}) {
const payload = bytesToBase64Url(
new TextEncoder().encode(
JSON.stringify({
userId: input.userId,
callbackPath: getSafeCallbackPath(
input.callbackURL,
input.publicOrigin,
),
exp: Date.now() + 10 * 60 * 1_000,
}),
),
);
const signature = await signState(payload, input.clientSecret);
return `${payload}.${signature}`;
}
async function verifyState(state: string, clientSecret: string) {
const [payload, signature] = state.split(".");
if (!payload || !signature) {
throw new AppError("VALIDATION_ERROR", "Invalid Search Console state");
}
const ok = await crypto.subtle.verify(
"HMAC",
await getStateKey(clientSecret),
base64UrlToBytes(signature),
new TextEncoder().encode(payload),
);
if (!ok) {
throw new AppError("VALIDATION_ERROR", "Invalid Search Console state");
}
const parsed = oauthStateSchema.parse(
JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload))),
);
if (parsed.exp < Date.now()) {
throw new AppError("VALIDATION_ERROR", "Expired Search Console state");
}
return parsed;
}
function getRedirectUri(publicOrigin: string) {
return `${publicOrigin}/api/gsc/oauth/callback`;
}
function accessTokenExpiresAt(tokens: GoogleTokenResponse) {
return new Date(Date.now() + (tokens.expires_in ?? 3600) * 1_000);
}
function storedScope(tokens: GoogleTokenResponse) {
return tokens.scope
? tokens.scope.trim().split(/\s+/).join(",")
: GSC_OAUTH_SCOPES.join(",");
}
function getGoogleAccountId(tokens: GoogleTokenResponse) {
if (!tokens.id_token) {
throw new AppError(
"VALIDATION_ERROR",
"Google did not return an ID token for Search Console.",
);
}
return googleIdTokenSchema.parse(decodeJwt(tokens.id_token)).sub;
}
async function upsertGrant(input: {
user: SelfHostedGscUser;
tokens: GoogleTokenResponse;
}) {
// Encrypt tokens at rest exactly the way Better Auth's setTokenUtil does
// (same key from BETTER_AUTH_SECRET, same crypto, same encryptOAuthTokens
// gate), so getAccessToken decrypts them on read — and so flipping the flag
// can never desync the write and read paths.
const ctx = await getAuth().$context;
const encrypt = (value: string) =>
ctx.options.account?.encryptOAuthTokens
? symmetricEncrypt({ key: ctx.secretConfig, data: value })
: value;
const existing = await db
.select({ id: account.id, refreshToken: account.refreshToken })
.from(account)
.where(
and(
eq(account.userId, input.user.userId),
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
),
)
.limit(1);
const accountValues = {
accountId: getGoogleAccountId(input.tokens),
providerId: GSC_OAUTH_PROVIDER_ID,
userId: input.user.userId,
accessToken: await encrypt(input.tokens.access_token),
// A fresh refresh token is encrypted here; an absent one falls back to the
// already-encrypted value stored on the existing grant.
refreshToken: input.tokens.refresh_token
? await encrypt(input.tokens.refresh_token)
: (existing[0]?.refreshToken ?? null),
idToken: input.tokens.id_token
? await encrypt(input.tokens.id_token)
: null,
accessTokenExpiresAt: accessTokenExpiresAt(input.tokens),
refreshTokenExpiresAt: null,
scope: storedScope(input.tokens),
password: null,
};
if (existing[0]) {
await db
.update(account)
.set({ ...accountValues, updatedAt: new Date() })
.where(eq(account.id, existing[0].id));
return;
}
await db.insert(account).values({
id: crypto.randomUUID(),
...accountValues,
createdAt: new Date(),
updatedAt: new Date(),
});
}
async function exchangeCode(input: {
code: string;
clientId: string;
clientSecret: string;
redirectUri: string;
}) {
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code: input.code,
client_id: input.clientId,
client_secret: input.clientSecret,
redirect_uri: input.redirectUri,
grant_type: "authorization_code",
}),
});
if (!response.ok) {
throw new AppError(
"VALIDATION_ERROR",
"Google rejected the Search Console authorization code.",
);
}
return googleTokenResponseSchema.parse(await response.json());
}
export async function createSelfHostedGscAuthorizationUrl(input: {
user: SelfHostedGscUser;
callbackURL: string;
publicOrigin: string;
}) {
const config = await getGscOAuthClientConfig();
if (!config || !(await hasSelfHostedGscConfig())) {
throw new AppError(
"AUTH_CONFIG_MISSING",
"Search Console is not configured. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET.",
);
}
const redirectUri = getRedirectUri(input.publicOrigin);
const state = await createState({
clientSecret: config.clientSecret,
userId: input.user.userId,
callbackURL: input.callbackURL,
publicOrigin: input.publicOrigin,
});
const url = new URL(GOOGLE_AUTH_URL);
url.searchParams.set("client_id", config.clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", GSC_OAUTH_SCOPES.join(" "));
url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent");
url.searchParams.set("state", state);
return url.toString();
}
export async function handleSelfHostedGscOAuthCallback(input: {
request: Request;
user: SelfHostedGscUser;
publicOrigin: string;
}) {
const config = await getGscOAuthClientConfig();
if (!config) {
return new Response("Missing Google Search Console OAuth configuration", {
status: 500,
});
}
const url = new URL(input.request.url);
const stateParam = url.searchParams.get("state");
if (!stateParam) {
return new Response("Missing Search Console OAuth state", { status: 400 });
}
const state = await verifyState(stateParam, config.clientSecret);
if (state.userId !== input.user.userId) {
return new Response("Search Console OAuth user mismatch", { status: 403 });
}
// state.callbackPath is a validated same-origin relative path
// (getSafeCallbackPath). Redirect with a *relative* Location so the browser
// resolves it against the real request origin — this avoids trusting
// x-forwarded-host for the final hop.
const redirectToCallback = () =>
new Response(null, {
status: 303,
headers: { Location: state.callbackPath },
});
if (url.searchParams.get("error")) {
return redirectToCallback();
}
const code = url.searchParams.get("code");
if (!code) {
return new Response("Missing Search Console OAuth code", { status: 400 });
}
const tokens = await exchangeCode({
code,
clientId: config.clientId,
clientSecret: config.clientSecret,
redirectUri: getRedirectUri(input.publicOrigin),
});
await upsertGrant({ user: input.user, tokens });
return redirectToCallback();
}

View File

@ -103,8 +103,10 @@ export function createGscClient(opts: { userId: string }) {
async function getToken(): Promise<string> { async function getToken(): Promise<string> {
let result: { accessToken?: string } | undefined; let result: { accessToken?: string } | undefined;
try { try {
// Headerless call: getAccessToken trusts body.userId only when no request // Headerless call: getAccessToken trusts body.userId when no request
// session is present, and auto-refreshes via the genericOAuth provider. // session is present, and auto-refreshes via the genericOAuth provider.
// Works in every auth mode — self-hosted builds the same Better Auth
// instance once BETTER_AUTH_SECRET is set.
result = await getAuth().api.getAccessToken({ result = await getAuth().api.getAccessToken({
body: { providerId: GSC_OAUTH_PROVIDER_ID, userId: opts.userId }, body: { providerId: GSC_OAUTH_PROVIDER_ID, userId: opts.userId },
}); });

View File

@ -2,7 +2,9 @@ import { isHostedAuthMode } from "@/lib/auth-mode";
let workersEnvPromise: Promise<Record<string, unknown> | null> | null = null; let workersEnvPromise: Promise<Record<string, unknown> | null> | null = null;
async function getEnvValue(name: string): Promise<string | undefined> { export async function getOptionalEnvValue(
name: string,
): Promise<string | undefined> {
const processValue = const processValue =
typeof process !== "undefined" ? process.env?.[name] : undefined; typeof process !== "undefined" ? process.env?.[name] : undefined;
if (processValue) { if (processValue) {
@ -15,7 +17,7 @@ async function getEnvValue(name: string): Promise<string | undefined> {
} }
export async function getRequiredEnvValue(name: string): Promise<string> { export async function getRequiredEnvValue(name: string): Promise<string> {
const value = await getEnvValue(name); const value = await getOptionalEnvValue(name);
if (!value) { if (!value) {
throw new Error(`Missing required environment variable: ${name}`); throw new Error(`Missing required environment variable: ${name}`);
} }
@ -23,7 +25,7 @@ export async function getRequiredEnvValue(name: string): Promise<string> {
} }
export async function isHostedServerAuthMode(): Promise<boolean> { export async function isHostedServerAuthMode(): Promise<boolean> {
return isHostedAuthMode(await getEnvValue("AUTH_MODE")); return isHostedAuthMode(await getOptionalEnvValue("AUTH_MODE"));
} }
async function getWorkersEnv(): Promise<Record<string, unknown> | null> { async function getWorkersEnv(): Promise<Record<string, unknown> | null> {

View File

@ -6,6 +6,7 @@ import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(), getProjectForOrganization: vi.fn(),
isHostedServerAuthMode: vi.fn(), isHostedServerAuthMode: vi.fn(),
hasSelfHostedGscConfig: vi.fn(),
GscService: { GscService: {
getPerformance: vi.fn(), getPerformance: vi.fn(),
inspectUrls: vi.fn(), inspectUrls: vi.fn(),
@ -33,6 +34,9 @@ vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/server/lib/runtime-env", () => ({ vi.mock("@/server/lib/runtime-env", () => ({
isHostedServerAuthMode: mocks.isHostedServerAuthMode, isHostedServerAuthMode: mocks.isHostedServerAuthMode,
})); }));
vi.mock("@/server/features/gsc/oauth-config", () => ({
hasSelfHostedGscConfig: mocks.hasSelfHostedGscConfig,
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({ vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: { ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization, getProjectForOrganization: mocks.getProjectForOrganization,
@ -75,6 +79,8 @@ describe("search console MCP tools", () => {
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" }); mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
mocks.isHostedServerAuthMode.mockReset(); mocks.isHostedServerAuthMode.mockReset();
mocks.isHostedServerAuthMode.mockResolvedValue(true); mocks.isHostedServerAuthMode.mockResolvedValue(true);
mocks.hasSelfHostedGscConfig.mockReset();
mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
mocks.GscService.getPerformance.mockReset(); mocks.GscService.getPerformance.mockReset();
mocks.GscService.inspectUrls.mockReset(); mocks.GscService.inspectUrls.mockReset();
}); });
@ -211,8 +217,9 @@ describe("search console MCP tools", () => {
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled(); expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
}); });
it("returns a hosted-only message in self-hosted mode", async () => { it("returns a setup message in self-hosted mode without a Google client", async () => {
mocks.isHostedServerAuthMode.mockResolvedValue(false); mocks.isHostedServerAuthMode.mockResolvedValue(false);
mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
const { getSearchConsolePerformanceTool } = const { getSearchConsolePerformanceTool } =
await import("./search-console-tools"); await import("./search-console-tools");
@ -221,10 +228,40 @@ describe("search console MCP tools", () => {
toolExtra, toolExtra,
); );
expect(result.structuredContent).toMatchObject({ reason: "hosted_only" }); expect(result.structuredContent).toMatchObject({
reason: "gsc_oauth_not_configured",
});
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled(); expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
}); });
it("allows performance queries in self-hosted mode with a Google client", async () => {
mocks.isHostedServerAuthMode.mockResolvedValue(false);
mocks.hasSelfHostedGscConfig.mockResolvedValue(true);
mocks.GscService.getPerformance.mockResolvedValue({
siteUrl: "https://example.com/",
connectedBy: "alice@example.com",
request: {
dimensions: ["query"],
startDate: "2026-04-27",
endDate: "2026-05-25",
rowLimit: 1000,
},
rows: [],
});
const { getSearchConsolePerformanceTool } =
await import("./search-console-tools");
const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1" },
toolExtra,
);
expect(mocks.GscService.getPerformance).toHaveBeenCalledWith(
expect.objectContaining({ projectId: "project_1" }),
);
expect(result.structuredContent).toMatchObject({ ok: true });
});
it("inspects multiple URLs and reports partial failures inline", async () => { it("inspects multiple URLs and reports partial failures inline", async () => {
mocks.GscService.inspectUrls.mockResolvedValue({ mocks.GscService.inspectUrls.mockResolvedValue({
siteUrl: "sc-domain:example.com", siteUrl: "sc-domain:example.com",
@ -285,8 +322,9 @@ describe("search console MCP tools", () => {
}); });
}); });
it("returns a hosted-only message for inspect_urls in self-hosted mode", async () => { it("returns a setup message for inspect_urls in self-hosted mode without a Google client", async () => {
mocks.isHostedServerAuthMode.mockResolvedValue(false); mocks.isHostedServerAuthMode.mockResolvedValue(false);
mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
const { inspectUrlsTool } = await import("./search-console-tools"); const { inspectUrlsTool } = await import("./search-console-tools");
const result = await inspectUrlsTool.handler( const result = await inspectUrlsTool.handler(
@ -294,7 +332,9 @@ describe("search console MCP tools", () => {
toolExtra, toolExtra,
); );
expect(result.structuredContent).toMatchObject({ reason: "hosted_only" }); expect(result.structuredContent).toMatchObject({
reason: "gsc_oauth_not_configured",
});
expect(mocks.GscService.inspectUrls).not.toHaveBeenCalled(); expect(mocks.GscService.inspectUrls).not.toHaveBeenCalled();
}); });
}); });

View File

@ -6,6 +6,7 @@ import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { projectIdSchema } from "@/server/mcp/schemas"; import { projectIdSchema } from "@/server/mcp/schemas";
import { buildDashboardUrl } from "@/server/mcp/urls"; import { buildDashboardUrl } from "@/server/mcp/urls";
import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { import {
GscNotConnectedError, GscNotConnectedError,
@ -21,6 +22,7 @@ import {
type GscPerformanceInput, type GscPerformanceInput,
} from "@/server/features/gsc/searchAnalytics"; } from "@/server/features/gsc/searchAnalytics";
import { GscApiError, GscTokenError } from "@/server/lib/gscClient"; import { GscApiError, GscTokenError } from "@/server/lib/gscClient";
import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc";
const TEXT_SUMMARY_ROWS = 15; const TEXT_SUMMARY_ROWS = 15;
@ -33,17 +35,28 @@ function integrationsUrl(baseUrl: string, projectId: string): string {
return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`); return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`);
} }
/** GSC connect requires Better Auth, which only runs in hosted mode. In /** Self-hosted GSC requires the operator to provide a Google OAuth client and
* self-hosted deployments the tools return this instead of a broken flow. */ * BETTER_AUTH_SECRET. Hosted mode always has both; self-hosted tools return this
async function hostedOnlyResponse( * setup nudge before attempting a token lookup when either is missing. */
async function missingSelfHostedGoogleClientResponse(
context: ProjectAuthContext, context: ProjectAuthContext,
projectId: string, projectId: string,
) { ) {
if (await isHostedServerAuthMode()) return null; const [hosted, configured] = await Promise.all([
isHostedServerAuthMode(),
hasSelfHostedGscConfig(),
]);
if (hosted || configured) return null;
return mcpResponse({ return mcpResponse({
text: "Google Search Console connect is only available on the hosted OpenSEO service, not in self-hosted mode. Use a GSC CSV export instead.", text: `This self-hosted OpenSEO deployment is not configured for Search Console yet. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET, then reconnect Search Console from Integrations. Setup docs: ${GSC_SELF_HOSTED_SETUP_DOCS_URL}`,
meta: buildProjectMeta(context, projectId), meta: buildProjectMeta(context, projectId),
structuredContent: { ok: false, connected: false, reason: "hosted_only" }, structuredContent: {
ok: false,
connected: false,
reason: "gsc_oauth_not_configured",
setupDocsUrl: GSC_SELF_HOSTED_SETUP_DOCS_URL,
},
}); });
} }
@ -147,6 +160,7 @@ export const getSearchConsolePerformanceTool = {
ok: z.boolean(), ok: z.boolean(),
reason: z.string().optional(), reason: z.string().optional(),
connectUrl: z.string().optional(), connectUrl: z.string().optional(),
setupDocsUrl: z.string().optional(),
siteUrl: z.string().optional(), siteUrl: z.string().optional(),
startDate: z.string().optional(), startDate: z.string().optional(),
endDate: z.string().optional(), endDate: z.string().optional(),
@ -176,7 +190,10 @@ export const getSearchConsolePerformanceTool = {
}, },
}, },
handler: withMcpProjectAuth(async (args: PerfArgs, context) => { handler: withMcpProjectAuth(async (args: PerfArgs, context) => {
const blocked = await hostedOnlyResponse(context, args.projectId); const blocked = await missingSelfHostedGoogleClientResponse(
context,
args.projectId,
);
if (blocked) return blocked; if (blocked) return blocked;
const connectUrl = integrationsUrl(context.baseUrl, args.projectId); const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
@ -292,6 +309,7 @@ export const inspectUrlsTool = {
ok: z.boolean(), ok: z.boolean(),
reason: z.string().optional(), reason: z.string().optional(),
connectUrl: z.string().optional(), connectUrl: z.string().optional(),
setupDocsUrl: z.string().optional(),
siteUrl: z.string().optional(), siteUrl: z.string().optional(),
results: z results: z
.array( .array(
@ -313,7 +331,10 @@ export const inspectUrlsTool = {
}, },
}, },
handler: withMcpProjectAuth(async (args: InspectArgs, context) => { handler: withMcpProjectAuth(async (args: InspectArgs, context) => {
const blocked = await hostedOnlyResponse(context, args.projectId); const blocked = await missingSelfHostedGoogleClientResponse(
context,
args.projectId,
);
if (blocked) return blocked; if (blocked) return blocked;
const connectUrl = integrationsUrl(context.baseUrl, args.projectId); const connectUrl = integrationsUrl(context.baseUrl, args.projectId);

View File

@ -1,8 +1,13 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { waitUntil } from "cloudflare:workers"; import { waitUntil } from "cloudflare:workers";
import { z } from "zod"; import { z } from "zod";
import { GscService } from "@/server/features/gsc/services/GscService"; import { GscService } from "@/server/features/gsc/services/GscService";
import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config";
import { createSelfHostedGscAuthorizationUrl } from "@/server/features/gsc/selfHostedOAuth";
import { captureServerEvent } from "@/server/lib/posthog"; import { captureServerEvent } from "@/server/lib/posthog";
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { import {
requireAuthenticatedContext, requireAuthenticatedContext,
requireProjectContext, requireProjectContext,
@ -12,6 +17,9 @@ const projectScopedSchema = z.object({ projectId: z.string().min(1) });
const setSiteSchema = projectScopedSchema.extend({ const setSiteSchema = projectScopedSchema.extend({
siteUrl: z.string().min(1), siteUrl: z.string().min(1),
}); });
const startSelfHostedLinkSchema = z.object({
callbackURL: z.string().min(1),
});
// Account-level grant check (no project needed) for surfaces like onboarding // Account-level grant check (no project needed) for surfaces like onboarding
// where the user hasn't picked a project yet. The OAuth grant is per-account; // where the user hasn't picked a project yet. The OAuth grant is per-account;
@ -26,13 +34,17 @@ export const getGscConnection = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => projectScopedSchema.parse(data)) .inputValidator((data: unknown) => projectScopedSchema.parse(data))
.handler(async ({ context }) => { .handler(async ({ context }) => {
const [connection, currentUserHasGrant] = await Promise.all([ const [connection, currentUserHasGrant, hosted, gscConfigured] =
await Promise.all([
GscService.getConnection(context.projectId), GscService.getConnection(context.projectId),
GscService.userHasGrant(context.userId), GscService.userHasGrant(context.userId),
isHostedServerAuthMode(),
hasSelfHostedGscConfig(),
]); ]);
return { return {
connected: Boolean(connection), connected: Boolean(connection),
currentUserHasGrant, currentUserHasGrant,
googleOAuthConfigured: hosted || gscConfigured,
siteUrl: connection?.siteUrl ?? null, siteUrl: connection?.siteUrl ?? null,
connectedByEmail: connection?.connectedAccountEmail ?? null, connectedByEmail: connection?.connectedAccountEmail ?? null,
connectedAt: connection?.createdAt ?? null, connectedAt: connection?.createdAt ?? null,
@ -97,3 +109,20 @@ export const disconnectGsc = createServerFn({ method: "POST" })
); );
return { connected: false as const }; return { connected: false as const };
}); });
export const startSelfHostedGscLink = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => startSelfHostedLinkSchema.parse(data))
.handler(async ({ data, context }) => {
const publicOrigin = getPublicOrigin(getRequest());
const url = await createSelfHostedGscAuthorizationUrl({
user: {
userId: context.userId,
userEmail: context.userEmail,
},
callbackURL: data.callbackURL,
publicOrigin,
});
return { url };
});

View File

@ -29,7 +29,7 @@ export const getOnboardingAnswers = createServerFn({ method: "GET" })
}, },
where: eq(userOnboardingAnswers.userId, context.userId), where: eq(userOnboardingAnswers.userId, context.userId),
}); });
const hostedUser = await db.query.user.findFirst({ const userRecord = await db.query.user.findFirst({
columns: { columns: {
createdAt: true, createdAt: true,
}, },
@ -53,7 +53,7 @@ export const getOnboardingAnswers = createServerFn({ method: "GET" })
return { return {
completedAt: answers?.completedAt ?? null, completedAt: answers?.completedAt ?? null,
gscNudgeDismissedAt: answers?.gscNudgeDismissedAt ?? null, gscNudgeDismissedAt: answers?.gscNudgeDismissedAt ?? null,
userCreatedAt: hostedUser?.createdAt?.toISOString() ?? null, userCreatedAt: userRecord?.createdAt?.toISOString() ?? null,
answers: { answers: {
interestedFeatures, interestedFeatures,
workFor: answers?.workFor ?? null, workFor: answers?.workFor ?? null,

View File

@ -2,3 +2,13 @@
* Kept in `shared` so both server (auth config, GSC client) and client (connect * Kept in `shared` so both server (auth config, GSC client) and client (connect
* button) can reference it without importing the server-only auth config. */ * button) can reference it without importing the server-only auth config. */
export const GSC_OAUTH_PROVIDER_ID = "google-search-console"; export const GSC_OAUTH_PROVIDER_ID = "google-search-console";
export const GSC_OAUTH_SCOPES = [
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/webmasters.readonly",
] as const;
export const GSC_SELF_HOSTED_SETUP_DOCS_URL =
"https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md";