{"id":49549045,"url":"https://github.com/mano8/auth-sdk-m8","last_synced_at":"2026-06-11T09:00:21.174Z","repository":{"id":355283506,"uuid":"1227156072","full_name":"mano8/auth-sdk-m8","owner":"mano8","description":"JWT validation SDK for FastAPI — HS256/RS256/ES256, JWKS resolver, Redis-backed revocation, refresh token rotation, and Prometheus metrics. Companion to fa-auth-m8.","archived":false,"fork":false,"pushed_at":"2026-06-10T16:51:48.000Z","size":513,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-10T17:23:01.764Z","etag":null,"topics":["auth","fa-auth-m8","fastapi","microservices","python","sdk-python"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mano8.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-02T09:34:32.000Z","updated_at":"2026-06-06T15:27:20.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mano8/auth-sdk-m8","commit_stats":null,"previous_names":["mano8/auth-sdk-m8"],"tags_count":38,"template":false,"template_full_name":null,"purl":"pkg:github/mano8/auth-sdk-m8","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mano8%2Fauth-sdk-m8","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mano8%2Fauth-sdk-m8/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mano8%2Fauth-sdk-m8/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mano8%2Fauth-sdk-m8/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mano8","download_url":"https://codeload.github.com/mano8/auth-sdk-m8/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mano8%2Fauth-sdk-m8/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34190585,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-11T02:00:06.485Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["auth","fa-auth-m8","fastapi","microservices","python","sdk-python"],"created_at":"2026-05-02T21:03:00.146Z","updated_at":"2026-06-11T09:00:21.142Z","avatar_url":"https://github.com/mano8.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# auth-sdk-m8\n\n![CI/CD](https://github.com/mano8/auth-sdk-m8/actions/workflows/CI.yaml/badge.svg?branch=main)\n[![PyPI version](https://img.shields.io/pypi/v/auth-sdk-m8)](https://pypi.org/project/auth-sdk-m8/)\n[![Python](https://img.shields.io/pypi/pyversions/auth-sdk-m8)](https://pypi.org/project/auth-sdk-m8/)\n[![PyPI Downloads](https://static.pepy.tech/personalized-badge/auth-sdk-m8?period=total\u0026units=INTERNATIONAL_SYSTEM\u0026left_color=BLACK\u0026right_color=GREEN\u0026left_text=downloads)](https://pepy.tech/projects/auth-sdk-m8)\n[![codecov](https://codecov.io/gh/mano8/auth-sdk-m8/graph/badge.svg?token=TF6OGIHOGF)](https://codecov.io/gh/mano8/auth-sdk-m8)\n[![Codacy Badge](https://app.codacy.com/project/badge/Grade/8b8e9726b0f8441ea480902ea8910812)](https://app.codacy.com/gh/mano8/auth-sdk-m8/dashboard?utm_source=gh\u0026utm_medium=referral\u0026utm_content=\u0026utm_campaign=Badge_grade)\n\nShared authentication schemas, JWT validation, and FastAPI base components for any service that issues or validates JWT tokens. Supports Python 3.11 – 3.14.\n\nCompanion SDK to [fa-auth-m8](https://github.com/mano8/fa-auth-m8) — install in any FastAPI service that needs to validate tokens from the fa-auth-m8 authentication service. Provides Pydantic schemas, JWT validation, `CommonSettings`, the fa-auth SSE event-stream bridge client, and optional Prometheus metrics.\n\n---\n\n## Summary\n\n- [Installation](#installation)\n- [Secure-by-default (1.0.0)](#secure-by-default-100)\n- [Deployment modes](#deployment-modes)\n  - [HS256 — symmetric](#hs256--symmetric-opt-in-single-service-or-monolith)\n  - [RS256 — issuer side](#rs256--asymmetric-issuer-side-auth_user_service)\n  - [RS256 — consumer JWKS](#rs256--asymmetric-consumer-side-jwks-recommended)\n  - [RS256 — consumer offline](#rs256--asymmetric-consumer-offline-static-public-key-file)\n  - [ES256 — ECDSA](#es256--ecdsa-drop-in-for-rs256)\n- [FastAPI integration](#fastapi-integration)\n- [Startup config validation](#startup-config-validation)\n- [Service role](#service-role-auth_service_role)\n- [Asymmetric key-strength enforcement](#asymmetric-key-strength-enforcement)\n- [Strict production mode](#strict-production-mode)\n- [Token modes](#token-modes)\n- [Chrome extension / native-app OAuth support](#chrome-extension--native-app-oauth-support)\n- [Auth degradation policy](#auth-degradation-policy)\n- [Issuer / audience enforcement](#issuer--audience-enforcement)\n- [Refresh token rotation](#refresh-token-rotation)\n- [Observability hooks](#observability-hooks)\n- [Prometheus metrics](#prometheus-metrics)\n- [Auth event stream (SSE bridge)](#auth-event-stream-sse-bridge)\n- [Redis event bus (deprecated)](#redis-event-bus-deprecated)\n- [Package layout](#package-layout)\n- [Architecture note](#architecture-note)\n\n---\n\n## Installation\n\n```bash\npip install auth-sdk-m8 --upgrade\n```\n\nInstall only what your service needs:\n\n| Extra | Installs | Use when |\n| --- | --- | --- |\n| *(none)* | `pydantic`, `email-validator` | schemas only |\n| `[security]` | `PyJWT`, `cryptography` | JWT validation |\n| `[fastapi]` | `fastapi` | cookie helpers, `BaseController` |\n| `[config]` | `pydantic-settings` | `CommonSettings` base class |\n| `[events]` | `httpx` | fa-auth SSE event-stream client |\n| `[redis]` | `redis` | Redis event bus (deprecated) / blacklist |\n| `[db]` | `sqlmodel`, `sqlalchemy` | `TimestampMixin`, DB error parsing |\n| `[mysql]` | `pymysql` | MySQL driver |\n| `[postgres]` | `psycopg2-binary` | PostgreSQL driver |\n| `[observability]` | `prometheus-client`, `fastapi` | Prometheus metrics middleware |\n| `[all]` | everything | full feature set |\n\n```bash\npip install \"auth-sdk-m8[security,fastapi,config,db,mysql]\"\n```\n\n---\n\n## Secure-by-default (1.0.0)\n\n**1.0.0 is a breaking release.** The most secure design is now the default; operators opt out via\nconfig. Three defaults changed:\n\n| Finding | Secure default (1.0.0) | Opt-out |\n| --- | --- | --- |\n| **F2 — algorithm** | `ACCESS_TOKEN_ALGORITHM=RS256` (asymmetric / JWKS) | `ACCESS_TOKEN_ALGORITHM=HS256` (+ `ACCESS_SECRET_KEY`) |\n| **F1 — token binding** | `TOKEN_STRICT_VALIDATION=true` — `iss`/`aud` enforced; `TOKEN_ISSUER` + `TOKEN_AUDIENCE` **required at boot** | `TOKEN_STRICT_VALIDATION=false` (single-service/dev) |\n| **F3 — event bus** | `EVENT_SIGNING_ENABLED=true` — payloads HMAC-signed; `EVENT_SIGNING_KEY` **required at boot** | `EVENT_SIGNING_ENABLED=false`, or `EVENT_SIGNING_ACCEPT_UNSIGNED=true` during rollout |\n\nA service that relied on the old implicit `HS256` default, or that ran without `TOKEN_ISSUER` /\n`TOKEN_AUDIENCE` / `EVENT_SIGNING_KEY`, will now **fail closed at startup** until it either adopts the\nsecure posture (recommended) or sets the opt-out. Refresh tokens are always `HS256` (internal,\nsymmetric) and `TOKEN_ALGORITHM` is never propagated to `REFRESH_TOKEN_ALGORITHM`.\n\n**Migrating an existing HS256 / permissive deployment:**\n\n1. Stay on HS256 for now: set `ACCESS_TOKEN_ALGORITHM=HS256`. Move to RS256 when ready (below).\n2. Set `TOKEN_ISSUER` and `TOKEN_AUDIENCE` on every service (both issuer and consumers must agree),\n   or set `TOKEN_STRICT_VALIDATION=false` if you genuinely have no cross-service boundary.\n3. Distribute a shared `EVENT_SIGNING_KEY` to all event-bus publishers and subscribers; roll out\n   with `EVENT_SIGNING_ACCEPT_UNSIGNED=true`, then flip it back to `false` once every publisher signs.\n   Set `EVENT_SIGNING_ENABLED=false` only if you do not use the event bus.\n\n---\n\n## Deployment modes\n\n| Mode | When to use |\n| ---- | ----------- |\n| **RS256 / ES256 — JWKS** | **Default.** Multiple independent consumers — each fetches the public key dynamically; recommended for most multi-service setups |\n| **RS256 / ES256 — offline** | Air-gapped or embedded deployments where the JWKS endpoint is unreachable |\n| **HS256** | Opt-in. Single service or tight monolith — all services share the same secret |\n\n### HS256 — symmetric (opt-in: single-service or monolith)\n\nEvery service shares the same secret. Simple to set up; not recommended when consumers are\nmaintained by different teams. **Since 1.0.0 HS256 is opt-in** — you must set\n`ACCESS_TOKEN_ALGORITHM=HS256` explicitly (the default is `RS256`).\n\n#### .env\n\n```ini\nACCESS_TOKEN_ALGORITHM=HS256\nACCESS_SECRET_KEY=your-strong-secret-key\nREFRESH_SECRET_KEY=your-strong-refresh-secret\n# Strict iss/aud binding is on by default — set both, or opt out:\nTOKEN_ISSUER=https://auth.example.com\nTOKEN_AUDIENCE=https://api.example.com\n# TOKEN_STRICT_VALIDATION=false   # single-service/dev opt-out instead of iss/aud\n```\n\n#### Settings\n\n```python\nfrom pathlib import Path\nfrom pydantic_settings import SettingsConfigDict\nfrom auth_sdk_m8.core.config import CommonSettings\nfrom auth_sdk_m8.utils.paths import find_dotenv\n\nclass Settings(CommonSettings):\n    ENV_FILE_DIR = Path(__file__).resolve().parent\n    model_config = SettingsConfigDict(\n        env_file=find_dotenv(ENV_FILE_DIR),\n        env_file_encoding=\"utf-8\",\n    )\n\nsettings = Settings()\n```\n\n#### Validate a token\n\n```python\nfrom auth_sdk_m8.core.exceptions import InvalidToken\nfrom auth_sdk_m8.security import build_access_validator\n\nvalidator = build_access_validator(settings)  # create once at module level\n\ntry:\n    payload = validator.validate_access_token(bearer_token)\n    print(payload.sub, payload.role)\nexcept InvalidToken:\n    ...\n```\n\n---\n\n### RS256 — asymmetric, issuer side (`auth_user_service`)\n\nThe auth service holds the private key and publishes a JWKS endpoint.\nConsumer services never receive the private key.\n\n#### Generate keys\n\n```bash\nopenssl genrsa -out keys/private.pem 2048\nopenssl rsa -in keys/private.pem -pubout -out keys/public.pem\n```\n\n#### docker-compose.yml (auth service)\n\n```yaml\nenvironment:\n  ACCESS_TOKEN_ALGORITHM: RS256\n  REFRESH_TOKEN_ALGORITHM: HS256\n  ACCESS_KEY_ID: main-2026-01\n  ACCESS_PRIVATE_KEY_FILE: /opt/keys/private.pem\n  ACCESS_PUBLIC_KEY_FILE: /opt/keys/public.pem\nvolumes:\n  - ./keys:/opt/keys:ro\n```\n\n#### .env (auth service)\n\n```ini\nACCESS_TOKEN_ALGORITHM=RS256\nREFRESH_TOKEN_ALGORITHM=HS256\nACCESS_KEY_ID=main-2026-01\nACCESS_PRIVATE_KEY_FILE=/opt/keys/private.pem\nACCESS_PUBLIC_KEY_FILE=/opt/keys/public.pem\n```\n\n\u003e Keys are loaded from disk at startup via `ACCESS_PRIVATE_KEY_FILE` /\n\u003e `ACCESS_PUBLIC_KEY_FILE`. Inline PEM strings in env vars are **not supported** —\n\u003e newline escaping breaks silently across shells and orchestrators.\n\n---\n\n### RS256 — asymmetric, consumer side (JWKS, recommended)\n\nConsumers fetch the public key dynamically from the auth service JWKS endpoint.\nNo key files needed. Supports zero-downtime key rotation.\n\n#### .env (consumer service)\n\n```ini\nACCESS_TOKEN_ALGORITHM=RS256\nJWKS_URI=http://auth_user_service:8000/user/.well-known/jwks.json\nJWKS_CACHE_TTL_SECONDS=300\n```\n\n`build_access_validator` automatically uses `JwksKeyResolver` when `JWKS_URI` is set:\n\n```python\n# No key file needed — the validator fetches the public key from JWKS.\nvalidator = build_access_validator(settings)\npayload = validator.validate_access_token(bearer_token)\n```\n\nOn an unknown `kid` the resolver refreshes once before raising, so key rotation on the issuer\nside is transparent to consumers with no restart required.\n\n---\n\n### RS256 — asymmetric, consumer offline (static public key file)\n\nFor air-gapped or embedded deployments where the JWKS endpoint is unreachable.\n\n#### .env (consumer)\n\n```ini\nACCESS_TOKEN_ALGORITHM=RS256\nACCESS_PUBLIC_KEY_FILE=/opt/keys/public.pem\n```\n\nMount only the public key — never the private key — to consumer containers:\n\n```yaml\nvolumes:\n  - ./keys/public.pem:/opt/keys/public.pem:ro\n```\n\n### ES256 — ECDSA (drop-in for RS256)\n\nES256 works identically to RS256 in all three modes above. Replace `RS256` with `ES256` and generate a P-256 EC key pair:\n\n```bash\nopenssl ecparam -genkey -name prime256v1 -noout -out keys/private.pem\nopenssl ec -in keys/private.pem -pubout -out keys/public.pem\n```\n\n`CommonSettings` enforces P-256 (secp256r1) at startup — other curves are rejected. Use ES256 when smaller key sizes and faster signature verification matter.\n\n---\n\n## FastAPI integration\n\n### Token validation dependency\n\nConsumer services validate tokens locally and check revocation via HTTP (not direct Redis access).\nSee `examples/fastapi_service/core/deps.py` in [fa-auth-m8](https://github.com/mano8/fa-auth-m8)\nfor the full reference implementation. The key pieces:\n\n```python\nfrom typing import Annotated\nfrom fastapi import Depends, HTTPException\nfrom fastapi.security import OAuth2PasswordBearer\nfrom auth_sdk_m8.core.exceptions import InvalidToken\nfrom auth_sdk_m8.schemas.user import UserModel\nfrom auth_sdk_m8.security import build_access_validator\n\noauth2 = OAuth2PasswordBearer(tokenUrl=\"/user/login/access-token\")\nTokenDep = Annotated[str, Depends(oauth2)]\n\n_validator = build_access_validator(settings)  # module-level singleton\n\nasync def get_current_user(token: TokenDep) -\u003e UserModel:\n    try:\n        payload = _validator.validate_access_token(token)\n    except InvalidToken as exc:\n        raise HTTPException(status_code=403, detail=\"Could not validate credentials.\") from exc\n    # Revocation: call auth service HTTP endpoint (not Redis directly)\n    # See RemoteRevocationClient in fa-auth-m8 examples/fastapi_service/core/revocation.py\n    return UserModel(**{**payload.model_dump(exclude={\"sub\", \"jti\", \"exp\", \"type\"}), \"id\": payload.sub})\n```\n\n\u003e **Redis isolation:** consumer services must not connect to auth Redis.\n\u003e Use `POST /private/v1/jti-status` on the auth service instead (see [fa-auth-m8](https://github.com/mano8/fa-auth-m8)).\n\n### Startup config validation\n\nCall `check_config_health` inside the FastAPI lifespan to surface misconfigurations before the\nfirst request:\n\n```python\nfrom contextlib import asynccontextmanager\nfrom fastapi import FastAPI\nfrom auth_sdk_m8.core.config import check_config_health\nimport logging\n\n_logger = logging.getLogger(__name__)\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    check_config_health(settings, _logger)  # raises ConfigurationError on fatal issues\n    yield\n\napp = FastAPI(lifespan=lifespan)\n```\n\nChecks performed:\n\n| Condition | Severity |\n| --- | --- |\n| RS256/ES256 without `ACCESS_PUBLIC_KEY_FILE` or `JWKS_URI` | **fatal** |\n| `JWKS_URI` set but algorithm is `HS256` | warning |\n| `AUTH_SERVICE_ROLE=consumer` with `ACCESS_PRIVATE_KEY_FILE` | **fatal** |\n| `AUTH_SERVICE_ROLE=issuer` with asymmetric algorithm but no private key | **fatal** |\n| `AUTH_SERVICE_ROLE=issuer` with `JWKS_URI` set | warning (fatal under `STRICT_PRODUCTION_MODE`) |\n| `AUTH_SERVICE_ROLE=issuer` + `TOKEN_MODE=stateful/hybrid` without Redis credentials | **fatal** (via `_enforce_redis_for_issuers`) |\n| `JWKS_CACHE_TTL_SECONDS` below 30 s | warning |\n| `ENVIRONMENT=production` with `localhost`/`127.0.0.1` in `ALLOWED_ORIGINS` | **fatal** |\n| `ENVIRONMENT=production` with `SET_DOCS=true` or `SET_OPEN_API=true` (and **not** `SERVE_DOCS_IN_PRODUCTION`) | warning (fatal under `STRICT_PRODUCTION_MODE`) |\n| `ENVIRONMENT=production` with `SERVE_DOCS_IN_PRODUCTION=true` (docs intentionally published) | warning (never fatal — explicit opt-in) |\n| `AUTH_SERVICE_ROLE=consumer` + `TOKEN_MODE=stateless` + `DB_HOST` set | warning |\n| `STRICT_PRODUCTION_MODE=true` with wildcard `*` in `ALLOWED_ORIGINS` | **fatal** |\n| `STRICT_PRODUCTION_MODE=true` with `SESSION_COOKIE_SECURE=false` outside `local` | **fatal** |\n\n---\n\n## Docs / OpenAPI gating (secure-by-default)\n\n`SET_OPEN_API`, `SET_DOCS`, and `SET_REDOC` keep their `True` defaults for developer\nexperience, but the interactive API docs (OpenAPI schema, Swagger UI, ReDoc) are **gated off in\nproduction by default**. Rather than reading the raw `SET_*` flags directly, mount your docs\nendpoints from the three computed properties, which are the single source of truth every consumer\ninherits:\n\n| Property | Value |\n| --- | --- |\n| `effective_set_open_api` | `SET_OPEN_API` **and not** gated |\n| `effective_set_docs` | `SET_DOCS` **and not** gated |\n| `effective_set_redoc` | `SET_REDOC` **and not** gated |\n\nwhere **gated** = production **and not** `SERVE_DOCS_IN_PRODUCTION`.\n\nProduction is `ENVIRONMENT == \"production\"` **or** `STRICT_PRODUCTION_MODE == true`. In production all\nthree effective flags resolve to `False` regardless of the raw `SET_*` values — **unless** you set\n`SERVE_DOCS_IN_PRODUCTION=true` to explicitly publish docs (e.g. a public / open-source API).\nSecure-by-default, but the operator can opt in.\n\n```python\nfrom fastapi import FastAPI\nfrom auth_sdk_m8.core.config import CommonSettings\n\nsettings = CommonSettings()  # your concrete settings\n\napp = FastAPI(\n    openapi_url=\"/openapi.json\" if settings.effective_set_open_api else None,\n    docs_url=\"/docs\" if settings.effective_set_docs else None,\n    redoc_url=\"/redoc\" if settings.effective_set_redoc else None,\n)\n```\n\n**Opting back on:** docs are available by default in every non-production environment (`local`,\n`development`, `staging`). To serve them **in production**, set `SERVE_DOCS_IN_PRODUCTION=true` (the\nraw `SET_*` flags still apply per-endpoint).\n\n\u003e ⚠️ **Risk — never silent.** Publishing docs in production exposes a live interactive\n\u003e Swagger/ReDoc console wired to your production server. `check_config_health` **always logs a\n\u003e warning** while `SERVE_DOCS_IN_PRODUCTION=true` so the choice is never accidental (it is *not*\n\u003e escalated to fatal, even under strict mode — it's your explicit decision).\n\nWhen the opt-in is **not** set, leaving raw `SET_DOCS`/`SET_OPEN_API` `true` in production also\ntriggers a `check_config_health` warning (fatal under strict mode), nudging you to disable them.\n\n---\n\n## Service role (`AUTH_SERVICE_ROLE`)\n\nSet `AUTH_SERVICE_ROLE` to declare whether a service issues tokens or only validates them.\n`check_config_health` uses this to enforce role-appropriate key configuration.\n\n```ini\n# auth_user_service — signs tokens and serves JWKS\nAUTH_SERVICE_ROLE=issuer\n\n# any consumer microservice — only validates\nAUTH_SERVICE_ROLE=consumer\n```\n\n| Role | Allowed | Rejected |\n| --- | --- | --- |\n| `issuer` | `ACCESS_PRIVATE_KEY_FILE` + `ACCESS_PUBLIC_KEY_FILE` | missing private key with asymmetric algorithm |\n| `consumer` | `JWKS_URI` or `ACCESS_PUBLIC_KEY_FILE` | `ACCESS_PRIVATE_KEY_FILE` (private key on a consumer is always fatal) |\n\n---\n\n## Consumer settings mixin (`ConsumerAuthMixin`)\n\nConsumer microservices that use HTTP introspection should mix `ConsumerAuthMixin` into their settings class. It adds `INTROSPECTION_URL` and `PRIVATE_API_SECRET` and enforces that both are set when `TOKEN_MODE` is `stateful` or `hybrid`.\n\n```python\nfrom auth_sdk_m8.core import ConsumerAuthMixin\nfrom auth_sdk_m8.core.config import CommonSettings\n\nclass MyServiceSettings(ConsumerAuthMixin, CommonSettings):\n    ...  # your service-specific fields\n```\n\nRequired fields added by the mixin:\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `INTROSPECTION_URL` | `AnyHttpUrl \\| None` | Full URL of the auth service JTI-status endpoint, e.g. `https://auth.example.com/user/private/v1/jti-status` |\n| `PRIVATE_API_SECRET` | `SecretStr \\| None` | Shared secret presented in `X-Internal-Token` for introspection requests |\n\nBoth fields default to `None` (stateless mode). The `_require_introspection_for_stateful_consumer` validator raises `ValueError` when `TOKEN_MODE` is `stateful` or `hybrid` and either field is unset.\n\n\u003e `fastapi-m8`'s `ConsumerServiceSettings` already inherits `ConsumerAuthMixin` — you only need to mix it in manually if you build a consumer without fastapi-m8.\n\n---\n\n## Asymmetric key-strength enforcement\n\n`CommonSettings` validates loaded key material at startup:\n\n- **RS256**: minimum **2048-bit** RSA key — smaller keys raise `ValueError` and abort startup.\n- **ES256**: requires a **P-256 (secp256r1)** EC key — other curves (P-384, secp256k1, …) are rejected.\n\nThis runs for both private keys (issuer) and public keys (consumer with `ACCESS_PUBLIC_KEY_FILE`).\nConsumer services using `JWKS_URI` skip this check — key strength is validated by the issuer.\n\n---\n\n## Strict production mode\n\nSet `STRICT_PRODUCTION_MODE=true` to escalate security warnings to fatal errors, aborting\nstartup instead of merely logging. Recommended for staging/production CI gates.\n\n```ini\nSTRICT_PRODUCTION_MODE=true\nSESSION_COOKIE_SECURE=true\nSET_DOCS=false\nSET_OPEN_API=false\n```\n\nWhat strict mode adds on top of the base `check_config_health` checks:\n\n- `SET_DOCS=true` or `SET_OPEN_API=true` in production → **fatal** (base: warning)\n- `AUTH_SERVICE_ROLE=issuer` with `JWKS_URI` set → **fatal** (base: warning)\n- Wildcard `*` in `ALLOWED_ORIGINS` → **fatal**\n- `SESSION_COOKIE_SECURE=false` outside `ENVIRONMENT=local` → **fatal**\n- `TOKEN_ISSUER` or `TOKEN_AUDIENCE` not set in production → **fatal** (base: warning)\n\n---\n\n## Token modes\n\nSet `TOKEN_MODE` to control session strategy. Both auth service and consumers must agree.\n\n| `TOKEN_MODE` | Access tokens | Refresh tokens | Redis required (issuer) | Redis required (consumer) |\n| --- | --- | --- | --- | --- |\n| `stateless` | pure JWT, no revocation | pure JWT | no | no |\n| `hybrid` | pure JWT | JTI tracked in Redis | yes | no |\n| `stateful` | JTI blacklisted in Redis | JTI tracked in Redis | yes | no — use HTTP introspection |\n\n`requires_redis` returns `True` only for `AUTH_SERVICE_ROLE=issuer` with `TOKEN_MODE` ≠ `stateless`.\nConsumer services never hold Redis credentials — they call `POST /private/v1/jti-status` on the\nauth service instead (see [fa-auth-m8](https://github.com/mano8/fa-auth-m8) for the reference\n`RemoteRevocationClient`).\n\n---\n\n## Refresh key rotation\n\n`REFRESH_SECRET_KEY_OLD` provides a zero-downtime rotation window for the refresh token signing key. When set, any refresh token that fails validation against the current `REFRESH_SECRET_KEY` is automatically retried against the old key. A `WARNING` is logged each time the old key is used so you can track when all legacy tokens have expired.\n\n**Rotation procedure:**\n\n1. Generate a new key and set it as `REFRESH_SECRET_KEY`.\n2. Move the previous key to `REFRESH_SECRET_KEY_OLD`.\n3. Deploy — old-key tokens validate via fallback; new tokens are signed with the new key.\n4. Once all refresh tokens issued before the rotation have expired (after `REFRESH_TOKEN_EXPIRE_MINUTES`), remove `REFRESH_SECRET_KEY_OLD` and redeploy.\n\n\u003e **Note:** Expired tokens are never retried against the old key — expiry is independent of the signing key.\n\n```ini\nREFRESH_SECRET_KEY=new-strong-secret\nREFRESH_SECRET_KEY_OLD=previous-strong-secret\n```\n\n---\n\n## Redis TLS\n\nSet `REDIS_SSL=true` to enable TLS on the `ConnectionPool` when Redis is reached over a network boundary in staging/production. Defaults to `false` for plain-TCP local/dev stacks.\n\n| Setting | Required | Description |\n| --- | --- | --- |\n| `REDIS_SSL` | no | `true` to enable TLS (default `false`) |\n| `REDIS_SSL_CA` | when `REDIS_SSL=true` | Path to CA certificate — required to verify the Redis server cert |\n| `REDIS_SSL_CERT` | no | Path to client certificate for mTLS — must be set together with `REDIS_SSL_KEY` |\n| `REDIS_SSL_KEY` | no | Path to client private key for mTLS — must be set together with `REDIS_SSL_CERT` |\n\n`REDIS_SSL_CERT` and `REDIS_SSL_KEY` follow an XOR rule: both must be set or both unset. All path fields are validated at startup — a missing file aborts startup immediately.\n\n```ini\n# TLS only (server cert verification)\nREDIS_SSL=true\nREDIS_SSL_CA=/opt/certs/ca.crt\n\n# mTLS (mutual TLS — client cert + key)\nREDIS_SSL=true\nREDIS_SSL_CA=/opt/certs/ca.crt\nREDIS_SSL_CERT=/opt/certs/client.crt\nREDIS_SSL_KEY=/opt/certs/client.key\n```\n\n---\n\n## Chrome extension / native-app OAuth support\n\n`CommonSettings` provides three settings for deploying `fa-auth-m8` as a backend\nfor Chrome extensions or native-app OAuth clients.\n\n| Setting | Default | Purpose |\n| --- | --- | --- |\n| `OAUTH_ALLOWED_REDIRECT_SCHEMES` | `[\"chrome-extension://\"]` | URI schemes accepted as `redirect_target` at the login-URL endpoint. `http://` and `https://` are always hard-rejected regardless of this list. |\n| `OAUTH_ALLOWED_REDIRECT_PREFIXES` | `[]` | Optional full-URI allowlist for operator-controlled extension binding. Empty = open public-client model (any extension with the correct scheme). |\n| `CORS_ALLOWED_ORIGIN_SCHEMES` | `[]` | URI scheme prefixes allowed as `Origin` in CORS preflight requests. Required for Chrome extension `fetch()` calls. |\n\nBoth settings accept comma-separated strings from env vars:\n\n```ini\n# Accept any chrome-extension:// redirect (open public-client model)\nOAUTH_ALLOWED_REDIRECT_SCHEMES=chrome-extension://\n\n# Optional: restrict to specific extension IDs\nOAUTH_ALLOWED_REDIRECT_PREFIXES=chrome-extension://abcdefghijklmnopqrstuvwxyzabcdef/\n\n# Enable CORS for extension fetch() calls\nCORS_ALLOWED_ORIGIN_SCHEMES=chrome-extension://\n```\n\n`CORS_ALLOWED_ORIGIN_SCHEMES` is consumed by `fa-auth-m8`'s `CORSMiddleware`\nsetup (`_build_cors_origin_regex`). Chrome extension IDs are constrained to\nexactly 32 lowercase letters; the middleware rejects any origin that does not\nmatch. Only `chrome-extension://` is a supported scheme value — other\nschemes require custom CORS validation.\n\n`EXTENSION_ID` (present in versions ≤ 0.6.12) has been removed. `fa-auth-m8`\nis a generic auth provider; it must not require per-client backend configuration.\n\n---\n\n## Auth degradation policy\n\nWhen Redis is unavailable, each security control can independently `fail_open` (allow the request through) or `fail_closed` (return HTTP 503). Set these in `CommonSettings` or your `.env`:\n\n| Setting | Default | Controls |\n| --- | --- | --- |\n| `AUTH_STRICT_MODE` | `false` | When `true`, overrides all per-control modes to `fail_closed` |\n| `REFRESH_VALIDATION_FAILURE_MODE` | `fail_closed` | Refresh token allowlist check |\n| `SESSION_WRITE_FAILURE_MODE` | `fail_closed` | Session write on login / logout revocation |\n| `RATE_LIMIT_FAILURE_MODE` | `fail_open` | Refresh rate limiter |\n| `ACCESS_REVOCATION_FAILURE_MODE` | `fail_closed` | Access token JTI blacklist check |\n\n\u003e **Security note:** `ACCESS_REVOCATION_FAILURE_MODE` defaults to `fail_closed` — any outage (auth service, Redis, network) that prevents verifying token revocation returns HTTP 503 rather than accepting a potentially-revoked token. Availability-first stacks can set `ACCESS_REVOCATION_FAILURE_MODE=fail_open` to preserve service availability during outages. High-security stacks can set `AUTH_STRICT_MODE=true` to force all controls closed regardless of individual settings.\n\n```ini\n# Harden everything — any Redis outage blocks the request\nAUTH_STRICT_MODE=true\n\n# Availability-first: allow requests when revocation check unavailable\nACCESS_REVOCATION_FAILURE_MODE=fail_open\n\n# Or tune per-control (AUTH_STRICT_MODE must be false/unset)\nRATE_LIMIT_FAILURE_MODE=fail_closed\n```\n\nResolve the effective mode programmatically:\n\n```python\nmode = settings.effective_failure_mode(\"rate_limit\")  # \"fail_open\" | \"fail_closed\"\n```\n\n`effective_failure_mode` accepts: `\"refresh_validation\"`, `\"session_write\"`, `\"rate_limit\"`, `\"access_revocation\"`.\n\n---\n\n## Rate limiting\n\n`LoginRateLimiter` and `RefreshRateLimiter` limits are configurable via `CommonSettings`. Defaults represent the recommended security posture; a startup warning is logged when the effective rate exceeds the per-control threshold.\n\n| Setting | Default | Bounds | Threshold warning |\n| --- | --- | --- | --- |\n| `LOGIN_RATE_LIMIT_REQUESTS` | `5` | 1–1000 | \u003e 5 req/min combined |\n| `LOGIN_RATE_LIMIT_WINDOW_MINUTES` | `15` | 1–1440 | — |\n| `REFRESH_RATE_LIMIT_REQUESTS` | `10` | 1–1000 | \u003e 20 req/min combined |\n| `REFRESH_RATE_LIMIT_WINDOW_MINUTES` | `5` | 1–1440 | — |\n\n```ini\n# Tighten for high-value deployments\nLOGIN_RATE_LIMIT_REQUESTS=3\nLOGIN_RATE_LIMIT_WINDOW_MINUTES=30\nREFRESH_RATE_LIMIT_REQUESTS=5\nREFRESH_RATE_LIMIT_WINDOW_MINUTES=10\n```\n\nThe refresh vars are unused in `TOKEN_MODE=stateless` (no refresh tokens are issued). `_check_rate_limit_config()` in `config_health.py` skips the refresh check automatically in that mode.\n\n---\n\n## Issuer / audience enforcement\n\nSet these in both the auth service and consumers to prevent token reuse across services:\n\n```ini\nTOKEN_ISSUER=https://auth.example.com\nTOKEN_AUDIENCE=https://api.example.com\n```\n\n**Since 1.0.0 strict binding is on by default** (`TOKEN_STRICT_VALIDATION=true`):\n`build_access_validator` enforces `iss` **and** `aud`, and `CommonSettings` **requires both\n`TOKEN_ISSUER` and `TOKEN_AUDIENCE`** at startup — a service without them fails closed at boot.\nTokens with a wrong or missing `iss`/`aud` are rejected.\n\nSingle-service or dev deployments that genuinely have no cross-service boundary opt out with\n`TOKEN_STRICT_VALIDATION=false`, which restores the permissive profile (claims enforced only when\n`TOKEN_ISSUER` / `TOKEN_AUDIENCE` are set).\n\n---\n\n## Refresh token rotation\n\n`RefreshTokenPolicy` enforces one-time use and atomic JTI rotation. A reused token is rejected\nimmediately — treat that as a compromise signal.\n\n```python\nfrom auth_sdk_m8.security import RefreshTokenPolicy\nimport uuid\n\npolicy = RefreshTokenPolicy(secrets=refresh_secrets, store=my_refresh_store)\n\n# On each /refresh request:\nuser_id, old_jti = await policy.validate_and_rotate(\n    token=refresh_token,\n    new_jti=str(uuid.uuid4()),\n    ttl_seconds=86_400,\n)\n# Issue a new token pair for user_id. old_jti is now revoked.\n\n# On logout:\nawait policy.revoke(jti)\n```\n\nImplement `RefreshTokenStore` against any backend:\n\n```python\nclass RedisRefreshStore:\n    def __init__(self, redis): self._r = redis\n\n    async def is_valid(self, jti: str) -\u003e bool:\n        return bool(await self._r.exists(f\"rt:{jti}\"))\n\n    async def rotate(self, old_jti: str, new_jti: str, ttl_seconds: int) -\u003e None:\n        pipe = self._r.pipeline()\n        pipe.delete(f\"rt:{old_jti}\")\n        pipe.setex(f\"rt:{new_jti}\", ttl_seconds, \"1\")\n        await pipe.execute()\n\n    async def revoke(self, jti: str) -\u003e None:\n        await self._r.delete(f\"rt:{jti}\")\n```\n\n---\n\n## Observability hooks\n\nAttach logging, metrics, or tracing to token validation events via `ValidationHooks`:\n\n```python\nimport logging\nfrom auth_sdk_m8.security import ValidationHooks, build_access_validator\n\nclass LogHooks:\n    def on_success(self, *, jti: str, sub: str, token_type: str) -\u003e None:\n        logging.info(\"token_ok type=%s sub=%s\", token_type, sub)\n\n    def on_failure(self, *, reason: str, token_type: str) -\u003e None:\n        logging.warning(\"token_fail type=%s reason=%s\", token_type, reason)\n\nvalidator = build_access_validator(settings, hooks=LogHooks())\n```\n\nFailure reasons: `\"expired\"`, `\"invalid\"`, `\"wrong_type\"`, `\"invalid_payload\"`, `\"revoked\"`, `\"reused\"`.\n\n---\n\n## Prometheus metrics\n\nRequires `pip install \"auth-sdk-m8[observability]\"`.\n\n```python\n# main.py\nfrom auth_sdk_m8.observability import metrics as _metrics\nfrom auth_sdk_m8.observability.middleware import MetricsMiddleware\nfrom auth_sdk_m8.observability.settings import ObservabilitySettingsMixin\nfrom fastapi import FastAPI, Response\n\nclass Settings(ObservabilitySettingsMixin, CommonSettings):\n    ...\n\n_metrics.setup(\n    enabled=settings.METRICS_ENABLED,\n    groups_str=settings.METRICS_GROUPS,\n    api_prefix=settings.API_PREFIX,\n)\n\napp = FastAPI(...)\nif settings.METRICS_ENABLED:\n    app.add_middleware(MetricsMiddleware)\n\n    @app.get(f\"{settings.API_PREFIX}/metrics\", include_in_schema=False)\n    def metrics_endpoint() -\u003e Response:\n        content, content_type = _metrics.render()\n        return Response(content=content, media_type=content_type)\n```\n\n```ini\nMETRICS_ENABLED=true\nMETRICS_GROUPS=all   # or: traffic,performance,reliability,health,auth\n```\n\n| Group | Metrics |\n| --- | --- |\n| `traffic` | `http_requests_total` (method, endpoint, status_code) |\n| `performance` | `http_request_duration_seconds` histogram (method, endpoint) |\n| `reliability` | `http_errors_total` (method, endpoint, status_class) |\n| `health` | `http_status_total` by exact status code |\n| `auth` | `auth_login_attempts_total` (result: success\\|wrong_credentials\\|inactive_user\\|rate_limited), `auth_token_refresh_total` (result: success\\|invalid\\|revoked\\|rate_limited), `auth_logout_total`, `auth_token_validation_failures_total` (reason: invalid\\|revoked\\|inactive), `auth_oauth_attempts_total` (provider, result: success\\|failed), `auth_code_exchange_total` (result: success\\|expired_or_invalid\\|pkce_failed\\|redis_unavailable), `auth_revocation_failure_total` (operation: access_blacklist\\|refresh_allowlist\\|db_session), `auth_degraded_decision_total` (control, mode, reason), `auth_redis_circuit_breaker_open` (gauge: 0=closed 1=open), `auth_degradation_mode_active` (gauge per control+mode), `auth_session_integrity_denial_total` (trigger: reuse_detected), `auth_api_key_validations_total` (result: success\\|invalid\\|revoked\\|expired), `auth_api_key_rate_limit_checks_total` (result: allowed\\|blocked), `auth_api_key_rate_limit_hits_total` (period: minute\\|hour\\|day\\|month), `auth_api_key_lifecycle_total` (action: created\\|revoked), `auth_api_key_flush_duration_seconds` (histogram) |\n\n\u003e Metric names are prefixed with the normalised `API_PREFIX` passed to `metrics.setup()` (e.g. `/user` → `user_auth_login_attempts_total`), so each service's metrics never collide.\n\n---\n\n## Auth event stream (SSE bridge)\n\n**The chosen transport for auth-state events in the m8 fleet** is an authenticated\nServer-Sent Events stream on fa-auth's private API, not the Redis Pub/Sub bus (see\n[Redis event bus (deprecated)](#redis-event-bus-deprecated) below).\n\nInstall the `events` extra:\n\n```bash\npip install \"auth-sdk-m8[events]\"\n```\n\n```python\nfrom contextlib import asynccontextmanager\nfrom fastapi import FastAPI\nfrom auth_sdk_m8.events import AuthEventStreamClient, AuthStreamEvent, derive_stream_url\n\nclient = AuthEventStreamClient(\n    stream_url=derive_stream_url(settings.INTROSPECTION_URL),  # e.g. http://fa-auth:8000/private/v1/events/stream\n    private_api_secret=settings.PRIVATE_API_SECRET.get_secret_value(),\n    signing_key=settings.EVENT_SIGNING_KEY.get_secret_value(),\n    on_event=handle_auth_event,\n    on_gap=flush_local_caches,\n)\n\nasync def handle_auth_event(event: AuthStreamEvent) -\u003e None:\n    if event.event_type == \"session-revoked\":\n        await cache.evict(event.payload.get(\"jti\"))\n    elif event.event_type == \"user-deleted\":\n        await cache.evict_user(event.payload.get(\"user_id\"))\n\nasync def flush_local_caches() -\u003e None:\n    \"\"\"Called when a resume gap is unresumable — flush all cached state.\"\"\"\n    await cache.flush_all()\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    client.start()\n    yield\n    await client.stop()\n```\n\nThe client:\n\n- Authenticates with `X-Internal-Token: \u003cPRIVATE_API_SECRET\u003e` (same header used by `jti-status`).\n- Verifies every `data` frame with HMAC-SHA256 (`EVENT_SIGNING_KEY`); forged/unsigned events are dropped.\n- Reconnects automatically with jittered exponential back-off; sends `Last-Event-ID` for resume.\n- Calls `on_gap()` when the server signals an unresumable gap (epoch change or buffer eviction) —\n  the caller **must** flush all locally cached validation state.\n- Push is a **best-effort accelerator**: the JTI blacklist behind `POST /private/v1/jti-status`\n  remains the revocation authority. A missed event is safe (just slower to converge).\n\n`derive_stream_url(introspection_url)` strips `/jti-status` from `INTROSPECTION_URL` and appends\n`/events/stream`.\n\n---\n\n## Redis event bus (deprecated)\n\n\u003e **Deprecated in 1.2.0.** `EventBus`, `EventPublisher`, and `EventSubscriber` will be removed in\n\u003e 2.0.0. Use `AuthEventStreamClient` (above) instead. `_signing.py` is exempt — the SSE bridge\n\u003e reuses it and `EVENT_SIGNING_KEY` stays.\n\nThe classes still work and still emit a `DeprecationWarning` on construction. HMAC-SHA256 signing\n(`EVENT_SIGNING_KEY`) behaves identically — the wire format is shared with the SSE bridge.\n\n---\n\n## Package layout\n\n```text\nauth_sdk_m8/\n├── schemas/\n│   ├── auth.py          # TokenUserData, TokenAccessData, TokenSecret, ASYMMETRIC_ALGORITHMS\n│   ├── base.py          # AuthProviderType, RoleType, Period, response models\n│   ├── shared.py        # ValidationConstants (regex patterns)\n│   ├── user.py          # UserModel, SessionModel\n│   └── user_events.py   # UserDeletedEvent, SessionRevokedEvent\n├── events/              # fa-auth SSE bridge client (pip install \"auth-sdk-m8[events]\")\n│   └── stream_client.py # AuthEventStreamClient, AuthStreamEvent, derive_stream_url\n├── core/\n│   ├── config.py        # CommonSettings, SecretProvider (re-exports check_config_health)\n│   ├── config_health.py # check_config_health — startup validation checks\n│   ├── consumer.py      # ConsumerAuthMixin — consumer introspection settings\n│   ├── exceptions.py    # InvalidToken, ConfigurationError\n│   └── security.py      # ComSecurityHelper (legacy: PKCE, token hashing)\n├── security/\n│   ├── factory.py            # build_access_validator() — settings-driven factory\n│   ├── headers.py            # add_security_headers_middleware, build_security_headers\n│   ├── blacklist.py          # AccessTokenBlacklist — Redis JTI revocation check\n│   ├── jwks_resolver.py      # JwksKeyResolver — JWKS endpoint with TTL cache\n│   ├── token_validator.py    # TokenValidator — stateless JWT validation\n│   ├── token_policy.py       # TokenPolicy — stateful validation with revocation store\n│   ├── refresh_token_policy.py  # RefreshTokenPolicy — one-time-use rotation\n│   ├── refresh_token_store.py   # RefreshTokenStore protocol\n│   ├── session_store.py      # SessionStore protocol\n│   ├── key_resolver.py       # KeyResolver protocol\n│   ├── hooks.py              # ValidationHooks protocol\n│   └── validation.py         # TokenValidationConfig\n├── observability/\n│   ├── metrics.py        # setup(), get(), render()\n│   ├── middleware.py     # MetricsMiddleware\n│   └── settings.py       # ObservabilitySettingsMixin\n├── redis_events/        # deprecated — use events/ instead; removed in 2.0.0\n│   ├── event_bus.py      # EventBus (deprecated)\n│   ├── publisher.py      # EventPublisher (deprecated)\n│   ├── subscriber.py     # EventSubscriber (deprecated)\n│   └── _signing.py       # canonical-JSON HMAC-SHA256 sign/verify (NOT deprecated; reused)\n├── controllers/\n│   └── base.py           # BaseController: exception → JSONResponse\n├── models/\n│   └── shared.py         # TimestampMixin, Message, Token, TokenPayload\n└── utils/\n    ├── email.py          # normalize_email\n    ├── errors_parser.py  # parse_integrity_error (MySQL + PostgreSQL), parse_pydantic_errors\n    └── paths.py          # find_dotenv\n```\n\n---\n\n## Architecture note\n\nThis SDK is intentionally thin — no business logic, only schemas, validation helpers, and base\nclasses. JWTs are validated locally (no network call per request). `auth_user_service` is the\nsole token **issuer**; this SDK provides the tools to **read** and **rotate** them.\n\nFor multi-team or multi-service deployments use **RS256** with JWKS: consumers only need the\nJWKS URI, never the signing key.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmano8%2Fauth-sdk-m8","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmano8%2Fauth-sdk-m8","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmano8%2Fauth-sdk-m8/lists"}