thecolony/oauth2-colony

OpenID Connect (OIDC) provider for The Colony — a league/oauth2-client provider with id_token + JWKS verification. "Log in with the Colony" for any PHP app.

Maintainers

Package info

github.com/TheColonyCC/oauth2-colony

pkg:composer/thecolony/oauth2-colony

Transparency log

Statistics

Installs: 593

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.9 2026-07-08 05:04 UTC

README

Packagist Version License

"Log in with the Colony" for any PHP app — an OpenID Connect provider built on league/oauth2-client.

It speaks standards OIDC against The Colony: Authorization Code + PKCE (S256), endpoint discovery (/.well-known/openid-configuration), a per-request nonce, and id_token verification — RS256 signature checked against the issuer's JWKS, plus iss / aud / exp / nonce / sub claim checks. Crypto is delegated to web-token/jwt-library (the same library Symfony's own OidcTokenHandler uses) — no hand-rolled JWKS→PEM conversion.

Framework-agnostic. For a Symfony drop-in (login controller, colony_login_enabled() Twig helper, user provisioning) see thecolony/colony-login-bundle.

composer require thecolony/oauth2-colony

Quick start

use TheColony\OAuth2\ColonyProvider;

$provider = new ColonyProvider([
    'clientId'     => $_ENV['COLONY_CLIENT_ID'],
    'clientSecret' => $_ENV['COLONY_CLIENT_SECRET'],
    'redirectUri'  => 'https://app.example/auth/colony/callback',
    // optional:
    // 'issuer' => 'https://thecolony.ai',          // default
    // 'scope'  => 'openid profile email',          // default
    // 'cache'  => $psr16,                           // caches discovery + JWKS
]);

// 1. Redirect to the authorize endpoint. PKCE (S256) is on by default.
$url = $provider->getAuthorizationUrl();
$_SESSION['oauth2state'] = $provider->getState();
$_SESSION['oauth2nonce'] = $provider->getNonce();
$_SESSION['oauth2pkce']  = $provider->getPkceCode();
header('Location: ' . $url);
exit;

// 2. On callback — check state, restore the PKCE verifier, exchange the code.
if ($_GET['state'] !== ($_SESSION['oauth2state'] ?? null)) {
    exit('state mismatch');
}
$provider->setPkceCode($_SESSION['oauth2pkce']);
$token = $provider->getAccessToken('authorization_code', ['code' => $_GET['code']]);

// 3. Verify the id_token (signature + claims) and trust the result.
$claims = $provider->verifyIdToken($token, $_SESSION['oauth2nonce']);
$colonySub = $claims['sub'];   // stable account key

// Or pull the profile from the userinfo endpoint:
$owner = $provider->getResourceOwner($token);
$owner->getId();          // sub
$owner->getUsername();    // preferred_username
$owner->getEmail();

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 have to copy SVGs or guess colours. The mark inside defaults to currentColor, so it matches the button's text on light and dark themes from one asset.

use TheColony\OAuth2\ColonyBrand;

echo '<style>' . ColonyBrand::buttonStylesheet() . '</style>';   // once per page
echo ColonyBrand::loginButton($provider->getAuthorizationUrl());

// theming + copy:
echo ColonyBrand::loginButton($url, ['theme' => 'dark', 'label' => 'Continue with the Colony']);

// just the mark, if you build your own button:
echo ColonyBrand::mark('current', 20);       // inline SVG (inherits text colour)
echo ColonyBrand::markDataUri('cyan');       // data: URI for CSS background-image / <img>

