Every moving part, and the full round trip of the keys — from the button the user clicks to the encrypted refresh token that mints an access token on every nightly sync. Written as a 101 explainer, but every name, route and file below is the real code.
OAuth exists to answer one question: how does Campaign Manager read a user's Google Ads data without ever knowing their Google password? Five parties are involved, and each holds a different secret.
This is the part people usually get tangled in. Google Ads is unusual: it needs three distinct credentials, and they come from three different places with three different lifetimes.
| Secret | Whose is it | Where it lives | Lifetime |
|---|---|---|---|
| Client ID + secret app-level |
Campaign Manager's. One pair for the whole product — identical for every customer. | Rails credentials google_ads.client_id/.client_secret, or GOOGLE_ADS_CLIENT_ID / _SECRET env. |
Forever, until rotated in Google Cloud Console. |
| Developer token app-level |
Campaign Manager's. Google Ads-specific: it says "this software is approved to use the Ads API at all." | google_ads.developer_token / GOOGLE_ADS_DEVELOPER_TOKEN. Sent as the developer-token header. |
Forever. Not an OAuth thing — an API access thing. |
| Refresh token per-user |
The customer's. Minted once when they click Allow. Represents their consent, for their account. | Encrypted column ad_accounts.refresh_token. |
Until revoked — or ~7 days if the consent screen is in Testing mode. |
| Access token derived |
Short-lived derivative of the refresh token. Never the source of truth. | Column exists, but the live path re-mints one per call. | ~1 hour. |
The mental model: client ID + secret identify the software. The developer token proves the software is allowed near the Ads API. The refresh token identifies the person and the permission they granted. All three must be present together for a single API call to succeed — that's exactly what build_client asserts before it does anything.
This is the whole dance. Steps 1–7 happen once, in a browser, in about ten seconds. Step 8 onward repeats forever, with no user present.
Steps 3–5 travel through the user's browser as URLs. Anything in a URL can leak — browser history, referrer headers, server logs, a screenshot. So Google puts a single-use authorization code there instead of a token. A code is useless on its own.
Step 7 is a direct server-to-server POST. To redeem the code you must also present the client secret, which only Campaign Manager's server has. So even a stolen code can't be cashed in by an attacker.
That split — public code, private redemption — is the "authorization code flow". The state nonce closes the remaining hole: it proves the callback you're handling belongs to a flow this same browser session actually started.
access_type=offline — without this Google never issues a refresh token. You'd get one hour of access and then a dead connection. This single word is what makes unattended nightly syncing possible.prompt=consent — forces the consent screen even on a re-connect. Google only returns a refresh token on a fresh grant; silently re-authorizing an already-consented user returns an access token with no refresh token, and the reconnect quietly produces a broken account.After step 9 the round trip is complete but not over — it now repeats in miniature on every API call, with three secrets re-converging from two different sources.
Why that pre-flight check exists. The google-ads gem refreshes tokens inside its gRPC calls, where an auth failure looks transient and gets retried to the RPC deadline. A dead refresh token therefore surfaced as a four-hour hang ending in DEADLINE_EXCEEDED. Minting a token over plain HTTP first turns that into a fast, readable "reconnect this account."
There are two ways an account gets connected, and the row remembers which via auth_method.
The flow above. Stores a refresh token; explicitly nils developer_token, oauth_client_id, oauth_client_secret — those come from app config, not the row.
A power user pastes their own client ID, secret, developer token and refresh token — typically because they have their own Google Ads MCC and developer token. The row carries all four, and they take precedence over app config in build_client.
That difference is the fingerprint google_ads_connected? uses to tell a genuinely-connected account from a demo/seed row, so live-only features stay hidden when there's nothing real behind them.
Campaign Manager requests exactly one Google scope: https://www.googleapis.com/auth/adwords. Google Ads has no separate read-only scope — that one grant technically covers writes too, so the brakes are applied on our side, not Google's:
access_level: "read_only" on connect.validate_only: true unless GOOGLE_ADS_WRITES_ENABLED=true — so the agent's "changes" are dry-run validated against Google without touching the live account.ACTION_NOT_PERMITTED), regardless of what our flag says.| Symptom | What actually happened | Fix |
|---|---|---|
| Tokens die every ~7 days | The OAuth consent screen is still in Testing mode. Google expires refresh tokens for test users after a week — nothing in the code can prevent it. | Publish the consent screen to Production in Google Cloud Console. Console-only; no code change helps. |
| "did not return an access token" | The code exchange failed — usually a redirect_uri that doesn't byte-match a registered redirect URI, or a rotated client secret. | Match the callback URL exactly in the Cloud Console credential. |
| No refresh token after reconnect | Consent was silently reused. Without prompt=consent Google returns only an access token on a repeat grant. | Already handled — the flow always sends prompt=consent. |
| "No accounts are accessible" | listAccessibleCustomers returned empty or threw. The Google login is real but reaches no Ads customer, or the developer token is missing/unapproved. | Sign in with a login that has Ads access; check developer-token approval level. |
| OAuth state mismatch | Session was lost between initiate and callback (cookie dropped, different browser, stale tab) — or a genuine CSRF attempt. | Start the connect again from the Platforms page in one browser. |
| Long hang, then DEADLINE_EXCEEDED | A revoked or cross-client refresh token retried inside gRPC. Now pre-empted by verify_refresh_token!. | Reconnect the account. |
| File | Does |
|---|---|
| config/routes.rb:80–82 | The three routes: auth/:platform, .../callback, .../select. |
| app/controllers/dashboard/oauth_controller.rb | Platform-agnostic conductor: state nonce, state validation, code exchange, account selection, upsert, session cleanup. |
| app/services/oauth/base_strategy.rb | The four-method contract every platform implements: authorize_url, exchange_code, list_accessible_accounts, build_account_attributes. |
| app/services/oauth/google_ads_strategy.rb | Google's specifics: scope, offline access, the token endpoint, listAccessibleCustomers, and naming the customer via GAQL. |
| app/services/oauth/strategy_factory.rb | Maps a platform key to its strategy. Adding a platform = one class + one registry line. |
| app/models/ad_account.rb | Encrypts all four secrets, exposes token_expired?, google_ads_connected?, connection_health; enqueues the initial backfill. |
| app/services/google_ads_service.rb | Consumer side: build_client reassembles the credentials, verify_refresh_token! pre-flights them, GAQL queries run, writes_enabled? gates mutations. |
The same shape covers Meta, TikTok, LinkedIn, Microsoft, Pinterest, Snapchat, Shopify and Triple Whale — only the strategy differs. Google is the awkward one purely because of the extra developer token.
The user clicks Connect. We send them to Google with our public client ID, the scope we want, and a random state we remember. They sign in to Google and click Allow. Google bounces them back with a one-time code. Our server swaps that code for a refresh token in a private call that proves who we are with the client secret. We encrypt the refresh token against their ad account and forget everything else. From then on, every nightly sync trades that refresh token for a one-hour access token, attaches the developer token, and pulls yesterday's numbers — no user, no password, and revocable at any time from the user's Google account.