Skip to content

feat(auth): add an opt-in OAuth2 client_credentials grant for machine clients - #3977

Open
bdchatham wants to merge 9 commits into
omnigent-ai:mainfrom
bdchatham:feat/client-credentials-grant
Open

feat(auth): add an opt-in OAuth2 client_credentials grant for machine clients#3977
bdchatham wants to merge 9 commits into
omnigent-ai:mainfrom
bdchatham:feat/client-credentials-grant

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

Related issue

Closes #3976

Summary

A headless process cannot authenticate to the server as itself today. Every path that mints a credential assumes a human at a browser: the device grant walks a person through a consent screen, cookie sessions come from an interactive login. So automating anything means either running a browser login for a pseudo-human service account, or handing the process the cookie-signing secret, which is the key to every session on the server.

This adds the standard answer, an OAuth 2.0 client-credentials grant (RFC 6749 §4.4). One confidential client presents an id and secret and receives a short-lived access token representing itself.

It reuses what is already here rather than adding a parallel model: mint_delegated_token already produces scope-carrying tokens, _DELEGATED_ALLOWED_PREFIXES already fail-closed-confines them off the admin and user-management paths, and hash_secret already compares a presented secret against a stored digest.

Opt-in and default-off, following the device grant's precedent. The client's env config is the opt-in: with none set, POST /oauth/token stays unrouted and answers exactly as it did before this commit. Nothing about an existing deployment's routed surface changes unless an operator opts in.

ELI5

A robot needs to log in. Today the only door has a "please click here" button on it, which a robot cannot click, so people either make a fake human account for it or give it the master key. This adds a door for robots: it shows an id and a password and gets a pass that works only on a few specific corridors, never the offices where user accounts are managed. The pass is stamped with the robot's own name, so the logs show the robot did it rather than a person.

How it works

  headless caller                        server
       │                                   │
       │  POST /oauth/token                │
       │  grant_type=client_credentials    │
       │  client_id + client_secret        │
       │─────────────────────────────────► │
       │                          compare secret against
       │                          the stored keyed hash
       │                                   │
       │                          vet the configured sub:
       │                          must NOT be an admin
       │                                   │
       │  ◄─────────────────────────────── │
       │  access_token (scope-carrying)    │
       │                                   │
       │  Authorization: Bearer …          │
       │─────────────────────────────────► │
       │                          fail-closed allowlist:
       │                          /v1/sessions ✓  /auth/users ✗

The configured subject must be a non-admin principal, vetted at mount and again on every mint. That matters because the allowlist confines the path, not the privilege level within it: the is_adminLEVEL_OWNER override inside /v1/sessions keys off the token's identity, so an admin subject would reach every tenant's sessions despite the allowlist. Re-vetting per mint means promoting that principal to admin later stops new tokens rather than waiting for the current one to expire.

Only one grant may own POST /oauth/token. The device grant wins when enabled and this one stands down, though its config is still parsed so a misconfiguration fails at startup rather than hiding until the device grant is switched off.

Test Plan

tests/server/test_client_credentials.py                    ┐
tests/server/test_oauth_shared.py                          ├─ 141 passed
tests/server/integration/test_client_credentials_e2e.py    │
tests/server/test_device_auth.py, test_oidc.py             ┘

ruff check      All checks passed!
ruff format     clean on all touched files
mypy            clean on all touched files

Coverage: config parsing (all-set, all-unset, every partial combination, raw secret pasted where a hash belongs, malformed and uppercase hashes, reserved principal, TTL bounds); secret comparison; the admin-subject refusal at mount and per mint; the fail-closed allowlist including a cache-replay attempt; throttling of repeated failed client authentication; the end-to-end path where a minted machine principal creates, operates and deletes its own session; standing down when the device grant owns the endpoint; and a half-configured client failing startup while standing down.

tests/server/test_device_auth.py is byte-identical to main, so no pre-existing test was modified. git diff main..HEAD -- tests/ contains zero removed lines.

The full tests/server run is 2883 passed / 9 failed. The 9 are all in test_sessions_snapshot.py and reproduce identically on a clean main worktree, so they are a pre-existing test-isolation issue rather than this change.

Demo

N/A, no UI surface. The end-to-end integration test drives the whole flow through a production-shaped ASGI app.

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

No E2E test: the surface is an HTTP grant with no UI, and the integration test already drives a real ASGI app end to end including session creation and deletion by the minted principal. Manual verification is unchecked because the automated coverage exercises the same paths a manual run would.

