Skip to content

fix(vault): accept dynamic client registration clientInfo in vault set - #287

Closed
Yigtwxx wants to merge 4 commits into
openclaw:mainfrom
Yigtwxx:fix/vault-clientinfo-dcr-validation
Closed

fix(vault): accept dynamic client registration clientInfo in vault set#287
Yigtwxx wants to merge 4 commits into
openclaw:mainfrom
Yigtwxx:fix/vault-clientinfo-dcr-validation

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #286. mcporter vault set rejects every clientInfo value that is not a string, so no real dynamic client registration response can be seeded into the vault. In RFC 7591, redirect_uris, grant_types, response_types and contacts are string arrays, and client_id_issued_at and client_secret_expires_at are numbers. A headless deployment that already holds OAuth credentials has no way to install them.

The declared type says otherwise. VaultPayload.clientInfo is OAuthClientInformationMixed, and mcporter builds that same array-valued shape itself in buildStaticClientInformation (src/oauth-client-info.ts:15) and during registration (src/oauth.ts:115), then reads clientInfo.redirect_uris back as an array (src/oauth.ts:506). Its own client information cannot round-trip through its own command.

Root cause

src/cli/vault-command.ts:154

function validateOAuthClientInfo(clientInfo: Record<string, unknown>): void {
  for (const [key, value] of Object.entries(clientInfo)) {
    if (value !== undefined && value !== null && typeof value !== 'string') {
      throw new CliUsageError(`Vault payload clientInfo.${key} must be a string.`);
    }
  }
}

One rule for every field. validateOAuthTokens, directly above it, already does the opposite: access_token and token_type non-empty strings, refresh_token/scope/issuer optional strings, expires_in a finite number. So the tokens half of the payload accepts its real shape and the client-information half does not.

Fix

validateOAuthClientInfo now checks each field against the JSON type OAuthClientInformationFullSchema declares for it (@modelcontextprotocol/sdk/shared/auth.js), in that schema's declaration order:

Type Fields
non-empty string, required client_id
string token_endpoint_auth_method, client_name, client_uri, logo_uri, scope, tos_uri, policy_uri, jwks_uri, software_id, software_version, software_statement, client_secret, issuer
array of strings redirect_uris, grant_types, response_types, contacts
finite number client_id_issued_at, client_secret_expires_at

The loop walks the rule table rather than the payload. Unknown keys therefore pass through untouched, so a full registration response keeps registration_client_uri, registration_access_token and whatever else the provider returned, and the reported field no longer depends on JSON key order. jwks stays unconstrained because the SDK types it as z.any(). redirect_uris stays optional even though the schema's wide member requires it, since the narrow member OAuthClientInformation is equally valid here. null is still treated as absent, as before.

URL-shaped fields are only checked for being strings. The SDK's SafeUrlSchema would additionally require a parseable URL. That starts rejecting redirect URIs that are legal in the wild, such as urn:ietf:wg:oauth:2.0:oob and custom-scheme mobile redirects, which is the wrong trade in a change whose point is to stop over-rejecting.

I kept the SDK's zod schemas out of this path for a related reason. They are all .strip(), so validating through them invites persisting result.data, which would quietly drop seeded fields the vault depends on: tokens.expires_at is read at src/oauth-persistence-stores.ts:37, src/oauth-token-refresh.ts:33 and src/runtime/oauth.ts:123, and validateVaultPayload deliberately returns the raw objects by reference. A union of OAuthClientInformationFullSchema and OAuthClientInformationSchema would not validate much either, since the second member requires only client_id and strips the rest, so { client_id: "abc", redirect_uris: 42 } passes it.

validateVaultPayload is unchanged, including the order in which it validates tokens before client information.

Also: three fields the read path requires and the write path did not check

externalVaultEntry (src/oauth-vault.ts:220) runs stored credentials through isStoredOAuthTokens and isStoredOAuthClientInformation, and drops the whole tokens or clientInfo object when a guard fails. Those guards require clientInfo.issuer to be a string and tokens.expires_at/tokens.expiresAt to be finite numbers. vault set checked none of the three, so a typo in one field silently discarded everything next to it, and the command still printed Saved OAuth credentials.

