Catalog PyPI
PACKAGE EXPLORERView in registry

aegistry

PyPI

Batteries-included authentication toolkit for FastAPI — a friendly fork of reauth

Versions collected1
Direct dependencies8From release 0.2.0
Last observed20 Oct, 20:19 UTC

README

Version 0.2.0 documentation

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+.

[!NOTE] Aegistry's core is a friendly fork of reauth 0.1.8 (MIT, © 2026 François Voron), backported to Python 3.12. Aegistry adds the integration layers reauth doesn't ship yet: password & LINE factors, post-login session management, SQLAlchemy stores, and ready-made FastAPI routers. We aim to stay architecturally compatible with reauth and upstream what makes sense.

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.

[!WARNING] Adding organizations widened the schema. There are no migrations (create_tables only creates); on an existing database run ALTER TABLE aegistry_sessions ADD COLUMN active_organization_id INTEGER NULL and let create_all add the four new aegistry_organization* tables (organizations, organization_roles, organization_memberships, organization_invitations; the role columns are VARCHAR(64)).

Status

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

License

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

Downloads & popularity

Awaiting observations

No metric observations yet

Download counts and popularity will appear here as the collector receives them.

Versions 1

Collected releases
VersionPublishedDirect dependenciesOpen version
0.2.025 Aug 20268

Package advisories

0
No matching advisories collected

This describes our collected data; it is not a security assessment.

Repository

PENDING
primemeridiem/aegistry

Shared repository data · GitHub

License detected in repository
Not collected

Metadata history

Meaningful changes
Current collected state Current

Observed since 20 Oct, 20:19 UTC

No superseded metadata states collected. Unchanged observations do not create new revisions.

Record identity

package:af3623bea65fe8013b1a319929e9cc6cb91025d92287f001c8edb1ac50e4864c