package authn
import (
"context"
"crypto/sha512"
"fmt"
"sync"
"time"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// RepoScope is the OAuth grant scope a meta.sr.ht personal access token must
// carry to act on dolt.sr.ht repositories: "dolt.sr.ht/repos". Reads require
// ":RO", pushes require ":RW". Personal tokens with no explicit grants are
// universal and pass unconditionally (auth.Grants.HasAll semantics).
const RepoScope = "dolt.sr.ht/repos"
// 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
// dolt.sr.ht repositories. 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.
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(RepoScope, kind)
}