clientInfo.issuer was covered incidentally by the old string-only rule, so the per-field table has to keep covering it. It is worth naming because vault set is the only writer of that field and its only consumer is a security check: assertRefreshIssuerBinding (src/oauth-token-refresh.ts:86) pins refresh to the recorded issuer, and an unusable value turns the pin into a no-op. tokens.expires_at and expiresAt are the pre-existing half, folded into the same loop as expires_in.

Deliberate tightening: client_id is now required

Today {"clientInfo": {}} is accepted. Both members of OAuthClientInformationMixed require client_id, so the existing as OAuthClientInformationMixed cast does not hold for that value. And {} is not read downstream as "no client information": it is a truthy object that saveVaultEntry persists and getClientInformation() later hands back in place of the statically configured client, so the failure surfaces much later as an opaque SDK error during refresh. clientIdFromEntry (src/oauth-vault.ts:302) and same-URL credential inheritance both need it too. This does make the write side stricter than isStoredOAuthClientInformation, which does not require client_id, and it is a behavior change, so I would rather flag it than bury it. It is one if plus one test case if you would rather not take it.

Two adjacent fixes in the same input path

  • JSON.parse at line 47 was unguarded, so an unparsable payload left the command as a raw SyntaxError and printed a stack trace through the unexpected-error path instead of the usage path every other payload problem already uses. It now fails as a CliUsageError naming the source. The message deliberately does not repeat the parser's reason: V8 quotes a prefix of the input (Unexpected token 's', "sk-live-9f"... is not valid JSON), and the input here is credential material that can end up in a CI log. Naming the file follows src/cli/call-arguments.ts:373.
  • The vault set help text showed clientInfo as client_id alone, which reads as the whole contract and is plausibly where the expectation in the issue came from. It now says a full registration response is accepted.

Behavior proof

The real CLI through tsx src/cli.ts, throwaway XDG_CONFIG_HOME and XDG_DATA_HOME, and the payload from the issue verbatim. The access token is fake and https://example.test/mcp is never contacted.

=== BEFORE (main @ 764bb87) ===
--- mcporter vault set demo --stdin
[mcporter] Vault payload clientInfo.redirect_uris must be a string.
--- stored vault entry
(no credentials file written)
--- mcporter vault set demo --stdin  (truncated JSON)
[mcporter] Unexpected end of JSON input
SyntaxError: Unexpected end of JSON input
    at JSON.parse (<anonymous>)

=== AFTER (this branch) ===
--- mcporter vault set demo --stdin
Saved OAuth credentials for 'demo' to <temp>/mcporter/credentials.json
--- stored vault entry
{
  "tokens": {
    "access_token": "fake",
    "token_type": "Bearer",
    "__mcporter_generation": "a344f320-3658-4b95-b6b4-972186d02740"
  },
  "clientInfo": {
    "client_id": "abc",
    "redirect_uris": [
      "https://example.test/cb"
    ],
    "grant_types": [
      "authorization_code"
    ],
    "response_types": [
      "code"
    ],
    "token_endpoint_auth_method": "none",
    "__mcporter_client_generation": "8468dd59-b501-47ff-a77e-7a76f3b471ab"
  }
}
--- mcporter vault set demo --stdin  (truncated JSON)
[mcporter] Vault payload from stdin is not valid JSON.

Same harness for the guard mismatch, reading the entry back through loadVaultEntry so the read-path guards actually run:

=== BEFORE (main @ 764bb87) ===
--- tokens.expires_at = "soon"
Saved OAuth credentials for 'demo' to <temp>/mcporter/credentials.json
    loadVaultEntry: (entry carries neither tokens nor clientInfo)
--- clientInfo.issuer = 42
[mcporter] Vault payload clientInfo.issuer must be a string.
    loadVaultEntry: (no vault entry)

=== AFTER (this branch) ===
--- tokens.expires_at = "soon"
[mcporter] Vault payload tokens.expires_at must be a finite number.
    loadVaultEntry: (no vault entry)
--- clientInfo.issuer = 42
[mcporter] Vault payload clientInfo.issuer must be a string.
    loadVaultEntry: (no vault entry)

The first case is the silent loss: the command reports success and the credentials are unreadable. The second is the one the old rule already caught and the new table has to keep catching.

Tests

