Metadata-Version: 2.5
Name: aiogram-keycloak-sso
Version: 0.2.1
Summary: SSO (OpenID Connect / Keycloak) authentication middleware for aiogram 3
Project-URL: Homepage, https://github.com/ggb0yyyyyyy/sso-aiogram-midleware
Project-URL: Repository, https://github.com/ggb0yyyyyyy/sso-aiogram-midleware
Project-URL: Issues, https://github.com/ggb0yyyyyyy/sso-aiogram-midleware/issues
Project-URL: Documentation, https://github.com/ggb0yyyyyyy/sso-aiogram-midleware#readme
Author-email: Mikhail Logunov <milog7890@gmail.com>
License: MIT
License-File: LICENSE
Keywords: aiogram,keycloak,middleware,oauth2,oidc,sso,telegram
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Internet :: WWW/HTTP :: Session
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: aiogram>=3.4
Requires-Dist: aiohttp>=3.9
Requires-Dist: pydantic>=2.4
Requires-Dist: pyjwt[crypto]>=2.8
Provides-Extra: dev
Requires-Dist: pytest-aiohttp>=1.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: redis
Requires-Dist: redis>=5.0; extra == 'redis'
Description-Content-Type: text/markdown

# aiogram-keycloak-sso

[![PyPI](https://img.shields.io/pypi/v/aiogram-keycloak-sso.svg)](https://pypi.org/project/aiogram-keycloak-sso/)
[![Python](https://img.shields.io/pypi/pyversions/aiogram-keycloak-sso.svg)](https://pypi.org/project/aiogram-keycloak-sso/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

SSO (OpenID Connect / **Keycloak**) authentication middleware for
[aiogram 3](https://github.com/aiogram/aiogram): outer/inner middlewares,
handler flags, DI through the `data` dict, and a swappable storage protocol.

This library is the **bot-side** adapter (login URL, session middleware,
`consume_login_events`). Completing the OIDC browser callback is **pluggable**:
any backend that writes the session and publishes a `LoginEvent` on the shared
event bus works. Wire DTOs (`SSOSession`, login events, Redis prefixes) must
stay in sync with that backend (`schema_version`).

![SSO login flow](https://raw.githubusercontent.com/ggb0yyyyyyy/sso-aiogram-midleware/master/docs/sso-flow.svg)

## Callback backends (pick one)

| Backend | Role |
|---|---|
| **[`keycloak-sso-gateway`](https://pypi.org/project/keycloak-sso-gateway/)** ([GitHub](https://github.com/ggb0yyyyyyy/keycloak-sso-gateway)) | Optional ready-made process: code exchange, Redis sessions, `RedisEventBus.publish` |
| **Your HTTP service** | Any stack that calls the same storage + bus contract (or embeds `SSOManager.handle_callback`) |
| **`CallbackHttpListener`** (this package) | Opt-in minimal aiohttp `GET /sso/callback`; **off by default** until `mount()` / `start()` |

Recommended for fleets: keep the callback in a separate process (gateway or
your own). For a single bot / lab setup, attach an inline listener:

```python
from aiogram_keycloak_sso import CallbackHttpListener, FleetBotManager, MemoryEventBus

bus = MemoryEventBus()  # or RedisEventBus
listener = CallbackHttpListener(event_bus=bus)
manager.add_listener(listener)          # bind only — no server yet
listener.mount(existing_aiohttp_app)    # or: await listener.start(port=8080)
```

## How it maps onto aiogram's design

| aiogram concept | aiogram-keycloak-sso counterpart |
|---|---|
| outer middleware | `SSOSessionMiddleware` — injects `sso_session`, renews at the IdP when TTL elapses |
| inner middleware + handler flags | `SSOAccessMiddleware` + `@sso_required` |
| DI via `data` dict | `sso_session: SSOSession \| None`, `sso_manager: SSOManager` |
| FSM `BaseStorage` protocol | `BaseSessionStorage` → `MemorySessionStorage`, `RedisSessionStorage` |

## Install

```bash
pip install aiogram-keycloak-sso
pip install "aiogram-keycloak-sso[redis]"   # Redis sessions + event bus
```

Import package name (underscore): `aiogram_keycloak_sso`.

## Quick start (bot process + external callback)

```python
from aiogram import Bot, Dispatcher, Router
from aiogram.filters import Command
from redis.asyncio import Redis

from aiogram_keycloak_sso import (
    AccessPolicy,
    FleetBotManager,
    SSOConfig,
    SSOSession,
    SSOSessionMiddleware,
    SSOAccessMiddleware,
    sso_required,
    login_notifier,
    login_failed_notifier,
    login_denied_notifier,
    consume_login_events,
)
from aiogram_keycloak_sso.events_redis import RedisEventBus
from aiogram_keycloak_sso.storage.redis import RedisSessionStorage

POLICY = AccessPolicy(groups=["/bots/alpha"])

redis = Redis.from_url("redis://localhost:6379/0")
config = SSOConfig(
    issuer="https://keycloak.example.com/realms/main",
    client_id="my-bot",
    client_secret="...",
    redirect_uri="https://sso.example.com/sso/callback",  # callback public URL
    state_secret="long-random-string-keep-it-secret",     # same as on the callback backend
    refresh_leeway=30,
)
manager = FleetBotManager("alpha", config, RedisSessionStorage(redis))

bot = Bot(token="...")
dp = Dispatcher()
router = Router()
dp.update.outer_middleware(SSOSessionMiddleware(manager, policy=POLICY))
router.message.middleware(SSOAccessMiddleware())
dp.include_router(router)

@router.message(Command("me"))
@sso_required
async def me(message, sso_session: SSOSession):
    await message.answer(f"{sso_session.username} / roles: {sso_session.realm_roles}")

@router.message(Command("admin"))
@sso_required(realm_roles=["admin"])
async def admin(message, sso_session: SSOSession): ...

await consume_login_events(
    RedisEventBus(redis),
    "alpha",
    on_login=login_notifier(bot),
    on_login_failed=login_failed_notifier(bot),
    policy=POLICY,
    on_denied=login_denied_notifier(bot),
)
# then: await dp.start_polling(bot)
```

Runnable example:
[`examples/bot_alpha.py`](https://github.com/ggb0yyyyyyy/sso-aiogram-midleware/blob/master/examples/bot_alpha.py).
Required process env vars are listed in
[`.env.example`](https://github.com/ggb0yyyyyyy/sso-aiogram-midleware/blob/master/.env.example)
(example only — the package does not load `.env`).

## After a successful login

| Hook | Where | Purpose |
|---|---|---|
| `on_login` / `login_notifier` via `consume_login_events` | **Telegram chat** | Success message, continue button (`result.state.next`) |
| `on_denied` / `login_denied_notifier` | **Telegram chat** | IdP ok, but bot `policy` rejected — "no access to this bot" |
| `on_login_failed` / `login_failed_notifier` | **Telegram chat** | Failure reaction |
| `post_login_redirect` (`SSOConfig`) | **Browser** (callback backend) | Deep link back to `t.me/<bot>` |

```python
from aiogram_keycloak_sso import login_notifier, login_failed_notifier, login_denied_notifier

await consume_login_events(
    bus, "alpha",
    on_login=login_notifier(bot),                # "✅ Signed in as {display_name}"
    on_login_failed=login_failed_notifier(bot),  # "❌ Sign-in was not completed..."
    policy=POLICY,
    on_denied=login_denied_notifier(bot),        # "❌ ... no access to this bot"
)
```

Chat hooks live on `consume_login_events` (event bus). The callback backend —
gateway, custom service, or `CallbackHttpListener` — writes the session and
publishes the event. Same-process setups can also pass `on_login` /
`on_login_failed` into `SSOManager` / `FleetBotManager` directly.

### Localization

Built-in strings (`en` / `ru`) are localizable; every component accepts `locale`:

```python
SSOAccessMiddleware()                    # locale="auto" → Telegram language_code
SSOAccessMiddleware(locale="ru-RU")      # pin one language
login_notifier(bot)                      # auto: IdP locale claim, then Telegram
```

Add a language with `register_texts("de", SSOTexts(...))`.

### Continue where the user left off (`next`)

`SSOAccessMiddleware` captures the interrupted action into signed `state`.
After login it returns as `result.state.next` for a "continue" button.

## Session lifecycle & TTL

- TTL defaults to the access token's `expires_in`; `session_ttl` caps it.
- Outer middleware refreshes via the refresh grant when TTL elapses (minus
  `refresh_leeway`). Concurrent updates for one user refresh once.
- Rejected refresh → session dropped → protected handlers show a login button.
- `manager.logout(user_id)` revokes the refresh token (RFC 7009) and deletes
  the session.

### Optional claims enricher

Prefer putting org attributes in Keycloak / FreeIPA claims. If you need an
extra directory lookup, pass a `SessionEnricher` into `FleetBotManager` /
`SSOManager` — it runs on login (`handle_callback`) and again on each OIDC
token refresh.

## Security notes

- `state` is HMAC-SHA256-signed (telegram user id, timestamp, nonce).
- OIDC ID-token `nonce` is **optional** (`require_id_token_nonce=False` by default);
  enable it only if your IdP echoes `nonce` into the ID token.
- ID tokens validated against realm JWKS (`iss`, `aud`, `exp`).

## Keycloak setup

1. Confidential client, *Standard flow* enabled.
2. Add the **callback** URL to *Valid redirect URIs* (one URI for the fleet).
3. Role mappers with *Add to ID token* enabled (`realm_access.roles` /
   `resource_access.*.roles`). Verify with *Client scopes → Evaluate*.

## Multi-bot fleets

| Package | Role |
|---|---|
| **[aiogram-keycloak-sso](https://github.com/ggb0yyyyyyy/sso-aiogram-midleware)** (this repo) | Middleware, flags, `FleetBotManager`, `consume_login_events`, optional `CallbackHttpListener` |
| **[keycloak-sso-gateway](https://github.com/ggb0yyyyyyy/keycloak-sso-gateway)** | Optional ready-made callback server + Redis publish |

Wire contracts (duplicated across bot + callback packages; keep in sync via `schema_version`):

| Contract | Default |
|---|---|
| Redis session keys | `keycloak_sso:session:{bot_id}:{telegram_user_id}` |
| Event channels | `keycloak_sso:events:{bot_id}` |
| State wire form | `bot:{bot_id}:{signed_state}` (HMAC short keys `u/c/n/t/x` in each app's `StateManager`) |

Isolation is derived from `bot_id` on both sides (storage namespace, event
channel, derived state secret). If the callback backend is down, *new* logins
pause; bots keep serving live sessions from storage.

### Access control (`AccessPolicy`)

When per-bot Keycloak clients are not an option, authorize on the consumer:

```python
from aiogram_keycloak_sso import AccessPolicy

POLICY = AccessPolicy(
    groups=["/bots/alpha"],
    claims={"department": ["R&D", "IT"]},
)

dp.update.outer_middleware(SSOSessionMiddleware(manager, policy=POLICY))
await consume_login_events(
    bus, "alpha",
    on_login=login_notifier(bot),
    policy=POLICY,
    on_denied=login_denied_notifier(bot),
)
```

Compose with `|` / `&` (`RND | INVITED`). Same object works with
`@sso_required(POLICY)`.

## Custom storage

Implement `BaseSessionStorage` (`get` / `set` / `delete`). Redis backend:
`aiogram_keycloak_sso.storage.redis` (requires `[redis]` extra).

## Development

```bash
git clone https://github.com/ggb0yyyyyyy/sso-aiogram-midleware.git
cd sso-aiogram-midleware
pip install -e ".[dev,redis]"
pytest
```

## License

MIT — see [LICENSE](LICENSE).
