Metadata-Version: 2.5
Name: oidc-session-broker
Version: 0.1.1
Summary: A session broker for OAuth2/OIDC identity providers: keeps SSO sessions warm and mints per-client application sessions on demand.
Project-URL: Homepage, https://github.com/lucascaovilla/oidc-session-broker
Project-URL: Issues, https://github.com/lucascaovilla/oidc-session-broker/issues
Author: Lucas Caovilla
License-Expression: MIT
License-File: LICENSE
Keywords: broker,cdp,chrome-devtools-protocol,cookie-jar,oauth2,oidc,session,sso
Classifier: Development Status :: 3 - Alpha
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: Topic :: Internet :: WWW/HTTP :: Session
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: pydoll-python>=2.24
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8.3; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Provides-Extra: examples
Requires-Dist: curl-cffi>=0.16; extra == 'examples'
Requires-Dist: pika>=1.3; extra == 'examples'
Description-Content-Type: text/markdown

# oidc-session-broker

> A session broker for OAuth2/OIDC identity providers: keeps SSO sessions warm
> and mints per-client application sessions on demand.

Unlike capture-and-replay session stores, this models the OIDC two-layer session
and re-mints downstream relying-party sessions from a warm upstream identity-provider
session — so it survives cookie rotation and per-session binding without
re-authenticating.

```sh
pip install oidc-session-broker
```

## Why two layers

An OIDC login is not one session. It is two, with wildly different costs.

1. The **IdP session** lives on the identity provider's domain. Creating it costs
   a credential and, on most real providers, a human solving a challenge.
2. The **RP session** lives on the relying party's domain — the application you
   actually wanted to reach. The RP mints it from tokens its *backend* exchanged
   for an authorization code. The browser never sees those tokens.

```
                 expensive, rare                cheap, frequent
   credential ──────────────────▶ IdP session ─────────────────▶ RP session
   + challenge                   (SSO cookie)     /authorize     (app cookie)
                                                   redirect
```

Two consequences drive the whole design:

- **There is no "IdP cookie" to share with the application.** The RP's session
  cookie is minted by the RP, from tokens the browser never touched. Copying the
  IdP jar into an RP request buys you nothing.
- **While the IdP session is alive, authenticating to another RP is just a
  redirect.** No credential, no challenge. That is what SSO *is*.

So the IdP session is not the thing you hand to a worker. It is a *generator*.
Keep a few warm, and mint a fresh, independent RP session per worker on demand.

| Layer | Lives on | Cost to create | Strategy |
|---|---|---|---|
| IdP session | the provider's domain | **high** (credential + challenge) | keep few, keep warm |
| RP session | the application's domain | **low** (one OIDC redirect) | mint one per worker |

## How this differs from a session store

Capture-and-replay tooling treats a session as an opaque blob: record a cookie
jar, replay it, and when it stops working, record another one. The recovery path
for *every* failure is a fresh login — so every cookie rotation, every binding
check and every idle timeout costs a challenge.

This library models the layers instead, which makes recovery a ladder:

```
1. RP session alive   → use it                                  zero cost
2. RP session dead    → re-run /authorize against the warm IdP  ~2 redirects
3. IdP session dead   → interactive login, by a human           expensive; avoid
```

Rung 2 is the entire point. It is also why per-worker sessions stop contending
over one shared blob: each worker gets a cookie the RP minted for it alone.

## Quick start

```python
import asyncio

from oidc_session_broker import SessionBroker, pin_profile
from oidc_session_broker.drivers.pydoll import PydollDriver
from oidc_session_broker.providers import govbr


async def main():
    driver = PydollDriver(pin_profile("my-account"))
    broker = SessionBroker(driver=driver)

    async with broker:
        # The one interactive step: a human authenticates once, in the browser.
        await broker.await_manual_login()

        sus = await broker.mint(govbr.SUS)  # a storage-token RP
        receita = await broker.mint(govbr.RECEITA)  # same SSO, no second login

        print(sus.authorization_token())  # send as Authorization
        print(receita.as_cookies(domain_contains="receita"))

        await broker.liveness()  # is it still alive? also keeps it warm


asyncio.run(main())
```

Two mints, and the second one is the claim: a jar for a *different* relying
party, from the same warm identity-provider session, with no second
authentication.

A `Jar` is the unit the broker hands out — cookies (`httpOnly` included) plus the
web storage that belongs with them. Present it however the relying party expects:

| Method | For |
|---|---|
| `as_cookies(domain_contains=…)` | a `{name: value}` map for a cookie RP |
| `cookies_for_curl(domain_contains=…)` | cookies scoped by domain and path |
| `authorization_token()` | the right bearer token for a storage-token RP |
| `to_dict()` / `from_dict()` | plain data, for putting a jar on a wire |
| `inject(driver)` | restoring a jar into a browser |

`authorization_token()` is ranked rather than arbitrary: a storage-token RP often
holds several JWTs at once, and sending the id token where the access token is
expected is a well-formed 401.

## Adding a provider

Nothing above is specific to one provider. A relying party is described by where
its login journey starts and where the session ends up:

```python
from oidc_session_broker import RpConfig, SessionCarrier

MY_APP = RpConfig(
    name="my-app",
    login_start_url="https://app.example.com/login",
    domain="app.example.com",
    session_carrier=SessionCarrier.COOKIE,  # or STORAGE_TOKEN, or MIXED
    success_url_contains=("app.example.com/home",),
    login_button="Sign in with SSO",  # for an SPA that waits for a click
    storage_origins=("https://app.example.com",),  # if the session lives in storage
)
```

`session_carrier` is the field worth measuring rather than guessing. It decides
what a consumer has to present, and presenting the wrong half authenticates as
nobody.

## Bring your own browser

Everything the engine needs from a browser is declared as a `BrowserDriver`
protocol, so the CDP client underneath is replaceable. The protocol is
structural, so an implementation needs no import of ours — and because it is
async, a *synchronous* CDP client is adapted rather than subclassed:

```python
class SyncDriverAdapter:
    def __init__(self, sync_driver, loop=None):
        self._d = sync_driver
        self._loop = loop or asyncio.get_running_loop()

    async def get_all_cookies(self):
        return await self._loop.run_in_executor(None, self._d.get_all_cookies)
```

Every protocol method maps to one blocking call, so the adapter stays mechanical:
nothing in the broker holds a CDP handle, keeps a transaction open across awaits,
or assumes a particular event loop.

## What the library refuses to be

The engine holds the IdP session and mints RP sessions. It does not decide when,
for whom, or how often. That belongs to whatever imports it — an orchestrator
that owns the queue, the mint policy, the liveness state machine and the
encryption of jars in flight.

```
   orchestrator          imports          engine
   queue, policy,      ──────────▶    holds SSO, mints jars
   liveness, codec                    (this library)
        ▲
        │ queue, not import
        │
    workers -- ask for a session, receive a grant, never see OIDC
```

The split exists because a login needs exactly one owner. If every worker could
trigger one, the login is back to being spread across every worker. So workers do
not import this library at all; they ask over a queue and get a grant.

`examples/poc/` is that arrangement, end to end and runnable: a session manager
that owns the login, a RabbitMQ topic exchange, and two crawlers that import
nothing from this library and never learn what SSO is.

## Non-goals

These are firm, not "not yet":

- **No automated challenge solving.** The initial login is always interactive: a
  human authenticates once, and the broker manages the lifecycle from there. The
  design goal is to *reduce* logins and load on the provider, not to grind past
  its defences.
- **No credentials in the library.** Not even a reference to one. The login is
  performed by a human in a browser, so the engine never has a secret to hold,
  read or leak.
- **No queue, no store, no quota.** Those belong to the orchestrator that imports
  this. The engine holds a session and mints jars; it has no opinion on who gets
  one or how many.
- **No business rules.** What a quota *means*, and who is authorised to consult
  what, belongs to the caller.
- **No bundled account or tenant configuration.** Provider mechanics are public;
  identities and specific relying parties are not.

## Design invariants

Learned the hard way; treated as non-negotiable:

1. **Never read cookies with `document.cookie`.** It cannot see `httpOnly`
   cookies, which is exactly where session state lives. Use CDP
   `Network.getAllCookies` / `Storage.getCookies`.
2. **Capture storage too.** `localStorage`, `sessionStorage` and IndexedDB can
   hold state the cookie jar does not. (IndexedDB is a documented gap in the
   reference driver: neither reference relying party keeps a session there.)
3. **Assume the RP rotates its session cookie** until measured otherwise. If it
   does, sharing a blob is impossible by construction.
4. **Assume the IdP caps concurrent sessions per identity** until measured. If
   the cap is one, concurrent login is destructive and needs a per-account mutex.
5. **Identity travels with the jar.** User agent, viewport, locale, timezone and
   egress IP are pinned per account and immutable for its lifetime.

## Reference provider

The mechanics are provider-agnostic (`providers/base.py`); anything that speaks
Authorization Code + PKCE can be plugged in. The reference implementation is
Brazil's **gov.br**, exercised against two relying parties — Meu SUS Digital and
Receita Federal's e-CAC — using the author's own account and own data. They were
chosen because they are opposite shapes: one carries its session as a bearer
token in web storage, the other as a server-side cookie. Reaching the second
without a new login is the observable difference between a session store and a
session broker.

## Built on pydoll

The reference implementation of `BrowserDriver` is a thin layer over
[pydoll](https://github.com/autoscrape-labs/pydoll) — an async CDP client that
talks to Chrome directly, with no WebDriver in the middle.

That mattered for a specific reason. This whole design rests on reading cookies
the way only CDP can: `Network.getAllCookies` and `Storage.getCookies` see
`httpOnly` cookies and see them across every domain at once, which is the
difference between capturing a session and capturing the half of it that does not
authenticate. pydoll exposes the protocol rather than hiding it, so the awkward
calls are reachable instead of abstracted away.

The protocol seam is there because downstream consumers are expected to bring
their own CDP client. It is not there because pydoll needed replacing.

## Development

```sh
uv venv --python 3.12
source .venv/bin/activate
uv pip install -e ".[dev,examples]"
ruff check . && ruff format --check . && pytest
```

`pytest` runs the unit suite: no browser, no network. `pytest -m browser` drives a
real Chrome against a local OIDC-shaped server on `127.0.0.1` — it still needs
neither the network nor an identity provider.

## License

MIT — see [LICENSE](LICENSE).
