Ctrl K
ComunidadGuías…

internal/oauth/service.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package oauth

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/stump-wtf/cairn/internal/id"
	"github.com/stump-wtf/cairn/internal/user"
)

// Defaults for the token model (SPEC-0007 REQ "Token Model": ~1h access,
// longer-lived rotating refresh; authorization codes single-use and
// short-lived).
const (
	DefaultAccessTTL  = time.Hour
	DefaultRefreshTTL = 30 * 24 * time.Hour
	DefaultCodeTTL    = 5 * time.Minute
)

// Options tunes the Service; zero values take the defaults above.
type Options struct {
	AccessTTL  time.Duration
	RefreshTTL time.Duration
	CodeTTL    time.Duration
}

// Service is the authorization-server core over Postgres. Every multi-step
// state change (code redemption minting a grant + token family; refresh
// rotation; revocation cascade) runs in a single transaction so a grant is
// never half-written (SPEC-0007 REQ "Database Operation Standards"), and every
// query is parameterized.
type Service struct {
	pool       *pgxpool.Pool
	audience   string
	accessTTL  time.Duration
	refreshTTL time.Duration
	codeTTL    time.Duration
	now        func() time.Time
}

// NewService builds the authorization-server core. audience is the RFC 8707
// resource identifier access tokens are bound to — Cairn's own public origin —
// so a token minted here is rejected by any other audience (SPEC-0007 scenario
// "Access token bound to audience").
func NewService(pool *pgxpool.Pool, audience string, opts Options) *Service {
	if opts.AccessTTL <= 0 {
		opts.AccessTTL = DefaultAccessTTL
	}
	if opts.RefreshTTL <= 0 {
		opts.RefreshTTL = DefaultRefreshTTL
	}
	if opts.CodeTTL <= 0 {
		opts.CodeTTL = DefaultCodeTTL
	}
	return &Service{
		pool:       pool,
		audience:   audience,
		accessTTL:  opts.AccessTTL,
		refreshTTL: opts.RefreshTTL,
		codeTTL:    opts.CodeTTL,
		now:        time.Now,
	}
}

// Audience returns the resource identifier this server binds access tokens to.
func (s *Service) Audience() string { return s.audience }

// RegisterClient persists a dynamically-registered public client (RFC 7591).
// Every redirect URI must pass the registration rules; a violation is
// ErrInvalidRedirect and nothing is stored (SPEC-0007 scenario "Registration
// with an invalid redirect URI").
func (s *Service) RegisterClient(ctx context.Context, name string, redirectURIs []string) (*Client, error) {
	if len(redirectURIs) == 0 {
		return nil, fmt.Errorf("at least one redirect_uri is required: %w", ErrInvalidRedirect)
	}
	if len(redirectURIs) > 10 {
		return nil, fmt.Errorf("too many redirect_uris: %w", ErrInvalidRequest)
	}
	for _, u := range redirectURIs {
		if err := ValidateRedirectURI(u); err != nil {
			return nil, err
		}
	}
	if len(name) > 256 {
		return nil, fmt.Errorf("client_name too long: %w", ErrInvalidRequest)
	}
	cid, err := id.NewLength(24)
	if err != nil {
		return nil, fmt.Errorf("oauth: mint client id: %w", err)
	}
	c := &Client{ID: "cairn-" + cid, Name: name, RedirectURIs: redirectURIs, CreatedAt: s.now().UTC()}
	if _, err := s.pool.Exec(ctx, `
		INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at)
		VALUES ($1, $2, $3, $4)`,
		c.ID, c.Name, c.RedirectURIs, c.CreatedAt,
	); err != nil {
		return nil, fmt.Errorf("oauth: insert client: %w", err)
	}
	return c, nil
}

// GetClient resolves a client id, or ErrInvalidClient when unknown.
func (s *Service) GetClient(ctx context.Context, clientID string) (*Client, error) {
	if clientID == "" {
		return nil, fmt.Errorf("client_id is required: %w", ErrInvalidClient)
	}
	c := &Client{ID: clientID}
	err := s.pool.QueryRow(ctx, `
		SELECT client_name, redirect_uris, created_at
		FROM oauth_clients WHERE client_id = $1`,
		clientID,
	).Scan(&c.Name, &c.RedirectURIs, &c.CreatedAt)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, fmt.Errorf("unknown client %q: %w", clientID, ErrInvalidClient)
	}
	if err != nil {
		return nil, fmt.Errorf("oauth: get client: %w", err)
	}
	return c, nil
}

