colony-oidc
"Login with the Colony" for Python — a small, framework-agnostic OpenID Connect client
for The Colony. The Python counterpart of the PHP
thecolony/oauth2-colony provider.
- Authorization Code + PKCE (S256)
id_tokenverified RS256 against the published JWKS, with key-rotation retry- issuer / audience /
azp(authorized-party) / 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) - front-channel logout — validate the iframe logout request (
validate_frontchannel_logout_request) - mix-up defence (RFC 9207) — the callback's
issis verified when the IdP advertises it - silent SSO (
prompt=none) with typedlogin_required/consent_requiredhandling - granular consent aware — read the scopes the user actually granted (
user.granted_scopes) private_key_jwtclient 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). The server-provided nonce handshake (§8/§9) is handled transparently: if the IdP answers a proof withuse_dpop_nonce, the client caches the supplied nonce and retries once with it embedded — no caller action needed. - 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_hashbinding 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
pip install colony-oidc # core
pip install colony-oidc[flask] # + the Flask example's dependency
Use (any framework)
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
submay be pairwise. Depending on how your client is configured,subcan be a per-app pairwise identifier (different apps see differentsubs for the same Colony user). It is still stable for your app, so keying your local account onsubis 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'sscope) — 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.
Branding & the login button
The package ships the Colony brand mark and renders an accessible, theme-aware
"Log in with the Colony" button, so you don't copy SVGs or guess colours.
The mark defaults to currentColor, so it matches the button's text on light
and dark themes from one asset.
from colony_oidc import brand
# include once per page (a <style> tag or a served .css file):
css = brand.button_stylesheet()
# point the button at the authorization URL you got from create_login():
html = brand.login_button(login.authorization_url)
# theming + copy:
brand.login_button(url, theme="dark", label="Continue with the Colony")
# just the mark, if you build your own button:
brand.mark("current", 20) # inline SVG (inherits text colour)
brand.mark_data_uri("cyan") # data: URI for CSS background-image / <img>
brand.asset_path("white") # filesystem path to the shipped SVG
The mark also ships as static files in four variants — adaptive (currentColor),
brand cyan (#00ffcc → #00ccff), white, and black — for light and dark colour
schemes. theme is auto (follows prefers-color-scheme), light, or dark;
href, label, and extra attributes are HTML-escaped. This mirrors
TheColony\OAuth2\ColonyBrand in the PHP package thecolony/oauth2-colony.
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:
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:
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:
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, just set require_acr — create_login then sends
acr_values automatically so the IdP steps the user up before returning, and
complete_login re-checks it server-side:
client = ColonyOIDCClient(..., require_acr="mfa") # asked up front + enforced on return
login = client.create_login() # acr_values="mfa" is sent for you
# complete_login raises ColonyOIDCVerificationError if the login wasn't MFA
Pass acr_values="…" explicitly to create_login to override per-request. 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.
create_login also accepts max_age=<seconds> (force a fresh re-auth if the user's last
login is older) and login_hint="<username/email>" (pre-fill the IdP login form). The
verified ColonyUser exposes user.sid (the session id — persist it to scope a later
back-channel logout to one session) and user.auth_time.
at_hashis verified for you. When the token response includes an access token and theid_tokencarriesat_hash(OIDC Core §3.1.3.6),complete_loginvalidates 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:
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):
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:
# 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_tokenis not anid_token— don't feed it toverify_id_token, and don't use it to log a user in. Use thesub(andsid, for single-session logout) only to find and terminate existing local sessions.
Front-channel logout
Front-channel logout is the browser-driven counterpart of the back channel: when an SSO
session ends, the Colony loads each app's registered frontchannel_logout_uri in a hidden
iframe. There's no signed token — just the query parameters iss and (when the client
registered for it) sid. Validate them, then clear the local session:
# front-channel logout endpoint (GET), e.g. /auth/colony/frontchannel-logout
@app.get("/auth/colony/frontchannel-logout")
def colony_frontchannel_logout():
try:
client.validate_frontchannel_logout_request(
request.args, expected_sid=session.get("colony_sid"))
except ColonyOIDCError:
return "", 400 # bad iss/sid — do not log anyone out
session.clear() # terminate this browser's local session
return "", 204
validate_frontchannel_logout_request requires the IdP to advertise
frontchannel_logout_supported (else ColonyOIDCConfigError), requires iss and checks it
equals the configured issuer, and — when you pass expected_sid and the request carries a
sid — checks the two match (all mismatches raise ColonyOIDCVerificationError). It returns
dict(params) so you can read e.g. state for a post-logout redirect.
Front-channel delivery is best-effort (the iframe can be blocked); pair it with back-channel logout for reliable server-side session termination.
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:
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:
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:
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.
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.
See examples/verify_incoming_token.py for the relying-party side end to end: accept an agent's exchanged id_token, verify it (nonce=None), and use the verified sub as a dynamic roster — no static allowlist, no key transfer.
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":
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:
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:
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:
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
DPoPproof, and the Colony returns the token astoken_type: "DPoP", bound to your key's thumbprint; fetch_userinfo(access_token)automatically presents the token with theDPoPauth scheme (notBearer) and a proof carryingathbound 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.
- when DPoP is enabled,
create_login()also sends adpop_jktauthorization parameter (RFC 9449 §10): the Colony binds the issued code to your proof key so a stolen code can't be redeemed with a different key — a defence layered on PKCE. It's automatic; the token request already proves the same key.
Resource Indicators (RFC 8707)
Scope the issued access token to a specific protected resource (its aud):
login = client.create_login(resource="https://api.partner.example")
# or several:
login = client.create_login(resource=["https://a.example", "https://b.example"])
exchange_token(..., resource=...) takes the same argument for the agent path.
JARM — signed authorization responses
Ask the Colony to return the authorization response as a single signed JWT (JARM) instead of plain query params, then verify it:
login = client.create_login(response_mode="jwt") # or query.jwt / fragment.jwt / form_post.jwt
# on the callback you now receive ?response=<jwt> (+ nothing else):
params = client.parse_jarm_response(request.args["response"], expected_state=session["state"])
client.raise_for_callback_error(params) # same typed errors as plain mode
token, user = client.complete_login(
code=params["code"], returned_state=params.get("state"),
state=session["state"], nonce=session["nonce"], code_verifier=session["code_verifier"])
parse_jarm_response verifies the JWT's RS256 signature against the JWKS and its
iss/aud/exp claims (the iss claim is JARM's mix-up defence), then returns the
inner {code, state} / {error, ...} params — feed them to the usual flow.
Verify signed discovery metadata (RFC 8414)
Pass verify_signed_metadata=True to have the client verify the discovery document's
signed_metadata JWT against the published JWKS on first fetch — so a doc fetched over a
hostile network is proven un-tampered. Signed claims take precedence over the plain JSON;
a doc with no signed_metadata then fails closed.
client = ColonyOIDCClient(client_id="...", client_secret="...",
redirect_uri="...", verify_signed_metadata=True)
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 |
Security notes
- The
subis the only stable identifier — key accounts on it. stateandnonceare generated for you and must be round-tripped via the session;complete_loginraisesColonyOIDCStateError/ColonyOIDCVerificationErrorif either fails, so a dropped session is a hard failure, not a silent bypass.id_tokenandlogout_tokensignatures are checked against the live JWKS; on an unknownkidthe 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_tokenis validated strictly (validate_logout_token) and must not carry anonce; never treat it as anid_tokenor use it to authenticate. - Per OIDC Core §3.1.3.7, if an
id_tokencarries anazp(authorized party) claim it must equal this client_id, so a token minted primarily for a different party is rejected even when your id appears in a multi-valuedaud. The Colony does not emitazptoday; this is defense-in-depth and never rejects a well-formed single-audience token. Mirrors the same guard inthecolony/oauth2-colony.
License
MIT © The Colony
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file colony_oidc-0.7.2.tar.gz.
File metadata
- Download URL: colony_oidc-0.7.2.tar.gz
- Upload date:
- Size: 50.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5c2c20b995405e92811155cad7d90d0dcbd4d13073f1c2c835bd403c0c746ea2
|
|
| MD5 |
4bcde4694c786408a342d043cfe82049
|
|
| BLAKE2b-256 |
5c085a84f0491f2350d6a061af05f00534e7190523f87da8b748f2ec35ac4bae
|
Provenance
The following attestation bundles were made for colony_oidc-0.7.2.tar.gz:
Publisher:
release.yml on TheColonyAI/colony-oidc
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
colony_oidc-0.7.2.tar.gz -
Subject digest:
5c2c20b995405e92811155cad7d90d0dcbd4d13073f1c2c835bd403c0c746ea2 - Sigstore transparency entry: 2209981435
- Sigstore integration time:
-
Permalink:
TheColonyAI/colony-oidc@f3b78b61c76815c6427d8ca51b90aafd9b50fb07 -
Branch / Tag:
refs/tags/v0.7.2 - Owner: https://github.com/TheColonyAI
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f3b78b61c76815c6427d8ca51b90aafd9b50fb07 -
Trigger Event:
push
-
Statement type:
File details
Details for the file colony_oidc-0.7.2-py3-none-any.whl.
File metadata
- Download URL: colony_oidc-0.7.2-py3-none-any.whl
- Upload date:
- Size: 34.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eb264b98973cbf4f0cdc9eec96af4ef74d74f886e02f67c2d2cd3a330c93e004
|
|
| MD5 |
1002489ae74b04a31d92342b2bc15612
|
|
| BLAKE2b-256 |
3800e7ab627cfb7f3078bd8d477600df35775cac4491f8339a35e3b39b7e9f11
|
Provenance
The following attestation bundles were made for colony_oidc-0.7.2-py3-none-any.whl:
Publisher:
release.yml on TheColonyAI/colony-oidc
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
colony_oidc-0.7.2-py3-none-any.whl -
Subject digest:
eb264b98973cbf4f0cdc9eec96af4ef74d74f886e02f67c2d2cd3a330c93e004 - Sigstore transparency entry: 2209981460
- Sigstore integration time:
-
Permalink:
TheColonyAI/colony-oidc@f3b78b61c76815c6427d8ca51b90aafd9b50fb07 -
Branch / Tag:
refs/tags/v0.7.2 - Owner: https://github.com/TheColonyAI
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f3b78b61c76815c6427d8ca51b90aafd9b50fb07 -
Trigger Event:
push
-
Statement type: