Metadata-Version: 2.5
Name: csrd-auth
Version: 0.8.0
Summary: Pluggable JWT authentication — authenticators, key providers, and FastAPI dependency factories
Project-URL: Repository, https://github.com/csrd-api/fastapi-common
Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/auth
Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
License: MIT
Requires-Python: >=3.12
Requires-Dist: cryptography>=43
Requires-Dist: csrd-context
Requires-Dist: csrd-logging
Requires-Dist: csrd-models
Requires-Dist: fastapi<1,>=0.115
Requires-Dist: pydantic<3,>=2
Requires-Dist: pyjwt[crypto]<3,>=2
Description-Content-Type: text/markdown

# csrd-auth

Pluggable JWT authentication for FastAPI — authenticators, key providers, and dependency factories.

**Tier 2 (bridge)** — depends on `csrd-models`, `csrd-context` and `csrd-logging` only (never `csrd-repository`: SQL storage is duck-typed over any adapter).

## Installation

```bash
pip install "csrd-auth @ git+https://github.com/csrd-api/fastapi-common.git#subdirectory=packages/auth"
```

## Usage

```python
from csrd.auth import (
    JWTAuthenticator,
    StaticKeyProvider,
    create_bearer_dependency,
    create_jwt_bearer,
)
```

### Quick start — JWT bearer dependency

```python
# Standalone (provide your own token_finder)
from csrd.auth import create_jwt_bearer

auth_dep = create_jwt_bearer(
    key="your-secret",
    token_finder=lambda: extract_token_from_request(),
)
```

When used with `csrd.versioning`, wire in `find_token` explicitly:

```python
from csrd.auth import create_jwt_bearer
from csrd.versioning import find_token

auth_dep = create_jwt_bearer(key="your-secret", token_finder=find_token)
```

## Internal token guard (service-to-service)

`require_internal_token` protects endpoints that other services call (not
users) with a shared token compared in constant time. It fails closed: when
the configured token is unset or empty, every request gets 403.

```python
from typing import Annotated

from fastapi import Depends

from csrd.auth import require_internal_token
from csrd.models import BaseSettings, InternalToken


class Settings(BaseSettings):
    internal_token: InternalToken = ""  # refuses to boot unless INTERNAL_TOKEN is a real secret


settings = Settings()

# Defaults: header "X-Internal-Token", setting "internal_token"
Internal = Annotated[None, Depends(require_internal_token(settings))]


@router.post("/internal/refs")
async def refs(_: Internal) -> dict: ...
```

Custom names (e.g. `X-Internal-Service-Token` / `internal_service_token`):

```python
InternalService = Annotated[
    None,
    Depends(
        require_internal_token(
            settings,
            setting_name="internal_service_token",
            header="X-Internal-Service-Token",
        )
    ),
]
```

- `settings` is the settings object or a zero-arg provider (e.g. a cached
  `get_settings`), resolved on every request. `str` and `SecretStr` values work.
- Rejection raises `HTTPException(403, "Not allowed")` by default. csrd-auth
  cannot depend on csrd-service (tier rules), so to render through
  `service_exception_handler` pass
  `error_factory=lambda: AuthorizationError("Not allowed")`.
- Callers send the header with `BaseDelegate(..., internal_token=settings.internal_token)`.
- Strip the header from inbound client traffic at the gateway.

## Refresh-token sessions (token issuers)

Short-lived JWT access tokens plus a long-lived, **rotating** refresh token
in an `HttpOnly` cookie. A session is a *family* of refresh tokens: sign-in
starts a family, every refresh rotates the presented token into a successor,
and exactly one token per family is live. Only `sha256(token)` is stored;
the raw token is `secrets.token_urlsafe(48)`.

```python
from csrd.auth import (
    SessionManager,
    SessionSettings,
    SqlRefreshTokenStore,
    RefreshTokenReuseError,
    SessionError,
    set_refresh_cookie,
    clear_refresh_cookie,
    refresh_token_from_request,
)

sessions = SessionManager(SqlRefreshTokenStore(app.state.db_adapter), SessionSettings())

issued = await sessions.issue(user_id, remember=True, user_agent=ua)  # IssuedSession
set_refresh_cookie(response, issued, sessions.settings)              # Set-Cookie
# mint the access JWT yourself (KeyRingManager), with sid=issued.session_id

issued = await sessions.rotate(raw_token)      # raises SessionError subclasses -> 401
await sessions.revoke(raw_token)               # logout: whole family
await sessions.revoke_all_for_user(user_id)    # sign out everywhere
await sessions.list_sessions(user_id)          # [SessionInfo], id = family id
await sessions.revoke_session(user_id, session_id)
await sessions.purge_expired(retention=timedelta(days=7))
```

