API reference

Auto-generated from docstrings. The most useful entry point is regstack.RegStack; everything else hangs off it.

This page is organized by what you’d reach for, not by package hierarchy. Each section starts with a one-paragraph orientation, then the autoclass / autofunction directives pull the docstrings, signatures, and parameter docs straight off the package source.

Top-level

The handful of things you import from regstack directly:

Most embeddings need only RegStack and RegStackConfig.

Façade

RegStack is the embeddable façade. One per FastAPI application; hosts mount its router and (optionally) ui_router. All collaborators — password hasher, JWT codec, repos, hooks bus, email and SMS services — hang off the instance.

class regstack.app.RegStack(*, config, backend=None, clock=None, email_service=None, mail_composer=None, sms_service=None, rate_limiter=None)[source]

Bases: object

Embeddable account-management façade.

One RegStack is constructed per FastAPI application. The host then mounts the JSON router (and optionally the SSR router) and regstack owns user accounts, authentication, password reset, email verification, and (optionally) SMS two-factor.

The persistence story is owned by a Backend selected by config.database_url’s URL scheme:

  • mongodb:// / mongodb+srv:// → MongoDB

  • sqlite+aiosqlite:// → SQLite

  • postgresql+asyncpg:// → PostgreSQL

Hosts that need to share a connection pool with their own code can pass an explicit backend= argument and the URL is ignored.

Typical embed:

config = RegStackConfig.load()
regstack = RegStack(config=config)

@asynccontextmanager
async def lifespan(app):
    await regstack.install_schema()
    yield
    await regstack.aclose()

app = FastAPI(lifespan=lifespan)
app.include_router(regstack.router, prefix=config.api_prefix)

Notable instance attributes (all set during __init__):

  • config — the loaded RegStackConfig.

  • clock — the injected Clock (SystemClock in production, FrozenClock in tests).

  • backend — the active backend (Mongo / SQLite / Postgres).

  • users, pending, blacklist, attempts, mfa_codes — repositories conforming to the protocols in regstack.backends.protocols.

  • password_hasher — Argon2id wrapper.

  • jwtJwtCodec for session tokens.

  • lockoutLockoutService.

  • email, sms — the active transports.

  • mail — the MailComposer.

  • hooks — the HookRegistry event bus.

  • depsAuthDependencies factory.

Parameters:
property router: APIRouter

The composite JSON APIRouter.

Mount with app.include_router(regstack.router, prefix=config.api_prefix). Includes register, verify, login, logout, account always; conditionally adds password (forgot/reset), phone + MFA, and admin based on config.enable_* flags.

Built lazily on first access. If any RegStackConfig.*_rate_limit is set, a slowapi.Limiter is also required — either via the rate_limiter= constructor argument or auto-built from the rate_limit extra. Each listed route is then decorated with limiter.limit(...) before the router is returned.

property ui_env: Environment

The Jinja2 environment that renders the SSR pages.

Built lazily on first access; rebuilt automatically after every add_template_dir() call so host overrides take effect.

property ui_router: APIRouter

The SSR APIRouter for the bundled HTML pages.

Mount with app.include_router(regstack.ui_router, prefix=config.ui_prefix). Only meaningful when config.enable_ui_router=True — building it on a host that won’t mount it is harmless but pointless.

property static_files: StaticFiles

Bundled CSS / JS as a Starlette StaticFiles app.

Mount with app.mount(config.static_prefix, regstack.static_files). Serves core.css, the default theme.css, and regstack.js — the assets the SSR pages link to.

async install_schema()[source]

Bring the database schema to head — idempotent.

On Mongo this means ensuring every required index exists. On SQL backends it runs Alembic migrations to head, which creates tables on a fresh database and applies any new revisions on an existing one. Safe to call on every application boot.

Return type:

None

async aclose()[source]

Tear down the backend’s connection pool.

Call from your FastAPI lifespan teardown so background connections are closed cleanly when the application shuts down.

Return type:

None

async bootstrap_admin(email, password)[source]

Create or promote a verified superuser. Idempotent.

If a user with this email already exists, they are promoted to is_superuser=True if they weren’t already (their password is not changed). Otherwise a new active, verified, superuser account is created with the given password.

Parameters:
  • email (str) – The admin’s email address. Must be valid for the user model’s email validator.

  • password (str) – The plaintext password to hash and store on a newly-created admin. Ignored when promoting an existing user.

Return type:

BaseUser

Returns:

The persisted (and now-superuser) BaseUser.

Raises:

UserAlreadyExistsError – If the create path races against another writer for the same email.

async promote_pending(email)[source]

Convert a pending registration directly into a verified user.

Bypasses the email-link round-trip. Useful when:

  • a user lost their verification link and resend-verification isn’t an option (admin-triggered onboarding, dev fixtures);

  • a CLI batch operation seeds users from a known-good list;

  • an admin is rescuing a stuck signup.

The pending row’s hashed_password and full_name carry over verbatim — the user logs in with the password they originally registered with. The pending row is deleted on success. Fires the user_verified hook so analytics / downstream listeners see the same event the email-driven POST /verify produces.

Parameters:

email (str) – The email address whose pending registration should be promoted.

Return type:

BaseUser

Returns:

The newly persisted, active, verified BaseUser.

Raises:
  • LookupError – If no pending registration exists for that email (caller’s job to surface as 404 / CLI error).

  • UserAlreadyExistsError – If a non-pending user with that email already exists (caller’s job to surface as 409).

set_email_backend(service)[source]

Replace the active email backend at runtime.

Useful for hosts that want a backend not in the bundled set (Postmark, SendGrid, MessageBird, …). See EmailService for the contract.

Parameters:

service (EmailService) – An EmailService implementation.

Return type:

None

set_sms_backend(service)[source]

Replace the active SMS backend at runtime.

Parameters:

service (SmsService) – A SmsService implementation.

Return type:

None

add_template_dir(path)[source]

Prepend a host template directory to the override chain.

Host templates win over regstack defaults via Jinja2’s ChoiceLoader for both the email composer and the SSR UI pages. To override the verification email, drop a verification.html file in the directory; to override the login page, drop auth/login.html.

Parameters:

path (str | Path) – Filesystem directory to search before regstack’s bundled templates. Must exist when templates are rendered.

Return type:

None

on(event, handler)[source]

Register an event handler. Sync and async handlers both work.

Forwards to HookRegistry.on. Handlers fire concurrently when an event happens; exceptions are logged but never break the primary auth flow. See KNOWN_EVENTS for the set of events regstack itself fires.

Parameters:
  • event (str) – The event name (e.g. "user_registered", "password_changed").

  • handler (Callable[..., Awaitable[None] | None]) – A callable invoked with the event’s keyword arguments. Can be sync or async.

Return type:

None

Configuration

RegStackConfig is a pydantic-settings model loaded from environment variables (REGSTACK_*), an optional regstack.secrets.env, an optional regstack.toml, and programmatic kwargs — in that priority order. See the Configuration guide for every field with its default.

class regstack.config.schema.RegStackConfig(_case_sensitive=None, _nested_model_default_partial_update=None, _env_prefix=None, _env_prefix_target=None, _env_file=PosixPath('.'), _env_file_encoding=None, _env_ignore_empty=None, _env_nested_delimiter=None, _env_nested_max_split=None, _env_parse_none_str=None, _env_parse_enums=None, _cli_prog_name=None, _cli_parse_args=None, _cli_settings_source=None, _cli_parse_none_str=None, _cli_hide_none_type=None, _cli_avoid_json=None, _cli_enforce_required=None, _cli_use_class_docs_for_groups=None, _cli_exit_on_error=None, _cli_prefix=None, _cli_flag_prefix_char=None, _cli_implicit_flags=None, _cli_ignore_unknown_args=None, _cli_kebab_case=None, _cli_shortcuts=None, _secrets_dir=None, _build_sources=None, **values)[source]

Bases: BaseSettings

Top-level configuration for an embedded regstack instance.

Loading order (highest priority first):
  1. Programmatic kwargs.

  2. Environment variables (REGSTACK_*, nested via __).

  3. TOML file at $REGSTACK_CONFIG or ./regstack.toml.

  4. Field defaults defined here.

