apitoken

package
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 17, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package apitoken implements scoped, expiring, individually revocable API tokens for non-interactive access to the cloop hub (Task 20175).

Why this exists

The hub previously had exactly two ways in. An OIDC browser session, which CI cannot hold; and one static bearer token (`--token`) that bypassed RBAC entirely and saw every project on the hub. There was no credential you could give a build job, a deploy script, or an edge device that was narrower than "everything", expired on its own, or could be taken away without rotating the secret every other caller also depended on.

A personal access token (PAT) is that credential. It carries its own roles, optionally names the projects it may address, optionally expires, and can be revoked one at a time.

The token string

cloop_pat_<id>_<secret>

`id` is the public half: 64 bits of randomness, stored in the clear, used as the primary key on the verification path so authenticating a request costs one indexed read rather than a scan-and-compare across every token the hub has ever issued. `secret` is 256 bits of randomness and is the only part that authenticates anything.

The `cloop_pat_` prefix is deliberate and load-bearing in two directions: it lets the auth chain tell "this is a PAT" from "this is the static token" without a database read, and it is the anchor a secret scanner needs to recognise a leaked cloop credential in a repository or a CI log.

What is stored

Only `<alg>$<salt-hex>$<digest-hex>` over the secret half, plus a display prefix. The plaintext is returned by Mint exactly once and is never written anywhere — not to the database, not to the audit trail, not to a log line. A stolen database file yields no usable credential.

Why HMAC-SHA256 and not argon2id

Password KDFs exist to make guessing expensive when the input is guessable. The secret here is 256 bits from crypto/rand: there is no dictionary, no reuse across sites, and no human choosing it. Stretching adds nothing an attacker must overcome, while putting a deliberately-slow function on the authentication path of every API request — which is a self-inflicted denial of service, since an unauthenticated caller controls how often it runs.

So the construction is HMAC-SHA256 keyed by a per-token salt: a proper PRF, constant-time to verify, and with a per-row key so one precomputed table cannot attack the whole column. The stored form is algorithm-tagged, so if a future token type ever does take low-entropy input, argon2id can be added alongside without a migration or a flag day.

What this package does not decide

It does not know about HTTP, OIDC, or the route table. Verify answers "is this string a live token, and if so which one" — the caller turns that into an authz.Decision (see Token.Decision) and the route gates enforce it unchanged. Deny-by-default therefore applies to PATs for free: a token is not a bypass, it is an identity with a permission set.

Index

Constants

View Source
const (

	// MaxNameLen bounds the operator-supplied label. Long enough to describe
	// a purpose ("github-actions deploy — payments"), short enough that the
	// column cannot be used as storage.
	MaxNameLen = 200

	// MaxProjectScope bounds how many projects one token may name. A token
	// that needs more than this wants an unscoped token and an honest
	// conversation about it.
	MaxProjectScope = 64
)
View Source
const (
	// KindGlasses is a per-user display-glasses link (Task 20194). One live
	// token per identity: minting rotates, so a link handed out yesterday
	// stops working the moment a new one is generated.
	KindGlasses = "glasses"
)

Kind labels what a token was issued for. The empty kind is an ordinary operator-minted PAT; named kinds mark tokens the hub issues on a user's behalf, which have their own lifecycle rules.

View Source
const Prefix = "cloop_pat_"

Prefix is the literal every minted token starts with. Exported so the auth chain can classify a credential without a database read, and so secret scanners have one constant to match.

Variables

View Source
var (
	// ErrMalformed means the string is not shaped like a token at all.
	ErrMalformed = errors.New("apitoken: malformed token")

	// ErrNotFound means no token has that id.
	ErrNotFound = errors.New("apitoken: token not found")

	// ErrBadSecret means the id resolved but the secret did not match.
	ErrBadSecret = errors.New("apitoken: token secret does not match")

	// ErrExpired means the token passed its ExpiresAt.
	ErrExpired = errors.New("apitoken: token has expired")

	// ErrRevoked means the token was withdrawn by an operator.
	ErrRevoked = errors.New("apitoken: token has been revoked")

	// ErrNoRoles means the token carries no role this binary recognises.
	// Treated as a hard failure rather than an empty permission set so a
	// token written by a newer binary cannot silently degrade into a
	// credential that authenticates but authorizes nothing — which reads to
	// an operator as "the token is broken", not "the token is unsafe".
	ErrNoRoles = errors.New("apitoken: token carries no usable role")
)

