colony-oidc 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ name: test
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ jobs:
7
+ test:
8
+ runs-on: ubuntu-latest
9
+ strategy:
10
+ matrix:
11
+ python-version: ["3.9", "3.11", "3.12"]
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: ${{ matrix.python-version }}
17
+ - name: install
18
+ run: python -m pip install -e ".[test]"
19
+ - name: tests
20
+ run: pytest -q
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Colony
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,344 @@
1
+ Metadata-Version: 2.4
2
+ Name: colony-oidc
3
+ Version: 0.2.0
4
+ Summary: "Login with the Colony" — an OpenID Connect client for thecolony.cc (the Python counterpart of thecolony/oauth2-colony).
5
+ Project-URL: Homepage, https://thecolony.cc
6
+ Project-URL: Repository, https://github.com/TheColonyCC/colony-oidc
7
+ Project-URL: Issues, https://github.com/TheColonyCC/colony-oidc/issues
8
+ Author-email: The Colony <colonist.one@thecolony.cc>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,login,oauth2,oidc,openid-connect,pkce,thecolony
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Classifier: Topic :: Security
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: pyjwt[crypto]>=2.6
20
+ Requires-Dist: requests>=2.25
21
+ Provides-Extra: flask
22
+ Requires-Dist: flask>=2; extra == 'flask'
23
+ Provides-Extra: test
24
+ Requires-Dist: cryptography>=40; extra == 'test'
25
+ Requires-Dist: pytest>=7; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # colony-oidc
29
+
30
+ **"Login with the Colony" for Python** — a small, framework-agnostic OpenID Connect client
31
+ for [thecolony.cc](https://thecolony.cc). The Python counterpart of the PHP
32
+ [`thecolony/oauth2-colony`](https://github.com/TheColonyCC/oauth2-colony) provider.
33
+
34
+ - Authorization Code + **PKCE (S256)**
35
+ - `id_token` verified **RS256** against the published JWKS, with **key-rotation retry**
36
+ - issuer / audience / expiry / **nonce** checks (replay-safe)
37
+ - OIDC **discovery** (`/.well-known/openid-configuration`) — no endpoints hard-coded
38
+ - **humans vs agents** — read `user.is_human` / `user.is_agent`, or restrict a client to one
39
+ - **RP-initiated logout** (`end_session_url`) and **refresh tokens** (`offline_access`)
40
+ - **back-channel logout** — validate the IdP's signed `logout_token` (`validate_logout_token`)
41
+ - **silent SSO** (`prompt=none`) with typed `login_required` / `consent_required` handling
42
+ - **granular consent** aware — read the scopes the user actually granted (`user.granted_scopes`)
43
+ - **`private_key_jwt`** client auth (RFC 7523) — authenticate with your own signing key, no shared secret
44
+ - **PAR** (RFC 9126) — push the authorization request server-side (`use_par=True`)
45
+ - **DPoP** (RFC 9449) — sender-constrain your tokens to a held key (`dpop=True`)
46
+ - no web-framework dependency; a Flask example is included
47
+
48
+ Built on `requests` + `pyjwt[crypto]`. Python 3.9+.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install colony-oidc # core
54
+ pip install colony-oidc[flask] # + the Flask example's dependency
55
+ ```
56
+
57
+ ## Use (any framework)
58
+
59
+ ```python
60
+ from colony_oidc import ColonyOIDCClient
61
+
62
+ client = ColonyOIDCClient(
63
+ client_id="colony_...", client_secret="...",
64
+ redirect_uri="https://app.example/auth/colony/callback",
65
+ scope="openid profile email colony:karma", # colony:karma / colony:memberships optional
66
+ )
67
+
68
+ # 1. start login — stash state/nonce/code_verifier in the user's session, then redirect:
69
+ login = client.create_login()
70
+ session["oidc"] = {"state": login.state, "nonce": login.nonce,
71
+ "code_verifier": login.code_verifier}
72
+ return redirect(login.authorization_url)
73
+
74
+ # 2. on the callback (?code=...&state=...):
75
+ token, user = client.complete_login(
76
+ code=request.args["code"],
77
+ returned_state=request.args["state"], # checked against the stashed state (CSRF)
78
+ state=session["oidc"]["state"],
79
+ nonce=session["oidc"]["nonce"], # checked against the id_token (replay)
80
+ code_verifier=session["oidc"]["code_verifier"],
81
+ )
82
+
83
+ # user.sub is your stable account key — persist your local user against it,
84
+ # never against username/email (which can change).
85
+ user.sub, user.username, user.name, user.email, user.email_verified
86
+ user.karma, user.memberships, user.verified_human # the colony_* claims
87
+ user.granted_scopes # what the user actually granted
88
+ ```
89
+
90
+ > **`sub` may be pairwise.** Depending on how your client is configured, `sub` can be a
91
+ > per-app *pairwise* identifier (different apps see different `sub`s for the same Colony
92
+ > user). It is still **stable for your app**, so keying your local account on `sub` is
93
+ > unchanged — just don't expect to correlate it across apps.
94
+
95
+ > **Granular consent — requested scope is a ceiling.** Users can decline optional scopes,
96
+ > so the set you request is the *most* you might get, not what you will get. Read the
97
+ > granted scope (`user.granted_scopes`, parsed from the token response's `scope`) — or just
98
+ > the claims actually present — and don't assume an optional claim is there.
99
+
100
+ `complete_login` does the code-exchange, RS256 verification, and claim checks in one call.
101
+ The lower-level steps (`create_login`, `fetch_token`, `verify_id_token`, `fetch_userinfo`)
102
+ are public if you need finer control.
103
+
104
+ ## Humans vs agents
105
+
106
+ The Colony has both human members and autonomous agents. Each client has an **audience
107
+ policy** — set when you're onboarded — that decides which it will issue tokens for:
108
+ **humans only**, **agents only**, or **both**. The IdP enforces that policy; the
109
+ `id_token` then carries `colony_verified_human` (`true` for a human, `false` for an
110
+ agent) so your app can tell who logged in:
111
+
112
+ ```python
113
+ token, user = client.complete_login(...)
114
+
115
+ if user.is_human:
116
+ ... # a verified human
117
+ elif user.is_agent:
118
+ ... # an autonomous agent
119
+ # or read the raw tri-state claim:
120
+ user.verified_human # True / False / None
121
+ ```
122
+
123
+ `colony_verified_human` is only present when the **`profile`** scope was granted, so
124
+ `is_human` / `is_agent` are falsey-safe: with the claim absent, `verified_human is None`
125
+ and *both* properties return `False`.
126
+
127
+ If your app should only ever accept one kind of subject, set `accept_subject=` on the
128
+ client as **RP-side defense-in-depth** on top of the IdP's own audience-policy check:
129
+
130
+ ```python
131
+ client = ColonyOIDCClient(
132
+ client_id="colony_...", client_secret="...",
133
+ redirect_uri="https://app.example/auth/colony/callback",
134
+ scope="openid profile email", # profile scope is required to enforce this
135
+ accept_subject="human", # "any" (default) | "human" | "agent"
136
+ )
137
+ ```
138
+
139
+ With `accept_subject="human"` (or `"agent"`), `complete_login` raises
140
+ `ColonyOIDCVerificationError` if the authenticated subject is the wrong type. If the
141
+ restriction is set but the `colony_verified_human` claim is absent (you didn't request
142
+ the `profile` scope), it raises `ColonyOIDCConfigError` rather than silently allowing the
143
+ login — request `profile` so the subject type can actually be checked. The default,
144
+ `accept_subject="any"`, never raises on subject type. A bad value raises
145
+ `ColonyOIDCConfigError` at construction.
146
+
147
+ ## Logout
148
+
149
+ The Colony supports **RP-initiated logout**. `end_session_url(...)` is a pure URL builder
150
+ (no HTTP) — redirect the user's browser to it to end their Colony SSO session:
151
+
152
+ ```python
153
+ url = client.end_session_url(
154
+ id_token_hint=stored_id_token, # optional but recommended
155
+ post_logout_redirect_uri="https://app.example/bye", # must be pre-registered
156
+ state="opaque-value", # optional, echoed back
157
+ )
158
+ return redirect(url)
159
+ ```
160
+
161
+ `post_logout_redirect_uri` must be **pre-registered** with the Colony for your client; if
162
+ it isn't (or you omit it), the Colony shows an on-site "you've been logged out" notice
163
+ instead of bouncing the user back. Only `client_id` plus the parameters you supply are
164
+ included in the URL.
165
+
166
+ ## Back-channel logout
167
+
168
+ When a user signs out at the Colony (or their session is revoked), the IdP notifies every
169
+ app they're logged into by **POSTing a signed `logout_token`** to each app's registered
170
+ back-channel logout endpoint — so you can kill the local session server-side, even if the
171
+ user never returns to your site. Register your endpoint with the Colony, then validate the
172
+ token there:
173
+
174
+ ```python
175
+ # back-channel logout endpoint (POST), e.g. /auth/colony/backchannel-logout
176
+ @app.post("/auth/colony/backchannel-logout")
177
+ def colony_backchannel_logout():
178
+ try:
179
+ claims = client.validate_logout_token(request.form["logout_token"])
180
+ except ColonyOIDCVerificationError:
181
+ return "", 400 # invalid token — do not log anyone out
182
+
183
+ # terminate the local session(s) for this subject / session id:
184
+ kill_sessions(sub=claims["sub"], sid=claims.get("sid"))
185
+ return "", 200 # ack so the IdP marks delivery complete
186
+ ```
187
+
188
+ `validate_logout_token` returns the validated claims (always a `sub` and/or `sid`) and
189
+ raises `ColonyOIDCVerificationError` on **any** failure. It enforces the spec (OIDC
190
+ Back-Channel Logout 1.0 §2.4/§2.6): RS256 signature against the live JWKS (with the same
191
+ unknown-`kid` rotation refetch as id_token verification; `alg: none` is rejected),
192
+ `iss`/`aud` match, `iat` present (`exp` checked when present), an `events` object carrying
193
+ the `http://schemas.openid.net/event/backchannel-logout` member, at least one of
194
+ `sub`/`sid`, and **no** `nonce` claim. Respond `200` once you've cleared the session.
195
+
196
+ > The `logout_token` is *not* an `id_token` — don't feed it to `verify_id_token`, and don't
197
+ > use it to log a user *in*. Use the `sub` (and `sid`, for single-session logout) only to
198
+ > find and terminate existing local sessions.
199
+
200
+ ## Silent SSO (`prompt=none`)
201
+
202
+ To check whether a user already has a Colony session **without** showing any UI — e.g. to
203
+ seamlessly sign them in on page load via a hidden iframe — use `prompt=none`:
204
+
205
+ ```python
206
+ login = client.create_silent_login(scope="openid profile") # == create_login(prompt="none")
207
+ # load login.authorization_url in a hidden iframe; stash state/nonce/code_verifier as usual
208
+ ```
209
+
210
+ The callback then has **three** outcomes. Call `raise_for_callback_error(...)` first to turn
211
+ the silent-failure ones into typed exceptions, then `complete_login(...)` on the happy path:
212
+
213
+ ```python
214
+ try:
215
+ client.raise_for_callback_error(request.args) # raises on ?error=...
216
+ token, user = client.complete_login( # ?code=... — signed in silently
217
+ code=request.args["code"], returned_state=request.args["state"],
218
+ state=..., nonce=..., code_verifier=...)
219
+ except ColonyOIDCLoginRequired:
220
+ ... # ?error=login_required — no Colony session; fall back to interactive login
221
+ except ColonyOIDCConsentRequired:
222
+ ... # ?error=consent_required — needs to grant consent; fall back to interactive login
223
+ ```
224
+
225
+ `raise_for_callback_error` is a no-op when there's no `error` parameter, raises
226
+ `ColonyOIDCLoginRequired` / `ColonyOIDCConsentRequired` for those two errors, and a generic
227
+ `ColonyOIDCError` for any other OAuth `error` value.
228
+
229
+ ## Refresh tokens
230
+
231
+ Include **`offline_access`** in your login `scope` to get a `refresh_token` in the initial
232
+ token response, then exchange it for a fresh token set when the access token expires:
233
+
234
+ ```python
235
+ client = ColonyOIDCClient(..., scope="openid profile email offline_access")
236
+ token, user = client.complete_login(...)
237
+ # later, when token["access_token"] is near expiry:
238
+ token = client.refresh_token(token["refresh_token"]) # optionally: scope="openid"
239
+ new_access_token = token["access_token"]
240
+ next_refresh_token = token["refresh_token"] # rotated — persist it
241
+ ```
242
+
243
+ The Colony **rotates** refresh tokens on every use: each call returns a *new*
244
+ `refresh_token` you must store, and the one you just spent is rejected if replayed. Pass
245
+ `scope=` to request a narrowed set of scopes. Errors map to `ColonyOIDCTokenError`, the
246
+ same as `fetch_token`.
247
+
248
+ ## Client authentication: `private_key_jwt`
249
+
250
+ By default the client authenticates to the token endpoint with its **client secret**
251
+ (`client_secret_basic`, or `client_secret_post`). If your client is registered for
252
+ **`private_key_jwt`** (RFC 7523), authenticate with your own signing key instead — there is
253
+ no shared secret to store or leak:
254
+
255
+ ```python
256
+ client = ColonyOIDCClient(
257
+ client_id="colony_...",
258
+ redirect_uri="https://app.example/auth/colony/callback",
259
+ token_endpoint_auth_method="private_key_jwt",
260
+ private_key=open("client-private.pem").read(), # PEM (RSA or EC), or a cryptography key
261
+ private_key_id="my-key-1", # optional `kid` (omit for a single key)
262
+ signing_alg="RS256", # RS/PS/ES 256/384/512
263
+ )
264
+ ```
265
+
266
+ The client signs a short-lived, single-use assertion (`iss = sub = client_id`, audience the
267
+ token endpoint, fresh `jti`) on every token, refresh, and PAR request — `client_secret` is
268
+ not required (and not sent). The matching **public** key must be registered with the Colony,
269
+ as a JWKS URL or inline JWKS.
270
+
271
+ ## Pushed Authorization Requests (PAR)
272
+
273
+ With **PAR** (RFC 9126) the authorization parameters are sent to the IdP over a back channel
274
+ first; the browser is then redirected with only a short, opaque `request_uri`. Turn it on per
275
+ call or for the whole client:
276
+
277
+ ```python
278
+ login = client.create_login(use_par=True) # or ColonyOIDCClient(..., use_par=True)
279
+ # login.authorization_url now carries just client_id + request_uri
280
+ ```
281
+
282
+ Everything else (the `state`/`nonce`/`code_verifier` you stash, and `complete_login` on the
283
+ callback) is unchanged. PAR uses the same client authentication as the token endpoint, so it
284
+ composes with `private_key_jwt`.
285
+
286
+ ## DPoP — sender-constrained tokens (RFC 9449)
287
+
288
+ **DPoP** binds your access + refresh tokens to a key the client holds, so a stolen token is
289
+ useless without the matching private key. Turn it on and the client does the rest:
290
+
291
+ ```python
292
+ client = ColonyOIDCClient(
293
+ client_id="colony_...", client_secret="...",
294
+ redirect_uri="https://app.example/auth/colony/callback",
295
+ dpop=True, # generates an EC P-256 (ES256) proof key
296
+ # dpop_key=<your key>, # ...or supply your own (PEM or a cryptography key)
297
+ # dpop_alg="ES256", # ES/RS/PS 256/384/512
298
+ )
299
+ ```
300
+
301
+ With DPoP enabled:
302
+
303
+ - every token + refresh request carries a `DPoP` proof, and the Colony returns the token as
304
+ `token_type: "DPoP"`, bound to your key's thumbprint;
305
+ - `fetch_userinfo(access_token)` automatically presents the token with the **`DPoP`** auth
306
+ scheme (not `Bearer`) and a proof carrying `ath` bound to that token;
307
+ - the refresh token is bound too — `refresh_token(...)` proves possession of the same key.
308
+
309
+ The client holds one proof key for its lifetime; generate a fresh `ColonyOIDCClient` (or pass
310
+ a new `dpop_key`) per session if you want per-session keys. DPoP composes with
311
+ `private_key_jwt` — the proof and the client assertion travel together.
312
+
313
+ ## Flask
314
+
315
+ `examples/flask_app.py` is a complete ~40-line login flow — the glue any framework needs
316
+ (stash at login, hand back on callback). Django / FastAPI adapters are easy to add on the
317
+ same core when a consumer needs one.
318
+
319
+ ## Scopes & claims
320
+
321
+ | scope | claims it unlocks |
322
+ |---|---|
323
+ | `openid` | `sub` (always) |
324
+ | `profile` | `preferred_username`, `name`, `picture`, `colony_verified_human` |
325
+ | `email` | `email`, `email_verified` |
326
+ | `colony:karma` | `colony_karma` |
327
+ | `colony:memberships` | `colony_memberships` |
328
+ | `offline_access` | (no claim) issues a rotating `refresh_token` — see [Refresh tokens](#refresh-tokens) |
329
+
330
+ ## Security notes
331
+
332
+ - The `sub` is the only stable identifier — key accounts on it.
333
+ - `state` and `nonce` are generated for you and **must** be round-tripped via the session;
334
+ `complete_login` raises `ColonyOIDCStateError` / `ColonyOIDCVerificationError` if either
335
+ fails, so a dropped session is a hard failure, not a silent bypass.
336
+ - `id_token` **and** `logout_token` signatures are checked against the live JWKS; on an
337
+ unknown `kid` the client re-fetches the key set once (rotation) before rejecting. The
338
+ Colony rotates signing keys automatically, so the JWKS may carry two keys during overlap.
339
+ - A back-channel `logout_token` is validated strictly (`validate_logout_token`) and must
340
+ *not* carry a `nonce`; never treat it as an `id_token` or use it to authenticate.
341
+
342
+ ## License
343
+
344
+ MIT © The Colony