Parameters:
  • _case_sensitive (bool | None)

  • _nested_model_default_partial_update (bool | None)

  • _env_prefix (str | None)

  • _env_prefix_target (Optional[Literal['variable', 'alias', 'all']])

  • _env_file (Path | str | Sequence[Path | str] | None)

  • _env_file_encoding (str | None)

  • _env_ignore_empty (bool | None)

  • _env_nested_delimiter (str | None)

  • _env_nested_max_split (int | None)

  • _env_parse_none_str (str | None)

  • _env_parse_enums (bool | None)

  • _cli_prog_name (str | None)

  • _cli_parse_args (bool | list[str] | tuple[str, ...] | None)

  • _cli_settings_source (Optional[CliSettingsSource[Any]])

  • _cli_parse_none_str (str | None)

  • _cli_hide_none_type (bool | None)

  • _cli_avoid_json (bool | None)

  • _cli_enforce_required (bool | None)

  • _cli_use_class_docs_for_groups (bool | None)

  • _cli_exit_on_error (bool | None)

  • _cli_prefix (str | None)

  • _cli_flag_prefix_char (str | None)

  • _cli_implicit_flags (Union[bool, Literal['dual', 'toggle'], None])

  • _cli_ignore_unknown_args (bool | None)

  • _cli_kebab_case (Union[bool, Literal['all', 'no_enums'], None])

  • _cli_shortcuts (Mapping[str, str | list[str]] | None)

  • _secrets_dir (Path | str | Sequence[Path | str] | None)

  • _build_sources (tuple[tuple[PydanticBaseSettingsSource, ...], dict[str, Any]] | None)

  • values (Any)

  • app_name (str)

  • base_url (AnyHttpUrl)

  • behind_proxy (bool)

  • database_url (SecretStr)

  • mongodb_database (str)

  • user_collection (str)

  • pending_collection (str)

  • blacklist_collection (str)

  • login_attempt_collection (str)

  • mfa_code_collection (str)

  • oauth_identity_collection (str)

  • oauth_state_collection (str)

  • jwt_secret (SecretStr)

  • jwt_algorithm (Literal['HS256', 'HS384', 'HS512'])

  • jwt_ttl_seconds (Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=60), Le(le=2592000)])])

  • jwt_audience (str | None)

  • transport (Literal['bearer'])

  • verification_token_ttl_seconds (Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=60)])])

  • password_reset_token_ttl_seconds (Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=60)])])

  • email_change_token_ttl_seconds (Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=60)])])

  • sms_code_length (Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=4), Le(le=10)])])

  • sms_code_ttl_seconds (Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=30), Le(le=1800)])])

  • sms_code_max_attempts (Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=1), Le(le=20)])])

  • mfa_pending_token_ttl_seconds (Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=60), Le(le=1800)])])

  • require_verification (bool)

  • allow_registration (bool)

  • enable_password_reset (bool)

  • enable_account_deletion (bool)

  • enable_admin_router (bool)

  • enable_ui_router (bool)

  • enable_sms_2fa (bool)

  • enable_oauth (bool)

  • rate_limit_disabled (bool)

  • login_lockout_threshold (Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=1)])])

  • login_lockout_window_seconds (Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=10)])])

  • login_rate_limit (str | None)

  • login_mfa_confirm_rate_limit (str | None)

  • register_rate_limit (str | None)

  • forgot_password_rate_limit (str | None)

  • reset_password_rate_limit (str | None)

  • verify_rate_limit (str | None)

  • resend_verification_rate_limit (str | None)

  • change_password_rate_limit (str | None)

  • change_email_rate_limit (str | None)

  • confirm_email_change_rate_limit (str | None)

  • delete_account_rate_limit (str | None)

  • oauth_exchange_rate_limit (str | None)

  • phone_start_rate_limit (str | None)

  • phone_confirm_rate_limit (str | None)

  • phone_disable_rate_limit (str | None)

  • email (EmailConfig)

  • sms (SmsConfig)

  • oauth (OAuthConfig)

  • brand_logo_url (str | None)

  • brand_tagline (str | None)

  • extra_template_dirs (list[Path])

  • extra_static_dirs (list[Path])

  • api_prefix (str)

  • ui_prefix (str)

  • static_prefix (str)

  • theme_css_url (str | None)

  • email_link_prefix (str | None)

  • verify_url_template (str | None)

  • password_reset_url_template (str | None)

  • email_change_url_template (str | None)

app_name: str
base_url: AnyHttpUrl
behind_proxy: bool
database_url: SecretStr
mongodb_database: str
user_collection: str
pending_collection: str
blacklist_collection: str
login_attempt_collection: str
mfa_code_collection: str
oauth_identity_collection: str
oauth_state_collection: str
jwt_secret: SecretStr
jwt_algorithm: JwtAlgorithm
jwt_ttl_seconds: Annotated[int, Field(ge=60, le=60 * 60 * 24 * 30)]
jwt_audience: str | None
transport: TokenTransport
verification_token_ttl_seconds: Annotated[int, Field(ge=60)]
password_reset_token_ttl_seconds: Annotated[int, Field(ge=60)]
email_change_token_ttl_seconds: Annotated[int, Field(ge=60)]
sms_code_length: Annotated[int, Field(ge=4, le=10)]
sms_code_ttl_seconds: Annotated[int, Field(ge=30, le=60 * 30)]
sms_code_max_attempts: Annotated[int, Field(ge=1, le=20)]
mfa_pending_token_ttl_seconds: Annotated[int, Field(ge=60, le=60 * 30)]
require_verification: bool
allow_registration: bool
enable_password_reset: bool
enable_account_deletion: bool
enable_admin_router: bool
enable_ui_router: bool
enable_sms_2fa: bool
enable_oauth: bool
rate_limit_disabled: bool
login_lockout_threshold: Annotated[int, Field(ge=1)]
login_lockout_window_seconds: Annotated[int, Field(ge=10)]
login_rate_limit: str | None
login_mfa_confirm_rate_limit: str | None
register_rate_limit: str | None
forgot_password_rate_limit: str | None
reset_password_rate_limit: str | None
verify_rate_limit: str | None
resend_verification_rate_limit: str | None
change_password_rate_limit: str | None
change_email_rate_limit: str | None
confirm_email_change_rate_limit: str | None
delete_account_rate_limit: str | None
oauth_exchange_rate_limit: str | None
phone_start_rate_limit: str | None
phone_confirm_rate_limit: str | None
phone_disable_rate_limit: str | None
email: EmailConfig
sms: SmsConfig
oauth: OAuthConfig
brand_logo_url: str | None
brand_tagline: str | None
extra_template_dirs: list[Path]
extra_static_dirs: list[Path]
api_prefix: str
ui_prefix: str
static_prefix: str
theme_css_url: str | None
verify_url_template: str | None
password_reset_url_template: str | None
email_change_url_template: str | None

Return the path prefix to prepend to email-link paths.

Used by the verification, password-reset and email-change routers when composing the URLs embedded in outgoing emails. See the email_link_prefix field for the semantics.

Return type:

str

resolve_verify_url(token)[source]

Return the URL embedded in the verification email.

Parameters:

token (str)

Return type:

str

resolve_password_reset_url(token)[source]

Return the URL embedded in the password-reset email.

Parameters:

token (str)

Return type:

str

resolve_email_change_url(token)[source]

Return the URL embedded in the email-change confirmation email.

Parameters:

token (str)

Return type:

str

classmethod load(toml_path=None, secrets_env_path=None, **overrides)[source]

Convenience constructor delegating to regstack.config.loader.load_config.

Parameters:
Return type:

RegStackConfig

class regstack.config.schema.EmailConfig(**data)[source]

Bases: BaseModel

Parameters:
  • data (Any)

  • backend (Literal['console', 'smtp', 'ses'])

  • from_address (EmailStr)

  • from_name (str | None)

  • smtp_host (str | None)

  • smtp_port (int)

  • smtp_starttls (bool)

  • smtp_username (str | None)

  • smtp_password (SecretStr | None)

  • ses_region (str)

  • ses_profile (str | None)

  • ses_access_key_id (SecretStr | None)

  • ses_secret_access_key (SecretStr | None)

  • log_bodies (bool)

backend: Literal['console', 'smtp', 'ses']
from_address: EmailStr
from_name: str | None

Display name on the From: header. None (the default) defers to RegStackConfig.app_name, so changing only app_name is enough to brand the outgoing emails. Set explicitly to override — e.g. from_name = "Acme Customer Service" when app_name is the internal product code and shouldn’t appear to end-users.

