mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-03 03:51:59 +00:00
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.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user