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 " // 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 }