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.
214 lines
9.3 KiB
Go
214 lines
9.3 KiB
Go
// Package lookup owns every database read and write the security package needs.
|
|
// pkg/security itself contains no SQL: it calls the store interfaces defined here.
|
|
//
|
|
// Each store has a procedure implementation (stored procedures, the Postgres default)
|
|
// and a direct implementation (tables through a dialect-driven query builder). Which
|
|
// one runs is decided per operation by Config.EffectiveMode.
|
|
package lookup
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/dialect"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
|
|
)
|
|
|
|
// AuthStore covers sessions, login, registration and password reset.
|
|
type AuthStore interface {
|
|
Login(ctx context.Context, req sectypes.LoginRequest) (*sectypes.LoginResponse, error)
|
|
Register(ctx context.Context, req sectypes.RegisterRequest) (*sectypes.LoginResponse, error)
|
|
Logout(ctx context.Context, req sectypes.LogoutRequest) error
|
|
// Session resolves a session token to its user. reference says where the token came
|
|
// from ("authenticate", "cookie", "refresh"); the procedure backend passes it through.
|
|
Session(ctx context.Context, token, reference string) (*sectypes.UserContext, error)
|
|
// TouchSession records last activity for a session token. user is the context the
|
|
// session resolved to; the procedure backend passes it to the update procedure.
|
|
TouchSession(ctx context.Context, token string, user *sectypes.UserContext) error
|
|
Refresh(ctx context.Context, refreshToken string) (*sectypes.LoginResponse, error)
|
|
// LoginAPIKey logs in with a raw header/generic API key. Unknown, expired, inactive and
|
|
// wrong-type keys all return the same error.
|
|
LoginAPIKey(ctx context.Context, rawKey string, claims map[string]any) (*sectypes.LoginResponse, error)
|
|
JWTLogin(ctx context.Context, req sectypes.LoginRequest) (*sectypes.LoginResponse, error)
|
|
JWTLogout(ctx context.Context, req sectypes.LogoutRequest) error
|
|
ResetRequest(ctx context.Context, req sectypes.PasswordResetRequest) (*sectypes.PasswordResetResponse, error)
|
|
ResetComplete(ctx context.Context, req sectypes.PasswordResetCompleteRequest) error
|
|
}
|
|
|
|
// ErrInvalidAPIKey is the single error LoginAPIKey returns for unknown, expired, inactive
|
|
// and wrong-type keys, so callers cannot tell them apart.
|
|
var ErrInvalidAPIKey = errors.New("invalid api key")
|
|
|
|
// KeyStore persists per-user auth keys. Hashing and raw-key generation happen in Go,
|
|
// so the store only sees key hashes.
|
|
type KeyStore interface {
|
|
Create(ctx context.Context, req sectypes.CreateKeyRequest, keyHash string) (*sectypes.UserKey, error)
|
|
List(ctx context.Context, userID int, keyType sectypes.KeyType) ([]sectypes.UserKey, error)
|
|
// Delete soft-deletes a key after verifying ownership and returns its hash so callers can
|
|
// invalidate caches. The hash is empty when the backend cannot report it.
|
|
Delete(ctx context.Context, userID int, keyID int64) (keyHash string, err error)
|
|
Validate(ctx context.Context, keyHash string, keyType sectypes.KeyType) (*sectypes.UserKey, error)
|
|
}
|
|
|
|
// OAuthClientStore persists the OAuth2 authorization server state (RFC 7591 clients,
|
|
// authorization codes, token introspection and revocation).
|
|
type OAuthClientStore interface {
|
|
RegisterClient(ctx context.Context, client *sectypes.OAuthServerClient) (*sectypes.OAuthServerClient, error)
|
|
GetClient(ctx context.Context, clientID string) (*sectypes.OAuthServerClient, error)
|
|
SaveCode(ctx context.Context, code *sectypes.OAuthCode) error
|
|
// ExchangeCode atomically consumes an authorization code.
|
|
ExchangeCode(ctx context.Context, code string) (*sectypes.OAuthCode, error)
|
|
Introspect(ctx context.Context, token string) (*sectypes.OAuthTokenInfo, error)
|
|
Revoke(ctx context.Context, token string) error
|
|
// UpdateClient rewrites the registration fields of an existing client (RFC 7592).
|
|
UpdateClient(ctx context.Context, client *sectypes.OAuthServerClient) error
|
|
// DeleteClient deactivates a client.
|
|
DeleteClient(ctx context.Context, clientID string) error
|
|
}
|
|
|
|
// OAuthSession is the session row written after an OAuth2 client login.
|
|
type OAuthSession struct {
|
|
SessionToken string
|
|
UserID int
|
|
AccessToken string
|
|
RefreshToken string
|
|
TokenType string
|
|
ExpiresAt time.Time
|
|
Provider string
|
|
}
|
|
|
|
// OAuthRefreshSession is the stored token state needed to refresh an OAuth2 login.
|
|
type OAuthRefreshSession struct {
|
|
UserID int `json:"user_id"`
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
Expiry time.Time `json:"expiry"`
|
|
}
|
|
|
|
// OAuthUserStore persists users and sessions created through OAuth2 client login.
|
|
type OAuthUserStore interface {
|
|
GetOrCreateUser(ctx context.Context, user *sectypes.UserContext, provider string) (int, error)
|
|
CreateSession(ctx context.Context, session OAuthSession) error
|
|
GetByRefreshToken(ctx context.Context, refreshToken string) (*OAuthRefreshSession, error)
|
|
UpdateRefreshToken(ctx context.Context, userID int, oldRefreshToken, newSessionToken, newAccessToken, newRefreshToken string, expiresAt time.Time) error
|
|
GetUser(ctx context.Context, userID int) (*sectypes.UserContext, error)
|
|
}
|
|
|
|
// PasskeyCredentialRecord is a credential as persisted: byte fields are base64 text.
|
|
type PasskeyCredentialRecord struct {
|
|
UserID int
|
|
CredentialID string // base64
|
|
PublicKey string // base64
|
|
AttestationType string
|
|
SignCount uint32
|
|
Transports []string
|
|
BackupEligible bool
|
|
BackupState bool
|
|
Name string
|
|
}
|
|
|
|
// PasskeyCredentialRef is a credential id and transports, as returned for a username lookup.
|
|
type PasskeyCredentialRef struct {
|
|
CredentialID string `json:"credential_id"`
|
|
Transports []string `json:"transports"`
|
|
}
|
|
|
|
// PasskeyStore persists WebAuthn credentials.
|
|
type PasskeyStore interface {
|
|
Store(ctx context.Context, rec PasskeyCredentialRecord) (int64, error)
|
|
// Get returns the owner and signature counter of a credential.
|
|
Get(ctx context.Context, credentialID string) (userID int, signCount uint32, err error)
|
|
UpdateCounter(ctx context.Context, credentialID string, newCounter uint32) (cloneWarning bool, err error)
|
|
List(ctx context.Context, userID int) ([]sectypes.PasskeyCredential, error)
|
|
Delete(ctx context.Context, userID int, credentialID string) error
|
|
Rename(ctx context.Context, userID int, credentialID, name string) error
|
|
ByUsername(ctx context.Context, username string) (userID int, creds []PasskeyCredentialRef, err error)
|
|
Login(ctx context.Context, userID int, claims map[string]any) (*sectypes.LoginResponse, error)
|
|
}
|
|
|
|
// TOTPStore persists two-factor state. Backup codes arrive already hashed.
|
|
type TOTPStore interface {
|
|
Enable(ctx context.Context, userID int, secret string, hashedCodes []string) error
|
|
Disable(ctx context.Context, userID int) error
|
|
Status(ctx context.Context, userID int) (bool, error)
|
|
Secret(ctx context.Context, userID int) (string, error)
|
|
RegenerateBackupCodes(ctx context.Context, userID int, hashedCodes []string) error
|
|
ValidateBackupCode(ctx context.Context, userID int, codeHash string) (bool, error)
|
|
}
|
|
|
|
// PolicyStore loads column and row security rules. No rules is an empty result, never
|
|
// an error; failures are errors so callers fail closed.
|
|
type PolicyStore interface {
|
|
ColumnSecurity(ctx context.Context, userID int, schema, table string) ([]sectypes.ColumnSecurity, error)
|
|
RowSecurity(ctx context.Context, userRef any, schema, table string) (sectypes.RowSecurity, error)
|
|
}
|
|
|
|
// Provider bundles every store. Security constructors take a Provider.
|
|
type Provider struct {
|
|
Auth AuthStore
|
|
Keys KeyStore
|
|
OAuthClient OAuthClientStore
|
|
OAuthUser OAuthUserStore
|
|
OAuthGrant OAuthGrantStore
|
|
Passkey PasskeyStore
|
|
TOTP TOTPStore
|
|
Policy PolicyStore
|
|
}
|
|
|
|
// Config selects dialect, mode and naming. The zero value is valid: dialect detected from
|
|
// the driver, default mode per dialect, default procedure/table/column names.
|
|
type Config struct {
|
|
// Dialect names a registered dialect ("postgres", "sqlite", "mysql", "mssql", or one added
|
|
// with dialect.Register). Empty = detect from the driver.
|
|
Dialect string
|
|
// Mode is the default mode for every operation. See ModeDefault.
|
|
Mode Mode
|
|
// Overrides sets the mode per operation, e.g. direct for OpSession, procedure for OpLogin.
|
|
Overrides map[Op]Mode
|
|
// Procs overrides procedure names; empty fields keep the default.
|
|
Procs ProcNames
|
|
// Schema overrides table and column names; missing entries keep the default.
|
|
Schema Schema
|
|
}
|
|
|
|
// Resolved is a Config merged with defaults and validated.
|
|
type Resolved struct {
|
|
Config
|
|
Procs ProcNames
|
|
Schema Schema
|
|
}
|
|
|
|
// Resolve merges c with the defaults and validates the result.
|
|
func (c Config) Resolve() (*Resolved, error) {
|
|
if !c.Mode.valid() {
|
|
return nil, fmt.Errorf("lookup: invalid mode %q", c.Mode)
|
|
}
|
|
for op, m := range c.Overrides {
|
|
if !m.valid() {
|
|
return nil, fmt.Errorf("lookup: invalid mode %q for %s", m, op)
|
|
}
|
|
}
|
|
if c.Dialect != "" {
|
|
if _, err := dialect.Get(c.Dialect); err != nil {
|
|
return nil, fmt.Errorf("lookup: %w", err)
|
|
}
|
|
}
|
|
procs := DefaultProcNames().Merge(c.Procs)
|
|
if err := procs.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
schema := DefaultSchema().Merge(c.Schema)
|
|
if err := schema.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Resolved{Config: c, Procs: procs, Schema: schema}, nil
|
|
}
|
|
|
|
// Registration conflicts reported by AuthStore.Register in direct mode.
|
|
var (
|
|
ErrUsernameExists = errors.New("username already exists")
|
|
ErrEmailExists = errors.New("email already exists")
|
|
)
|