smtp_host: str | None
smtp_port: int
smtp_starttls: bool
smtp_username: str | None
smtp_password: SecretStr | None
ses_region: str
ses_profile: str | None
ses_access_key_id: SecretStr | None

Explicit AWS access key id for the SES backend. None (the default) lets boto3 walk its usual credential chain — env vars, shared credentials file, EC2/ECS instance profile. Set explicitly when the host already loads its AWS credentials from a secrets store (Vault, AWS Secrets Manager, secrets.env) and would rather pass them in than re-export them into the process env.

ses_secret_access_key: SecretStr | None

Explicit AWS secret access key — pairs with ses_access_key_id. Both must be set together; setting only one is a config error.

log_bodies: bool

When True, the console backend logs the full rendered body at INFO instead of DEBUG. Off by default because bodies contain one-time tokens; turn on only for the console backend in a dev/staging deployment that you intend to probe with regstack validate. Other backends ignore this flag — they don’t log bodies at all.

class regstack.config.schema.SmsConfig(**data)[source]

Bases: BaseModel

Parameters:
  • data (Any)

  • backend (Literal['null', 'sns', 'twilio'])

  • from_number (str | None)

  • sns_region (str)

  • twilio_account_sid (str | None)

  • twilio_auth_token (SecretStr | None)

  • log_bodies (bool)

backend: Literal['null', 'sns', 'twilio']
from_number: str | None
sns_region: str
twilio_account_sid: str | None
twilio_auth_token: SecretStr | None
log_bodies: bool

When True, the null backend logs the SMS body (including the 6-digit MFA code) at INFO. Defaults to False so a misconfigured deployment can’t leak codes into shared logs — symmetric with email.log_bodies. Set it True for local dev when you want to read the code out of stdout (the bundled examples instead surface it via an mfa_login_started hook, so they don’t need this). Other backends ignore this flag — they never log message bodies. (Security review 2026-05-19.)

class regstack.config.schema.OAuthConfig(**data)[source]

Bases: BaseModel

OAuth provider configuration.

The router is mounted only when enable_oauth=True AND at least one provider has its credentials set (currently just Google).

Parameters:
  • data (Any)

  • google_client_id (str | None)

  • google_client_secret (SecretStr | None)

  • google_redirect_uri (AnyHttpUrl | None)

  • auto_link_verified_emails (bool)

  • enforce_mfa_on_oauth_signin (bool)

  • state_ttl_seconds (int)

  • completion_ttl_seconds (int)

google_client_id: str | None
google_client_secret: SecretStr | None
google_redirect_uri: AnyHttpUrl | None

Defaults to f"{base_url}{api_prefix}/oauth/google/callback" when None. Must match the redirect URI registered with the provider exactly.

Auto-link Google sign-ins to existing email-registered users when the provider’s email_verified=true. Off by default: that policy trusts the provider’s verified-email claim forever, which lets account-recycling at the provider become an account takeover at the host. Hosts that consciously accept that risk for UX can flip this on; the safer default is to require an authenticated link from /account/me. See tasks/oauth-design.md §1 for the full threat model.

enforce_mfa_on_oauth_signin: bool

When True, an OAuth sign-in for a user with SMS MFA enabled still goes through the second-factor step. Off by default — the OAuth provider already authenticated the human, and SMS over OAuth is mostly redundant outside regulated environments.

state_ttl_seconds: int
completion_ttl_seconds: int
regstack.config.loader.load_config(toml_path=None, secrets_env_path=None, **overrides)[source]

Build a RegStackConfig by merging defaults, TOML, env, and kwargs.

Highest priority wins:

kwargs > os.environ > secrets.env > TOML > defaults.

Parameters:
Return type:

RegStackConfig

Auth primitives

The pieces that make authentication work: password hashing (Argon2id), JWT issuance and validation with per-purpose derived keys, login lockout, and the FastAPI dependency factory. Hosts rarely instantiate these directly — they’re built and wired by the RegStack constructor — but the docstrings on the public methods explain the contract.

class regstack.auth.password.PasswordHasher[source]

Bases: object

Argon2id password hashing facade.

Thin wrapper over pwdlib so the algorithm choice (and any future algorithm rotation) lives behind one interface. Callers don’t import pwdlib directly.

A single instance is held on the RegStack façade as regstack.password_hasher; constructing your own is rarely necessary.

hash(password)[source]

Hash a plaintext password with Argon2id.

Parameters:

password (str) – The plaintext password to hash.

Return type:

str

Returns:

The Argon2 PHC-formatted hash string. Includes algorithm, parameters, salt, and digest, so verify() can recover everything it needs.

verify(password, hashed)[source]

Constant-time check that password matches hashed.

Parameters:
  • password (str) – The plaintext password supplied by the user.

  • hashed (str) – A previously stored hash() result.

Return type:

bool

Returns:

True if the password matches; False otherwise. No exception is raised on mismatch.

class regstack.auth.jwt.JwtCodec(config, clock)[source]

Bases: object

Encode and decode regstack’s signed JWTs.

Each purpose (session, password_reset, email_change, phone_setup, login_mfa) signs with a separate key derived from config.jwt_secret via HMAC-SHA256. Compromise of one derived key does not compromise the others, and an attacker who captures a session token cannot replay it as a password-reset token.

Expiry (exp) and issued-at (iat) are evaluated against the injected Clock, not the system wall clock — that’s the seam FrozenClock-driven tests rely on.

Parameters:
encode(subject, *, purpose='session', ttl_seconds=None)[source]

Sign and return a JWT plus its decoded payload.

Parameters:
  • subject (str) – The sub claim — usually a user id.

  • purpose (str) – Logical token kind (session by default). The derived signing key depends on this string, so a token minted with one purpose cannot be decoded with another.

  • ttl_seconds (int | None) – Override for the token lifetime. When None, config.jwt_ttl_seconds is used.

Return type:

tuple[str, TokenPayload]

Returns:

A (token, payload) tuple. token is the encoded string the caller hands to the client; payload is the in-memory TokenPayload (useful when the caller also needs to record the jti for later revocation).

decode(token, *, purpose='session')[source]

Verify a token’s signature and decode its claims.

Verification is strict: signature, required-claims set, and aud (when configured) must all pass. exp is checked against the injected Clock, not time.time(), so frozen-clock tests stay deterministic. The purpose claim must match the expected purpose — a session token cannot satisfy decode(..., purpose="password_reset").

Parameters:
  • token (str) – The encoded JWT string from the client.

  • purpose (str) – The expected token kind. Must match the value used at encode() time.

Return type:

TokenPayload

Returns:

The decoded TokenPayload.

Raises:

TokenError – On any failure — bad signature, expired token, purpose mismatch, missing required claim, etc.

class regstack.auth.jwt.TokenPayload(sub, jti, iat, exp, purpose)[source]

Bases: object

Decoded JWT claims for a regstack-issued token.

Returned by JwtCodec.decode() and produced as a side-effect of JwtCodec.encode(). Carries everything callers need to enforce revocation and audit trails without re-decoding the raw token.

Parameters:
sub: str

The token subject — for session tokens, the user id.

jti: str

Per-token unique id used by the blacklist repo for explicit revocation on logout.

iat: datetime

When the token was issued. Tz-aware. Stored as a float (RFC 7519 NumericDate) so the bulk-revoke comparison iat <= cutoff is precise even when a login completes microseconds after a password change.

exp: datetime

Expiry timestamp. Tz-aware.

purpose: str

Token kind — session, password_reset, email_change, phone_setup, or login_mfa. Each kind is signed with a different derived key.

to_claims(audience)[source]

Serialize as a JWT claims dict, including aud if set.

Parameters:

audience (str | None) – Optional audience claim. When non-None, added as aud; JwtCodec.decode then validates it.

Return type:

dict[str, Any]

Returns:

A dict suitable for pyjwt.encode.

exception regstack.auth.jwt.TokenError[source]

Bases: Exception

Raised when a token cannot be decoded or is no longer valid.

regstack.auth.jwt.is_payload_bulk_revoked(payload, cutoff)[source]

Decide whether a token has been bulk-revoked.

Bulk revocation lets regstack invalidate every outstanding session when a user changes their password (or admin disables them) without enumerating every jti. The user document carries a tokens_invalidated_after timestamp; any token with iat <= cutoff is rejected.

The comparison is <= (not <) so a token issued at exactly the cutoff instant is treated as before-the-change for security. Float-precision iat (RFC 7519 NumericDate) means a login completing microseconds after a password change has iat > cutoff and survives.

Parameters:
Return type:

bool

Returns:

True if the token must be rejected; False if it’s still valid (subject to the per-token blacklist check).

class regstack.auth.lockout.LockoutService(*, attempts, config, clock)[source]

Bases: object

Per-account login lockout — sliding window over recent failures.

Counts failed logins per email in a window of config.login_lockout_window_seconds. Once the count exceeds config.login_lockout_threshold, every login attempt for that email is rejected with HTTP 429 (and a Retry-After header) for the rest of the window — even when the password is correct, so an attacker can’t tell whether their guess was right.

Successful logins call clear() to wipe the recorded failures eagerly.

Disabled (always returns locked=False and never writes failures) when config.rate_limit_disabled is set. Tests rely on this to avoid timing flakes.

Parameters:
async check(email)[source]

Decide whether email is currently locked out.

Should be called before the password is verified — the whole point is that a locked-out attacker can’t probe whether their guess was correct by observing different responses.

Parameters:

email (str) – The email being attempted.

Return type:

LockoutDecision

Returns:

A LockoutDecision. When locked is True the caller should respond with HTTP 429 and the Retry-After header.

async attempts_remaining(email)[source]

Return how many failures are left before lockout fires.

Used by the login route after a wrong-password 401 so we can surface the same “N attempts remaining” message MFA shows. Returns None when rate-limiting is disabled (tests) — the route should then suppress the line entirely.

Parameters:

email (str)

Return type:

int | None

async record_failure(email, *, ip=None)[source]

Record one failed login. No-op when rate limiting is disabled.

Parameters:
  • email (str) – The email that failed.

  • ip (str | None) – Optional source IP. Recorded for auditing; not used by the threshold calculation today.

Return type:

None

async clear(email)[source]

Wipe accumulated failures for email.

Called on successful login (so the user’s next mistype doesn’t get them halfway to lockout) and on successful password reset (so the legitimate user isn’t still gated out by the attacker’s attempts).

Parameters:

email (str) – The email whose failure rows should be deleted.

Return type:

None

class regstack.auth.lockout.LockoutDecision(locked, retry_after_seconds)[source]

Bases: object

Result of a LockoutService.check() call.

Parameters:
  • locked (bool)

  • retry_after_seconds (int)

locked: bool

True if the email is locked out and the login request should be rejected without verifying the password.

retry_after_seconds: int

How long the client should wait before retrying. Surfaced to the client as the HTTP Retry-After header on a 429 response. 0 when locked=False.

class regstack.auth.dependencies.AuthDependencies(*, jwt, users, blacklist)[source]

Bases: object

Factory for FastAPI auth dependencies, bound to one RegStack instance.

A factory (rather than module-level dependencies) so two embedded RegStack instances in the same process don’t share state via module globals — useful for multi-tenant deployments.

Hosts that want to require authentication on their own endpoints use current_user() and current_admin() as FastAPI Depends(...) arguments:

from fastapi import Depends

@app.get("/me/orders")
async def list_orders(user = Depends(regstack.deps.current_user())):
    ...
Parameters:
current_user()[source]

Return a FastAPI dependency that yields the authenticated user.

The returned callable validates the Authorization: Bearer <token> header against the JWT codec, the per-token blacklist, and the user’s bulk-revoke cutoff. On success the BaseUser is stashed on request.state.regstack_user for downstream middleware.

Return type:

Callable[..., Awaitable[BaseUser]]

Returns:

A callable suitable for Depends(...). Raises fastapi.HTTPException 401 on any auth failure.

current_user_optional()[source]

Return a FastAPI dependency that yields the user or None.

For endpoints that render differently for authenticated vs anonymous callers (think “show your cart icon if logged in”). Treats every form of auth failure — missing header, bad scheme, expired token, revoked jti, deleted user, bulk-revoked session — as anonymous and returns None rather than raising 401.

On success the user is stashed on request.state.regstack_user (same as current_user()), so downstream middleware sees the same shape for either path.

Return type:

Callable[..., Awaitable[Optional[BaseUser]]]

Returns:

A callable suitable for Depends(...). Never raises an HTTPException — auth problems collapse to None.

current_admin()[source]

Return a FastAPI dependency that yields a superuser.

Same checks as current_user(), plus a 403 if the authenticated user does not have is_superuser=True.

Return type:

Callable[..., Awaitable[BaseUser]]

Returns:

A callable suitable for Depends(...). Raises 401 on auth failure or 403 on insufficient privilege.

Time

Every time-sensitive operation reads now() through a Clock. Tests inject FrozenClock to make assertions deterministic.

class regstack.auth.clock.Clock(*args, **kwargs)[source]

Bases: Protocol

Source of “now” — the seam that makes time-sensitive code testable.

JWT issuance, JWT expiry validation, lockout window calculations, and bulk-revoke comparisons all read time through this protocol rather than calling datetime.now() directly. Production passes SystemClock; tests pass FrozenClock so a single test can deterministically assert “this token expires in exactly 7200 seconds” without sleeping.

now()[source]

Return the current tz-aware UTC datetime.

Return type:

datetime

class regstack.auth.clock.SystemClock[source]

Bases: object

Production Clock — wraps datetime.now(UTC).

now()[source]

Return the current wall-clock time as a tz-aware UTC datetime.

Return type:

datetime

class regstack.auth.clock.FrozenClock(start=None)[source]

Bases: object

Test Clock — returns a fixed timestamp until advanced.

Pin the clock to a known instant for the lifetime of a test, then advance it explicitly to step over expiry boundaries:

clock = FrozenClock()
token, _ = codec.encode("user-1")
clock.advance(timedelta(seconds=7201))   # past exp
with pytest.raises(TokenError):
    codec.decode(token)
Parameters:

start (datetime | None)

now()[source]

Return the currently-pinned timestamp.

Return type:

datetime

advance(delta)[source]

Move the clock forward by delta.

Parameters:

delta (timedelta) – How far to advance. Negative values are accepted but rarely useful.

Return type:

None

set(when)[source]

Reset the clock to an absolute instant.

Parameters:

when (datetime) – The new “now”. Should be tz-aware.

Return type:

None

Backends

regstack ships three storage backends behind one set of Protocol classes: SQLite (default, no infrastructure), PostgreSQL, and MongoDB. The active backend is auto-built from config.database_url’s URL scheme via build_backend. Hosts that need to share a connection pool with their own application can pass an explicit Backend to the RegStack constructor.

class regstack.backends.base.Backend(*, config, clock)[source]

Bases: ABC

A configured persistence backend for one regstack instance.

Parameters:
kind: BackendKind
users: UserRepoProtocol
pending: PendingRepoProtocol
blacklist: BlacklistRepoProtocol
attempts: LoginAttemptRepoProtocol
mfa_codes: MfaCodeRepoProtocol
oauth_identities: OAuthIdentityRepoProtocol
oauth_states: OAuthStateRepoProtocol
abstractmethod async install_schema()[source]

Create indexes (Mongo) or run migrations (SQL).

Idempotent — safe to call on every app start. Hosts typically invoke this from a FastAPI lifespan startup hook.

Return type:

None

abstractmethod async aclose()[source]

Close the underlying connection pool / client.

Return type:

None

abstractmethod async ping()[source]

Cheap connectivity probe. Raises on failure. Used by regstack doctor.

Return type:

None

class regstack.backends.base.BackendKind(value)[source]

Bases: StrEnum

SQLITE = 'sqlite'
POSTGRES = 'postgres'
MONGO = 'mongo'
regstack.backends.factory.build_backend(config, *, clock=None)[source]

Construct the configured backend without opening any sockets yet (pools are lazy in every implementation).

Parameters:
Return type:

Backend

class regstack.backends.sql.SqlBackend(*, config, clock, kind)[source]

Bases: Backend

SQLAlchemy 2 async backend. Same code path drives SQLite (via aiosqlite) and Postgres (via asyncpg) — only database_url differs.

Parameters:
async install_schema()[source]

Run the bundled Alembic migrations to head.

Idempotent — Alembic’s upgrade is a no-op when the database is already at the target revision. Safe to call from a FastAPI lifespan startup on every boot.

Hosts that need to drive migrations from CI / a deploy step instead of in-process can call regstack migrate (CLI) or regstack.backends.sql.migrations.upgrade(database_url) (programmatic) and skip install_schema().

