Module: KnoxCall::CredentialsFile
- Defined in:
- lib/knoxcall/credentials_file.rb
Overview
Shared credentials file (+~/.knoxcall/credentials.json+) — read, write, lock, refresh.
The file is written by knoxcall login and consumed by every SDK through
the StoredCredentials bootstrap. Format, lock protocol, and refresh rules
are cross-SDK identical (PARITY §2). The server's refresh tokens are
SINGLE-USE with family revocation on reuse, so any refresh MUST:
- hold the sibling
credentials.json.lockfile (exclusive-create, 100ms retry up to 10s; a lock older than the stale window is broken by atomic rename and retried once — ownership-aware so a peer's live lock is never deleted, and the window stays above the bounded refresh timeout), - RE-READ the file after acquiring the lock (another process may have already refreshed), and
- atomically (temp file + rename) write back the rotated refresh token before releasing the lock.
Defined Under Namespace
Classes: Lock
Constant Summary collapse
- DEFAULT_PROFILE =
"default"- FRESH_WINDOW_SECONDS =
A stored access token is "fresh" while it has more than this much validity left; below the threshold the provider refreshes under the file lock.
60.0- REFRESH_TIMEOUT_SECONDS =
The locked refresh HTTP call is bounded so the lock is provably released well within the lock's stale window (Lock#stale_after) — otherwise a slow token endpoint could hold the lock long enough for a peer to break it and double-refresh the single-use token. Stays below STALE_AFTER_SECONDS.
30.0- RELOGIN_MESSAGE =
"stored CLI credentials are no longer valid — run `knoxcall login` again"
Class Method Summary collapse
-
.available? ⇒ Boolean
Chain-slot check for zero-arg construction: anything missing/malformed (including an unresolvable home directory) skips the provider silently.
-
.cached_from_record(record) ⇒ Object
The fresh-token fast path: use the stored access token while >60s valid.
-
.fetch_stored_token(path:, profile:, token_endpoint:, timeout: 30) ⇒ Object
Produce a usable token hash (the Client token-cache shape) from the credentials file.
-
.format_expiry(time) ⇒ Object
-- Expiry formatting ----------------------------------------------------------.
- .parse_expiry(value) ⇒ Object
- .presence(value) ⇒ Object
-
.profile_available?(path, profile) ⇒ Boolean
Auto-detect presence check: file exists AND the selected profile parses.
-
.read_document(path) ⇒ Object
Parse the whole file; nil on missing/malformed/unexpected shape.
-
.read_profile(path, profile) ⇒ Object
One profile's record, or nil (missing file, malformed JSON, unknown profile).
- .refresh_and_write_back(record, path:, profile:, token_endpoint:, timeout:) ⇒ Object
- .relogin_error(status = 401, headers: nil, body: nil) ⇒ Object
-
.remove_profile(path, profile) ⇒ Object
Remove one profile; delete the file when it was the last one.
-
.resolve_path(override = nil) ⇒ Object
Credentials file path: explicit override > KNOXCALL_CREDENTIALS_FILE > default.
-
.resolve_profile(override = nil) ⇒ Object
Profile name: explicit override > KNOXCALL_PROFILE > "default".
-
.warn_if_loose_permissions(path) ⇒ Object
Warn (once) if the credentials file — which holds a single-use refresh token — is readable by group/other.
- .windows_platform? ⇒ Boolean
-
.write_document(path, doc) ⇒ Object
Atomic write: temp file in the same directory → fsync → rename over the target.
-
.write_profile(path, profile, record) ⇒ Object
Merge one profile into the file (other profiles untouched), atomically.
Class Method Details
.available? ⇒ Boolean
Chain-slot check for zero-arg construction: anything missing/malformed (including an unresolvable home directory) skips the provider silently.
172 173 174 175 176 |
# File 'lib/knoxcall/credentials_file.rb', line 172 def available? profile_available?(resolve_path, resolve_profile) rescue StandardError false end |
.cached_from_record(record) ⇒ Object
The fresh-token fast path: use the stored access token while >60s valid.
No :refresh_token in the returned hash on purpose — the file is the sole refresh authority, so no in-process fallback can ever replay a consumed (rotated) refresh token. No :lifetime either: the Client's refresh-ahead window then falls back to its 300s default, and re-entering this method inside that window just re-reads the file (still no HTTP until <60s).
344 345 346 347 348 349 350 351 352 353 354 355 356 |
# File 'lib/knoxcall/credentials_file.rb', line 344 def cached_from_record(record) token = record["access_token"] expires_at = parse_expiry(record["access_token_expires_at"]) return nil unless token.is_a?(String) && !token.empty? && expires_at return nil if expires_at - Time.now <= FRESH_WINDOW_SECONDS { access_token: token, token_type: "Bearer", expires_at: expires_at, lifetime: nil, tenant: presence(record["tenant"]) } end |
.fetch_stored_token(path:, profile:, token_endpoint:, timeout: 30) ⇒ Object
Produce a usable token hash (the Client token-cache shape) from the credentials file.
Fast path: stored access token with >60s validity, no HTTP. Otherwise lock → re-read → re-check → refresh-token grant → atomic write-back. The caller's in-process token mutex (Client#token) wraps this whole method, so the file lock is only ever contended across processes.
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 |
# File 'lib/knoxcall/credentials_file.rb', line 315 def fetch_stored_token(path:, profile:, token_endpoint:, timeout: 30) record = read_profile(path, profile) # Detection saw the profile but it has since vanished/corrupted. raise relogin_error unless record cached = cached_from_record(record) return cached if cached Lock.new(path).with_lock do record = read_profile(path, profile) raise relogin_error unless record cached = cached_from_record(record) # another process refreshed while we waited return cached if cached refresh_and_write_back(record, path: path, profile: profile, token_endpoint: token_endpoint, timeout: timeout) end end |
.format_expiry(time) ⇒ Object
-- Expiry formatting ----------------------------------------------------------
180 181 182 |
# File 'lib/knoxcall/credentials_file.rb', line 180 def format_expiry(time) time.getutc.strftime("%Y-%m-%dT%H:%M:%SZ") end |
.parse_expiry(value) ⇒ Object
184 185 186 187 188 189 |
# File 'lib/knoxcall/credentials_file.rb', line 184 def parse_expiry(value) return nil unless value.is_a?(String) && !value.empty? Time.iso8601(value) rescue ArgumentError nil end |
.presence(value) ⇒ Object
438 439 440 |
# File 'lib/knoxcall/credentials_file.rb', line 438 def presence(value) value.is_a?(String) && !value.empty? ? value : nil end |
.profile_available?(path, profile) ⇒ Boolean
Auto-detect presence check: file exists AND the selected profile parses.
166 167 168 |
# File 'lib/knoxcall/credentials_file.rb', line 166 def profile_available?(path, profile) !read_profile(path, profile).nil? end |
.read_document(path) ⇒ Object
Parse the whole file; nil on missing/malformed/unexpected shape.
65 66 67 68 69 70 71 72 |
# File 'lib/knoxcall/credentials_file.rb', line 65 def read_document(path) (path) doc = JSON.parse(File.read(path, encoding: "utf-8")) return nil unless doc.is_a?(Hash) && doc["profiles"].is_a?(Hash) doc rescue SystemCallError, IOError, JSON::ParserError, EncodingError nil end |
.read_profile(path, profile) ⇒ Object
One profile's record, or nil (missing file, malformed JSON, unknown profile).
101 102 103 104 105 106 |
# File 'lib/knoxcall/credentials_file.rb', line 101 def read_profile(path, profile) doc = read_document(path) return nil unless doc record = doc["profiles"][profile] record.is_a?(Hash) ? record.dup : nil end |
.refresh_and_write_back(record, path:, profile:, token_endpoint:, timeout:) ⇒ Object
358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 |
# File 'lib/knoxcall/credentials_file.rb', line 358 def refresh_and_write_back(record, path:, profile:, token_endpoint:, timeout:) refresh_token = presence(record["refresh_token"]) client_id = presence(record["client_id"]) raise relogin_error unless refresh_token && client_id uri = URI.parse(token_endpoint) req = Net::HTTP::Post.new(uri) req["Content-Type"] = "application/x-www-form-urlencoded" req["Accept"] = "application/json" req["User-Agent"] = SDK_VERSION req.body = URI.encode_www_form( grant_type: "refresh_token", refresh_token: refresh_token, client_id: client_id # the tenant's real CLI client (public, no secret) ) # Bound the refresh call so the lock is provably released within the # stale window: never wait longer than REFRESH_TIMEOUT_SECONDS even if the # caller configured a larger client timeout (a smaller one still wins). refresh_timeout = [timeout, REFRESH_TIMEOUT_SECONDS].min resp = begin http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" http.open_timeout = refresh_timeout http.read_timeout = refresh_timeout http.start { |h| h.request(req) } rescue Net::OpenTimeout, Net::ReadTimeout => e raise ConnectionTimeoutError, "token refresh timed out: #{e.message}" rescue OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e raise NetworkError, "token refresh failed: #{e.class}: #{e.message}" end data = begin JSON.parse(resp.body.to_s) rescue JSON::ParserError nil end if resp.code.to_i >= 400 if data.is_a?(Hash) && data["error"] == "invalid_grant" # Revoked family or expired refresh token — unrecoverable here. headers = {} resp.each_header { |k, v| headers[k.downcase] = v } raise relogin_error(resp.code.to_i, headers: headers, body: data) end raise KnoxCall.error_from_response(resp) end unless data.is_a?(Hash) && data["access_token"].is_a?(String) && !data["access_token"].empty? # e.g. an HTML page from an edge proxy with a 200 status raise TokenError, "token endpoint returned an unexpected response (status #{resp.code})" end lifetime = begin Float(data["expires_in"] || 3600) rescue ArgumentError, TypeError 3600.0 end now = Time.now # Write back the rotated refresh token BEFORE releasing the lock (the # caller holds it) — the old one is already consumed server-side. updated = record.dup updated["access_token"] = data["access_token"] updated["access_token_expires_at"] = format_expiry(now + lifetime) updated["refresh_token"] = data["refresh_token"] if presence(data["refresh_token"]) updated["scope"] = data["scope"] if presence(data["scope"]) # extension members (RFC 6749 §5.1): tenant slug + the real per-tenant CLI client id updated["tenant"] = data["tenant"] if presence(data["tenant"]) updated["client_id"] = data["client_id"] if presence(data["client_id"]) write_profile(path, profile, updated) { access_token: data["access_token"], token_type: "Bearer", expires_at: now + lifetime, lifetime: lifetime, tenant: presence(updated["tenant"]) } end |
.relogin_error(status = 401, headers: nil, body: nil) ⇒ Object
333 334 335 |
# File 'lib/knoxcall/credentials_file.rb', line 333 def relogin_error(status = 401, headers: nil, body: nil) AuthenticationError.new(RELOGIN_MESSAGE, status, headers: headers, body: body) end |
.remove_profile(path, profile) ⇒ Object
Remove one profile; delete the file when it was the last one.
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/knoxcall/credentials_file.rb', line 149 def remove_profile(path, profile) doc = read_document(path) return false unless doc && doc["profiles"].key?(profile) doc["profiles"].delete(profile) if doc["profiles"].empty? begin File.unlink(path) rescue SystemCallError # already gone end else write_document(path, doc) end true end |
.resolve_path(override = nil) ⇒ Object
Credentials file path: explicit override > KNOXCALL_CREDENTIALS_FILE > default.
49 50 51 52 53 54 |
# File 'lib/knoxcall/credentials_file.rb', line 49 def resolve_path(override = nil) return override.to_s if override && !override.to_s.empty? env = ENV["KNOXCALL_CREDENTIALS_FILE"] return env if env && !env.empty? File.join(Dir.home, ".knoxcall", "credentials.json") end |
.resolve_profile(override = nil) ⇒ Object
Profile name: explicit override > KNOXCALL_PROFILE > "default".
57 58 59 60 |
# File 'lib/knoxcall/credentials_file.rb', line 57 def resolve_profile(override = nil) value = override || ENV["KNOXCALL_PROFILE"] value && !value.to_s.empty? ? value.to_s : DEFAULT_PROFILE end |
.warn_if_loose_permissions(path) ⇒ Object
Warn (once) if the credentials file — which holds a single-use refresh token — is readable by group/other. POSIX only: on Windows the mode bits are advisory and confidentiality rests on the %USERPROFILE% ACL, so the check is skipped. Never raises; a missing/unreadable file is a no-op (the read below handles absence).
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/knoxcall/credentials_file.rb', line 79 def (path) return if windows_platform? mode = begin File.stat(path).mode rescue SystemCallError return # missing/unreadable — nothing to warn about end return if (mode & 0o077).zero? Warnings.warn_once( "KNOXCALL_CREDENTIALS_FILE_PERMS", "KnoxCall credentials file #{path} is accessible to group/other " \ "(mode #{format('%03o', mode & 0o777)}) and holds a refresh token. " \ "Restrict it: chmod 600 #{path}" ) end |
.windows_platform? ⇒ Boolean
95 96 97 98 |
# File 'lib/knoxcall/credentials_file.rb', line 95 def windows_platform? Gem.win_platform? || (RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/i ? true : false) end |
.write_document(path, doc) ⇒ Object
Atomic write: temp file in the same directory → fsync → rename over the target. The directory is created 0700 and the file written 0600 (best-effort — the mode bits are advisory on Windows).
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'lib/knoxcall/credentials_file.rb', line 111 def write_document(path, doc) directory = File.dirname(path) FileUtils.mkdir_p(directory, mode: 0o700) tmp_path = File.join( directory, ".credentials-#{Process.pid}-#{format('%08x', rand(2**32))}.tmp" ) begin File.open(tmp_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |f| f.write(JSON.pretty_generate(doc) + "\n") f.flush f.fsync end begin File.chmod(0o600, tmp_path) rescue SystemCallError # best-effort on platforms without POSIX modes end File.rename(tmp_path, path) rescue Exception begin File.unlink(tmp_path) rescue SystemCallError # already gone / never created end raise end nil end |
.write_profile(path, profile, record) ⇒ Object
Merge one profile into the file (other profiles untouched), atomically.
141 142 143 144 145 146 |
# File 'lib/knoxcall/credentials_file.rb', line 141 def write_profile(path, profile, record) doc = read_document(path) || { "version" => 1, "profiles" => {} } doc["version"] ||= 1 doc["profiles"][profile.to_s] = record.reject { |_k, v| v.nil? } write_document(path, doc) end |