Skip to main content

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

Project description

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.9.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.
  • Zero-dependency core (its only dependency is hayate, itself zero-dependency). Databases, KDFs, and email are injected protocols.

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

hayate_auth-0.10.1.tar.gz (58.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

hayate_auth-0.10.1-py3-none-any.whl (74.8 kB view details)

Uploaded Python 3

File details

Details for the file hayate_auth-0.10.1.tar.gz.

File metadata

  • Download URL: hayate_auth-0.10.1.tar.gz
  • Upload date:
  • Size: 58.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for hayate_auth-0.10.1.tar.gz
Algorithm Hash digest
SHA256 9d44c49ccc350104f0fba5171325748a249a3a9cf02d99dc9b47666fcaa180bb
MD5 a81d5f79a4952fe6dc682023fd379297
BLAKE2b-256 1edbd08c99bbc2a269bb6f58fc96142dc7b28fecedd8974535c68250cdd71e08

See more details on using hashes here.

Provenance

The following attestation bundles were made for hayate_auth-0.10.1.tar.gz:

Publisher: release.yml on hayatepy/hayate-auth

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file hayate_auth-0.10.1-py3-none-any.whl.

File metadata

  • Download URL: hayate_auth-0.10.1-py3-none-any.whl
  • Upload date:
  • Size: 74.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for hayate_auth-0.10.1-py3-none-any.whl
Algorithm Hash digest
SHA256 1a32d8e0a41b53b56bc8eec1c9a540c2382410921cc3bf4e081dd6ca90317531
MD5 654321593b06d65c68cbcb90c71d0a8f
BLAKE2b-256 92aba95c4892923dbcada0a1a73ff00ae08f509d55d94d89184c2000ebd7b828

See more details on using hashes here.

Provenance

The following attestation bundles were made for hayate_auth-0.10.1-py3-none-any.whl:

Publisher: release.yml on hayatepy/hayate-auth

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page