~bigbes/sr-ht-spec

ref: 51f56da36c777b20e634700cffb29b1e89da9e78 sr-ht-spec/authn/token.go -rw-r--r-- 6.1 KiB
51f56da3 — Eugene Blikh bd: clear sync.remote 26 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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
}