Class: Dinie::Internal::TokenManager

Inherits:
Object
  • Object
show all
Defined in:
lib/dinie/runtime/token_manager.rb,
sig/dinie/runtime/token_manager.rbs

Overview

TokenManager — the OAuth2 client_credentials token cache (architecture §10, RB-T, runtime-patterns.md §4), porting sdk-js src/runtime/token-manager.ts. It acquires and transparently refreshes the Bearer token every request rides on, speaking RFC 6749:

POST {base_url}/auth/token
Authorization: Basic base64("{client_id}:{client_secret}")
Content-Type:  application/x-www-form-urlencoded
body:          grant_type=client_credentials
→ 200 { access_token, token_type: "bearer", expires_in }

Three behaviours make it one of the risky-core modules:

1. **Proactive refresh** — the cached token is treated as stale {REFRESH_MARGIN_SECONDS}
 (300s) BEFORE its real expiry, so a live request never races the boundary.
2. **Concurrency lock** — a `Mutex` + `ConditionVariable` (+ an `@refreshing` flag)
 serialize refreshes: N simultaneous `#access_token` callers trigger exactly ONE token
 POST. The claim ("I will refresh") is made UNDER the mutex, so it is impossible for two
 threads to both start a refresh; the losers park on the condition variable and, after a
 **double-check** on wake, return the freshly-cached token. The POST itself runs OUTSIDE
 the lock so a slow handshake never blocks the validity check.
3. **401 invalidation** — `#invalidate!` drops the cached token. The 401 one-shot re-auth
 itself is orchestrated by {HttpClient} (story 003); this module only exposes the seam
 (`#access_token` / `#invalidate!`) and never loops on requests itself.

