package authn import ( "context" "crypto/sha512" "errors" "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" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // forgeWorkingToken builds a token shaped exactly as tokens.sr.ht seals one: // the same format and the same HMAC key as a meta PAT, differing only in the // ClientID. That difference is the whole routing decision in ResolveBearer, so // the fixture has to carry it rather than assert it. func forgeWorkingToken(username string, expires time.Time) string { bt := auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(expires), ClientID: bearer.TokensClientID, Username: username, } return bt.Encode() } // fakeValidator stands in for sr-ht-ecore's bearer.Validator: it answers with // whatever the test configured and records what it was asked, so that a test can // tell "refused before the daemon" from "the daemon refused". type fakeValidator struct { tok *bearer.Token err error inspected []string } func (f *fakeValidator) Inspect(ctx context.Context, presented string) (*bearer.Token, error) { f.inspected = append(f.inspected, presented) if f.err != nil { return nil, f.err } return f.tok, nil } // mustGrants parses a tokens.sr.ht grant string with the one parser that is // allowed to read one. func mustGrants(t *testing.T, s string) grants.Grants { t.Helper() g, err := grants.Parse(s) require.NoError(t, err, "grants.Parse(%q)", s) return g } func TestParseBearer(t *testing.T) { cases := []struct { name string header string // "" means no Authorization header at all want string }{ {"absent header", "", ""}, {"basic is not ours to reject", "Basic dXNlcjpwYXNz", ""}, {"canonical scheme", "Bearer abc123", "abc123"}, {"lower-case scheme (RFC 7235 is case-insensitive)", "bearer abc123", "abc123"}, {"upper-case scheme", "BEARER abc123", "abc123"}, {"padded value", "Bearer abc123 ", "abc123"}, {"scheme with no value", "Bearer", ""}, {"scheme with only spaces", "Bearer ", ""}, {"bare token with no scheme", "abc123", ""}, {"unknown scheme", "Internal abc123", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/mcp", nil) if tc.header != "" { r.Header.Set("Authorization", tc.header) } assert.Equal(t, tc.want, ParseBearer(r)) }) } } func TestResolveBearer_WorkingToken_WithReadGrant(t *testing.T) { withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{ "bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER), }}) v := &fakeValidator{tok: &bearer.Token{ Username: "bigbes", Grants: mustGrants(t, "dolt:read id:7"), TokenID: 7, }} token := forgeWorkingToken("bigbes", time.Now().Add(time.Hour)) bc, err := ResolveBearer(testCtx(), v, token) require.NoError(t, err) require.NotNil(t, bc.AuthContext) assert.True(t, bc.InstanceToken) assert.Equal(t, "bigbes", bc.AuthContext.Username) assert.Equal(t, 1, bc.AuthContext.UserID) assert.Equal(t, AuthMethodInstanceToken, bc.AuthContext.AuthMethod) assert.NoError(t, bc.Authorize(core.GrantRead)) // The presented string reaches the validator untouched — nothing here // re-encodes or trims a credential. assert.Equal(t, []string{token}, v.inspected) // A working token must not be subjected to meta's OAuth gate: it carries no // core-go grants and could never satisfy it. assert.Nil(t, bc.AuthContext.BearerToken) assert.True(t, TokenGrantsAllow(bc.AuthContext, core.AccessRO)) } func TestResolveBearer_WorkingToken_WithoutReadGrant(t *testing.T) { withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{ "bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER), }}) v := &fakeValidator{tok: &bearer.Token{ Username: "bigbes", Grants: mustGrants(t, "cover:read"), }} // It resolves: who the caller is does not depend on what they may do. The // gate belongs to the surface, but the answer belongs to this package. bc, err := ResolveBearer(testCtx(), v, forgeWorkingToken("bigbes", time.Now().Add(time.Hour))) require.NoError(t, err) assert.Equal(t, "bigbes", bc.AuthContext.Username) err = bc.Authorize(core.GrantRead) require.Error(t, err) assert.ErrorIs(t, err, ErrMissingGrant) assert.NotErrorIs(t, err, ErrInvalidToken, "a narrow grant is a 403, not a bad credential") } func TestResolveBearer_WorkingToken_UniversalGrant(t *testing.T) { withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{ "bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER), }}) v := &fakeValidator{tok: &bearer.Token{ Username: "bigbes", Grants: mustGrants(t, "*"), }} bc, err := ResolveBearer(testCtx(), v, forgeWorkingToken("bigbes", time.Now().Add(time.Hour))) require.NoError(t, err) assert.NoError(t, bc.Authorize(core.GrantRead)) } func TestResolveBearer_WorkingToken_ValidatorRefusals(t *testing.T) { cases := []struct { name string err error // permanent: wraps ErrInvalidToken (401); otherwise transient (503). permanent bool grant bool }{ {"forged or expired", bearer.ErrInvalid, true, false}, {"revoked", bearer.ErrRevoked, true, false}, {"foreign issuer", bearer.ErrNotOurs, true, false}, {"missing grant", bearer.ErrForbidden, true, true}, {"daemon unreachable", bearer.ErrUnavailable, false, false}, {"a sentinel this table has never seen", errBackendDown, false, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{ "bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER), }}) v := &fakeValidator{err: tc.err} bc, err := ResolveBearer(testCtx(), v, forgeWorkingToken("bigbes", time.Now().Add(time.Hour))) require.Error(t, err) assert.Nil(t, bc, "a refusal is never a caller") assert.ErrorIs(t, err, tc.err, "the underlying refusal must stay readable") if tc.permanent { assert.ErrorIs(t, err, ErrInvalidToken) } else { assert.NotErrorIs(t, err, ErrInvalidToken, "an unreachable or unreadable answer is transient, never a credential verdict") } assert.Equal(t, tc.grant, errors.Is(err, ErrMissingGrant)) }) } } func TestResolveBearer_WorkingToken_NoValidatorIsRefused(t *testing.T) { sb := &stubBackend{users: map[string]auth.AuthContext{ "bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER), }} withStubBackend(t, sb) t.Run("a working token cannot be verified", func(t *testing.T) { _, err := ResolveBearer(testCtx(), nil, forgeWorkingToken("bigbes", time.Now().Add(time.Hour))) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidToken) assert.Empty(t, sb.lookedUp, "an unverifiable credential must not reach the backend") }) t.Run("a meta PAT still resolves", func(t *testing.T) { pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour)) bc, err := ResolveBearer(testCtx(), nil, pat) require.NoError(t, err) assert.False(t, bc.InstanceToken) assert.Equal(t, "bigbes", bc.AuthContext.Username) }) } func TestResolveBearer_WorkingToken_LookupFailures(t *testing.T) { t.Run("backend down is transient", func(t *testing.T) { withStubBackend(t, &stubBackend{lookupErr: errBackendDown}) v := &fakeValidator{tok: &bearer.Token{Username: "bigbes", Grants: mustGrants(t, "dolt:read")}} _, err := ResolveBearer(testCtx(), v, forgeWorkingToken("bigbes", time.Now().Add(time.Hour))) require.Error(t, err) assert.NotErrorIs(t, err, ErrInvalidToken) }) t.Run("no mirrored user id is permanent", func(t *testing.T) { // LookupUser answers, but with no id: nothing downstream can key on that. withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{ "ghost": {Username: "ghost", UserType: auth.USER_TYPE_USER}, }}) v := &fakeValidator{tok: &bearer.Token{Username: "ghost", Grants: mustGrants(t, "dolt:read")}} _, err := ResolveBearer(testCtx(), v, forgeWorkingToken("ghost", time.Now().Add(time.Hour))) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidToken) }) } func TestResolveBearer_MetaPAT_SufficientGrants(t *testing.T) { withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{ "bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER), }}) v := &fakeValidator{err: errBackendDown} // must not be consulted at all pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour)) bc, err := ResolveBearer(testCtx(), v, pat) require.NoError(t, err) assert.False(t, bc.InstanceToken) assert.Equal(t, "bigbes", bc.AuthContext.Username) assert.Equal(t, auth.AUTH_OAUTH2, bc.AuthContext.AuthMethod) assert.Equal(t, sha512.Sum512([]byte(pat)), bc.AuthContext.TokenHash) assert.Empty(t, v.inspected, "a PAT is never handed to the tokens.sr.ht validator") // The tokens.sr.ht vocabulary does not apply to a PAT: it was scoped at // resolve time by the OAuth gate instead. assert.NoError(t, bc.Authorize(core.GrantRead)) } func TestResolveBearer_MetaPAT_UniversalGrantsAndTilde(t *testing.T) { withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{ "bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER), }}) // An empty grant string is a universal personal token, and the token's own // username is the identity — including its "~" sigil, which must not become // a mismatch against itself. pat := forgePAT("~BigBes", "", time.Now().Add(time.Hour)) bc, err := ResolveBearer(testCtx(), nil, pat) require.NoError(t, err) assert.Equal(t, 1, bc.AuthContext.UserID) } func TestResolveBearer_MetaPAT_InsufficientGrants(t *testing.T) { withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{ "bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER), }}) pat := forgePAT("bigbes", "git.sr.ht/repos:RW", time.Now().Add(time.Hour)) bc, err := ResolveBearer(testCtx(), nil, pat) require.Error(t, err) assert.Nil(t, bc) assert.ErrorIs(t, err, ErrMissingGrant, "a 403: the token is good, its scope is not") assert.ErrorIs(t, err, ErrInvalidToken, "and permanent, so a caller that knows only the two classes still answers 401") } func TestResolveBearer_MetaPAT_Revoked(t *testing.T) { pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour)) withStubBackend(t, &stubBackend{ users: map[string]auth.AuthContext{"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER)}, revoked: map[[64]byte]bool{sha512.Sum512([]byte(pat)): true}, }) _, err := ResolveBearer(testCtx(), nil, pat) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidToken) } func TestResolveBearer_MetaPAT_BackendDownIsTransient(t *testing.T) { withStubBackend(t, &stubBackend{ users: map[string]auth.AuthContext{"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER)}, revokeErr: errBackendDown, }) pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour)) _, err := ResolveBearer(testCtx(), nil, pat) require.Error(t, err) assert.NotErrorIs(t, err, ErrInvalidToken, "meta being unreachable is a 503, not a verdict on the credential") } func TestResolveBearer_UnusableCredentials(t *testing.T) { // None of these ever reaches a backend or a validator: they fail on the // local HMAC/expiry check, which is why that step runs first. cases := []struct { name string presented string }{ {"empty (the caller lost its own header)", ""}, {"garbage", "this-is-not-a-token"}, {"expired meta PAT", forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(-time.Minute))}, {"expired working token", forgeWorkingToken("bigbes", time.Now().Add(-time.Minute))}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { sb := &stubBackend{users: map[string]auth.AuthContext{ "bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER), }} withStubBackend(t, sb) v := &fakeValidator{tok: &bearer.Token{Username: "bigbes", Grants: mustGrants(t, "*")}} bc, err := ResolveBearer(testCtx(), v, tc.presented) require.Error(t, err) assert.Nil(t, bc, "a failed credential is a refusal, never a downgrade to anonymous") assert.ErrorIs(t, err, ErrInvalidToken) assert.Empty(t, v.inspected) assert.Empty(t, sb.lookedUp) }) } }