Machine clients, API keys and personal access tokens#
Requirements: §17 (MCH-001..MCH-017), TEST-039, TEST-065. Reference code:
examples/machine-client, internal/machine, internal/oauth/token.go
(clientCredentialsGrant). Tests: TestIntrospectionExchangeAndMachines in
tests/e2e/flows_test.go.
Service principals and client credentials#
A machine identity is a service principal (svc_...) with an owner,
optional allowed organizations, roles and a lifecycle. A client with the
client_credentials grant authenticates as that principal.
# 1. Service principal (realm.service_principals.write)
identity-cli api POST /service-principals '{"name":"nightly-billing","allowed_organization_ids":["org_..."]}'
# 2. Machine client linked to it (realm.clients.write); the response shows client_secret once
identity-cli api POST /clients '{"client_id":"batch-job","name":"Nightly billing batch","client_type":"machine","service_principal_id":"svc_..."}'
# 3. Allow the client to request a resource for service-principal subjects
identity-cli api PUT /clients/cli_.../resource-grants/res_billing '{"allowed_scopes":["billing:read"],"subject_types":["service_principal"]}'Token request (see examples/machine-client/main.go):
curl -u "batch-job:$CLIENT_SECRET" https://id.example.com/realms/main/oauth/token \
-d grant_type=client_credentials \
-d resource=https://billing.example.com \
-d scope=billing:read \
-d organization=org_...- Exactly one
resource; account and UserInfo audiences are refused (MCH-004, TEST-039). The admin audience can be requested when explicitly granted (used by resource servers that call the decision API). - If the resource requires an organization,
organizationmust be the principal's owner organization or one of itsallowed_organization_ids; otherwise the owner organization is used when set. - The token is a short-lived
service_accessJWT withsub= the service principal, no refresh token (MCH-003). Re-authenticate when it expires. private_key_jwtis available instead of a shared secret: register the client withtoken_endpoint_auth_method: private_key_jwt, add public keys withPOST /clients/{client}/credentials {"jwks": {...}}(404for an unknown client), and sign assertions withiss=sub= client ID,aud= the token endpoint URL or the issuer (everyaudvalue must be one of them, at most two),exp≤ 5 minutes, a uniquejti(MCH-010,TestPrivateKeyJWT).- Mutual-TLS client authentication (
tls_client_auth,self_signed_tls_client_auth) and certificate-bound tokens are available where the deployment accepts client certificates (MCH-014, mutual-tls.md). - Without any stored credential: a workload that holds an identity token from
its platform (GitHub Actions, Kubernetes, SPIFFE, ...) can use the
jwt-bearergrant instead of a secret (MCH-011..MCH-013, workload-federation.md). - DPoP-bound tokens: send a
DPoPproof header (oauth-oidc-profile.md). - Budget:
security.rate_limits.token_per_clientrequests per minute; excess gets429 slow_down. Tokens for a resource that requires an organization also count against that organization's budget (security.rate_limits.organization_per_window; TEN-018). - Realm policy
tokens.service_access_token_ttl_seconds(andtokens.access_token_ttl_seconds) can shorten service tokens below the resource's lifetime (TOK-018, tokens.md).
Lifecycle:
POST /service-principals/{id}/disablestops new tokens immediately and makes existing service tokens inactive at introspection and the IdP's APIs; offline verifiers accept them until expiry.- Secret rotation without downtime:
POST /clients/{client}/credentials {"retire_others_after_seconds": 86400}adds a secret and bounds the old ones; at most three active secrets. - Service principals hold roles like users (
POST /role-assignmentswith"subject": {"type": "service_principal", "id": "svc_..."}); the built-inresource-serverrole allows calling the decision API. A disabled or deleted service principal holds no permissions anywhere (decisions included). - Secret lifecycle is covered by
TestClientSecretLifecycle: rotation withretire_others_after_seconds, RFC 7009 revocation, decommissioning (a decommissioned client cannot be reactivated) andCache-Control: no-storeon token responses.
Agents#
MCH-017, TestAgentPrincipals (tests/e2e/agents_test.go). A service
principal created with "is_agent": true represents software that acts with
some autonomy (an AI agent, an automation that decides what to call). It is
an ordinary service principal with three differences:
- An accountable human owner.
owner_user_idis required; it defaults to the creating user. An agent owned only by an organization is refused (422onowner_user_id, "an agent needs an accountable human owner (MCH-017)").is_agentcannot be changed after creation, and the owner cannot be cleared. - Two people for privileged roles. Assigning an agent a privileged role
through
POST /role-assignmentsanswers202 {"status": "pending_approval", "request": {...}}and creates a request under/role-assignment-requeststhat a second administrator must approve (POST /role-assignment-requests/{id}/approve; neither the requester nor the subject can approve,403). A role is privileged when it, or any role it composes at any depth, has the realm boundary or the organizationownerrole, a wildcard permission (*,x.*), or any permission registered as privileged, custom permissions included (POST /permissionswith"sensitivity": "privileged"); a composition of more than 64 roles counts as privileged. Organization templates apply the same rule: a template that would grant an agent a privileged role (or any subject a role that requires approval) is not applied; its plan lists the conflict "grant <role> to service principal <name> needs an independent approver: assign it through POST /role-assignments", and applying it fails with409. Request the grant directly; once it is approved, the template sees it as unchanged. A dry run cannot evaluate a role the template itself creates; the real application refuses such a grant the same way. - Marked tokens. Access tokens of an agent carry
agent: true(a reserved claim, also returned by introspection of JWT and opaque tokens); introspection of the agent's API keys returnsagent: trueas well. When an agent's client exchanges a person's token, the exchanged token'sactentry carriesagent: true(tokens.md).
Agents get no other exemption and no extra power: ordinary authorization,
delegation ceilings, resource grants and token exchange policies apply to
them as to any service principal. Resource servers that treat agents
differently read agent (or act.agent) and decide themselves.
Acting on behalf of a user#
MCH-015. A service or agent that acts for a person uses token exchange (TOK-016, TOK-017; oauth-oidc-profile.md), never its own credentials with a user ID in the request:
- The person's token is the subject; the exchanging client's service
principal becomes the actor in
act(withagent: truefor agents), and prior actors are nested, at mosttokens.exchange_max_chain_depthlevels (2 by default; a deeper chain isinvalid_grant). - Each hop needs a registered audience transition: the client's resource
grant for the target lists the incoming token's audience in
exchange_policy.accept_from_audiences, and the scopes are capped byexchange_policy.max_scopes, the organization's scope policy and, at the first hop, the person's consent for the client they used. - The person's session, account and membership are checked live, and the exchanged token never outlives the token it came from.
- Service tokens (
service_access) can never be exchanged as subjects (invalid_grant, "this subject token cannot be exchanged"), so a service cannot turn its own credential into user authority (TestExchangeChainLimits). - The audit trail keeps the real actors apart from the person (AUD-005,
TestExchangeActorPreservation).token.exchangednames the exchanging client's service principal (or agent) as actor, or the client when it has none, the person as subject, and the whole delegation chain inmetadata.actor_chain(outermost actor first, earlier hops included, agents markedagent: true).authorization.decisionevents about an exchanged or support token keep the outermost actor inmetadata.actorand the sameactor_chain; a decision about an agent's own token carriesmetadata.subject_agent: true.
Owner departure#
A service principal's owner_user_id is the person accountable for it (MCH-007,
migration 0031, TestServiceOwnerDeparture in tests/e2e/lifecycle_test.go).
The worker task machine.owner_departures runs hourly for every realm:
- An active service principal whose owner is no longer an active user (for
example suspended, locked or deleted) is marked with the time this was
detected, and
service_principal.owner_departedis audited once (actorsystem, subject the owner, target the principal, metadatapolicy). The credential inventory lists its API keys with theowner_departedflag. - Realm policy
machine.owner_departuredecides what follows:flag(default): nothing else; the principal keeps working.disable: oncemachine.owner_departure_grace_days(0–90, default 0) have passed since the detection, the principal is disabled exactly as byPOST /service-principals/{id}/disable: its API keys and opaque tokens are revoked, client credentials fail, andservice_principal.disabled(reason codeowner_departed) and theservice_principal.state_changedevent are recorded. At most 100 principals per realm are disabled per run. The grace period counts from the detection, also when the policy was switched todisablelater, so switching can disable principals at the next run.
- When the owner is active again, or an administrator gives the principal an
active owner (
PATCH /service-principals/{id}withowner_user_id), the next run clears the marker, and a later departure is reported again. A principal the policy disabled is not re-enabled automatically. - Principals without an
owner_user_idare not covered. Personal access tokens and a user's own API keys stop working with their owner regardless of this policy. - An account that owns live service principals (or projects, clients,
resources or connections) cannot be deleted until they are moved to
another owner (
409 last_ownerwith the extensionowned; USR-020).POST /users/{user}/ownership-transfer {"to_user_id": "usr_..."}moves all of them at once, each audited as<kind>.ownership_transferred(data-inventory.md).
API keys#
Scoped, opaque keys (pa_key_...) owned by a service principal, for integrations
that cannot run an OAuth client:
identity-cli api POST /api-keys '{"service_principal_id":"svc_...","name":"sync",
"audience":"https://billing.example.com","scopes":["billing:read"],"organization_id":"org_...","ttl_days":90}'- The key is returned once; afterwards only ID, display prefix and metadata are visible (MCH-008).
- Rules (API keys and personal tokens): expiry is mandatory,
ttl_daysdefaults to 90 and may not exceed 365; the audience must be an active business resource (never the account or admin APIs); scopes must be declared by that resource and must not beprivileged;organization_idis required when the resource requires an organization and must be allowed for the owner. - One audience per key. Resource servers validate keys by introspection; an
introspector of another audience receives
{"active": false}. - Introspection returns
token_kind: api_key,sub(the owner),subject_type,scope,aud,exp,org_id. - Each successful introspection records the key's last use:
last_used_atinGET /api-keys(and in the credential inventory) is updated at most once a minute. Personal access tokens record their last use the same way. DELETE /api-keys/{id}revokes immediately (next introspection). A disabled owner makes its keys inactive.
Personal access tokens#
Users create their own automation tokens (pa_pat_...) through self-service, with
authentication within security.recent_auth_window:
identity-cli api --audience account POST /me/personal-tokens \
'{"name":"cli","audience":"https://orders.example.com","scopes":["orders:read"],"organization_id":"org_...","ttl_days":30}'- Requires
identity-cli login --audience accountfirst (the CLI stores one credential per audience). - Shown once; listed without the secret (
GET /me/personal-tokens); revoked withDELETE /me/personal-tokens/{id}. - Scopes never exceed the owner's current authority (MCH-006): at creation, a
scope is refused (
403) when the organization'sallowed_scopesexcludes it, the owner lacks a permission listed in the scope'srequired_permissions, or the scope has a step-up policy (step_up_acr,max_auth_age_secondsorsensitivity: privileged; AUT-010), because a token holds no authentication event to be strong or recent enough; on every introspection the scopes are narrowed again the same way, and the token becomes inactive when none remain. The same rules apply to API keys. Scopes withoutrequired_permissionsare not tied to the owner's roles, so resource servers must still authorize the owner.ttl_daysis 1–365 (default 90). For tokens bound to an organization (organization_id), organization policy can refuse them (personal_tokens: "disabled",403) or capttl_days(personal_token_max_days,422); tokens without an organization are not subject to organization policy (TestPersonalTokenCeiling). - Introspection returns
token_kind: personal_access. The token is inactive when the owner is no longer active or, for organization-bound tokens, loses the membership. Deleting the user revokes their tokens. - Support (impersonation) tokens cannot create personal tokens (IMP-012).
Inventory and expiry#
GET /credential-inventory(realm.credentials.inventory) lists active API keys, personal tokens and client secrets that carry at least one flag. Flags:expiring(14 days),unused(60 days),ownerless(the owning user or service principal is no longer active; for client secrets, no active owning user and no technical contact),owner_departed(a service principal's accountable owner user is no longer active),over_privileged(scopes beyond the owner's current authority) andno_expiry(client secrets) (MCH-007, MCH-016, APP-014).POST /credential-inventory/revocations{"ids": [...], "reason": "..."}revokes 1–100 API keys, personal tokens (realm.api_keys.manage) or client secrets (realm.clients.credentials), each in its own transaction with per-item results; users need recent authentication (TestCredentialInventory).- The worker sends one notification per credential 14 days before an API key,
personal token or client secret/key expires: to the owner (the user, or the
service principal's owning user), and for clients to the owning user and every
technical contact. The contacts of dynamically registered clients are
registrant-supplied and unverified and are never mailed
(dynamic-client-registration.md); such a
client's alerts reach only an owning user an administrator sets. The alert
is claimed in
credential_expiry_alertsin the same transaction that enqueues it, so several workers or a retried run never notify twice (migration0023; TOK-021, MCH-009,TestCredentialExpiryAlerts).
Ownership report#
DOM-009, APP-014, MCH-007, migration 0047, TestOwnershipReport. Every
integration object should have someone accountable for it.
GET /api/v1/realms/{realm}/ownership-report (realm.settings.read; paged
with limit and cursor, {items, next_cursor}) lists the live objects that
lack one:
kind | Covered when |
|---|---|
client | Active, with or without secrets (the operator CLI client excluded); its contacts count as accountable |
project | Not archived |
service_principal | Active |
resource | Active business API resources |
identity_provider | Active |
webhook | Not disabled |
hook | Active pre-action hooks |
authorization_policy | Not disabled; an organization-scoped policy is owned by its organization |
Each item has kind, id, name, owner_user_id, owner_organization_id
and flags: ownerless (no owner user, owning organization or, for clients,
contacts) or owner_departed (the owner user is no longer active or
recovery_restricted). Only flagged objects are listed.
Authorization policies (POST /policies) and pre-action hooks (POST /hooks)
now take owner_user_id too; it defaults to the creating user and must be an
active account of the realm (422). It is not returned on the policy or
hook. To resolve a finding: transfer ownership (POST /users/{user}/ownership-transfer moves projects, clients, service
principals, resources, identity providers, hooks, webhook subscriptions and
authorization policies; POST /projects/{project}/ownership-transfer one
project; POST /hooks/{id}/ownership-transfer, POST /webhooks/{id}/ownership-transfer and POST /policies/{policy}/ownership-transfer with {"owner_user_id"} one hook,
subscription or policy, also one without an owner), or disable or delete the
object. A plugin-managed hook or subscription keeps its plugin when its owner
changes (the owner is not part of the reviewed package). Each change is
audited as <kind>.ownership_transferred and moves the object's version
(ETag; the single-object routes honor If-Match).