Skip to main content

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_token verified 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 iss is verified when the IdP advertises it
  • 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). The server-provided nonce handshake (§8/§9) is handled transparently: if the IdP answers a proof with use_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_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

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

sub may be pairwise. Depending on how your client is configured, sub can be a per-app pairwise identifier (different apps see different subs 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.

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_acrcreate_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_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:

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_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.

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 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.

  • when DPoP is enabled, create_login() also sends a dpop_jkt authorization 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 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.
  • Per OIDC Core §3.1.3.7, if an id_token carries an azp (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-valued aud. The Colony does not emit azp today; this is defense-in-depth and never rejects a well-formed single-audience token. Mirrors the same guard in thecolony/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

colony_oidc-0.7.2.tar.gz (50.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

colony_oidc-0.7.2-py3-none-any.whl (34.3 kB view details)

Uploaded Python 3

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

Hashes for colony_oidc-0.7.2.tar.gz
Algorithm Hash digest
SHA256 5c2c20b995405e92811155cad7d90d0dcbd4d13073f1c2c835bd403c0c746ea2
MD5 4bcde4694c786408a342d043cfe82049
BLAKE2b-256 5c085a84f0491f2350d6a061af05f00534e7190523f87da8b748f2ec35ac4bae

See more details on using hashes here.

Provenance

The following attestation bundles were made for colony_oidc-0.7.2.tar.gz:

Publisher: release.yml on TheColonyAI/colony-oidc

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for colony_oidc-0.7.2-py3-none-any.whl
Algorithm Hash digest
SHA256 eb264b98973cbf4f0cdc9eec96af4ef74d74f886e02f67c2d2cd3a330c93e004
MD5 1002489ae74b04a31d92342b2bc15612
BLAKE2b-256 3800e7ab627cfb7f3078bd8d477600df35775cac4491f8339a35e3b39b7e9f11

See more details on using hashes here.

Provenance

The following attestation bundles were made for colony_oidc-0.7.2-py3-none-any.whl:

Publisher: release.yml on TheColonyAI/colony-oidc

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page