feat(auth): add an opt-in OAuth2 client_credentials grant for machine clients - #3977
Open
bdchatham wants to merge 9 commits into
Open
feat(auth): add an opt-in OAuth2 client_credentials grant for machine clients#3977bdchatham wants to merge 9 commits into
bdchatham wants to merge 9 commits into
Conversation
… 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>
13 tasks
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:
Use |
Contributor
|
Apply skip-security-scan to unblock CIs |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_tokenalready produces scope-carrying tokens,_DELEGATED_ALLOWED_PREFIXESalready fail-closed-confines them off the admin and user-management paths, andhash_secretalready 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/tokenstays 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
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_admin→LEVEL_OWNERoverride inside/v1/sessionskeys 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
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.pyis byte-identical tomain, so no pre-existing test was modified.git diff main..HEAD -- tests/contains zero removed lines.The full
tests/serverrun is 2883 passed / 9 failed. The 9 are all intest_sessions_snapshot.pyand reproduce identically on a cleanmainworktree, 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
Test coverage
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:
device_auth.pyis refactored here, not just added to. Two helpers it owned privately, the OAuth error shape and the no-store headers, move toroutes/_oauth.pyso 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.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/tokenstays unrouted unless a machine client is configured.