Metadata-Version: 2.4
Name: auth-paseto
Version: 0.2.0
Summary: Access/refresh token authentication built on PASETO, a safer alternative to JWT.
Keywords: paseto,authentication,token,jwt,fastapi,security
Author: Kohio Deflesselle
Author-email: Kohio Deflesselle <kohiodef@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Classifier: Topic :: Internet :: WWW/HTTP :: Session
Classifier: Typing :: Typed
Requires-Dist: pydantic>=2.12.5
Requires-Dist: pyseto>=1.9.1
Requires-Python: >=3.12
Project-URL: Homepage, https://gitlab.com/Soralsei/auth_paseto
Project-URL: Source, https://gitlab.com/Soralsei/auth_paseto
Project-URL: Issues, https://gitlab.com/Soralsei/auth_paseto/-/issues
Description-Content-Type: text/markdown

# auth-paseto

[![PyPI](https://img.shields.io/pypi/v/auth-paseto)](https://pypi.org/project/auth-paseto/)
[![Python](https://img.shields.io/pypi/pyversions/auth-paseto)](https://pypi.org/project/auth-paseto/)
[![License](https://img.shields.io/pypi/l/auth-paseto)](https://gitlab.com/Soralsei/auth_paseto/-/blob/main/LICENSE)

Access/refresh token authentication built on [PASETO](https://paseto.io/) (Platform-Agnostic
Security Tokens), a safer alternative to JWT. Framework-agnostic, with first-class support for
async frameworks such as [FastAPI](https://fastapi.tiangolo.com/).

## Features

- **PASETO v1–v4** (defaults to v4), powered by [pyseto](https://github.com/dajiaji/pyseto).
- **Both purposes:** `local` (symmetric encryption) and `public` (asymmetric signatures).
- **Access and refresh tokens** with independent lifetimes and type checking on verification.
- **Fresh tokens** for sensitive operations (e.g. password changes).
- **Revocation** through an async callback (e.g. a denylist in Redis/Valkey).
- **Typed:** Pydantic configuration and a `py.typed` marker.

## Installation

```bash
pip install auth-paseto
# or
uv add auth-paseto
```

Requires Python 3.12+.

## Quick start

```python
import asyncio
import secrets

from auth_paseto import AuthConfig, PASETOAuth

auth = PASETOAuth(AuthConfig(secret_key=secrets.token_urlsafe(32)))

token = auth.create_access_token(subject="user_123", user_claims={"role": "admin"})
payload = asyncio.run(auth.get_access_token_payload(token))

print(payload["sub"], payload["role"])  # user_123 admin
```

## Configuration

### Local (symmetric) tokens

```python
from auth_paseto import AuthConfig, TokenPurpose

config = AuthConfig(
    secret_key="<at least 32 random bytes>",  # e.g. secrets.token_urlsafe(32)
    purpose=TokenPurpose.LOCAL,  # default
)
```

Use a long, randomly generated secret loaded from your environment or secret manager; never
commit it.

### Public (asymmetric) tokens

Tokens are signed with the private key and verified with the public key. Keys are PEM-encoded
and can be given inline (`private_key`, `public_key`) or as file paths:

```python
from auth_paseto import AuthConfig, TokenPurpose

config = AuthConfig(
    purpose=TokenPurpose.PUBLIC,
    private_key_file="keys/private.pem",
    public_key_file="keys/public.pem",
)
```

For v4 (the default), generate an Ed25519 key pair:

```bash
openssl genpkey -algorithm ed25519 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem
```

### Options

| Option | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `secret_key` | `str` | `None` | Symmetric key. Required for `local`. |
| `private_key` / `private_key_file` | `str` | `None` | PEM private key (or path to it). Required for `public`. |
| `public_key` / `public_key_file` | `str` | `None` | PEM public key (or path to it). Required for `public`. |
| `purpose` | `TokenPurpose` | `LOCAL` | `local` or `public`. |
| `version` | `PASETOVersion` | `v4` | PASETO protocol version (1–4). |
| `access_token_expires` | `int \| timedelta \| False` | `30 min` | Access token lifetime (seconds if `int`). `False` disables expiry. |
| `refresh_token_expires` | `int \| timedelta` | `15 days` | Refresh token lifetime (seconds if `int`). |
| `decode_leeway` | `int \| timedelta` | `2` | Clock-skew tolerance when verifying, in seconds. |
| `encode_issuer` | `str` | `None` | Sets the `iss` claim on issued tokens. |
| `encode_audience` | `str` | `""` | Sets the `aud` claim on issued tokens (omitted if empty). |
| `decode_issuer` | `str` | `None` | If set, verification rejects tokens whose `iss` claim differs. |
| `decode_audience` | `str` | `None` | If set, verification rejects tokens whose `aud` claim differs. |

## Usage

### Creating tokens

```python
access = auth.create_access_token(subject="user_123", user_claims={"role": "admin"})
fresh = auth.create_access_token(subject="user_123", fresh=True)
refresh = auth.create_refresh_token(subject="user_123")
```

Every token carries `sub`, `jti` (a UUID4), `type`, `iat`, `nbf` and `exp` claims; access tokens
also carry `fresh`. Pass `base64_encode=True` to get the token base64-encoded.

### Verifying tokens

The verification methods are coroutines that return the decoded payload as a `dict`:

```python
payload = await auth.get_access_token_payload(token)        # access tokens only
payload = await auth.get_fresh_access_token_payload(token)  # fresh access tokens only
payload = await auth.get_refresh_token_payload(token)       # refresh tokens only
```

On failure they raise `TokenVerificationError`. Its `detail` attribute is a Pydantic model with
a machine-readable `type` (`ErrorCode`) and a human-readable `reason`:

| `ErrorCode` | Raised when |
| :--- | :--- |
| `TOKEN_MISSING` | The token is empty or `None`. |
| `TOKEN_INVALID_ERROR` | The token is malformed, tampered with, expired, not yet valid, or has the wrong issuer/audience. |
| `UNEXPECTED_TOKEN_TYPE` | E.g. a refresh token was passed where an access token was expected. |
| `EXPECTED_FRESH_TOKEN` | A fresh token was required but the token is not fresh. |
| `TOKEN_REVOKED` | The revocation callback reported the token as revoked. |

```python
from auth_paseto import ErrorCode, TokenVerificationError

try:
    payload = await auth.get_access_token_payload(token)
except TokenVerificationError as e:
    if e.detail.type == ErrorCode.TOKEN_INVALID_ERROR:
        ...
```

### Revocation

Pass an async callback that receives the token's `jti` and returns `True` if the token is
revoked. It runs on every verification.

```python
async def is_revoked(jti: str) -> bool:
    return await redis.exists(f"revoked:{jti}") > 0

auth = PASETOAuth(config, is_revoked_callback=is_revoked)
```

To revoke a token (e.g. on logout), store its `jti` until the token would have expired:

```python
payload = await auth.get_refresh_token_payload(token)
await redis.set(f"revoked:{payload['jti']}", 1, ex=auth.refresh_token_expires)
```

## FastAPI integration

auth-paseto has no dependency on FastAPI. Wire it in with a token extractor, a dependency and an
exception handler:

```python
from typing import Annotated, Any

from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordBearer

from auth_paseto import AuthConfig, PASETOAuth, TokenVerificationError

auth = PASETOAuth(AuthConfig(secret_key=os.environ["AUTH_SECRET_KEY"]))
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login")

app = FastAPI()


@app.exception_handler(TokenVerificationError)
async def token_error_handler(request: Request, exc: TokenVerificationError):
    return JSONResponse(
        status_code=401,
        content=exc.detail.model_dump(mode="json"),
        headers={"WWW-Authenticate": "Bearer"},
    )


async def access_payload(token: Annotated[str, Depends(oauth2_scheme)]) -> dict[str, Any]:
    return await auth.get_access_token_payload(token)


async def fresh_payload(token: Annotated[str, Depends(oauth2_scheme)]) -> dict[str, Any]:
    return await auth.get_fresh_access_token_payload(token)


@app.post("/login")
async def login():
    # ... authenticate the user first ...
    user_id = "user_123"
    return {
        "access_token": auth.create_access_token(subject=user_id, fresh=True),
        "refresh_token": auth.create_refresh_token(subject=user_id),
        "token_type": "bearer",
    }


@app.get("/me")
async def me(payload: Annotated[dict[str, Any], Depends(access_payload)]):
    return {"user_id": payload["sub"]}


@app.post("/change-password")
async def change_password(payload: Annotated[dict[str, Any], Depends(fresh_payload)]):
    return {"message": "Access granted to sensitive operation"}
```

## Development

```bash
git clone https://gitlab.com/Soralsei/auth_paseto.git
cd auth_paseto
uv sync
uv run pytest
```

## License

[MIT](LICENSE)
