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 }