Skip to content

OAuth Connector Flow

Connecting a Google property (Search Console or GA4) to a project is a standard OAuth 2.0 authorization-code flow with offline access, implemented across two Astro API routes. This is separate from user authentication — it grants VisibilityIQ delegated read access to a Google data source on behalf of the project owner. The user is already signed in (session-authenticated) before this flow starts.

Both routes are dynamic per provider: src/pages/api/auth/[provider]/connect.ts and src/pages/api/auth/[provider]/callback.ts. The provider path segment is gsc (Search Console) or ga4 (Analytics 4).

The browser hits GET /api/auth/{provider}/connect?projectId={id}. The route resolves the Google OAuth client ID (from env.GOOGLE_CLIENT_ID, or falling back to a per-project client_id stored in the connection’s token_data), then builds the Google consent URL and 302-redirects to it:

https://accounts.google.com/o/oauth2/v2/auth
?client_id=...
&redirect_uri={origin}/api/auth/{provider}/callback
&response_type=code
&scope={scope}
&access_type=offline
&prompt=consent
&state={base64(JSON)}

access_type=offline plus prompt=consent is what guarantees Google returns a refresh token — without both, repeat authorizations omit it. The state parameter is a base64-encoded JSON payload carrying { projectId, provider, redirectUri }, round-tripped back on the callback for CSRF-binding and context.

If no client ID can be resolved the route redirects to …/connectors?error=oauth-not-configured; an unsupported provider returns 400 { success: false, error: "Unsupported provider: …" }; a missing projectId returns 400.

Scopes are read-leaning and defined directly in connect.ts:

ProviderScopes requested
gschttps://www.googleapis.com/auth/webmasters.readonly and https://www.googleapis.com/auth/webmasters
ga4https://www.googleapis.com/auth/analytics.readonly

GA4 is strictly read-only. GSC requests the read-only scope plus the broader webmasters scope (used for sitemap submission and URL-inspection actions); request only what a project actually needs.

Google redirects back to GET /api/auth/{provider}/callback?code=…&state=…. The route:

  1. Short-circuits to …/?error=oauth-denied&provider=… if Google returned an error param.
  2. Returns 400 if code or state is missing, or if state fails to JSON.parse(atob(...)).
  3. Resolves client_id / client_secret (env first, then the project’s stored token_data).
  4. Exchanges the code at https://oauth2.googleapis.com/token (grant_type=authorization_code, 20-second AbortSignal.timeout).

A failed exchange redirects to …/connectors?error=oauth-token-failed&source={provider}. On success the token response (access_token, refresh_token?, expires_in, scope) is persisted.

Tokens are written through upsertConnection (src/lib/db/connections.ts) into the external_connections table. The token JSON — client_id, client_secret, access_token, refresh_token, expiry_date (computed as Date.now() + expires_in * 1000), and scope — is serialized into the token_data column, which upsertConnection encrypts via encryptSecret before writing. The row is upserted on the (project_id, source) unique constraint with status = 'connected' and scope_summary set to the granted scope string. The callback then redirects the user back to …/connectors?status=connection-saved&source={provider}.

-- external_connections (migration 001, source CHECK extended through 027)
INSERT INTO external_connections
(id, project_id, source, status, account_name, account_email,
property_id, sync_cadence, scope_summary, token_data, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, source) DO UPDATE SET
status = excluded.status,
token_data = COALESCE(excluded.token_data, token_data),
scope_summary = excluded.scope_summary,
updated_at = excluded.updated_at;

Reads go back through getConnection, which transparently decryptSecrets token_data — credentials never leave the connector layer in plaintext at rest.

There is no dedicated refresh route. The stored expiry_date lets a consumer detect an expired access_token and re-mint one from the stored refresh_token against https://oauth2.googleapis.com/token with grant_type=refresh_token. Because prompt=consent + access_type=offline guaranteed a refresh token at authorization time, the connection survives access-token expiry without re-prompting the user. If a refresh ever fails, the connection’s status is meant to move to reauth-required, which the dashboard surfaces as a re-connect prompt.