fix: allow any port on loopback OAuth2 redirect URIs - #29013
Conversation
RFC 8252 §7.3 requires an authorization server to accept any port on a loopback redirect URI, because a native app binds an ephemeral port at runtime and cannot know it at registration. Coder compares redirect URIs by exact string equality with no such exception, so a public client that registers http://127.0.0.1/callback can never match at authorize time. This adds the comparison rule without wiring it in yet: - RedirectURIMatches returns true on exact string equality (OAuth 2.1 §2.3.1), or when the registered URI is http to a loopback host and the two URIs are equal with the port removed. Every other component, including query and userinfo, must still match. The exception is decided by the registered URI, so a client cannot opt in by presenting a loopback host the app never registered. - isLoopbackAddress is exported as IsLoopbackAddress so registration and comparison share one definition of loopback. Its one caller is updated. A follow-up commit swaps the comparison in httpapi.QueryParamParser.RedirectURL. No behavior changes in this commit. Part of PLAT-488.
RedirectURL rejected any redirect_uri that was not byte-for-byte equal to the registered one. RFC 8252 §7.3 requires the port of a loopback redirect URI to be accepted whatever the client bound at runtime, so a public client registered with http://127.0.0.1/callback could never pass this check. The comparison now goes through codersdk.RedirectURIMatches, which keeps exact matching for every URI except a registered http URI to a loopback host, where the port is ignored. Both callers, the authorize handlers and the token endpoint, pick up the change through this one function. The default when redirect_uri is absent, the unparsable-input path, and the error text are unchanged. The code-vs-token redirect_uri check in the token endpoint is unchanged too: RFC 6749 §4.1.3 requires those two values to be identical, and a client presents the same port at both steps. Part of PLAT-488.
…on end to end The comparator change in httpapi is exercised here through both handlers. A public client registers http://<host>/callback with no port and authorizes with port 53219 for 127.0.0.1, [::1], and localhost. The consent page's cancel link, the code redirect, and the token exchange all use the presented port. Two cases pin what the exception does not do. An exchange from a different port than the code was issued to is refused with invalid_grant (RFC 6749 §4.1.3), and the code stays redeemable from the right port. A code issued with no redirect_uri, which leaves nothing on the code to compare against, still exchanges from a loopback port, so the token endpoint's own registration check is covered on its own. Part of PLAT-488.
The OAuth2 provider page said redirect URIs must match exactly and listed exact matching among the OAuth 2.1 requirements Coder enforces. Both are now qualified: the port of a loopback http redirect URI is not compared, as RFC 8252 requires for native apps that choose a port at runtime. The loopback note under Client Authentication Methods says how to register such a URI, the "Invalid redirect_uri" troubleshooting entry names the exception and points at that note, and the Standards Compliance paragraph links RFC 8252. Paragraphs touched are reflowed to one sentence per line, per the docs style guide. Part of PLAT-488.
Docs previewCheck off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here. |
|
/coder-agents-review |
|
/coder-agents-review |
|
Chat: Review in progress (17/17 reviewers complete) | View chat deep-review v0.9.0 | Round 1 | Last posted: Round 1, 11 findings (2 P3, 4 Nit, 5 Note), COMMENT. Review Finding inventoryFinding inventory - PR #29013Findings
Round logRound 1Netero-only first pass: No findings (one Note deferred to Gon, subsumed by CRF-1). Effective LOC +279 (< 1000), Law not spawned. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
This is a tight, well-scoped change. The port exception lives behind a single comparator (RedirectURIMatches) that both authorize and token endpoints route through, the loopback predicate is exported so registration and comparison share one definition, the exception is keyed on the registered URI so a client cannot opt in by presenting a loopback host it never registered, and the RFC 6749 §4.1.3 code-vs-token exact equality is deliberately left untouched. The panel tried to break the match on every off-axis input (host substitution, scheme, userinfo, path, query, IPv6, https loopback, non-loopback, .localhost subdomain, 127.0.0.2) and each is refused. Test density is 88% and the cases are real, not tautological.
No P0-P2 findings. Severity count: 2 P3, 4 Nit, 5 Note (plus 1 commit nit below). The two P3s are both documentation accuracy on security-relevant code, not behavior: a stale doc comment on newAuthorizeResponse that still promises exact matching, and the IsLoopbackAddress doc crediting localhost to RFC 8252 §7.3 (the RFC names only the IP literals; §8.3 marks localhost NOT RECOMMENDED). The docs page inherits the same RFC-attribution imprecision. Everything else is latent-hazard or cleanup: the same-typed (presented, registered) args carry opposite trust with no compile-time guard, and the IPv6 path compares two symmetrically malformed strings.
As Hisoka put it: "I came to fight this one. It fought back."
Process note: the commit bodies are a model of the form, stating pre-change behavior, the forcing RFC clause, and what deliberately did not change. One subject, test(coderd/oauth2provider): cover the loopback redirect port exception end to end, runs to 82 characters, over the 72-char limit; dropping "end to end" lands it at 70.
Generated by Coder Agents review bot.
coderd/oauth2provider/authorize.go:430
P3 [CRF-1] The newAuthorizeResponse doc comment still promises it "exact-matches any redirect_uri the client sent against it," but after this PR the match ignores the port for a registered loopback http URI. (Netero Note, Mafuuu Nit, Zoro Nit)
A reader trusting this docstring would conclude a differing port is rejected at
/oauth2/authorize, the opposite of what the PR ships. The author updated the comparator's comment and the docs page but left this one stale.
authorize.go is outside the diff, but the behavior it documents changed underneath it: p.RedirectURL now delegates to codersdk.RedirectURIMatches. Three reviewers converged here. A doc comment on a security-relevant handler that states the opposite of the shipped behavior misleads the next maintainer making an auth decision. Update the clause to name the loopback http port exception.
🤖
codersdk/oauth2_validation.go:87
Note [CRF-11] The ValidateRedirectURIScheme doc comment is duplicated verbatim (the block appears twice, immediately above the function). (Zoro)
Pre-existing and unrelated to this change, but the file is touched here, so it is cheap to delete the second copy while in the neighborhood. Human's call whether to fold it in or leave it for a dedicated cleanup.
🤖
🤖 This review was automatically generated with Coder Agents.
…IMatches comments
|
The review fixes were first pushed to the stacked branch by mistake. They are now on this branch, so the commit IDs in the thread replies map as follows:
On this branch the mismatch message keeps the registered URI: "must match ; only the port of a loopback URI may differ". The "one of the application's registered redirect URIs" wording belongs to #29014. |
TL;DR
RFC 8252 §7.3 requires an authorization server to accept any port on a loopback redirect URI, because a native app asks the OS for a port at runtime and cannot know it at registration. Coder compared redirect URIs by exact string match with no exception, so a public client registered with
http://127.0.0.1/callbackwas rejected at/oauth2/authorizebefore a code was ever issued. The comparison now ignores the port when the registered URI is http to a loopback host. Nothing else about the match changes.Contract change
http://127.0.0.1/callbackhttp://127.0.0.1:53219/callbackhttp://localhost:9876/callbackhttp://localhost:53219/callbackhttp://127.0.0.1/callbackhttp://localhost:53219/callbackhttps://app.example.com/callbackhttps://app.example.com:8443/callbackNot changed, on purpose
The token endpoint's second check, that the redirect_uri at exchange equals the one the code was issued to, stays exact. RFC 6749 §4.1.3 requires those two values to be identical, OAuth 2.1 dropped the parameter from the token request rather than relaxing it, and a client presents the same port at both steps. PLAT-488 asked for this check to be relaxed as well; the ticket will be amended.
Where in the OAuth Flow
Diagram: a native app on an ephemeral port
sequenceDiagram participant App as Native app (127.0.0.1:53219) participant B as Browser participant S as coderd App->>B: /oauth2/authorize?redirect_uri=http://127.0.0.1:53219/callback B->>S: GET /oauth2/authorize S->>S: registered http://127.0.0.1/callback is loopback http: port ignored, match S-->>B: 200 consent page B->>S: POST /oauth2/authorize (Allow) S-->>B: 302 http://127.0.0.1:53219/callback?code=...&state=... B->>App: code delivered to the listener App->>S: POST /oauth2/tokens redirect_uri=http://127.0.0.1:53219/callback S->>S: registration match (port ignored), then code-vs-token equality (exact), then PKCE S-->>App: 200 tokensWhat it satisfies
Docs. The OAuth2 provider page's loopback note says the port is not compared and how to register for it, the "Invalid redirect_uri" entry names the exception, and Standards Compliance qualifies "exact redirect URI string matching". That last paragraph is also edited by #28752; a one-line conflict for whichever lands second.
PLAT-488. Closes D1-04 of the OAuth 2.1 GA requirements. Related: PLAT-582 / #28910.
Manual Tests
Run on 2026-09-08 against a dev server at this PR's head (
e521650366) in a review workspace, driven from a laptop over an ssh tunnel. Fixtures registered through DCR. The full runbook with every captured output lives in the design-documents repo underprojects/PLAT-488-oauth2-loopback/PR-29013-verification-runbook.md.Scenario summary, all 29
Ris the registered redirect URI,Pthe presented one.http://127.0.0.1/callback, P:53219: consent page renders, cancel link carries the port127.0.0.1:53219/callbackwith code and state; code row stores the presented URI with port:53219and no client secret answers 200http://[::1]/callback, P[::1]:53219: accepted, 302 to[::1]:53219http://localhost/callback, Plocalhost:53219: acceptedhttp://localhost:9876/callback, Plocalhost:53219: accepted, a registered port does not pinhttp://127.0.0.1:53219/callback, P without a port: acceptedredirect_uriabsent: defaults to the registered URI (port 80), code row stores nonehttps): 400 on Coderlocalhostfor127.0.0.1: 400 on Coderinvalid_requestJSON, no Location:53219, exchanged with:53220: 400invalid_grant; the code survives and a retry with:53219answers 200redirect_uri, exchanged with:53219: 200 (registration check alone decides):53219, exchanged with:53219/other: 400invalid_request; retry with:53219answers 200:53219, exchanged with noredirect_uri: 400invalid_grant; retry answers 200https://app.example.com/callback, P:8443: 400 on Coderhttp://127.0.0.1:53219/callback: 400, a client cannot opt in by presenting loopbackhttp://app.localhost/callback, P:53219: 400; exact P acceptedhttp://127.0.0.2/callbackrefused at registration for both client typeshttp://127.0.0.1/callback, P:53219: accepted, exchange with secret answers 200cursor://anysphere.cursor-mcp/oauth/callback: identical P accepted, other path 400httpsflow completes, 302 hostapp.example.comwith no port951042226f): scenario 1's request answers 400must exactly match; back on this head it answers 200Not run: a browser click-through to a receiver on an OS-chosen port. Scenarios 1 to 3 cover each piece of that path from the Location header.
Shell helpers used throughout
Fixtures, all registered through
POST /oauth2/register(public ones withtoken_endpoint_auth_method: none):1. The motivating case: a public client on an ephemeral port
The registration has no port, the request has 53219, and both authorize handlers and the token endpoint accept it. The cancel link, the code redirect, and the value stored on the code all carry the presented port. No client secret is sent; the client is public.
2. The loopback host set
All three hosts get the exception, the brackets on
[::1]survive, a registered port does not pin, and a portless request against a ported registration is a port too. An absentredirect_uristill defaults to the registration exactly as before.3. Only the port is excepted
Every other component still has to match, and a mismatch stays on Coder with no redirect, from GET and POST alike. The query and userinfo rows are the two a field-by-field comparison would have let through.
4. The token endpoint: shared registration check, exact code check
Two checks, two error codes. A port-only mismatch passes the registration comparison and is refused by the unchanged code-vs-token equality as
invalid_grant. A path mismatch fails the registration comparison first asinvalid_request. In every case the refusal happens before the code is consumed, so the retry with the right port succeeds. The generic wording on theinvalid_requestcase is this branch's token error path, not this PR; the PLAT-481 stack replaces it.5. Outside the set, exact match still rules
The exception is decided by the registered URI: an
httpsregistration gets none, a client cannot opt in by presenting a loopback URI, a.localhostsubdomain is outside the set, and127.0.0.2cannot be registered at all. A confidential client with a loopback registration is relaxed the same way as a public one, with the secret still required.6. Regression control and docs
The ordinary confidential flow is unchanged end to end. The three doc passages name all three hosts, say public and confidential alike, exclude
.localhostsubdomains, and limit the exception to the port, which is what sections 2, 3 and 5 observed.7. Control against the pre-fix code
Same fixture, same database, same request, with the server restarted on the branch this PR was written beside (
951042226f, which has noRedirectURIMatches), then back on this head.8. Cleanup