Files
ResolveSpec/pkg/security/lookup/grant.go
T
Hein 640faeeeaf feat(security): full OAuth 2.1 / OpenID Connect server and OIDC relying-party client
Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid,
at_hash, signed userinfo, RP-initiated and back-channel logout), managed
refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens,
DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592
registration, RFC 9207 iss, signing keyring with rotation.

State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct
backends, four dialect DDLs, conformance cases).

Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL.

PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse.

Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
2026-10-01 14:42:12 +02:00

118 lines
5.5 KiB
Go

package lookup
import (
"context"
"errors"
"time"
)
// Errors returned by OAuthGrantStore. Callers compare with errors.Is.
var (
// ErrRefreshInvalid: the refresh token is unknown, expired or revoked.
ErrRefreshInvalid = errors.New("invalid refresh token")
// ErrRefreshReused: a refresh token that was already rotated was presented again. The
// store has revoked the whole token family.
ErrRefreshReused = errors.New("refresh token reuse detected")
// ErrDevicePending, ErrDeviceSlowDown, ErrDeviceDenied and ErrDeviceExpired are the RFC 8628
// polling outcomes other than success.
ErrDevicePending = errors.New("authorization pending")
ErrDeviceSlowDown = errors.New("slow down")
ErrDeviceDenied = errors.New("access denied")
ErrDeviceExpired = errors.New("device code expired")
// ErrNotFound: the requested record does not exist or has expired.
ErrNotFound = errors.New("not found")
)
// Consent records that a user allowed a client to act with Scopes.
type Consent struct {
UserID int `json:"user_id"`
ClientID string `json:"client_id"`
Scopes []string `json:"scopes"`
ExpiresAt time.Time `json:"expires_at"`
}
// RefreshToken is a server-managed refresh token. Only the SHA-256 hash of the raw token
// is stored.
type RefreshToken struct {
TokenHash string `json:"token_hash"`
FamilyID string `json:"family_id"`
ClientID string `json:"client_id"`
UserID int `json:"user_id"`
SessionToken string `json:"session_token"`
Scopes []string `json:"scopes,omitempty"`
Extra map[string]any `json:"extra,omitempty"` // nonce, auth_time, acr, sid, dpop_jkt, resource
ExpiresAt time.Time `json:"expires_at"`
}
// DeviceStatus is the state of an RFC 8628 device authorization.
type DeviceStatus string
const (
DevicePending DeviceStatus = "pending"
DeviceApproved DeviceStatus = "approved"
DeviceDenied DeviceStatus = "denied"
)
// DeviceCode is a pending RFC 8628 device authorization. DeviceHash is the SHA-256 hash of the
// device_code returned to the device; UserCode is stored as typed by the user (normalised).
type DeviceCode struct {
DeviceHash string `json:"device_hash"`
UserCode string `json:"user_code"`
ClientID string `json:"client_id"`
Scopes []string `json:"scopes,omitempty"`
Status DeviceStatus `json:"status"`
UserID int `json:"user_id,omitempty"`
SessionToken string `json:"session_token,omitempty"`
Interval int `json:"interval"` // minimum seconds between polls
ExpiresAt time.Time `json:"expires_at"`
}
// PushedRequest is an RFC 9126 pushed authorization request.
type PushedRequest struct {
RequestURI string `json:"request_uri"`
ClientID string `json:"client_id"`
Params map[string]string `json:"params"`
ExpiresAt time.Time `json:"expires_at"`
}
// OAuthGrantStore persists the OAuth2 authorization server state that is not a client, a code
// or a session: consents, refresh tokens, device codes, pushed requests and the replay cache.
type OAuthGrantStore interface {
// SaveConsent replaces the consent of (UserID, ClientID).
SaveConsent(ctx context.Context, c Consent) error
// GetConsent returns the unexpired consent or ErrNotFound.
GetConsent(ctx context.Context, userID int, clientID string) (*Consent, error)
RevokeConsent(ctx context.Context, userID int, clientID string) error
SaveRefresh(ctx context.Context, t RefreshToken) error
// RotateRefresh atomically consumes the token with hash oldHash and stores next in the same
// family. It returns the consumed token. An unknown, expired or revoked token is
// ErrRefreshInvalid. A token that was already consumed revokes its family and returns the
// token together with ErrRefreshReused so the caller can end the session.
RotateRefresh(ctx context.Context, oldHash string, next RefreshToken) (*RefreshToken, error)
// PeekRefresh returns the token without consuming it. Unknown, expired and revoked tokens are
// ErrRefreshInvalid; an already rotated token is returned so RotateRefresh can report its reuse.
PeekRefresh(ctx context.Context, hash string) (*RefreshToken, error)
RevokeRefreshFamily(ctx context.Context, familyID string) error
// RevokeRefreshBySession revokes every refresh token bound to a session token.
RevokeRefreshBySession(ctx context.Context, sessionToken string) error
CreateDevice(ctx context.Context, d DeviceCode) error
// DeviceByUserCode returns the unexpired pending device authorization or ErrNotFound.
DeviceByUserCode(ctx context.Context, userCode string) (*DeviceCode, error)
// DeviceDecide approves or denies the device authorization of userCode.
DeviceDecide(ctx context.Context, userCode string, approve bool, userID int, sessionToken string) error
// DevicePoll implements the token endpoint side: it enforces the poll interval and returns
// one of ErrDevicePending, ErrDeviceSlowDown, ErrDeviceDenied, ErrDeviceExpired or, once
// approved, the record (consumed: it cannot be polled again).
DevicePoll(ctx context.Context, deviceHash string) (*DeviceCode, error)
SavePushedRequest(ctx context.Context, r PushedRequest) error
// ConsumePushedRequest returns and deletes the request or ErrNotFound.
ConsumePushedRequest(ctx context.Context, requestURI string) (*PushedRequest, error)
// SeenJTI records key until expires and reports whether it was already recorded. It is the
// replay cache for DPoP proofs and client assertions.
SeenJTI(ctx context.Context, key string, expires time.Time) (bool, error)
}