package authn
import (
"context"
"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 both planes: the real validator against
// daemonStatus, and the local agent-token store the old credential lives in.
type planeFixture struct {
rs *Resolver
store *stubStore
users *stubUsers
daemon *fakeDaemon
}
func newPlaneFixture(t *testing.T, daemonStatus int) *planeFixture {
t.Helper()
d := newFakeDaemon(t, daemonStatus)
f := &planeFixture{store: newStubStore(), users: newStubUsers(), daemon: d}
rs, err := NewResolver("bigbes", f.store, 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 is configured with today still authenticates, still resolves to an
// agent, and is still authorized to propose — with the instance plane wired in
// front of it.
func TestResolve_LocalAgentTokenStillWorksWithTheInstancePlaneWired(t *testing.T) {
f := newPlaneFixture(t, http.StatusNoContent)
f.store.add("live-token", "laptop")
p, err := f.rs.Resolve(context.Background(), bearerRequest("live-token"))
require.NoError(t, err)
assert.True(t, p.IsAgent(), "the old agent token must still resolve to an agent")
assert.Equal(t, PlaneLocal, p.Plane)
assert.Equal(t, "bigbes", p.Owner)
assert.Equal(t, "laptop", p.TokenName)
assert.Equal(t, "claude-code/spec-writer", p.Agent)
assert.Equal(t, "8fb9c9a4-b078-4af1-89eb-d97c522f9921", p.Session)
// It carries no grants and is refused nothing: the local plane's boundary is
// the refs rule, and this change does not move it.
assert.NoError(t, p.Authorize(ActionPropose))
assert.NoError(t, p.Authorize(ActionRead))
// It never became a question for tokens.sr.ht, and never could: the daemon
// is only asked about a token that decoded as one of its own.
assert.Zero(t, f.daemon.hits, "the local plane must not talk to tokens.sr.ht")
assert.Zero(t, f.users.calls, "the local plane has no owner to resolve")
}
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)
assert.Zero(t, f.store.calls, "an accepted instance token must not reach the local store")
}
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 instance token must be refused outright and must not get a second
// chance at the old door. The same string is registered as a local agent token
// so that a fall-through would visibly succeed.
func TestResolve_RevokedInstanceTokenDoesNotFallThroughToTheLocalPlane(t *testing.T) {
f := newPlaneFixture(t, http.StatusNotFound)
tok := instanceToken("spec:propose id:42")
f.store.add(tok, "shadow")
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")
assert.Zero(t, f.store.calls, "a revoked instance token must never reach the local store")
_, 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, and never a silent
// downgrade to the legacy plane. Reading "I could not ask" as "revoked" would
// refuse every live instance token while a daemon that is deliberately off the
// hot path restarts.
func TestResolve_UnreachableDaemonIs503AndDoesNotFallThrough(t *testing.T) {
store := newStubStore()
users := newStubUsers()
rs, err := NewResolver("bigbes", store,
WithInstancePlane(validatorFor(t, unreachableOrigin(t)), users))
require.NoError(t, err)
tok := instanceToken("spec:propose id:42")
store.add(tok, "shadow")
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")
assert.Zero(t, store.calls, "an unanswerable revocation must not fall back to the local store")
_, code, reached := runMiddleware(t, rs, bearerRequest(tok))
assert.False(t, reached)
assert.Equal(t, http.StatusServiceUnavailable, code)
}
// The two refusals that do fall through. spec's local token has no prefix to
// discriminate on, so "did not decode as one of ours" is exactly what it looks
// like — which is why the order is instance-plane-first with a fallback rather
// than a shape test.
func TestResolve_ForeignAndUndecodableTokensFallThroughToTheLocalPlane(t *testing.T) {
metaPAT := seal("bigbes", "meta.sr.ht", "git.sr.ht/OBJECTS:RW", time.Now().Add(time.Hour))
for name, presented := range map[string]string{
"opaque local secret": "live-token",
"expired instance token": seal("bigbes", bearer.TokensClientID, "spec:propose",
time.Now().Add(-time.Hour)),
"meta.sr.ht PAT": metaPAT,
} {
t.Run(name, func(t *testing.T) {
t.Run("registered locally", func(t *testing.T) {
f := newPlaneFixture(t, http.StatusNoContent)
f.store.add(presented, "laptop")
p, err := f.rs.Resolve(context.Background(), bearerRequest(presented))
require.NoError(t, err)
assert.True(t, p.IsAgent())
assert.Equal(t, PlaneLocal, p.Plane)
assert.Equal(t, 1, f.store.calls)
})
t.Run("not registered", func(t *testing.T) {
f := newPlaneFixture(t, http.StatusNoContent)
_, err := f.rs.Resolve(context.Background(), bearerRequest(presented))
assert.ErrorIs(t, err, ErrUnknownToken,
"the local plane must be the one that refuses it")
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")
assert.Zero(t, f.store.calls, "and never falls through to the local plane")
_, 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))
}
// An instance with no [tokens.sr.ht] section builds no instance plane, starts,
// and serves its local agent token exactly as before.
func TestResolve_WithoutTheInstancePlaneOnlyTheLocalOneExists(t *testing.T) {
store := newStubStore()
rs, err := NewResolver("bigbes", store)
require.NoError(t, err)
assert.False(t, rs.HasInstancePlane())
store.add("live-token", "laptop")
p, err := rs.Resolve(context.Background(), bearerRequest("live-token"))
require.NoError(t, err)
assert.True(t, p.IsAgent())
assert.Equal(t, PlaneLocal, p.Plane)
// A perfectly good instance token is just an unknown secret here — there is
// nothing on this instance that could validate it.
_, err = rs.Resolve(context.Background(), bearerRequest(instanceToken("spec:propose")))
assert.ErrorIs(t, err, ErrUnknownToken)
}
func TestWithInstancePlane_RejectsHalfWiring(t *testing.T) {
v := validatorFor(t, "https://tokens.example")
_, err := NewResolver("bigbes", newStubStore(), WithInstancePlane(nil, newStubUsers()))
assert.Error(t, err, "a plane with no validator must be refused")
_, err = NewResolver("bigbes", newStubStore(), WithInstancePlane(v, nil))
assert.Error(t, err, "a plane with no user lookup must be refused")
}
// Provenance is mandatory on every agent write, on both planes. Grants do not
// replace it and do not excuse it.
func TestAgentWriteFor_ProvenanceRequiredOnBothPlanes(t *testing.T) {
base := "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809"
for _, plane := range []Plane{PlaneLocal, PlaneInstance} {
t.Run(string(plane), 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"},
{Kind: KindAgent, Owner: "bigbes", Plane: PlaneLocal},
{Kind: KindAgent, Owner: "bigbes"}, // an unset plane is the local one
} {
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},
{"unknown local token", ErrUnknownToken, http.StatusUnauthorized},
{"revoked local token", ErrRevokedToken, http.StatusUnauthorized},
{"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))
})
}
}