feat(auth): OAuth 2.0 client credentials support for CLI + library#746
Conversation
Adds RFC 6749 §4.4 machine-to-machine OAuth to @adcp/client so CI compliance runs can point at an OAuth-protected agent without manual token management. Addresses adcontextprotocol/adcp#2677. Library auto-refreshes tokens before every call via `ensureClientCredentialsTokens` (coalesced across concurrent callers) and retries once on mid-session 401. CLI exposes `--save-auth --oauth-token-url` with `--client-secret-env` env-var indirection for CI. Includes RFC 8707 `resource`/`audience` support, RFC 6749 §2.3.1 Basic-auth encoding, TLS enforcement with userinfo/SSRF guards, and control-char sanitization on AS-supplied error messages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| bindAgentStorage, | ||
| NeedsAuthorizationError, | ||
| exchangeClientCredentials, | ||
| ensureClientCredentialsTokens, |
Prettier reformat across new CC files. In bin/adcp.js `--list-agents`, pull the env-var name via `extractEnvSecretName` so the raw `client_secret` field never enters the log format string. CodeQL's clear-text-logging rule considers any `*_secret` field access tainted regardless of branch guards; routing through a helper that returns only the safe label avoids the false positive without losing the DX of showing which env var holds the secret. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL's clear-text-logging rule treats any field access on `oauth_client_credentials` as tainted, even when the branch proves the value is a non-sensitive label (env-var name). Drop the secret-source breakdown from --list-agents and surface only "OAuth: client credentials"; users who want the full config can run --show-config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL alerts — 27 pre-existing false positivesThe Breakdown:
Main had 0 open alerts before this PR — I confirmed via What I fixed in my own code:
Options for the remaining 27:
I'd suggest (1) for this PR and (2) as follow-up work on the repo's code-scanning config. All other checks pass: Test & Build, Code Quality, Schema Sync, Publish Dry Run, Security Audit, CodeQL Analyze ( |
Make --oauth-token-url optional. When omitted from a client-credentials --save-auth invocation, walk the RFC 9728 protected-resource metadata and RFC 8414 authorization-server metadata chain (the same probe that powers adcp diagnose-auth) and use the advertised token_endpoint. Falls back to a clear error with guidance if discovery can't resolve it. Also relaxes the auth-mode mutex so that --client-id/--client-secret alongside --oauth is accepted (credentials already imply M2M; the flags are harmless redundancy rather than a mode conflict). New integration test spins up an agent that serves both well-known metadata documents and asserts the discovered token endpoint matches the advertised one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library:
- discoverAuthorizationRequirements now falls back to OpenID Connect
Discovery 1.0 (/.well-known/openid-configuration) when RFC 8414's
/.well-known/oauth-authorization-server misses. Covers Keycloak-in-OIDC,
Ping, ADFS. Results carry `metadataSource` so callers can tell which
doc they read.
- AuthorizationRequirements exposes the full `authorization_servers` list
and `grantTypesSupported` array so callers can log multi-issuer
deployments and make informed grant-compatibility decisions.
- diagnose-auth grows H7: informational check for DCR (RFC 7591)
readiness for client_credentials.
CLI:
- `adcp --save-auth` with client credentials:
* Pre-checks `grant_types_supported` when present (only when the AS
published it — RFC 8414 says absent defaults to
['authorization_code', 'implicit'], so absence is not proof of
non-support).
* Prints a multi-AS warning when PRM lists > 1 authorization server.
* Says "--oauth flag ignored; client credentials imply M2M" rather
than silently absorbing a redundant --oauth flag.
* Shows the discovered authorization server alongside the token URL.
* --dry-run validates + prints the plan without exchanging tokens or
writing config.
* Arg-shape mutex (--client-id vs --client-id-env etc.) now runs
before network discovery so bad-flags cases fail fast.
- Bare `--oauth` (browser) now pre-checks the discovered AS's
`grant_types_supported` and bails with guidance ("use client
credentials instead") when authorization_code isn't advertised.
- Help: --oauth-token-url labeled "(optional; auto-discovered)";
subcommand help lists exit codes.
- Inline require for discoverAuthorizationRequirements moved to the
top-of-file bulk import.
Tests:
- test/oauth-client-credentials-cli.test.js — new. Subprocess-spawned
smoke tests for the CLI wire: discovery + persist, --dry-run, OIDC
fallback, no-metadata error path, flag mutex, $ENV:VAR unset.
- Extended integration test for OIDC fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| discoveredRequirements = await discoverAuthorizationRequirements(url, { allowPrivateIp: true }); | ||
| if (discoveredRequirements && discoveredRequirements.tokenEndpoint) { | ||
| resolvedOauthEndpoint = discoveredRequirements.tokenEndpoint; | ||
| console.log(` Found token endpoint: ${resolvedOauthEndpoint}`); |
| * don't touch the user's real config. | ||
| */ | ||
|
|
||
| const { test, describe, before, after } = require('node:test'); |
| * don't touch the user's real config. | ||
| */ | ||
|
|
||
| const { test, describe, before, after } = require('node:test'); |
Adjusts the diagnose-auth report-shape assertion. H7 was added in the previous commit; the test was still pinned to the pre-H7 count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
@adcp/clientso machine-to-machine compliance runs no longer need manual token management. Addresses adcontextprotocol/adcp#2677.ProtocolClient.callTool(pre-flight + mid-session 401 retry, coalesced across concurrent callers). Storyboard/fuzz/grade pick it up via the newauth: { type: 'oauth_client_credentials', credentials, tokens? }option onTestOptions.adcp --save-auth <alias> <url> --oauth-token-url <url> --client-id ... --client-secret ..., with--client-id-env/--client-secret-envfor CI (stores$ENV:VARreferences — nothing sensitive on disk). Dedicatedadcp --save-auth --helpsubcommand help.token_endpoint(localhost carved out for dev), userinfo rejected, private-IP SSRF guard with operator opt-in, RFC 6749 §2.3.1 Basic-auth encoding, control-char sanitization on ASerror_description(defends against ANSI/CRLF injection from a hostile AS).resource/audienceforwarding for audience-validating authorization servers (Auth0, Okta, Azure AD, Keycloak).ClientCredentialsExchangeErrorwithkind: 'oauth' | 'malformed' | 'network';MissingEnvSecretErrorwithreason: 'unset' | 'empty'.is401Errornow recognizeserr.code === 401(MCP SDK'sStreamableHTTPClientTransporterror shape), so the 401-retry actually fires end-to-end.Relates to adcontextprotocol/adcp#2738 — that PR fixed the auth-code handoff from
ComplianceDatabase.resolveOwnerAuthto the SDK. This PR adds the client-credentials leg on the SDK side. A follow-up issue will file the dashboard-side work forsave_agentto accept CC config.Test plan
test/oauth-client-credentials.test.js(mocked fetch): exchange shape, RFC 6749 §2.3.1 encoding, RFC 8707 resource/audience, TLS/userinfo/SSRF rejection, errorkinddiscriminator, control-char sanitization, coalescing, force + concurrency interaction,getAuthTokenintegration,createTestClientwiring.test/oauth-client-credentials-integration.test.js(real MCP stub + real token server on127.0.0.1:0): initial exchange + bearer attach, warm cache, mid-session token rotation → 401 → force-refresh → retry succeeds, unbounded-retry protection, concurrent-call coalescing through the real call path.oauth-*,oauth-storyboard-plumbing,server-auth,cli-multi-instance-strategy).error-extraction.test.js+test/unit/mcp-*.test.js(61 tests) still pass —is401Errorchange didn't regress anything.--save-authhappy path, TLS rejection, userinfo rejection,localhost.attacker.comprefix bypass blocked, empty env var surfaces a distinct error.adcp --save-auth --helpprints dedicated subcommand help, unshadowed by the main--helphandler.Reviewer passes
Ran four specialist reviews (security, code, DX, testing) — two rounds. All must-fix and should-fix feedback is addressed in-PR:
http://localhost.attacker.comprefix bypass), coalesce key includestoken_endpoint + client_id.runFullAssessmentusesbuildResolvedAuthOptionhelper,MissingEnvSecretError.reasondiscriminator, URLSearchParams-based test assertions, force+coalesce buckets so a 401-triggered forced refresh doesn't piggyback on a stale in-flight exchange, A2A 401-retry symmetric with MCP (wraps withrethrowAsNeedsAuthorization).--oauth-endpoint→--oauth-token-url, three extra error hints (invalid_grant,invalid_request, non-JSON/404/405 → issuer-URL paste hint), CC symbols re-exported from@adcp/clienttop level for IntelliSense discovery.🤖 Generated with Claude Code