Class: MicrosoftAds::OauthService

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

Overview

Handles Microsoft Advertising (Bing Ads) OAuth 2.0 via the Microsoft
identity platform (Entra ID), for the campaign sync that drives
MicrosoftAdsCampaignSyncWorker and the server-side conversion upload in
ConversionReporter.

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

Differences from Pinterest/YouTube

  • No static-token fallback. Microsoft access tokens are short-lived
    (~1 hour) and only obtainable via OAuth, so every caller goes through
    #access_token!, which refreshes lazily when the stored token expired.
  • Confidential client. The app is registered in Entra as a Web app
    with a client secret — required for a server-side daemon that refreshes
    tokens across our Dallas/Chicago containers (a public client's refresh
    token is bound to the single device that minted it).
  • The long-lived refresh token is additionally exercised once a day by
    MicrosoftAdsTokenRefreshWorker to surface a revoked grant before a
    feature path hits it.

Flow

  1. Crm::MicrosoftAdsOauthController#authorize redirects an admin to
    #authorization_url.
  2. Microsoft redirects back to /microsoft_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
    reused until Microsoft rotates it (the new value is then persisted).

Defined Under Namespace

Classes: TokenRefreshError

Constant Summary collapse

PROVIDER =

OAuth provider key stored on OauthCredential#provider.

'microsoft_ads'
LOGIN_HOST =

Microsoft identity platform v2.0 host.

'https://login.microsoftonline.com'
DEFAULT_TENANT =

Tenant segment of the authority. common supports both work/school
(Entra) and personal Microsoft accounts, and is correct for a
multi-tenant + personal-accounts app registration (Microsoft's
recommended Bing Ads type). For a single-tenant ("My organization only")
registration, pin microsoft_ads.tenant_id to the directory (tenant) ID
so the authority is tenant-scoped — common rejects an org-only app.

'common'
SCOPES =

Space-separated scopes. msads.manage grants Microsoft Advertising API
access; offline_access is required to receive a refresh token.

'https://ads.microsoft.com/msads.manage offline_access'

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 Microsoft — a single,
    system-level ad-account credential. The keyword is kept for signature
    parity with the other OAuth services (Pinterest/YouTube/Zoom).



61
62
63
64
65
# File 'app/services/microsoft_ads/oauth_service.rb', line 61

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

Instance Method Details

#access_token!String

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

Returns:

  • (String)

Raises:



120
121
122
123
124
125
126
127
128
129
# File 'app/services/microsoft_ads/oauth_service.rb', line 120

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

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

#authorization_url(state:) ⇒ String

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

Parameters:

  • state (String)

    CSRF token echoed back to the callback

Returns:

  • (String)


71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'app/services/microsoft_ads/oauth_service.rb', line 71

def authorization_url(state:)
  query = {
    client_id:     @client_id,
    scope:         SCOPES,
    response_type: 'code',
    redirect_uri:  redirect_uri,
    response_mode: 'query',
    state:         state,
    # Force the account chooser so the admin connects the intended Super
    # Admin user (the one that owns the developer token), not whatever
    # Microsoft session happens to be active in the browser.
    prompt:        'select_account'
  }.to_query
  "#{LOGIN_HOST}/#{tenant}/oauth2/v2.0/authorize?#{query}"
end

#connected?Boolean

Returns whether the OAuth flow has been connected.

Returns:

  • (Boolean)

    whether the OAuth flow has been connected.



132
133
134
# File 'app/services/microsoft_ads/oauth_service.rb', line 132

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: }



139
140
141
142
143
144
145
146
147
# File 'app/services/microsoft_ads/oauth_service.rb', line 139

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.



150
151
152
# File 'app/services/microsoft_ads/oauth_service.rb', line 150

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:



92
93
94
95
96
97
98
99
# File 'app/services/microsoft_ads/oauth_service.rb', line 92

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

#healthy?Boolean

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

Returns:

  • (Boolean)


158
159
160
161
162
# File 'app/services/microsoft_ads/oauth_service.rb', line 158

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

#refresh!OauthCredential

Exchange the stored refresh token for a fresh access + refresh token pair.

Returns:

Raises:



105
106
107
108
109
110
111
112
# File 'app/services/microsoft_ads/oauth_service.rb', line 105

def refresh!
  credential = OauthCredential.for(PROVIDER, account: @account)
  raise TokenRefreshError, 'No Microsoft Advertising 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