Class: Ksef::Auth::AccessToken

Inherits:
Object
  • Object
show all
Defined in:
lib/ksef/auth/access_token.rb

Overview

Holds the redeemed token pair and keeps the access token fresh (DESIGN.md §5.1, §6.3; docs/REFERENCE.md §4.2).

Expiry comes from the response, never from the JWT

The access token is a JWT, and it is tempting to read exp out of it. This gem does not, and that is a locked decision: DESIGN.md §4.3 excludes the jwt dependency and treats the token as an opaque bearer string, and the contract's TokenInfo carries validUntil precisely so no decoding is needed. (§6.3 used to say "expiry in exp", contradicting §4.3; corrected 2026-08-23.)

Why refresh early rather than on expiry

Refreshing at ~80% of the token's life means a request never carries a credential that expires mid-flight. Waiting for expiry guarantees the opposite: the first request after the deadline fails, and on a non-idempotent call — an invoice submission — a failure that might have been delivered is exactly the situation this gem works hardest to avoid (DESIGN.md §6.7).

Thread safety

A Ksef::Client is shareable across threads (DESIGN.md §5.2), so this is the piece that has to be safe: one mutex guards the pair, and the staleness check is re-run inside the lock so a burst of threads produces one refresh rather than a stampede.

Not in scope here: the 401 refresh-and-replay of §6.3. That needs to see the response, so it belongs to the HTTP layer; this class only refreshes on time.

Constant Summary collapse

REDACTED =
"[REDACTED]"
REFRESH_THRESHOLD =

Fraction of the access token's observed lifetime after which it is considered stale. "~80%" per §6.3 — the docs describe the lifetime only as "kilkanaście minut", so a proportion travels better than a fixed number of seconds.

0.8

Instance Method Summary collapse

Constructor Details

#initialize(tokens, client:, clock: -> { Time.now }, threshold: REFRESH_THRESHOLD) ⇒ AccessToken

Returns a new instance of AccessToken.

Parameters:

  • tokens (Tokens) —

    the pair from POST /auth/token/redeem

  • client (Client) —

    used for POST /auth/token/refresh

  • clock (#call) (defaults to: -> { Time.now }) —

    injected for tests; returns the current Time

  • threshold (Float) (defaults to: REFRESH_THRESHOLD) —

    override for REFRESH_THRESHOLD



44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/ksef/auth/access_token.rb', line 44

def initialize(tokens, client:, clock: -> { Time.now }, threshold: REFRESH_THRESHOLD)
  @client = client
  @clock = clock
  @threshold = threshold
  @mutex = Mutex.new
  @access = tokens.access_token
  @refresh = tokens.refresh_token
  # The issue time is not in the response, so the lifetime is measured from when we
  # took delivery. That can only *under*-estimate the remaining life, which errs the
  # safe way: we refresh slightly early rather than slightly late.
  @acquired_at = @clock.call
end

Instance Method Details

#bearer ⇒ String

The bearer string for an Authorization header, refreshing first if the token has gone stale.

This may perform a network call — deliberately named #bearer rather than #token so that is not a surprise at the call site.

@access.nil? is not a paranoid guard: TokenInfo.from(nil) returns nil, so a redeem response missing its accessToken produces a pair with no token at all. Without this arm that state reaches nil.token and reports NoMethodError from deep inside the client, instead of saying the credential is unusable.

Returns:

  • (String)


68
69
70
71
72
73
# File 'lib/ksef/auth/access_token.rb', line 68

def bearer
  @mutex.synchronize do
    renew! if @access.nil? || stale_unlocked?
    @access.token
  end
end

#expired?(now = @clock.call) ⇒ Boolean

Returns:

  • (Boolean)


86
# File 'lib/ksef/auth/access_token.rb', line 86

def expired?(now = @clock.call) = @access.nil? || @access.expired?(now)

#inspect ⇒ Object



106
107
108
109
# File 'lib/ksef/auth/access_token.rb', line 106

def inspect
  "#<Ksef::Auth::AccessToken token=#{REDACTED} valid_until=#{valid_until.inspect} " \
    "stale=#{stale?} refresh=#{REDACTED}>"
end

#refresh! ⇒ self

Forces a refresh regardless of staleness.

Returns:

  • (self)


78
79
80
81
# File 'lib/ksef/auth/access_token.rb', line 78

def refresh!
  @mutex.synchronize { renew! }
  self
end

#refresh_token_expired?(now = @clock.call) ⇒ Boolean

The refresh token is valid up to seven days and is reusable (§4.2). Once it lapses there is no way back but a full re-authentication.

Returns:

  • (Boolean)


100
# File 'lib/ksef/auth/access_token.rb', line 100

def refresh_token_expired?(now = @clock.call) = @refresh.nil? || @refresh.expired?(now)

#stale?(now = @clock.call) ⇒ Boolean

True once the token is past REFRESH_THRESHOLD of its observed lifetime.

False when the lifetime cannot be established, which is the conservative answer: a validUntil we could not parse is no reason to spend a refresh, and a genuinely expired token still surfaces as a 401 from the API.

Returns:

  • (Boolean)


93
94
95
96
# File 'lib/ksef/auth/access_token.rb', line 93

def stale?(now = @clock.call)
  deadline = refresh_deadline
  !deadline.nil? && now >= deadline
end

#to_s ⇒ Object

Both redacted, #to_s included: these are live credentials, and interpolating one into a log line is how they escape (DESIGN.md §4.5).



104
# File 'lib/ksef/auth/access_token.rb', line 104

def to_s = REDACTED

#valid_until ⇒ Time?

Returns when the current access token stops being valid.

Returns:

  • (Time, nil) —

    when the current access token stops being valid



84
# File 'lib/ksef/auth/access_token.rb', line 84

def valid_until = @access&.valid_until