Skip to content

Repository files navigation

hayate-auth

Hayate ecosystem: Start here · Production golden app · Tested compatibility

Standards-first authentication for hayate — a mountable, better-auth-style auth handler built on the WHATWG Request/Response model.

Status: alpha (0.10.x). Email+password, sessions, CSRF, email verification, password reset, OAuth 2.1 + PKCE (Google / GitHub), TOTP two-factor, API keys, an OAuth 2.1 authorization server (AS mode: RFC 8414 metadata, RFC 7591 dynamic registration, MCP Client ID Metadata Documents, PKCE-only code + refresh grants), magic links, and passkeys (WebAuthn L3, [passkey] extra) are implemented and attack-regression-tested; a generate CLI, a plugin API, and a Cloudflare D1 adapter ship too. Not yet security-audited — see SECURITY.md. The internal design memo (Japanese) lives in DESIGN.md; release history is in CHANGELOG.md.

import os

from hayate import Hayate
from hayate_auth import Auth
from hayate_auth.adapters.sqlite import SQLiteAdapter

adapter = SQLiteAdapter("app.db")
adapter.create_tables()

auth = Auth(secret=os.environ["AUTH_SECRET"], adapter=adapter)

app = Hayate()
auth.register(app)  # serves /api/auth/* (sign-up, sign-in, session, ...)

@app.get("/me", auth.require_session())
async def me(c):
    return c.json(c.get("user"))

The same file runs under any ASGI server and on Cloudflare Python Workers — see examples/todo.

Endpoints

Method / path (under /api/auth) Purpose
POST /sign-up/email Register with email + password, start a session
POST /sign-in/email Verify credentials, start a session
GET /get-session Current {user, session} (or nulls)
POST /sign-out Revoke the session server-side
POST /forget-password/reset-password Reset flow via a one-shot hashed token
GET /verify-email Confirm an email with a one-shot token
POST /sign-in/social → GET /callback/:provider OAuth 2.1 + PKCE (Google / GitHub)
POST /two-factor/enable · /verify · /disable TOTP (RFC 6238) enrollment
POST /sign-in/two-factor Second step when 2FA is on
POST /api-key/create · /verify · /delete · GET /api-key/list API keys (hashed, scoped, expiring)
GET /oauth2/authorize · POST /oauth2/consent · /oauth2/token · /oauth2/register AS mode: OAuth 2.1 authorization server
POST /sign-in/magic-link → GET /magic-link/verify Magic links (plugin)
POST /passkey/generate-register-options · /verify-registration · /generate-authenticate-options · /verify-authentication · GET /passkey/list-user-passkeys · POST /passkey/delete-passkey Passkeys (WebAuthn L3, [passkey] extra)

Magic links ship as the first AuthPlugin — plugins add routes with the same handler signature the built-ins use:

from hayate_auth.plugins import magic_link

auth = Auth(
    secret=..., adapter=adapter,
    plugins=[magic_link(send=deliver_link_email)],  # async (email, token) -> None
)

Passkeys need the extra (pip install hayate-auth[passkey], pulls py_webauthn) and a relying-party config:

from hayate_auth import PasskeyConfig

auth = Auth(
    secret=..., adapter=adapter,
    passkey=PasskeyConfig(rp_id="example.com", rp_name="My App",
                          origin="https://example.com"),
)

AS mode: be the OAuth authorization server

Pass an AuthorizationServer config and your app issues OAuth 2.1 tokens — authorization-code + PKCE (S256 only), refresh rotation with reuse detection, RFC 8414 metadata at /.well-known/oauth-authorization-server, and open RFC 7591 dynamic client registration (what MCP clients expect). Tokens are opaque and stored hashed; login and consent pages stay yours (login_url / consent_url), the consent decision is one JSON POST.

from hayate_auth import Auth, AuthorizationServer

auth = Auth(
    secret=os.environ["AUTH_SECRET"],
    adapter=adapter,
    authorization_server=AuthorizationServer(
        issuer="https://app.example.com",
        login_url="/login",
        consent_url="/consent",
        scopes_supported=("mcp",),
        resource="https://app.example.com/mcp",
    ),
)

Paired with hayate-mcp's resource server, that is an MCP server and its authorization server in one app — the flow MCP Inspector and Claude Code drive end to end (examples/mcp-oauth):

from hayate_mcp import Authorization, McpMount
McpMount(server, authorization=Authorization(
    resource="https://app.example.com/mcp",
    authorization_servers=["https://app.example.com"],
    verify_token=auth.oauth_token_verifier(resource="https://app.example.com/mcp"),
)).register(app)