Return type:

None

async aclose()[source]

Close the underlying connection pool / client.

Return type:

None

async ping()[source]

Cheap connectivity probe. Raises on failure. Used by regstack doctor.

Return type:

None

property engine: AsyncEngine

Repository protocols

The five repository protocols are the contract every backend implements. Routers and services depend only on these — switching backends is a wiring change, not a code change.

class regstack.backends.protocols.UserRepoProtocol(*args, **kwargs)[source]

Bases: Protocol

async create(user)[source]
Parameters:

user (BaseUser)

Return type:

BaseUser

async get_by_email(email)[source]
Parameters:

email (str)

Return type:

BaseUser | None

async get_by_id(user_id)[source]
Parameters:

user_id (str)

Return type:

BaseUser | None

async set_last_login(user_id, when)[source]
Parameters:
Return type:

None

async set_tokens_invalidated_after(user_id, when)[source]
Parameters:
Return type:

None

async update_password(user_id, hashed_password)[source]
Parameters:
  • user_id (str)

  • hashed_password (str)

Return type:

None

async set_active(user_id, *, is_active)[source]
Parameters:
Return type:

None

async set_superuser(user_id, *, is_superuser)[source]
Parameters:
  • user_id (str)

  • is_superuser (bool)

Return type:

None

async set_full_name(user_id, full_name)[source]
Parameters:
Return type:

None

async set_phone(user_id, phone_number)[source]
Parameters:
Return type:

None

async set_mfa_enabled(user_id, *, is_mfa_enabled)[source]
Parameters:
  • user_id (str)

  • is_mfa_enabled (bool)

Return type:

None

async update_email(user_id, new_email)[source]

Atomically swap email + bump tokens_invalidated_after.

Implementations MUST raise UserAlreadyExistsError if the new email is already taken by a different user. Bulk-revoke is the caller-visible side-effect that sessions bound to the old email die immediately.

Parameters:
  • user_id (str)

  • new_email (str)

Return type:

None

async delete(user_id)[source]
Parameters:

user_id (str)

Return type:

bool

async count(*, is_active=None, is_verified=None, is_superuser=None)[source]

Count users matching ALL of the provided filters (None = ignored).

Parameters:
Return type:

int

async list_paged(*, skip=0, limit=50, sort_by_created_at_desc=True)[source]
Parameters:
  • skip (int)

  • limit (int)

  • sort_by_created_at_desc (bool)

Return type:

list[BaseUser]

class regstack.backends.protocols.PendingRepoProtocol(*args, **kwargs)[source]

Bases: Protocol

async upsert(pending)[source]

Insert or replace the pending registration for this email.

Resends overwrite outstanding rows so the most recent token is the only valid one — old verification links must stop working.

Parameters:

pending (PendingRegistration)

Return type:

PendingRegistration

async find_by_token_hash(token_hash)[source]
Parameters:

token_hash (str)

Return type:

PendingRegistration | None

async find_by_email(email)[source]
Parameters:

email (str)

Return type:

PendingRegistration | None

async delete_by_email(email)[source]
Parameters:

email (str)

Return type:

None

async purge_expired(now=None)[source]

Sweep expired rows. MongoDB has a TTL index; SQL backends rely on a periodic call to this method.

Parameters:

now (datetime | None)

Return type:

int

async count_unexpired(now=None)[source]

Count pending-registration rows whose expires_at is in the future.

“Unexpired” rather than a raw row-count because SQL backends accumulate dead rows until purge_expired runs — a raw count would double-report a verification email that’s been unanswered for a month and a fresh one sent today.

Parameters:

now (datetime | None) – Reference instant. Defaults to datetime.now(UTC).

Return type:

int

Returns:

Number of pending rows with expires_at > now.

class regstack.backends.protocols.BlacklistRepoProtocol(*args, **kwargs)[source]

Bases: Protocol

async revoke(jti, exp)[source]
Parameters:
Return type:

None

async is_revoked(jti)[source]
Parameters:

jti (str)

Return type:

bool

async purge_expired(now=None)[source]
Parameters:

now (datetime | None)

Return type:

int

class regstack.backends.protocols.LoginAttemptRepoProtocol(*args, **kwargs)[source]

Bases: Protocol

async record_failure(email, *, when=None, ip=None)[source]
Parameters:
Return type:

None

async count_recent(email, *, window, now)[source]
Parameters:
Return type:

int

async clear(email)[source]
Parameters:

email (str)

Return type:

None

async purge_expired(now, window)[source]
Parameters:
Return type:

int

class regstack.backends.protocols.MfaCodeRepoProtocol(*args, **kwargs)[source]

Bases: Protocol

async put(code)[source]
Parameters:

code (MfaCode)

Return type:

None

async verify(*, user_id, kind, raw_code)[source]
Parameters:
  • user_id (str)

  • kind (Literal['phone_setup', 'login_mfa'])

  • raw_code (str)

Return type:

MfaVerifyResult

async delete(*, user_id, kind=None)[source]
Parameters:
Return type:

None

async find(*, user_id, kind)[source]
Parameters:
  • user_id (str)

  • kind (Literal['phone_setup', 'login_mfa'])

Return type:

MfaCode | None

async purge_expired(now=None)[source]
Parameters:

now (datetime | None)

Return type:

int

class regstack.backends.protocols.MfaVerifyOutcome(value)[source]

Bases: StrEnum

Result of submitting an SMS MFA code to the repo.

The five possible values:

  • OK — the code matched and the row was consumed.

  • WRONG — the code didn’t match. attempts_remaining on the paired MfaVerifyResult says how many tries are left before the row is deleted (forcing a re-issue).

  • EXPIRED — a row exists but its TTL has passed.

  • LOCKED — too many wrong guesses; the row was deleted and the user must request a new code.

  • MISSING — no outstanding code for this user / kind.

OK = 'ok'
WRONG = 'wrong'
EXPIRED = 'expired'
LOCKED = 'locked'
MISSING = 'missing'
class regstack.backends.protocols.MfaVerifyResult(outcome, attempts_remaining=0)[source]

Bases: object

Outcome of MfaCodeRepoProtocol.verify().

Parameters:
outcome: MfaVerifyOutcome

Which terminal state the verify call landed in. See MfaVerifyOutcome.

attempts_remaining: int

For MfaVerifyOutcome.WRONG, how many more guesses the user has before the code is deleted and they must request a new one. 0 for any other outcome.

exception regstack.backends.protocols.UserAlreadyExistsError[source]

Bases: Exception

Raised when an insert / email-change collides with an existing user.

Backend-agnostic — every repo raises this same type on its integrity-error path so callers can branch on the type without importing a backend module. Surfaced by the registration and change-email routers as HTTP 409.

exception regstack.backends.protocols.PendingAlreadyExistsError[source]

Bases: Exception

A pending registration with this email already exists.

In practice the registration router uses an upsert so this exception is rarely raised — kept as the backend-agnostic name for the error so future callers don’t need to import a backend module.

Models

The persisted data shapes. BaseUser is the canonical user document; UserCreate validates registration input; UserPublic is what the API returns (omits the password hash). The other models drive verification, lockout, and SMS MFA.

class regstack.models.user.BaseUser(**data)[source]

Bases: BaseModel

Persisted user document.

The default field set covers what both winebox and putplace need today. Hosts add their own fields by subclassing or by registering an extension mixin via RegStack.extend_user_model.

Parameters:
id: Annotated[str, _IdValidator] | None
email: EmailStr
hashed_password: str | None

Argon2id hash of the user’s password, or None for users who only ever signed in via OAuth and never set a password. OAuth-only users can use the password-reset flow as a “set initial password” step.

is_active: bool
is_verified: bool
is_superuser: bool
full_name: str | None
phone_number: str | None
is_mfa_enabled: bool
created_at: datetime
updated_at: datetime
last_login: datetime | None
tokens_invalidated_after: datetime | None
property is_admin: bool

Alias kept for parity with putplace’s is_admin field name.

to_mongo()[source]
Return type:

dict[str, Any]

class regstack.models.user.UserCreate(**data)[source]

Bases: BaseModel

Parameters:
  • data (Any)

  • email (EmailStr)

  • password (Annotated[str, FieldInfo(annotation=NoneType, required=True, metadata=[MinLen(min_length=8), MaxLen(max_length=128)])])

  • full_name (str | None)