Three things I would rather surface than have found in review:

  1. device_auth.py is refactored here, not just added to. Two helpers it owned privately, the OAuth error shape and the no-store headers, move to routes/_oauth.py so both grants share one definition instead of drifting. hash_secret's docstring is also corrected, since it described itself as hashing device codes and refresh tokens and is now used for a client secret too.
  2. The throttle ceiling and the TTL ceiling are independent knobs, and an operator can configure a TTL short enough that the throttle window outlives it. That combination is legal and not obviously wrong, but it is worth a maintainer's opinion.
  3. Public OAuth routes in the neighbouring module carry an explicit dependencies=[] marker. This module relies on the same effect without the marker. Happy to add it for symmetry if you would prefer the convention held.

Changelog

Adds an opt-in OAuth 2.0 client-credentials grant so a headless client can authenticate as its own non-admin principal, scoped by the existing delegated-token allowlist. Default-off: POST /oauth/token stays unrouted unless a machine client is configured.

… clients

Add a confidential-client credentials grant (RFC 6749 §4.4) so a headless
process can mint a scoped, short-lived session token non-interactively. The
device grant covers a client acting for a human; this covers a client acting
as itself, reusing the existing delegated-token machinery instead of the
interactive OIDC round-trip or the cookie-signing key.

Opt-in and default-off, following the device grant: the machine client's env
config IS the opt-in, so POST /oauth/token stays UNROUTED in every deployment
that configures no machine client, answering exactly as it did before this
commit: a 404, or a 405 where a built web SPA's catch-all is mounted at / and
serves GET only.
Nothing about the routed surface of an existing oidc/accounts deploy changes
unless an operator sets OMNIGENT_MACHINE_*. (The device grant needs its own
OMNIGENT_DEVICE_GRANT_ENABLED because its endpoints are useful with zero
config; this grant has nothing to serve without a configured client, so a
second flag would only be another way to say the same thing.)

- routes/client_credentials.py: POST /oauth/token (grant_type=
  client_credentials). One confidential client read from the env
  (OMNIGENT_MACHINE_CLIENT_ID / _CLIENT_SECRET_HASH / _SUB / _TOKEN_TTL). All
  three of the first group set builds the router; all unset returns None and
  leaves the path unrouted; any other combination raises at startup. The
  secret hash must have the hash_secret digest's shape (64 hex chars, matched
  by a self-anchored pattern so the strictness does not depend on the call
  site's choice of fullmatch), so the raw secret pasted in its place is an
  operator-visible startup error rather than an endpoint that 401s every
  correct credential forever. The TTL is capped at 3600s — expiry is this
  model's only revocation, so the cap is what makes "bounded by a short TTL"
  true, and it matches the device grant's fixed access-token lifetime. The
  client secret is compared in constant time against its cookie-secret-keyed
  digest. Mints a delegated token with scope=sessions and no grant_id.
- throttle the pre-authentication client check: nothing has authenticated when
  the secret comparison runs, so the endpoint carries the same per-source-IP
  sliding window the device grant already applies to its public authorize
  endpoint (10/60s, 429 slow_down), gating ahead of the comparison rather than
  counting failures — a failure-only limiter would still answer the guess that
  happened to be right, leaving the guess rate unbounded. The secret's own
  entropy is NOT machine-checkable here (only its digest is configured, and
  every digest is 64 hex chars whatever the secret), so that half is stated as
  operator guidance — generate it with secrets.token_urlsafe(32) — instead of
  pretending to enforce it. The limiter is per replica and per source IP; the
  design doc's Deliberate limits says so rather than overselling it.
- vet the machine principal against the permission store at mount AND before
  every mint: the path allowlist confines routes, not privilege level, and
  /v1/sessions has an is_admin->OWNER override. An admin sub leaves the router
  unbuilt at mount, and a sub promoted to admin afterwards gets
  403 unauthorized_client on its next mint instead of new tokens until a
  restart. Both checks fail closed on a store error, and the refusal names
  which of the two it hit (the vetting helper returns ok / admin /
  unverifiable) so a store outage is not logged as a bad sub.
- RFC 6749 conformance on the token endpoint: §5.1's Cache-Control: no-store +
  Pragma: no-cache on every response (the shared oauth_error factory carries
  them, so the device grant's error shapes get them too; its two success
  responses now send them as well), and §5.2's WWW-Authenticate challenge on a
  401 invalid_client that rejected an Authorization header. Basic is matched
  case-insensitively per RFC 7235 §2.1, and both halves of a Basic credential
  are form-urldecoded after the split on ":" as §2.3.1 requires, so a secret
  containing ":", "%", "+" or a space is not read as its encoded form.