tests/vault-validation.test.ts goes from 16 to 32 cases, in the table-driven style the file already uses:

  • accepts a dynamic client registration clientInfo payload — the issue's payload verbatim.
  • accepts registration timestamps, contacts, and a jwks documentclient_secret, client_id_issued_at, client_secret_expires_at: 0, contacts, and a jwks document. Both acceptance cases assert the stored entry through loadVaultEntry, not the input object, so a dropped clientInfo fails the test.
  • preserves unknown clientInfo fields and seeded token expiryissuer, registration_client_uri, registration_access_token, tokens.expires_at and id_token all reach the vault verbatim. This is the case that fails if anyone later swaps the validator for one that reparses the payload.
  • persists null clientInfo fields verbatim — the pre-0.12.1 null tolerance, held in place.
  • rejects malformed clientInfo — a non-string entry inside redirect_uris, redirect_uris and grant_types given as bare strings, client_id_issued_at and client_secret_expires_at given as strings, a non-string client_name, a non-string issuer, and clientInfo with no client_id.
  • rejects malformed payload — the existing rows plus tokens.expires_at and tokens.expiresAt given as strings.
  • reports malformed stdin JSON as a usage error without echoing the payload and names the file when its JSON is malformed — one per input source.

Reverting only src/cli/vault-command.ts to main and keeping the tests:

=== tests against the unpatched validator ===
 FAIL  rejects malformed payload 6            (tokens.expires_at)
 FAIL  rejects malformed payload 7            (tokens.expiresAt)
 FAIL  rejects malformed payload 9            (clientInfo.client_id message)
 FAIL  reports malformed stdin JSON as a usage error without echoing the payload
 FAIL  names the file when its JSON is malformed
 FAIL  accepts a dynamic client registration clientInfo payload
 FAIL  accepts registration timestamps, contacts, and a jwks document
 FAIL  rejects malformed clientInfo 0         (redirect_uris entry)
 FAIL  rejects malformed clientInfo 1         (redirect_uris as string)
 FAIL  rejects malformed clientInfo 2         (grant_types as string)
 FAIL  rejects malformed clientInfo 3         (client_id_issued_at)
 FAIL  rejects malformed clientInfo 4         (client_secret_expires_at)
 FAIL  rejects malformed clientInfo 7         (missing client_id)
      Tests  13 failed | 19 passed (32)

The other 19 pass on both sides and cover what this change must not alter, including rejects malformed clientInfo 6, the issuer case.

One committed expectation moved: the row asserting clientInfo.client_id must be a string for client_id: 42 now expects must be a non-empty string, matching tokens.access_token.

Gates

Windows 11, Node 22.20.0, pnpm 10.33.2.

  • pnpm exec vitest run tests/vault-validation.test.ts tests/vault-command.test.ts — 37 passed.
  • pnpm check — oxfmt, type-aware oxlint and tsc --noEmit clean.
  • pnpm build — clean.
  • pnpm test — 1283 passed, 84 skipped, 7 failed. The 7 are in tests/chrome-devtools-relay-handoff.test.ts and tests/runtime-chrome-relay-handoff.test.ts, and they fail identically on unmodified main at 764bb87 on this machine (Invalid URL from the relay handoff), so they predate this branch and touch nothing it changes. I could not clear them here because this box is below the engine floor the repo declares (node >=24). CI settles it: build passes on ubuntu-latest, macos-15 and windows-latest for this branch.

Thanks @feniix for the report. The version table and the pointer straight at the validator left nothing to guess.

Yigtwxx added 4 commits August 8, 2026 14:33
vault set rejected every non-string clientInfo value, so no RFC 7591 dynamic
client registration response could be seeded: redirect_uris, grant_types,
response_types and contacts are string arrays, and client_id_issued_at and
client_secret_expires_at are numbers. mcporter builds that same array-valued
shape itself in buildStaticClientInformation, so its own client info could not
round-trip through the command.

Check each field against the type OAuthClientInformationFullSchema declares for
it, and require client_id, which both members of OAuthClientInformationMixed
demand. Iterating the rule table rather than the payload keeps unknown provider
fields untouched and makes the reported field independent of JSON key order.