email: EmailStr
password: Annotated[str, FieldInfo(annotation=NoneType, required=True, metadata=[MinLen(min_length=8), MaxLen(max_length=128)])]
full_name: str | None
class regstack.models.user.UserPublic(**data)[source]

Bases: BaseModel

Safe-to-serialise projection of a user (no password hash).

The on-wire JSON contract uses id — the conventional HTTP API field name — even though the underlying Mongo document keys the user by _id. BaseUser keeps the alias (it round-trips to Mongo via to_mongo()), but UserPublic never touches Mongo so there’s no reason to leak the BSON convention into the API contract every host has to build its clients against.

Parameters:
id: str
email: EmailStr
is_active: bool
is_verified: bool
is_superuser: bool
full_name: str | None
phone_number: str | None
is_mfa_enabled: bool
created_at: datetime
updated_at: datetime
last_login: datetime | None
tokens_invalidated_after: datetime | None

Bulk-revoke cutoff. SPAs comparing this to their session JWT’s iat can tell when the token they hold has been invalidated by a password change / email change without making another request.

classmethod from_user(user)[source]
Parameters:

user (BaseUser)

Return type:

UserPublic

class regstack.models.pending_registration.PendingRegistration(**data)[source]

Bases: BaseModel

Pre-verification user record. Lives in pending_registrations until the user clicks the verification link (which moves them to users) or expires_at passes (TTL index reaps).

Only the SHA-256 hash of the verification token is stored — the raw token only exists in the email body and the user’s clipboard.

Parameters:
id: Annotated[str, _IdValidator] | None
email: EmailStr
hashed_password: str
full_name: str | None
token_hash: str
created_at: datetime
expires_at: datetime
to_mongo()[source]
Return type:

dict[str, Any]

class regstack.models.login_attempt.LoginAttempt(**data)[source]

Bases: BaseModel

One row per failed login attempt. TTL index on when reaps rows once they fall outside the lockout window.

Parameters:
id: Annotated[str, _IdValidator] | None
email: EmailStr
when: datetime
ip: str | None
to_mongo()[source]
Return type:

dict[str, Any]

class regstack.models.mfa_code.MfaCode(**data)[source]

Bases: BaseModel

One-time SMS code awaiting verification.

Only the SHA-256 hash of the code lives in the DB — a database read does not yield usable codes. Codes are unique per (user_id, kind) so re-issuing a code automatically invalidates the previous one.

Parameters:
id: Annotated[str, _IdValidator] | None
user_id: str
kind: Literal['phone_setup', 'login_mfa']
code_hash: str
expires_at: datetime
attempts: int
max_attempts: int
created_at: datetime
to_mongo()[source]
Return type:

dict[str, Any]

Email + SMS

Pluggable transports for the verification / reset / change-email emails and the SMS MFA codes. Implement EmailService or SmsService to plug in a provider that isn’t bundled (Postmark, SendGrid, MessageBird, …) and pass the instance to regstack.set_email_backend / set_sms_backend.

Email

class regstack.email.base.EmailMessage(to, subject, html, text, from_address, from_name)[source]

Bases: object

A rendered, multipart email ready to hand to an EmailService.

Always carries both an html and a text body — the SMTP and SES backends emit a multipart/alternative message. The from_* fields are pre-resolved at composition time (from EmailConfig), so a backend doesn’t have to know the host’s branding settings.

Parameters:
to: str

Recipient email address.

subject: str

The Subject: header.

html: str

The HTML body.

text: str

The plaintext body for clients that don’t render HTML.

from_address: str

The bare user@host address.

from_name: str

The display name shown to the recipient.

property from_header: str

The composed "Name <user@host>" From: header.

class regstack.email.base.EmailService[source]

Bases: ABC

Pluggable transport for sending an EmailMessage.

Bundled implementations:

  • ConsoleEmailService — prints to stdout, used in dev and tests.

  • SMTP (aiosmtplib-backed) — for any SMTP relay.

  • Amazon SES (aioboto3) — needs the ses extra.

To plug in a different provider (Postmark, SendGrid, MessageBird, …) implement send() and pass the instance to RegStack.set_email_backend.

abstractmethod async send(message)[source]

Deliver one email. Implementations should not retry — the caller decides whether a transient failure is fatal.

Parameters:

message (EmailMessage) – The pre-rendered message to deliver.

Raises:

Exception – Implementations may raise transport-specific errors. The caller (typically a router endpoint) is responsible for translating them into HTTP responses.

Return type:

None

class regstack.email.console.ConsoleEmailService(*, log_bodies=False)[source]

Bases: EmailService

Logs the email payload instead of sending it. Used in dev and tests.

Captured messages are also kept in self.outbox so tests can assert on rendered content without scraping logs.

When log_bodies=True the rendered text body is logged at INFO so operators (and regstack validate) can scrape one-time tokens from stdout without enabling DEBUG globally. Default is False — bodies stay at DEBUG so logs aren’t noisy by default.

Parameters:

log_bodies (bool)

async send(message)[source]

Deliver one email. Implementations should not retry — the caller decides whether a transient failure is fatal.

Parameters:

message (EmailMessage) – The pre-rendered message to deliver.

Raises:

Exception – Implementations may raise transport-specific errors. The caller (typically a router endpoint) is responsible for translating them into HTTP responses.

Return type:

None

class regstack.email.composer.MailComposer(*, email_config, app_name, host_template_dirs=None)[source]

Bases: object

Builds rendered EmailMessage instances from Jinja2 templates.

Hosts override the default templates by registering their own template directory via RegStack.add_template_dir; the underlying ChoiceLoader resolves the host directory first.

Each email kind has three template files:

<name>.subject.txt — single line, whitespace-stripped <name>.html — rich body <name>.txt — plain-text fallback

Parameters:
add_template_dir(path)[source]
Parameters:

path (Path)

Return type:

None

verification(*, to, full_name, url, ttl_hours)[source]
Parameters:
Return type:

EmailMessage

password_reset(*, to, full_name, url, ttl_minutes)[source]
Parameters:
Return type:

EmailMessage

email_change(*, to, full_name, url, ttl_minutes)[source]
Parameters:
Return type:

EmailMessage

sms_body(*, kind, **context)[source]
Parameters:
Return type:

str

regstack.email.factory.build_email_service(config)[source]
Parameters:

config (EmailConfig)

Return type:

EmailService

SMS

class regstack.sms.base.SmsMessage(to, body, from_number=None)[source]

Bases: object

A rendered SMS ready to hand to an SmsService.

Parameters:
to: str

Recipient phone number in E.164 format.

body: str

The SMS body. Implementations are not required to enforce the 160-character GSM-7 limit; long messages may be split by the upstream provider.

from_number: str | None

Sender phone number in E.164. None lets the backend fall back to a configured default (e.g. config.sms.from_number).

class regstack.sms.base.SmsService[source]

Bases: ABC

Pluggable transport for sending an SmsMessage.

Bundled implementations:

  • NullSmsService — discards messages, the default when SMS 2FA is off.

  • Amazon SNS (aioboto3) — needs the sns extra.

  • Twilio — needs the twilio extra.

To plug in a different provider implement send() and pass the instance to RegStack.set_sms_backend.

abstractmethod async send(message)[source]

Deliver one SMS. No retries; caller decides on failure.

Parameters:

message (SmsMessage) – The pre-rendered message.

Return type:

None

regstack.sms.base.is_valid_e164(phone)[source]

Whether phone is a valid E.164 international phone number.

E.164 means a leading +, then a country code starting with a non-zero digit, then up to 15 total digits. Used to validate user-supplied phone numbers before storing them or handing them to an SMS provider.

Parameters:

phone (str) – A candidate phone number string (e.g. +15551234567).

Return type:

bool

Returns:

True if phone matches the E.164 grammar.

class regstack.sms.null.NullSmsService(*, log_bodies=False)[source]

Bases: SmsService

Default backend. Records messages in self.outbox so tests and dev runs can inspect them without contacting a real SMS gateway. Logs each send at INFO.

log_bodies (default False) controls whether the message body (containing the 6-digit code) is included in the log line. It defaults off so a misconfigured deployment can’t leak codes into shared logs; flip it on for local dev when you want to read the code out of stdout.

Parameters:

log_bodies (bool)

async send(message)[source]

Deliver one SMS. No retries; caller decides on failure.

Parameters:

message (SmsMessage) – The pre-rendered message.

