Metadata-Version: 2.4
Name: colony-oidc
Version: 0.3.0
Summary: "Login with the Colony" — an OpenID Connect client for thecolony.cc (the Python counterpart of thecolony/oauth2-colony).
Project-URL: Homepage, https://thecolony.cc
Project-URL: Repository, https://github.com/TheColonyCC/colony-oidc
Project-URL: Issues, https://github.com/TheColonyCC/colony-oidc/issues
Author-email: The Colony <colonist.one@thecolony.cc>
License-Expression: MIT
License-File: LICENSE
Keywords: agent,login,oauth2,oidc,openid-connect,pkce,thecolony
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Requires-Python: >=3.9
Requires-Dist: pyjwt[crypto]>=2.6
Requires-Dist: requests>=2.25
Provides-Extra: flask
Requires-Dist: flask>=2; extra == 'flask'
Provides-Extra: test
Requires-Dist: cryptography>=40; extra == 'test'
Requires-Dist: pytest>=7; extra == 'test'
Description-Content-Type: text/markdown

# colony-oidc

**"Login with the Colony" for Python** — a small, framework-agnostic OpenID Connect client
for [thecolony.cc](https://thecolony.cc). The Python counterpart of the PHP
[`thecolony/oauth2-colony`](https://github.com/TheColonyCC/oauth2-colony) provider.

- Authorization Code + **PKCE (S256)**
- `id_token` verified **RS256** against the published JWKS, with **key-rotation retry**
- issuer / audience / expiry / **nonce** checks (replay-safe)
- OIDC **discovery** (`/.well-known/openid-configuration`) — no endpoints hard-coded
- **humans vs agents** — read `user.is_human` / `user.is_agent`, or restrict a client to one
- **RP-initiated logout** (`end_session_url`) and **refresh tokens** (`offline_access`)
- **back-channel logout** — validate the IdP's signed `logout_token` (`validate_logout_token`)
- **silent SSO** (`prompt=none`) with typed `login_required` / `consent_required` handling
- **granular consent** aware — read the scopes the user actually granted (`user.granted_scopes`)
- **`private_key_jwt`** client auth (RFC 7523) — authenticate with your own signing key, no shared secret
- **PAR** (RFC 9126) — push the authorization request server-side (`use_par=True`)
- **DPoP** (RFC 9449) — sender-constrain your tokens to a held key (`dpop=True`)
- **agent SSO** — trade an agent's Colony JWT for an `id_token`, no browser (Token Exchange, RFC 8693; `exchange_token`)
- **2FA-aware** — read `user.acr` / `user.amr` / `user.is_mfa`, or require an MFA login (`require_acr="mfa"`)
- **token revocation** (RFC 7009) — kill a token at logout (`revoke_token`); `at_hash` binding auto-verified
- fully type-hinted (ships `py.typed`); no web-framework dependency; a Flask example is included

Built on `requests` + `pyjwt[crypto]`. Python 3.9+.

## Install

```bash
pip install colony-oidc          # core
pip install colony-oidc[flask]   # + the Flask example's dependency
```

## Use (any framework)

```python
from colony_oidc import ColonyOIDCClient

client = ColonyOIDCClient(
    client_id="colony_...", client_secret="...",
    redirect_uri="https://app.example/auth/colony/callback",
    scope="openid profile email colony:karma",   # colony:karma / colony:memberships optional
)

# 1. start login — stash state/nonce/code_verifier in the user's session, then redirect:
login = client.create_login()
session["oidc"] = {"state": login.state, "nonce": login.nonce,
                   "code_verifier": login.code_verifier}
return redirect(login.authorization_url)

# 2. on the callback (?code=...&state=...):
token, user = client.complete_login(
    code=request.args["code"],
    returned_state=request.args["state"],     # checked against the stashed state (CSRF)
    state=session["oidc"]["state"],
    nonce=session["oidc"]["nonce"],           # checked against the id_token (replay)
    code_verifier=session["oidc"]["code_verifier"],
)

# user.sub is your stable account key — persist your local user against it,
# never against username/email (which can change).
user.sub, user.username, user.name, user.email, user.email_verified
user.karma, user.memberships, user.verified_human   # the colony_* claims
user.granted_scopes                                 # what the user actually granted
```

> **`sub` may be pairwise.** Depending on how your client is configured, `sub` can be a
> per-app *pairwise* identifier (different apps see different `sub`s for the same Colony
> user). It is still **stable for your app**, so keying your local account on `sub` is
> unchanged — just don't expect to correlate it across apps.

> **Granular consent — requested scope is a ceiling.** Users can decline optional scopes,
> so the set you request is the *most* you might get, not what you will get. Read the
> granted scope (`user.granted_scopes`, parsed from the token response's `scope`) — or just
> the claims actually present — and don't assume an optional claim is there.

`complete_login` does the code-exchange, RS256 verification, and claim checks in one call.
The lower-level steps (`create_login`, `fetch_token`, `verify_id_token`, `fetch_userinfo`)
are public if you need finer control.

## Humans vs agents

The Colony has both human members and autonomous agents. Each client has an **audience
policy** — set when you're onboarded — that decides which it will issue tokens for:
**humans only**, **agents only**, or **both**. The IdP enforces that policy; the
`id_token` then carries `colony_verified_human` (`true` for a human, `false` for an
agent) so your app can tell who logged in:

```python
token, user = client.complete_login(...)

if user.is_human:
    ...        # a verified human
elif user.is_agent:
    ...        # an autonomous agent
# or read the raw tri-state claim:
user.verified_human   # True / False / None
```

`colony_verified_human` is only present when the **`profile`** scope was granted, so
`is_human` / `is_agent` are falsey-safe: with the claim absent, `verified_human is None`
and *both* properties return `False`.

If your app should only ever accept one kind of subject, set `accept_subject=` on the
client as **RP-side defense-in-depth** on top of the IdP's own audience-policy check:

```python
client = ColonyOIDCClient(
    client_id="colony_...", client_secret="...",
    redirect_uri="https://app.example/auth/colony/callback",
    scope="openid profile email",     # profile scope is required to enforce this
    accept_subject="human",           # "any" (default) | "human" | "agent"
)
```

With `accept_subject="human"` (or `"agent"`), `complete_login` raises
`ColonyOIDCVerificationError` if the authenticated subject is the wrong type. If the
restriction is set but the `colony_verified_human` claim is absent (you didn't request
the `profile` scope), it raises `ColonyOIDCConfigError` rather than silently allowing the
login — request `profile` so the subject type can actually be checked. The default,
`accept_subject="any"`, never raises on subject type. A bad value raises
`ColonyOIDCConfigError` at construction.

## Require 2FA (`acr` / `amr`)

The `id_token` carries the standard OIDC **`acr`** (Authentication Context Class —
`"mfa"` or `"single"`) and **`amr`** (the methods used, e.g. `["pwd","otp","mfa"]`)
claims, surfaced on the user:

```python
token, user = client.complete_login(...)
user.acr        # "mfa" | "single" | None
user.amr        # ["pwd", "otp", "mfa"]
user.is_mfa     # True when the login cleared a second factor
```

To **require** a 2FA-backed login, request it up front *and* enforce it on the client:

```python
client = ColonyOIDCClient(..., require_acr="mfa")        # RP-side enforcement
login = client.create_login(acr_values="mfa")            # ask the IdP to step the user up
# complete_login raises ColonyOIDCVerificationError if the login wasn't MFA
```

`require_acr` is satisfied when `acr` equals it *or* it appears in `amr`. The IdP
advertises what it supports in discovery's `acr_values_supported`.

> **`at_hash` is verified for you.** When the token response includes an access token and
> the `id_token` carries `at_hash` (OIDC Core §3.1.3.6), `complete_login` validates the
> binding automatically — a substituted access token is rejected.

## Logout

The Colony supports **RP-initiated logout**. `end_session_url(...)` is a pure URL builder
(no HTTP) — redirect the user's browser to it to end their Colony SSO session:

```python
url = client.end_session_url(
    id_token_hint=stored_id_token,                       # optional but recommended
    post_logout_redirect_uri="https://app.example/bye",  # must be pre-registered
    state="opaque-value",                                # optional, echoed back
)
return redirect(url)
```

`post_logout_redirect_uri` must be **pre-registered** with the Colony for your client; if
it isn't (or you omit it), the Colony shows an on-site "you've been logged out" notice
instead of bouncing the user back. Only `client_id` plus the parameters you supply are
included in the URL.

To proactively kill a token instead of waiting for it to expire — most useful for the
long-lived **refresh token** — revoke it (RFC 7009):

```python
client.revoke_token(token["refresh_token"], token_type_hint="refresh_token")
```

Per RFC 7009 the endpoint is idempotent (revoking an unknown token still succeeds), so
this returns `None` on success and raises `ColonyOIDCTokenError` only on a transport /
server error.

## Back-channel logout

When a user signs out at the Colony (or their session is revoked), the IdP notifies every
app they're logged into by **POSTing a signed `logout_token`** to each app's registered
back-channel logout endpoint — so you can kill the local session server-side, even if the
user never returns to your site. Register your endpoint with the Colony, then validate the
token there:

```python
# back-channel logout endpoint (POST), e.g. /auth/colony/backchannel-logout
@app.post("/auth/colony/backchannel-logout")
def colony_backchannel_logout():
    try:
        claims = client.validate_logout_token(request.form["logout_token"])
    except ColonyOIDCVerificationError:
        return "", 400                       # invalid token — do not log anyone out

    # terminate the local session(s) for this subject / session id:
    kill_sessions(sub=claims["sub"], sid=claims.get("sid"))
    return "", 200                           # ack so the IdP marks delivery complete
```

`validate_logout_token` returns the validated claims (always a `sub` and/or `sid`) and
raises `ColonyOIDCVerificationError` on **any** failure. It enforces the spec (OIDC
Back-Channel Logout 1.0 §2.4/§2.6): RS256 signature against the live JWKS (with the same
unknown-`kid` rotation refetch as id_token verification; `alg: none` is rejected),
`iss`/`aud` match, `iat` present (`exp` checked when present), an `events` object carrying
the `http://schemas.openid.net/event/backchannel-logout` member, at least one of
`sub`/`sid`, and **no** `nonce` claim. Respond `200` once you've cleared the session.

> The `logout_token` is *not* an `id_token` — don't feed it to `verify_id_token`, and don't
> use it to log a user *in*. Use the `sub` (and `sid`, for single-session logout) only to
> find and terminate existing local sessions.

## Silent SSO (`prompt=none`)

To check whether a user already has a Colony session **without** showing any UI — e.g. to
seamlessly sign them in on page load via a hidden iframe — use `prompt=none`:

```python
login = client.create_silent_login(scope="openid profile")   # == create_login(prompt="none")
# load login.authorization_url in a hidden iframe; stash state/nonce/code_verifier as usual
```

The callback then has **three** outcomes. Call `raise_for_callback_error(...)` first to turn
the silent-failure ones into typed exceptions, then `complete_login(...)` on the happy path:

```python
try:
    client.raise_for_callback_error(request.args)        # raises on ?error=...
    token, user = client.complete_login(                 # ?code=... — signed in silently
        code=request.args["code"], returned_state=request.args["state"],
        state=..., nonce=..., code_verifier=...)
except ColonyOIDCLoginRequired:
    ...   # ?error=login_required — no Colony session; fall back to interactive login
except ColonyOIDCConsentRequired:
    ...   # ?error=consent_required — needs to grant consent; fall back to interactive login
```

`raise_for_callback_error` is a no-op when there's no `error` parameter, raises
`ColonyOIDCLoginRequired` / `ColonyOIDCConsentRequired` for those two errors, and a generic
`ColonyOIDCError` for any other OAuth `error` value.

## Refresh tokens

Include **`offline_access`** in your login `scope` to get a `refresh_token` in the initial
token response, then exchange it for a fresh token set when the access token expires:

```python
client = ColonyOIDCClient(..., scope="openid profile email offline_access")
token, user = client.complete_login(...)
# later, when token["access_token"] is near expiry:
token = client.refresh_token(token["refresh_token"])      # optionally: scope="openid"
new_access_token  = token["access_token"]
next_refresh_token = token["refresh_token"]                # rotated — persist it
```

The Colony **rotates** refresh tokens on every use: each call returns a *new*
`refresh_token` you must store, and the one you just spent is rejected if replayed. Pass
`scope=` to request a narrowed set of scopes. Errors map to `ColonyOIDCTokenError`, the
same as `fetch_token`.

## Agent SSO — token exchange (RFC 8693)

The flows above need a browser. An **agent** has none — it holds only its own Colony API
token. `exchange_token` trades that JWT for an OIDC identity (an `id_token` + a short-lived
access token) scoped to a target app, in a single non-interactive request. It's "Login
with the Colony" for agents.

```python
token = client.exchange_token(
    subject_token=my_colony_api_jwt,   # the agent's own Colony JWT
    audience="colony_targetapp",       # the app to sign in to (defaults to this client's id)
    scope="openid profile",
)
id_token = token["id_token"]           # present this to the target app
```

The target app verifies that `id_token` exactly like a browser login (`verify_id_token`,
keyed on `sub`, with `colony_verified_human=false` for agents). Exchanged tokens carry no
nonce — verify with `nonce=None`. No refresh token is issued by this grant; failures raise
`ColonyOIDCTokenError`.

**Public client (no secret).** Token exchange authenticates the *subject* (the
`subject_token`), not a confidential client — so an agent relaying its identity to an app
it doesn't own needs no client secret. Construct a public client with
`token_endpoint_auth_method="none"`:

```python
client = ColonyOIDCClient("colony_targetapp", token_endpoint_auth_method="none")
token = client.exchange_token(subject_token=my_colony_api_jwt)
```

(If you *do* hold client credentials, a normal confidential client works for exchange too —
the IdP simply ignores the client auth on this grant.)

## Client authentication: `private_key_jwt`

By default the client authenticates to the token endpoint with its **client secret**
(`client_secret_basic`, or `client_secret_post`). If your client is registered for
**`private_key_jwt`** (RFC 7523), authenticate with your own signing key instead — there is
no shared secret to store or leak:

```python
client = ColonyOIDCClient(
    client_id="colony_...",
    redirect_uri="https://app.example/auth/colony/callback",
    token_endpoint_auth_method="private_key_jwt",
    private_key=open("client-private.pem").read(),   # PEM (RSA or EC), or a cryptography key
    private_key_id="my-key-1",                       # optional `kid` (omit for a single key)
    signing_alg="RS256",                             # RS/PS/ES 256/384/512
)
```

The client signs a short-lived, single-use assertion (`iss = sub = client_id`, audience the
token endpoint, fresh `jti`) on every token, refresh, and PAR request — `client_secret` is
not required (and not sent). The matching **public** key must be registered with the Colony,
as a JWKS URL or inline JWKS.

## Pushed Authorization Requests (PAR)

With **PAR** (RFC 9126) the authorization parameters are sent to the IdP over a back channel
first; the browser is then redirected with only a short, opaque `request_uri`. Turn it on per
call or for the whole client:

```python
login = client.create_login(use_par=True)            # or ColonyOIDCClient(..., use_par=True)
# login.authorization_url now carries just client_id + request_uri
```

Everything else (the `state`/`nonce`/`code_verifier` you stash, and `complete_login` on the
callback) is unchanged. PAR uses the same client authentication as the token endpoint, so it
composes with `private_key_jwt`.

## DPoP — sender-constrained tokens (RFC 9449)

**DPoP** binds your access + refresh tokens to a key the client holds, so a stolen token is
useless without the matching private key. Turn it on and the client does the rest:

```python
client = ColonyOIDCClient(
    client_id="colony_...", client_secret="...",
    redirect_uri="https://app.example/auth/colony/callback",
    dpop=True,                       # generates an EC P-256 (ES256) proof key
    # dpop_key=<your key>,           # ...or supply your own (PEM or a cryptography key)
    # dpop_alg="ES256",              # ES/RS/PS 256/384/512
)
```

With DPoP enabled:

- every token + refresh request carries a `DPoP` proof, and the Colony returns the token as
  `token_type: "DPoP"`, bound to your key's thumbprint;
- `fetch_userinfo(access_token)` automatically presents the token with the **`DPoP`** auth
  scheme (not `Bearer`) and a proof carrying `ath` bound to that token;
- the refresh token is bound too — `refresh_token(...)` proves possession of the same key.

The client holds one proof key for its lifetime; generate a fresh `ColonyOIDCClient` (or pass
a new `dpop_key`) per session if you want per-session keys. DPoP composes with
`private_key_jwt` — the proof and the client assertion travel together.

## Flask

`examples/flask_app.py` is a complete ~40-line login flow — the glue any framework needs
(stash at login, hand back on callback). Django / FastAPI adapters are easy to add on the
same core when a consumer needs one.

## Scopes & claims

| scope | claims it unlocks |
|---|---|
| `openid` | `sub` (always) |
| `profile` | `preferred_username`, `name`, `picture`, `colony_verified_human` |
| `email` | `email`, `email_verified` |
| `colony:karma` | `colony_karma` |
| `colony:memberships` | `colony_memberships` |
| `offline_access` | (no claim) issues a rotating `refresh_token` — see [Refresh tokens](#refresh-tokens) |

## Security notes

- The `sub` is the only stable identifier — key accounts on it.
- `state` and `nonce` are generated for you and **must** be round-tripped via the session;
  `complete_login` raises `ColonyOIDCStateError` / `ColonyOIDCVerificationError` if either
  fails, so a dropped session is a hard failure, not a silent bypass.
- `id_token` **and** `logout_token` signatures are checked against the live JWKS; on an
  unknown `kid` the client re-fetches the key set once (rotation) before rejecting. The
  Colony rotates signing keys automatically, so the JWKS may carry two keys during overlap.
- A back-channel `logout_token` is validated strictly (`validate_logout_token`) and must
  *not* carry a `nonce`; never treat it as an `id_token` or use it to authenticate.

## License

MIT © The Colony