Validation still inspects the payload without reparsing it, so seeded extras
such as tokens.expires_at continue to reach the vault verbatim.
An unparsable payload escaped JSON.parse as a raw SyntaxError, so piping a
truncated one-liner to vault set --stdin printed 'Unexpected end of JSON input'
through the unexpected-error path instead of the usage path the other payload
problems already use. Report it as a CliUsageError and keep the parser message
as the reason.
The payload example showed clientInfo as client_id alone, which reads as the
whole contract. Say that a full registration response is accepted.
Two fields decide whether a stored credential survives the read path, and
neither was checked on the way in. isStoredOAuthClientInformation requires
clientInfo.issuer to be a string and isStoredOAuthTokens requires expires_at
and expiresAt to be finite numbers; externalVaultEntry drops the whole tokens
or clientInfo object when either guard fails. So a typo in one field silently
discarded the rest, and for issuer that also unbinds the refresh issuer check
in assertRefreshIssuerBinding. Validate all three where the payload enters.

Also stop repeating the parser message for an unparsable payload: V8 quotes a
prefix of the input, which here is credential material, and it can reach a CI
log. Name the source instead, matching how call-arguments reports a bad
argument file.
@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P1 Urgent regression or broken agent/channel workflow affecting real users now. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 auth-provider 🚨 Merging this PR could break OAuth, tokens, provider routing, model choice, or credentials. labels Aug 8, 2026
@clawsweeper

clawsweeper Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed August 8, 2026, 4:14 PM ET / 20:14 UTC.

ClawSweeper review

What this changes

This PR updates mcporter vault set to accept typed OAuth dynamic-registration metadata, report malformed credential JSON safely, and add persistence-focused regression coverage.

Regression provenance

Possible regression — probable (reproduction; reviewed change). No predecessor PR is attributed.

Merge readiness

⚠️ Ready for maintainer review - 3 items remain

Current main still rejects valid array- and number-valued OAuth dynamic-registration metadata, so this PR remains necessary. The fix has strong real CLI proof; the only unresolved point is whether its new required client_id check is an intended compatibility change.

Priority: P1
Reviewed head: 2733f0c98895e781db609243e294792bae321f0a
Owner decision: Required. See Decision needed.

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) This is a focused, well-tested fix with strong real CLI proof; the remaining blocker is an intentional compatibility choice rather than a demonstrated patch defect.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): The PR body supplies an isolated real-CLI before/after transcript with fake credentials, persistence output, and malformed-input behavior.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The PR body supplies an isolated real-CLI before/after transcript with fake credentials, persistence output, and malformed-input behavior.
Evidence reviewed 4 items Current behavior: Current main rejects every non-string clientInfo field, preventing standard dynamic-registration arrays and timestamps from being imported.
Read-path contract: Stored tokens are discarded unless expiry fields are finite numbers, while stored client information currently requires only an optional string issuer.
Feature provenance: Blame attributes the current vault validation to the v0.13.0 release commit, which remains the source of the current behavior.
Findings None None.
Security None None.

How this fits together

mcporter vault set imports OAuth tokens and optional client metadata from a file or standard input into the local credential vault. OAuth runtime code later reads that vault to refresh credentials and connect to an MCP server.

flowchart LR
A[Credential JSON] --> B[Vault import command]
B --> C[Field validation]
C --> D[Local credential vault]
D --> E[OAuth refresh runtime]
E --> F[Authenticated MCP server]
Loading

Decision needed

Question Recommendation
Should mcporter vault set reject existing object-shaped clientInfo payloads that omit a non-empty client_id? Preserve import compatibility: Remove the required-client-id rejection and land the typed-field validation plus malformed-input handling.

Why: The central bug fix is compatible, but this additional validation changes a previously accepted input while the stored-client read guard remains broader.

Before merge

  • Resolve merge risk (P1) - Existing scripts that import object-shaped but client-id-less clientInfo will start failing at the new required-client-id check without an explicit upgrade contract.
  • Complete next step (P2) - Maintainer approval is required for the deliberate backwards-incompatible client-id validation change; no mechanical repair should be chosen first.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Diff scope 190 added, 19 removed across 2 files The patch is focused on the vault command and its regression coverage.
Regression coverage 16 to 32 validation cases New coverage exercises accepted DCR shapes, rejected malformed fields, persistence, and credential-safe JSON errors.

Merge-risk options

Maintainer options:

  1. Preserve existing import compatibility (recommended)
    Remove the required-client-id check while retaining dynamic-registration type validation and vault read-path protections.
  2. Approve strict client imports
    Keep the rejection only after maintainers explicitly accept the upgrade behavior for previously accepted partial payloads.