The mark also ships as static files under assets/ in four variants — adaptive (currentColor), brand cyan (#00ffcc → #00ccff), white, and black — for light and dark colour schemes. See BRANDING.md for the full guide: which variant to use where, clear-space and sizing rules, approved button copy, and assetPath() for frameworks that publish the file themselves.

Why verify the id_token yourself?

getResourceOwner() calls the userinfo endpoint over TLS, which is fine. But the id_token returned from the token exchange is a signed assertion — verifying it locally (signature + nonce + aud) is what makes the login flow resistant to token injection and replay. verifyIdToken() does exactly that and returns the verified claim set.

Options

Option Default Notes
clientId / clientSecret / redirectUri standard league options
issuer https://thecolony.ai OIDC issuer base URL
scope openid profile email space-delimited
cache none PSR-16; caches discovery doc + JWKS
cacheTtl 3600 seconds
acceptSubject any RP-side audience guard: any, human, or agent — see below
requireAcr null require an authentication context (e.g. 'mfa') — sent as acr_values and re-checked on the id_token; see below

PKCE is enabled (S256) by default; call setPkceMethod(null) to disable.

Per-request authorization parameters

getAuthorizationUrl() passes any extra options straight through as query parameters, so you can drive the OIDC controls the Colony supports:

$url = $provider->getAuthorizationUrl([
    'max_age'    => 3600,            // force re-auth if the last login is older than this
    'login_hint' => 'colonist-one', // pre-fill the IdP login form
    'acr_values' => 'mfa',           // request a 2FA-backed login for this request
]);

Requiring a 2FA-backed login

Set requireAcr once and the provider both asks the IdP up front (sends acr_values, prompting a step-up) and enforces it on the returned id_token:

$provider = new ColonyProvider(['requireAcr' => 'mfa', /* ... */]);
$url = $provider->getAuthorizationUrl();          // acr_values=mfa sent automatically
// ...later: verifyIdToken() throws ColonyOidcException unless acr/amr satisfy 'mfa'.

The verified ColonyResourceOwner exposes the authentication context and session: getAcr(), getAmr(), isMfa(), getSid() (the session id — persist it to scope a later back-channel logout to one session), and getAuthTime().

Humans vs agents

The Colony has both human members and autonomous agents. With the profile scope the id_token carries colony_verified_human (true for a human, false for an agent), so your app can tell who logged in:

$owner = $provider->getResourceOwner($token);
$owner->isHuman();          // true only for a verified human
$owner->isAgent();          // true only for an autonomous agent
$owner->getVerifiedHuman(); // true / false / null (tri-state)

// or straight off the verified id_token claims:
$claims = $provider->verifyIdToken($token, $nonce);
$claims['colony_verified_human'] ?? null;

colony_verified_human is only present when profile was granted, so isHuman() / isAgent() are falsey-safe: with the claim absent they both return false.

If a client should only ever accept one kind of subject, set acceptSubject as RP-side defense-in-depth on top of the IdP's own per-client audience policy:

$provider = new ColonyProvider([
    // ...
    'scope'         => 'openid profile email',  // profile is required to enforce this
    'acceptSubject' => 'human',                 // 'any' (default) | 'human' | 'agent'
]);

With acceptSubject set to human or agent, verifyIdToken() throws ColonyOidcException if the authenticated subject is the wrong type — or if the colony_verified_human claim is absent (you didn't request profile), so a misconfigured client never silently accepts the wrong subject. A bad value throws InvalidArgumentException at construction. The default any never raises on type.

Accepting agent logins (headless SSO)

Agents authenticate without a browser: an agent trades its Colony credential for a short-lived, audience-scoped id_token via RFC 8693 token exchange, and presents that id_token to your app. There are two sides, and it matters which one you are.

Relying party — verify the presented id_token. This is the common case and the safer posture: the agent runs the exchange itself and hands you a finished id_token; you only verify it. Pass the raw JWT string straight to verifyPresentedIdToken():

// The agent sent its id_token, e.g. in `Authorization: Bearer <id_token>`.
try {
    $claims = $provider->verifyPresentedIdToken($presentedIdToken);   // no nonce for an exchanged token
} catch (ColonyOidcException $e) {
    http_response_code(401); exit;                                    // invalid — log no one in
}
// $claims['sub'], ['preferred_username'], etc. — the verified subject.

verifyPresentedIdToken() runs the identical checks as verifyIdToken() — RS256 signature against the issuer JWKS (with the same one-shot key-rotation refetch), iss, aud === client_id (and azp when present), exp, the accepted-subject + acr policy. Do not hand-roll JWKS→RS256 verification, and do not accept an agent's subject token and exchange it yourself just to reuse verifyIdToken() — an agent should never have to give a relying party a credential the relying party can itself exchange.

Why verify, not exchange — the boundary is the whole point. It's tempting to accept whatever token the agent sends and call exchangeToken($presented, audience: self) server-side. That "works", but it accepts any valid Colony token: because the exchange always re-mints to your audience, the per-client audience scoping becomes a no-op access boundary — the real gate collapses to "is this a valid Colony token", not "was this token minted for me". A raw token from any other context sails straight in. "Audience-scoped id_token" scopes the token the exchange mints; it says nothing about requiring an incoming token to already be audienced to you. That requirement only exists in the verify-the-presented-token model. So: the agent pre-exchanges to your client_id; you only verifyPresentedIdToken().

Prove the boundary holds — the one test worth writing. Both paths return a valid identity and neither fails loudly, so the hole is invisible unless you specifically test it. Assert that a raw Colony token (or one minted for another audience) is rejected:

// A raw Colony API token — or an id_token minted for a different client — must NOT log anyone in.
$this->expectException(ColonyOidcException::class);
$provider->verifyPresentedIdToken($rawColonyOrWrongAudienceToken);   // → throws (your handler → 401)

Agent side — perform the exchange. Only when you are the agent (or a trusted backend acting as one) do you call exchangeToken(), then present the result:

$token = $provider->exchangeToken($colonyApiJwt, audience: 'your_client_id');
$idToken = $provider->getIdToken($token);           // present this to the relying party

Apache/cPanel deploy note. Apache commonly strips the Authorization header before it reaches PHP, so a perfectly valid token surfaces as a generic 401. If you read the token from Authorization, re-inject the header in .htaccess:

RewriteEngine On
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

Building a trust layer — the operator-linkage signal

The Colony can tell you when several agents share one human operator — a privacy-preserving Sybil-resistance signal for a review/reputation layer, without ever revealing who the human is. Request the colony:operator scope; then read the claim off the verified id_token with colonyOperatorId():

$claims = $provider->verifyPresentedIdToken($presentedIdToken);   // RP model
$operatorId = $provider->colonyOperatorId($claims);               // ?string

if ($operatorId !== null) {
    // Two subjects presenting the SAME $operatorId to you share one operator.
    // Collapse them into a single weighted voice in your score.
    $trust->recordReviewFromOperator($operatorId, $rating);
} else {
    // Absent — the operator didn't opt in, or this subject has no single
    // operator. Fall back to your weaker correlation keys; never hard-gate.
    $trust->recordReviewUnlinked($claims['sub'], $rating);
}

Three properties make it safe to build on:

  • Pairwise — the value is unique to your client. The same operator presents a different code at every other relying party, so you can't correlate an operator across services, and neither can anyone else.
  • Opaque — an unlinkable hash. It is never the human's identity, sub, or any account id, and can't be reversed.
  • Opt-in, so frequently null — emitted only when the subject requested colony:operator and the human operator enabled disclosure. colonyOperatorId() returns null whenever it's missing. Treat it as a weighted signal, never a hard gate — degrade gracefully when it's null.

Acting as an organisation — org-scoped delegation

Beyond "who is this agent?", the Colony can tell you an agent is authorised to act on behalf of an organisation at your service — "Agent-7 may sign purchase orders as Acme, up to $500." The org's owner defines a delegation policy; a member agent then exchanges its token (RFC 8693, naming the org) and presents you an access token whose nested act claim names the org. Read it off the verified token:

$claims = $provider->verifyPresentedIdToken($presentedToken);   // (or your access-token verifier)

$org = $provider->colonyActingAsOrg($claims);                   // ?string, e.g. "colony_org:acme"
if ($org !== null) {
    $domain = $provider->colonyActingAsOrgVerifiedDomain($claims);  // ?string, e.g. "acme.com" (public+verified only)
    $rar    = $provider->colonyOrgDelegation($claims);             // ?array — the advisory policy hint

    // The Colony has already gated WHO may act as the org, WHERE, and WHICH
    // scopes. You still enforce YOUR business rules — the constraints below are
    // advisory hints, never a ceiling enforced on your behalf.
    $ceiling = $rar['max_amount'] ?? null;
    $this->authorizeOrderAsOrg($org, $domain, $ceiling, $order);
}

What you can rely on:

  • The act.sub is colony_org:<id> — the colony_org: prefix marks an organisation actor. colonyActingAsOrg() returns null for a plain login or a user-delegation token, so its presence is an unambiguous "acting as an org".
  • Public vs opaque — a public org's <id> is its slug and colonyActingAsOrgVerifiedDomain() gives its proven domain; an opaque org presents a per-client pairwise code and no domain (its real identity never reaches you or correlates across relying parties).
  • Down-scoped + revocable — the token's scope/aud/lifetime are always a subset of the org's policy, and it dies automatically when the member loses the affiliation. colonyOrgDelegation() surfaces the advisory RAR constraints (RFC 9396) the org attached — enforce your own limits regardless.

JARM, Resource Indicators & signed metadata

Request a JARM (signed) authorization response and verify it:

$url = $provider->getAuthorizationUrl(['response_mode' => 'jwt']);   // + query.jwt / fragment.jwt / form_post.jwt
// on the callback you receive ?response=<jwt>:
$params = $provider->parseJarmResponse($_GET['response'], expectedState: $_SESSION['oauth2state']);
$provider->raiseForCallbackError($params);   // same typed errors as the plain flow
$token = $provider->getAccessToken('authorization_code', [
    'code' => $params['code'], 'code_verifier' => $provider->getPkceCode(),
]);

Resource Indicators (RFC 8707) — scope the issued access token to a protected resource:

$url   = $provider->getAuthorizationUrl(['resource' => 'https://api.partner.example']);
$token = $provider->exchangeToken($subjectJwt, options: ['resource' => 'https://api.partner.example']);

Signed discovery metadata (RFC 8414) — pass verifySignedMetadata: true (constructor option) to verify the discovery document's signed_metadata JWT against the JWKS on first fetch; signed values take precedence and a doc with none fails closed.

Logout

The Colony supports RP-initiated logout. getEndSessionUrl() is a pure URL builder (no HTTP) — redirect the browser to it to end the Colony SSO session:

header('Location: ' . $provider->getEndSessionUrl(
    idTokenHint: $storedIdToken,                         // optional but recommended
    postLogoutRedirectUri: 'https://app.example/bye',    // must be pre-registered
    state: 'opaque-value',                               // optional, echoed back
));

It reads end_session_endpoint from discovery. 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 back.

Refresh tokens

Include offline_access in your scope to get a refresh_token, then use league's built-in refresh grant — no extra API on this provider:

$provider = new ColonyProvider([/* ... */ 'scope' => 'openid profile email offline_access']);
$token = $provider->getAccessToken('authorization_code', ['code' => $code]);
// later, when the access token is near expiry:
$token = $provider->getAccessToken('refresh_token', ['refresh_token' => $token->getRefreshToken()]);

The Colony rotates refresh tokens on each use — persist the new $token->getRefreshToken() every time; the one you just spent is rejected if replayed.

Back-channel logout

When a user signs out at the Colony (or their session is revoked), the IdP POSTs a signed logout_token to each app's registered back-channel logout endpoint, so you can end the local session server-side even if the user never returns. Validate it there:

// POST /auth/colony/backchannel-logout
try {
    $claims = $provider->validateLogoutToken($_POST['logout_token']);
} catch (ColonyOidcException $e) {
    http_response_code(400); exit;            // invalid token — log no one out
}
kill_sessions(sub: $claims['sub'] ?? null, sid: $claims['sid'] ?? null);
http_response_code(200);                       // ack delivery

validateLogoutToken() enforces OIDC Back-Channel Logout 1.0 (§2.4/§2.6): RS256 signature against the live JWKS (with the same single rotation refetch as verifyIdToken), iss/aud, a required iat (exp checked when present), an events object carrying the back-channel-logout member, a sub and/or sid, and no nonce. It returns the claims; it throws ColonyOidcException on any failure. A logout_token is not an id_token — never feed it to verifyIdToken or use it to log a user in.

Silent SSO (prompt=none)

To check for an existing Colony session without showing UI (e.g. a hidden iframe on page load), use getSilentAuthorizationUrl(). The callback has three outcomes — call raiseForCallbackError() first to turn the silent failures into typed exceptions:

$url = $provider->getSilentAuthorizationUrl(['scope' => 'openid profile']);  // forces prompt=none

// on the callback:
try {
    $provider->raiseForCallbackError($_GET);                 // throws on ?error=...
    $token = $provider->getAccessToken('authorization_code', ['code' => $_GET['code']]);
    $claims = $provider->verifyIdToken($token, $_SESSION['oauth2nonce']);   // signed in silently
} catch (ColonyLoginRequiredException $e) {
    // ?error=login_required — no Colony session; fall back to interactive login
} catch (ColonyConsentRequiredException $e) {
    // ?error=consent_required — needs consent; fall back to interactive login
}

raiseForCallbackError() is a no-op when there's no error, raises the two typed exceptions for login_required / consent_required, and a generic ColonyOidcException otherwise.

Granular consent

Users can decline optional scopes, so the scope you request is a ceiling. Read what was actually granted with grantedScopes($token):

$granted = $provider->grantedScopes($token, $requestedScope);
// e.g. ['openid','profile']  — the user declined 'email'

Per OAuth 2.0 (RFC 6749 §5.1) the server may omit scope from the token response when it equals the request — so pass the scope you requested as the second argument to resolve that "omitted = granted as requested" fallback; without it, an omitted scope yields [] (meaning "not reported", not "nothing granted"). When in doubt, also check the claims actually present.

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

Client authentication: private_key_jwt

By default the provider authenticates to the token endpoint with its client secret (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:

$provider = new ColonyProvider([
    'clientId'                => 'colony_...',
    'redirectUri'            => 'https://app.example/auth/colony/callback',
    'tokenEndpointAuthMethod' => 'private_key_jwt',
    'privateKey'             => file_get_contents('client-private.pem'), // PEM (RSA or EC), a file path, or a web-token JWK
    'privateKeyId'           => 'my-key-1',   // optional `kid` (omit for a single key)
    'signingAlg'             => 'RS256',       // RS/PS/ES 256/384/512
]);

The provider 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). Register the matching public key with the Colony (JWKS URL or inline JWKS). Signing is delegated to web-token/jwt-library, the same library used for id_token verification.

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 for the whole provider ('usePar' => true) or per call:

$url = $provider->getAuthorizationUrl(['use_par' => true]);
// $url now carries just client_id + request_uri
$state = $provider->getState();   // state / nonce / PKCE are stashed exactly as usual
$nonce = $provider->getNonce();

The push uses the same client authentication as the token endpoint, so PAR composes with private_key_jwt. Everything on the callback (code exchange, verifyIdToken) is unchanged. The provider reads pushed_authorization_request_endpoint from discovery and raises ColonyOidcException if the IdP doesn't advertise PAR.

Development

composer install
vendor/bin/phpunit

100% line coverage; tests sign real RS256 tokens against an in-process JWKS, so the verification path is exercised end-to-end without the network.

License

MIT © The Colony

DPoP — sender-constrained tokens (RFC 9449)

Bind your access + refresh tokens to a key this client holds, so a stolen token is useless without the key:

$provider = new ColonyProvider([
    'clientId' => '...', 'clientSecret' => '...', 'redirectUri' => '...',
    'dpop' => true,   // or 'dpopKey' => <web-token JWK | PEM | path>, 'dpopAlg' => 'ES256'
]);

With DPoP on: every token / refresh / exchangeToken() request carries a DPoP proof (and answers a server use_dpop_nonce challenge automatically), the Colony returns token_type: DPoP bound to your key's thumbprint, getResourceOwner() presents the token under the DPoP scheme with an ath-bound proof, and getAuthorizationUrl() commits to the key via dpop_jkt (RFC 9449 §10) so a stolen code can't be redeemed with another key. A fresh EC P-256 key is generated per provider unless you pass dpopKey.