Class: AmazonAds::OauthService

Inherits:
Object
  • Object
show all
Defined in:
app/services/amazon_ads/oauth_service.rb

Overview

Handles Amazon Advertising API OAuth 2.0 (Login with Amazon) for the
campaign sync that drives AmazonAdsCampaignSyncWorker.

System-level credential (account_id: nil) — one shared token for the
WarmlyYours Amazon Ads account, not a per-employee login.

Uses the "Heatwave" LWA security profile (credentials key
amazon_ads_api), approved for the advertising::campaign_management
scope. Like Microsoft there is no static-token fallback: Amazon access
tokens are short-lived (~1 hour) and only obtainable via OAuth.

Refresh-token expiry policy

Per the Amazon Ads Acceptable Use Policy effective 2026-07-30, refresh
tokens from consent granted after that date expire after 365 days and
require annual re-consent; consent granted before is grandfathered
(non-expiring). #exchange_code! stamps metadata['consented_at'] so the
token's age is always known, and AmazonAdsTokenRefreshWorker surfaces a
dead grant (invalid_grant) via OauthCredential#record_refresh_failure!
— reconnect at /amazon_ads/oauth/authorize when that happens.

Flow

  1. Crm::AmazonAdsOauthController#authorize redirects an admin to
    #authorization_url.
  2. Amazon redirects back to /amazon_ads/oauth/callback?code=….
  3. #exchange_code! swaps the code for an access + refresh token,
    stored in OauthCredential.
  4. #access_token! refreshes lazily on expiry; the refresh token is
    long-lived and reused (Amazon does not rotate it on refresh).

Defined Under Namespace

Classes: TokenRefreshError, TransportError

Constant Summary collapse

PROVIDER =

OAuth provider key stored on OauthCredential#provider.

'amazon_ads'
AUTHORIZE_URL =

Endpoint the admin's browser is redirected to for consent (NA region).

'https://www.amazon.com/ap/oa'
TOKEN_HOST =

LWA token endpoint host — shared with SP-API, distinct from the Ads API host.

'https://api.amazon.com'
SCOPES =

Space-separated scopes requested during consent. Campaign management is
the only Ads scope the "Heatwave" app is approved for (no
advertising::audiences).

'advertising::campaign_management'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(account: nil) ⇒ OauthService

Returns a new instance of OauthService.

Parameters:

  • account (Account, nil) (defaults to: nil)

    always nil for Amazon — a single,
    system-level ad-account credential. The keyword is kept for signature
    parity with the other OAuth services (Pinterest/Microsoft/YouTube).



65
66
67
68
69
# File 'app/services/amazon_ads/oauth_service.rb', line 65

def initialize(account: nil)
  @account       = 
  @client_id     = Heatwave::Configuration.fetch(:amazon_ads_api, :client_id)
  @client_secret = Heatwave::Configuration.fetch(:amazon_ads_api, :client_secret)
end

Instance Attribute Details

#client_idString (readonly)

Returns the LWA client id — also sent as the
Amazon-Advertising-API-ClientId header on every Ads API call.

Returns:

  • (String)

    the LWA client id — also sent as the
    Amazon-Advertising-API-ClientId header on every Ads API call.



73
74
75
# File 'app/services/amazon_ads/oauth_service.rb', line 73

def client_id
  @client_id
end

Instance Method Details

#access_token!String

Return a valid access token, refreshing lazily when the stored one has
expired. No static fallback — the OAuth flow must have been connected.

Returns:

  • (String)

Raises:



122
123
124
125
126
127
128
129
130
131
# File 'app/services/amazon_ads/oauth_service.rb', line 122

def access_token!
  credential = OauthCredential.for(PROVIDER, account: @account)
  unless credential
    raise TokenRefreshError,
          'Amazon Ads not connected: run the OAuth flow at /amazon_ads/oauth/authorize'
  end

  refresh! if credential.token_expired?
  credential.reload.access_token
end

#authorization_url(state:) ⇒ String

Amazon consent URL to redirect the admin to for one-time authorization.

Parameters:

  • state (String)

    CSRF token echoed back to the callback

Returns:

  • (String)


79
80
81
82
83
84
85
86
87
88
# File 'app/services/amazon_ads/oauth_service.rb', line 79

def authorization_url(state:)
  query = {
    client_id:     @client_id,
    scope:         SCOPES,
    response_type: 'code',
    redirect_uri:  redirect_uri,
    state:         state
  }.to_query
  "#{AUTHORIZE_URL}?#{query}"
end

#connected?Boolean

Returns whether the OAuth flow has been connected.

Returns:

  • (Boolean)

    whether the OAuth flow has been connected.



134
135
136
# File 'app/services/amazon_ads/oauth_service.rb', line 134

def connected?
  OauthCredential.for(PROVIDER, account: @account).present?
end

#connection_statusHash

DB-only connection status (no HTTP) — safe to call on every page load.

Returns:

  • (Hash)

    { connected:, healthy:, credential:, via: }



141
142
143
144
145
146
147
148
149
# File 'app/services/amazon_ads/oauth_service.rb', line 141

def connection_status
  credential = OauthCredential.for(PROVIDER, account: @account)
  if credential
    healthy = credential.token_fresh? || credential.refresh_token.present?
    { connected: true, healthy: healthy, credential: credential, via: :oauth }
  else
    { connected: false, healthy: false, credential: nil, via: nil }
  end
end

#disconnect!Object

Remove the stored credential.



152
153
154
# File 'app/services/amazon_ads/oauth_service.rb', line 152

def disconnect!
  OauthCredential.destroy_by(provider: PROVIDER, account_id: @account&.id)
end

#exchange_code!(code) ⇒ OauthCredential

Exchange the authorization code for tokens and persist them.

Parameters:

  • code (String)

    the code query param from the callback

Returns:

Raises:



95
96
97
98
99
100
101
102
# File 'app/services/amazon_ads/oauth_service.rb', line 95

def exchange_code!(code)
  response = token_request(
    grant_type:   'authorization_code',
    code:         code,
    redirect_uri: redirect_uri
  )
  persist_tokens!(response, consented: true)
end

#healthy?Boolean

Health check: confirm we can produce a usable access token (lazy refresh
included). DB + token endpoint only — no Ads API call.

Returns:

  • (Boolean)


160
161
162
163
164
# File 'app/services/amazon_ads/oauth_service.rb', line 160

def healthy?
  access_token!.present?
rescue TokenRefreshError, StandardError
  false
end

#refresh!OauthCredential

Exchange the stored refresh token for a fresh access token.

Returns:

Raises:



108
109
110
111
112
113
114
115
# File 'app/services/amazon_ads/oauth_service.rb', line 108

def refresh!
  credential = OauthCredential.for(PROVIDER, account: @account)
  raise TokenRefreshError, 'No Amazon Ads credential found' unless credential
  raise TokenRefreshError, 'No refresh token available' if credential.refresh_token.blank?

  response = token_request(grant_type: 'refresh_token', refresh_token: credential.refresh_token)
  persist_tokens!(response, credential: credential)
end