fix: wrap ExternalPrincipalExchange application_id-not-found as *OAuthError - #169
Conversation
…hError
ExternalPrincipalExchange (internal/service/oauth.go:621-623) used
plain fmt.Errorf when the requested application_id couldn't be resolved:
return nil, fmt.Errorf("invalid_request: application_id %s not found ...",
req.ApplicationID)
extractOAuthError only matches *service.OAuthError via errors.As — a
plain wrapped error falls through to the "server_error" 500 default.
The token endpoint emitted 500 instead of the intended 400
invalid_request, masking the actual failure cause and giving clients
no actionable wire signal.
Replaced with oauthBadRequestCause (matching the pattern used at lines
598, 602, 608, 611 in the same function) so:
- HTTP status is correct (400, not 500)
- error code on the wire is "invalid_request" as the original code
intended
- the wrapped err remains accessible via errors.Unwrap for logs
No semantic change to the wire response shape beyond the status code
correction. The error message text now lives in the Description field
(unchanged content) rather than in fmt.Errorf's format string.
Pre-existing bug from commit d5bedb4 (March 2026). Caught by Copilot
during PR-167 review (out of scope for that PR — see thread on PR-167).
No new test: ExternalPrincipalExchange has no integration coverage
today (the path requires a TrustedServiceValidator that isn't wired in
any test setup). Adding one would be substantial scope creep for a
1-line bug fix; whoever next touches this code path should add
coverage. The fix is strictly better than the bug regardless.
There was a problem hiding this comment.
Code Review
This pull request updates the error handling in ExternalPrincipalExchange when retrieving an identity fails. It replaces a plain fmt.Errorf with oauthBadRequestCause to ensure the token endpoint correctly returns an invalid_request (400) status instead of a server_error (500), while preserving the underlying error for logging. There are no review comments, so I have no additional feedback to provide.
There was a problem hiding this comment.
Pull request overview
Fixes OAuth token exchange behavior for the ExternalPrincipalExchange path by ensuring an application_id lookup failure returns a structured *service.OAuthError, so handlers emit the intended RFC-compliant invalid_request (HTTP 400) instead of defaulting to server_error (HTTP 500).
Changes:
- Replace the
fmt.Errorf("invalid_request: ...")return withoauthBadRequestCause(...)soextractOAuthErrorcanerrors.Asthe error properly. - Preserve the underlying
GetIdentityerror in the wrapped cause for logging/diagnostics.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…guish not-found from transient errors
Two findings from Copilot on the merge-from-main commit (now that PR-167's
oautherror package is available on this branch):
1. The bare "invalid_request" string violated PR-167's documented convention
("emission sites use the constant; comments may use bare strings"). Swapped
to oautherror.InvalidRequest.
2. More substantive: GetIdentity can fail for reasons other than "row not
found." The bun repository wraps sql.ErrNoRows for genuine-missing OR
IDOR-protected cross-tenant lookups, but transient infrastructure failures
(DB unreachable, query timeout) surface as wrapped driver errors. The
previous code mapped ALL of them to invalid_request 400 — so a client
hitting a 500-class infra problem got back a 400 with a misleading
"not found or access denied" message and no signal to retry.
Now uses errors.Is(err, sql.ErrNoRows) to gate: matched → 400
invalid_request with the application_id message; unmatched → 500
server_error via oauthServerError so the client can retry. The wrapped
cause is preserved in both branches for logs.
This is strictly an improvement on the original bug fix — the previous
fmt.Errorf had the same not-found-vs-infra confusion baked in, just behind
a different wire-shape bug. Catching both now while we're here.
No new test: ExternalPrincipalExchange still has no integration coverage
(see PR-169 original description — would need a TrustedServiceValidator
test harness). Whoever next touches this path should add one.
Summary
ExternalPrincipalExchange(the external-principal path of thetoken_exchangegrant, whenactor_tokenis empty) had two bugs atinternal/service/oauth.go:621-630:fmt.Errorf("invalid_request: ...")falls throughextractOAuthError'serrors.As(*OAuthError)check and emits 500server_errorinstead of the intended 400invalid_request.GetIdentityfailures were lumped into the same 400 message, including transient infrastructure errors (DB unreachable, query timeout, etc.) that should be 500-retriable.Final shape (after Copilot review fixes — commit
483816e)Net wire-behavior change vs the original buggy code:
{"error":"server_error"}→{"error":"invalid_request"}{"error":"server_error"}→{"error":"server_error"}(unchanged but now via the right helper)GetIdentityerror viaerrors.Unwrap(previously discarded)oautherror.InvalidRequest(PR-167's RFC-anchored constant), not a bare stringCommit history
3e417f5oauthBadRequestCause. Used bare"invalid_request"string because PR-167 wasn't merged into main yet.571368foautherrorpackage.483816eoautherror.InvalidRequest, distinguishsql.ErrNoRowsfrom transient errors.Origin and scope
The original wire-shape bug was from commit
d5bedb44(March 2026 — Yash Datta). Caught by Copilot during PR-167's review; split out of that PR to keep its scope clean (constants sweep, not error-wrapping fixes).A grep for
fmt\.Errorf\("invalid_acrossinternal/returned this as the only site with this anti-pattern. Confirmed the same shape doesn't appear viaerrors.Neweither.Test plan
go build ./...cleango vet ./...cleango test ./tests/integration/...— ~36s, no regressions)ExternalPrincipalExchangehas no integration coverage today; the path requires aTrustedServiceValidatorthat isn't wired in any test setup. Adding the full harness for a 1-line bug fix is disproportionate; whoever next touches this code path should add coverage.What this PR is NOT