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:
RegStack— the embeddable façade.RegStackConfig— top-level config.EmailConfig— email-backend sub-config.SmsConfig— SMS-backend sub-config.OAuthConfig— OAuth provider sub-config.
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:
objectEmbeddable account-management façade.
One
RegStackis 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
Backendselected byconfig.database_url’s URL scheme:mongodb:///mongodb+srv://→ MongoDBsqlite+aiosqlite://→ SQLitepostgresql+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 loadedRegStackConfig.clock— the injectedClock(SystemClockin production,FrozenClockin tests).backend— the active backend (Mongo / SQLite / Postgres).users,pending,blacklist,attempts,mfa_codes— repositories conforming to the protocols inregstack.backends.protocols.password_hasher— Argon2id wrapper.jwt—JwtCodecfor session tokens.lockout—LockoutService.email,sms— the active transports.mail— theMailComposer.hooks— theHookRegistryevent bus.deps—AuthDependenciesfactory.
- Parameters:
config (
RegStackConfig)email_service (
EmailService|None)mail_composer (
MailComposer|None)sms_service (
SmsService|None)
- property router: APIRouter¶
The composite JSON
APIRouter.Mount with
app.include_router(regstack.router, prefix=config.api_prefix). Includesregister,verify,login,logout,accountalways; conditionally addspassword(forgot/reset),phone+ MFA, andadminbased onconfig.enable_*flags.Built lazily on first access. If any
RegStackConfig.*_rate_limitis set, aslowapi.Limiteris also required — either via therate_limiter=constructor argument or auto-built from therate_limitextra. Each listed route is then decorated withlimiter.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
APIRouterfor the bundled HTML pages.Mount with
app.include_router(regstack.ui_router, prefix=config.ui_prefix). Only meaningful whenconfig.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
StaticFilesapp.Mount with
app.mount(config.static_prefix, regstack.static_files). Servescore.css, the defaulttheme.css, andregstack.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:
- 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:
- 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=Trueif they weren’t already (their password is not changed). Otherwise a new active, verified, superuser account is created with the given password.- Parameters:
- Return type:
- 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-verificationisn’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_passwordandfull_namecarry over verbatim — the user logs in with the password they originally registered with. The pending row is deleted on success. Fires theuser_verifiedhook so analytics / downstream listeners see the same event the email-drivenPOST /verifyproduces.- Parameters:
email (
str) – The email address whose pending registration should be promoted.- Return type:
- 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
EmailServicefor the contract.- Parameters:
service (
EmailService) – AnEmailServiceimplementation.- Return type:
- set_sms_backend(service)[source]¶
Replace the active SMS backend at runtime.
- Parameters:
service (
SmsService) – ASmsServiceimplementation.- Return type:
- add_template_dir(path)[source]¶
Prepend a host template directory to the override chain.
Host templates win over regstack defaults via Jinja2’s
ChoiceLoaderfor both the email composer and the SSR UI pages. To override the verification email, drop averification.htmlfile in the directory; to override the login page, dropauth/login.html.
- 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. SeeKNOWN_EVENTSfor the set of events regstack itself fires.
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:
BaseSettingsTop-level configuration for an embedded regstack instance.
- Loading order (highest priority first):
Programmatic kwargs.
Environment variables (
REGSTACK_*, nested via__).TOML file at
$REGSTACK_CONFIGor./regstack.toml.Field defaults defined here.
- Parameters:
_env_prefix_target (
Optional[Literal['variable','alias','all']])_cli_settings_source (
Optional[CliSettingsSource[Any]])_cli_implicit_flags (
Union[bool,Literal['dual','toggle'],None])_cli_kebab_case (
Union[bool,Literal['all','no_enums'],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)
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)
- base_url: AnyHttpUrl¶
- database_url: SecretStr¶
- jwt_secret: SecretStr¶
- jwt_algorithm: JwtAlgorithm¶
- jwt_ttl_seconds: Annotated[int, Field(ge=60, le=60 * 60 * 24 * 30)]¶
- transport: TokenTransport¶
- sms_code_ttl_seconds: Annotated[int, Field(ge=30, le=60 * 30)]¶
- mfa_pending_token_ttl_seconds: Annotated[int, Field(ge=60, le=60 * 30)]¶
- email: EmailConfig¶
- oauth: OAuthConfig¶
- resolve_email_link_prefix()[source]¶
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_prefixfield for the semantics.- Return type:
- 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)
- from_name: str | None¶
Display name on the
From:header.None(the default) defers toRegStackConfig.app_name, so changing onlyapp_nameis enough to brand the outgoing emails. Set explicitly to override — e.g.from_name = "Acme Customer Service"whenapp_nameis the internal product code and shouldn’t appear to end-users.
- 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
consolebackend in a dev/staging deployment that you intend to probe withregstack validate. Other backends ignore this flag — they don’t log bodies at all.
- class regstack.config.schema.SmsConfig(**data)[source]¶
Bases:
BaseModel- Parameters:
- log_bodies: bool¶
When True, the
nullbackend 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 withemail.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 anmfa_login_startedhook, 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:
BaseModelOAuth provider configuration.
The router is mounted only when
enable_oauth=TrueAND at least one provider has its credentials set (currently just Google).- Parameters:
- 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_verified_emails: bool¶
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.
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:
objectArgon2id password hashing facade.
Thin wrapper over
pwdlibso the algorithm choice (and any future algorithm rotation) lives behind one interface. Callers don’t importpwdlibdirectly.A single instance is held on the
RegStackfaçade asregstack.password_hasher; constructing your own is rarely necessary.
- class regstack.auth.jwt.JwtCodec(config, clock)[source]¶
Bases:
objectEncode and decode regstack’s signed JWTs.
Each purpose (
session,password_reset,email_change,phone_setup,login_mfa) signs with a separate key derived fromconfig.jwt_secretvia 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 injectedClock, not the system wall clock — that’s the seamFrozenClock-driven tests rely on.- Parameters:
config (
RegStackConfig)clock (
Clock)
- encode(subject, *, purpose='session', ttl_seconds=None)[source]¶
Sign and return a JWT plus its decoded payload.
- Parameters:
subject (
str) – Thesubclaim — usually a user id.purpose (
str) – Logical token kind (sessionby 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. WhenNone,config.jwt_ttl_secondsis used.
- Return type:
- Returns:
A
(token, payload)tuple.tokenis the encoded string the caller hands to the client;payloadis the in-memoryTokenPayload(useful when the caller also needs to record thejtifor 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.expis checked against the injectedClock, nottime.time(), so frozen-clock tests stay deterministic. Thepurposeclaim must match the expected purpose — a session token cannot satisfydecode(..., purpose="password_reset").- Parameters:
- Return type:
- 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:
objectDecoded JWT claims for a regstack-issued token.
Returned by
JwtCodec.decode()and produced as a side-effect ofJwtCodec.encode(). Carries everything callers need to enforce revocation and audit trails without re-decoding the raw token.- iat: datetime¶
When the token was issued. Tz-aware. Stored as a float (RFC 7519 NumericDate) so the bulk-revoke comparison
iat <= cutoffis precise even when a login completes microseconds after a password change.
- exception regstack.auth.jwt.TokenError[source]¶
Bases:
ExceptionRaised 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 atokens_invalidated_aftertimestamp; any token withiat <= cutoffis rejected.The comparison is
<=(not<) so a token issued at exactly the cutoff instant is treated as before-the-change for security. Float-precisioniat(RFC 7519 NumericDate) means a login completing microseconds after a password change hasiat > cutoffand survives.- Parameters:
payload (
TokenPayload) – The decodedTokenPayload.cutoff (
datetime|None) – The user’stokens_invalidated_aftervalue, orNoneif they’ve never been bulk-revoked.
- Return type:
- Returns:
Trueif the token must be rejected;Falseif it’s still valid (subject to the per-token blacklist check).
- class regstack.auth.lockout.LockoutService(*, attempts, config, clock)[source]¶
Bases:
objectPer-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 exceedsconfig.login_lockout_threshold, every login attempt for that email is rejected with HTTP 429 (and aRetry-Afterheader) 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=Falseand never writes failures) whenconfig.rate_limit_disabledis set. Tests rely on this to avoid timing flakes.- Parameters:
attempts (
LoginAttemptRepoProtocol)config (
RegStackConfig)clock (
Clock)
- async check(email)[source]¶
Decide whether
emailis 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:
- Returns:
A
LockoutDecision. WhenlockedisTruethe caller should respond with HTTP 429 and theRetry-Afterheader.
- 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
Nonewhen rate-limiting is disabled (tests) — the route should then suppress the line entirely.
- async record_failure(email, *, ip=None)[source]¶
Record one failed login. No-op when rate limiting is disabled.
- class regstack.auth.lockout.LockoutDecision(locked, retry_after_seconds)[source]¶
Bases:
objectResult of a
LockoutService.check()call.
- class regstack.auth.dependencies.AuthDependencies(*, jwt, users, blacklist)[source]¶
Bases:
objectFactory 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()andcurrent_admin()as FastAPIDepends(...)arguments:from fastapi import Depends @app.get("/me/orders") async def list_orders(user = Depends(regstack.deps.current_user())): ...
- Parameters:
jwt (
JwtCodec)users (
UserRepoProtocol)blacklist (
BlacklistRepoProtocol)
- 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 theBaseUseris stashed onrequest.state.regstack_userfor downstream middleware.- Return type:
- Returns:
A callable suitable for
Depends(...). Raisesfastapi.HTTPException401 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
Nonerather than raising 401.On success the user is stashed on
request.state.regstack_user(same ascurrent_user()), so downstream middleware sees the same shape for either path.
- 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 haveis_superuser=True.
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:
ProtocolSource 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 passesSystemClock; tests passFrozenClockso a single test can deterministically assert “this token expires in exactly 7200 seconds” without sleeping.
- class regstack.auth.clock.SystemClock[source]¶
Bases:
objectProduction
Clock— wrapsdatetime.now(UTC).
- class regstack.auth.clock.FrozenClock(start=None)[source]¶
Bases:
objectTest
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)
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:
ABCA configured persistence backend for one regstack instance.
- Parameters:
config (
RegStackConfig)clock (
Clock)
- 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
lifespanstartup hook.- Return type:
- 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:
config (
RegStackConfig)
- Return type:
- class regstack.backends.sql.SqlBackend(*, config, clock, kind)[source]¶
Bases:
BackendSQLAlchemy 2 async backend. Same code path drives SQLite (via aiosqlite) and Postgres (via asyncpg) — only
database_urldiffers.- Parameters:
config (
RegStackConfig)clock (
Clock)kind (
BackendKind)
- 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
lifespanstartup on every boot.Hosts that need to drive migrations from CI / a deploy step instead of in-process can call
regstack migrate(CLI) orregstack.backends.sql.migrations.upgrade(database_url)(programmatic) and skipinstall_schema().- Return type:
- async ping()[source]¶
Cheap connectivity probe. Raises on failure. Used by regstack doctor.
- Return type:
- 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 update_email(user_id, new_email)[source]¶
Atomically swap email + bump tokens_invalidated_after.
Implementations MUST raise
UserAlreadyExistsErrorif 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.
- 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:
- async purge_expired(now=None)[source]¶
Sweep expired rows. MongoDB has a TTL index; SQL backends rely on a periodic call to this method.
- async count_unexpired(now=None)[source]¶
Count pending-registration rows whose
expires_atis in the future.“Unexpired” rather than a raw row-count because SQL backends accumulate dead rows until
purge_expiredruns — a raw count would double-report a verification email that’s been unanswered for a month and a fresh one sent today.
- class regstack.backends.protocols.LoginAttemptRepoProtocol(*args, **kwargs)[source]¶
Bases:
Protocol
- class regstack.backends.protocols.MfaCodeRepoProtocol(*args, **kwargs)[source]¶
Bases:
Protocol
- class regstack.backends.protocols.MfaVerifyOutcome(value)[source]¶
Bases:
StrEnumResult 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_remainingon the pairedMfaVerifyResultsays 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:
objectOutcome of
MfaCodeRepoProtocol.verify().- Parameters:
outcome (
MfaVerifyOutcome)attempts_remaining (
int)
- 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.0for any other outcome.
- exception regstack.backends.protocols.UserAlreadyExistsError[source]¶
Bases:
ExceptionRaised 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:
ExceptionA 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:
BaseModelPersisted 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:
data (
Any)email (EmailStr)
hashed_password (str | None)
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)
extra_data (Any)
- class regstack.models.user.UserCreate(**data)[source]¶
Bases:
BaseModel- Parameters:
- class regstack.models.user.UserPublic(**data)[source]¶
Bases:
BaseModelSafe-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.BaseUserkeeps the alias (it round-trips to Mongo viato_mongo()), butUserPublicnever 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:
- 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.
- class regstack.models.pending_registration.PendingRegistration(**data)[source]¶
Bases:
BaseModelPre-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:
- class regstack.models.login_attempt.LoginAttempt(**data)[source]¶
Bases:
BaseModelOne row per failed login attempt. TTL index on
whenreaps rows once they fall outside the lockout window.- Parameters:
- class regstack.models.mfa_code.MfaCode(**data)[source]¶
Bases:
BaseModelOne-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:
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:
objectA rendered, multipart email ready to hand to an
EmailService.Always carries both an
htmland atextbody — the SMTP and SES backends emit amultipart/alternativemessage. Thefrom_*fields are pre-resolved at composition time (fromEmailConfig), so a backend doesn’t have to know the host’s branding settings.
- class regstack.email.base.EmailService[source]¶
Bases:
ABCPluggable 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 thesesextra.
To plug in a different provider (Postmark, SendGrid, MessageBird, …) implement
send()and pass the instance toRegStack.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:
- class regstack.email.console.ConsoleEmailService(*, log_bodies=False)[source]¶
Bases:
EmailServiceLogs the email payload instead of sending it. Used in dev and tests.
Captured messages are also kept in
self.outboxso tests can assert on rendered content without scraping logs.When
log_bodies=Truethe rendered text body is logged at INFO so operators (andregstack 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:
- class regstack.email.composer.MailComposer(*, email_config, app_name, host_template_dirs=None)[source]¶
Bases:
objectBuilds rendered
EmailMessageinstances from Jinja2 templates.Hosts override the default templates by registering their own template directory via
RegStack.add_template_dir; the underlyingChoiceLoaderresolves 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:
email_config (
EmailConfig)app_name (
str)
- regstack.email.factory.build_email_service(config)[source]¶
- Parameters:
config (
EmailConfig)- Return type:
SMS¶
- class regstack.sms.base.SmsMessage(to, body, from_number=None)[source]¶
Bases:
objectA rendered SMS ready to hand to an
SmsService.
- class regstack.sms.base.SmsService[source]¶
Bases:
ABCPluggable transport for sending an
SmsMessage.Bundled implementations:
NullSmsService— discards messages, the default when SMS 2FA is off.Amazon SNS (
aioboto3) — needs thesnsextra.Twilio — needs the
twilioextra.
To plug in a different provider implement
send()and pass the instance toRegStack.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:
- regstack.sms.base.is_valid_e164(phone)[source]¶
Whether
phoneis 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.
- class regstack.sms.null.NullSmsService(*, log_bodies=False)[source]¶
Bases:
SmsServiceDefault backend. Records messages in
self.outboxso 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:
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:
ABCAbstract 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
asyncmethods (the dance involves a singlePOSTto 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
RegStackis 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 theoauth_identities.providercolumn.
- 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-flightOAuthStaterow.code_challenge (
str) – Base64url-encoded SHA-256 of thecode_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:
- 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) – Thecodeparameter the provider sent to the callback.redirect_uri (
str) – Must exactly match theredirect_uriused inauthorization_url()— the provider refuses the exchange otherwise.code_verifier (
str) – The PKCE pre-image whose SHA-256 was sent ascode_challengeoriginally. Read from the server-side state row.
- Return type:
- Returns:
OAuthTokenswith at leastid_tokenandaccess_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,
issmatches the provider’s issuer,audmatches the configured client_id,expis in the future,noncematchesexpected_nonce.- Parameters:
id_token (
str) – The ID token fromOAuthTokens.expected_nonce (
str) – The nonce the auth request was made with, stored on the state row.
- Return type:
- Returns:
OAuthUserInfodistilled 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:
objectTokens returned by a provider’s token-exchange endpoint.
- 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.
- class regstack.oauth.base.OAuthUserInfo(subject_id, email, email_verified, full_name, picture_url)[source]¶
Bases:
objectCanonical, 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’ssubor GitHub’sid.- Parameters:
- subject_id: str¶
the
subclaim. 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
Nonefor providers that don’t always return one (some GitHub configurations).
- class regstack.oauth.registry.OAuthRegistry[source]¶
Bases:
objectName →
OAuthProviderlookup, scoped to oneRegStackinstance.Empty by default. The
RegStackconstructor will populate it fromOAuthConfigwhenenable_oauthis on (M3).- register(provider)[source]¶
Register a provider. Replaces any existing provider with the same name.
- Parameters:
provider (
OAuthProvider) – AnOAuthProviderimplementation. The lookup key isprovider.name.- Return type:
- get(name)[source]¶
Look up a provider by name.
- Parameters:
name (
str) – The provider’sOAuthProvider.name(e.g."google").- Return type:
- Returns:
The registered provider.
- Raises:
OAuthConfigError – If no provider is registered under that name.
- exception regstack.oauth.errors.OAuthError[source]¶
Bases:
ExceptionBase class for every OAuth-layer failure.
- exception regstack.oauth.errors.OAuthConfigError[source]¶
Bases:
OAuthErrorThe OAuth subsystem isn’t configured for the requested provider.
Raised when a router endpoint is hit for a provider whose
client_id/client_secretaren’t set, or whenOAuthRegistryis asked for a provider name that isn’t registered.
- exception regstack.oauth.errors.OAuthTokenExchangeError[source]¶
Bases:
OAuthErrorThe 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:
OAuthErrorThe 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:
BaseModelA user’s link to one external OAuth provider.
- Parameters:
- class regstack.models.oauth_state.OAuthState(**data)[source]¶
Bases:
BaseModelOne 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.
- 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_urlat /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.
- expires_at: datetime¶
TTL boundary. Mongo’s
expireAfterSecondsreaps rows; SQL backends rely on read-sideexpires_at > now()plus the optionalpurge_expiredreaper.
- class regstack.backends.protocols.OAuthIdentityRepoProtocol(*args, **kwargs)[source]¶
Bases:
ProtocolExternal-OAuth identities linked to regstack users.
One row per
(provider, subject_id). Two unique constraints — seeOAuthIdentityfor the rationale.- async create(identity)[source]¶
Insert a new identity. Raises
OAuthIdentityAlreadyLinkedErroron either unique-constraint violation.- Parameters:
identity (
OAuthIdentity)- Return type:
- async list_for_user(user_id)[source]¶
Every identity linked to
user_id, sorted bylinked_atascending.- Parameters:
user_id (
str)- Return type:
- class regstack.backends.protocols.OAuthStateRepoProtocol(*args, **kwargs)[source]¶
Bases:
ProtocolServer-side state rows for in-flight OAuth flows.
The OAuth
stateparameter the browser carries is just the row’sid. The PKCEcode_verifierand the post-callbackresult_tokenare 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:
- 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_atis given, the row’sexpires_atis bumped to that timestamp at the same time — this is how callers shorten the redemption window fromoauth.state_ttl_seconds(covering the round-trip with the provider) tooauth.completion_ttl_seconds(covering only the SPA’s exchange call after the callback lands).
- exception regstack.backends.protocols.OAuthIdentityAlreadyLinkedError[source]¶
Bases:
ExceptionAn identity is already linked to a regstack user.
Raised by
OAuthIdentityRepoProtocol.create()when theUNIQUE(provider, subject_id)orUNIQUE(user_id, provider)constraint fires. Routers translate this to HTTP 409.
Router¶
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:
objectTiny 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_EVENTSfor 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().
- 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
RegStackinstance.Always includes
register,verify,login,logout, andaccount. Conditionally adds:password(forgot/reset) whenconfig.enable_password_reset.phoneand the MFA confirm route whenconfig.enable_sms_2fa.adminwhenconfig.enable_admin_router.oauthwhenconfig.enable_oauthAND at least one provider is registered onrs.oauth.
Hosts normally don’t call this directly; access
regstack.routerinstead, which calls it lazily.
- regstack.ui.pages.build_ui_router(rs)[source]¶
Build the SSR
APIRouterfor the bundled HTML pages.Mounts a
GETendpoint for each ofPAGE_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.jsreads the API and UI prefixes from<body data-rs-api data-rs-ui>, drives form submissions viafetch, and stores the access token inlocalStorage. No cookie session is established here, so this router is safe to mount alongside the JSON API without CSRF middleware.
- regstack.ui.pages.build_ui_environment(host_template_dirs=None)[source]¶
Construct the Jinja2 environment used by the SSR pages.
Wraps a
jinja2.ChoiceLoaderso that host directories are searched first, falling back to the bundled templates from the regstack package. A host can overrideauth/login.html(or any other bundled template) by dropping a same-named file into one of the supplied directories.
- regstack.ui.pages.default_static_dir()[source]¶
Return the filesystem path to the bundled static assets.
Resolves
regstack/ui/static/from the installed package viaimportlib.resources, so it works whether regstack is in an editable install or a wheel. Used by theStaticFilesfactory onRegStack.- Return type:
- Returns:
Filesystem path containing
css/core.css,css/theme.css, andjs/regstack.js.