── DI seam (architecture §10, RB15) ── This is the REAL token source HttpClient expects via its injected token_manager: (auth_headers calls #access_token; the 401 one-shot calls #invalidate!). Client builds ONE Faraday::Connection and injects it into BOTH the HttpClient and this manager, so the token POST rides the SAME connection pool as every other request — and, once story 005 mounts the logging middleware on that shared connection, the Authorization: Basic header (which carries the client secret) is redacted there. URLs are absolute, so the connection's url_prefix is irrelevant (mirrors HttpClient). A connection: is optional: standalone use (and most specs) lets the manager build its own.

── runtime ↔ generated boundary ── Lives in runtime/, imports only errors (for OAuthError) + Faraday, and is NOT part of the public barrel: Client/HttpClient construct it internally.

The ClassLength cop is disabled below: the token lifecycle (validity check → claim → POST → parse → cache → wake) is one cohesive concurrency unit; splitting it would scatter the invariant (the refresh claim and the cache mutation must share one mutex).

Constant Summary collapse

TOKEN_PATH =

Bare token-endpoint path, appended to the configured base URL (which already carries the /api/v3 version prefix, so the full URL is …/api/v3/auth/token).

Returns:

  • (String)
"/auth/token"
SESSION_EXCHANGE_PATH =

Session-exchange endpoint path — step 2 of the two-step session-mode flow. POSTed with the cc-bearer in Authorization: Bearer … and { code } JSON body.

Returns:

  • (String)
"/biometrics/session-exchange"
REFRESH_MARGIN_SECONDS =

Refresh the token this many SECONDS before its stated expiry (300s, runtime-patterns.md §4). The margin absorbs clock skew and in-flight latency so a live request never carries a token that expires mid-flight.

Returns:

  • (Integer)
300
GRANT_BODY =

The fixed application/x-www-form-urlencoded body of the client_credentials grant.

Returns:

  • (String)
"grant_type=client_credentials"
FORM_CONTENT_TYPE =

Content-Type of the token request.

Returns:

  • (String)
"application/x-www-form-urlencoded"
MISSING_ACCESS_TOKEN =

OAuthError messages for a malformed token payload (kept as constants so the guard clauses stay on one line and read cleanly).

Returns:

  • (String)
'OAuth2 token response was missing a valid "access_token".'
MISSING_EXPIRES_IN =

OAuthError message for a token payload missing a valid expires_in.

Returns:

  • (String)
'OAuth2 token response was missing a valid "expires_in".'

Instance Method Summary collapse

Constructor Details

#initialize(client_id:, client_secret:, base_url: nil, connection: nil, refresh_margin_seconds: REFRESH_MARGIN_SECONDS, adapter: nil, clock: nil, code: nil) ⇒ TokenManager

Returns a new instance of TokenManager.

Parameters:

  • client_id (String) —

    OAuth2 client id (the Basic-auth username)

  • client_secret (String) —

    OAuth2 client secret (the Basic-auth password)

  • base_url (String, nil) (defaults to: nil) —

    API base URL incl. the version prefix (default HttpClient::DEFAULT_BASE_URL); the bare TOKEN_PATH is appended to it

  • connection (Faraday::Connection, nil) (defaults to: nil) —

    injected shared connection (pool-sharing seam); nil builds a standalone one. Requests use absolute URLs, so url_prefix is irrelevant

  • refresh_margin_seconds (Integer) (defaults to: REFRESH_MARGIN_SECONDS) —

    proactive-refresh margin (default REFRESH_MARGIN_SECONDS); parameterized for boundary tests

  • adapter (Symbol, nil) (defaults to: nil) —

    Faraday adapter for the standalone connection (default :net_http_persistent)

  • clock (#call, nil) (defaults to: nil) —

    monotonic-ish "seconds now" source (tests inject a controllable one to exercise the margin boundary); defaults to wall-clock seconds

  • code (String, nil) (defaults to: nil) —

    session-mode authorization code (from the biometrics flow). When present the manager operates in session mode: #access_token performs a two-step exchange (cc-credentials → SESSION_EXCHANGE_PATH) exactly once. After the session token expires, SessionTokenExpiredError is raised — the code is single-use and cannot be re-exchanged.

  • client_id: (String)
  • client_secret: (String)
  • base_url: (String, nil) (defaults to: nil)
  • connection: (Object) (defaults to: nil)
  • refresh_margin_seconds: (Integer) (defaults to: REFRESH_MARGIN_SECONDS)
  • adapter: (Symbol, nil) (defaults to: nil)
  • clock: (Object) (defaults to: nil)
  • code: (String, nil) (defaults to: nil)


88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/dinie/runtime/token_manager.rb', line 88

def initialize(client_id:, client_secret:, base_url: nil, connection: nil, # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
               refresh_margin_seconds: REFRESH_MARGIN_SECONDS, adapter: nil, clock: nil, code: nil)
  @client_id = client_id
  @client_secret = client_secret
  @base_url = (base_url || HttpClient::DEFAULT_BASE_URL).sub(%r{/+\z}, "")
  @refresh_margin_seconds = refresh_margin_seconds
  @clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
  @connection = connection || build_connection(adapter)
  @code = code
  @exchanged = false

  @mutex = Mutex.new
  @condition = ConditionVariable.new
  @refreshing = false
  @access_token = nil
  @expires_at = nil
end

Instance Method Details

#access_token ⇒ String

Return a valid Bearer access token, acquiring or refreshing transparently.

Returns:

  • (String)


115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/dinie/runtime/token_manager.rb', line 115

def access_token
  @mutex.synchronize do
    loop do
      return @access_token if token_valid?
      break unless @refreshing

      # Another thread is already refreshing — park until it broadcasts, then re-check
      # (double-check pattern): it may have populated the cache, or its refresh may have failed.
      @condition.wait(@mutex)
    end
    @refreshing = true # claim the refresh (under the mutex — only one thread can win)
  end

  refresh_and_cache
end

#basic_credentials ⇒ String

Base64 (RFC 4648) of client_id:client_secret, no line breaks (HTTP Basic).

Returns:

  • (String)


304
305
306
# File 'lib/dinie/runtime/token_manager.rb', line 304

def basic_credentials
  Base64.strict_encode64("#{@client_id}:#{@client_secret}")
end

#body_detail(response) ⇒ String

Best-effort body text for a non-2xx failure message (never raises).

Parameters:

  • response (Object)

Returns:

  • (String)


317
318
319
320
321
# File 'lib/dinie/runtime/token_manager.rb', line 317

def body_detail(response)
  response.body.to_s.strip
rescue StandardError
  ""
end

#build_connection(adapter) ⇒ Object

Standalone fallback connection (production injects the shared one from Client). No logger seam here: the canonical, logged connection is the Client's shared one.

Parameters:

  • adapter (Symbol, nil)

Returns:

  • (Object)


334
335
336
337
338
# File 'lib/dinie/runtime/token_manager.rb', line 334

def build_connection(adapter)
  Faraday.new(url: @base_url) do |faraday|
    faraday.adapter(adapter || :net_http_persistent)
  end
end

#exchange(cc_token, code) ⇒ [ String, Numeric ]

Step 2 of the session two-step: POST SESSION_EXCHANGE_PATH with the cc-bearer and the authorization code. Returns [customer_access_token, expires_in]. Non-2xx raises the typed API error from Errors.from_response (e.g. AuthError on 401).

Parameters:

  • cc_token (String)
  • code (String)

Returns:

  • ([ String, Numeric ])


214
215
216
217
218
219
220
221
222
223
# File 'lib/dinie/runtime/token_manager.rb', line 214

def exchange(cc_token, code)
  response = request_exchange(cc_token, code)
  unless success?(response.status)
    raise Dinie::Internal::Errors.from_response(
      status: response.status, headers: response.headers.to_h, body: response.body
    )
  end

  parse_exchange_response(response.body)
end

#exchange_url ⇒ String

Returns:

  • (String)


254
255
256
# File 'lib/dinie/runtime/token_manager.rb', line 254

def exchange_url
  "#{@base_url}#{SESSION_EXCHANGE_PATH}"
end

#fetch_client_credentials_token ⇒ String

Step 1 of the session two-step: obtain a cc-bearer (used as the Authorization header on the subsequent exchange POST). Returns only the access_token string.

Returns:

  • (String)

Raises:



203
204
205
206
207
208
209
# File 'lib/dinie/runtime/token_manager.rb', line 203

def fetch_client_credentials_token
  response = request_token
  raise Dinie::OAuthError, status_failure(response.status, body_detail(response)) unless success?(response.status)

  access_token, = parse_token_response(response.body)
  access_token
end

#fetch_partner_token ⇒ [ String, Numeric ]

Partner mode: a single client_credentials POST, returns [access_token, absolute_expiry_seconds]. The original fetch_token body, extracted so fetch_token stays a clean dispatcher.

Returns:

  • ([ String, Numeric ])

Raises:



193
194
195
196
197
198
199
# File 'lib/dinie/runtime/token_manager.rb', line 193

def fetch_partner_token
  response = request_token
  raise Dinie::OAuthError, status_failure(response.status, body_detail(response)) unless success?(response.status)

  access_token, expires_in = parse_token_response(response.body)
  [access_token, now + expires_in]
end

#fetch_session_token ⇒ [ String, Numeric ]

Session mode step: cc-credentials → SESSION_EXCHANGE_PATH. Returns [customer_access_token, absolute_expiry_seconds]. Sets @exchanged AFTER the POST succeeds so T9 (exchange failure) leaves @exchanged = false and the caller can see the typed API error (no phantom expiry on the next call).

Returns:

  • ([ String, Numeric ])


183
184
185
186
187
188
# File 'lib/dinie/runtime/token_manager.rb', line 183

def fetch_session_token
  cc_token = fetch_client_credentials_token
  access_token, expires_in = exchange(cc_token, @code)
  @exchanged = true
  [access_token, now + expires_in]
end

#fetch_token ⇒ [ String, Numeric ]

Dispatch to partner mode or session mode. Partner mode (no code) performs the standard client_credentials POST and caches until the margin. Session mode performs the two-step exchange exactly once; subsequent stale-token paths raise SessionTokenExpiredError.

Returns:

  • ([ String, Numeric ])

Raises:



172
173
174
175
176
177
# File 'lib/dinie/runtime/token_manager.rb', line 172

def fetch_token
  return fetch_partner_token unless @code
  raise Dinie::SessionTokenExpiredError if @exchanged

  fetch_session_token
end

#invalidate! ⇒ void

This method returns an undefined value.

Drop the cached token so the next access_token re-authenticates (the 401 one-shot seam).



136
137
138
139
140
141
# File 'lib/dinie/runtime/token_manager.rb', line 136

def invalidate!
  @mutex.synchronize do
    @access_token = nil
    @expires_at = nil
  end
end

#now ⇒ Numeric

Returns:

  • (Numeric)


328
329
330
# File 'lib/dinie/runtime/token_manager.rb', line 328

def now
  @clock.call
end

#parse_exchange_response(raw_body) ⇒ [ String, Numeric ]

Validate + extract the exchange response, returning [access_token, expires_in]. Raises OAuthError on structural problems (shape, missing fields).

Parameters:

  • raw_body (Object)

Returns:

  • ([ String, Numeric ])

Raises:



242
243
244
245
246
247
248
249
250
251
252
# File 'lib/dinie/runtime/token_manager.rb', line 242

def parse_exchange_response(raw_body)
  parsed = parse_json(raw_body)
  raise Dinie::OAuthError, "Session exchange response was not a JSON object." unless parsed.is_a?(Hash)

  access_token = parsed[:access_token]
  expires_in = parsed[:expires_in]
  raise Dinie::OAuthError, MISSING_ACCESS_TOKEN unless valid_access_token?(access_token)
  raise Dinie::OAuthError, MISSING_EXPIRES_IN unless valid_expires_in?(expires_in)

  [access_token, expires_in]
end

#parse_json(raw_body) ⇒ Object

Parameters:

  • raw_body (Object)

Returns:

  • (Object)


281
282
283
284
285
# File 'lib/dinie/runtime/token_manager.rb', line 281

def parse_json(raw_body)
  JSON.parse(raw_body.to_s, symbolize_names: true)
rescue JSON::ParserError
  raise Dinie::OAuthError, "OAuth2 token response body was not valid JSON."
end

#parse_token_response(raw_body) ⇒ [ String, Numeric ]

Validate + extract the wire payload, returning [access_token, expires_in]. token_type is intentionally ignored: HttpClient always sends Authorization: Bearer …, so the wire value is informational. Any shape problem raises OAuthError.

Parameters:

  • raw_body (Object)

Returns:

  • ([ String, Numeric ])

Raises:



269
270
271
272
273
274
275
276
277
278
279
# File 'lib/dinie/runtime/token_manager.rb', line 269

def parse_token_response(raw_body)
  parsed = parse_json(raw_body)
  raise Dinie::OAuthError, "OAuth2 token response was not a JSON object." unless parsed.is_a?(Hash)

  access_token = parsed[:access_token]
  expires_in = parsed[:expires_in]
  raise Dinie::OAuthError, MISSING_ACCESS_TOKEN unless valid_access_token?(access_token)
  raise Dinie::OAuthError, MISSING_EXPIRES_IN unless valid_expires_in?(expires_in)

  [access_token, expires_in]
end

#refresh_and_cache ⇒ String

Run the refresh OUTSIDE the mutex (the POST is I/O — holding the lock would block the validity check for every other caller). Whatever happens, the ensure clears the claim and wakes the parked threads, so a failed handshake never leaves a permanently-stuck lock: the next waiter re-checks, finds no token, and retries.

Returns:

  • (String)


155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/dinie/runtime/token_manager.rb', line 155

def refresh_and_cache # rubocop:disable Metrics/MethodLength
  access_token, expires_at = fetch_token
  @mutex.synchronize do
    @access_token = access_token
    @expires_at = expires_at
  end
  access_token
ensure
  @mutex.synchronize do
    @refreshing = false
    @condition.broadcast
  end
end

#request_exchange(cc_token, code) ⇒ Object

One Faraday round-trip to SESSION_EXCHANGE_PATH. Absolute URL. Transport failures become OAuthError (no server response to dispatch on).

Parameters:

  • cc_token (String)
  • code (String)

Returns:

  • (Object)


227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/dinie/runtime/token_manager.rb', line 227

def request_exchange(cc_token, code)
  @connection.run_request(
    :post,
    exchange_url,
    JSON.generate(code: code),
    "authorization" => "Bearer #{cc_token}",
    "content-type" => "application/json",
    "accept" => "application/json"
  )
rescue Faraday::Error => e
  raise Dinie::OAuthError, "Session exchange request failed before a response was received: #{e.message}"
end

#request_token ⇒ Object

One Faraday round-trip to the token endpoint. Absolute URL ⇒ the connection's url_prefix is irrelevant. A transport failure (DNS, refused, timeout) becomes OAuthError.

Returns:

  • (Object)


260
261
262
263
264
# File 'lib/dinie/runtime/token_manager.rb', line 260

def request_token
  @connection.run_request(:post, token_url, GRANT_BODY, token_request_headers)
rescue Faraday::Error => e
  raise Dinie::OAuthError, "OAuth2 token request failed before a response was received: #{e.message}"
end

#status_failure(status, detail) ⇒ String

Parameters:

  • status (Integer)
  • detail (String)

Returns:

  • (String)


323
324
325
326
# File 'lib/dinie/runtime/token_manager.rb', line 323

def status_failure(status, detail)
  suffix = detail.empty? ? "" : ": #{detail}"
  "OAuth2 token request failed with status #{status}#{suffix}"
end

#success?(status) ⇒ Boolean

Parameters:

  • status (Integer)

Returns:

  • (Boolean)


312
313
314
# File 'lib/dinie/runtime/token_manager.rb', line 312

def success?(status)
  (200..299).cover?(status)
end

#token_request_headers ⇒ Hash[String, String]

Returns:

  • (Hash[String, String])


295
296
297
298
299
300
301
# File 'lib/dinie/runtime/token_manager.rb', line 295

def token_request_headers
  {
    "authorization" => "Basic #{basic_credentials}",
    "content-type" => FORM_CONTENT_TYPE,
    "accept" => "application/json"
  }
end

#token_url ⇒ String

Returns:

  • (String)


308
309
310
# File 'lib/dinie/runtime/token_manager.rb', line 308

def token_url
  "#{@base_url}#{TOKEN_PATH}"
end

#token_valid? ⇒ Boolean

True when a token is cached and still outside the refresh margin. Only ever called under the mutex, so reading @access_token/@expires_at together is race-free.

Returns:

  • (Boolean)


147
148
149
# File 'lib/dinie/runtime/token_manager.rb', line 147

def token_valid?
  !@access_token.nil? && now < @expires_at - @refresh_margin_seconds
end

#valid_access_token?(value) ⇒ Boolean

Parameters:

  • value (Object)

Returns:

  • (Boolean)


287
288
289
# File 'lib/dinie/runtime/token_manager.rb', line 287

def valid_access_token?(value)
  value.is_a?(String) && !value.empty?
end

#valid_expires_in?(value) ⇒ Boolean

Parameters:

  • value (Object)

Returns:

  • (Boolean)


291
292
293
# File 'lib/dinie/runtime/token_manager.rb', line 291

def valid_expires_in?(value)
  value.is_a?(Numeric) && value.positive?
end