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).
Step 1 — Authorize redirect
Section titled “Step 1 — Authorize redirect”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 requested
Section titled “Scopes requested”Scopes are read-leaning and defined directly in connect.ts:
| Provider | Scopes requested |
|---|---|
gsc | https://www.googleapis.com/auth/webmasters.readonly and https://www.googleapis.com/auth/webmasters |
ga4 | https://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.
Step 2 — Callback and token exchange
Section titled “Step 2 — Callback and token exchange”Google redirects back to GET /api/auth/{provider}/callback?code=…&state=…. The route:
- Short-circuits to
…/?error=oauth-denied&provider=…if Google returned anerrorparam. - Returns
400ifcodeorstateis missing, or ifstatefails toJSON.parse(atob(...)). - Resolves
client_id/client_secret(env first, then the project’s storedtoken_data). - Exchanges the code at
https://oauth2.googleapis.com/token(grant_type=authorization_code, 20-secondAbortSignal.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.
Token storage
Section titled “Token storage”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.
Refresh
Section titled “Refresh”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.
Related
Section titled “Related”- Connectors & Sync — the
external_connectionsandconnector_sync_runsschema in full - Authentication Overview — session vs API-key auth
- Data Model Overview