mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
6365ac08fa
* fix(oauth): a revoked grant mints nothing, and one refresh token rotates once (BUG-3678) Fosite's refresh validates the presented token, then calls RotateRefreshToken, CreateAccessTokenSession and CreateRefreshTokenSession, each committing on its own. Two windows followed: - A revoke landing after validation: rotation flipped the family and the new pair was inserted active, so a revoked connection came back. The account claim and fosite's own RevokeRefreshToken (RFC 7009, refresh replay) had the same hole. - Two concurrent refreshes of one token: both validated, both rotated (rotation ignored the presented signature), and both pairs ended up minted. Now: - RotateRefreshToken spends the presented token conditionally, in one transaction with the family flips. A token already inactive fails with ErrOAuthRefreshNotActive, which the adapter returns as fosite.ErrSerializationFailure (invalid_request, "multiple concurrent requests"). - A revoked grant is recorded in oauth_revoked_grants (migration 143). RevokeOAuthGrant (connected-apps revoke, adapter RevokeRefreshToken) takes the subject's users row FOR NO KEY UPDATE first, then records the grant and flips the families. The claim records every grant of the account under the users row its first statement holds. - Issuance refuses a recorded request_id under the users FOR SHARE that requireActiveUserTx already takes (ErrOAuthGrantRevoked, mapped to fosite.ErrInactiveToken). So a racing refresh either committed before the revoke's flips, which then see its rows, or finds the record. Disable, installed-app grants and client deletion were already fenced (BUG-3349, TASK-3394/3399, the client FK). Tests: store repros for both windows, the claim and a double rotation, plus test-pg legs for concurrent refreshes and a revoke racing a refresh. HTTP tests drive fosite's real token endpoint through a before-rotation seam. Each production guard was removed in turn, and the matching tests went red. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): the install barrier refuses a revoked grant; rotation holds no row across the family flips (BUG-3678, codex r1) P1: an installed app's tokens are minted through installIssuanceBarrierTx, which returned before the revoked-grant check. The check now runs there under the install row. RevokeOAuthGrant locks that row (not the bot's users row, which the install lifecycle takes after the install) for an install client's grant. P2: RotateRefreshToken held the presented refresh row in a transaction while flipping the access family, and the claim and disable flip access before refresh under the users row, so the two could deadlock on Postgres. The conditional spend is its own statement again, and the flips follow it as before. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): a fosite revoke of an installed app's grant takes the app-grant path (BUG-3678, codex r2) Round 2: RevokeOAuthGrant locked the install row for an installed app's grant and then flipped refresh before access, while the account claim holds the person's row and flips access before refresh. Neither shared a lock with the other, so the two could deadlock on Postgres. Installed-app grants already have their own tombstone (the binding's revoked_at, which the issuance barrier refuses) and a lock order that the barrier, RevokeUserAppGrant, the claim and disable share through the binding rows. RevokeOAuthGrant now ends a bound grant through that same path (revokeAppGrantOnce, split out of RevokeUserAppGrant, retried on deadlock). The round-1 check in the barrier and the install-row lock in RevokeOAuthGrant are gone. oauth_revoked_grants covers only grants with no binding, under the subject's users row. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): a tokenless grant's revoke still locks the person (BUG-3678, codex r3) Round 3: RevokeOAuthGrant took the subject from token rows only, so a grant whose consent was recorded but whose first tokens were not yet committed had no person to lock. The revoke could then record the grant and flip nothing while a first issuance that had already passed its check committed an active token. The lookup now falls back to the consent's oauth_connections row. A Postgres test holds the person's row the way issuance does and requires the revoke to wait on it. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): the claim also ends grants with no token yet (BUG-3678, codex r4) Round 4: the claim recorded revoked grants from token rows only. A grant consented to before the claim but whose code was not yet exchanged had none, so an exchange that validated before the claim minted an active token after it. The claim now also records the request ids of the account's oauth_connections rows, read before it deletes them. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): the revoked-grants table exists on Postgres too (BUG-3678) Migration 143 added oauth_revoked_grants to SQLite only; pgmigrations is a separate tree, so every Postgres read and write of it failed with 42P01 (CI Go (PostgreSQL) on #2121). 113 is its twin. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
675 lines
28 KiB
Go
675 lines
28 KiB
Go
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ory/fosite"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
// Storage is the adapter that bridges fosite's storage interfaces to
|
|
// pad's persistence layer (internal/store/oauth.go from sub-PR A).
|
|
//
|
|
// What it satisfies:
|
|
//
|
|
// - fosite.ClientManager (GetClient, ClientAssertionJWTValid,
|
|
// SetClientAssertionJWT)
|
|
// - github.com/ory/fosite/handler/oauth2.AuthorizeCodeStorage
|
|
// - github.com/ory/fosite/handler/oauth2.AccessTokenStorage
|
|
// - github.com/ory/fosite/handler/oauth2.RefreshTokenStorage
|
|
// - github.com/ory/fosite/handler/oauth2.TokenRevocationStorage
|
|
// - github.com/ory/fosite/handler/pkce.PKCERequestStorage
|
|
//
|
|
// fosite's compose.Compose stuffs `storage interface{}` and type-asserts
|
|
// to fosite.Storage (which is just ClientManager) at minimum, then
|
|
// each factory type-asserts to its own narrower storage interface as
|
|
// it wires handlers. So every method declared here must be on the
|
|
// concrete *Storage receiver — no embedding shortcuts — to satisfy
|
|
// the per-factory interface set.
|
|
//
|
|
// Translation contract:
|
|
//
|
|
// - On insert (Create*Session): convert fosite.Requester →
|
|
// models.OAuthRequest, attach the supplied signature, and call
|
|
// the matching store method.
|
|
// - On read (Get*Session): fetch the stored row, hydrate session_data
|
|
// into the supplied fosite.Session pointer, build a fosite.Request
|
|
// using the client looked up via GetClient, and return it.
|
|
// - Errors: map sentinel errors from internal/store/oauth.go to
|
|
// fosite.ErrNotFound / fosite.ErrInvalidatedAuthorizeCode /
|
|
// fosite.ErrInactiveToken so fosite's handler chain branches
|
|
// correctly (esp. ErrInvalidatedAuthorizeCode → triggers grant-
|
|
// family revocation in handler/oauth2/flow_authorize_code_token.go).
|
|
type Storage struct {
|
|
store *store.Store
|
|
canonicalAudiences []string
|
|
// appAudience is the app API resource (SPEC-6 U5a): held by install
|
|
// clients only, never by a DCR client.
|
|
appAudience string
|
|
|
|
// onTokenRevoked is an optional observer that fires AFTER each
|
|
// successful access-token family revocation. Wired by cmd/pad
|
|
// from internal/metrics so the OAuth surface stays metrics-naive
|
|
// (no Prometheus import in this package).
|
|
//
|
|
// kind is one of:
|
|
// - "user_initiated" — caller hit /oauth/revoke
|
|
// - "rotated" — refresh-rotation revoked the parent family
|
|
// - "replayed" — replay-detection (refresh used twice)
|
|
//
|
|
// ttl is wall-clock age of the oldest token in the revoked family.
|
|
// Zero when the lookup couldn't determine an issuance time (no
|
|
// rows, parse failure) — observers should treat zero as "no
|
|
// observation to record."
|
|
//
|
|
// Best-effort: failures from the lookup or the observer must not
|
|
// block revocation. The store's revoke-then-observe ordering means
|
|
// the on-disk state is correct even if we crash mid-observation.
|
|
onTokenRevoked func(kind string, ttl time.Duration)
|
|
}
|
|
|
|
// SetRevocationObserver wires a callback that fires after each
|
|
// access-token family revocation. Optional — leaving it unset is
|
|
// equivalent to wiring a no-op. Replacing a previously-set observer
|
|
// is allowed; cmd/pad calls this once at startup. Concurrent calls
|
|
// to SetRevocationObserver during traffic are not synchronized —
|
|
// callers responsible for one-shot wiring before serving requests.
|
|
func (s *Storage) SetRevocationObserver(fn func(kind string, ttl time.Duration)) {
|
|
s.onTokenRevoked = fn
|
|
}
|
|
|
|
// observeRevocation looks up the oldest active-token issuance time
|
|
// for the family and fires the configured observer. Always called
|
|
// AFTER the family has been flagged inactive in storage so a slow
|
|
// observer never blocks revocation latency.
|
|
func (s *Storage) observeRevocation(requestID, kind string) {
|
|
if s.onTokenRevoked == nil {
|
|
return
|
|
}
|
|
issuedAt, err := s.store.OldestAccessTokenIssuedAtByRequestID(requestID)
|
|
if err != nil || issuedAt.IsZero() {
|
|
// No matching family (already pruned), parse failure, or
|
|
// transient store error — record nothing rather than emit a
|
|
// bogus zero/negative duration. Observability noise is worse
|
|
// than a missed datapoint.
|
|
return
|
|
}
|
|
ttl := time.Since(issuedAt)
|
|
if ttl < 0 {
|
|
// Clock skew between issuance and revocation — clamp to zero
|
|
// rather than poison the histogram with negative observations
|
|
// (Prometheus accepts them but the bucket math is meaningless).
|
|
ttl = 0
|
|
}
|
|
s.onTokenRevoked(kind, ttl)
|
|
}
|
|
|
|
// NewStorage wraps a *store.Store as a fosite-compatible adapter.
|
|
// The store must be a fully-initialized pad store (migrations applied);
|
|
// no validation here because misuse would surface as a runtime panic
|
|
// the moment fosite touches an unmigrated table — fast and obvious.
|
|
//
|
|
// canonicalAudience is the RFC 8707 audience every client implicitly
|
|
// allows (PLAN-943: every client in this server is registered to one
|
|
// resource — the MCP transport URL). The adapter injects it into the
|
|
// fosite.Client.Audience field on hydration so audienceMatchingStrategy's
|
|
// haystack-side check passes for every persisted client.
|
|
//
|
|
// Why inject vs persist: pad's authorization-server is single-resource
|
|
// for v1. Storing an audience column per client would always hold the
|
|
// same value — purely write amplification — and a future move to a
|
|
// multi-resource AS will need explicit per-client policy anyway. Codex
|
|
// review #371 round 1 caught the gap where the adapter returned
|
|
// Audience=nil and every flow failed validation.
|
|
func NewStorage(s *store.Store, canonicalAudiences ...string) *Storage {
|
|
return &Storage{
|
|
store: s,
|
|
canonicalAudiences: canonicalAudiences,
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// fosite.ClientManager
|
|
// =====================================================================
|
|
|
|
// GetClient looks up a registered client by ID. Returns fosite.ErrNotFound
|
|
// when the client is unknown so fosite's auth-request validator can
|
|
// reject with a proper OAuth-error response shape.
|
|
func (s *Storage) GetClient(_ context.Context, id string) (fosite.Client, error) {
|
|
c, err := s.store.GetOAuthClient(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrOAuthNotFound) {
|
|
return nil, fosite.ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("oauth: get client: %w", err)
|
|
}
|
|
return s.modelClientToFosite(c), nil
|
|
}
|
|
|
|
// ClientAssertionJWTValid is a no-op because pad doesn't accept JWT
|
|
// client assertions (private_key_jwt / client_secret_jwt) — public
|
|
// clients only, PKCE-authenticated. fosite calls this during JWT-
|
|
// based client auth flows we don't enable, so returning nil is safe.
|
|
//
|
|
// If a future change adds JWT client auth, populate a JTI-blocklist
|
|
// table and check it here. For now, returning nil means "nothing
|
|
// blocklisted" — equivalent to fosite's no-op.
|
|
func (s *Storage) ClientAssertionJWTValid(_ context.Context, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
// SetClientAssertionJWT is the companion no-op. Same rationale — we
|
|
// don't accept JWT client assertions.
|
|
func (s *Storage) SetClientAssertionJWT(_ context.Context, _ string, _ time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
// =====================================================================
|
|
// AuthorizeCodeStorage
|
|
// =====================================================================
|
|
|
|
// CreateAuthorizeCodeSession persists a new authorization code's
|
|
// fosite.Requester under the supplied signature. fosite calls this
|
|
// once per /authorize that yields a code; the signature is the HMAC
|
|
// of the code, so a DB read can't replay the actual code value.
|
|
func (s *Storage) CreateAuthorizeCodeSession(_ context.Context, signature string, req fosite.Requester) error {
|
|
r, err := requesterToOAuthRequest(req, signature)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.store.CreateAuthorizationCode(r)
|
|
}
|
|
|
|
// reusedByAnotherClient reports whether a reused (invalidated or inactive)
|
|
// code or refresh token is being presented by a client other than the one
|
|
// it was issued to (BUG-3400).
|
|
//
|
|
// fosite handles a reused token by revoking its whole family, and it does so
|
|
// BEFORE it checks that the token belongs to the requesting client
|
|
// (flow_refresh.go handleRefreshTokenReuse, flow_authorize_code_token.go). A
|
|
// client holding another client's spent token could therefore revoke that
|
|
// client's live family. fosite's ordering cannot change, so the storage read
|
|
// answers ErrNotFound for that case instead of the reuse signal: the request
|
|
// fails as an unknown token and nothing is revoked.
|
|
//
|
|
// The requesting client is the one fosite authenticated: NewAccessRequest
|
|
// puts the access request into the context (fosite.AccessRequestContextKey)
|
|
// and sets its client before any grant handler runs. A read with no access
|
|
// request in the context (introspection, revocation, admin paths) is not a
|
|
// grant exchange and is left as it was.
|
|
//
|
|
// Residuals, accepted (lead ruling on BUG-3400):
|
|
// - The client id is only as strong as the client's authentication. A
|
|
// PUBLIC client (every DCR client) authenticates by its client_id alone,
|
|
// and client ids are not secret, so anyone holding a public client's
|
|
// spent token can name that client and trigger the family revoke, as the
|
|
// OAuth BCP intends for a replay. Closing that needs sender-constrained
|
|
// tokens. A CONFIDENTIAL client's (an installed app's) id cannot be named
|
|
// without its secret, so its family is fully protected here.
|
|
// - A foreign client's spent token answers "not found" while the owner's
|
|
// answers "already used" and a foreign LIVE token gets fosite's client
|
|
// mismatch, so error_description tells a holder spent from live. That is
|
|
// told only to someone who already holds the token; answering a spent
|
|
// token as good to unify the text would leave replay refusal to fosite's
|
|
// step order, which storage should not depend on.
|
|
func reusedByAnotherClient(ctx context.Context, issuedTo string) bool {
|
|
ar, ok := ctx.Value(fosite.AccessRequestContextKey).(fosite.AccessRequester)
|
|
if !ok || ar == nil || ar.GetClient() == nil {
|
|
return false
|
|
}
|
|
return ar.GetClient().GetID() != issuedTo
|
|
}
|
|
|
|
// GetAuthorizeCodeSession hydrates the session for an auth code. The
|
|
// caller supplies an empty fosite.Session (typically &Session{}); we
|
|
// JSON-unmarshal the stored session_data into it.
|
|
//
|
|
// Returning fosite.ErrInvalidatedAuthorizeCode (alongside the request
|
|
// payload) when the row is invalidated triggers fosite's grant-family
|
|
// revocation in flow_authorize_code_token.go — the canonical "code
|
|
// was used twice → revoke the whole grant" behaviour.
|
|
func (s *Storage) GetAuthorizeCodeSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) {
|
|
stored, err := s.store.GetAuthorizationCode(signature)
|
|
if errors.Is(err, store.ErrOAuthNotFound) {
|
|
return nil, fosite.ErrNotFound
|
|
}
|
|
// ErrOAuthInvalidatedCode is special: fosite needs the request
|
|
// payload AND the error so it can revoke the grant.
|
|
if errors.Is(err, store.ErrOAuthInvalidatedCode) {
|
|
if reusedByAnotherClient(ctx, stored.ClientID) {
|
|
return nil, fosite.ErrNotFound
|
|
}
|
|
req, _ := s.oauthRequestToFositeRequest(stored, session)
|
|
return req, fosite.ErrInvalidatedAuthorizeCode
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oauth: get auth code: %w", err)
|
|
}
|
|
return s.oauthRequestToFositeRequest(stored, session)
|
|
}
|
|
|
|
// InvalidateAuthorizeCodeSession marks a code's row inactive. Called
|
|
// by fosite once /token successfully exchanges the code; subsequent
|
|
// reads return ErrInvalidatedAuthorizeCode (which triggers family
|
|
// revocation if the same code is presented again — the OAuth 2.1
|
|
// "single-use code" anti-replay rule).
|
|
func (s *Storage) InvalidateAuthorizeCodeSession(_ context.Context, signature string) error {
|
|
return s.store.InvalidateAuthorizationCode(signature)
|
|
}
|
|
|
|
// =====================================================================
|
|
// AccessTokenStorage
|
|
// =====================================================================
|
|
|
|
// CreateAccessTokenSession persists an access token. The row is always
|
|
// inserted with active=true (per insertOAuthRequestRow's contract);
|
|
// later RevokeAccessToken / DeleteAccessTokenSession flip or remove it.
|
|
func (s *Storage) CreateAccessTokenSession(_ context.Context, signature string, req fosite.Requester) error {
|
|
r, err := requesterToOAuthRequest(req, signature)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return mapGrantRevoked(s.store.CreateAccessToken(r))
|
|
}
|
|
|
|
// mapGrantRevoked turns a mint refused on a revoked grant into
|
|
// fosite.ErrInactiveToken, which fosite answers invalid_request rather
|
|
// than server_error (BUG-3678).
|
|
func mapGrantRevoked(err error) error {
|
|
if errors.Is(err, store.ErrOAuthGrantRevoked) {
|
|
return fmt.Errorf("%w: %w", fosite.ErrInactiveToken, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// GetAccessTokenSession hydrates an access token. Inactive rows
|
|
// surface as fosite.ErrInactiveToken so the introspection /
|
|
// authorization-bearer middleware can reject cleanly.
|
|
//
|
|
// Inactive rows return the request payload AND the error so callers
|
|
// (introspector, revocation handler) can read req.GetID() for grant-
|
|
// scoped follow-up actions without nil-pointer dereferencing.
|
|
// Codex review #371 round 2 caught the equivalent gap on the refresh
|
|
// path; symmetric fix here for safety even though the access path's
|
|
// callers don't currently dereference on inactive.
|
|
func (s *Storage) GetAccessTokenSession(_ context.Context, signature string, session fosite.Session) (fosite.Requester, error) {
|
|
stored, err := s.store.GetAccessToken(signature)
|
|
if errors.Is(err, store.ErrOAuthNotFound) {
|
|
return nil, fosite.ErrNotFound
|
|
}
|
|
if errors.Is(err, store.ErrOAuthInactiveToken) {
|
|
req, _ := s.oauthRequestToFositeRequest(stored, session)
|
|
return req, fosite.ErrInactiveToken
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oauth: get access token: %w", err)
|
|
}
|
|
return s.oauthRequestToFositeRequest(stored, session)
|
|
}
|
|
|
|
// DeleteAccessTokenSession removes the row entirely. Distinct from
|
|
// RevokeAccessToken (which preserves the row marked inactive for
|
|
// audit). fosite uses Delete after successful exchange of an
|
|
// authorization code to prevent reuse.
|
|
func (s *Storage) DeleteAccessTokenSession(_ context.Context, signature string) error {
|
|
return s.store.DeleteAccessToken(signature)
|
|
}
|
|
|
|
// =====================================================================
|
|
// RefreshTokenStorage
|
|
// =====================================================================
|
|
|
|
// CreateRefreshTokenSession persists a refresh token alongside its
|
|
// paired access token signature. The pair sharing a request_id is
|
|
// what makes family revocation work: revoking by request_id walks
|
|
// the indexed column and flips every chain member.
|
|
func (s *Storage) CreateRefreshTokenSession(_ context.Context, signature, accessSignature string, req fosite.Requester) error {
|
|
r, err := requesterToOAuthRequest(req, signature)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.AccessTokenSignature = accessSignature
|
|
return mapGrantRevoked(s.store.CreateRefreshToken(r))
|
|
}
|
|
|
|
// GetRefreshTokenSession hydrates a refresh token. Inactive rows
|
|
// surface as fosite.ErrInactiveToken — fosite's refresh-flow handler
|
|
// (handler/oauth2/flow_refresh.go) treats this as a replay signal
|
|
// and triggers RevokeRefreshToken / RevokeAccessToken on the
|
|
// request_id, which under our adapter walks the entire family and
|
|
// revokes it (the OAuth 2.1 BCP §4.14 rule).
|
|
//
|
|
// Crucially, the inactive-row path returns the request payload
|
|
// ALONGSIDE the error: fosite's handleRefreshTokenReuse
|
|
// (flow_refresh.go:178-204) calls req.GetID() on the returned value
|
|
// to drive RevokeRefreshToken(requestID) + RevokeAccessToken(requestID).
|
|
// Returning a nil request would nil-deref the family-revocation
|
|
// flow, defeating replay detection. Codex review #371 round 2 caught
|
|
// this. The symmetric pattern is used by the auth-code invalidation
|
|
// path (GetAuthorizeCodeSession above).
|
|
func (s *Storage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) {
|
|
stored, err := s.store.GetRefreshToken(signature)
|
|
if errors.Is(err, store.ErrOAuthNotFound) {
|
|
return nil, fosite.ErrNotFound
|
|
}
|
|
if errors.Is(err, store.ErrOAuthInactiveToken) {
|
|
// Another client presenting this spent token must not revoke its
|
|
// family (BUG-3400).
|
|
if reusedByAnotherClient(ctx, stored.ClientID) {
|
|
return nil, fosite.ErrNotFound
|
|
}
|
|
// fosite needs req.GetID() to revoke the family; hydrate even
|
|
// on the failure path. Hydration may itself fail (e.g. the
|
|
// client was deleted) — in that case return the underlying
|
|
// error rather than masking it; replay detection still loses
|
|
// but at least the failure is observable.
|
|
req, hydrateErr := s.oauthRequestToFositeRequest(stored, session)
|
|
if hydrateErr != nil {
|
|
return nil, hydrateErr
|
|
}
|
|
return req, fosite.ErrInactiveToken
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oauth: get refresh token: %w", err)
|
|
}
|
|
return s.oauthRequestToFositeRequest(stored, session)
|
|
}
|
|
|
|
// DeleteRefreshTokenSession removes a refresh row entirely. Used by
|
|
// fosite's rotation flow to drop the previous-step's refresh after
|
|
// the new one has been issued + the old one rotated.
|
|
func (s *Storage) DeleteRefreshTokenSession(_ context.Context, signature string) error {
|
|
return s.store.DeleteRefreshToken(signature)
|
|
}
|
|
|
|
// RotateRefreshToken matches fosite's reference MemoryStore
|
|
// (storage/memory.go:497-504): revoke the entire grant — both the
|
|
// refresh family AND the access family — for the request_id, then
|
|
// fosite immediately issues a fresh pair via CreateAccessTokenSession
|
|
// + CreateRefreshTokenSession (which inherit the same request_id and
|
|
// land active=TRUE per the store's hardcode).
|
|
//
|
|
// signatureToRotate is the presented refresh token; the store spends it
|
|
// conditionally, so only one of two concurrent refreshes rotates.
|
|
func (s *Storage) RotateRefreshToken(_ context.Context, requestID, signatureToRotate string) error {
|
|
if err := s.store.RotateRefreshToken(requestID, signatureToRotate); err != nil {
|
|
if errors.Is(err, store.ErrOAuthRefreshNotActive) {
|
|
// A concurrent refresh rotated it first, or a revoke landed
|
|
// (BUG-3678). Fosite answers invalid_request.
|
|
return fmt.Errorf("%w: %w", fosite.ErrSerializationFailure, err)
|
|
}
|
|
return err
|
|
}
|
|
// TASK-961: report rotation-induced revocation to the observability
|
|
// hook. Distinct kind label so dashboards can separate "user clicked
|
|
// revoke" from "client refreshed and we naturally rolled the family."
|
|
s.observeRevocation(requestID, "rotated")
|
|
return nil
|
|
}
|
|
|
|
// =====================================================================
|
|
// TokenRevocationStorage (RFC 7009)
|
|
// =====================================================================
|
|
|
|
// RevokeRefreshToken walks the chain of refresh tokens sharing the
|
|
// given requestID and marks every one inactive. Called by fosite's
|
|
// /oauth/revoke handler (sub-PR D wires the endpoint) and by the
|
|
// rotation flow's replay-detection branch.
|
|
//
|
|
// No metrics observation here: when fosite revokes a grant family
|
|
// from /oauth/revoke or replay-detection, it pairs RevokeRefreshToken
|
|
// with RevokeAccessToken — emitting from both would double-count.
|
|
// We let the access-side emitter own the reporting and keep this
|
|
// path lean.
|
|
//
|
|
// It ends the grant, not only its refresh tokens (BUG-3678): fosite calls
|
|
// it for every RFC 7009 revoke, whatever the token type, and on a refresh
|
|
// replay, and a refresh that validated before either must not mint a
|
|
// pair after it.
|
|
func (s *Storage) RevokeRefreshToken(_ context.Context, requestID string) error {
|
|
return s.store.RevokeOAuthGrant(requestID)
|
|
}
|
|
|
|
// RevokeAccessToken mirrors RevokeRefreshToken for access tokens.
|
|
// fosite calls these in pairs when revoking a grant (the unified
|
|
// "revoke the whole family" behaviour).
|
|
//
|
|
// TASK-961: emits an observability signal AFTER the family is
|
|
// flagged inactive in storage. The default kind here is
|
|
// "user_initiated" because /oauth/revoke is the dominant caller —
|
|
// fosite's replay-detection path also routes through this method,
|
|
// so the label is best-effort rather than ground truth (Codex
|
|
// could later differentiate via a context-carried hint, but the
|
|
// gross signal "tokens are getting revoked" matters more than the
|
|
// per-cause split for v1 alerting).
|
|
func (s *Storage) RevokeAccessToken(_ context.Context, requestID string) error {
|
|
if err := s.store.RevokeAccessTokenFamily(requestID); err != nil {
|
|
return err
|
|
}
|
|
s.observeRevocation(requestID, "user_initiated")
|
|
return nil
|
|
}
|
|
|
|
// =====================================================================
|
|
// PKCERequestStorage
|
|
// =====================================================================
|
|
|
|
// CreatePKCERequestSession persists the PKCE session keyed by the
|
|
// auth-code's signature. fosite stores the original /authorize
|
|
// request (which carries code_challenge + code_challenge_method) so
|
|
// the verifier from /token can be checked against it.
|
|
func (s *Storage) CreatePKCERequestSession(_ context.Context, signature string, req fosite.Requester) error {
|
|
r, err := requesterToOAuthRequest(req, signature)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.store.CreatePKCERequest(r)
|
|
}
|
|
|
|
// GetPKCERequestSession hydrates the PKCE session. Returns
|
|
// fosite.ErrNotFound when missing (e.g. someone replayed an old
|
|
// auth code that's already been deleted post-exchange).
|
|
func (s *Storage) GetPKCERequestSession(_ context.Context, signature string, session fosite.Session) (fosite.Requester, error) {
|
|
stored, err := s.store.GetPKCERequest(signature)
|
|
if errors.Is(err, store.ErrOAuthNotFound) {
|
|
return nil, fosite.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oauth: get pkce request: %w", err)
|
|
}
|
|
return s.oauthRequestToFositeRequest(stored, session)
|
|
}
|
|
|
|
// DeletePKCERequestSession removes the row after a successful /token
|
|
// exchange. fosite's PKCE lifecycle is delete-on-use, distinct from
|
|
// auth codes (which are flagged inactive so a replay can be
|
|
// distinguished from a missing row).
|
|
func (s *Storage) DeletePKCERequestSession(_ context.Context, signature string) error {
|
|
return s.store.DeletePKCERequest(signature)
|
|
}
|
|
|
|
// =====================================================================
|
|
// Translation helpers
|
|
// =====================================================================
|
|
|
|
// requesterToOAuthRequest converts a fosite.Requester to the flat
|
|
// model the store layer accepts. Session and form data are JSON-
|
|
// encoded so the storage layer never imports fosite types.
|
|
//
|
|
// Consumes: req.GetID, req.GetClient.GetID, req.GetRequestedAt,
|
|
// req.GetRequestedScopes, req.GetGrantedScopes, req.GetRequestForm,
|
|
// req.GetSession, req.GetRequestedAudience, req.GetGrantedAudience.
|
|
//
|
|
// signature is the HMAC of the token / code value (provided by
|
|
// fosite at insert time), separate from the requester so the same
|
|
// requester can be persisted under multiple signatures during a
|
|
// single grant flow.
|
|
func requesterToOAuthRequest(req fosite.Requester, signature string) (models.OAuthRequest, error) {
|
|
if req == nil {
|
|
return models.OAuthRequest{}, fmt.Errorf("oauth: nil requester")
|
|
}
|
|
if req.GetClient() == nil || req.GetClient().GetID() == "" {
|
|
return models.OAuthRequest{}, fmt.Errorf("oauth: requester missing client")
|
|
}
|
|
|
|
sessionBytes := []byte("{}")
|
|
if sess := req.GetSession(); sess != nil {
|
|
var err error
|
|
sessionBytes, err = json.Marshal(sess)
|
|
if err != nil {
|
|
return models.OAuthRequest{}, fmt.Errorf("oauth: encode session: %w", err)
|
|
}
|
|
}
|
|
|
|
subject := ""
|
|
if sess := req.GetSession(); sess != nil {
|
|
subject = sess.GetSubject()
|
|
}
|
|
|
|
return models.OAuthRequest{
|
|
Signature: signature,
|
|
RequestID: req.GetID(),
|
|
RequestedAt: req.GetRequestedAt(),
|
|
ClientID: req.GetClient().GetID(),
|
|
Scopes: strings.Join(req.GetRequestedScopes(), " "),
|
|
GrantedScopes: strings.Join(req.GetGrantedScopes(), " "),
|
|
RequestForm: req.GetRequestForm().Encode(),
|
|
SessionData: string(sessionBytes),
|
|
Audience: strings.Join(req.GetRequestedAudience(), " "),
|
|
GrantedAudience: strings.Join(req.GetGrantedAudience(), " "),
|
|
Subject: subject,
|
|
}, nil
|
|
}
|
|
|
|
// oauthRequestToFositeRequest hydrates a stored row into a fresh
|
|
// fosite.Request, fetching the client by ID and unmarshalling the
|
|
// session bytes into the caller-supplied session pointer.
|
|
//
|
|
// fosite's contract is that the session pointer (passed by fosite to
|
|
// every Get*Session call) gets populated in-place — it's how fosite
|
|
// flows the session through the handler chain. We JSON-unmarshal
|
|
// directly into it so the caller's concrete type (e.g. *Session)
|
|
// stays intact.
|
|
//
|
|
// The form is parsed back from URL-encoded; any decode error means
|
|
// the storage row was tampered with or written by an incompatible
|
|
// adapter version, and we surface as an internal error rather than
|
|
// silently dropping fields.
|
|
func (s *Storage) oauthRequestToFositeRequest(stored *models.OAuthRequest, session fosite.Session) (fosite.Requester, error) {
|
|
if stored == nil {
|
|
return nil, fosite.ErrNotFound
|
|
}
|
|
|
|
client, err := s.store.GetOAuthClient(stored.ClientID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrOAuthNotFound) {
|
|
// The client was deleted while this token was active —
|
|
// e.g. an admin-driven revocation in between issuance and
|
|
// use. Treat as not-found so the calling handler returns
|
|
// a clean OAuth error.
|
|
return nil, fosite.ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("oauth: hydrate client: %w", err)
|
|
}
|
|
|
|
if session != nil && stored.SessionData != "" {
|
|
if err := json.Unmarshal([]byte(stored.SessionData), session); err != nil {
|
|
return nil, fmt.Errorf("oauth: decode session: %w", err)
|
|
}
|
|
}
|
|
|
|
form, err := url.ParseQuery(stored.RequestForm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oauth: parse request form: %w", err)
|
|
}
|
|
|
|
return &fosite.Request{
|
|
ID: stored.RequestID,
|
|
RequestedAt: stored.RequestedAt,
|
|
Client: s.modelClientToFosite(client),
|
|
RequestedScope: splitSpaceSeparated(stored.Scopes),
|
|
GrantedScope: splitSpaceSeparated(stored.GrantedScopes),
|
|
Form: form,
|
|
Session: session,
|
|
RequestedAudience: splitSpaceSeparated(stored.Audience),
|
|
GrantedAudience: splitSpaceSeparated(stored.GrantedAudience),
|
|
}, nil
|
|
}
|
|
|
|
// modelClientToFosite turns a stored client row into the
|
|
// fosite.DefaultClient fosite expects. Public clients only — Secret
|
|
// stays empty; PKCE is the only auth path.
|
|
//
|
|
// Audience: every client in this server is implicitly authorized
|
|
// for the canonical audience (PLAN-943: single-resource AS). The
|
|
// adapter injects Storage.canonicalAudience into the hydrated
|
|
// client's Audience field so audienceMatchingStrategy's haystack
|
|
// check (client.GetAudience() must contain canonical) passes for
|
|
// every persisted client. Codex review #371 round 1 caught the bug
|
|
// where this returned Audience=nil and every flow failed validation.
|
|
//
|
|
// If canonicalAudience is empty (a misconfigured Storage — should
|
|
// never happen in production because NewServer rejects empty
|
|
// AllowedAudience), Audience stays nil and the strategy will reject
|
|
// every request with ServerError, surfacing the misconfiguration
|
|
// fast.
|
|
func (s *Storage) modelClientToFosite(c *models.OAuthClient) fosite.Client {
|
|
// An installed app's client (SPEC-6 U5a, TASK-3394) is confidential:
|
|
// fosite compares its secret against the stored hash, and its audiences
|
|
// are its own, the app API resource only.
|
|
if c.IsInstallClient() {
|
|
return &fosite.DefaultClient{
|
|
ID: c.ID,
|
|
Secret: []byte(c.SecretHash),
|
|
RedirectURIs: append([]string(nil), c.RedirectURIs...),
|
|
GrantTypes: append([]string(nil), c.GrantTypes...),
|
|
ResponseTypes: append([]string(nil), c.ResponseTypes...),
|
|
Scopes: append([]string(nil), c.Scopes...),
|
|
Audience: append([]string(nil), c.AllowedAudiences...),
|
|
Public: false,
|
|
}
|
|
}
|
|
var audience []string
|
|
for _, a := range s.canonicalAudiences {
|
|
if a != "" && (s.appAudience == "" || NormalizeAudience(a) != NormalizeAudience(s.appAudience)) {
|
|
audience = append(audience, a)
|
|
}
|
|
}
|
|
return &fosite.DefaultClient{
|
|
ID: c.ID,
|
|
Secret: nil, // public client
|
|
RedirectURIs: append([]string(nil), c.RedirectURIs...),
|
|
GrantTypes: append([]string(nil), c.GrantTypes...),
|
|
ResponseTypes: append([]string(nil), c.ResponseTypes...),
|
|
Scopes: append([]string(nil), c.Scopes...),
|
|
Audience: audience,
|
|
Public: true,
|
|
}
|
|
}
|
|
|
|
// splitSpaceSeparated decodes the "a b c" form back to fosite.Arguments
|
|
// (a []string alias). Empty input → empty slice (non-nil) so range
|
|
// is safe.
|
|
func splitSpaceSeparated(s string) fosite.Arguments {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return fosite.Arguments{}
|
|
}
|
|
parts := strings.Split(s, " ")
|
|
out := make(fosite.Arguments, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|