package authn
import (
"bytes"
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
"sourcecraft.dev/bigbes/sr-ht-ecore/grants"
)
// These tests run against the real sr-ht-ecore validator over real signed
// tokens, not a stub of it. The signature, the expiry, the ClientID
// discrimination and the revocation round trip are the whole of what this plane
// is, and a fake Inspect would assert only that the wiring calls something.
// crypto.InitCrypto has already run in TestMain, which is what makes minting one
// here possible at all.
// mustGrants parses a grant string or fails the test.
func mustGrants(t *testing.T, s string) grants.Grants {
t.Helper()
g, err := grants.Parse(s)
require.NoError(t, err, "parse grants %q", s)
return g
}
// seal mints a signed bearer token the way tokens.sr.ht does — or, with another
// clientID, the way meta.sr.ht does its PATs.
func seal(username, clientID, grantString string, expires time.Time) string {
bt := &auth.BearerToken{
Version: auth.TokenVersion,
Expires: auth.ToTimestamp(expires),
Grants: grantString,
ClientID: clientID,
Username: username,
}
return bt.Encode()
}
// instanceToken is a live working token from this instance's tokens.sr.ht.
func instanceToken(grantString string) string {
return seal("bigbes", bearer.TokensClientID, grantString, time.Now().Add(time.Hour))
}
// fakeDaemon stands in for tokens.sr.ht's revocation endpoint: 204 is live, 404
// is revoked, and anything else is the absence of an answer. It counts requests
// so a test can assert whether the daemon was asked at all.
type fakeDaemon struct {
server *httptest.Server
status int
hits int
}
func newFakeDaemon(t *testing.T, status int) *fakeDaemon {
t.Helper()
d := &fakeDaemon{status: status}
d.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
d.hits++
w.WriteHeader(d.status)
}))
t.Cleanup(d.server.Close)
return d
}
// unreachableOrigin is a URL nothing answers on: a fake daemon that has already
// been shut down, which is what a restarting tokens.sr.ht looks like from here.
func unreachableOrigin(t *testing.T) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
origin := srv.URL
srv.Close()
return origin
}
// validatorFor builds the real ecore validator pointed at origin.
func validatorFor(t *testing.T, origin string) *bearer.Validator {
t.Helper()
v, err := bearer.New(bearer.Options{
Origin: origin,
ClientID: ConfigSection,
NodeID: "spec-test",
})
require.NoError(t, err)
return v
}
// stubUsers resolves usernames to fixed rows, and can be told to fail so the
// transient path is exercised.
type stubUsers struct {
rows map[string]InstanceUser
err error
calls int
}
func newStubUsers() *stubUsers {
return &stubUsers{rows: map[string]InstanceUser{"bigbes": {ID: 1, Username: "bigbes"}}}
}
func (s *stubUsers) LookupUser(_ context.Context, username string) (InstanceUser, error) {
s.calls++
if s.err != nil {
return InstanceUser{}, s.err
}
row, ok := s.rows[username]
if !ok {
return InstanceUser{}, errNoSuchUser(username)
}
return row, nil
}
func errNoSuchUser(username string) error {
return &noSuchUserError{username: username}
}
type noSuchUserError struct{ username string }
func (e *noSuchUserError) Error() string { return "no such user " + e.username }
// planeFixture wires a resolver with the one agent credential plane: the real
// ecore validator pointed at a daemon answering daemonStatus, and a stub lookup
// for the owner a token names.
type planeFixture struct {
rs *Resolver
users *stubUsers
daemon *fakeDaemon
}
func newPlaneFixture(t *testing.T, daemonStatus int) *planeFixture {
t.Helper()
d := newFakeDaemon(t, daemonStatus)
f := &planeFixture{users: newStubUsers(), daemon: d}
rs, err := NewResolver("bigbes", WithInstancePlane(validatorFor(t, d.server.URL), f.users))
require.NoError(t, err)
f.rs = rs
return f
}
// bearerRequest builds a request presenting token with full provenance headers.
func bearerRequest(token string) *http.Request {
return request("", map[string]string{
"Authorization": "Bearer " + token,
HeaderAgent: "claude-code/spec-writer",
HeaderAgentSession: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
})
}
// The most important test in this change: the credential every agent on the
// instance used to be configured with — an opaque 32-byte secret out of
// agent_token — authenticates nowhere any more. It is not a token this instance
// sealed, and there is no longer a second store to ask.
func TestResolve_OldOpaqueAgentTokenIsRefused(t *testing.T) {
f := newPlaneFixture(t, http.StatusNoContent)
old := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x5a}, 32))
p, err := f.rs.Resolve(context.Background(), bearerRequest(old))
require.Error(t, err)
assert.ErrorIs(t, err, bearer.ErrInvalid)
assert.True(t, p.IsAnonymous(), "a refused credential must yield no authority")
assert.Equal(t, http.StatusUnauthorized, StatusFor(err))
// The push path refuses it for the same reason and through the same call.
_, err = f.rs.ResolveAgent(context.Background(), old, "claude-code", "s-1")
assert.ErrorIs(t, err, bearer.ErrInvalid)
_, code, reached := runMiddleware(t, f.rs, bearerRequest(old))
assert.False(t, reached)
assert.Equal(t, http.StatusUnauthorized, code)
}
func TestResolve_InstanceTokenAccepted(t *testing.T) {
f := newPlaneFixture(t, http.StatusNoContent)
tok := instanceToken("spec:propose spec:read")
p, err := f.rs.Resolve(context.Background(), bearerRequest(tok))
require.NoError(t, err)
assert.True(t, p.IsAgent())
assert.Equal(t, PlaneInstance, p.Plane)
assert.Equal(t, "bigbes", p.Owner, "the agent still acts for the instance owner")
assert.Equal(t, 1, p.UserID, "the token's owner was resolved to a local row")
assert.Equal(t, "tokens.sr.ht (stateless)", p.TokenName)
assert.NoError(t, p.Authorize(ActionPropose))
assert.NoError(t, p.Authorize(ActionRead))
// Provenance is read off the headers on this plane exactly as on the other.
assert.Equal(t, "claude-code/spec-writer", p.Agent)
assert.Equal(t, "8fb9c9a4-b078-4af1-89eb-d97c522f9921", p.Session)
// A stateless token has no row, so step 4 costs nothing.
assert.Zero(t, f.daemon.hits)
}
// ResolveAgent is what the SSH push path calls: the same validator, the same
// refusals, with the provenance passed as arguments because a hook has no
// headers to read them from.
func TestResolveAgent_IsTheSameCheckAsTheHTTPPlane(t *testing.T) {
f := newPlaneFixture(t, http.StatusNoContent)
p, err := f.rs.ResolveAgent(context.Background(), instanceToken("spec:propose"),
"claude-code/spec-writer", "s-1")
require.NoError(t, err)
assert.True(t, p.IsAgent())
assert.Equal(t, PlaneInstance, p.Plane)
assert.Equal(t, "bigbes", p.Owner)
assert.Equal(t, "claude-code/spec-writer", p.Agent)
assert.Equal(t, "s-1", p.Session)
assert.NoError(t, p.Authorize(ActionPropose))
assert.ErrorIs(t, p.Authorize(ActionRead), ErrMissingGrant)
// No credential at all is ErrNoToken, not an anonymous principal: a caller
// that asked to authenticate an agent and passed nothing has a bug.
_, err = f.rs.ResolveAgent(context.Background(), "", "claude-code", "s-1")
assert.ErrorIs(t, err, ErrNoToken)
assert.True(t, IsAuthFailure(err))
}
// A resolver with no agent plane — an instance whose config.ini has no
// [tokens.sr.ht] origin — refuses every credential, and does it as a backend
// failure rather than as a bad token: the holder's credential may be perfect and
// re-provisioning it would not help.
func TestResolveAgent_WithoutAPlaneIsAWiringFailure(t *testing.T) {
rs, err := NewResolver("bigbes")
require.NoError(t, err)
assert.False(t, rs.HasInstancePlane())
tok := instanceToken("spec:propose")
p, err := rs.ResolveAgent(context.Background(), tok, "claude-code", "s-1")
require.Error(t, err)
assert.ErrorIs(t, err, ErrNoAgentPlane)
assert.True(t, p.IsAnonymous())
assert.False(t, IsAuthFailure(err), "a service that cannot check is not a bad credential")
assert.Equal(t, http.StatusServiceUnavailable, StatusFor(err))
_, code, reached := runMiddleware(t, rs, bearerRequest(tok))
assert.False(t, reached)
assert.Equal(t, http.StatusServiceUnavailable, code)
// The cookie plane is unaffected: browsing an instance with no tokens.sr.ht
// still works, it just has no agent to serve.
owner, err := rs.Resolve(context.Background(), request(sealCookie(t, "bigbes"), nil))
require.NoError(t, err)
assert.True(t, owner.IsOwner())
}
func TestResolve_InstanceTokenMissingAGrantStillAuthenticates(t *testing.T) {
f := newPlaneFixture(t, http.StatusNoContent)
// The resolver knows no action, so a narrow token authenticates here and is
// refused later, where the action is known.
p, err := f.rs.Resolve(context.Background(), bearerRequest(instanceToken("spec:read")))
require.NoError(t, err)
assert.True(t, p.IsAgent())
assert.NoError(t, p.Authorize(ActionRead))
assert.ErrorIs(t, p.Authorize(ActionPropose), ErrMissingGrant)
assert.Equal(t, http.StatusForbidden, StatusFor(p.Authorize(ActionPropose)))
}
// A revoked token is refused outright. There is no second door for it to be
// re-tried at, which is the property the local plane's removal makes structural
// rather than merely intended.
func TestResolve_RevokedInstanceTokenIsRefused(t *testing.T) {
f := newPlaneFixture(t, http.StatusNotFound)
tok := instanceToken("spec:propose id:42")
p, err := f.rs.Resolve(context.Background(), bearerRequest(tok))
require.Error(t, err)
assert.ErrorIs(t, err, bearer.ErrRevoked)
assert.True(t, p.IsAnonymous())
assert.Equal(t, http.StatusUnauthorized, StatusFor(err))
assert.True(t, IsAuthFailure(err), "a revoked token is a permanent credential failure")
assert.Equal(t, 1, f.daemon.hits, "a registered token is checked against the daemon")
_, code, reached := runMiddleware(t, f.rs, bearerRequest(tok))
assert.False(t, reached)
assert.Equal(t, http.StatusUnauthorized, code)
}
// An unreachable tokens.sr.ht is 503 and never 401. Reading "I could not ask"
// as "revoked" would refuse every live token on the instance while a daemon
// that is deliberately off the hot path restarts.
func TestResolve_UnreachableDaemonIs503(t *testing.T) {
users := newStubUsers()
rs, err := NewResolver("bigbes",
WithInstancePlane(validatorFor(t, unreachableOrigin(t)), users))
require.NoError(t, err)
tok := instanceToken("spec:propose id:42")
p, err := rs.Resolve(context.Background(), bearerRequest(tok))
require.Error(t, err)
assert.ErrorIs(t, err, bearer.ErrUnavailable)
assert.True(t, p.IsAnonymous())
assert.Equal(t, http.StatusServiceUnavailable, StatusFor(err))
assert.False(t, IsAuthFailure(err), "an unreachable daemon is not a bad credential")
_, code, reached := runMiddleware(t, rs, bearerRequest(tok))
assert.False(t, reached)
assert.Equal(t, http.StatusServiceUnavailable, code)
}
// The two refusals that used to fall through to spec's own store. With one
// plane they are plain 401s — and for the meta PAT that is a change of status
// as well as of path: ErrNotOurs joined IsAuthFailure when the store it used to
// be handed to went away, so it answers 401 rather than the 503 an unclassified
// error would have earned.
func TestResolve_ForeignAndUndecodableTokensAreRefused(t *testing.T) {
for name, c := range map[string]struct {
presented string
want error
}{
"opaque secret from the old plane": {"live-token", bearer.ErrInvalid},
"expired instance token": {
seal("bigbes", bearer.TokensClientID, "spec:propose", time.Now().Add(-time.Hour)),
bearer.ErrInvalid,
},
"meta.sr.ht PAT": {
seal("bigbes", "meta.sr.ht", "git.sr.ht/OBJECTS:RW", time.Now().Add(time.Hour)),
bearer.ErrNotOurs,
},
} {
t.Run(name, func(t *testing.T) {
f := newPlaneFixture(t, http.StatusNoContent)
p, err := f.rs.Resolve(context.Background(), bearerRequest(c.presented))
require.Error(t, err)
assert.ErrorIs(t, err, c.want)
assert.True(t, p.IsAnonymous())
assert.True(t, IsAuthFailure(err), "a credential this service does not take is permanent")
assert.Equal(t, http.StatusUnauthorized, StatusFor(err))
})
}
}
// spec.sr.ht answers to one human. A working token belonging to somebody else is
// refused rather than admitted as a second identity: Principal.Owner is read by
// the provenance committer, the refs rule and the coreauth bridge, all of which
// are written for the instance owner.
func TestResolve_InstanceTokenOfAnotherOwnerIsRefused(t *testing.T) {
f := newPlaneFixture(t, http.StatusNoContent)
tok := seal("someone", bearer.TokensClientID, "spec:propose", time.Now().Add(time.Hour))
p, err := f.rs.Resolve(context.Background(), bearerRequest(tok))
require.Error(t, err)
assert.ErrorIs(t, err, ErrNotInstanceOwner)
assert.True(t, p.IsAnonymous())
assert.Equal(t, http.StatusForbidden, StatusFor(err))
assert.Zero(t, f.users.calls, "a foreign owner is refused before any lookup")
_, code, reached := runMiddleware(t, f.rs, bearerRequest(tok))
assert.False(t, reached)
assert.Equal(t, http.StatusForbidden, code)
}
// A user lookup that cannot answer is transient: 503, never a bad credential.
func TestResolve_UserLookupFailureIs503(t *testing.T) {
f := newPlaneFixture(t, http.StatusNoContent)
f.users.err = errNoSuchUser("connection refused")
_, err := f.rs.Resolve(context.Background(), bearerRequest(instanceToken("spec:read")))
require.Error(t, err)
assert.False(t, IsAuthFailure(err))
assert.Equal(t, http.StatusServiceUnavailable, StatusFor(err))
}
func TestWithInstancePlane_RejectsHalfWiring(t *testing.T) {
v := validatorFor(t, "https://tokens.example")
_, err := NewResolver("bigbes", WithInstancePlane(nil, newStubUsers()))
assert.Error(t, err, "a plane with no validator must be refused")
_, err = NewResolver("bigbes", WithInstancePlane(v, nil))
assert.Error(t, err, "a plane with no user lookup must be refused")
}
// Provenance is mandatory on every agent write. Grants do not replace it and do
// not excuse it, and neither does the plane the agent came in on.
func TestAgentWriteFor_ProvenanceRequired(t *testing.T) {
base := "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809"
for name, plane := range map[string]Plane{
"instance token": PlaneInstance,
"locally asserted agent": Plane(""),
} {
t.Run(name, func(t *testing.T) {
complete := Principal{
Kind: KindAgent, Owner: "bigbes", Agent: "a", Session: "s-1",
Plane: plane, Grants: mustGrants(t, "*"),
}
_, err := complete.AgentWriteFor(base)
require.NoError(t, err)
noSession := complete
noSession.Session = ""
_, err = noSession.AgentWriteFor(base)
assert.ErrorIs(t, err, ErrMissingProvenance)
noAgent := complete
noAgent.Agent = ""
_, err = noAgent.AgentWriteFor(base)
assert.ErrorIs(t, err, ErrMissingProvenance)
})
}
}
func TestAuthorize(t *testing.T) {
t.Run("off the instance plane every action passes", func(t *testing.T) {
for _, p := range []Principal{
{Kind: KindOwner, Owner: "bigbes"},
// The CLI's locally asserted agent: no credential, so no grant to
// clip. The resolver never produces one of these.
{Kind: KindAgent, Owner: "bigbes"},
} {
assert.NoError(t, p.Authorize(ActionPropose))
assert.NoError(t, p.Authorize(ActionRead))
}
})
t.Run("on the instance plane the grant set decides", func(t *testing.T) {
narrow := Principal{
Kind: KindAgent, Owner: "bigbes", Plane: PlaneInstance,
Grants: mustGrants(t, "spec:read"),
}
assert.NoError(t, narrow.Authorize(ActionRead))
assert.ErrorIs(t, narrow.Authorize(ActionPropose), ErrMissingGrant)
universal := Principal{
Kind: KindAgent, Owner: "bigbes", Plane: PlaneInstance,
Grants: mustGrants(t, "*"),
}
assert.NoError(t, universal.Authorize(ActionPropose))
// The zero grant set admits nothing, which is why Authorize checks the
// plane before the set: a principal that never went through the
// resolver must not be silently universal.
empty := Principal{Kind: KindAgent, Owner: "bigbes", Plane: PlaneInstance}
assert.ErrorIs(t, empty.Authorize(ActionRead), ErrMissingGrant)
})
}
func TestStatusFor(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"nil", nil, http.StatusOK},
{"unreachable daemon", bearer.ErrUnavailable, http.StatusServiceUnavailable},
{"missing grant", ErrMissingGrant, http.StatusForbidden},
{"foreign owner", ErrNotInstanceOwner, http.StatusForbidden},
{"bearer forbidden", bearer.ErrForbidden, http.StatusForbidden},
{"revoked instance token", bearer.ErrRevoked, http.StatusUnauthorized},
{"undecodable instance token", bearer.ErrInvalid, http.StatusUnauthorized},
{"token from another issuer", bearer.ErrNotOurs, http.StatusUnauthorized},
{"no credential presented", ErrNoToken, http.StatusUnauthorized},
{"no agent plane configured", ErrNoAgentPlane, http.StatusServiceUnavailable},
{"store outage", errNoSuchUser("postgres"), http.StatusServiceUnavailable},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, StatusFor(c.err))
})
}
}
// RFC 9110 asks a 401 to name the scheme it would accept, and the realm is this
// service's config section — the string that identifies it in the config, in the
// nav and in a grant. One spelling, so that the four surfaces which refuse a
// credential cannot name four realms.
func TestChallenge(t *testing.T) {
assert.Equal(t, `Bearer realm="spec.sr.ht"`, Challenge())
}