mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 19:41:57 +00:00
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.
167 lines
5.4 KiB
Go
167 lines
5.4 KiB
Go
// Package backends assembles a lookup.Provider: it builds the procedure and direct stores for
|
|
// one database and routes every operation to one of them according to lookup.Config.
|
|
// It lives apart from package lookup because both backends import lookup.
|
|
package backends
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/direct"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/procedure"
|
|
)
|
|
|
|
// Options are the settings that are not naming or mode.
|
|
type Options struct {
|
|
// DBFactory is called to obtain a fresh *sql.DB when the current one has been closed.
|
|
// Nil disables reconnecting.
|
|
DBFactory func() (*sql.DB, error)
|
|
// UpgradePasswordHash rewrites a legacy cleartext password as bcrypt after a successful
|
|
// direct-mode login. Off by default.
|
|
UpgradePasswordHash bool
|
|
// NoGroupTables skips the group membership table when loading direct-mode policy rules.
|
|
NoGroupTables bool
|
|
}
|
|
|
|
// New builds a Provider for db. cfg is merged with the defaults and validated; the dialect
|
|
// is cfg.Dialect or detected from the driver. Every operation's mode is resolved up front so
|
|
// an impossible combination (procedure mode on a non-Postgres dialect) fails here, not on the
|
|
// first request.
|
|
func New(db *sql.DB, cfg lookup.Config, opts Options) (*lookup.Provider, error) {
|
|
if db == nil {
|
|
return nil, fmt.Errorf("backends: nil database")
|
|
}
|
|
res, err := cfg.Resolve()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d, err := cfg.ResolveDialect(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, op := range lookup.AllOps() {
|
|
if _, err := res.EffectiveMode(op, d.Name()); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
c := &chooser{cfg: res, dialect: d.Name(), procs: res.Procs}
|
|
run := procedure.NewDB(db, opts.DBFactory, c.resetProbes)
|
|
c.db = run
|
|
|
|
base, err := direct.NewBase(run, d, res.Schema)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := procedure.NewPasskey(run, res.Procs)
|
|
return &lookup.Provider{
|
|
Auth: &authRouter{c: c,
|
|
proc: procedure.NewAuth(run, res.Procs),
|
|
direct: direct.NewAuth(base, direct.AuthOptions{UpgradePasswordHash: opts.UpgradePasswordHash})},
|
|
Keys: &keysRouter{c: c,
|
|
proc: procedure.NewKeys(run, res.Procs),
|
|
direct: direct.NewKeys(base)},
|
|
OAuthClient: &oauthClientRouter{c: c,
|
|
proc: procedure.NewOAuthClients(run, res.Procs),
|
|
direct: direct.NewOAuthClients(base)},
|
|
OAuthUser: &oauthUserRouter{c: c,
|
|
proc: procedure.NewOAuthUsers(run, res.Procs),
|
|
direct: direct.NewOAuthUsers(base)},
|
|
OAuthGrant: &oauthGrantRouter{c: c,
|
|
proc: procedure.NewOAuthGrants(run, res.Procs),
|
|
direct: direct.NewOAuthGrants(base)},
|
|
Passkey: &passkeyRouter{c: c, proc: p, direct: direct.NewPasskey(base)},
|
|
TOTP: &totpRouter{c: c,
|
|
proc: procedure.NewTOTP(run, res.Procs),
|
|
direct: direct.NewTOTP(base)},
|
|
Policy: &policyRouter{c: c,
|
|
proc: procedure.NewPolicy(run, res.Procs),
|
|
direct: direct.NewPolicy(base, direct.PolicyOptions{NoGroups: opts.NoGroupTables})},
|
|
}, nil
|
|
}
|
|
|
|
// Failed returns a Provider whose every operation returns err. Constructors that cannot
|
|
// return an error use it so a bad configuration fails closed on first use.
|
|
func Failed(err error) *lookup.Provider {
|
|
c := &chooser{fail: err}
|
|
return &lookup.Provider{
|
|
Auth: &authRouter{c: c},
|
|
Keys: &keysRouter{c: c},
|
|
OAuthClient: &oauthClientRouter{c: c},
|
|
OAuthUser: &oauthUserRouter{c: c},
|
|
OAuthGrant: &oauthGrantRouter{c: c},
|
|
Passkey: &passkeyRouter{c: c},
|
|
TOTP: &totpRouter{c: c},
|
|
Policy: &policyRouter{c: c},
|
|
}
|
|
}
|
|
|
|
// chooser decides per operation whether the procedure or the direct store runs.
|
|
type chooser struct {
|
|
cfg *lookup.Resolved
|
|
dialect string
|
|
procs lookup.ProcNames
|
|
db *procedure.DB
|
|
probes sync.Map // proc name -> bool
|
|
fail error // set by Failed: every operation returns it
|
|
}
|
|
|
|
func (c *chooser) resetProbes() {
|
|
c.probes.Range(func(k, _ any) bool { c.probes.Delete(k); return true })
|
|
}
|
|
|
|
// useProc reports whether op should call the stored procedure proc. In auto mode on Postgres
|
|
// the catalog is probed once per procedure (cached until a reconnect).
|
|
func (c *chooser) useProc(ctx context.Context, op lookup.Op, proc string) (bool, error) {
|
|
if c.fail != nil {
|
|
return false, c.fail
|
|
}
|
|
m, err := c.cfg.EffectiveMode(op, c.dialect)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
switch m {
|
|
case lookup.ModeProcedure:
|
|
return true, nil
|
|
case lookup.ModeAuto:
|
|
if v, ok := c.probes.Load(proc); ok {
|
|
return v.(bool), nil
|
|
}
|
|
exists := probeProcedure(ctx, c.db.Get(), proc)
|
|
c.probes.Store(proc, exists)
|
|
return exists, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// probeProcedure asks the Postgres catalog whether a function exists. Any failure counts as
|
|
// "does not exist" so the probe can never block an operation.
|
|
func probeProcedure(ctx context.Context, db *sql.DB, proc string) (exists bool) {
|
|
if db == nil {
|
|
return false
|
|
}
|
|
defer func() { _ = recover() }()
|
|
dbtrace.Raw(ctx, "probe.pg_proc")
|
|
if err := db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1)`, proc).Scan(&exists); err != nil {
|
|
return false
|
|
}
|
|
return exists
|
|
}
|
|
|
|
// pick returns the store that should serve op.
|
|
func pick[T any](c *chooser, ctx context.Context, op lookup.Op, proc string, p, d T) (T, error) {
|
|
use, err := c.useProc(ctx, op, proc)
|
|
if err != nil {
|
|
var zero T
|
|
return zero, err
|
|
}
|
|
if use {
|
|
return p, nil
|
|
}
|
|
return d, nil
|
|
}
|