Errors returned by Verify. Callers on the authentication path must not distinguish these to the client: every one of them is answered with the same 401, so an unauthenticated caller cannot use the response to learn whether a token id exists, whether it is merely expired, or whether it was revoked. They are distinguished here so the *audit trail* can say which it was.

Functions

func CheckDelegation

func CheckDelegation(d Delegator, roles []string, projectScope []string) error

CheckDelegation reports whether the delegator may mint a token carrying roles and projectScope.

Both inputs are the *normalized* forms (NormalizeRoles / NormalizeProjectScope); callers run those first so a validation error is reported as a bad request rather than as a permission denial.

The error text names the specific permission or project that was refused. That is safe to return: it describes the caller's own authority, which they can already read from /api/me, and withholding it produces the worst kind of security UX — a 403 with no way to discover what would have worked.

func LooksLikeToken

func LooksLikeToken(raw string) bool

LooksLikeToken reports whether a credential string is claiming to be a PAT.

Used by the auth chain to decide whether to consult the token store at all, so that a deployment with no PATs — or a request carrying the static token — never pays for a database read. It is a prefix test, not validation: Verify still decides.

func NormalizeProjectScope

func NormalizeProjectScope(in []string) ([]string, error)

NormalizeProjectScope trims, de-duplicates, and bounds a project scope list. An empty result means "every project", which is the documented meaning of omitting the field.

func NormalizeRoles

func NormalizeRoles(in []string) ([]string, error)

NormalizeRoles validates and de-duplicates a role list, returning the canonical lowercase names to persist.

Unknown names are rejected rather than dropped. This is the mirror image of ParsedRoles, and the asymmetry is intentional: at *mint* time a typo must be a loud failure, because silently issuing a token weaker than the operator asked for produces a credential that fails mysteriously in CI a week later. At *verification* time the same value must be ignored rather than fatal, because by then the token exists and the safe reading of an unrecognised role is "grants nothing".

func Parse

func Parse(raw string) (id, secret string, ok bool)

Parse splits a token string into its id and secret halves without touching any storage. ok is false for anything not shaped like a token.

Both halves are required to be lowercase hex of exactly the minted length. That strictness is what lets the auth chain reject obvious garbage — a truncated copy-paste, a URL-encoded blob, an SQL fragment — before it becomes a database lookup.

func VerifyHash

func VerifyHash(encoded, secret string) bool

VerifyHash reports whether secret derives to encoded, in constant time with respect to the digest.

The comparison is subtle.ConstantTimeCompare over the raw digest bytes, not a string == on the hex form: an early-exit compare over the encoding leaks, through response timing, how many leading bytes of a guess were right, which turns an offline problem into an online one.

A malformed or unknown-algorithm stored value returns false. That case is database corruption, not an attacker-controlled path, so returning early is not a leak — there is no secret in the comparison that never happened.

Types

type Delegator

type Delegator struct {
	// Decision is the minter's own resolved authority, evaluated at the
	// global scope. Global rather than per-project because a token is a
	// fleet-level object: it is not created "inside" a project, and
	// resolving against one would let a caller who is maintainer on a single
	// project mint a hub-wide maintainer credential.
	Decision authz.Decision

	// ProjectScope is non-empty when the minter is itself a project-scoped
	// token. A scoped minter may only issue tokens at least as narrow.
	ProjectScope []string

	// Unrestricted marks a caller whose authority is not expressible as a
	// permission set — the OIDC-disabled local operator, and the static
	// bearer token. Both already act as the deployment itself, so bounding
	// them against their own (allow-all) decision would be theatre. Recorded
	// as a distinct flag rather than inferred, so the bypass is visible at
	// every call site instead of hiding inside an allow-all Decision.
	Unrestricted bool
}

Delegator is the authority a new token is being minted from.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager is the verification entry point: it holds the store, the clock, and the coalescing state for last-used tracking.

Safe for concurrent use. Construct one per hub and share it.

func NewManager

func NewManager(store Store) (*Manager, error)

NewManager builds a Manager over store.

func (*Manager) Get

func (m *Manager) Get(id string) (Token, error)

Get returns one token by id.

func (*Manager) List

func (m *Manager) List() ([]Token, error)

List returns every token, newest first.

func (*Manager) Mint

func (m *Manager) Mint(opts MintOptions) (Minted, error)

Mint creates and persists a token, returning the plaintext once.

func (*Manager) Revoke

func (m *Manager) Revoke(id string) error

Revoke withdraws a token. Idempotent; wraps ErrNotFound for an unknown id.

func (*Manager) SetClock

func (m *Manager) SetClock(now func() time.Time)

SetClock overrides the clock. Tests only.

func (*Manager) SetTouchErrorHandler

func (m *Manager) SetTouchErrorHandler(fn func(error))

SetTouchErrorHandler installs a sink for asynchronous last-used write failures.

func (*Manager) Verify

func (m *Manager) Verify(raw string) (*Token, error)

Verify authenticates a token string.

Order of checks is deliberate. The secret is compared *before* expiry and revocation are consulted, so that a caller holding a wrong secret cannot distinguish "this id exists but is revoked" from "this id does not exist" by timing or by which error came back. Only a caller who already proved possession learns why their token stopped working — which is the person entitled to know.

A successful verification schedules an asynchronous LastUsedAt update and returns immediately; the read path never waits on a write.

type MintOptions

type MintOptions struct {
	// Name is the operator-supplied label. Required: an unnamed credential
	// is one nobody can decide whether to revoke.
	Name string

	// Roles are the authz roles the bearer acts with. Required — a token
	// with no role can authenticate and do nothing, which is a support
	// ticket, not a security posture.
	Roles []string

	// ProjectScope optionally restricts which projects the token may
	// address, by registry name or filesystem path.
	ProjectScope []string

	// CreatedBy records the acting identity for the audit trail.
	CreatedBy string

	// ExpiresAt is when the token stops working. Zero means never, which
	// callers should discourage but which the model permits: an edge device
	// that cannot be re-provisioned needs a credential that outlives the
	// operator's calendar.
	ExpiresAt time.Time

	// Kind labels a hub-issued token; "" for an ordinary PAT.
	Kind string

	// Owner binds the token to the identity it acts for. See Token.Owner.
	Owner *Owner

	// Now overrides the clock, for tests.
	Now time.Time
}

MintOptions describes the token to create.

type Minted

type Minted struct {
	Token Token

	// Plaintext is the full `cloop_pat_<id>_<secret>` string. It exists only
	// in this struct and only until the caller returns it; nothing in cloop
	// stores it, and no later API call can retrieve it.
	Plaintext string
}

Minted is the one-time result of Mint: the persistable record plus the plaintext, which the caller must hand to the operator and then drop.

func Mint

func Mint(opts MintOptions) (Minted, error)

Mint generates a new token. The plaintext is returned exactly once.

Validation is strict and happens before any randomness is drawn, so a rejected request cannot leave a half-formed record or consume entropy.

type Owner

type Owner struct {
	Sub    string   `json:"sub,omitempty"`
	Email  string   `json:"email,omitempty"`
	Name   string   `json:"name,omitempty"`
	Groups []string `json:"groups,omitempty"`
	Roles  []string `json:"roles,omitempty"`
}

Owner is the identity a token was minted on behalf of.

It is the claim bundle as it stood at mint time, not a foreign key: cloop has no user directory to resolve one against, and an IdP subject that has not signed in recently exists nowhere else on the hub. Storing the claims lets the authorization path re-resolve the owner's *current* authority from the *current* policy on every request — so an operator who edits a role mapping changes what every delegated token can do, immediately, without having to find and revoke them.

What it deliberately does not store: anything from the ID token beyond the claims pkg/authz already resolves against. No access token, no refresh token, nothing that would let a stolen database act as the user elsewhere.

func (*Owner) Key

func (o *Owner) Key() string

Key returns the stable per-user string, matching oidcauth.Identity.OwnerKey so a token's owner and a project's recorded owner compare directly. A nil owner yields "", which is "no owner" and never matches a recorded one.

func (*Owner) Label

func (o *Owner) Label() string

Label is the friendliest name for the owning user, for display and audit.

type SQLStore

type SQLStore struct {
	// contains filtered or unexported fields
}

SQLStore is the statedb-backed Store.

func NewSQLStore

func NewSQLStore(db *statedb.DB) (*SQLStore, error)

NewSQLStore adapts a control-plane database.

Which database matters: tokens must live in the *hub's* own state, never in a managed project's, for the same reason executor bindings and secret grants do. A tenant that could write to the database holding its own credentials could mint itself an admin token.

func (*SQLStore) Get

func (s *SQLStore) Get(id string) (Token, error)

func (*SQLStore) List

func (s *SQLStore) List() ([]Token, error)

func (*SQLStore) Put

func (s *SQLStore) Put(t Token) error

func (*SQLStore) Revoke

func (s *SQLStore) Revoke(id string, at time.Time) error

