Metadata-Version: 2.4
Name: gopaperless-auth
Version: 0.1.0
Summary: Reusable OAuth2 authentication against GoPaperless (Nextcloud/LibreSign) — authorize, callback, and auto-refreshing bearer tokens keyed on an opaque subject.
Project-URL: Homepage, https://github.com/astralyngroup/gopaperless-auth
Project-URL: Issues, https://github.com/astralyngroup/gopaperless-auth/issues
Author: Astralyn Group
License: MIT
License-File: LICENSE
Keywords: authentication,gopaperless,libresign,nextcloud,oauth2,sso
Classifier: Framework :: AsyncIO
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Requires-Python: >=3.9
Requires-Dist: cryptography>=41.0
Requires-Dist: httpx>=0.24
Provides-Extra: dev
Requires-Dist: aiosqlite>=0.19; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=7.4; extra == 'dev'
Provides-Extra: sql
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'sql'
Description-Content-Type: text/markdown

# gopaperless-auth (Python)

Reusable OAuth2 authentication against **GoPaperless**. Drop it into any backend so your
users log in with their GoPaperless account and your app gets an **auto-refreshing bearer
token** for them — no passwords handled, no token lifecycle to reinvent.

```bash
pip install gopaperless-auth
```

## Why

Your users own GoPaperless accounts. This library runs the OAuth2 authorization-code flow
on their behalf, fetches their GoPaperless identity (account id, email, display name), and
stores encrypted tokens keyed on a **subject** *you* choose (a user id, a phone number —
anything). Later, `get_valid_token(subject)` hands you a token that's valid *right now*,
refreshing transparently when needed.

## 60-second quick start

```python
from gopaperless_auth import GoPaperlessAuth

auth = GoPaperlessAuth(
    base_url="https://gopaperless.ke",
    client_id="my-app-client-id",       # from your GoPaperless admin (OAuth clients)
    client_secret="…",
    redirect_uri="https://my-app.com/auth/callback",
    encryption_key="<fernet-key>",      # Fernet.generate_key().decode()
    # store=None  -> in-memory (dev). See "Storage tiers" for production.
)

# 1. Send the user to GoPaperless to log in
url = auth.authorize_url(subject="user-123", extra={"return_to": "/documents"})
#    -> redirect the user's browser to `url`

# 2. GoPaperless redirects back to your redirect_uri with ?code=…&state=…
result = await auth.handle_callback(code, state)
#    result.subject == "user-123"; result.identity.user_id / .email / .display_name

# 3. Anywhere later, get a valid bearer token (auto-refreshes)
token = await auth.get_valid_token("user-123")
#    use it: Authorization: Bearer {token}  against the GoPaperless API
```

Generate the encryption key once and keep it secret (env var / secrets manager):

```python
from cryptography.fernet import Fernet
print(Fernet.generate_key().decode())
```

## Storage tiers

You never have to run Redis. Tokens just need to live *somewhere*; pick the tier that fits.

| Tier | How | When |
|------|-----|------|
| **0 — in-memory** (default) | `store=None` | dev / trying it out (lost on restart) |
| **1 — database URL** | `store="postgresql+asyncpg://…"` or `"sqlite+aiosqlite:///tokens.db"` | production; the SDK creates & manages its own `gopaperless_oauth_tokens` table (needs `pip install "gopaperless-auth[sql]"`) |
| **2 — your own store** | `store=MyStore()` | persist inside your existing user DB |
| **3 — stateless** | use `OAuthClient` directly | "just give me the token, I'll handle storage" |

**Tier 2 — bring your own storage** (implement three async methods):

```python
from gopaperless_auth import TokenStore, TokenRecord

class MyStore(TokenStore):
    async def get(self, subject: str) -> TokenRecord | None: ...
    async def save(self, record: TokenRecord) -> None: ...
    async def delete(self, subject: str) -> None: ...

auth = GoPaperlessAuth(..., store=MyStore())
```

Records handed to a store are **already encrypted** — your store persists opaque strings
and never sees the encryption key.

**Tier 3 — fully stateless** (no store, no lifecycle opinions):

```python
from gopaperless_auth.client import OAuthClient
from gopaperless_auth import OAuthConfig

client = OAuthClient(OAuthConfig(base_url=…, client_id=…, client_secret=…,
                                 redirect_uri=…, encryption_key=…))
tokens = await client.exchange_code(code)      # raw access/refresh, stored nowhere
identity = await client.fetch_identity(tokens.access_token)
fresh = await client.refresh(tokens.refresh_token)
```

## FastAPI example

```python
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse

app = FastAPI()
auth = GoPaperlessAuth(..., store="postgresql+asyncpg://…")

@app.get("/login")
async def login(user_id: str):
    return RedirectResponse(auth.authorize_url(subject=user_id))

@app.get("/auth/callback")               # must match the registered redirect_uri
async def callback(code: str, state: str):
    result = await auth.handle_callback(code, state)
    return {"linked": result.identity.email}

@app.get("/sign")
async def sign(user_id: str):
    token = await auth.get_valid_token(user_id)   # raises TokenExpiredError if re-auth needed
    # call GoPaperless with Authorization: Bearer {token}
```

## API reference

`GoPaperlessAuth(base_url, client_id, client_secret, redirect_uri, encryption_key, store=None, token_expiry_margin=120)`

- `authorize_url(subject, extra=None) -> str` — URL to send the user to. `state` is
  HMAC-signed so a callback can't be spoofed with an arbitrary subject.
- `await handle_callback(code, state) -> AuthResult` — exchanges the code, fetches
  identity, persists encrypted tokens. Returns `AuthResult(subject, identity, tokens, extra)`.
- `await get_valid_token(subject) -> str` — valid bearer token, auto-refreshed. Raises
  `TokenNotFoundError` (never authed) or `TokenExpiredError` (refresh dead → send them
  through `authorize_url` again).
- `await get_identity(subject) -> Identity | None`
- `await logout(subject)` — delete stored tokens.

## Errors

`GoPaperlessAuthError` (base) · `ConfigError` · `CodeExchangeError` · `TokenExpiredError`
· `TokenNotFoundError` · `StateError`.

## Provider setup (one-time, per app)

In your GoPaperless admin, register an **OAuth client** for your app. Set the redirect URI
to your app's callback (it's validated exactly). Copy the generated **Client Identifier**
and **Secret** into your config. Register **one client per app** so each can be
rotated/revoked independently.

> **Note — no scopes.** GoPaperless issues full-account tokens; there is no "signing-only"
> scope. Treat the token as full access to that user's GoPaperless account.

## License

MIT.
