Skip to content

fix(oauth): name the offending redirect URI when client validation fails - #1628

Merged
krishagel merged 2 commits into
devfrom
fix/oauth-invalid-client-diagnostics
Aug 11, 2026
Merged

fix(oauth): name the offending redirect URI when client validation fails#1628
krishagel merged 2 commits into
devfrom
fix/oauth-invalid-client-diagnostics

Conversation

@krishagel

Copy link
Copy Markdown
Member

Agent-connect OAuth is broken in prod for every user

The PSD OpenClaw OIDC client (7e8646f4…) carries three redirect URIs — two correct HTTPS callbacks and one dev-only http://localhost:3000/agent-connect-aistudio/callback.

Its application_type is native, and validateNativeUri rejects an HTTP redirect whose host isn't a literal loopback address — LOOPBACK_HOSTS is {"127.0.0.1", "[::1]"}, and localhost isn't in it (RFC 8252).

Confirmed by running the real validator against the three stored URIs:

VALID: false
ERRORS: ["http://localhost:3000/agent-connect-aistudio/callback:
         native HTTP redirect URIs must use literal 127.0.0.1 or [::1]"]

Validation is all-or-nothing, so that single entry makes loadClient return undefined and oidc-provider answer invalid_client. The two valid URIs never get a chance. One user's three attempts are the errors logged at 19:35:28, 19:36:25, 19:39:43 UTC on 2026-08-10 — but this affects everyone.

⚠️ This PR does not fix the bad data

The redirect URI is a prod DB row and must change through the admin client UI — to http://127.0.0.1:3000/..., or by dropping the localhost entry. The flow stays broken until that happens.

What this PR fixes: why it took an investigation to find

The error logged redirectErrorCount: 1 and nothing else. A count can't be distinguished from a missing client, a disabled client, or a PKCE/auth-method problem — and the caller only ever sees the generic invalid_client. There was no path from symptom to URI without reproducing the validator by hand.

redirectValidation.errors already held the exact message with the offending URI. It just wasn't being written down. Now it is.

Redirect URIs are registration metadata, not credentials — these errors carry no token, code, or secret.

Test changes

The logger mock built a fresh object per createLogger() call, so no test could assert on what was logged — which is why the missing detail went unnoticed. It's now a single shared spy.

Added a regression case reproducing the exact prod client (one bad localhost entry beside a valid production URI), asserting the client fails closed and that the log names the offending URI without blaming the valid one. Verified it fails against the previous code and passes here.

Verification

  • 68 tests pass across tests/unit/lib/oauth/
  • eslint clean on both touched files and repo-wide
  • Repo typecheck reports the same 20 pre-existing AWS SDK type errors before and after

Agent-connect OAuth is broken in production for every user, and the log line
that should have identified it in seconds gave a bare count instead.

What is wrong in prod: the `PSD OpenClaw` OIDC client
(7e8646f4-4091-4a34-a6b9-0d3721e8a126) carries three redirect URIs — two
correct production/dev HTTPS callbacks and one dev-only
`http://localhost:3000/agent-connect-aistudio/callback`. Its application_type is
`native`, and validateNativeUri rejects an HTTP redirect whose host is not a
literal loopback address: LOOPBACK_HOSTS is {"127.0.0.1", "[::1]"} and
`localhost` is not in it (RFC 8252). Confirmed by running the real validator
against the three stored URIs:

  VALID: false
  ERRORS: ["http://localhost:3000/agent-connect-aistudio/callback:
           native HTTP redirect URIs must use literal 127.0.0.1 or [::1]"]

Validation is all-or-nothing, so that single entry makes loadClient return
undefined and oidc-provider answer `invalid_client` — the two valid URIs never
get a chance, and the flow is dead for everyone, not just the user who reported
it. That user's three attempts are the three errors logged at 19:35:28,
19:36:25 and 19:39:43 UTC on 2026-08-10.

This commit does NOT fix the bad data — the redirect URI is a prod DB row and
has to change through the admin client UI (to `http://127.0.0.1:3000/...`, or
by dropping the localhost entry). What it fixes is why that took a full
investigation to find.

The error logged `redirectErrorCount: 1` and nothing else. A count cannot be
told apart from a missing client, a disabled client, or a PKCE/auth-method
problem, and the caller only ever sees the generic `invalid_client`, so there
was no way to get from the symptom to the URI without reproducing the validator
by hand. `redirectValidation.errors` already held the exact message with the
offending URI in it — it simply was not being written down. It is now.

Redirect URIs are registration metadata, not credentials; these errors carry no
token, code, or secret, so logging them leaks nothing.

Test changes: the logger mock built a fresh object per createLogger() call, so
no test could ever assert on what was logged — which is why the missing detail
went unnoticed. It is now a single shared spy. Added a regression case that
reproduces the exact prod client (one bad localhost entry alongside a valid
production URI) and asserts the client fails closed AND that the log names the
offending URI without blaming the valid one. Verified it fails against the
previous code and passes here.

Verification: 68 tests pass across tests/unit/lib/oauth/; eslint clean on both
touched files and repo-wide. Repo typecheck reports the same 20 pre-existing
AWS SDK type errors before and after this change.
Copilot AI lite review requested due to automatic review settings August 10, 2026 23:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review

Small, well-scoped fix — good root-cause diagnosis in the PR description (loopback vs. localhost under RFC 8252, all-or-nothing validation). Comments below are non-blocking.

Code quality

  • The fix is purely additive: it threads redirectValidation.errors into the existing this.log.error(...) call without touching validation logic, so the "fail closed on invalid client" behavior is unchanged — low risk.
  • redirectErrorCount is kept alongside the new redirectErrors array even though the count is now derivable from the array's length. That's fine if any existing log-based alerting/dashboards filter on the count field, but worth a one-line confirmation that it's intentional rather than leftover.
  • The 14-line comment above the log.error call (and the similar block in the test) narrates the specific 2026-08-10 PSD OpenClaw incident inline in the source. Per this repo's comment guidance, that kind of "why we're touching this" context belongs in the PR description/commit message, not baked into the code permanently — it'll read as stale trivia once the incident is old news. A 2-3 line comment stating the durable invariant ("validation is all-or-nothing; log the specific URI so failures are diagnosable without hand-reproducing the validator") would be more maintainable than the full incident narrative baked into both files.

Bugs

None found. Logic is untouched; only the log payload grows.

Security

  • Correctly reasoned: redirect URIs are registration metadata, not credentials, so logging them is fine — no token/code/secret is included in redirectValidation.errors.
  • The array is implicitly bounded by MAX_REDIRECT_URIS (enforced earlier in validateOAuthRedirectUris), so there's no unbounded-log-growth concern from a malicious/misconfigured client with many redirect URIs.

Performance

Negligible — a small, bounded string array added to a structured log call on an already-slow-path (client validation failure).

Test coverage

  • Good catch fixing the logger mock: the old mock built a fresh object per createLogger() call, so error spies were unreachable — meaning no prior test could have asserted on logged content, in this file at least. Making it a single shared spy is a real improvement beyond this PR's immediate scope.
  • The new regression test reproduces the exact prod client shape (one bad localhost URI beside a valid HTTPS one) and asserts both that the client fails closed and that the valid URI isn't blamed in the error output — good coverage of the specific failure mode.
  • Since this touches the shared logger mock, it might be worth double-checking (if not already done in CI) that no other test file in tests/unit/lib/oauth/ still relies on the old per-call-fresh-mock behavior in a way that would break silently — the diff only shows this one test file, so I can't confirm from the diff alone.

Overall: solid, minimal diagnostic fix with a legitimate regression test. Nothing here blocks merge.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad0458a562

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/oauth/drizzle-adapter.ts Outdated
clientId,
applicationType: client.applicationType,
redirectErrorCount: redirectValidation.errors.length,
redirectErrors: redirectValidation.errors,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Redact credentials from redirect diagnostics

When a stored redirect is rejected for containing URL userinfo, such as https://alice:hunter2@example.com/callback, validateCommon() rejects it but includes the original URI in redirectValidation.errors; logging that array therefore writes the username and password to CloudWatch. The logger's generic filtering does not recognize URL userinfo, and malformed or invalid-policy URIs may likewise contain sensitive query values, so redact userinfo and sensitive query data while retaining enough origin/path detail to identify the offending entry.

AGENTS.md reference: AGENTS.md:L293-L293

Useful? React with 👍 / 👎.

…re logging

Addresses the P2 on this PR, which was correct and contradicts what the
previous commit message asserted. I wrote that these errors "carry no token,
code, or secret". They can.

`validateCommon` rejects a redirect URI *because* it contains userinfo, and
`validateOAuthRedirectUris` builds its message as `${uri}: ${reason}` with the
original string interpolated. So a stored
`https://alice:hunter2@example.com/callback` produces the error
"https://alice:hunter2@example.com/callback: must not contain userinfo", and
logging that array writes the password to CloudWatch. The logger's generic
field filtering matches key names and does not recognise URL userinfo, so
nothing downstream would have caught it. Invalid-policy and malformed entries
can carry sensitive query values the same way.

The whole point of this PR is that a rejected redirect URI is untrusted input,
so it gets redacted at the point of logging rather than trusted because it came
from our own table. `redactUrisForLog` drops userinfo, query and fragment and
keeps scheme, host, port and path — enough to pick the offending entry out of
the client's stored list, which is all the diagnostic needs.

Two details worth stating:

- Redaction happens at the LOG site, not inside redirect-uri-policy. The admin
  client UI surfaces these same errors to the person editing the URI, and there
  the full string is correct: they typed it, and truncating it would make the
  form unusable.
- The token filter deliberately accepts more than well-formed URIs. Requiring a
  legal scheme would let `ht!tp://alice:hunter2@host` through untouched purely
  because `ht!tp` is not a valid scheme, and a malformed entry is exactly the
  kind most likely to be carrying junk. Anything containing `://`, `@` or `?`
  is treated as sensitive; an unparseable candidate is replaced wholesale with
  `<unparseable redirect URI>` rather than echoed. Ordinary prose, bare IPs like
  `127.0.0.1`, and words such as "URI:" match neither test and pass through, so
  the messages stay readable.

Tests: 5 new cases covering userinfo, query+fragment, the second
"Invalid redirect URI: <uri>" message shape, a malformed-scheme URI that still
carries a password, and a prose/localhost case asserting messages stay legible.
All five fail against the previous commit. 73 tests pass across
tests/unit/lib/oauth/; eslint clean; typecheck clean.
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review: fix(oauth) — name the offending redirect URI when client validation fails

Good root-cause work: tracing redirectErrorCount: 1 back through validateOAuthRedirectUris to the actual localhost vs. 127.0.0.1 gap, and fixing the logger mock so the regression test can actually assert on log output, are both real value independent of the redaction logic itself.

I focused most of the review on the new redactUrisForLog (lib/oauth/drizzle-adapter.ts:751-781), since that's where the risk in this diff concentrates.

Bug: mangles the literal "URI:" label in one of the two documented message shapes

The JSDoc says errors come in two shapes — "<uri>: <reason>" and "Invalid redirect URI: <uri>" — and the function is built to handle both. But for the second shape, the token "URI:" (from ...redirect URI: https://...) itself matches uriShaped (/^[a-z][a-z0-9+.-]*:/i matches any bare word:, not just real schemes). It then strips the trailing :, tries new URL("URI"), that throws, and the catch branch replaces the label with "<unparseable redirect URI>:".

Trace for redactUrisForLog("Invalid redirect URI: https://bob:pw@host.example/cb"):

  • token "URI:"uriShaped true → candidate "URI"new URL("URI") throws → replaced with "<unparseable redirect URI>:"
  • token "https://bob:pw@host.example/cb" → redacted correctly to "https://host.example/cb"

Result: "Invalid redirect <unparseable redirect URI>: https://host.example/cb" — the credential is stripped correctly, but the message that's supposed to make this diagnosable from the log group alone is now garbled. Since this fires exactly on the "Invalid redirect URI: <uri>" shape (produced whenever a stored redirect URI fails new URL() in redirect-uri-policy.ts), it's not a rare corner case — it's one of the two shapes this function exists to handle.

The existing test ("handles the 'Invalid redirect URI: <uri>' shape too") only asserts .not.toContain("pw@") / .toContain(...), so it doesn't catch this. Worth asserting the surrounding prose (e.g. .toContain("Invalid redirect URI:")) so this regresses loudly next time.

Possible gap: opaque (no-//) custom-scheme URIs bypass credential stripping

validateNativeUri's private-use branch (RFC 8252 §7.1) expects reverse-domain custom schemes like com.example.app:/callback — i.e. URIs without // after the scheme. For such "opaque path" URLs, WHATWG's URL never populates username/password (everything after the scheme lands in pathname instead), so parsed.username = "" / parsed.password = "" in redactUrisForLog are no-ops, and any user:pass@host-shaped text embedded in the pathname is logged verbatim.

This also means the pre-existing validateCommon userinfo check (parsed.username || parsed.password) has the same blind spot — a stored URI like myapp:alice:hunter2@evil.com would sail past "must not contain userinfo" (same URL object, same empty username/password) and surface later via a different error (e.g. "native private-use schemes must be reverse-domain based"), with the credential-shaped substring intact in the redacted output.

Given the PR's own framing — "a rejected URI is UNTRUSTED" and the JSDoc's claim that credentials are stripped from "any URI embedded in a validation message" — this seems worth at least a follow-up issue, even if the practical exposure is narrow (requires DB/admin-level write access to oauth_clients.redirect_uris already).

Style: inline comment references a specific dated incident

CLAUDE.md is explicit: "Don't reference the current task, fix, or callers... since those belong in the PR description and rot as the codebase evolves." The comment block above this.log.error (drizzle-adapter.ts:451-473) names the specific date (2026-08-10), the specific client (PSD OpenClaw), and narrates the incident — all of which is already in this PR's excellent description. Worth trimming to just the durable rationale (why redact, why all-or-nothing validation makes a count insufficient) and letting the incident narrative live in the PR/commit history.

Nits

  • Minor: redirectErrors can carry up to MAX_REDIRECT_URIS (20) full messages up to MAX_REDIRECT_URI_LENGTH (2048 chars) each into a single log field in the worst case. Not a real problem at CloudWatch scale, just flagging in case there's a log-size budget elsewhere in the stack.

What's solid

  • No any, no console.*, follows the createLogger/ActionState conventions.
  • Regression test is real (verified to fail against pre-fix code per the PR description) and reproduces the actual prod client shape.
  • Fixing the logger mock to a shared spy is a good catch — it explains why this gap was invisible to the test suite before, and unblocks future assertions on logged output in this file.

Overall: the core diagnostic fix (surfacing redirectValidation.errors) is correct and valuable on its own. The redaction wrapper is the part I'd want another look at before merge — the "URI:" mangling is a real, easily-reproduced bug in the happy path this PR is meant to improve.

@krishagel
krishagel merged commit 3829a87 into dev Aug 11, 2026
7 checks passed
@krishagel
krishagel deleted the fix/oauth-invalid-client-diagnostics branch August 11, 2026 00:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants