Class: Dinie::Internal::TokenManager

Inherits:
Object
  • Object
show all
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

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.



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_tokenString

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.

Returns:

  • (String)

    a valid OAuth2 access token

Raises:

  • (Dinie::OAuthError)

    the token handshake failed (transport, non-2xx, or malformed body)



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