From 9f5f260674a9f0fcccbcbe83cab220d45044e970 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Fri, 4 Sep 2026 18:36:06 +0000 Subject: [PATCH] fix(coderd/oauth2provider): accept any registered redirect_uri, not only the first Dynamic client registration stores every redirect_uris entry, but authorize and token validated redirect_uri only against callback_url, the first entry. Cursor registers a desktop deep link and a web callback and uses the second, so /oauth2/authorize answered 400. - Match the presented redirect_uri against redirect_uris; fall back to callback_url for API-created apps and unregistered values. - Require redirect_uri on /oauth2/authorize when several are registered (RFC 6749 3.1.2.3) and report misses against the registered set. - UpdateApp replaces redirect_uris when an admin changes the callback, so the edit revokes the previous targets now that the list is authoritative. --- coderd/oauth2provider/apps.go | 8 +- coderd/oauth2provider/authorize.go | 63 +++++- .../oauth2provider/authorize_internal_test.go | 46 ++++- .../oauth2providertest/oauth2_test.go | 193 ++++++++++++++++++ coderd/oauth2provider/tokens.go | 10 +- 5 files changed, 303 insertions(+), 17 deletions(-) diff --git a/coderd/oauth2provider/apps.go b/coderd/oauth2provider/apps.go index 046f615670b38..bda3a293a4b32 100644 --- a/coderd/oauth2provider/apps.go +++ b/coderd/oauth2provider/apps.go @@ -143,13 +143,19 @@ func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo if !httpapi.Read(ctx, rw, r, &req) { return } + // Authorization matches against redirect_uris when it is populated + // (DCR clients), so a callback edit must replace it or it revokes nothing. + redirectURIs := app.RedirectUris + if len(redirectURIs) > 0 && req.CallbackURL != app.CallbackURL { + redirectURIs = []string{req.CallbackURL} + } app, err := db.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{ ID: app.ID, UpdatedAt: dbtime.Now(), Name: req.Name, Icon: req.Icon, CallbackURL: req.CallbackURL, - RedirectUris: app.RedirectUris, // Keep existing value + RedirectUris: redirectURIs, ClientType: app.ClientType, // Keep existing value DynamicallyRegistered: app.DynamicallyRegistered, // Keep existing value ClientSecretExpiresAt: app.ClientSecretExpiresAt, // Keep existing value diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index 9bb552899a840..95acd60c246bc 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -288,7 +288,7 @@ func extractAuthorizeParams(r *http.Request, logger slog.Logger, app database.OA // response_type and client_id are always required. p.RequiredNotEmpty("response_type", "client_id") - response, err := newAuthorizeResponse(p, vals, app.CallbackURL) + response, err := newAuthorizeResponse(p, vals, app) if err != nil { return authorizeParams{}, &authorizeFailure{corruptCallback: err} } @@ -426,9 +426,9 @@ type authorizeResponse struct { state string } -// newAuthorizeResponse parses the app's registered callback, checks it, -// exact-matches any redirect_uri the client sent against it, and reads the -// state to echo back. +// newAuthorizeResponse selects the app's registered callback for this request, +// checks it, exact-matches any redirect_uri the client sent against it, and +// reads the state to echo back. // // The scheme is checked on the registered URL rather than on the match's result, // because p.RedirectURL returns the client's URI when the match fails, and @@ -439,16 +439,17 @@ type authorizeResponse struct { // A returned error means the registration itself is unusable, which is server // state. A mismatch is the client's mistake and joins the other parameter // failures in p.Errors. -func newAuthorizeResponse(p *httpapi.QueryParamParser, vals url.Values, registered string) (authorizeResponse, error) { - registeredURL, err := url.Parse(registered) +func newAuthorizeResponse(p *httpapi.QueryParamParser, vals url.Values, app database.OAuth2ProviderApp) (authorizeResponse, error) { + registeredURL, err := registeredRedirectURL(app, vals.Get("redirect_uri")) if err != nil { return authorizeResponse{}, err } if err := codersdk.ValidateRedirectURIScheme(registeredURL); err != nil { - return authorizeResponse{}, err + return authorizeResponse{}, xerrors.Errorf("%s: %w", registeredURL, err) } - callback := p.RedirectURL(vals, registeredURL, "redirect_uri") + callback := validateRedirectURI(p, vals, app, registeredURL) + requireRedirectURIWhenSeveralRegistered(p, vals, app) response := authorizeResponse{state: p.String(vals, "", "state")} // The field, not a count of errors across these two lines: reading state // can fail too, and that failure belongs to the client's callback rather @@ -550,9 +551,55 @@ func redirectAuthorizeError(rw http.ResponseWriter, r *http.Request, logger slog http.Redirect(rw, r, response.errorURL(code, description).String(), http.StatusFound) } +// registeredRedirectURL returns the URL a request's redirect_uri must match +// exactly: the presented value when it is one of the app's registered +// redirect_uris (RFC 7591 clients may register several), otherwise the primary +// callback, which is both the default for an omitted redirect_uri and the value +// an unregistered one fails against. Membership here is the authorization +// decision; the caller's exact-match check can only fail on the fallback path. +func registeredRedirectURL(app database.OAuth2ProviderApp, presented string) (*url.URL, error) { + if presented != "" && slices.Contains(app.RedirectUris, presented) { + return url.Parse(presented) + } + return url.Parse(app.CallbackURL) +} + +// validateRedirectURI runs the exact-match check against the URL selected by +// registeredRedirectURL and, when the client registered several URIs, words a +// miss against the set rather than the primary alone. +func validateRedirectURI(p *httpapi.QueryParamParser, vals url.Values, app database.OAuth2ProviderApp, base *url.URL) *url.URL { + v := p.RedirectURL(vals, base, "redirect_uri") + presented := vals.Get("redirect_uri") + if len(app.RedirectUris) <= 1 || presented == "" || slices.Contains(app.RedirectUris, presented) { + return v + } + for i := range p.Errors { + if p.Errors[i].Field == "redirect_uri" { + p.Errors[i].Detail = `Query param "redirect_uri" must exactly match one of the client's registered redirect URIs` + } + } + return v +} + +// requireRedirectURIWhenSeveralRegistered applies RFC 6749 §3.1.2.3: a client +// with several registered URIs must name one, since defaulting to the primary +// could deliver the code to a callback the requester does not control. The +// token endpoint is exempt; §4.1.3 only requires redirect_uri there when the +// authorization request carried one, and authorizationCodeGrant binds the code +// to that value. +func requireRedirectURIWhenSeveralRegistered(p *httpapi.QueryParamParser, vals url.Values, app database.OAuth2ProviderApp) { + if len(app.RedirectUris) > 1 && vals.Get("redirect_uri") == "" { + p.Errors = append(p.Errors, codersdk.ValidationError{ + Field: "redirect_uri", + Detail: `Query param "redirect_uri" is required because the client registered more than one redirect URI`, + }) + } +} + // logCorruptCallback reports a registered callback URL this server should never // have stored: unparsable, or using a scheme registration rejects. The response // only says the callback is bad, so operators need the log to identify the app. +// The error names the URL, which may be a registered one other than callback_url. func logCorruptCallback(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, err error) { logger.Error(ctx, "oauth2 app has an unusable registered callback URL", slog.Error(err), diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 04e172dd9d1de..6d8dd83e5154a 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -504,7 +504,7 @@ func TestNewAuthorizeResponse(t *testing.T) { response, err := newAuthorizeResponse(p, url.Values{ "redirect_uri": {registered}, "state": {"abc123"}, - }, registered) + }, appWithCallback(registered)) require.NoError(t, err) require.Empty(t, p.Errors) @@ -517,7 +517,7 @@ func TestNewAuthorizeResponse(t *testing.T) { t.Parallel() p := httpapi.NewQueryParamParser() - response, err := newAuthorizeResponse(p, url.Values{}, registered) + response, err := newAuthorizeResponse(p, url.Values{}, appWithCallback(registered)) require.NoError(t, err) require.Empty(t, p.Errors) @@ -531,7 +531,7 @@ func TestNewAuthorizeResponse(t *testing.T) { p := httpapi.NewQueryParamParser() response, err := newAuthorizeResponse(p, url.Values{ "redirect_uri": {"https://elsewhere.example/cb"}, - }, registered) + }, appWithCallback(registered)) // The client's mistake, so it joins the parser's other errors rather // than becoming a server fault. @@ -548,7 +548,7 @@ func TestNewAuthorizeResponse(t *testing.T) { p := httpapi.NewQueryParamParser() response, err := newAuthorizeResponse(p, url.Values{ "redirect_uri": {"javascript:alert(1)"}, - }, registered) + }, appWithCallback(registered)) require.NoError(t, err, "the app registered a usable callback; the client did not send one") require.NotEmpty(t, p.Errors) @@ -559,7 +559,7 @@ func TestNewAuthorizeResponse(t *testing.T) { t.Parallel() p := httpapi.NewQueryParamParser() - response, err := newAuthorizeResponse(p, url.Values{}, "javascript:alert(1)") + response, err := newAuthorizeResponse(p, url.Values{}, appWithCallback("javascript:alert(1)")) require.Error(t, err) require.Empty(t, p.Errors, "the registration is rejected before any parameter is read") @@ -570,12 +570,46 @@ func TestNewAuthorizeResponse(t *testing.T) { t.Parallel() p := httpapi.NewQueryParamParser() - response, err := newAuthorizeResponse(p, url.Values{}, "http://a b") + response, err := newAuthorizeResponse(p, url.Values{}, appWithCallback("http://a b")) require.Error(t, err, "a registration that does not parse is the same class as one this server rejects") require.Empty(t, p.Errors) require.False(t, response.canRedirect()) }) + + t.Run("SecondRegisteredURIIsADestination", func(t *testing.T) { + t.Parallel() + + const second = "https://second.example.com/callback" + p := httpapi.NewQueryParamParser() + response, err := newAuthorizeResponse(p, url.Values{ + "redirect_uri": {second}, + }, database.OAuth2ProviderApp{CallbackURL: registered, RedirectUris: []string{registered, second}}) + + require.NoError(t, err) + require.Empty(t, p.Errors) + require.True(t, response.canRedirect()) + require.Equal(t, second, response.callbackURL()) + }) + + t.Run("UnregisteredURIRejectedAgainstTheSet", func(t *testing.T) { + t.Parallel() + + p := httpapi.NewQueryParamParser() + response, err := newAuthorizeResponse(p, url.Values{ + "redirect_uri": {"https://elsewhere.example/cb"}, + }, database.OAuth2ProviderApp{CallbackURL: registered, RedirectUris: []string{registered, "https://second.example.com/callback"}}) + + require.NoError(t, err) + require.Len(t, p.Errors, 1) + require.Contains(t, p.Errors[0].Detail, "registered redirect URIs") + require.False(t, response.canRedirect()) + }) +} + +// appWithCallback is an API-created app: one callback and no redirect_uris. +func appWithCallback(callback string) database.OAuth2ProviderApp { + return database.OAuth2ProviderApp{CallbackURL: callback} } // TestAuthorizeResponseZeroValue pins the zero value as inert, since it is what diff --git a/coderd/oauth2provider/oauth2providertest/oauth2_test.go b/coderd/oauth2provider/oauth2providertest/oauth2_test.go index 50469e26ba74c..0882fcd01f5ff 100644 --- a/coderd/oauth2provider/oauth2providertest/oauth2_test.go +++ b/coderd/oauth2provider/oauth2providertest/oauth2_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/require" "golang.org/x/oauth2" @@ -622,3 +623,195 @@ func TestOAuth2RegisterPublicClient(t *testing.T) { resp := oauth2providertest.RegisterPublicClient(t, client, "test-public-client", "https://example.com/callback") require.NotEmpty(t, resp.ClientID) } + +// RFC 7591 clients may register several redirect_uris and use any of them. +// Cursor registers a desktop deep link and a web callback, then uses the second. +func TestOAuth2MultipleRegisteredRedirectURIs(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + oauth2providertest.EnableDCR(t, client) + + const ( + desktopRedirectURI = "cursor://anysphere.cursor-mcp/oauth/callback" + webRedirectURI = "https://www.cursor.com/agents/mcp/oauth/callback" + unregisteredURI = "https://www.cursor.com/agents/mcp/oauth/callback/other" + ) + + register := func(t *testing.T) codersdk.OAuth2ClientRegistrationResponse { + t.Helper() + ctx := testutil.Context(t, testutil.WaitLong) + registration, err := client.PostOAuth2ClientRegistration(ctx, codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{desktopRedirectURI, webRedirectURI}, + ClientName: "cursor-" + testutil.MustRandString(t, 10), + TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethodNone, + }) + require.NoError(t, err) + return registration + } + + // The package helpers only POST and always send redirect_uri. + sendAuthorize := func(t *testing.T, method, clientID, redirectURI string) *http.Response { + t.Helper() + ctx := testutil.Context(t, testutil.WaitLong) + _, codeChallenge := oauth2providertest.GeneratePKCE(t) + authURL, err := url.Parse(client.URL.String() + "/oauth2/authorize") + require.NoError(t, err) + query := url.Values{} + query.Set("client_id", clientID) + query.Set("response_type", "code") + query.Set("state", oauth2providertest.GenerateState(t)) + query.Set("code_challenge", codeChallenge) + query.Set("code_challenge_method", "S256") + if redirectURI != "" { + query.Set("redirect_uri", redirectURI) + } + authURL.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(ctx, method, authURL.String(), nil) + require.NoError(t, err) + req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }}).Do(req) + require.NoError(t, err) + return resp + } + + authorize := func(t *testing.T, clientID, redirectURI string) (code, verifier string) { + t.Helper() + verifier, challenge := oauth2providertest.GeneratePKCE(t) + code = oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), oauth2providertest.AuthorizeParams{ + ClientID: clientID, + ResponseType: "code", + RedirectURI: redirectURI, + State: oauth2providertest.GenerateState(t), + CodeChallenge: challenge, + CodeChallengeMethod: "S256", + }) + return code, verifier + } + + t.Run("SecondRegisteredURIAccepted", func(t *testing.T) { + t.Parallel() + registration := register(t) + + getResp := sendAuthorize(t, http.MethodGet, registration.ClientID, webRedirectURI) + defer getResp.Body.Close() + require.Equal(t, http.StatusOK, getResp.StatusCode, "consent page must render for a non-primary registered URI") + + code, verifier := authorize(t, registration.ClientID, webRedirectURI) + token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), oauth2providertest.TokenExchangeParams{ + GrantType: "authorization_code", + Code: code, + ClientID: registration.ClientID, + CodeVerifier: verifier, + RedirectURI: webRedirectURI, + }) + require.NotEmpty(t, token.AccessToken) + }) + + t.Run("CodeDeliveredToPresentedURI", func(t *testing.T) { + t.Parallel() + registration := register(t) + + resp := sendAuthorize(t, http.MethodPost, registration.ClientID, webRedirectURI) + defer resp.Body.Close() + require.Equal(t, http.StatusFound, resp.StatusCode) + require.True(t, strings.HasPrefix(resp.Header.Get("Location"), webRedirectURI+"?"), + "the browser must land on the URI it presented, not the primary: %s", resp.Header.Get("Location")) + }) + + t.Run("UnregisteredURIRejected", func(t *testing.T) { + t.Parallel() + registration := register(t) + + getResp := sendAuthorize(t, http.MethodGet, registration.ClientID, unregisteredURI) + defer getResp.Body.Close() + require.Equal(t, http.StatusBadRequest, getResp.StatusCode) + + resp := sendAuthorize(t, http.MethodPost, registration.ClientID, unregisteredURI) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + var oauthErr oauth2providertest.OAuth2Error + require.NoError(t, json.NewDecoder(resp.Body).Decode(&oauthErr)) + require.Equal(t, "invalid_request", oauthErr.Error) + require.Contains(t, oauthErr.ErrorDescription, "registered redirect URIs", + "the rejection must not point the client at the primary URI only") + }) + + // RFC 6749 §3.1.2.3. + t.Run("OmittedURIRejectedWhenSeveralRegistered", func(t *testing.T) { + t.Parallel() + registration := register(t) + + getResp := sendAuthorize(t, http.MethodGet, registration.ClientID, "") + defer getResp.Body.Close() + require.Equal(t, http.StatusBadRequest, getResp.StatusCode) + + resp := sendAuthorize(t, http.MethodPost, registration.ClientID, "") + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + var oauthErr oauth2providertest.OAuth2Error + require.NoError(t, json.NewDecoder(resp.Body).Decode(&oauthErr)) + require.Equal(t, "invalid_request", oauthErr.Error) + require.Contains(t, oauthErr.ErrorDescription, "more than one redirect URI") + }) + + // RFC 6749 §4.1.3. + t.Run("ExchangeWithOtherRegisteredURIRejected", func(t *testing.T) { + t.Parallel() + registration := register(t) + + code, verifier := authorize(t, registration.ClientID, webRedirectURI) + oauth2providertest.PerformTokenExchangeExpectingError(t, client.URL.String(), oauth2providertest.TokenExchangeParams{ + GrantType: "authorization_code", + Code: code, + ClientID: registration.ClientID, + CodeVerifier: verifier, + RedirectURI: desktopRedirectURI, + }, "invalid_grant") + }) + + t.Run("ExchangeWithUnregisteredURIRejected", func(t *testing.T) { + t.Parallel() + registration := register(t) + + code, verifier := authorize(t, registration.ClientID, webRedirectURI) + oauth2providertest.PerformTokenExchangeExpectingError(t, client.URL.String(), oauth2providertest.TokenExchangeParams{ + GrantType: "authorization_code", + Code: code, + ClientID: registration.ClientID, + CodeVerifier: verifier, + RedirectURI: unregisteredURI, + }, "invalid_request") + }) + + t.Run("AdminCallbackEditRevokesOtherURIs", func(t *testing.T) { + t.Parallel() + registration := register(t) + ctx := testutil.Context(t, testutil.WaitLong) + + appID, err := uuid.Parse(registration.ClientID) + require.NoError(t, err) + _, err = client.PutOAuth2ProviderApp(ctx, appID, codersdk.PutOAuth2ProviderAppRequest{ + Name: "cursor-" + testutil.MustRandString(t, 10), + CallbackURL: webRedirectURI, + }) + require.NoError(t, err) + + getResp := sendAuthorize(t, http.MethodGet, registration.ClientID, desktopRedirectURI) + defer getResp.Body.Close() + require.Equal(t, http.StatusBadRequest, getResp.StatusCode, "the replaced URI must no longer be accepted") + + code, verifier := authorize(t, registration.ClientID, webRedirectURI) + token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), oauth2providertest.TokenExchangeParams{ + GrantType: "authorization_code", + Code: code, + ClientID: registration.ClientID, + CodeVerifier: verifier, + RedirectURI: webRedirectURI, + }) + require.NotEmpty(t, token.AccessToken, "the new callback must keep working") + }) +} diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 640f1f4565991..2b0ea5de9b702 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -180,7 +180,7 @@ func extractTokenRequest(r *http.Request, callbackURL *url.URL, app database.OAu } // Validate redirect URI - errors are added to p.Errors. - _ = p.RedirectURL(vals, callbackURL, "redirect_uri") + _ = validateRedirectURI(p, vals, app, callbackURL) // Validate resource parameter syntax (RFC 8707): must be absolute URI without fragment. if err := validateResourceParameter(req.Resource); err != nil { @@ -205,7 +205,13 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L ctx := r.Context() app := httpmw.OAuth2ProviderApp(r) - callbackURL, err := url.Parse(app.CallbackURL) + // Parsed here so redirect_uri can select among the registered URIs. + // net/http caches the result, so the later ParseForm calls are no-ops. + if err := r.ParseForm(); err != nil { + httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Failed to parse form values") + return + } + callbackURL, err := registeredRedirectURL(app, r.Form.Get("redirect_uri")) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to validate form values.",