Skip to main content

Aegistry

Batteries-included authentication for FastAPI

Aegistry is an authentication toolkit for Python with first-class FastAPI integration: Email/Password, Email OTP, Google Sign-In, LINE Login, MFA (TOTP, backup codes), session management, and organizations (members, roles, invitations) — on Python 3.12+.

Architecture

aegistry/
├── crypto.py                  # opaque tokens + HMAC-SHA256 hash pairs
├── amr.py                     # RFC 8176 Authentication Method References
├── authentication_session.py  # pre-login MFA state machine (steps, AMR)
├── session.py                 # post-login sessions (sliding expiration)
├── organization.py            # orgs, members/roles, invitations   [aegistry]
├── factors/
│   ├── password.py            # argon2id via pwdlib          [aegistry]
│   ├── email_otp.py           # one-time codes by email
│   ├── totp.py / hotp.py / backup_codes.py
│   └── oauth2/
│       ├── base.py            # OAuth2 authorization code + PKCE
│       ├── oidc.py            # discovery, JWKS, id_token validation
│       ├── google.py / github.py / apple.py
│       └── line.py            # LINE Login v2.1               [aegistry]
└── contrib/
    ├── sqlalchemy/            # ready-made async stores        [aegistry]
    └── fastapi/               # routers, dependencies, cookies [aegistry]
        ├── id_token.py        # LIFF / One Tap id-token login  [aegistry]
        └── organizations.py   # org/member/invitation routes   [aegistry]

clients/                       # TypeScript SDK (pnpm workspace) [aegistry]
├── packages/client            # @aegistry/client — core, getServerSession
└── packages/react             # @aegistry/react — useSession()

For the Next.js/React side, see clients/README.md.

Design principles (inherited from reauth, shared with Better Auth):

  • Framework-agnostic core. Factors and services are plain async Python with abstract persistence methods. contrib/ packages depend on the core — never the reverse.
  • Tokens are opaque, prefixed, and stored hashed. Only HMAC-SHA256 hashes hit the database.
  • MFA by construction. Login is an authentication session that factors advance step by step; it completes only when no required factor remains.
  • PKCE + state + nonce on every OAuth2/OIDC flow.

Installation

pip install "aegistry[all]"            # everything
pip install "aegistry[fastapi,sqlalchemy,password]"

Quickstart (FastAPI + SQLAlchemy)

from aegistry.contrib.fastapi import AuthConfig, get_password_router, get_oauth2_login_router
from aegistry.contrib.sqlalchemy import create_tables

config = AuthConfig(success_redirect_url="/app")
tables = create_tables(metadata)  # or define your own tables/stores

app.include_router(
    get_password_router(
        factor_dependency=get_password_factor,
        authentication_session_service_dependency=get_authentication_session_service,
        session_service_dependency=get_session_service,
        identity_resolver_dependency=get_identity_resolver,
        config=config,
    ),
    prefix="/auth",
)
app.include_router(
    get_oauth2_login_router(
        identifier="google",
        factor_dependency=get_google_factor,
        authentication_session_service_dependency=get_authentication_session_service,
        session_service_dependency=get_session_service,
        identity_resolver_dependency=get_identity_resolver,
        scope=["openid", "email", "profile"],
        config=config,
    ),
    prefix="/auth",
)

Your app provides the dependencies (wired to your database session) and an IdentityResolver mapping verified emails to your user rows; aegistry provides the flows. See tests/contrib/test_fastapi_routers.py for a complete, runnable wiring.

Provider notes

  • Google — pure OIDC; GoogleOAuth2Factor validates id_tokens against Google's JWKS. Email arrives with email_verified.
  • LINE — LineOAuth2Factor validates id_tokens through LINE's verify endpoint (web-login tokens are HS256-signed with the channel secret, so JWKS validation can't be used). The email scope requires applying for permission in the LINE Developers console, and LINE never returns email_verified — don't auto-link LINE accounts to existing users by email without an extra verification step.
    • Add-friend at login. Pass extra_authorize_params={"bot_prompt": "aggressive"} to get_oauth2_login_router to prompt the user to add your linked Official Account as a friend during login. LINE reports the outcome as a friendship_status_changed callback param; implement an optional mark_oa_followed(identity_id) coroutine on your identity resolver (see FriendshipMarkingIdentityResolver) to record it when the value is "true". Declining the prompt still completes the login. Note that bot_prompt only renders on the account's first consent to the Login channel, so it helps first-time sign-ins and isn't a reachability guarantee — keep an explicit add-friend deep-link/QR fallback for users who are linked but not yet friends.

ID-token login (LINE LIFF, Google One Tap, Apple JS)