Return type:

None

regstack.sms.factory.build_sms_service(config)[source]
Parameters:

config (SmsConfig)

Return type:

SmsService

OAuth

Opt-in subsystem behind enable_oauth and the oauth extra. v1 ships Google; the abstraction is shaped so adding GitHub / Microsoft / Apple later is a new module under regstack.oauth.providers plus a registry entry. The full host guide is in OAuth; the threat model is in Security model.

Provider abstraction

class regstack.oauth.base.OAuthProvider[source]

Bases: ABC

Abstract base class for OAuth / OIDC providers.

Subclasses must implement the three methods of the Authorization Code with PKCE flow. They’re free to make synchronous network calls inside async methods (the dance involves a single POST to the token endpoint) but should not introduce any long-running background work.

Subclasses are stateless once constructed — repeat calls don’t accumulate state — so a single instance per provider per RegStack is the intended lifetime.

abstract property name: str

The lookup key for this provider in OAuthRegistry. Lowercase ASCII — used in URL paths (/oauth/{name}/start) and in the oauth_identities.provider column.

abstractmethod authorization_url(*, redirect_uri, state, code_challenge, nonce)[source]

Build the URL the browser should be redirected to.

Parameters:
  • redirect_uri (str) – The full callback URL where the provider will return the user. Must be registered with the provider out-of-band (in Google Cloud console, etc.).

  • state (str) – Opaque CSRF token. Provider returns this verbatim in the callback. regstack uses it as the lookup key for the in-flight OAuthState row.

  • code_challenge (str) – Base64url-encoded SHA-256 of the code_verifier (PKCE). The verifier itself stays server-side.

  • nonce (str) – Random string included in the auth request and returned in the ID token. Defends against ID token replay across separate authentication ceremonies.

Return type:

str

Returns:

The URL to redirect the browser to.

abstractmethod async exchange_code(*, code, redirect_uri, code_verifier)[source]

Trade an authorization code for tokens.

Parameters:
  • code (str) – The code parameter the provider sent to the callback.

  • redirect_uri (str) – Must exactly match the redirect_uri used in authorization_url() — the provider refuses the exchange otherwise.

  • code_verifier (str) – The PKCE pre-image whose SHA-256 was sent as code_challenge originally. Read from the server-side state row.

Return type:

OAuthTokens

Returns:

OAuthTokens with at least id_token and access_token.

Raises:

OAuthTokenExchangeError – On any non-200 response from the provider’s token endpoint.

abstractmethod async verify_id_token(id_token, *, expected_nonce)[source]

Verify the ID token’s signature and claims.

Concretely this checks: signature against the provider’s JWKS, iss matches the provider’s issuer, aud matches the configured client_id, exp is in the future, nonce matches expected_nonce.

Parameters:
  • id_token (str) – The ID token from OAuthTokens.

  • expected_nonce (str) – The nonce the auth request was made with, stored on the state row.

Return type:

OAuthUserInfo

Returns:

OAuthUserInfo distilled from the provider’s native claim shape.

Raises:

OAuthIdTokenError – If any check fails. The exception message is suitable for logging; do NOT echo it to the end user — it could leak which check failed and help an attacker craft a better forgery.

class regstack.oauth.base.OAuthTokens(access_token, id_token, refresh_token=None)[source]

Bases: object

Tokens returned by a provider’s token-exchange endpoint.

Parameters:
access_token: str

Bearer token usable against the provider’s APIs. regstack does not store or use this for anything other than the (rare) call to a separate userinfo endpoint. Most providers (including Google) put everything we need in the ID token.

id_token: str

Signed JWT carrying the user’s identity claims. This is the trust anchor — its signature is verified against the provider’s JWKS.

refresh_token: str | None

Optional refresh token. regstack does NOT use refresh tokens for anything in v1; if set, it is discarded.

class regstack.oauth.base.OAuthUserInfo(subject_id, email, email_verified, full_name, picture_url)[source]

Bases: object

Canonical, provider-agnostic user information.

Each provider’s OAuthProvider.verify_id_token() produces one of these from its native claim shape. Routers downstream see only this — they don’t care whether it came from Google’s sub or GitHub’s id.

Parameters:
subject_id: str

the sub claim. NEVER an email address — emails can change, subjects don’t.

Type:

The provider’s stable, opaque user identifier. For Google

email: str | None

Optional email claim. May be None for providers that don’t always return one (some GitHub configurations).

email_verified: bool

Whether the provider considers the email verified. Auto-linking only happens when this is True and email is non-None.

full_name: str | None

Display name. Optional.

picture_url: str | None

Avatar URL. Optional.

class regstack.oauth.registry.OAuthRegistry[source]

Bases: object

Name → OAuthProvider lookup, scoped to one RegStack instance.

Empty by default. The RegStack constructor will populate it from OAuthConfig when enable_oauth is on (M3).

register(provider)[source]

Register a provider. Replaces any existing provider with the same name.

Parameters:

provider (OAuthProvider) – An OAuthProvider implementation. The lookup key is provider.name.

Return type:

None

get(name)[source]

Look up a provider by name.

Parameters:

name (str) – The provider’s OAuthProvider.name (e.g. "google").

Return type:

OAuthProvider

Returns:

The registered provider.

Raises:

OAuthConfigError – If no provider is registered under that name.

names()[source]

Sorted list of registered provider names. Useful for diagnostics (and for the SSR login template, which wants to know which “Sign in with X” buttons to render).

Return type:

list[str]

exception regstack.oauth.errors.OAuthError[source]

Bases: Exception

Base class for every OAuth-layer failure.

exception regstack.oauth.errors.OAuthConfigError[source]

Bases: OAuthError

The OAuth subsystem isn’t configured for the requested provider.

Raised when a router endpoint is hit for a provider whose client_id / client_secret aren’t set, or when OAuthRegistry is asked for a provider name that isn’t registered.

exception regstack.oauth.errors.OAuthTokenExchangeError[source]

Bases: OAuthError

The provider’s token endpoint refused our authorization code.

Concretely: a non-200 response from https://oauth2.googleapis.com/token (or equivalent). The exception message carries the provider’s error body for logs; callers should NOT surface the raw message to end users.

exception regstack.oauth.errors.OAuthIdTokenError[source]

Bases: OAuthError

The ID token failed verification.

Catch-all for: bad signature, wrong issuer, wrong audience, expired, nonce mismatch, missing required claim. Routers translate this to HTTP 400 without echoing the reason.

Google provider

Identity + state storage

class regstack.models.oauth_identity.OAuthIdentity(**data)[source]

Bases: BaseModel

A user’s link to one external OAuth provider.

Parameters:
id: Annotated[str, _IdValidator] | None
user_id: Annotated[str, _IdValidator]

The regstack user this identity belongs to.

provider: str

Provider name — matches OAuthProvider.name (e.g. "google").

subject_id: str

The provider’s stable, opaque user identifier. Never an email.

email: str | None

Snapshot of the provider’s email at link time. Non-authoritative — the provider may change it. We never key on this field.

linked_at: datetime
last_used_at: datetime | None
to_mongo()[source]
Return type:

dict[str, Any]

class regstack.models.oauth_state.OAuthState(**data)[source]

Bases: BaseModel

One row per in-flight OAuth flow.

Parameters:
id: Annotated[str, _IdValidator]

The state-parameter value the browser carries through Google. Always supplied by the caller; this is the lookup key.

provider: str

Provider name — matches OAuthProvider.name.

code_verifier: str

PKCE pre-image. Never leaves the server.

nonce: str

Random string echoed in the ID token. Defends against ID-token replay across separate authentication ceremonies.

redirect_to: str

Where the SPA should land after the OAuth flow completes. Validated against config.base_url at /start time so the callback can’t be coerced into an open redirect.

mode: Literal['signin', 'link']

"signin" for unauthenticated start, "link" for adding a provider to a logged-in account.

linking_user_id: Annotated[str, _IdValidator] | None

Set on mode = "link" flows. The user the new identity gets attached to. Captured at /start time from the bearer token, so the callback (which has no auth header) doesn’t have to re-authenticate.

created_at: datetime
expires_at: datetime

TTL boundary. Mongo’s expireAfterSeconds reaps rows; SQL backends rely on read-side expires_at > now() plus the optional purge_expired reaper.

result_token: str | None

Populated on a successful callback. The SPA exchanges its id for this value via POST /oauth/exchange; the row is deleted on exchange.

