package authn import ( "context" "errors" "net/http" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-ecore/metapat" ) // The meta.sr.ht plane as a unit: what it makes of an answer from the shared // validator, and what each of that validator's refusals means here. // // graph/credential_test.go drives the same plane through a real handler with a // real metapat.Validator behind it. These go in at the seam instead, because the // arms that matter most — a meta.sr.ht that will not answer, a sentinel nobody // has written yet — cannot be provoked from the outside without breaking // something on purpose. // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- // stubPATs is an in-memory MetaValidator: it answers with whatever the test put // in it, which is how every arm of the classification below is exercised with no // meta.sr.ht, no network and no database. type stubPATs struct { ac *auth.AuthContext err error calls int } func (s *stubPATs) Resolve(context.Context, string) (*auth.AuthContext, error) { s.calls++ if s.err != nil { return nil, s.err } return s.ac, nil } // resolvedPAT is what metapat.Resolve leaves behind for a live personal access // token of username carrying grantString: core-go's own OAuth2 caller, with the // grants decoded in meta's vocabulary. // // They are decoded through a context naming this service because // auth.DecodeGrants reads that name to expand a grant written without one — and // panics when nothing put it there. That is the trap metapat.Options.Service // exists to close, met here from the other side. func resolvedPAT(t *testing.T, username, grantString string) *auth.AuthContext { t.Helper() g, err := auth.DecodeGrants( config.Context(context.Background(), ini.File{}, ConfigSection), grantString) require.NoError(t, err) return &auth.AuthContext{ AuthMethod: auth.AUTH_OAUTH2, UserID: 1, Username: username, Email: username + "@example.org", BearerToken: &auth.BearerToken{Username: username, Grants: grantString}, Grants: g, } } // newMetaAuth builds the plane over a validator that answers every credential // the same way, for the instance owner these tests use throughout. func newMetaAuth(t *testing.T, ac *auth.AuthContext, resolveErr error) (*MetaAuth, *stubPATs) { t.Helper() stub := &stubPATs{ac: ac, err: resolveErr} plane, err := NewMetaAuth(stub, "bigbes", ScopeRead) require.NoError(t, err) return plane, stub } // errUnknownToTheTable is a refusal this package's classification has never seen. // It stands for the sentinel a future version of metapat adds without telling // anybody, which is the case the fail-closed default exists for. var errUnknownToTheTable = errors.New("metapat: something nobody has classified") // --------------------------------------------------------------------------- // Construction // --------------------------------------------------------------------------- func TestNewMetaAuthRefusesAPlaneThatCannotWork(t *testing.T) { t.Run("a nil validator", func(t *testing.T) { // Not the "this instance has no such plane" configuration — that one is a // nil *MetaAuth, and graph.New refuses even that — but a plane that would // fail every PAT while looking configured. _, err := NewMetaAuth(nil, "bigbes", ScopeRead) require.Error(t, err) assert.Contains(t, err.Error(), "MetaValidator") }) t.Run("an empty owner", func(t *testing.T) { // Every token's account would be compared against "", so the check that // makes this a single-owner instance would be the one silently doing // nothing. _, err := NewMetaAuth(&stubPATs{}, "", ScopeRead) require.Error(t, err) assert.Contains(t, err.Error(), "owner") }) t.Run("an owner that is not a usable name", func(t *testing.T) { // The same rule NewResolver applies, so the two planes cannot disagree // about who the instance owner is. _, err := NewMetaAuth(&stubPATs{}, "not a username", ScopeRead) require.Error(t, err) }) t.Run("an empty scope", func(t *testing.T) { // metapat.Allows would be asked about the grant name "", which no token // carries and no meta checkbox can mint: every PAT on the instance // refused, with a message naming a permission that does not exist. _, err := NewMetaAuth(&stubPATs{}, "bigbes", "") require.Error(t, err) assert.Contains(t, err.Error(), "scope") }) } func TestTheOwnerAndScopeAreSpelledOnceAndReadBack(t *testing.T) { plane, _ := newMetaAuth(t, nil, nil) assert.Equal(t, ScopeRead, plane.Scope()) assert.Equal(t, "bigbes", plane.Owner()) // The leading '~' is a display convention, not part of the name, and both // planes strip it — a resolver built with "~bigbes" and one built with // "bigbes" must admit the same tokens. tilde, err := NewMetaAuth(&stubPATs{}, "~bigbes", ScopeRead) require.NoError(t, err) assert.Equal(t, "bigbes", tilde.Owner()) // The two halves the instance has to agree on: what meta prefixes into a // checkbox, and what this service checks. assert.Equal(t, ScopeRead, metapat.Scope(ConfigSection, metapat.ScopeName(ScopeRead))) } // --------------------------------------------------------------------------- // VerifyToken // --------------------------------------------------------------------------- func TestAPersonalAccessTokenCarryingTheScopeIsAnAgent(t *testing.T) { plane, stub := newMetaAuth(t, resolvedPAT(t, "bigbes", ScopeRead+":RO"), nil) p, err := plane.VerifyToken(t.Context(), "presented") require.NoError(t, err) assert.Equal(t, 1, stub.calls) assert.Equal(t, KindAgent, p.Kind) assert.Equal(t, PlaneMeta, p.Plane) assert.Equal(t, "bigbes", p.Owner) assert.Equal(t, 1, p.UserID, "the row id every user-scoped lookup keys off") assert.True(t, p.CanRead()) // It may read and it may not approve: KindOwner is the human at a browser, // and a PAT is a string any process holding it can present. assert.False(t, p.IsOwner()) // It carries no tokens.sr.ht grants, and must not be asked for any — no PAT // can ever be minted with spec:read, so a grant check here would refuse every // one of them rather than scope it. assert.NoError(t, p.Authorize(ActionRead)) assert.NoError(t, p.Authorize(ActionPropose)) } func TestAnUngrantedPersonalAccessTokenIsUniversal(t *testing.T) { // meta.sr.ht mints a token with no grants selected and core-go reads that as // every permission (auth.Grants.HasAll). Refusing it here would refuse the // commonest credential on the instance — and the one a federated query is // most likely to arrive holding. plane, _ := newMetaAuth(t, resolvedPAT(t, "bigbes", ""), nil) p, err := plane.VerifyToken(t.Context(), "presented") require.NoError(t, err) assert.Equal(t, PlaneMeta, p.Plane) } func TestAPersonalAccessTokenWithoutTheScopeIsForbidden(t *testing.T) { plane, _ := newMetaAuth(t, resolvedPAT(t, "bigbes", "meta.sr.ht/PROFILE:RO"), nil) p, err := plane.VerifyToken(t.Context(), "presented") require.ErrorIs(t, err, ErrMissingScope) assert.Equal(t, http.StatusForbidden, StatusFor(err), "403: the credential is fine, the permission is not") assert.False(t, IsAuthFailure(err), "401 would send the holder re-minting a good token") assert.True(t, p.IsAnonymous()) assert.Contains(t, err.Error(), ScopeRead, "the refusal must name what to go and obtain") } func TestAPersonalAccessTokenOfAnotherOwnerIsForbidden(t *testing.T) { // The single-owner rule, applied to the credential every account on the // instance can mint for itself. Without it the widest credential in existence // would be the one that skipped the narrowest identity check. // // 403 and not 401, and the same sentinel a foreign working token gets: the // token verifies and the holder is who they say they are, there is simply // nothing on this instance to grant them, so retrying will not help. plane, _ := newMetaAuth(t, resolvedPAT(t, "somebody-else", ScopeRead), nil) p, err := plane.VerifyToken(t.Context(), "presented") require.ErrorIs(t, err, ErrNotInstanceOwner) assert.Equal(t, http.StatusForbidden, StatusFor(err)) assert.True(t, p.IsAnonymous()) // The refusal is about whose token it is and not about what it carries: this // one holds the scope and is refused anyway, because a scope says what a // token may do and never whose it is. assert.NotErrorIs(t, err, ErrMissingScope) } func TestTheOwnerIsRecognisedThroughATildeInTheToken(t *testing.T) { // meta.sr.ht writes usernames both ways depending on the call, and the other // plane already strips one leading '~' before comparing. A plane that did not // would refuse the instance owner's own token half the time. plane, _ := newMetaAuth(t, resolvedPAT(t, "~bigbes", ScopeRead), nil) p, err := plane.VerifyToken(t.Context(), "presented") require.NoError(t, err) assert.Equal(t, "bigbes", p.Owner) } func TestAnAbsentCredentialIsNotARefusal(t *testing.T) { // ErrNoToken is what the caller reads as "nothing was presented" rather than // as a rejection, and nothing is asked of meta.sr.ht for it. plane, stub := newMetaAuth(t, nil, nil) _, err := plane.VerifyToken(t.Context(), "") require.ErrorIs(t, err, ErrNoToken) assert.Zero(t, stub.calls, "nothing to validate, so nothing is asked") } func TestAResolvedCallerWithNoIDIsRefused(t *testing.T) { // metapat refuses this itself; the check is kept because the seam is an // interface, and a zero id downstream would own whichever row has an unset // owner. ac := resolvedPAT(t, "bigbes", ScopeRead) ac.UserID = 0 plane, _ := newMetaAuth(t, ac, nil) _, err := plane.VerifyToken(t.Context(), "presented") require.ErrorIs(t, err, ErrInvalidPersonalToken) assert.True(t, IsAuthFailure(err)) assert.Equal(t, http.StatusUnauthorized, StatusFor(err)) } // --------------------------------------------------------------------------- // classifyResolve — the table the surfaces turn into statuses // --------------------------------------------------------------------------- func TestEveryRefusalOfTheSharedValidatorIsClassified(t *testing.T) { cases := []struct { name string from error want error status int }{ { name: "a forged or expired token", from: metapat.ErrInvalid, want: ErrInvalidPersonalToken, status: http.StatusUnauthorized, }, { name: "a revoked token", from: metapat.ErrRevoked, want: ErrInvalidPersonalToken, status: http.StatusUnauthorized, }, { // Unreachable in production — graph routes on metapat.PlaneOf before // this plane is asked — and classified so the table is total. Were it // ever to arrive it is a bad credential *for this plane*. name: "a working token that reached the wrong plane", from: metapat.ErrNotOurs, want: ErrInvalidPersonalToken, status: http.StatusUnauthorized, }, { // Resolve is never told a scope, so it cannot raise this; classified // so the table is total. name: "a scope refusal from the validator itself", from: metapat.ErrForbidden, want: ErrMissingScope, status: http.StatusForbidden, }, { // The arm that has to be defended: "I could not check" is not "your // credential is bad", and 401 here would tell every federated client // on the instance to re-mint over a meta.sr.ht restart. name: "meta.sr.ht unreachable", from: metapat.ErrUnavailable, want: ErrMetaUnavailable, status: http.StatusServiceUnavailable, }, { // Fail-closed: a sentinel this table has never seen must read as "I // could not decide", never as a verdict about the credential. name: "a sentinel nobody has classified yet", from: errUnknownToTheTable, want: ErrMetaUnavailable, status: http.StatusServiceUnavailable, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { plane, _ := newMetaAuth(t, nil, tc.from) p, err := plane.VerifyToken(t.Context(), "presented") require.ErrorIs(t, err, tc.want) assert.ErrorIs(t, err, tc.from, "the cause must survive for the log") assert.Equal(t, tc.status, StatusFor(err)) assert.True(t, p.IsAnonymous(), "a refused caller carries no authority") }) } } // The two planes owe the surfaces above the same three answers, so their tables // must not drift apart. This asserts the property rather than the code: every // refusal this plane can produce lands on exactly one of 401, 403 and 503, and // never on 200. func TestTheClassificationIsTotalAndUnambiguous(t *testing.T) { for _, from := range []error{ metapat.ErrInvalid, metapat.ErrNotOurs, metapat.ErrRevoked, metapat.ErrForbidden, metapat.ErrUnavailable, errUnknownToTheTable, } { plane, _ := newMetaAuth(t, nil, from) _, err := plane.VerifyToken(t.Context(), "presented") require.Error(t, err) status := StatusFor(err) assert.Contains(t, []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusServiceUnavailable}, status, "%v mapped to %d", from, status) } }