package authn
import (
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// bearerScheme is the Authorization scheme agents present the token under.
// Matched case-insensitively, as RFC 7235 requires.
const bearerScheme = "bearer"
// HeaderAgent and HeaderAgentSession carry the mandatory provenance an agent
// must send alongside its token. They are named after the git trailers they end
// up in, so that what an agent sends and what a reviewer reads in `git log` are
// spelled the same way.
//
// There is deliberately no X-Agent-Base header: the base revision is the
// `If-Match` value the write plane already defines, and giving it a second
// spelling is exactly how REST and MCP end up disagreeing about what it means.
const (
HeaderAgent = "X-Agent"
HeaderAgentSession = "X-Agent-Session"
)
// AgentToken is one row of the agent_token table, in the shape this package
// needs. It is declared here rather than in db/ so that authn owns its own
// input contract and the dependency arrow keeps pointing downward.
type AgentToken struct {
// ID is the agent_token primary key. Diagnostics and audit only.
ID int64
// Name is the operator-facing label of the token ("laptop", "cron"). With
// one token and no scopes it grants nothing; it exists so a revocation can
// be aimed at something a human recognises.
Name string
// Hash is the stored sha256 of the token as issued. ResolveAgentToken
// re-checks it against the presented token in constant time rather than
// trusting that the store's lookup was an exact match.
Hash []byte
// Created is when the token was issued.
Created time.Time
// Revoked is when the token was revoked, or nil while it is live. A
// revoked row still resolves from the store — it is this package that
// turns it into a refusal, so the refusal can say "revoked" rather than
// "unknown".
Revoked *time.Time
}
// IsRevoked reports whether the token has been revoked.
func (t AgentToken) IsRevoked() bool { return t.Revoked != nil }
// TokenStore is the sliver of db/ that authn needs: look up an agent token row
// by the hash of the presented secret. service/ wires the real Postgres
// implementation in; tests wire a map.
//
// Contract:
//
// - hash is the value HashToken returned; the implementation must match it
// against agent_token.token_hash exactly, never by prefix.
// - When no row matches, return an error satisfying
// errors.Is(err, ErrUnknownToken). sql.ErrNoRows is accepted as an
// equivalent spelling, since that is what a bare QueryRow().Scan() yields.
// - Any other error is taken to be transient (Postgres down, context
// cancelled) and is surfaced as such, never as a bad credential.
//
// The interface takes no scope or space argument on purpose: v1 has one agent
// token and no per-space scoping, and the boundary that actually bounds damage
// is the refs rule in gitx. Adding scopes later is a column here and a filter
// clause there, not a reshaping of this interface.
type TokenStore interface {
LookupAgentToken(ctx context.Context, hash []byte) (AgentToken, error)
}
// HashToken returns the sha256 of a presented agent token — the value stored in
// agent_token.token_hash and the only form of the secret this service keeps.
// The token itself is opaque and high-entropy, so a plain hash is sufficient:
// there is no low-entropy password here for a KDF to slow down guessing of.
func HashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
// BearerFromRequest returns the token from an "Authorization: Bearer <token>"
// header, or "" when the header is absent or uses another scheme. Anything
// after the scheme is returned verbatim apart from surrounding whitespace: the
// token is opaque and this package is not the place to guess at its grammar.
func BearerFromRequest(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
scheme, rest, ok := strings.Cut(h, " ")
if !ok || !strings.EqualFold(scheme, bearerScheme) {
return ""
}
return strings.TrimSpace(rest)
}
// ResolveAgentToken validates a presented agent token against the store.
//
// Unlike the cookie path this fails loudly. An agent that presented a
// credential and got silently downgraded to an anonymous reader would sail
// through its reads and then fail incomprehensibly at its first propose; a 401
// at the door is the only useful answer.
//
// Permanent refusals (ErrNoToken, ErrUnknownToken, ErrRevokedToken,
// ErrInvalidToken) satisfy IsAuthFailure. A store failure is returned wrapped
// and does not, so callers answer 503 rather than 401 and agents retry rather
// than re-provision.
func ResolveAgentToken(ctx context.Context, store TokenStore, presented string) (AgentToken, error) {
if store == nil {
// A nil store is a wiring bug, not a credential problem. Refusing
// loudly beats resolving every token as unknown, which would look like
// a revocation storm.
return AgentToken{}, errors.New("authn: nil TokenStore")
}
if presented == "" {
return AgentToken{}, ErrNoToken
}
hash := HashToken(presented)
tok, err := store.LookupAgentToken(ctx, hash)
switch {
case err == nil:
// fall through
case errors.Is(err, ErrUnknownToken), errors.Is(err, sql.ErrNoRows):
// Two spellings of "no such row"; ErrUnknownToken is the contract,
// sql.ErrNoRows is what an unwrapped Scan leaks.
return AgentToken{}, fmt.Errorf("%w: no agent token matches the presented secret", ErrUnknownToken)
default:
return AgentToken{}, fmt.Errorf("looking up agent token: %w", err)
}
// Re-check the hash ourselves, in constant time. The store's WHERE clause
// already did an equality match, but this is the one comparison that
// decides authentication and it costs a memcmp to not depend on somebody
// else getting it right.
if subtle.ConstantTimeCompare(tok.Hash, hash) != 1 {
return AgentToken{}, fmt.Errorf("%w: store returned a row whose hash does not match the presented token", ErrInvalidToken)
}
if tok.IsRevoked() {
return AgentToken{}, fmt.Errorf("%w: token %q was revoked at %s",
ErrRevokedToken, tok.Name, tok.Revoked.UTC().Format(time.RFC3339))
}
return tok, nil
}