to_mongo()[source]
Return type:

dict[str, Any]

class regstack.backends.protocols.OAuthIdentityRepoProtocol(*args, **kwargs)[source]

Bases: Protocol

External-OAuth identities linked to regstack users.

One row per (provider, subject_id). Two unique constraints — see OAuthIdentity for the rationale.

async create(identity)[source]

Insert a new identity. Raises OAuthIdentityAlreadyLinkedError on either unique-constraint violation.

Parameters:

identity (OAuthIdentity)

Return type:

OAuthIdentity

async find_by_subject(*, provider, subject_id)[source]
Parameters:
  • provider (str)

  • subject_id (str)

Return type:

OAuthIdentity | None

async list_for_user(user_id)[source]

Every identity linked to user_id, sorted by linked_at ascending.

Parameters:

user_id (str)

Return type:

list[OAuthIdentity]

async delete(*, user_id, provider)[source]

Delete one identity. Returns True if a row was removed.

Parameters:
  • user_id (str)

  • provider (str)

Return type:

bool

async delete_by_user_id(user_id)[source]

Delete every identity for a user. Called from the delete-account path so identities don’t outlive their owner.

Parameters:

user_id (str)

Return type:

int

async touch_last_used(*, provider, subject_id, when)[source]

Update last_used_at. Called on each successful sign-in through this identity. Best-effort — failure is logged, not raised.

Parameters:
Return type:

None

class regstack.backends.protocols.OAuthStateRepoProtocol(*args, **kwargs)[source]

Bases: Protocol

Server-side state rows for in-flight OAuth flows.

The OAuth state parameter the browser carries is just the row’s id. The PKCE code_verifier and the post-callback result_token are server-side fields on the row.

async create(state)[source]

Insert. Caller picks the row id (usually secrets.token_urlsafe()).

Parameters:

state (OAuthState)

Return type:

None

async find(state_id)[source]
Parameters:

state_id (str)

Return type:

OAuthState | None

async set_result_token(state_id, token, *, new_expires_at=None)[source]

Stash the session JWT after a successful callback so the SPA can pick it up via consume().

If new_expires_at is given, the row’s expires_at is bumped to that timestamp at the same time — this is how callers shorten the redemption window from oauth.state_ttl_seconds (covering the round-trip with the provider) to oauth.completion_ttl_seconds (covering only the SPA’s exchange call after the callback lands).

Parameters:
Return type:

None

async consume(state_id)[source]

Atomic read + delete. The exchange endpoint reads the result_token; the row is gone after this call returns, making the exchange single-use.

Returns None if the row is missing.

Parameters:

state_id (str)

Return type:

OAuthState | None

async purge_expired(now=None)[source]

Sweep expired rows. Mongo has a TTL index; SQL relies on a periodic call to this.

Parameters:

now (datetime | None)

Return type:

int

exception regstack.backends.protocols.OAuthIdentityAlreadyLinkedError[source]

Bases: Exception

An identity is already linked to a regstack user.

Raised by OAuthIdentityRepoProtocol.create() when the UNIQUE(provider, subject_id) or UNIQUE(user_id, provider) constraint fires. Routers translate this to HTTP 409.

Router

regstack.routers.oauth.build_oauth_router(rs)[source]

Build the OAuth router. Captures rs in closures so two RegStack instances in one process don’t share state.

Parameters:

rs (RegStack)

Return type:

APIRouter

Hooks

The event bus regstack uses to fire side-effect notifications (user_registered, password_changed, etc.) without coupling auth code to host concerns like CRMs or analytics. See the Embedding guide for examples.

class regstack.hooks.events.HookRegistry[source]

Bases: object

Tiny in-process event bus for auth-flow side-effects.

Hosts subscribe handlers (sync or async) to event names; regstack fires events at the natural points in each flow (registration, login, password change, etc.). Handlers run concurrently.

Exceptions raised by a handler are logged and swallowed so a misbehaving notification handler cannot break the primary auth flow. If you need a hard dependency on a side-effect succeeding, do that work synchronously in a wrapper around the regstack call — not in a hook.

See KNOWN_EVENTS for the events regstack itself fires; hosts may subscribe to custom event names too.

on(event, handler)[source]

Subscribe a handler to an event.

Multiple handlers per event are allowed and are fired concurrently. Handlers can be sync or async; an async handler is awaited (in parallel with its siblings) inside fire().

Parameters:
  • event (str) – The event name (e.g. "user_registered"). Not validated against KNOWN_EVENTS — hosts can use custom names.

  • handler (Callable[..., Awaitable[None] | None]) – A callable invoked with the event’s keyword arguments. Returning a value is fine; it’s ignored.

Return type:

None

async fire(event, /, **kwargs)[source]

Run every handler subscribed to event.

Sync handlers run inline (in registration order). Async handlers are awaited concurrently via asyncio.gather. Exceptions in either kind are logged and discarded — they never propagate.

Parameters:
  • event (str) – The event name to dispatch.

  • **kwargs (Any) – Keyword arguments forwarded to every handler. regstack passes a contextually-relevant set per event (e.g. user for "user_registered").

Return type:

None

regstack.hooks.events.KNOWN_EVENTS = {'email_change_requested', 'email_changed', 'mfa_disabled', 'mfa_enabled', 'mfa_login_started', 'oauth_account_linked', 'oauth_account_unlinked', 'oauth_signin_completed', 'oauth_signin_started', 'password_changed', 'password_reset_completed', 'password_reset_requested', 'phone_setup_disabled', 'phone_setup_started', 'user_deleted', 'user_logged_in', 'user_logged_out', 'user_registered', 'user_verified', 'verification_requested'}

set() -> new empty set object set(iterable) -> new set object

Build an unordered collection of unique elements.

Routers

Hosts normally access these via the regstack.router and regstack.ui_router properties; the builder functions are public for hosts that want to compose differently.

regstack.routers.build_router(rs)[source]

Build the composite JSON router for one RegStack instance.

Always includes register, verify, login, logout, and account. Conditionally adds:

  • password (forgot/reset) when config.enable_password_reset.

  • phone and the MFA confirm route when config.enable_sms_2fa.

  • admin when config.enable_admin_router.

  • oauth when config.enable_oauth AND at least one provider is registered on rs.oauth.

Hosts normally don’t call this directly; access regstack.router instead, which calls it lazily.

Parameters:

rs (RegStack) – The owning RegStack instance — its config drives which sub-routers are mounted, and its collaborators are captured in the endpoint closures.

Return type:

APIRouter

Returns:

A FastAPI APIRouter ready for app.include_router(..., prefix=config.api_prefix).

regstack.ui.pages.build_ui_router(rs)[source]

Build the SSR APIRouter for the bundled HTML pages.

Mounts a GET endpoint for each of PAGE_NAMES (login, register, verify, forgot, reset, me, confirm-email-change, mfa-confirm).

Pages are stateless — they render the same HTML regardless of auth state. The bundled regstack.js reads the API and UI prefixes from <body data-rs-api data-rs-ui>, drives form submissions via fetch, and stores the access token in localStorage. No cookie session is established here, so this router is safe to mount alongside the JSON API without CSRF middleware.

Parameters:

rs (RegStack) – The owning RegStack instance — its config drives the page context (brand, prefixes, theme URL) and its template environment is reused.

Return type:

APIRouter

Returns:

A FastAPI APIRouter ready for app.include_router(..., prefix=config.ui_prefix).

regstack.ui.pages.build_ui_environment(host_template_dirs=None)[source]

Construct the Jinja2 environment used by the SSR pages.

Wraps a jinja2.ChoiceLoader so that host directories are searched first, falling back to the bundled templates from the regstack package. A host can override auth/login.html (or any other bundled template) by dropping a same-named file into one of the supplied directories.

Parameters:

host_template_dirs (list[Path] | None) – Optional list of host template directories to prepend. Order matters — earlier entries win on collisions.

Return type:

Environment

Returns:

A configured jinja2.Environment with autoescape on for HTML.

regstack.ui.pages.default_static_dir()[source]

Return the filesystem path to the bundled static assets.

Resolves regstack/ui/static/ from the installed package via importlib.resources, so it works whether regstack is in an editable install or a wheel. Used by the StaticFiles factory on RegStack.

Return type:

Path

Returns:

Filesystem path containing css/core.css, css/theme.css, and js/regstack.js.