Metadata-Version: 2.5
Name: aegistry
Version: 0.2.0
Summary: Batteries-included authentication toolkit for FastAPI — a friendly fork of reauth
Project-URL: Source, https://github.com/primemeridiem/aegistry
Author: aegistry
License: MIT License
        
        Copyright (c) 2026, François Voron
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
License-File: LICENSE
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.12
Requires-Dist: anyio
Requires-Dist: cryptography<49.0.0,>=48.0.0
Requires-Dist: httpx>=0.28.1
Requires-Dist: pyjwt>=2.12.1
Provides-Extra: all
Requires-Dist: email-validator>=2.0; extra == 'all'
Requires-Dist: fastapi>=0.115.0; extra == 'all'
Requires-Dist: pwdlib[argon2]>=0.2.1; extra == 'all'
Requires-Dist: sqlalchemy[asyncio]>=2.0.30; extra == 'all'
Provides-Extra: fastapi
Requires-Dist: email-validator>=2.0; extra == 'fastapi'
Requires-Dist: fastapi>=0.115.0; extra == 'fastapi'
Provides-Extra: password
Requires-Dist: pwdlib[argon2]>=0.2.1; extra == 'password'
Provides-Extra: sqlalchemy
Requires-Dist: sqlalchemy[asyncio]>=2.0.30; extra == 'sqlalchemy'
Description-Content-Type: text/markdown

# Aegistry

<p align="center">
    <em>Batteries-included authentication for FastAPI</em>
</p>

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](https://github.com/frankie567/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](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

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

## Quickstart (FastAPI + SQLAlchemy)

```python
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:

```ts
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.
