Metadata-Version: 2.3
Name: nyxa-auth
Version: 0.1.0
Summary: Auth and policies for Nyxa — JWT, password hashing, Gate helpers
Keywords: fastapi,auth,jwt,policies,nyxa
Author: Al-Amin Islam Nerob
Author-email: Al-Amin Islam Nerob <alamin@aincoder.com>
License: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Dist: nyxa>=0.1.0
Requires-Dist: fastapi>=0.115.0
Requires-Dist: pyjwt>=2.8.0
Requires-Dist: pwdlib[argon2]>=0.2.0
Requires-Dist: itsdangerous>=2.0.0
Requires-Dist: authlib>=1.3.0 ; extra == 'oauth'
Requires-Dist: httpx>=0.27.0 ; extra == 'oauth'
Requires-Python: >=3.11
Project-URL: Homepage, https://nyxadev.com
Project-URL: Repository, https://github.com/NyxaDev/nyxa
Provides-Extra: oauth
Description-Content-Type: text/markdown

# nyxa-auth — JWT / session auth & policies for Nyxa

Authentication and authorization for apps built with
[nyxa](https://pypi.org/project/nyxa/). Core `nyxa` does **not**
depend on this package (no JWT in the core install).

## At a glance

| What you need | Nyxa Auth |
| --- | --- |
| Protect routes | `Route.middleware(require_auth)` or `CurrentUser` Depends |
| Current user | `get_current_user` / `CurrentUser` / core `user()` stubs |
| Policies | `Policy` subclasses under `policies/` + `authorize(...)` |
| Hash / verify passwords | `hash_password` / `verify_password` |
| JWT tokens | `create_access_token` (`AUTH_DRIVER=jwt`, default) |
| Cookie sessions | `login_user` / `logout_user` + `configure_session` (`AUTH_DRIVER=session`) |
| Personal access tokens (PATs) | `create_personal_token` + `set_token_lookup` (Bearer alongside JWT) |
| OAuth (Google/GitHub) | `create_authorize_redirect` / `complete_oauth_login` + `oauth_identities` (`nyxa-auth[oauth]`) |

## Install

```bash
uv add nyxa-auth
uv add 'nyxa-auth[oauth]'   # Authlib — Google / GitHub OAuth
# JWT (default driver):
uv run nyxa init myapi --database sqlite --auth jwt --no-input
# Session cookies:
uv run nyxa init myapi --database sqlite --auth session --no-input
```

### JWT driver

```bash
AUTH_DRIVER=jwt
AUTH_SECRET_KEY=change-me-to-a-long-random-secret!!
# AUTH_ALGORITHM=HS256
# AUTH_ACCESS_TOKEN_EXPIRE_MINUTES=60
```

### Session driver

```bash
AUTH_DRIVER=session
AUTH_SECRET_KEY=change-me-to-a-long-random-secret!!
# AUTH_SESSION_SECRET=  # optional override for cookie signing
```

In `main.py`:

```python
from nyxa_auth import configure_session, set_user_loader

app = FastAPI()
configure_session(app)
set_user_loader(...)
```

Login stores `user_id` in a signed cookie; `require_auth` reads it instead of Bearer JWT.

### Personal access tokens

Requires `nyxa-db`. Under `AUTH_DRIVER=jwt`, Bearer auth tries JWT first, then a registered personal-token lookup.

```python
from nyxa_auth import (
    create_personal_token,
    find_token,
    set_token_lookup,
    set_user_loader,
)

def lookup(plain: str) -> str | None:
    session = get_session_factory()()
    try:
        row = find_token(session, plain)
        if row is None:
            return None
        session.commit()
        return str(row.tokenable_id)
    finally:
        session.close()

set_user_loader(...)
set_token_lookup(lookup)

# Issue (plaintext once):
issued = create_personal_token(session, user.id, "ci")
# Authorization: Bearer nyxa_<id>_<secret>
```

Table: `personal_access_tokens` (hash only). Revoke with `revoke_personal_token` / `revoke_all_for_user`.

### OAuth (Google + GitHub)

Requires the optional extra and Authlib. Identity rows need `nyxa-db`:

```bash
uv add 'nyxa-auth[oauth]'
```

```bash
OAUTH_GOOGLE_CLIENT_ID=...
OAUTH_GOOGLE_CLIENT_SECRET=...
OAUTH_GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/api/oauth/google/callback
OAUTH_GITHUB_CLIENT_ID=...
OAUTH_GITHUB_CLIENT_SECRET=...
OAUTH_GITHUB_REDIRECT_URI=http://127.0.0.1:8000/api/oauth/github/callback
AUTH_SECRET_KEY=...   # also signs the OAuth state cookie
```

```python
from nyxa_auth import (
    clear_oauth_state_cookie,
    complete_oauth_login,
    create_authorize_redirect,
    provider_from_env,
    resolve_oauth_identity,
)

provider = provider_from_env("google")  # or "github"
# GET /oauth/google/redirect → create_authorize_redirect(request, provider)
# callback → resolve_oauth_identity(...) then complete_oauth_login(...)
# JWT driver returns {"access_token", "token_type"}; session driver returns {"ok": true}
```

Users are linked by `(provider, sub)` in `oauth_identities`. On first login, email
auto-link attaches the identity to an existing user when emails match (trusts the
provider). Existing Google users get an identity row on their next OAuth login.

## Quick start

```python
from typing import Annotated

from fastapi import Depends, FastAPI
from nyxa import Route, register
from nyxa_auth import (
    CurrentUser,
    create_access_token,
    hash_password,
    require_auth,
    set_user_loader,
    verify_password,
)

app = FastAPI()

# Map JWT "sub" → your user object (sync callable).
users = {"1": {"id": 1, "email": "ada@example.com", "password_hash": hash_password("secret")}}
set_user_loader(lambda sub: users.get(sub))
register(app)

@app.post("/login")
def login(email: str, password: str) -> dict[str, str]:
    user = next((u for u in users.values() if u["email"] == email), None)
    if user is None or not verify_password(password, user["password_hash"]):
        from nyxa_auth import AuthenticationError
        raise AuthenticationError("Invalid credentials")
    return {"access_token": create_access_token(str(user["id"])), "token_type": "bearer"}

# Protect a route group:
with Route.prefix("/api"), Route.middleware(require_auth):
    Route.get("/me", lambda user: user)  # prefer a controller in real apps
```

Or inject the user on a controller action:

```python
from nyxa_auth import CurrentUser

async def me(self, user: CurrentUser) -> dict:
    return {"id": getattr(user, "id", None)}
```

## Policies

```bash
uv run nyxa make:policy User
```

```python
from nyxa_auth import Policy, authorize

class UserPolicy(Policy):
    def update(self, user, model=None) -> bool:
        return model is not None and user.id == model.id

# In a controller:
authorize(user, UserPolicy, "update", target_user)
```

Denied abilities raise `AuthorizationError` (HTTP 403). Missing/invalid
credentials raise `AuthenticationError` (HTTP 401).