MCP 2025-11-25 recommends Client ID Metadata Documents before DCR. Enable them with an injected fetcher so your deployment controls DNS and egress:

from hayate_auth import ClientIdMetadataDocuments

authorization_server = AuthorizationServer(
    # issuer/login_url/consent_url/scopes_supported/resource as above
    client_id_metadata_documents=ClientIdMetadataDocuments(
        fetch_client_metadata,  # async URL -> hayate.Response; must reject redirects
        allow_url=outbound_client_policy,
    ),
)

hayate-auth validates HTTPS URL-form client IDs, exact client_id, same-origin or loopback redirects, public-client metadata, JSON content type, and a 5 KiB body limit. The fetcher remains responsible for DNS-level SSRF protection.

API keys are the lighter-weight bridge to the same resource server — verify_token=auth.verify_api_key protects an MCP server with a static key instead of the full OAuth dance.

REST and generated OpenAPI share the same authorization declaration:

from hayate_openapi import OpenApi

@app.get("/documents", auth.require_oauth_token(
    "documents:read", resource="https://app.example.com/mcp"
))
async def documents(c):
    return c.json({"subject": c.get("principal")["subject"]})

OpenApi(
    app,
    title="API",
    version="1",
    security_schemes=auth.openapi_security_schemes(),
)

With TOTP enabled, /sign-in/email returns {"two_factor_required": true} plus a short-lived signed challenge cookie instead of a session; the client then posts the authenticator code to /sign-in/two-factor to get the session — so a stolen password alone never signs in.

Email delivery is your callback (send_reset_password / send_verification_email); the core mints and verifies tokens but never builds URLs or sends mail. Generate migration DDL with python -m hayate_auth generate --dialect sqlite|postgres|d1. When upgrading an existing 0.9.1 database to the next release, emit the single-use TOTP column migration with python -m hayate_auth generate --dialect <dialect> --upgrade-from 0.9.1 and apply it exactly once before deploying the new code.

OAuth providers are injected; the token exchange runs over hayate-fetch, so it works on ASGI and Workers alike:

from hayate_auth import Auth, google, github

auth = Auth(
    secret=os.environ["AUTH_SECRET"],
    adapter=adapter,
    providers=[
        google(client_id=..., client_secret=...),
        github(client_id=..., client_secret=...),
    ],
)

Why

  • Python has no equivalent of better-auth: a framework-agnostic, self-hosted, schema-owning auth library. django-allauth is Django-only; fastapi-users is in maintenance mode.
  • better-auth works on every JS framework because its core is a single fetch(Request) -> Response handler. hayate is the only Python framework whose user-facing surface is WHATWG Request/Response — so that architecture finally maps 1:1 to Python.
  • Small portability-first base with 2 direct runtime dependencies: hayate and hayate-fetch. Database drivers, KDFs, email delivery, DPoP cryptography, and WebAuthn remain injected protocols or optional extras.

Security posture

  • Passwords: scrypt at OWASP parameters (N=2^17, r=8, p=1) on every runtime, PBKDF2-HMAC-SHA256 (600k) fallback; PHC-style strings make the backends mutually verifiable. Length-only policy per NIST SP 800-63B.
  • Sessions: opaque 256-bit tokens, only their SHA-256 stored; __Host--prefixed HttpOnly SameSite=Lax cookies on HTTPS.
  • CSRF: SameSite + Origin (RFC 6454) + Fetch Metadata — no token embedding.
  • Sign-in failures are uniform in body and KDF timing (enumeration defense).
  • Coverage ledger: docs/asvs.md (selected OWASP ASVS 5.0.0 controls, exact IDs and explicit gaps; not a certification).
  • Independent review pack: audit/README.md retains the signed v0.9.1 base and pins signed v0.10.3 as the current delta-review target, including release/SBOM hashes, a threat model, SQLite/direct HTTP + PostgreSQL DDL + workerd/D1 profiles, and a reviewer RFP. The audit has not yet been commissioned.
  • You must rate-limit /api/auth/* (hayate middleware or your infrastructure): brute-force throttling is deliberately out of core.
  • TOTP seeds and upstream provider access/refresh tokens are stored recoverably. Production databases and backups must be access-controlled and encrypted; see SECURITY.md for known limitations.
  • Adapters must implement atomic update_many() and return the affected-row count. This is the security boundary for single-use TOTP steps and prevents concurrent authorization-code or refresh-token redemption from minting multiple token families. Durable compromise markers and guarded post-insert finalization also prevent replay detection during token creation from leaving a live family.

License

MIT

About

Standards-first authentication for hayate: sessions, OAuth 2.1, passkeys, API keys, and Cloudflare D1

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages