Files
ResolveSpec/pkg/security/lookup/backends/backends.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

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
}