mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
98c8b78d06
Plug MCP traffic and OAuth flow events into pad's existing
internal/metrics Prometheus surface, plus a Grafana dashboard.
Metrics (all under pad_*):
- Counters: mcp_tool_calls_total{user_id,tool,status},
mcp_authz_denials_total{reason}, oauth_flows_total{stage},
oauth_token_revocations_total{reason}
- Histograms: mcp_tool_call_duration_seconds{tool},
oauth_flow_duration_seconds{stage}, oauth_token_ttl_seconds
- Gauges: mcp_active_sessions, oauth_active_tokens (callback collector)
Wiring seams: MCPAuditLog (per-call), MCPBearerAuth (audience denials),
emitMCPAuditDenied (rate-limit denials), RequireWorkspaceAccess (gated
to MCP-origin via context — workspace_not_in_allowlist + not_a_member),
OAuth handlers (per-stage flow events + per-handler latency), and
internal/oauth/storage.go via a new SetRevocationObserver hook so the
OAuth package stays metrics-naive.
Cmd/pad wires both observers via Server.wireOAuthMetricsObserver(),
called from both SetMetrics and SetOAuthServer for order-independence.
Store helpers added (with full test coverage):
- CountActiveOAuthAccessTokens — backs the active-tokens gauge
- OldestAccessTokenIssuedAtByRequestID — backs the TTL observation
Grafana dashboard at monitoring/grafana/mcp.json: 13 panels across MCP
traffic + OAuth flow rows (rate-by-tool, p50/p95/p99 latency, status
breakdown, denial reasons, active sessions, top-10 users, OAuth flow
events by stage, OAuth handler p95, active tokens, revocations by
reason, TTL p50/p95).
Codex review caught one HIGH issue (round 1, fixed in same commit):
the active-tokens collector originally emitted NewInvalidMetric on
provider error, which propagates through Registry.Gather() and fails
the entire /metrics scrape via promhttp's default error handler.
Switched to log + skip-the-sample so a transient SQLite blip drops
ONE gauge for one scrape rather than the whole observability surface.
Added TestRegisterOAuthActiveTokensCollector_ErrorIsScrapeSafe to pin
the contract.
Tests cover increments, histogram bucket placement, callback collector
freshness across mutations + error path, observer hook firing on user-
initiated revocation + rotation + nil-safety, and per-helper unit tests
for the server-side metric emission.
Verified with `make check` (golangci-lint + go test ./... + web build).
587 lines
24 KiB
Go
587 lines
24 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
|
|
canonicalAudience 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, canonicalAudience string) *Storage {
|
|
return &Storage{
|
|
store: s,
|
|
canonicalAudience: canonicalAudience,
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// 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)
|
|
}
|
|
|
|
// 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(_ 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) {
|
|
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 s.store.CreateAccessToken(r)
|
|
}
|
|
|
|
// 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 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(_ 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) {
|
|
// 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 fosite's hint about which row triggered the
|
|
// rotation; the store layer ignores it and revokes by request_id.
|
|
func (s *Storage) RotateRefreshToken(_ context.Context, requestID, signatureToRotate string) error {
|
|
if err := s.store.RotateRefreshToken(requestID, signatureToRotate); err != nil {
|
|
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.
|
|
func (s *Storage) RevokeRefreshToken(_ context.Context, requestID string) error {
|
|
return s.store.RevokeRefreshTokenFamily(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 {
|
|
var audience []string
|
|
if s.canonicalAudience != "" {
|
|
audience = []string{s.canonicalAudience}
|
|
}
|
|
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
|
|
}
|