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