Turnkey Cbox ID client for Python. It speaks standard OpenID Connect against a Cbox ID instance — so integrating is a redirect and a callback, not a rewrite — and adds the conveniences a hosted-identity product needs:
- Login — PKCE (S256), a CSRF
state, a nonce, and fullid_tokenverification (signature against the instance's JWKS via PyJWT, plus issuer, audience and nonce). - Organizations — bind a sign-in to one organization, switch between them, and read the person's tier, roles and permissions there — and whether a support agent is acting as them.
- Hosted profile management — a redirect to the instance's own account page.
- Back-channel calls — machine (client-credentials) tokens, UserInfo, RFC 7662 introspection, RFC 7009 revocation.
- Webhook / action verification — confirm an inbound
X-Cbox-Signature.
Framework-agnostic: works with Flask, FastAPI, Django or plain scripts.
Everything above needs a client secret. A publishable key is the opposite — public on purpose, and useful only from the origins you registered. Reading the environment's own sign-in configuration lets a Django or Flask template render a themed sign-in box without shipping a JavaScript SDK to do it:
from cbox_id import FrontendClient
frontend = FrontendClient("https://id.acme.com", "pk_live_…")
config = frontend.config() # endpoints, social buttons, which methods are on, the theme
acme = frontend.config(organization="acme") # the buttons acme's hosted page shows
session = frontend.session(token) # session.user is None when nobody is signed inSigned-out is a state rather than an error, and the key grants nothing on its own: the
access token is the entire authority for session(). Passing a client secret raises
immediately rather than failing later as an opaque 401.
Before any of this works, an operator has to turn the Frontend API on
(CBOX_ID_FRONTEND_API=true — it is off by default) and mint a publishable key under
Developers → Frontend keys, listing the exact origins allowed to use it. Exact matches
only: https://acme.com does not cover https://www.acme.com.
Cbox ID can ask your system whether an email and password it has never seen are good, and import that person on the yes. You write the lookup; the handler owns the signature, the freshness window and the constant-time compare:
Your handler has 3 seconds to answer, must be reachable over HTTPS (plain http,
including http://localhost, is refused — the body is a live password), and is resolved
through an SSRF guard that blocks private ranges unless an operator relaxes
cbox-id.migration.verify_url for an endpoint on their own network. A slow bcrypt under
load therefore reads to the person signing in as a wrong password.
from cbox_id import LegacyUser, handle_legacy_login
@app.post("/cbox-legacy")
def cbox_legacy():
status, body = handle_legacy_login(
request.get_data(as_text=True), # the RAW body — the signature covers it
request.headers.get("X-Cbox-Signature"),
secret=os.environ["CBOX_LEGACY_SECRET"],
verify=lambda email, password: (
LegacyUser(email, row.name, password_hash=row.hash)
if (row := lookup(email)) and check(password, row.hash)
else None
),
)
return jsonify(body), statusReturn None for a wrong password. Raising is different: it means your store could not
decide, and is answered with 503 so Cbox ID refuses the sign-in rather than reading an
outage as a bad credential.
It takes the raw body and header rather than a request object, because Flask, Django, FastAPI and Starlette all differ — adapting three lines is a smaller imposition than a request abstraction invented to avoid them.
Where do
issuer,client_idandredirect_uricome from? Register an application in your environment console — see Integrate your app.
pip install cbox-id-clientPublished to PyPI from 0.10.0, by the release workflow (Trusted Publishing — no stored
token). Earlier versions were never on PyPI; install those from the tag instead:
pip install git+https://github.com/cboxdk/id-python@v0.9.0.
from cbox_id import CboxIdClient, CboxIdConfig
client = CboxIdClient(
CboxIdConfig(
issuer="https://id.acme.com",
client_id="client_...",
client_secret="secret_...",
redirect_uri="https://app.acme.com/auth/callback",
)
)
# Start login — persist state/code_verifier/nonce (e.g. in the session).
req = client.create_authorization_request()
session["cbox"] = {"state": req.state, "verifier": req.code_verifier, "nonce": req.nonce}
# redirect the user to req.url ...
# On the callback:
stored = session["cbox"]
user = client.authenticate(
code=request.args.get("code"),
state=request.args.get("state"),
expected_state=stored["state"],
code_verifier=stored["verifier"],
nonce=stored["nonce"],
)
# key your local account on user.id (the stable subject)A callback that carried an error raises AuthenticationError with the code on
exc.error (and exc.error_description), so you can branch on access_denied without
matching on the message. Pass error=request.args.get("error") and
error_description=request.args.get("error_description") through to authenticate.
A sign-in can be bound to one organization. The tokens then carry org, org_name, the
person's membership tier in it (org_role), and the app roles / permissions they hold
there — so switching organization means a new authorization, not a flag on the old
session.
from cbox_id import AuthorizationPrompt
# Bind to an organization you already know (the person must be an active member):
req = client.create_authorization_request(organization="org_2x…")
# Always show the hosted organization picker, with your guess preselected:
req = client.create_authorization_request(
prompt=AuthorizationPrompt.SELECT_ORGANIZATION,
organization_hint="org_2x…",
)
# Hosted "create a team" step; the person becomes its owner and the sign-in
# continues bound to the new organization:
req = client.create_authorization_request(prompt=AuthorizationPrompt.CREATE_ORGANIZATION)| Argument | Sent as | Meaning |
|---|---|---|
organization |
organization |
Bind the sign-in to this organization. |
organization_hint |
organization_hint |
Preselect it in the picker; the person may choose another. |
prompt=AuthorizationPrompt.SELECT_ORGANIZATION |
prompt=select_organization |
Always show the picker. |
prompt=AuthorizationPrompt.CREATE_ORGANIZATION |
prompt=create_organization |
Create an organization first. |
prompt takes one value or a list (plain strings work too, and a space-separated string
is split). organization cannot be combined with either organization prompt — it has
already made the choice they ask the person to make — so the SDK raises
ConfigurationError rather than sending it; use organization_hint with the picker
instead. An empty organization or organization_hint, and prompt="none" combined
with anything else, are refused the same way.
client.switch_organization(org_id) is create_authorization_request(organization=org_id)
under a name that says what it is for. Persist and redirect exactly as for a sign-in;
Cbox ID already has the person's session, so they normally come straight back without
seeing a form.
The binding is checked, not trusted. The request echoes req.organization: persist it
with the rest and pass it back to authenticate(organization=…), which refuses tokens for
any other organization. An instance that predates organization selection ignores the
parameter and answers for whichever organization the session already had — without the
check, your app would show the new organization's name over the old one's data.
from cbox_id import AuthenticationError
@app.get("/auth/switch-organization")
def switch_organization():
req = client.switch_organization(request.args["org"])
session["cbox"] = {
"state": req.state,
"verifier": req.code_verifier,
"nonce": req.nonce,
"organization": req.organization,
}
return redirect(req.url)
# On the callback:
stored = session["cbox"]
try:
user = client.authenticate(
code=request.args.get("code"),
state=request.args.get("state"),
error=request.args.get("error"),
expected_state=stored["state"],
code_verifier=stored["verifier"],
nonce=stored["nonce"],
organization=stored.get("organization"),
)
except AuthenticationError as exc:
if exc.error == "access_denied":
... # not a member of that organization — send them back to the one they were in
raiseReplace your session with the user the callback returns rather than patching the old one:
org_role, roles and permissions can all differ between organizations.
The signed-in user carries them typed:
user.organization # ActiveOrganization(id, name, role) or None
user.organization.role # OrganizationRole.OWNER / ADMIN / DEVELOPER / MEMBER / VIEWER, or None
user.roles # list[str]
user.permissions # list[str]
user.actor # Actor(sub, actor) or None — see support sessions below
user.session_id # the id_token's `sid`, for back-channel logout
user.has_permission("invoices:create")The same helpers work on the user and on a claim mapping you verified yourself, such as an access token's payload on a resource server:
from cbox_id import OrganizationRole, has_permission, is_support_session, organization
if not has_permission(payload, "invoices:create"):
abort(403)
org = organization(user)
if org and org.role is OrganizationRole.OWNER:
show_billing()Matching is exact — invoices:* does not grant invoices:delete. An org_role this SDK
version does not recognise reads as None, never as a tier it would have to guess.
Request the feature_flags scope (and give the app that scope on its Scopes tab) and the
tokens and UserInfo carry the keys of every flag that is on for the person, in the
organization they signed in to:
from cbox_id import FEATURE_FLAGS_SCOPE, CboxIdConfig, has_feature
config = CboxIdConfig(..., scopes=["openid", "profile", "email", FEATURE_FLAGS_SCOPE])
user.feature_flags # ["acme-beta", "new-dashboard"], or None
if user.has_feature("new-dashboard"):
show_new_dashboard()
has_feature(access_token_payload, "billing.v2") # on a verified claim set tooNone means the claim is absent — the scope was not requested — and [] means nothing is
on. Either way has_feature() is False: a missing scope turns every feature off, never
on. A token carries the flags as they were when it was issued; the next refresh picks up a
change. To ask without a token in hand (a job, a webhook handler), use
env.feature_flags.evaluate({"user_id": ..., "organization_id": ...}) from the
Management API.
A staff member can act as one of your users for a limited time (at most an hour, no refresh
token, with a recorded reason). Those tokens carry the RFC 8693 act claim naming the
staff member, and is_support_session() reports it:
if user.is_support_session: # or is_support_session(payload) on a resource server
... # show a banner, and refuse password, email and payout changesIt is fail-closed: any act claim counts, including one whose shape the SDK cannot
read (user.actor.sub is then None). A claim it cannot parse is not evidence that nobody
else is at the keyboard.
return redirect(client.profile_url(return_to="https://app.acme.com/dashboard"))token = client.machine_token(scopes=["reports.read"]) # as your app
claims = client.userinfo(user.access_token) # as a user
result = client.introspect(some_token) # RFC 7662
client.revoke(user.refresh_token, "refresh_token") # RFC 7009Revoking a refresh token drops the whole token family — that's what "sign out
everywhere" needs. Machine tokens and introspection are confidential-client calls and
require a client_secret; revocation also works for a public (PKCE) client, which names
itself in the request body instead.
When a person has connected their account at a provider, lease a fresh access token for it.
client.pipes gets its own client-credentials token with the vault.lease scope (and
reuses it until it nearly expires); Cbox ID refreshes the provider token first when it is
about to expire:
from cbox_id import (
PipeNotConnectedError,
PipeReauthorizationRequiredError,
PipeTemporarilyUnavailableError,
)
try:
token = client.pipes.lease_token("github", user_id=user_id, purpose="list-repos")
# call https://api.github.com/user/repos with token.access_token, then drop it
except (PipeNotConnectedError, PipeReauthorizationRequiredError) as e:
return redirect(e.connect_url_with(client_id=CLIENT_ID, return_to=request.url))
except PipeTemporarilyUnavailableError as e:
retry_later(e.retry_after)
# PipeLeaseDeniedError: the app is not granted this pipe — a configuration problem.With a token issued for the person (they signed in to your app), use
PipesClient(issuer, user.access_token).lease_token("github", purpose=...) and leave
user_id out. To send somebody to connect before any lease,
client.pipe_connect_url("github", return_to) is the hosted connect page, preselected to
your app; they come back with ?provider=github&status=connected (or cancelled,
failed).
Your app declares its authorization roles and permissions in code and publishes that catalog to Cbox ID on deploy. Cbox ID owns identity and who holds which role; your app owns what a role means. Publishing is idempotent — an unchanged catalog is a server-side no-op.
from cbox_id import AuthzManifest
manifest = (
AuthzManifest()
.permission("invoices:create", "Create invoices")
.role("billing-admin", "Billing Admin", permissions=["invoices:create"])
)
summary = client.publish_manifest(manifest) # run on deployTwo flags decide who may grant what:
manifest = (
AuthzManifest()
.permission("support:impersonate", "Act as a customer")
.permission("reports:read", "Read reports", tenant_assignable=True)
.role(
"support",
"Support",
permissions=["support:impersonate"],
tenant_assignable=False, # a staff role: only your own operators grant it
)
)- A role is assignable by each customer's administrators unless you pass
tenant_assignable=False, which makes it a staff role only your own operators can grant. - A permission is internal — reachable only through the roles you declare — unless you
pass
tenant_assignable=True, which lets a customer's administrators grant it on its own.
Both must be real booleans: "false" from a config file raises ConfigurationError
rather than publishing a staff role every tenant can hand out.
publish_manifest mints a client-credentials token with the apps.manifest scope, POSTs
the manifest to {issuer}/api/v1/apps/manifest, and returns the server's sync summary
(unchanged, roles_declared, permissions_declared, orphaned_roles, …). It needs a
client_secret and raises ManifestPublishError if the push is rejected.
from cbox_id import verify_webhook
ok = verify_webhook(
payload=raw_body, # the exact bytes received
signature_header=request.headers.get("X-Cbox-Signature"),
secret=os.environ["CBOX_ID_WEBHOOK_SECRET"],
)An endpoint switched to the standard_webhooks signature scheme sends webhook-id,
webhook-timestamp and webhook-signature instead, verifiable with any
Standard Webhooks library or with:
from cbox_id import verify_standard_webhook
ok = verify_standard_webhook(
raw_body, # the exact bytes received
webhook_id=request.headers.get("webhook-id"),
webhook_timestamp=request.headers.get("webhook-timestamp"),
webhook_signature=request.headers.get("webhook-signature"),
secret=os.environ["CBOX_ID_WEBHOOK_SECRET"], # whsec_…, or the endpoint's hex secret
)Switching scheme keeps the secret: a hex Cbox secret is used as whsec_ + base64 of itself,
and both forms verify here. Update the receiver before you switch the endpoint
(env.webhooks.signature_scheme.change(id, {"signature_scheme": "standard_webhooks"})).
cbox_id.management is a typed client for Cbox ID's management planes. It is generated from
the OpenAPI documents the server publishes, so every method, path, scope and type matches the
server it was generated from. Use it from server code only: every client holds a management
credential.
| Client | Plane | Credential | base_url |
|---|---|---|---|
EnvironmentClient |
One environment's tenancy: organizations, users, apps, roles, SSO, audit logs… | cbid_env_… key, or a delegated access token |
The environment's own host, or the platform root with environment |
WorkspaceClient |
The workspace above its environments: projects, environments, team, keys | cbid_ws_… key, or a person's root token |
https://api.cboxid.com (default) |
PlatformClient |
The deployment itself, for operators | Delegated operator token only | https://api.cboxid.com (default) |
AccountClient |
A person's own account | Delegated token only | The environment's own host |
Method names are the server's action names: apps.secrets.rotate is
env.apps.secrets.rotate(...), and sso.saml_metadata.import is
env.sso.saml_metadata.import_(...). Path parameters come first, in path order, then the body
(or the query, for a read) as a dict, then keyword-only options. Every call returns an
ApiResponse with data, meta, body, status, replayed, idempotency_key,
request_id and headers. Bodies, queries and data are TypedDicts, so mypy and your
editor check the keys.
The client is synchronous, on httpx, like the rest of this package.
import os
from cbox_id.management import ApprovalDeniedError, CboxIdApiError, EnvironmentClient
env = EnvironmentClient(
base_url="https://acme.cboxid.com",
api_key=os.environ["CBOX_ID_ENV_KEY"], # cbid_env_…
on_approval_required=lambda approval, ctx: print(
f"Approve {ctx.action} on your device. Code: {approval.binding_code}"
),
)
app = env.apps.create(
{"name": "Billing", "type": "web", "redirect_uris": ["https://billing.acme.com/callback"]}
).data
# app["client_secret"] is in this response and in no other. Store it now.
try:
secret = env.apps.secrets.rotate(app["id"], {"grace_seconds": 3600}).data
# secret["client_secret"]: the new secret, shown once.
except ApprovalDeniedError:
print("Rotation was declined.")
except CboxIdApiError as exc:
if not exc.is_validation_error:
raise
print(exc.errors)When a key's policy holds an action for a person's approval, the server answers
202 approval_required. By default the client calls on_approval_required (show the
binding_code so the person can match it on their device), polls the approval, and repeats
the request with Cbox-Approval: <id> and the same Idempotency-Key once it is approved.
ApprovalDeniedError and ApprovalExpiredError are raised when it is denied or expires. The
poll goes only to the plane's own host: a poll_url on another origin is refused rather than
handed the credential. If you do not want to wait in the same call, pass approval="return":
from cbox_id.management import PendingApprovalResult
outcome = env.apps.secrets.rotate(app["id"], {"grace_seconds": 0}, approval="return")
if isinstance(outcome, PendingApprovalResult):
print(f"Code: {outcome.approval.binding_code}")
secret = outcome.resume().data # polls, then repeats the requestfrom cbox_id.management import EnvironmentClient, WorkspaceClient
workspace = WorkspaceClient(api_key=os.environ["CBOX_ID_WS_KEY"]) # cbid_ws_…
created = workspace.environments.create(
{
"name": "Staging",
"type": "sandbox",
"initial_key": {"name": "bootstrap", "scopes": ["apps:write", "organizations:write"]},
}
).data
# The first management key, returned once. On an idempotent replay its token is None.
initial_key = created["initial_key"]
assert initial_key is not None and initial_key["token"] is not None
env = EnvironmentClient(base_url=created["issuer"], api_key=initial_key["token"])
env.organizations.create({"name": "Acme", "slug": "acme"})- Every
POST,PUT,PATCHandDELETEsends anIdempotency-Key, a fresh UUID unless you passidempotency_key=. Network failures,5xx,429and409 idempotency_in_progressare retried with the same key (retry=RetryOptions(max_retries=3, base_delay=0.5, max_delay=30.0), in seconds).Retry-Afteris respected; one longer thanmax_delayis raised instead of waited out.replayedisTruewhen the server returned the first request's stored answer (Idempotent-Replayed). A secret in a replayed answer isNone. - A failed call raises
CboxIdApiErrorwithstatus,error(the stable code),message,errors(field-keyed, onvalidation_failed),request_id(the envelope'srequest_id, else theX-Request-Idheader; quote it when reporting a problem) andretry_after. A call that never got an answer raisesManagementNetworkError, which carries theidempotency_keyso you can repeat the request safely. Both areCboxIdErrors. - The client never logs. Secrets in responses are returned as they arrive, and request bodies are never put in an error.
A person's access token issued at the platform root reaches the workspace, account and
operator planes there, and any environment of their workspace when the request names it with
Cbox-Environment. Pass environment (an id or slug) and the root host as base_url:
staging = EnvironmentClient(
base_url="https://api.cboxid.com",
access_token=tokens.current, # a str, or a callable called before every request
environment="acme-staging", # sent as Cbox-Environment on every request
)
staging.organizations.portal_links.create(org_id, {"intents": ["sso", "dsync"]})What the token may do there is bounded by the person's role and the token's scopes. A
cbid_env_… key is bound to its own environment's host, so environment is refused with one.
Every paged list also has a …_all variant that iterates every item, fetching pages as you
reach them. It follows meta.next_cursor on the environment plane and meta.next_page on the
workspace plane:
for org in env.organizations.list_all({"limit": 100}):
print(org["id"], org["name"])env.fga writes relationship tuples and asks checks. Every write answers with a
consistency_token; pass it to a check that must see the write:
from cbox_id.management import fga_tuple
written = env.fga.tuples.write(
{
"tuples": [
{
"resource_type": "document",
"resource_id": "leave",
"relation": "viewer",
"subject": {"type": "user", "id": "alice"},
}
]
}
)
answer = env.fga.check(
{
"resource_type": "document",
"resource_id": "leave",
"relation": "viewer",
"subject_type": "user",
"subject_id": "alice",
"consistency_token": written.data["consistency_token"],
}
)
answer.data["allowed"]
# Up to 100 at once, in the tuple notation, answered in order:
env.fga.check_batch(
{"checks": ["document:leave#viewer@user:alice", "document:readme#editor@user:alice"]}
)env.fga.tuples.delete(), env.fga.resources.list() (which documents can alice view),
env.fga.subjects.list() (who can view this one), and env.fga.schema.get() /
.update() / .validate() complete it. fga_tuple() writes the notation from the same
mapping tuples.write takes.
Your app records what its users did, per organization (your customer), and Cbox ID keeps
each organization's events in a tamper-evident hash chain. AuditLogger buffers events and
sends them in batches of up to 100, each batch under its own Idempotency-Key. It sends a
batch when it is full, every flush_interval seconds (default 5, from a daemon thread), and on
flush() / close(). A batch that fails stays queued with the same key, so sending it again
never records an event twice:
from collections.abc import Sequence
from cbox_id.management import AuditLogEventInput, AuditLogger
def report(exc: Exception, batch: Sequence[AuditLogEventInput]) -> None:
log.warning("audit flush failed (%d events kept for the next one): %s", len(batch), exc)
with AuditLogger(env, on_error=report) as audit:
audit.record(
{
"organization_id": org["id"],
"action": "invoice.voided",
"actor": {"id": user.id, "type": "user", "name": user.name},
"targets": [{"id": invoice.id, "type": "invoice"}],
"context": {"location": request.remote_addr, "user_agent": request.user_agent.string},
"metadata": {"reason": "duplicate"},
}
) # occurred_at defaults to now
# Leaving the block (or audit.close()) stops the thread and sends what is left.Read events with env.audit_logs.events.list_all({"organization_id": …, "actions": […]}).
To get a CSV, export_audit_logs(env, filters) starts an export and polls it until it is
ready. Its url is signed and short-lived. To check the chain yourself instead of trusting
the server's env.audit_logs.verify(), call verify_audit_log_chain(env, organization_id) or
verify_audit_chain(events). They recompute sha256(prev_hash + canonical JSON) exactly as
the server does — keys sorted by UTF-8 bytes at every depth, PHP's list and empty-object
rules, PHP's float format, slashes and Unicode unescaped — and report
AuditChainVerification(valid, reason, broken_at_sequence, …).
Pass access_token (a string, or a callable called before every request so it can refresh)
instead of api_key. For a DPoP-bound token, pass a signer built from the P-256 key the token
was bound to:
from cbox_id.management import AccountClient, ES256DPoPSigner
me = AccountClient(
base_url="https://acme.cboxid.com",
access_token=tokens.current,
dpop=ES256DPoPSigner(private_key), # an EllipticCurvePrivateKey (cryptography)
)
me.sessions.revoke_others()Schema and operation types live in each plane's module (cbox_id.management.environment_api.App,
cbox_id.management.workspace_api.EnvironmentsCreateBody). The operation tables
(ENVIRONMENT_OPERATIONS, …) list each action's method, path, scope, danger and whether it can
be held for approval, which is useful for showing a confirmation before a critical action.
env.request(method, path, query=…, body=…) calls a route that is not generated.
The specs are vendored in openapi/. python -m scripts.generate_management (with the dev
extra installed) regenerates src/cbox_id/management/generated/ from them, and
python -m scripts.generate_management --fetch environment=https://acme.cboxid.com (or
workspace=, platform=, account=, all=) refreshes a vendored spec from a running server
first. --check exits non-zero when the generated code is stale, and the test suite fails
when the generated code and the vendored specs disagree.
Login is hardened by default — PKCE, state, nonce, and full id_token verification
via PyJWT, against an explicit allow-list of RS256 and ES256 keyed by JWKS key type,
so alg:none and algorithm confusion are both refused. Keep the
client secret and webhook secrets server-side.
This is a client. It authenticates users — into a chosen organization, when you ask —
and calls a Cbox ID instance's endpoints. The management client drives the server's own
management API with a credential you hold; SSO, SCIM and the rules behind every action stay
platform capabilities of cboxdk/laravel-id.
Report vulnerabilities via this repo's GitHub Private Vulnerability Reporting.
MIT © Cbox.