diff --git a/coderd/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index 8b563520d2a..cce69bc634b 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -232,11 +232,12 @@ func (p *QueryParamParser) RedirectURL(vals url.Values, base *url.URL, queryPara return base } - // OAuth 2.1 requires exact redirect URI matching. - if v.String() != base.String() { + // OAuth 2.1 §2.3.1 requires an exact match. RFC 8252 §7.3 excepts the port + // of a loopback redirect URI; the comparator owns that rule. + if !codersdk.RedirectURIMatches(v, base) { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, - Detail: fmt.Sprintf("Query param %q must exactly match %s", queryParam, base), + Detail: fmt.Sprintf("Query param %q must match %s; only the port of a loopback URI may differ", queryParam, base), }) } diff --git a/coderd/httpapi/queryparams_test.go b/coderd/httpapi/queryparams_test.go index 44cc66d09c7..592f43550c6 100644 --- a/coderd/httpapi/queryparams_test.go +++ b/coderd/httpapi/queryparams_test.go @@ -616,7 +616,7 @@ func TestRedirectURL(t *testing.T) { vals := url.Values{"redirect_uri": []string{"https://evil.example.com/steal"}} parser.RedirectURL(vals, base, "redirect_uri") require.Len(t, parser.Errors, 1) - require.Contains(t, parser.Errors[0].Detail, "must exactly match") + require.Contains(t, parser.Errors[0].Detail, "must match") }) // url.Parse returns a nil URL alongside its error for these, so a caller @@ -636,4 +636,58 @@ func TestRedirectURL(t *testing.T) { require.Contains(t, parser.Errors[0].Detail, "must be a valid url") } }) + + // RFC 8252 §7.3: a loopback redirect URI may present any port. The returned + // URL is the presented one, since that is where the response must go. + t.Run("LoopbackPortDiffers", func(t *testing.T) { + t.Parallel() + for _, host := range []string{"127.0.0.1", "[::1]", "localhost"} { + registered, err := url.Parse("http://" + host + "/callback") + require.NoError(t, err) + presented := "http://" + host + ":53219/callback" + + parser := httpapi.NewQueryParamParser() + vals := url.Values{"redirect_uri": []string{presented}} + got := parser.RedirectURL(vals, registered, "redirect_uri") + require.Empty(t, parser.Errors, "host %s", host) + require.Equal(t, presented, got.String(), "host %s", host) + } + }) + + t.Run("LoopbackRegisteredWithPort", func(t *testing.T) { + t.Parallel() + registered, err := url.Parse("http://localhost:9876/callback") + require.NoError(t, err) + presented := "http://localhost:53219/callback" + + parser := httpapi.NewQueryParamParser() + vals := url.Values{"redirect_uri": []string{presented}} + got := parser.RedirectURL(vals, registered, "redirect_uri") + require.Empty(t, parser.Errors) + require.Equal(t, presented, got.String()) + }) + + // Only the port is excepted. codersdk.TestRedirectURIMatches covers each + // other component; this checks the wrapper reports the mismatch. + t.Run("LoopbackOtherComponentDiffers", func(t *testing.T) { + t.Parallel() + registered, err := url.Parse("http://127.0.0.1/callback") + require.NoError(t, err) + parser := httpapi.NewQueryParamParser() + vals := url.Values{"redirect_uri": []string{"http://127.0.0.1:53219/other"}} + parser.RedirectURL(vals, registered, "redirect_uri") + require.Len(t, parser.Errors, 1) + require.Equal(t, "redirect_uri", parser.Errors[0].Field) + require.Contains(t, parser.Errors[0].Detail, "must match") + }) + + // A non-loopback registration keeps the exact match, port included. + t.Run("NonLoopbackPortDiffers", func(t *testing.T) { + t.Parallel() + parser := httpapi.NewQueryParamParser() + vals := url.Values{"redirect_uri": []string{"https://app.example.com:8443/callback"}} + parser.RedirectURL(vals, base, "redirect_uri") + require.Len(t, parser.Errors, 1) + require.Contains(t, parser.Errors[0].Detail, "must match") + }) } diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 04e172dd9d1..6b759c26939 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -576,6 +576,26 @@ func TestNewAuthorizeResponse(t *testing.T) { require.Empty(t, p.Errors) require.False(t, response.canRedirect()) }) + + // RFC 8252 §7.3: the port of a loopback redirect URI is not compared, and + // the response goes to the port the client presented. + t.Run("LoopbackPortDiffersIsADestination", func(t *testing.T) { + t.Parallel() + + const presented = "http://127.0.0.1:53219/callback" + p := httpapi.NewQueryParamParser() + response, err := newAuthorizeResponse(p, url.Values{ + "redirect_uri": {presented}, + "state": {"abc123"}, + }, "http://127.0.0.1/callback") + + require.NoError(t, err) + require.Empty(t, p.Errors) + require.True(t, response.canRedirect()) + require.Equal(t, presented, response.callbackURL()) + errorURL := response.errorURL(codersdk.OAuth2ErrorCodeAccessDenied, "denied") + require.Equal(t, "127.0.0.1:53219", errorURL.Host, "error redirects must keep the presented port") + }) } // TestAuthorizeResponseZeroValue pins the zero value as inert, since it is what diff --git a/coderd/oauth2provider/authorize_test.go b/coderd/oauth2provider/authorize_test.go index 1cbbeed6cd2..5fac477efdc 100644 --- a/coderd/oauth2provider/authorize_test.go +++ b/coderd/oauth2provider/authorize_test.go @@ -361,7 +361,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { "%s: the user must not be redirected to a URI the app did not register", method) // The request also carries an invalid scope, so this pins which // guard rejected it first. - require.Contains(t, readBody(t, resp), "must exactly match", + require.Contains(t, readBody(t, resp), "must match", "%s: the rejection must come from redirect_uri validation", method) } @@ -1032,3 +1032,91 @@ func readBody(t *testing.T, resp *http.Response) string { require.NoError(t, err) return string(body) } + +// RFC 8252 §7.3: a native app registers a loopback redirect URI without a port +// and presents whichever port it bound at runtime. +func TestOAuth2AuthorizeLoopbackRedirectPort(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + oauth2providertest.EnableDCR(t, client) + + const port = "53219" + + // Registers a public client on http:///callback and authorizes with + // http://:53219/callback. Returns what the exchange needs. + authorize := func(ctx context.Context, t *testing.T, host string) (clientID, presented, code, verifier string) { + t.Helper() + + app := oauth2providertest.RegisterPublicClient(t, client, "loopback", "http://"+host+"/callback") + presented = "http://" + host + ":" + port + "/callback" + + verifier, challenge := oauth2providertest.GeneratePKCE(t) + query := authorizeQuery(t, app.ClientID, "") + query.Set("code_challenge", challenge) + query.Set("redirect_uri", presented) + + get := sendAuthorizeRequest(ctx, t, client, http.MethodGet, query) + defer get.Body.Close() + body := readBody(t, get) + require.Equal(t, http.StatusOK, get.StatusCode, body) + require.Equal(t, host+":"+port, cancelLinkFromConsentPage(t, body).Host, + "the cancel link must go to the presented port") + + post := sendAuthorizeRequest(ctx, t, client, http.MethodPost, query) + defer post.Body.Close() + require.Equal(t, http.StatusFound, post.StatusCode, readBody(t, post)) + location, err := url.Parse(post.Header.Get("Location")) + require.NoError(t, err) + require.Equal(t, host+":"+port, location.Host, "the code must go to the presented port") + require.Equal(t, "/callback", location.Path) + require.Equal(t, authorizeState, location.Query().Get("state")) + code = location.Query().Get("code") + require.NotEmpty(t, code, "authorization did not issue a code") + return app.ClientID, presented, code, verifier + } + + exchange := func(ctx context.Context, t *testing.T, clientID, code, verifier, redirectURI string) (int, string) { + t.Helper() + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", clientID) + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + form.Set("code_verifier", verifier) + return postTokenRequest(ctx, t, client, form) + } + + for name, host := range map[string]string{ + "IPv4": "127.0.0.1", + "IPv6": "[::1]", + "Localhost": "localhost", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + clientID, presented, code, verifier := authorize(ctx, t, host) + status, body := exchange(ctx, t, clientID, code, verifier, presented) + requireTokenResponse(t, status, body) + }) + } + + // RFC 6749 §4.1.3: the redirect_uri at the exchange must be identical to + // the one the code was issued to. The loopback exception does not apply + // here, and a refused exchange does not consume the code. + t.Run("ExchangeFromAnotherPortIsRefused", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + clientID, presented, code, verifier := authorize(ctx, t, "127.0.0.1") + + status, body := exchange(ctx, t, clientID, code, verifier, "http://127.0.0.1:53220/callback") + requireTokenGrantError(t, status, body) + + status, body = exchange(ctx, t, clientID, code, verifier, presented) + requireTokenResponse(t, status, body) + }) +} diff --git a/coderd/oauth2provider/tokens_test.go b/coderd/oauth2provider/tokens_test.go index ccb50246266..d8f41b0ff45 100644 --- a/coderd/oauth2provider/tokens_test.go +++ b/coderd/oauth2provider/tokens_test.go @@ -591,3 +591,27 @@ func mintedKeyScopes(ctx context.Context, t *testing.T, db database.Store, refre require.NoError(t, err) return key.Scopes } + +// The token endpoint compares redirect_uri against the registration on its +// own. Authorizing without redirect_uri leaves nothing on the code to compare +// against, so this check is the only one the exchange runs. +func TestOAuth2TokenExchangeLoopbackRedirectPort(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + oauth2providertest.EnableDCR(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + app := oauth2providertest.RegisterPublicClient(t, client, "loopback", "http://127.0.0.1/callback") + code, verifier := authorizeCode(ctx, t, client, app.ClientID, "") + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", app.ClientID) + form.Set("code", code) + form.Set("redirect_uri", "http://127.0.0.1:53219/callback") + form.Set("code_verifier", verifier) + status, body := postTokenRequest(ctx, t, client, form) + requireTokenResponse(t, status, body) +} diff --git a/codersdk/oauth2_test.go b/codersdk/oauth2_test.go index e75a5e3b525..f4ed0af1976 100644 --- a/codersdk/oauth2_test.go +++ b/codersdk/oauth2_test.go @@ -1,6 +1,7 @@ package codersdk_test import ( + "net/url" "testing" "github.com/stretchr/testify/require" @@ -86,3 +87,43 @@ func TestOAuth2ClientRegistrationRequest_DetermineClientType(t *testing.T) { }) } } + +func TestRedirectURIMatches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + registered string + presented string + want bool + }{ + {"ExactMatch", "https://app.example.com/callback", "https://app.example.com/callback", true}, + {"CustomSchemeExact", "cursor://anysphere.cursor-mcp/oauth/callback", "cursor://anysphere.cursor-mcp/oauth/callback", true}, + {"LoopbackIPv4PortDiffers", "http://127.0.0.1/callback", "http://127.0.0.1:53219/callback", true}, + {"LoopbackIPv6PortDiffers", "http://[::1]/callback", "http://[::1]:53219/callback", true}, + {"LocalhostPortDiffers", "http://localhost/callback", "http://localhost:53219/callback", true}, + {"RegisteredPortDoesNotPin", "http://localhost:9876/callback", "http://localhost:53219/callback", true}, + {"PresentedWithoutPort", "http://127.0.0.1:53219/callback", "http://127.0.0.1/callback", true}, + {"LoopbackPathDiffers", "http://127.0.0.1/callback", "http://127.0.0.1:53219/other", false}, + {"LoopbackSchemeDiffers", "http://127.0.0.1/callback", "https://127.0.0.1:53219/callback", false}, + {"LoopbackHostSubstitution", "http://127.0.0.1/callback", "http://localhost:53219/callback", false}, + {"LoopbackQueryDiffers", "http://127.0.0.1/callback", "http://127.0.0.1:53219/callback?next=x", false}, + {"LoopbackUserinfoDiffers", "http://127.0.0.1/callback", "http://user@127.0.0.1:53219/callback", false}, + {"LocalhostSubdomain", "http://app.localhost/callback", "http://app.localhost:53219/callback", false}, + {"OtherLoopbackIP", "http://127.0.0.2/callback", "http://127.0.0.2:53219/callback", false}, + {"HTTPSLoopbackPortDiffers", "https://127.0.0.1/callback", "https://127.0.0.1:53219/callback", false}, + {"NonLoopbackPortDiffers", "https://app.example.com/callback", "https://app.example.com:8443/callback", false}, + {"LoopbackPresentedAgainstNonLoopback", "https://app.example.com/callback", "http://127.0.0.1:53219/callback", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + registered, err := url.Parse(tt.registered) + require.NoError(t, err) + presented, err := url.Parse(tt.presented) + require.NoError(t, err) + require.Equal(t, tt.want, codersdk.RedirectURIMatches(presented, registered)) + }) + } +} diff --git a/codersdk/oauth2_validation.go b/codersdk/oauth2_validation.go index 9c61739c797..70d36b4a74e 100644 --- a/codersdk/oauth2_validation.go +++ b/codersdk/oauth2_validation.go @@ -95,6 +95,26 @@ func ValidateRedirectURIScheme(u *url.URL) error { return validateScheme(u) } +// RedirectURIMatches reports whether a redirect_uri a client presented may be +// used in place of one the app registered. The rule is exact string equality +// (OAuth 2.1 §2.3.1). The one exception is a registered http URI to a loopback +// host, where the port is ignored (RFC 8252 §7.3). The exception depends on the +// registered URI alone, not on the client type. +func RedirectURIMatches(presented, registered *url.URL) bool { + if presented.String() == registered.String() { + return true + } + if registered.Scheme != "http" || !isLoopbackAddress(registered.Hostname()) { + return false + } + // Drop the port from both sides. Every other component must still match. + // Hostname() also strips IPv6 brackets, so both strings are built the same + // way and stay comparable. + p, r := *presented, *registered + p.Host, r.Host = p.Hostname(), r.Hostname() + return p.String() == r.String() +} + func validateScheme(u *url.URL) error { if u.Scheme == "" { return xerrors.New("redirect URI must have a scheme") @@ -307,7 +327,8 @@ func isLocalhost(hostname string) bool { strings.HasSuffix(hostname, ".localhost") } -// isLoopbackAddress checks if hostname is a strict loopback address (RFC 8252) +// isLoopbackAddress reports whether hostname is a loopback host. RFC 8252 §7.3 +// names 127.0.0.1 and ::1. Coder also accepts localhost as its own policy. func isLoopbackAddress(hostname string) bool { return hostname == "localhost" || hostname == "127.0.0.1" || diff --git a/codersdk/oauth2_validation_internal_test.go b/codersdk/oauth2_validation_internal_test.go new file mode 100644 index 00000000000..e13cf8b7267 --- /dev/null +++ b/codersdk/oauth2_validation_internal_test.go @@ -0,0 +1,19 @@ +package codersdk + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsLoopbackAddress(t *testing.T) { + t.Parallel() + + for _, host := range []string{"localhost", "127.0.0.1", "::1"} { + require.True(t, isLoopbackAddress(host), host) + } + // Callers pass url.URL.Hostname(), which strips IPv6 brackets. + for _, host := range []string{"", "[::1]", "app.localhost", "127.0.0.2", "0.0.0.0", "example.com"} { + require.False(t, isLoopbackAddress(host), host) + } +} diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index 12b008428e4..35cb123c1c6 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -129,11 +129,11 @@ Public clients suit native, mobile, and CLI applications that cannot keep a secr If you use Dynamic Client Registration (RFC 7591) and omit `token_endpoint_auth_method`, clients default to `client_secret_basic`. To request `client_secret_post`, set `token_endpoint_auth_method` to `client_secret_post` in the registration request. To register a public client, set it to `none`: Coder issues no `client_secret`, and the registration response omits that field entirely. > [!IMPORTANT] -> A public client may use `http://` only with a loopback host -> (`localhost`, `127.0.0.1`, `[::1]`). An `http://` redirect URI to any -> other host is rejected, so use `https://` instead. A confidential -> client has the same restriction but also accepts `.localhost` -> subdomains over `http://`. +> A public client may use `http://` only with a loopback host (`localhost`, `127.0.0.1`, `[::1]`). +> An `http://` redirect URI to any other host is rejected, so use `https://` instead. +> A confidential client has the same restriction but also accepts `.localhost` subdomains over `http://`. +> Coder ignores the port of an `http://` redirect URI to one of those three loopback hosts, for public and confidential clients alike. RFC 8252 requires this for `127.0.0.1` and `[::1]` so that native apps can choose a port at runtime. Coder applies it to `localhost` too. A `.localhost` subdomain still requires an exact port match. +> Register `http://127.0.0.1/callback` and present whichever port the client is listening on. > > Which schemes a redirect URI may use is a separate restriction that > also differs by client type. See @@ -399,6 +399,8 @@ Add `oauth2` to your experiment flags: `coder server --experiments oauth2` ### "Invalid redirect_uri" Ensure the redirect URI in your request exactly matches the one registered for your application. +The one exception is the port of a loopback `http://` redirect URI (`localhost`, `127.0.0.1`, `[::1]`), which may differ from the registered one. +Refer to the note under [Client Authentication Methods](#client-authentication-methods). ### "Invalid Callback URL" on the consent page @@ -585,13 +587,8 @@ As an experimental feature, the current implementation has limitations: ## Standards Compliance -This implementation follows established OAuth2 standards including -[RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) (OAuth2 core), -[RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) (PKCE), and the -[OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12). -Coder enforces OAuth 2.1 requirements including mandatory PKCE for all -authorization code grants, exact redirect URI string matching, rejection -of the implicit grant, and CSRF protections on consent pages. +This implementation follows established OAuth2 standards including [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) (OAuth2 core), [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) (PKCE), and the [OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12). +Coder enforces OAuth 2.1 requirements including mandatory PKCE for all authorization code grants, exact redirect URI string matching with the [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) loopback port exception, rejection of the implicit grant, and CSRF protections on consent pages. ## Next Steps