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