Clients that already hold an OIDC ID token — a LIFF app after liff.getIDToken(), Google One Tap, Sign in with Apple JS — can sign in without the redirect round-trip: get_id_token_login_router(identifier=...) mounts POST /{identifier}/id-token {"id_token": ...}, which validates the token through the provider factor (get_id_token_claims — JWKS for OIDC providers, LINE's verify endpoint for LINE) and answers exactly like /login: {"status": "complete"} with the session cookie, or {"status": "mfa_required", "factors": [...]}. Identity is keyed by (provider, sub); a first-time account is linked by email through your IdentityResolver.get_or_create_by_email, so the LINE caveat above applies (LINE never reports email_verified). Wire it with the same factor dependency the OAuth router and the authentication session service use — factor membership is checked by instance.

Nonce binding is optional: LIFF mints its own tokens and offers no way to pass a server nonce, while Google One Tap and Apple JS do. When you pass state_service_dependency, POST /{identifier}/id-token/nonce returns {state, nonce}; put nonce in the provider request and send state back with the token (require_nonce=True makes that mandatory). Without a nonce, anyone holding a valid token can sign in as its subject until it expires — serve this over TLS only.

Inside a LIFF app, joining an organization by invite link becomes:

await liff.init({ liffId });                 // restores ?token=… into location.search
if (!liff.isLoggedIn()) liff.login();
const { error } = await auth.signIn.idToken({ provider: "line", idToken: liff.getIDToken()! });
const token = new URLSearchParams(location.search).get("token");
if (!error && token) await auth.organization.invitations.accept({ token });

Share invite links from the app with liff.shareTargetPicker(). The add-friend signal (friendship_status_changed) only exists on the redirect flow; use liff.getFriendship() client-side instead.

Organizations

aegistry.organization adds multi-tenant organizations on top of the flat identity_id, with org-defined roles and permissions. Each organization starts as a blank slate with exactly one seeded role — the protected owner, holding the * permission; it can never be renamed, edited, or deleted. Every other role is created by the organization: a role is a named list of flat permission keys (resource:action), where resource:* and * act as wildcards. Valid keys are aegistry's BUILT_IN_PERMISSIONS (member:update, invitation:create, role:create, …) plus whatever vocabulary your app registers via OrganizationService(permissions={"listing:publish", ...}).

Structural invariants live in the service: the creator becomes the first owner; an organization can never lose its last owner (409 last_owner); roles still held by members or pending invitations can't be deleted (409 role_in_use); renaming a role follows through to every member and invitation holding it. Route authorization is permission-based, and every action that touches a role's permission set also passes the escalation rule: an actor may only grant, assign, or manage permission sets that are a wildcard-aware subset of their own (403 permission_escalation) — so nobody can hand out power they don't hold, and granting owner takes an owner.

Joining is invitation-based, with two kinds sharing one model (each carries the name of an existing role):

  • Email invitations (email set) are single-use and deleted on accept. If your identity resolver implements get_email_by_id (see EmailProvidingIdentityResolver), acceptance is bound to the invited email; provide an OrganizationInvitationSender to deliver them.
  • Invite links (email omitted) are multi-use join links — the raw token is returned to the inviter to share anywhere (e.g. paste into a LINE chat) — valid until revoked or expired (7 days by default). Links can never carry the owner role.

Invitation tokens follow the session-token pattern: opaque, prefixed (aegistry_oi_), only the HMAC-SHA256 hash persisted.

Sessions carry an optional active organization (POST /organizations/active, surfaced in GET /session and the TS SDK's $session). It is a hint for clients, never an authorization fact: membership (and, when you ask for one, a permission) is re-checked at request time. Guard your own routes with the dependency builders::

publish_guard = build_current_membership(          # org id from the path
    get_organization_service, get_session_service, config,
    permission="listing:publish",
)
active_guard = build_active_organization_membership(  # session's active org
    get_organization_service, get_session_service, config,
    permission="listing:publish",
)

A member removed (or stripped of a permission) after selecting an organization is refused on their next guarded request — no session rewrites needed.

Status

Early scaffold — APIs unstable. See upstream reauth for the core roadmap.

License

MIT. Contains code from reauth, © 2026 François Voron, MIT licensed.

Metadata

Release files for aegistry 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for aegistry 0.2.0
File Size Uploaded Provenance
aegistry-0.2.0.tar.gz 97.4 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for aegistry 0.2.0
File Interpreter ABI Platform
aegistry-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 186.2 kB

Release files / aegistry-0.2.0.tar.gz

Download URL aegistry-0.2.0.tar.gz
Size 97.4 kB
Tags Source
SHA-256 checksum
How to use checksums
23b0c65cbc08c3189410b2217b02e36e3c922f8559475400a067ebb881828d5a
BLAKE2b-256 checksum
How to use checksums
b1143a3199dd82a26780d2d96bbc82083ff1447bc6b0b57da6b12d9c29d0d864
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 25, 2026.

Transparency log

Release files / aegistry-0.2.0-py3-none-any.whl

Download URL aegistry-0.2.0-py3-none-any.whl
Size 88.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7ea6e4e187878d835e21082805c3da4d0331cdaf78c1e65ece52ccc69cf9d605
BLAKE2b-256 checksum
How to use checksums
ab650e3c522130b84f7ba53076995094f989f294659c9cad36220777cc5da28f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 25, 2026.

Transparency log

Security for aegistry 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for aegistry-0.2.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 25, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page