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