### Rotation rules

For a presented token `T`, `rotate` does, in order:

1. unknown: `RefreshTokenInvalidError`;
2. past `expires_at` or the absolute cap: `RefreshTokenExpiredError` (nothing revoked);
3. revoked: the family is revoked (again) and `RefreshTokenReuseError` is raised;
4. already rotated:
   - within `grace_seconds` of `T.rotated_at`, with a live, unexpired head:
     **not reuse**. The family's current head is rotated and its successor
     returned (`IssuedSession.grace is True`). The family still has one live
     token, and the head just replaced gets its own grace window. This
     absorbs two tabs or a retried request racing on one cookie.
   - otherwise: the family is revoked and `RefreshTokenReuseError` is raised;
5. live: rotated into a successor (compare-and-set, so concurrent rotations
   of the same token cannot both win).

**The reuse revoke persists although the request fails.** It is written by
`RefreshTokenStore.revoke_family` before the error is raised, outside any
caller transaction. `SqlRefreshTokenStore` must be given the **unbound**
adapter (`app.state.db_adapter`), never one yielded by
`adapter.transaction()`: it refuses a transaction-bound adapter with
`RuntimeError`, autocommits revokes, and runs each rotation in its own
top-level `transaction()`. (csrd-repository's `transaction(join=...)` only
joins when asked; the store never joins.) On `SQLiteAdapter` (single
connection) do not call the store while holding `adapter.transaction()`
in the same task.

### Settings

`SessionSettings` (frozen pydantic model; durations take `timedelta` or seconds):

| Field | Default | Meaning |
|---|---|---|
| `access_ttl` | 15 min | Access-token lifetime (carried for the issuer/client; the library does not mint JWTs) |
| `refresh_ttl` | 12 h | Refresh lifetime without remember-me |
| `remember_ttl` | 30 d | Lifetime with `remember=True`; `None` disables remember-me |
| `absolute_ttl` | 90 d | Hard cap from sign-in; `None` = no cap |
| `sliding` | `True` | Rotation restarts the lifetime (capped). `False`: successors keep the family's original expiry |
| `grace_seconds` | 30 | Grace window for a just-rotated token; `0` disables |
| `cookie_name` | `refresh_token` | |
| `cookie_path` | `/api/token` | Must cover every route that reads the cookie (refresh **and** logout) |
| `cookie_domain` | `None` | |
| `cookie_samesite` | `strict` | `none` requires `cookie_secure` |
| `cookie_secure` | `True` | Browsers accept Secure cookies on `http://localhost` |
| `transient_cookie_without_remember` | `False` | `True`: a non-remembered session gets a browser-session cookie (no `Max-Age`) |

The cookie is always `HttpOnly`; `Max-Age` tracks the token's expiry.

Configuring the two known deployments (scaffold env vars in brackets):

| | card-gen | vtt-cluster |
|---|---|---|
| access | 900 s (`JWT_TTL_SECONDS=900`) | 3600 s (`JWT_TTL_SECONDS=3600`) |
| refresh | 12 h (`SESSION_REFRESH_TTL_SECONDS=43200`) | 30 d (`SESSION_REFRESH_TTL_SECONDS=2592000`) |
| remember-me | 30 d (`SESSION_REMEMBER_TTL_SECONDS=2592000`) | off (`SESSION_REMEMBER_TTL_SECONDS=0`) |
| absolute cap | 90 d (`SESSION_ABSOLUTE_TTL_SECONDS=7776000`) | none (`SESSION_ABSOLUTE_TTL_SECONDS=0`) |
| sliding | yes (`SESSION_SLIDING=true`) | no (`SESSION_SLIDING=false`) |
| grace | 30 s (`SESSION_GRACE_SECONDS=30`) | 0 s today (`SESSION_GRACE_SECONDS=0`); 30 recommended with multi-tab clients |
| cookie | defaults | defaults (`SESSION_COOKIE_SECURE` replaces `COOKIE_SECURE`) |

card-gen's values are the library defaults.

### Storage and migration

