class

KemalIdentity::ApiTokens::Service

Inherits KemalIdentity::RequestAuthenticator < Reference < Object

Issues, resolves and revokes personal access tokens.

A RequestAuthenticator: it answers "who is making this request?" from the value after the Bearer scheme, and returns the same Outcome union a session cookie does. Everything downstream — require!, PathGuard, env.auth — works unchanged, because the whole point of that union is that the credential type stops mattering once it has been resolved.

Opaque, and therefore revocable

docs/06-roadmap.md puts opaque tokens ahead of JWT deliberately. Validity is read from storage on every request, so revoking a token takes effect on the next one. A stateless JWT cannot make that promise without server-side state, at which point it is not stateless.

Constants

DEFAULT_PREFIX = "ki_"

Prepended to every token, before the random part.

Not decoration. A fixed, searchable prefix is what lets secret scanners — GitHub's, a pre-commit hook, a log scrubber — recognise one of these in a commit or a paste and report it. A bare base64 blob is indistinguishable from any other base64 blob.

An application should change this to something identifying itself, so a scanner can tell whose token it found.

DEFAULT_TOUCH_INTERVAL = 5.minutes

How stale last_used_at may get before a read is allowed to write.

The same throttle sessions use, for the same reason: without it every authenticated API request becomes a write, and an API's request rate is exactly where that hurts most. The cost is that "last used" is accurate only to within one interval, which is fine for the management screen it exists to feed.

Constructors

new(tokens : Repository, clock : Clock, random : RandomSource, prefix : String = DEFAULT_PREFIX, touch_interval : Time::Span = DEFAULT_TOUCH_INTERVAL, lifetime_policy : LifetimePolicy | Nil = nil)

lifetime_policy is the deployment's rule about how long a token may live, and is nil by default: an application that wants non-expiring deploy keys is not wrong, so nothing is imposed. See LifetimePolicy, and note that it is checked at issuance only.

Source

Instance methods

authenticate(credential : String | Nil) : Outcome

Resolves the value after the Bearer scheme.

The order of the checks below mirrors Sessions::Service#resolve, and for the same reasons: shape before any I/O, then revocation, then expiry, then account status.

Returns Anonymous when nothing was presented and Failed when something was — a caller needs that difference to decide between "this is a public endpoint" and "answer 401".

Source
delete_expired

Disk reclamation. A token with no expiry is never swept.

Source
expire(token_id : String, account_id : String, at : Time) : Bool

Brings a token's expiry forward only if it belongs to account_id.

Answers false for somebody else's token and for one that does not exist — the same answer, for the reason the two-argument revoke gives: a token id is not secret material, so the difference would tell a caller whether an id is real.

Source
expire(token_id : String, at : Time) : Bool

Brings a token's expiry forward, for a rotation that wants a bounded overlap.

Issue the replacement, then give the old credential a deadline:

replacement = api.issue(account, "deploy-key (rotated)", scopes: old.scopes)
api.expire(old.id, account.id, at: clock.now + 15.minutes)

Both credentials work until the deadline and each is separately auditable — two ids, two last_used_at stamps, so "has the fleet picked up the new key" is a question the management listing answers. After it, the old one fails as Expired on the authentication path itself: the window closes whether or not a sweeper ran.

Never lengthens. Answers false for a token that already expires at or before at, for a revoked one, and for one that does not exist. See Repository#expire.

By id alone, so it is an administrative call — the three-argument form below is what a route may hand a client-supplied id to.

Source
issue(account : Accounts::Account, name : String, expires_at : Time | Nil = nil, scopes : Array(String) | Nil = nil) : Issued

Mints a token for an account. The raw value is returned exactly once and never again.

expires_at of nil means it never expires — a real choice for a deploy key and a poor one for a laptop, so it is the caller's to make.

scopes of nil means the token is not attenuated: it carries whatever its owner holds, which is what every token issued before v0.8 does. An empty array means it permits nothing — valid, and not the same answer. There is no wildcard; unrestricted is nil.

Source
list(account_id : String) : Array(Token)

Every token for an account, newest first, for a management screen. Revoked ones included: "when did I revoke that?" is a question such a screen exists to answer.

Source
prefix
Source
revoke(token_id : String, account_id : String) : Bool

Ends one token only if it belongs to account_id, which is what a "revoke this token" button in a user's own settings needs.

Answers false for a token that exists and belongs to somebody else, and for one that does not exist at all — the same answer, so a caller cannot use it to discover whether an id is real. Costs one extra read; this is a management action, not a request-path one.

Source
revoke(token_id : String) : Bool

Ends one token. Takes effect on the very next request, because validity is read from storage rather than asserted by a signature.

Revokes by id alone, so it is an administrative call. A route that lets a client name the token to revoke — DELETE /tokens/:id — must use the two-argument form below, or it will happily end a token belonging to somebody else. A token id is not secret material: it appears in api_token.revoked and api_token.issued audit lines, in a management listing, and in whatever an operator exports from either.

Source
revoke_all(account_id : String) : Int32

Ends every token for an account, returning how many. The right response to a compromised account, and what a "revoke all my tokens" button calls.

Source