Class: Dinie::Internal::TokenManager
- Inherits:
-
Object
- Object
- Dinie::Internal::TokenManager
- Defined in:
- lib/dinie/runtime/token_manager.rb
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`).
"/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-urlencoded` body of the client_credentials grant.
"grant_type=client_credentials"- FORM_CONTENT_TYPE =
‘Content-Type` of 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.
-
#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_token re-authenticates.
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.
Fast path: a cached token still inside the margin is returned without a request. Otherwise the FIRST caller claims the refresh under the mutex and runs the POST outside it; concurrent callers park on the condition variable and, after a double-check on wake, return the freshly-cached token. Under N concurrent callers exactly ONE token POST occurs.
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 |
#invalidate! ⇒ void
This method returns an undefined value.
Drop the cached token so the next #access_token re-authenticates. Called by HttpClient on a 401 (the one-shot re-auth is orchestrated there). Takes the mutex so it never races a concurrent validity check.
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 |