// CreateAuthCode mints a single-use authorization code after consent approval,
// bound to the client, the exact redirect URI presented, the approved scope
// subset, and the PKCE S256 challenge (SPEC-0007: codes "single-use,
// short-lived, and bound to the client, redirect URI, and PKCE challenge").
// The raw code is returned exactly once; only its hash is stored.
func (s *Service) CreateAuthCode(ctx context.Context, clientID, userID, redirectURI string, scopes []string, challenge string) (string, error) {
	if clientID == "" || !user.ValidID(userID) || redirectURI == "" || len(scopes) == 0 {
		return "", fmt.Errorf("code requires client, actor, redirect uri, and scopes: %w", ErrInvalidRequest)
	}
	if !ValidChallenge(challenge) {
		return "", fmt.Errorf("a valid S256 code_challenge is required: %w", ErrInvalidRequest)
	}
	code, err := newSecret("cairn_ac_")
	if err != nil {
		return "", err
	}
	now := s.now().UTC()
	if _, err := s.pool.Exec(ctx, `
		INSERT INTO oauth_auth_codes
			(code_hash, client_id, user_id, redirect_uri, scope, code_challenge, created_at, expires_at)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
		hashSecret(code), clientID, userID, redirectURI, JoinScope(scopes), challenge, now, now.Add(s.codeTTL),
	); err != nil {
		return "", fmt.Errorf("oauth: insert auth code: %w", err)
	}
	return code, nil
}

// RedeemCode exchanges an authorization code + PKCE verifier for a fresh grant
// and its token family, atomically: the code is marked spent, the grant row
// created, and both tokens inserted in one transaction. Failure modes are all
// ErrInvalidGrant (unknown/expired code, client or redirect mismatch, bad
// verifier) so the token endpoint leaks nothing about which check failed. A
// replayed (already-redeemed) code additionally revokes the grant it minted
// (SPEC-0007 scenario "Replayed authorization code").
func (s *Service) RedeemCode(ctx context.Context, code, clientID, redirectURI, verifier string) (*TokenSet, error) {
	if code == "" || clientID == "" {
		return nil, fmt.Errorf("code and client_id are required: %w", ErrInvalidGrant)
	}
	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, fmt.Errorf("oauth: begin redeem: %w", err)
	}
	defer tx.Rollback(ctx) //nolint:errcheck // no-op after commit

	var (
		rowClientID, rowUser, rowRedirect, rowScope, rowChallenge string
		expiresAt                                                 time.Time
		redeemedAt, suspendedAt                                   *time.Time
		priorGrantID                                              *string
	)
	err = tx.QueryRow(ctx, `
		SELECT c.client_id, c.user_id::text, c.redirect_uri, c.scope, c.code_challenge,
		       c.expires_at, c.redeemed_at, c.grant_id, u.suspended_at
		FROM oauth_auth_codes c JOIN users u ON u.id = c.user_id
		WHERE c.code_hash = $1 FOR UPDATE OF c`,
		hashSecret(code),
	).Scan(&rowClientID, &rowUser, &rowRedirect, &rowScope, &rowChallenge, &expiresAt, &redeemedAt, &priorGrantID, &suspendedAt)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, fmt.Errorf("unknown authorization code: %w", ErrInvalidGrant)
	}
	if err != nil {
		return nil, fmt.Errorf("oauth: load auth code: %w", err)
	}
	now := s.now().UTC()
	if redeemedAt != nil {
		// Replay: the code was already spent. Revoke the token family it minted
		// so a stolen code cannot coexist with the legitimate exchange.
		if priorGrantID != nil {
			if err := revokeGrantTx(ctx, tx, *priorGrantID, now); err != nil {
				return nil, err
			}
		}
		if err := tx.Commit(ctx); err != nil {
			return nil, fmt.Errorf("oauth: commit replay revocation: %w", err)
		}
		return nil, fmt.Errorf("authorization code already redeemed: %w", ErrInvalidGrant)
	}
	if now.After(expiresAt) {
		return nil, fmt.Errorf("authorization code expired: %w", ErrInvalidGrant)
	}
	// A failed exchange attempt BURNS the code (single-use means one attempt,
	// not one success): a stolen code cannot be used to brute-force the
	// verifier, and the legitimate holder's later exchange fails loudly.
	burn := func(reason string) (*TokenSet, error) {
		if _, err := tx.Exec(ctx, `
			UPDATE oauth_auth_codes SET redeemed_at = $1 WHERE code_hash = $2`,
			now, hashSecret(code),
		); err == nil {
			_ = tx.Commit(ctx)
		}
		return nil, fmt.Errorf("%s: %w", reason, ErrInvalidGrant)
	}
	if suspendedAt != nil {
		// Suspension deletes unredeemed codes; this burns one that raced it
		// (SPEC-0023 "Suspending a user offboards them").
		return burn("user suspended")
	}
	if rowClientID != clientID {
		return burn("code was not issued to this client")
	}
	if rowRedirect != redirectURI {
		return burn("redirect_uri does not match the code's binding")
	}
	if !VerifyPKCE(verifier, rowChallenge) {
		// PKCE is mandatory for every client: no verifier, wrong verifier, and
		// malformed verifier are indistinguishable failures issuing no tokens
		// (SPEC-0007 scenario "Code exchange without a verifier").
		return burn("pkce verification failed")
	}

	gid, err := id.NewLength(16)
	if err != nil {
		return nil, fmt.Errorf("oauth: mint grant id: %w", err)
	}
	grantID := "grant-" + gid
	if _, err := tx.Exec(ctx, `
		INSERT INTO oauth_grants (grant_id, client_id, user_id, scope, created_at, last_used_at)
		VALUES ($1, $2, $3, $4, $5, $5)`,
		grantID, clientID, rowUser, rowScope, now,
	); err != nil {
		return nil, fmt.Errorf("oauth: insert grant: %w", err)
	}
	if _, err := tx.Exec(ctx, `
		UPDATE oauth_auth_codes SET redeemed_at = $1, grant_id = $2 WHERE code_hash = $3`,
		now, grantID, hashSecret(code),
	); err != nil {
		return nil, fmt.Errorf("oauth: mark code redeemed: %w", err)
	}
	set, err := s.issueTokensTx(ctx, tx, grantID, rowScope, now)
	if err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, fmt.Errorf("oauth: commit redeem: %w", err)
	}
	return set, nil
}

// Refresh rotates a refresh token: the presented token is invalidated and a
// new access + refresh pair issued, atomically, so a crash can never leave two
// valid or zero valid refresh tokens (SPEC-0007 scenario "Atomic refresh
// rotation"). Presenting a rotated-out or revoked refresh token is reuse —
// the whole token family (the grant) is revoked (scenario "Refresh rotation
// and reuse detection").
func (s *Service) Refresh(ctx context.Context, refreshToken, clientID string) (*TokenSet, error) {
	if refreshToken == "" || clientID == "" {
		return nil, fmt.Errorf("refresh_token and client_id are required: %w", ErrInvalidGrant)
	}
	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, fmt.Errorf("oauth: begin refresh: %w", err)
	}
	defer tx.Rollback(ctx) //nolint:errcheck

	var (
		grantID, kind, grantClient, scope               string
		tokenExpires                                    time.Time
		rotatedAt, revokedAt, grantRevoked, suspendedAt *time.Time
	)
	err = tx.QueryRow(ctx, `
		SELECT t.grant_id, t.kind, t.expires_at, t.rotated_at, t.revoked_at,
		       g.client_id, g.scope, g.revoked_at, u.suspended_at
		FROM oauth_tokens t
		JOIN oauth_grants g ON g.grant_id = t.grant_id
		JOIN users u ON u.id = g.user_id
		WHERE t.token_hash = $1 FOR UPDATE OF t, g`,
		hashSecret(refreshToken),
	).Scan(&grantID, &kind, &tokenExpires, &rotatedAt, &revokedAt, &grantClient, &scope, &grantRevoked, &suspendedAt)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, fmt.Errorf("unknown refresh token: %w", ErrInvalidGrant)
	}
	if err != nil {
		return nil, fmt.Errorf("oauth: load refresh token: %w", err)
	}
	now := s.now().UTC()
	if kind != "refresh" {
		return nil, fmt.Errorf("token is not a refresh token: %w", ErrInvalidGrant)
	}
	if grantClient != clientID {
		return nil, fmt.Errorf("refresh token was not issued to this client: %w", ErrInvalidGrant)
	}
	if rotatedAt != nil || revokedAt != nil {
		// Reuse of a rotated-out (or revoked) refresh token: assume theft and
		// revoke the entire family, then surface invalid_grant.
		if err := revokeGrantTx(ctx, tx, grantID, now); err != nil {
			return nil, err
		}
		if err := tx.Commit(ctx); err != nil {
			return nil, fmt.Errorf("oauth: commit reuse revocation: %w", err)
		}
		return nil, fmt.Errorf("refresh token reuse detected, family revoked: %w", ErrInvalidGrant)
	}
	if grantRevoked != nil {
		return nil, fmt.Errorf("grant is revoked: %w", ErrInvalidGrant)
	}
	if suspendedAt != nil {
		return nil, fmt.Errorf("user suspended: %w", ErrInvalidGrant)
	}
	if now.After(tokenExpires) {
		return nil, fmt.Errorf("refresh token expired: %w", ErrInvalidGrant)
	}
	if _, err := tx.Exec(ctx, `
		UPDATE oauth_tokens SET rotated_at = $1, revoked_at = $1 WHERE token_hash = $2`,
		now, hashSecret(refreshToken),
	); err != nil {
		return nil, fmt.Errorf("oauth: rotate out refresh token: %w", err)
	}
	if _, err := tx.Exec(ctx, `
		UPDATE oauth_grants SET last_used_at = $1 WHERE grant_id = $2`,
		now, grantID,
	); err != nil {
		return nil, fmt.Errorf("oauth: touch grant: %w", err)
	}
	set, err := s.issueTokensTx(ctx, tx, grantID, scope, now)
	if err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, fmt.Errorf("oauth: commit refresh: %w", err)
	}
	return set, nil
}

// issueTokensTx mints and persists one access + refresh pair for a grant
// inside the caller's transaction. Access tokens are audience-bound to the
// Cairn resource server (RFC 8707).
func (s *Service) issueTokensTx(ctx context.Context, tx pgx.Tx, grantID, scope string, now time.Time) (*TokenSet, error) {
	access, err := newSecret("cairn_at_")
	if err != nil {
		return nil, err
	}
	refresh, err := newSecret("cairn_rt_")
	if err != nil {
		return nil, err
	}
	if _, err := tx.Exec(ctx, `
		INSERT INTO oauth_tokens (token_hash, grant_id, kind, audience, created_at, expires_at)
		VALUES ($1, $2, 'access', $3, $4, $5), ($6, $2, 'refresh', $3, $4, $7)`,
		hashSecret(access), grantID, s.audience, now, now.Add(s.accessTTL),
		hashSecret(refresh), now.Add(s.refreshTTL),
	); err != nil {
		return nil, fmt.Errorf("oauth: insert token family: %w", err)
	}
	return &TokenSet{
		GrantID:         grantID,
		AccessToken:     access,
		RefreshToken:    refresh,
		AccessExpiresIn: int64(s.accessTTL / time.Second),
		Scope:           scope,
	}, nil
}

// revokeGrantTx revokes a grant and every token in its family inside the
// caller's transaction (the RFC 7009 / reuse-detection cascade).
func revokeGrantTx(ctx context.Context, tx pgx.Tx, grantID string, now time.Time) error {
	if _, err := tx.Exec(ctx, `
		UPDATE oauth_grants SET revoked_at = COALESCE(revoked_at, $1) WHERE grant_id = $2`,
		now, grantID,
	); err != nil {
		return fmt.Errorf("oauth: revoke grant: %w", err)
	}
	if _, err := tx.Exec(ctx, `
		UPDATE oauth_tokens SET revoked_at = COALESCE(revoked_at, $1) WHERE grant_id = $2`,
		now, grantID,
	); err != nil {
		return fmt.Errorf("oauth: revoke grant tokens: %w", err)
	}
	return nil
}

// Revoke implements RFC 7009 semantics over a presented token (access or
// refresh): when the token resolves to a grant owned by the presenting
// client, the entire grant family is revoked — access and refresh together,
// without touching the human's sibling grants (SPEC-0007 scenario "Revoke one
// connection"). An unknown token is NOT an error (RFC 7009 §2.2: the client
// asked for an already-invalid credential to be invalid). A token owned by a
// different client is treated as unknown, leaking nothing.
func (s *Service) Revoke(ctx context.Context, token, clientID string) error {
	if token == "" {
		return fmt.Errorf("token is required: %w", ErrInvalidRequest)
	}
	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("oauth: begin revoke: %w", err)
	}
	defer tx.Rollback(ctx) //nolint:errcheck

	var (
		grantID, grantClient string
	)
	err = tx.QueryRow(ctx, `
		SELECT t.grant_id, g.client_id
		FROM oauth_tokens t
		JOIN oauth_grants g ON g.grant_id = t.grant_id
		WHERE t.token_hash = $1 FOR UPDATE OF t, g`,
		hashSecret(token),
	).Scan(&grantID, &grantClient)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil // unknown token: success per RFC 7009
	}
	if err != nil {
		return fmt.Errorf("oauth: load token for revocation: %w", err)
	}
	if clientID == "" || grantClient != clientID {
		return nil // not this client's token: indistinguishable from unknown
	}
	if err := revokeGrantTx(ctx, tx, grantID, s.now().UTC()); err != nil {
		return err
	}
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("oauth: commit revoke: %w", err)
	}
	return nil
}

// RevokeGrant revokes a grant (and its whole token family) by id — the
// settings-page path ("revoke anytime in settings").
func (s *Service) RevokeGrant(ctx context.Context, grantID string) error {
	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("oauth: begin revoke grant: %w", err)
	}
	defer tx.Rollback(ctx) //nolint:errcheck
	if err := revokeGrantTx(ctx, tx, grantID, s.now().UTC()); err != nil {
		return err
	}
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("oauth: commit revoke grant: %w", err)
	}
	return nil
}

// AuthenticateAccess resolves a presented access token to its identity: the
// human subject, the client, and the granted scopes. It fails with
// ErrInvalidGrant for anything not a live, audience-valid access token —
// unknown, expired, revoked, wrong kind, or bound to a different audience
// (SPEC-0007 scenario "Access token bound to audience", scenario "Revoked
// token used"). Lookup is by hash, parameterized, constant-cost.
func (s *Service) AuthenticateAccess(ctx context.Context, token string) (*Identity, error) {
	if token == "" {
		return nil, fmt.Errorf("no token presented: %w", ErrInvalidGrant)
	}
	var (
		kind, audience, grantID, clientID, userID, actor, scope string
		expiresAt                                               time.Time
		revokedAt, grantRevoked, suspendedAt                    *time.Time
	)
	err := s.pool.QueryRow(ctx, `
		SELECT t.kind, t.audience, t.expires_at, t.revoked_at,
		       g.grant_id, g.client_id, g.user_id::text, `+user.ActorSQL("u")+`, g.scope, g.revoked_at,
		       u.suspended_at
		FROM oauth_tokens t
		JOIN oauth_grants g ON g.grant_id = t.grant_id
		JOIN users u ON u.id = g.user_id
		WHERE t.token_hash = $1`,
		hashSecret(token),
	).Scan(&kind, &audience, &expiresAt, &revokedAt, &grantID, &clientID, &userID, &actor, &scope, &grantRevoked, &suspendedAt)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, fmt.Errorf("unknown access token: %w", ErrInvalidGrant)
	}
	if err != nil {
		return nil, fmt.Errorf("oauth: load access token: %w", err)
	}
	now := s.now().UTC()
	switch {
	case kind != "access":
		return nil, fmt.Errorf("token is not an access token: %w", ErrInvalidGrant)
	case audience != s.audience:
		return nil, fmt.Errorf("token audience mismatch: %w", ErrInvalidGrant)
	case revokedAt != nil || grantRevoked != nil:
		return nil, fmt.Errorf("token revoked: %w", ErrInvalidGrant)
	case suspendedAt != nil:
		// Suspension revokes the user's grants too; this refuses a token
		// that raced the revocation (SPEC-0023 "Suspending a user offboards
		// them").
		return nil, fmt.Errorf("user suspended: %w", ErrInvalidGrant)
	case now.After(expiresAt):
		return nil, fmt.Errorf("token expired: %w", ErrInvalidGrant)
	}
	scopes, err := ParseScope(scope)
	if err != nil {
		return nil, fmt.Errorf("oauth: stored scope invalid: %w", err)
	}
	return &Identity{UserID: userID, ActorID: actor, ClientID: clientID, GrantID: grantID, Scopes: scopes, ExpiresAt: expiresAt}, nil
}

// ActorGrant is one OAuth connection a human has approved, as the Settings
// page lists it: which client, with which scopes, since when, last used
// when. It carries no secret — grant ids are owner-scoped identifiers, and
// every token in the family is hashed at rest.
type ActorGrant struct {
	GrantID   string
	ClientID  string
	Client    string
	Scope     string
	CreatedAt time.Time
	LastUsed  *time.Time
}

// ListActorGrants returns the user's still-active OAuth grants, newest
// first (SPEC-0023 REQ "Closing the Audited Surfaces", A20): a grant used
// purely over REST never opens an MCP session, so without this it is
// invisible to the one human who can revoke it. Grants belong to a user id
// (SPEC-0023 REQ "Owner Model").
func (s *Service) ListActorGrants(ctx context.Context, userID string) ([]*ActorGrant, error) {
	if !user.ValidID(userID) {
		return nil, fmt.Errorf("oauth: user is required: %w", ErrInvalidRequest)
	}
	rows, err := s.pool.Query(ctx, `
		SELECT g.grant_id, g.client_id, c.client_name, g.scope, g.created_at, g.last_used_at
		FROM oauth_grants g
		JOIN oauth_clients c ON c.client_id = g.client_id
		WHERE g.user_id = $1 AND g.revoked_at IS NULL
		ORDER BY g.created_at DESC`,
		userID)
	if err != nil {
		return nil, fmt.Errorf("oauth: list grants: %w", err)
	}
	defer rows.Close()
	var out []*ActorGrant
	for rows.Next() {
		g := &ActorGrant{}
		if err := rows.Scan(&g.GrantID, &g.ClientID, &g.Client, &g.Scope, &g.CreatedAt, &g.LastUsed); err != nil {
			return nil, fmt.Errorf("oauth: scan grant: %w", err)
		}
		out = append(out, g)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("oauth: list grants: %w", err)
	}
	return out, nil
}

// RevokeActorGrant revokes the user's OWN grant and every token in its
// family — the same cascade the RFC 7009 endpoint runs, gated on ownership
// so a Settings caller can only ever cut their own connections. An unknown
// grant, or one belonging to somebody else, is the same error: the endpoint
// never discloses which.
func (s *Service) RevokeActorGrant(ctx context.Context, userID, grantID string) error {
	if grantID == "" {
		return fmt.Errorf("grant id is required: %w", ErrInvalidRequest)
	}
	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("oauth: begin revoke grant: %w", err)
	}
	defer tx.Rollback(ctx) //nolint:errcheck
	tag, err := tx.Exec(ctx, `
		UPDATE oauth_grants SET revoked_at = COALESCE(revoked_at, $1)
		WHERE grant_id = $2 AND user_id = $3 AND revoked_at IS NULL`,
		s.now().UTC(), grantID, user.IDParam(userID),
	)
	if err != nil {
		return fmt.Errorf("oauth: revoke grant: %w", err)
	}
	if tag.RowsAffected() == 0 {
		return fmt.Errorf("oauth: unknown grant: %w", ErrInvalidGrant)
	}
	if err := revokeGrantTx(ctx, tx, grantID, s.now().UTC()); err != nil {
		return err
	}
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("oauth: commit revoke grant: %w", err)
	}
	return nil
}