`RefreshTokenStore` is a protocol. `InMemoryRefreshTokenStore` serves tests
and single-process apps. `SqlRefreshTokenStore(adapter, table="refresh_tokens")`
works over any csrd-repository adapter; csrd-auth does not import
csrd-repository (tier rule), it duck-types the adapter like `KeyRingManager`.

`REFRESH_TOKENS_MIGRATION_UP` / `_DOWN` (and
`refresh_tokens_migration_sql(table=, users_table=, users_id_column=, user_id_type=)`)
create, portably across SQLite / PostgreSQL / MariaDB:

```
refresh_tokens(
  id VARCHAR(64) PK, token_hash VARCHAR(64) UNIQUE NOT NULL,
  user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  family_id VARCHAR(64) NOT NULL, created_at, last_used_at, expires_at NOT NULL,
  absolute_expires_at, remember INTEGER NOT NULL DEFAULT 0, rotated_at,
  replaced_by VARCHAR(64), revoked_at, user_agent VARCHAR(512))
+ indexes on family_id and user_id
```

Timestamps are written as fixed-width ISO-8601 UTC text
(`2026-01-31T12:00:00.000000+00:00`); native `TIMESTAMP` values and naive
text (read as UTC) are also accepted when reading a migrated table.
The scaffold registers it as migration `005` "Refresh-token sessions table".
(SQLite enforces the cascade only with `PRAGMA foreign_keys=ON`.)

#### Mapping an existing `refresh_tokens` table

Option A, drop and re-login: `DROP TABLE refresh_tokens;` then apply the
library migration. Every user signs in once more.

Option B, migrate rows in place. Rename the old table, drop its indexes (the
new ones reuse the names `idx_refresh_tokens_family` / `_user`), apply
`REFRESH_TOKENS_MIGRATION_UP` (with `user_id_type` matching your users id),
copy, drop the old table. Timestamp conversion to the canonical text:

- SQLite: `strftime('%Y-%m-%dT%H:%M:%f', col) || '000+00:00'`
- PostgreSQL (`timestamptz`): `to_char(col AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"+00:00"')`
- MariaDB (UTC `DATETIME(6)`): `DATE_FORMAT(col, '%Y-%m-%dT%H:%i:%s.%f+00:00')`

Below, `TS(col)` stands for that expression (and stays NULL for NULL).

card-gen (same columns; only types/format change):

```sql
ALTER TABLE refresh_tokens RENAME TO refresh_tokens_legacy;
-- drop legacy indexes, then apply REFRESH_TOKENS_MIGRATION_UP
INSERT INTO refresh_tokens (id, token_hash, user_id, family_id, created_at, last_used_at,
    expires_at, absolute_expires_at, remember, rotated_at, replaced_by, revoked_at, user_agent)
SELECT CAST(id AS VARCHAR(64)), token_hash, user_id, CAST(family_id AS VARCHAR(64)),
       TS(created_at), TS(last_used_at), TS(expires_at), TS(absolute_expires_at),
       CASE WHEN remember THEN 1 ELSE 0 END, TS(rotated_at),
       CAST(replaced_by AS VARCHAR(64)), TS(revoked_at), user_agent
FROM refresh_tokens_legacy;
DROP TABLE refresh_tokens_legacy;
```

`replaced_by` must hold the successor row's `id` (it does in card-gen). If
the hash column has another name, select it `AS token_hash`.

vtt-cluster (id, token_hash, user_id, family_id, expires_at, revoked_at,
created_at). vtt revokes the old token on rotation, so its revoked rows keep
`revoked_at` and presenting one is still reuse:

```sql
ALTER TABLE refresh_tokens RENAME TO refresh_tokens_legacy;
-- drop legacy indexes, then apply refresh_tokens_migration_sql(user_id_type=<users.id type>)
INSERT INTO refresh_tokens (id, token_hash, user_id, family_id, created_at, last_used_at,
    expires_at, absolute_expires_at, remember, rotated_at, replaced_by, revoked_at, user_agent)
SELECT CAST(id AS VARCHAR(64)), token_hash, user_id, CAST(family_id AS VARCHAR(64)),
       TS(created_at), TS(created_at), TS(expires_at), NULL,
       0, NULL, NULL, TS(revoked_at), NULL
FROM refresh_tokens_legacy;
DROP TABLE refresh_tokens_legacy;
```

### Cookie helpers

- `set_refresh_cookie(response, issued, settings)`: `HttpOnly`, `Path`,
  `SameSite`, `Secure`, `Max-Age` to expiry.
