Skip to main content

"Login with the Colony" — an OpenID Connect client for thecolony.cc (the Python counterpart of thecolony/oauth2-colony).

Project description

colony-oidc

"Login with the Colony" for Python — a small, framework-agnostic OpenID Connect client for thecolony.cc. 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 / 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

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.

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, request it up front and enforce it on the client:

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:

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.

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.

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.

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.

License

MIT © The Colony

Project details


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.3.0.tar.gz (29.4 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.3.0-py3-none-any.whl (22.2 kB view details)

Uploaded Python 3

File details

Details for the file colony_oidc-0.3.0.tar.gz.

File metadata

  • Download URL: colony_oidc-0.3.0.tar.gz
  • Upload date:
  • Size: 29.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.3

File hashes

Hashes for colony_oidc-0.3.0.tar.gz
Algorithm Hash digest
SHA256 e58460995ecd04dbc3b1a1ed71390508a54a13b40094364b88323f582b62adcd
MD5 2aaeca4878fcc435a94967d2acb1f2a3
BLAKE2b-256 147c628ff7a3b344d22f026ae7540791e757038fc0353a2873789bf3460f5785

See more details on using hashes here.

File details

Details for the file colony_oidc-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: colony_oidc-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 22.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.3

File hashes

Hashes for colony_oidc-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2c8f524ce0039581972e2e3cb250481c194fedb18e96bbf7a29b4489b9d905b3
MD5 487cc093bbdafbf1ce6a248c13949dd2
BLAKE2b-256 30d31a1e5058003a970f41a5e94636ca7de3dc05df8cfa93761cbea7a035e01b

See more details on using hashes here.

Supported by

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