🤖 apitoken
amadan.net/rastrillo/rastrillo/apitoken
Personal API tokens. A person makes a token for an agent, such as Claude, or for their own script. The agent sends it on every API call, and the call acts as that person. JSON API shows the store and the token page wired into an app.
The token
tickets_k3Jx9QpL2mWa_<43 characters>
A token has three parts: the app's prefix, a public ID and a secret. The public ID is safe to log and to show. The secret is shown once, when the token is made. The database stores only its SHA-256.
New, Config and Schema
func New(cfg Config) (*Store, error)
type Config struct {
DB *sql.DB
Prefix string // ^[a-z][a-z0-9]{0,15}$
Allowed func(ctx context.Context, subject string) (bool, error)
Now func() time.Time
}
func DerivePrefix(appName string) string
DB and Prefix are required. DerivePrefix makes a prefix from your
app name. Do not change the prefix later: every token already made
stops working.
Set Allowed to refuse tokens whose owner can no longer use the app,
such as a suspended account. With no Allowed, every owner is allowed.
Merge Schema into your boot set. It adds one table, api_tokens.
Making and managing tokens
func (s *Store) Mint(ctx context.Context, subject string, in MintInput) (secret string, t Token, err error)
type MintInput struct {
Agent string // "Claude"
ForSelf bool // a token for the person, not an agent
Access rastrillo.Access
TTL time.Duration // 0 means never
}
func ValidAgentName(name string) (string, bool)
func (s *Store) List(ctx context.Context, subject string) ([]Token, error)
func (s *Store) Revoke(ctx context.Context, subject, id string) error
func (s *Store) RevokeAgent(ctx context.Context, subject, agent string) (int64, error)
func (s *Store) RevokeAll(ctx context.Context, subject string) (int64, error)
type Token struct {
ID, Subject, Agent string
Access rastrillo.Access
CreatedAt time.Time
LastUsed, ExpiresAt, RevokedAt *time.Time
}
func (t Token) Actor() rastrillo.Actor
Show the secret Mint returns once, in the response to the form that
made it. Never put it in a URL, a redirect, a flash message or the
session.
Token.Actor is who the token acts as: the person, the agent if there
is one, the token as the credential, and its access. It is what
Verify answers for a request on the token.
Name the agent in Agent. An agent name is 1 to 40 letters, digits,
spaces, hyphens and dots. ValidAgentName checks one. For a token that
is not for an agent, set ForSelf and leave Agent empty.
Take subject from sessions.Current, never from the form. Every
method only reads or changes that person's tokens. Revoke returns
sql.ErrNoRows for a token that is not theirs: answer 404.
List returns revoked and expired tokens too, newest first.
The token page
func NewHandlers(cfg HandlersConfig) (*Handlers, error)
type HandlersConfig struct {
Store *Store
Path string // the page's path; revokes redirect there
Render func(w http.ResponseWriter, r *http.Request, d PageData)
Name func(ctx context.Context, subject string) string
}
func (h *Handlers) Page(w http.ResponseWriter, r *http.Request) // GET
func (h *Handlers) Create(w http.ResponseWriter, r *http.Request) // POST
func (h *Handlers) Revoke(w http.ResponseWriter, r *http.Request) // POST, field "id"
func (h *Handlers) RevokeAgent(w http.ResponseWriter, r *http.Request) // POST, field "agent"
func (h *Handlers) RevokeAll(w http.ResponseWriter, r *http.Request) // POST
type PageData struct {
Tokens []Row
Agents []string // each agent with a live token, once
Created *Created // set only by a successful Create
Errors map[string]string // "agent"
Form map[string]string // agent, for_self, access, expiry
CSRFOK bool
}
type Row struct {
Token
Display string // "Claude for Paul"
Revoked, Expired bool // against the store's clock
}
type Created struct {
Secret string
Token Row
}
The handlers take the signed-in person from sessions.Current and
answer 403 without one. They set Cache-Control: no-store on every
response. Mount them under your session router, behind CSRF, and mount
Create behind sessions.RequireFresh(10 * time.Minute) as well.
Render draws the page. Call ui's api-tokens partial with
PageData as Page and the page's path as Path, under your own
page-header; see Templates.
Row.Display is the actor phrase for the token, in the reader's
language. Set Name to turn a subject into a person's name; without
it, the page shows the subject. Row.Revoked and Row.Expired say a
token's state against the store's clock, and Agents lists each agent
with a live token once, for one "revoke all for this agent" button per
agent.
Create reads the form fields agent, for_self, access (read or
write) and expiry (30, 90, 365 or never). It answers 200
with Created set, in that response only. An empty agent name without
for_self answers 422 with Errors["agent"] and the form echoed. A
value the form never offers answers 400.
The revokes redirect to Path with 303. Revoke answers 404 for a
token that is not the caller's.
Verify, Recheck and Redact
func (s *Store) Verify(ctx context.Context, r *http.Request) (rastrillo.Actor, error)
func (s *Store) Recheck(ctx context.Context, r *http.Request, was rastrillo.Actor) (rastrillo.Actor, error)
func (s *Store) Redact(text string) string
Pass the Store as api.Config.Verify, and Store.Redact as
api.Config.Redact.
Verify reads only Authorization: Bearer <token>. A missing, wrong,
revoked or expired token gets the same 401. So does an owner Allowed
refuses. A database error, or an error from Allowed, is a 503.
It records when each token was last used, at most once a minute.
Recheck makes every check Verify makes and writes nothing, so the
API can call it as each answer goes out without touching last_used.
It ignores was: the API compares the two actors itself. This makes
the Store an api.Rechecker, so a token revoked while an answer is
going out stops that answer.
Because of that, an API answer through the store needs a connection
that takes a write deadline. Through httptest.ResponseRecorder, every
API answer is 503. Test the API with httptest.NewServer, as
JSON API shows.
Redact replaces each token's secret in a log line with [redacted].
The prefix and public ID stay, so the log still shows which token it
was.
Read this page as markdown — exact, unstyled, and cheap for an agent to fetch.