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:

  1. hold the sibling credentials.json.lock file (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),
  2. RE-READ the file after acquiring the lock (another process may have already refreshed), and
  3. 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

Class Method Details

.available? ⇒ Boolean

Chain-slot check for zero-arg construction: anything missing/malformed (including an unresolvable home directory) skips the provider silently.

Returns:



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.

Returns:



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)
  warn_if_loose_permissions(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 warn_if_loose_permissions(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

Returns:



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