Class: Dinie::Internal::TokenManager
- Inherits:
-
Object
- Object
- Dinie::Internal::TokenManager
- 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/v3version prefix, so the full URL is…/api/v3/auth/token). "/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. "/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. 300- GRANT_BODY =
The fixed
application/x-www-form-urlencodedbody of the client_credentials grant. "grant_type=client_credentials"- FORM_CONTENT_TYPE =
Content-Typeof the token request. "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).
'OAuth2 token response was missing a valid "access_token".'- MISSING_EXPIRES_IN =
OAuthError message for a token payload missing a valid
expires_in. 'OAuth2 token response was missing a valid "expires_in".'
Instance Method Summary collapse
-
#access_token ⇒ String
Return a valid Bearer access token, acquiring or refreshing transparently.
-
#basic_credentials ⇒ String
Base64 (RFC 4648) of
client_id:client_secret, no line breaks (HTTP Basic). -
#body_detail(response) ⇒ String
Best-effort body text for a non-2xx failure message (never raises).
-
#build_connection(adapter) ⇒ Object
Standalone fallback connection (production injects the shared one from Client).
-
#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.
- #exchange_url ⇒ String
-
#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).
-
#fetch_partner_token ⇒ [ String, Numeric ]
Partner mode: a single client_credentials POST, returns
[access_token, absolute_expiry_seconds]. -
#fetch_session_token ⇒ [ String, Numeric ]
Session mode step: cc-credentials → SESSION_EXCHANGE_PATH.
-
#fetch_token ⇒ [ String, Numeric ]
Dispatch to partner mode or session mode.
-
#initialize(client_id:, client_secret:, base_url: nil, connection: nil, refresh_margin_seconds: REFRESH_MARGIN_SECONDS, adapter: nil, clock: nil, code: nil) ⇒ TokenManager
constructor
A new instance of TokenManager.
-
#invalidate! ⇒ void
Drop the cached token so the next
access_tokenre-authenticates (the 401 one-shot seam). - #now ⇒ Numeric
-
#parse_exchange_response(raw_body) ⇒ [ String, Numeric ]
Validate + extract the exchange response, returning
[access_token, expires_in]. - #parse_json(raw_body) ⇒ Object
-
#parse_token_response(raw_body) ⇒ [ String, Numeric ]
Validate + extract the wire payload, returning
[access_token, expires_in]. -
#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).
-
#request_exchange(cc_token, code) ⇒ Object
One Faraday round-trip to SESSION_EXCHANGE_PATH.
-
#request_token ⇒ Object
One Faraday round-trip to the token endpoint.
- #status_failure(status, detail) ⇒ String
- #success?(status) ⇒ Boolean
- #token_request_headers ⇒ Hash[String, String]
- #token_url ⇒ String
-
#token_valid? ⇒ Boolean
True when a token is cached and still outside the refresh margin.
- #valid_access_token?(value) ⇒ Boolean
- #valid_expires_in?(value) ⇒ Boolean
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.
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.
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).
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).
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.
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).
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
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.
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.
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).
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.
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
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).
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
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.
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.
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).
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.}" 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.
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.}" end |
#status_failure(status, detail) ⇒ 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
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]
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
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.
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
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
291 292 293 |
# File 'lib/dinie/runtime/token_manager.rb', line 291 def valid_expires_in?(value) value.is_a?(Numeric) && value.positive? end |