- `clear_refresh_cookie(response, settings)`: same attributes, `Max-Age=0`.
- `refresh_token_from_request(request, settings, body_token=None)`: the
  cookie, else the body token (non-browser clients).

### Scaffolded endpoints (`jwt-auth-provider`)

| Endpoint | Auth | Behaviour |
|---|---|---|
| `POST /api/token` | public | Body `{username, password, remember?, returnRefreshToken?}`. Returns `{accessToken, tokenType, expiresIn, sessionId}`; sets the cookie |
| `POST /api/token/refresh` | cookie | Rotates the cookie, returns a new access token. Optional body `{refreshToken, returnRefreshToken}` when there is no cookie. 401 clears the cookie |
| `POST /api/token/logout` | cookie | Revokes the family, clears the cookie, 204. Works with an expired access token |
| `GET /api/sessions` | JWT | The caller's active sessions (`current` marks the caller's `sid`) |
| `DELETE /api/sessions/{id}` | JWT | Revoke one of the caller's sessions (404 if not theirs) |
| `POST /api/sessions/revoke-all` | JWT | Sign out everywhere: `{revoked: n}` |

Access tokens carry `sid` (session id). Revoking a session stops its refresh
token; access tokens already issued live until `exp`, so keep `access_ttl` short.

### Client contract

Browsers never see the refresh token. A client:

1. keeps the access token **in memory** (not `localStorage`);
2. sends every request with `credentials: 'include'`;
3. refreshes about **60 s before** `expiresIn` elapses (`POST /api/token/refresh`);
4. on a **401**, refreshes **once** and retries the request **once**; a second
   401 (or a 401 from refresh) means signed out;
5. coordinates tabs: a refresh runs under `navigator.locks.request(...)`, so
   one tab rotates and the others reuse its result (shared over a
   `BroadcastChannel`). Without Web Locks, the `BroadcastChannel` still shares
   new tokens and logouts, and the server's grace window absorbs the
   remaining races;
6. logs out with `POST /api/token/logout` (inside the cookie path, so the
   cookie is sent and the session revoked), then clears local state in
   every tab;
7. restores a session on page load with one refresh.

The `frontend-vue` scaffold ships this as `src/api/session.ts`
(`createSessionClient(options)`: `login`, `refresh`, `restore`, `logout`,
`fetch`, `getAccessToken`, `subscribe`, `dispose`), framework-agnostic and
dependency-free.

Behind the gateway, `/api/token*` must be public routes that forward
`Cookie` and `Set-Cookie` per request (never a shared cookie jar), and
`/api/sessions*` JWT-guarded routes to the auth service.

## Key ring readiness

`KeyRingManager.initialized` is `True` once `initialize()` has an active
key; `health()` reports it as `UP` / `DOWN`. The auth scaffold initialises
the key ring in the lifespan (`app.state.key_ring`) and passes
`health_gates={"keyRing": state_health_gate("key_ring", "initialized")}`
(csrd-versioning), so `/_info/health` is 503 until the first key exists.

## Authenticators

| Class | Strategy |
|-------|----------|
| `JWTAuthenticator` | Decode & verify JWT locally via PyJWT |
| `StaticAuthenticator` | Accept a fixed token (dev/testing) |
| `RemoteAuthenticator` | Validate via upstream HTTP endpoint |
| `CallbackAuthenticator` | Delegate to any sync/async callable |
| `ChainedAuthenticator` | Try multiple authenticators in order |

## Key Providers

| Class | Strategy |
|-------|----------|
| `StaticKeyProvider` | Fixed string, bytes, or `SecretStr` |
| `EnvKeyProvider` | Read from `app.state` or settings loader |
| `JWKSKeyProvider` | Fetch from JWKS endpoint (cached with TTL) |
| `MultiKeyProvider` | Route by `kid` header or try all |
| `CallbackKeyProvider` | Delegate to any callable |

## Protocols

- `Authenticator` — `(Request, str) -> UserClaims`
- `KeyProvider` — `(token_headers, app) -> key`

Both are `@runtime_checkable` protocols.

## Dependency Tier

```
Tier 1      csrd.models  csrd.lifespan  csrd.context
Tier 2      csrd.auth  csrd.logging  csrd.delegate  csrd.repository  csrd.service
Tier 3      csrd.versioning
```

`csrd.auth` depends only on `csrd.models` (for `UserClaims`) and `csrd.context` (for `user_info_context`). It has **no dependency** on `csrd.versioning`.