func (*SQLStore) Touch

func (s *SQLStore) Touch(id string, at time.Time) error

type Store

type Store interface {
	// Put writes a newly minted token. It must reject a duplicate ID rather
	// than overwrite: rewriting a live credential's roles is a privilege
	// change nobody asked for.
	Put(Token) error

	// Get returns the token with id, or an error wrapping ErrNotFound.
	Get(id string) (Token, error)

	// List returns every token, newest first, including revoked and expired
	// ones.
	List() ([]Token, error)

	// Revoke stamps a token as withdrawn. Revoking twice is a no-op success;
	// revoking an unknown id must wrap ErrNotFound.
	Revoke(id string, at time.Time) error

	// Touch records a token's last use. Called off the verification path;
	// failures are advisory.
	Touch(id string, at time.Time) error
}

Store persists tokens. Implementations must be safe for concurrent use: every authenticated request reads through one.

type Token

type Token struct {
	ID           string
	Name         string
	Hash         string
	Prefix       string
	Roles        []string
	ProjectScope []string
	CreatedBy    string
	CreatedAt    time.Time
	ExpiresAt    time.Time
	LastUsedAt   time.Time
	RevokedAt    time.Time

	// Kind is "" for an ordinary PAT, or one of the Kind* constants.
	Kind string

	// Owner is set on a token minted on a user's behalf. When present, the
	// token's authority is the *intersection* of its own roles and whatever
	// the owner may currently do — see authz.Intersect and the ui package's
	// grant.decide. Roles alone are a ceiling, never a grant.
	Owner *Owner

	// OwnerUnreadable marks a row whose stored owner binding did not decode.
	// Only List sets it: the verification path refuses such a row outright
	// (see SQLStore.Get), but a listing has to keep showing it or an operator
	// would have no surface from which to revoke it. A token carrying this
	// flag must never be treated as unbound — "we cannot tell whose this is"
	// is not the same answer as "it belongs to nobody".
	OwnerUnreadable bool
}

Token is one API token as persisted. Hash is the derived secret; the plaintext exists only in the return value of Mint.

func (*Token) Active

func (t *Token) Active(now time.Time) bool

Active reports whether the token would be accepted at now.

func (*Token) AllowsProject

func (t *Token) AllowsProject(name, path string) bool

AllowsProject reports whether the token may address a project identified by its registry name and/or filesystem path.

An empty ProjectScope means every project the roles allow. A non-empty scope matches either identifier, case-insensitively, mirroring how authz.Binding resolves a project so an operator writes the same thing in both places.

A scoped token asked about *no* project at all (both arguments empty, i.e. the global scope) is allowed through: that is a fleet-wide action, which rule 1 above deliberately does not govern.

func (*Token) Decision

func (t *Token) Decision(scope authz.Scope, now time.Time) authz.Decision

Decision resolves the token's authority over scope.

Two rules, in order:

  1. If the token names a project scope and the request is about a project outside it, the answer is a full deny — not a reduced role. The caller's 404/403 split then reports the project as nonexistent, which is the correct answer to give a credential that has no business knowing it is there.

  2. Otherwise the token's roles apply as an ordinary permission set.

Note what rule 1 does *not* do: it does not narrow global-scope actions. A project scope limits which projects a token can reach; it is not a second axis of privilege. What a token may do anywhere is decided by its roles, and the mint path is what keeps those from exceeding the minter's own.

func (*Token) Expired

func (t *Token) Expired(now time.Time) bool

Expired reports whether the token is past its expiry at time now. A zero ExpiresAt means the token does not expire.

func (*Token) Label

func (t *Token) Label() string

Label identifies the token in audit records. It never contains the secret: the prefix is the public half by construction.

func (*Token) OwnerBinding

func (t *Token) OwnerBinding() *Owner

OwnerBinding returns the identity this token acts for, or nil. Nil-safe on the receiver so callers can chain it off a lookup that may have found no token at all — "no token" and "a token acting as nobody" are the same answer to every question the callers ask.

func (*Token) ParsedRoles

func (t *Token) ParsedRoles() []authz.Role

ParsedRoles returns the token's roles as validated authz.Role values, dropping any this binary does not recognise.

Dropping rather than failing is the conservative direction: an unknown role name can only ever have granted permissions this binary cannot enumerate, so ignoring it can shrink the permission set but never grow it. Verify turns a token left with nothing into ErrNoRoles.

func (*Token) Revoked

func (t *Token) Revoked() bool

Revoked reports whether the token has been withdrawn.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL