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)) }) } }