Observed since 20 Oct, 20:19 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
Batteries-included authentication toolkit for FastAPI — a friendly fork of reauth
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.
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):
contrib/ packages depend on the core — never
the reverse.pip install "aegistry[all]" # everything
pip install "aegistry[fastapi,sqlalchemy,password]"
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.
GoogleOAuth2Factor validates id_tokens against
Google's JWKS. Email arrives with email_verified.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.
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.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.
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 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.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_tablesonly creates); on an existing database runALTER TABLE aegistry_sessions ADD COLUMN active_organization_id INTEGER NULLand letcreate_alladd the four newaegistry_organization*tables (organizations,organization_roles,organization_memberships,organization_invitations; therolecolumns areVARCHAR(64)).
Early scaffold — APIs unstable. See upstream reauth for the core roadmap.
MIT. Contains code from reauth, © 2026 François Voron, MIT licensed.
Download counts and popularity will appear here as the collector receives them.
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 0.2.0 | 25 Aug 2026 | 8 |
This describes our collected data; it is not a security assessment.
Observed since 20 Oct, 20:19 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:af3623bea65fe8013b1a319929e9cc6cb91025d92287f001c8edb1ac50e4864c