package authn import ( "context" "crypto/sha512" "fmt" "sync" "time" "git.sr.ht/~sircmpwn/core-go/auth" "go.bigb.es/sourcehut-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) }