mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-05 13:01:58 +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:
@@ -313,3 +313,38 @@ func (o *OAuthClients) Revoke(ctx context.Context, token string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateClient implements lookup.OAuthClientStore.
|
||||
func (o *OAuthClients) UpdateClient(ctx context.Context, client *sectypes.OAuthServerClient) error {
|
||||
input, err := json.Marshal(client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal client: %w", err)
|
||||
}
|
||||
var success bool
|
||||
var errMsg sql.NullString
|
||||
err = o.run.Run(func(db *sql.DB) error {
|
||||
return db.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error
|
||||
FROM %s($1::jsonb)
|
||||
`, o.procs.OAuthUpdateClient), input).Scan(&success, &errMsg)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update client: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return failure(errMsg, "failed to update client")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteClient implements lookup.OAuthClientStore.
|
||||
func (o *OAuthClients) DeleteClient(ctx context.Context, clientID string) error {
|
||||
ok, errMsg, err := o.callNoData(ctx, o.procs.OAuthDeleteClient, clientID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete client: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return failure(errMsg, "failed to delete client")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package procedure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
||||
)
|
||||
|
||||
// OAuthGrants implements lookup.OAuthGrantStore with the resolvespec_oauth_* grant procedures.
|
||||
// Every procedure takes one jsonb request and returns (p_success, p_error, p_data). A failure
|
||||
// that maps to a lookup sentinel carries a stable code in p_error (see the grantErrors table).
|
||||
type OAuthGrants struct {
|
||||
run Runner
|
||||
procs lookup.ProcNames
|
||||
}
|
||||
|
||||
var _ lookup.OAuthGrantStore = (*OAuthGrants)(nil)
|
||||
|
||||
// NewOAuthGrants creates the procedure-backed OAuthGrantStore.
|
||||
func NewOAuthGrants(run Runner, procs lookup.ProcNames) *OAuthGrants {
|
||||
return &OAuthGrants{run: run, procs: procs}
|
||||
}
|
||||
|
||||
// grantErrors maps the codes a grant procedure puts in p_error to the lookup sentinels.
|
||||
var grantErrors = map[string]error{
|
||||
"not_found": lookup.ErrNotFound,
|
||||
"refresh_invalid": lookup.ErrRefreshInvalid,
|
||||
"refresh_reused": lookup.ErrRefreshReused,
|
||||
"device_pending": lookup.ErrDevicePending,
|
||||
"device_slowdown": lookup.ErrDeviceSlowDown,
|
||||
"device_denied": lookup.ErrDeviceDenied,
|
||||
"device_expired": lookup.ErrDeviceExpired,
|
||||
}
|
||||
|
||||
// call runs proc with the JSON-encoded request. The returned data is the p_data of the
|
||||
// procedure, also when it reports a failure (rotate returns the reused token that way).
|
||||
func (o *OAuthGrants) call(ctx context.Context, proc string, req any) (data []byte, err error) {
|
||||
input, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
var success bool
|
||||
var errMsg sql.NullString
|
||||
err = o.run.Run(func(db *sql.DB) error {
|
||||
return db.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error, p_data::text
|
||||
FROM %s($1::jsonb)
|
||||
`, proc), input).Scan(&success, &errMsg, &data)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", proc, err)
|
||||
}
|
||||
if success {
|
||||
return data, nil
|
||||
}
|
||||
if e, ok := grantErrors[errMsg.String]; ok {
|
||||
return data, e
|
||||
}
|
||||
return data, failure(errMsg, proc+" failed")
|
||||
}
|
||||
|
||||
// SaveConsent implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) SaveConsent(ctx context.Context, c lookup.Consent) error {
|
||||
_, err := o.call(ctx, o.procs.OAuthSaveConsent, c)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetConsent implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) GetConsent(ctx context.Context, userID int, clientID string) (*lookup.Consent, error) {
|
||||
data, err := o.call(ctx, o.procs.OAuthGetConsent, map[string]any{"user_id": userID, "client_id": clientID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c lookup.Consent
|
||||
if err := json.Unmarshal(normalizeTimes(data), &c); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse consent: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// RevokeConsent implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) RevokeConsent(ctx context.Context, userID int, clientID string) error {
|
||||
_, err := o.call(ctx, o.procs.OAuthRevokeConsent, map[string]any{"user_id": userID, "client_id": clientID})
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveRefresh implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) SaveRefresh(ctx context.Context, t lookup.RefreshToken) error {
|
||||
_, err := o.call(ctx, o.procs.OAuthSaveRefresh, t)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseRefresh(data []byte) (*lookup.RefreshToken, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var t lookup.RefreshToken
|
||||
if err := json.Unmarshal(normalizeTimes(data), &t); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse refresh token: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// RotateRefresh implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) RotateRefresh(ctx context.Context, oldHash string, next lookup.RefreshToken) (*lookup.RefreshToken, error) {
|
||||
data, err := o.call(ctx, o.procs.OAuthRotateRefresh, map[string]any{"old_hash": oldHash, "next": next})
|
||||
if err != nil && err != lookup.ErrRefreshReused { //nolint:errorlint // sentinel returned unwrapped by call
|
||||
return nil, err
|
||||
}
|
||||
t, perr := parseRefresh(data)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// PeekRefresh implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) PeekRefresh(ctx context.Context, hash string) (*lookup.RefreshToken, error) {
|
||||
data, err := o.call(ctx, o.procs.OAuthPeekRefresh, map[string]any{"token_hash": hash})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseRefresh(data)
|
||||
}
|
||||
|
||||
// RevokeRefreshFamily implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) RevokeRefreshFamily(ctx context.Context, familyID string) error {
|
||||
_, err := o.call(ctx, o.procs.OAuthRevokeRefreshFamily, map[string]any{"family_id": familyID})
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeRefreshBySession implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) RevokeRefreshBySession(ctx context.Context, sessionToken string) error {
|
||||
_, err := o.call(ctx, o.procs.OAuthRevokeRefreshByUser, map[string]any{"session_token": sessionToken})
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateDevice implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) CreateDevice(ctx context.Context, d lookup.DeviceCode) error {
|
||||
if d.Status == "" {
|
||||
d.Status = lookup.DevicePending
|
||||
}
|
||||
_, err := o.call(ctx, o.procs.OAuthCreateDevice, d)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseDevice(data []byte) (*lookup.DeviceCode, error) {
|
||||
var d lookup.DeviceCode
|
||||
if err := json.Unmarshal(normalizeTimes(data), &d); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse device code: %w", err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// DeviceByUserCode implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) DeviceByUserCode(ctx context.Context, userCode string) (*lookup.DeviceCode, error) {
|
||||
data, err := o.call(ctx, o.procs.OAuthDeviceByUserCode, map[string]any{"user_code": userCode})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseDevice(data)
|
||||
}
|
||||
|
||||
// DeviceDecide implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) DeviceDecide(ctx context.Context, userCode string, approve bool, userID int, sessionToken string) error {
|
||||
_, err := o.call(ctx, o.procs.OAuthDeviceDecide, map[string]any{
|
||||
"user_code": userCode, "approve": approve, "user_id": userID, "session_token": sessionToken,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// DevicePoll implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) DevicePoll(ctx context.Context, deviceHash string) (*lookup.DeviceCode, error) {
|
||||
data, err := o.call(ctx, o.procs.OAuthDevicePoll, map[string]any{"device_hash": deviceHash})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseDevice(data)
|
||||
}
|
||||
|
||||
// SavePushedRequest implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) SavePushedRequest(ctx context.Context, r lookup.PushedRequest) error {
|
||||
_, err := o.call(ctx, o.procs.OAuthSavePAR, r)
|
||||
return err
|
||||
}
|
||||
|
||||
// ConsumePushedRequest implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) ConsumePushedRequest(ctx context.Context, requestURI string) (*lookup.PushedRequest, error) {
|
||||
data, err := o.call(ctx, o.procs.OAuthConsumePAR, map[string]any{"request_uri": requestURI})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r lookup.PushedRequest
|
||||
if err := json.Unmarshal(normalizeTimes(data), &r); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse pushed request: %w", err)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// SeenJTI implements lookup.OAuthGrantStore.
|
||||
func (o *OAuthGrants) SeenJTI(ctx context.Context, key string, expires time.Time) (bool, error) {
|
||||
data, err := o.call(ctx, o.procs.OAuthSeenJTI, map[string]any{"key": key, "expires_at": expires})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var out struct {
|
||||
Seen bool `json:"seen"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return false, fmt.Errorf("failed to parse jti result: %w", err)
|
||||
}
|
||||
return out.Seen, nil
|
||||
}
|
||||
Reference in New Issue
Block a user