package authn
import (
"context"
"crypto/sha512"
"fmt"
"sync"
"time"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// DatabaseScope is the OAuth grant scope a meta.sr.ht personal access token
// must carry to act on this service's databases: "dolt.sr.ht/DATABASES". Reads
// require ":RO", pushes require ":RW". Personal tokens with no explicit grants
// are universal and pass unconditionally (auth.Grants.HasAll semantics).
//
// The spelling is not free. meta.sr.ht discovers a service's scopes from its
// api-meta.json and then validates a requested grant with a plain `scope in
// scopes` — no case folding, no aliasing — so the string published there and
// the string checked here must match exactly, and a grant a user types by hand
// must match too. Every other service on the instance names its scopes in
// upper case (git.sr.ht/REPOSITORIES, todo.sr.ht/TRACKERS, paste.sr.ht/PASTES),
// because upstream derives them from a GraphQL enum; this service has no
// @access directive to derive from, so it follows the convention deliberately.
//
// DATABASES rather than REPOS because that is what the surface calls the
// object everywhere a user meets it — the GraphQL `databases` connection, the
// web pages, the docs. The storage layer underneath still says "repo"; that is
// a separate, deeper rename and is not what a token grant names.
const DatabaseScope = "dolt.sr.ht/DATABASES"
// tokenCacheTTL bounds how long a positively-resolved Basic token is trusted
// without re-checking revocation on meta.sr.ht. A single push issues many RPCs;
// caching keeps each from hammering meta while bounding the revocation-lag
// window to this duration.
const tokenCacheTTL = 60 * time.Second
type cacheEntry struct {
ac *auth.AuthContext
expires time.Time
}
var (
tokenCacheMu sync.Mutex
tokenCache = map[[64]byte]cacheEntry{}
// nowFn is overridable in tests to exercise cache expiry deterministically.
nowFn = time.Now
)
func cacheGet(key [64]byte) *auth.AuthContext {
tokenCacheMu.Lock()
defer tokenCacheMu.Unlock()
e, ok := tokenCache[key]
if !ok {
return nil
}
if !nowFn().Before(e.expires) {
delete(tokenCache, key)
return nil
}
return e.ac
}
func cachePut(key [64]byte, ac *auth.AuthContext) {
tokenCacheMu.Lock()
defer tokenCacheMu.Unlock()
tokenCache[key] = cacheEntry{ac: ac, expires: nowFn().Add(tokenCacheTTL)}
}
// ResolveBasic resolves the caller for a Basic-auth credential: a meta.sr.ht
// personal access token presented as the password alongside username. It
// implements core-go's OAuth2 validation trio, offline-first:
//
// 1. auth.DecodeBearerToken(password) — offline HMAC + expiry check.
// 2. The token's own username must equal the presented username (case- and
// "~"-insensitive), so a token cannot be used to impersonate another user.
// 3. meta.LookupUser (mirror the profile) + meta.IsRevoked (revocation check).
//
// A positive result is cached for tokenCacheTTL keyed by sha512(password);
// negative results are never cached. Suspended users resolve successfully — the
// suspension flag rides on the caller and gates writes at the access layer.
//
// Permanent rejections (bad/expired token, username mismatch, revoked) wrap
// ErrInvalidToken; a backend failure (meta unreachable, database error) is
// returned unwrapped so callers treat it as transient. See package core for how
// the resulting grants are enforced (TokenGrantsAllow).
func ResolveBasic(ctx context.Context, username, password string) (*auth.AuthContext, error) {
hash := sha512.Sum512([]byte(password))
if ac := cacheGet(hash); ac != nil {
// Guard against a cached entry being reused under a different presented
// username (same password could only be the same token, but check
// anyway — defence in depth costs nothing here).
if equalUsername(username, ac.Username) {
return ac, nil
}
}
bt := auth.DecodeBearerToken(password)
if bt == nil {
return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalidToken)
}
if !equalUsername(bt.Username, username) {
return nil, fmt.Errorf("%w: token belongs to %q, not presented user %q",
ErrInvalidToken, bt.Username, username)
}
var ac auth.AuthContext
if err := meta.LookupUser(ctx, bt.Username, &ac); err != nil {
return nil, fmt.Errorf("looking up user %q: %w", bt.Username, err)
}
revoked, err := meta.IsRevoked(ctx, bt.Username, hash, bt.ClientID)
if err != nil {
return nil, fmt.Errorf("checking token revocation for %q: %w", bt.Username, err)
}
if revoked {
return nil, fmt.Errorf("%w: token has been revoked", ErrInvalidToken)
}
grants, err := auth.DecodeGrants(ctx, bt.Grants)
if err != nil {
return nil, fmt.Errorf("%w: decoding token grants: %v", ErrInvalidToken, err)
}
ac.AuthMethod = auth.AUTH_OAUTH2
ac.BearerToken = bt
ac.TokenHash = hash
ac.Grants = grants
cachePut(hash, &ac)
return &ac, nil
}
// TokenGrantsAllow reports whether the caller's token grants permit access at
// the given mode (core.AccessRO for browse/clone, core.AccessRW for push) on
// this service's databases. It is the OAuth-grant gate that complements the ACL
// decision in core.Allowed: a token must carry BOTH sufficient grants and a
// sufficient ACL/visibility to act.
//
// Non-token callers (anonymous, cookie, or dolt-key auth) carry no OAuth grants
// and are not scoped by them, so they pass this gate unconditionally; their
// access is decided solely by core.Allowed. Personal tokens with empty grants
// are universal and also pass.
//
// The match is exact, DatabaseScope and nothing else: auth.Grants.Has is a map
// lookup, so a token minted against an older spelling of this scope is refused
// here rather than quietly honoured. Re-minting it is the fix.
func TokenGrantsAllow(ac *auth.AuthContext, mode core.AccessMode) bool {
if ac == nil || ac.BearerToken == nil {
return true
}
kind := auth.RO
if mode == core.AccessRW {
kind = auth.RW
}
return ac.Grants.Has(DatabaseScope, kind)
}