Technical review

Best possible solution:

Accept typed dynamic-registration metadata and safer malformed-input handling while preserving partial client-info imports unless maintainers intentionally adopt the stricter client-id contract.

Do we have a high-confidence way to reproduce the issue?

Yes. Current main visibly rejects non-string client-info fields, and the PR body supplies a real isolated CLI before/after run using a fake credential payload.

Is this the best way to solve the issue?

Unclear. Typed per-field validation is the narrow fix, but requiring client_id adds a compatibility policy change that maintainers must choose explicitly.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against dee2fa23ac82.

Labels

Label justifications:

  • P1: The validator blocks real OAuth dynamic-registration credentials from being imported into the vault.
  • merge-risk: 🚨 compatibility: The branch newly rejects previously accepted partial client-info payloads without a client identifier.
  • merge-risk: 🚨 auth-provider: The changed import validation controls OAuth credentials and client metadata persisted for later refresh.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body supplies an isolated real-CLI before/after transcript with fake credentials, persistence output, and malformed-input behavior.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body supplies an isolated real-CLI before/after transcript with fake credentials, persistence output, and malformed-input behavior.

Evidence

What I checked:

Likely related people:

  • Peter Steinberger: Current-main blame attributes the vault import validation and help text to the v0.13.0 release commit. (role: introduced current vault validation; confidence: high; commits: 49dcd3e7fffd; files: src/cli/vault-command.ts, src/oauth-credential-validation.ts, src/oauth-vault.ts)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Get a maintainer decision on whether imports without a client identifier should remain accepted.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (4 earlier review cycles)
  • reviewed 2026-08-08T12:05:39.069Z sha 2733f0c :: needs maintainer review before merge. :: none
  • reviewed 2026-08-08T14:05:41.899Z sha 2733f0c :: needs maintainer review before merge. :: none
  • reviewed 2026-08-08T16:54:37.445Z sha 2733f0c :: needs maintainer review before merge. :: none
  • reviewed 2026-08-08T19:03:34.754Z sha 2733f0c :: needs maintainer review before merge. :: none

@Yigtwxx

Yigtwxx commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

The one open item is the client_id requirement, so here is the measurement behind it.

Nothing in the repo seeds clientInfo without client_id. Searching src/, tests/, docs/ and README.md finds no empty or client_id-less clientInfo object. The only internal writer, saveClientInfo (src/oauth-persistence-stores.ts:384), passes SDK registration output, which always carries one, and it does not go through this validator. docs/config.md:259 documents the payload as { "tokens": { ... }, "clientInfo": { ... } } without naming fields, so no documented workflow depends on the empty form.

So the tightening only reaches a hand-written script that seeds clientInfo: {}, and that script is already storing a truthy object which shadows the configured client and then fails during refresh with an opaque SDK error. If you would rather not take it, the drop is the three-line client_id block at the top of validateOAuthClientInfo plus the last row of the rejects malformed clientInfo table. Nothing else in the change depends on it.

@steipete

steipete commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Superseded by the narrower compatibility-preserving fix in #288, now merged as 4e8e37df2004aeef8b7c913b50d8de8dfecf1e32. That landing resolves #286 while retaining null tolerance, partial-object compatibility, unknown provider metadata, and existing acceptance of clientInfo without a mandatory client_id.

Thank you @Yigtwxx for the concurrent analysis and especially for identifying two separate worthwhile follow-ups. They are explicitly preserved outside this PR’s scope:

  1. Sanitize malformed-JSON errors so credential payload fragments cannot be echoed in diagnostics.
  2. Validate token expires_at and expiresAt values as finite numbers at the vault input boundary.

Closing this overlapping implementation as superseded; those follow-up findings are not being rejected or folded into the #286 fix.

@steipete steipete closed this Aug 8, 2026
@Yigtwxx
Yigtwxx deleted the fix/vault-clientinfo-dcr-validation branch August 9, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 auth-provider 🚨 Merging this PR could break OAuth, tokens, provider routing, model choice, or credentials. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. P1 Urgent regression or broken agent/channel workflow affecting real users now. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

vault set rejects clientInfo from OAuth dynamic client registration

2 participants