- routes/_oauth.py: the helpers two grant routers now share. NO_STORE_HEADERS
  is a read-only mapping (MappingProxyType) — one object reaches responses from
  both routers, so a mutable one would let any caller silently re-header every
  later token response in the process. The sliding-window limiter moves here
  from device_auth.py unchanged, because both grants throttle an
  unauthenticated endpoint with it.
- auth.py: trigger the delegated path-allowlist on a scope claim as well as a
  grant_id claim, so a scoped-but-grantless token is confined to the allowlist
  and returns before the token-keyed credential cache. A token carrying either
  claim still fails closed, and a non-string grant_id is still rejected
  outright.
- device_auth.py: mint_delegated_token takes grant_id as optional; the claim is
  omitted when None. Its OAuth error factory and the rate limiter move to
  routes/_oauth.py now that two grant routers answer on /oauth/token. Its
  POST /oauth/device/authorize 200 also sends the no-store pair: RFC 8628 §3.2
  carries the bearer device_code and the user_code, which are as sensitive as a
  token response.
- device_grant_store.py: hash_secret's doc now describes it as the server's one
  stored-secret form (device codes, refresh tokens, and this grant's configured
  client secret) rather than device-code-specific, since a second module keys
  off it. Left in place rather than moved, to keep the diff off unrelated
  callers.
- app.py: mount the grant only when the factory returns a router, in the
  cookie-based auth modes and never alongside the device grant, which already
  owns POST /oauth/token; thread the permission store for the principal check.
- designs/CLIENT_CREDENTIALS.md: the operator-facing configuration, the token
  shape, and the threat table — companion to DEVICE_AUTH.md. The identity-
  collision row states the fresh-identity requirement as operator guidance
  instead of claiming a startup check: whether a sub collides with a human
  account is not reliably answerable through the permission store (is_admin
  cannot distinguish absent from non-admin, list_users is paginated), so the
  doc no longer promises a warning that would silently stop firing. The
  secret-entropy requirement is stated the same way, for the same reason.
- tests: unit + e2e, incl. the default-off contract at both the factory and
  the whole-app level, the admin-sub mount refusal, the promoted-sub and
  store-error mint refusals (and that each names its own cause), the
  secret-hash and TTL-ceiling config errors, the anchored hash pattern, the
  form-urldecoded Basic credential, the throttle (including that it gates a
  correct credential too), the §5.1/§5.2 response headers, the
  scope-cache-bypass guard, and the grant_id leg of the allowlist trigger.
  tests/server/test_oauth_shared.py covers what the two grants share: the
  read-only header constant, the error factory's merge, the limiter, and the
  device authorize response's no-store headers.

Naming: the env vars are OMNIGENT_MACHINE_* (not OMNIGENT_M2M_*), matching how
the adjacent grant names its config after the kind of client it serves
(OMNIGENT_DEVICE_CLIENT_SECRET) and dropping the "M2M" jargon; the code calls
this a machine client throughout. MIGRATION for anyone already running the
pre-review shape: rename OMNIGENT_M2M_CLIENT_ID / _CLIENT_SECRET_HASH / _SUB /
_TOKEN_TTL to OMNIGENT_MACHINE_*. There is no back-compat alias — the old names
were never released.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: bdchatham <bdchatham@gmail.com>
@github-actions
github-actions Bot requested a review from bbqiu August 3, 2026 16:39
@github-actions github-actions Bot added the size/XL Pull request size: XL label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@bdchatham This PR is a Bug fix, Feature, or UI / frontend change but the Demo section is missing or only contains a placeholder.

These change types require a screenshot or screen recording so reviewers can see the new behaviour without checking out the branch. Please update the Demo section with:

  • A screenshot or screen recording of the change, or
  • A link to a hosted video or GIF showing the new behaviour.

Use N/A only when the change has no user-visible effect whatsoever (e.g. a pure refactor or test-only change). If that's the case, uncheck the relevant type box and check Refactor / chore or Test / CI instead.

@github-actions github-actions Bot added the needs-demo PR needs a demo screenshot or recording label Aug 3, 2026
@PattaraS

PattaraS commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Apply skip-security-scan to unblock CIs

@github-actions github-actions Bot added P2-medium Priority: bug with workaround, important feature request waiting-for-review and removed waiting-on-author labels Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-demo PR needs a demo screenshot or recording P2-medium Priority: bug with workaround, important feature request size/XL Pull request size: XL skip-security-scan Skip security gate waiting-for-review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] OAuth2 client-credentials grant so a headless process can authenticate as itself

3 participants