Metadata-Version: 2.4
Name: hypershub-sso
Version: 0.1.0
Summary: Server-side OAuth 2.0/OIDC client for HypersHub SSO
Author: HypersHub
License-Expression: MIT
License-File: LICENSE
Keywords: bff,oauth2,oidc,openid-connect,pkce,sso
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: cryptography>=46.0.5
Requires-Dist: httpx<1,>=0.28.1
Requires-Dist: pyjwt<3,>=2.12.1
Description-Content-Type: text/markdown

# hypershub-sso

Typed synchronous and asynchronous OAuth 2.0 / OpenID Connect client for
HypersHub SSO. It implements Authorization Code + PKCE for Python backends and
BFFs, verifies ID Tokens, and manages refreshable server-side sessions.

> Use this package only in a trusted Python server. Never expose the OAuth
> client secret, PKCE verifier, authorization code, access token, or refresh
> token to browser code.

## Requirements

- Python 3.11 or newer
- An SSO client registration with an exact HTTPS redirect URI
- A server-side session store such as Redis, Valkey, or a database
- A high-entropy opaque browser session identifier in a host-only
  `HttpOnly; Secure; SameSite=Lax` cookie

## Install

```bash
python -m pip install hypershub-sso
```

## Synchronous client

Use `SsoClient` with Flask, Django, or another synchronous application:

```python
import os

from hypershub_sso import (
    KeyValueStore,
    SsoClient,
    create_encrypted_session_store,
)


class RedisStore(KeyValueStore):
    def get(self, key: str) -> str | None:
        value = redis.get(key)
        return value.decode() if value is not None else None

    def set(self, key: str, value: str, ttl_seconds: int) -> None:
        redis.set(key, value, ex=ttl_seconds)

    def delete(self, key: str) -> None:
        redis.delete(key)


session_store = create_encrypted_session_store(
    RedisStore(),
    os.environ["SSO_SESSION_ENCRYPTION_KEY"],
)

sso = SsoClient(
    issuer="https://main.example.com/sso",
    client_id=os.environ["SSO_CLIENT_ID"],
    client_secret=os.environ["SSO_CLIENT_SECRET"],
    redirect_uri="https://project.example.com/auth/callback",
    store=session_store,
)
```

## Asynchronous client

Use `AsyncSsoClient` with FastAPI, Starlette, Quart, or another asyncio
application. The raw store methods must also be async:

```python
from hypershub_sso import (
    AsyncSsoClient,
    create_async_encrypted_session_store,
)

session_store = create_async_encrypted_session_store(
    async_redis_store,
    os.environ["SSO_SESSION_ENCRYPTION_KEY"],
)

sso = AsyncSsoClient(
    issuer="https://main.example.com/sso",
    client_id=os.environ["SSO_CLIENT_ID"],
    client_secret=os.environ["SSO_CLIENT_SECRET"],
    redirect_uri="https://project.example.com/auth/callback",
    store=session_store,
)
```

Close a client during application shutdown with `sso.close()` or
`await sso.aclose()`. A caller-supplied `httpx.Client` or `httpx.AsyncClient`
remains owned by the caller and is not closed by the SDK.

## Route integration

The SDK does not own framework cookies or responses. Your application supplies
the opaque browser session ID as `key`:

```python
# GET /auth/login
return redirect(sso.begin_login(session_id, "/dashboard"))

# GET /auth/callback?code=...&state=...
result = sso.handle_callback(session_id, request.args)
return redirect(result.return_to)

# Before a protected handler
session = sso.require_session(session_id)
user_subject = session.principal.sub

# POST /auth/logout; protect this route with normal CSRF controls
sso.logout(session_id)
clear_session_cookie()
```

For `AsyncSsoClient`, await each method. Only the opaque session ID belongs in
the cookie; token-bearing `SsoSession` objects remain in server-side storage.

## Encryption key

Generate a 256-bit key once, then store it in your secret manager:

```bash
python -c "from hypershub_sso import generate_session_encryption_key as g; print(g())"
```

The encrypted adapters use AES-256-GCM and authenticate the storage key to
prevent ciphertext swapping. Rotating the key requires a key-ring migration or
invalidating existing local sessions. Never commit it to source control.

For multi-process deployments, the raw store may implement `with_lock` using a
Redis `SET NX` lock and owner-token checked Lua release. The encrypted adapter
automatically exposes it to the client as a distributed refresh lock.

## Errors

Expected failures are `SsoClientError` instances with stable codes:

```python
from hypershub_sso import SsoClientError

try:
    session = sso.require_session(session_id)
except SsoClientError as error:
    if error.code == "AUTH_REAUTH_REQUIRED":
        return redirect("/auth/login")
    if error.retriable:
        return service_unavailable()
    raise
```

- `AUTH_REAUTH_REQUIRED`: the local or refresh session is invalid; begin login.
- `SSO_TEMPORARILY_UNAVAILABLE`: network, timeout, rate-limit, JWKS, or SSO 5xx
  failure. Return 503 or retry later; do not log the user out.
- `SSO_STATE_MISMATCH`, `SSO_NONCE_MISMATCH`, `SSO_ID_TOKEN_INVALID`: reject
  the callback and do not create an application session.
- `SSO_TOKEN_ERROR`: the token endpoint rejected a non-retryable request.

Messages do not include credentials or tokens. Optional SDK logs contain only
fixed text and non-sensitive error metadata.

## Defaults

| Option | Default | Purpose |
|---|---:|---|
| `refresh_ahead_seconds` | `300` | Refresh before access-token expiry |
| `session_idle_seconds` | `43200` | Sliding local-session idle lifetime |
| `session_absolute_seconds` | `2592000` | Maximum local-session lifetime |
| `refresh_max_attempts` | `3` | Attempts for transient refresh failures |
| `refresh_retry_base_delay` | `0.2` | Initial exponential delay in seconds |
| `request_timeout` | `10` | Token, revocation, and JWKS timeout |
| `clock_tolerance_seconds` | `60` | ID Token clock-skew tolerance |
| `validate_return_to` | relative paths only | Prevent open redirects |

Remote JWKS honor bounded `Cache-Control: max-age` caching. An unknown `kid`
causes one immediate refresh. Plain HTTP is rejected except for loopback
development hosts unless `allow_insecure_http=True` is explicitly set.

## License

[MIT](./LICENSE)
