Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3bbc6ec
feat(codersdk): add a loopback-aware redirect URI comparator
BobbyHo Sep 6, 2026
81e2fde
fix(coderd/httpapi): allow any port on loopback redirect URIs
BobbyHo Sep 6, 2026
ac9f591
test(coderd/oauth2provider): cover the loopback redirect port excepti…
BobbyHo Sep 6, 2026
61d4bd4
docs(admin/integrations): describe the loopback redirect port exception
BobbyHo Sep 6, 2026
baca1c6
Merge branch 'main' into plat488-1-loopback-comparator
BobbyHo Sep 7, 2026
c33f0b4
Merge branch 'main' into plat488-1-loopback-comparator
BobbyHo Sep 7, 2026
182b465
docs(codersdk): cite RFC 8252 correctly in the IsLoopbackAddress comment
BobbyHo Sep 7, 2026
6cf371f
docs(admin/integrations): say which hosts and clients get the loopbac…
BobbyHo Sep 7, 2026
6fa2fc6
refactor(codersdk): unexport isLoopbackAddress and clarify RedirectUR…
BobbyHo Sep 7, 2026
2eeee6c
fix(coderd): say the loopback port may differ in the redirect_uri mis…
BobbyHo Sep 7, 2026
e521650
test(coderd/httpapi): collapse the loopback component mismatch cases
BobbyHo Sep 7, 2026
841c2b6
fix: match OAuth2 redirect_uri against every registered redirect URI
BobbyHo Sep 6, 2026
2fc732b
test(coderd/oauth2provider): authorize with a redirect URI that is no…
BobbyHo Sep 6, 2026
cbfbb70
docs(admin/integrations): say every registered redirect URI is honoured
BobbyHo Sep 6, 2026
f57f4bc
refactor(coderd): take the primary redirect URI as its own parameter
BobbyHo Sep 8, 2026
af29bc6
test(coderd/oauth2provider): put the TestNewAuthorizeResponse doc com…
BobbyHo Sep 8, 2026
573912d
Merge branch 'main' into plat488-1-loopback-comparator
BobbyHo Sep 8, 2026
3f0b046
Merge branch 'plat488-1-loopback-comparator' into plat582-1-redirect-…
BobbyHo Sep 8, 2026
844a46f
Merge branch 'main' into plat582-1-redirect-uri-allowlist
BobbyHo Sep 9, 2026
0386b2a
Merge branch 'main' into plat582-1-redirect-uri-allowlist
BobbyHo Sep 9, 2026
249ac1b
Merge branch 'main' into plat582-1-redirect-uri-allowlist
BobbyHo Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions coderd/httpapi/queryparams.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/url"
"slices"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -219,25 +220,28 @@ func (p *QueryParamParser) UUIDs(vals url.Values, def []uuid.UUID, queryParam st
})
}

func (p *QueryParamParser) RedirectURL(vals url.Values, base *url.URL, queryParam string) *url.URL {
v, err := parseQueryParam(p, vals, url.Parse, base, queryParam)
// RedirectURL parses queryParam as a URL and requires it to match the primary
// callback or one of the alternates. When the param is absent it returns primary.
func (p *QueryParamParser) RedirectURL(vals url.Values, primary *url.URL, alternates []*url.URL, queryParam string) *url.URL {
v, err := parseQueryParam(p, vals, url.Parse, primary, queryParam)
if err != nil {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: queryParam,
Detail: fmt.Sprintf("Query param %q must be a valid url: %s", queryParam, err.Error()),
})
// url.Parse returns a nil URL alongside its error, so the comparison
// below would panic. base stands in: p.Errors is already non-empty, so
// every caller rejects the request before reading this.
return base
// below would panic. primary stands in: p.Errors is already non-empty,
// so every caller rejects the request before reading this.
return primary
}

// 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) {
// OAuth 2.1 §2.3.1 requires an exact match against a registered URI. RFC 8252
// §7.3 excepts the port of a loopback redirect URI; the comparator owns that rule.
matches := func(a *url.URL) bool { return codersdk.RedirectURIMatches(v, a) }
if !matches(primary) && !slices.ContainsFunc(alternates, matches) {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: queryParam,
Detail: fmt.Sprintf("Query param %q must match %s; only the port of a loopback URI may differ", queryParam, base),
Detail: fmt.Sprintf("Query param %q must match one of the application's registered redirect URIs; only the port of a loopback URI may differ", queryParam),
Comment thread
BobbyHo marked this conversation as resolved.
})
}

Expand Down
51 changes: 42 additions & 9 deletions coderd/httpapi/queryparams_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ func TestRedirectURL(t *testing.T) {
t.Run("Omitted", func(t *testing.T) {
t.Parallel()
parser := httpapi.NewQueryParamParser()
got := parser.RedirectURL(url.Values{}, base, "redirect_uri")
got := parser.RedirectURL(url.Values{}, base, nil, "redirect_uri")
require.Empty(t, parser.Errors)
require.Equal(t, base.String(), got.String())
})
Expand All @@ -605,7 +605,7 @@ func TestRedirectURL(t *testing.T) {
t.Parallel()
parser := httpapi.NewQueryParamParser()
vals := url.Values{"redirect_uri": []string{base.String()}}
got := parser.RedirectURL(vals, base, "redirect_uri")
got := parser.RedirectURL(vals, base, nil, "redirect_uri")
require.Empty(t, parser.Errors)
require.Equal(t, base.String(), got.String())
})
Expand All @@ -614,7 +614,7 @@ func TestRedirectURL(t *testing.T) {
t.Parallel()
parser := httpapi.NewQueryParamParser()
vals := url.Values{"redirect_uri": []string{"https://evil.example.com/steal"}}
parser.RedirectURL(vals, base, "redirect_uri")
parser.RedirectURL(vals, base, nil, "redirect_uri")
require.Len(t, parser.Errors, 1)
require.Contains(t, parser.Errors[0].Detail, "must match")
})
Expand All @@ -627,7 +627,7 @@ func TestRedirectURL(t *testing.T) {
parser := httpapi.NewQueryParamParser()
vals := url.Values{"redirect_uri": []string{raw}}
require.NotPanics(t, func() {
got := parser.RedirectURL(vals, base, "redirect_uri")
got := parser.RedirectURL(vals, base, nil, "redirect_uri")
require.NotNil(t, got, "a nil URL would panic in the caller")
require.Equal(t, base.String(), got.String())
}, "redirect_uri=%q must not panic", raw)
Expand All @@ -648,7 +648,7 @@ func TestRedirectURL(t *testing.T) {

parser := httpapi.NewQueryParamParser()
vals := url.Values{"redirect_uri": []string{presented}}
got := parser.RedirectURL(vals, registered, "redirect_uri")
got := parser.RedirectURL(vals, registered, nil, "redirect_uri")
require.Empty(t, parser.Errors, "host %s", host)
require.Equal(t, presented, got.String(), "host %s", host)
}
Expand All @@ -662,7 +662,7 @@ func TestRedirectURL(t *testing.T) {

parser := httpapi.NewQueryParamParser()
vals := url.Values{"redirect_uri": []string{presented}}
got := parser.RedirectURL(vals, registered, "redirect_uri")
got := parser.RedirectURL(vals, registered, nil, "redirect_uri")
require.Empty(t, parser.Errors)
require.Equal(t, presented, got.String())
})
Expand All @@ -675,19 +675,52 @@ func TestRedirectURL(t *testing.T) {
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")
parser.RedirectURL(vals, registered, nil, "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")
require.Contains(t, parser.Errors[0].Detail, "must match one of")
})

// 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")
parser.RedirectURL(vals, base, nil, "redirect_uri")
require.Len(t, parser.Errors, 1)
require.Contains(t, parser.Errors[0].Detail, "must match")
})

// RFC 6749 §3.1.2.3: the presented URI must match one of the registered URIs,
// not only the first.
t.Run("MatchesAnyRegisteredEntry", func(t *testing.T) {
t.Parallel()
second, err := url.Parse("https://www.cursor.com/agents/mcp/oauth/callback")
require.NoError(t, err)
alternates := []*url.URL{second}

for _, want := range []*url.URL{base, second} {
parser := httpapi.NewQueryParamParser()
vals := url.Values{"redirect_uri": []string{want.String()}}
got := parser.RedirectURL(vals, base, alternates, "redirect_uri")
require.Empty(t, parser.Errors, want.String())
require.Equal(t, want.String(), got.String())
}

parser := httpapi.NewQueryParamParser()
vals := url.Values{"redirect_uri": []string{"https://evil.example.com/steal"}}
parser.RedirectURL(vals, base, alternates, "redirect_uri")
require.Len(t, parser.Errors, 1)
require.Contains(t, parser.Errors[0].Detail, "must match one of")
})

t.Run("OmittedWithSeveralRegisteredDefaultsToPrimary", func(t *testing.T) {
t.Parallel()
second, err := url.Parse("https://www.cursor.com/agents/mcp/oauth/callback")
require.NoError(t, err)
parser := httpapi.NewQueryParamParser()
got := parser.RedirectURL(url.Values{}, base, []*url.URL{second}, "redirect_uri")
require.Empty(t, parser.Errors)
require.Equal(t, base.String(), got.String())
})
}
42 changes: 32 additions & 10 deletions coderd/oauth2provider/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand Down Expand Up @@ -426,11 +426,31 @@ 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.
// registeredRedirectURIs returns the app's primary callback and its other
// registered redirect URIs, parsed and deduplicated. CallbackURL is the primary
// because admin-created apps have an empty RedirectUris list.
func registeredRedirectURIs(app database.OAuth2ProviderApp) (primary *url.URL, alternates []*url.URL, err error) {
primary, err = url.Parse(app.CallbackURL)
if err != nil {
return nil, nil, xerrors.Errorf("parse callback URL %q: %w", app.CallbackURL, err)
}
for _, s := range slice.Unique(app.RedirectUris) {
if s == app.CallbackURL {
continue
}
u, err := url.Parse(s)
if err != nil {
return nil, nil, xerrors.Errorf("parse registered redirect URI %q: %w", s, err)
}
alternates = append(alternates, u)
}
return primary, alternates, nil
}

// newAuthorizeResponse checks the app's registered redirect URIs, matches any
// redirect_uri the client sent against them, and reads the state to echo back.
//
// The scheme is checked on the registered URL rather than on the match's result,
// The scheme is checked on the registered URLs rather than on the match's result,
// because p.RedirectURL returns the client's URI when the match fails, and
// answering 500 for a scheme the client chose would blame the app for a request
// it did not make. It is checked before the match so no parse outcome can reach
Expand All @@ -439,16 +459,18 @@ 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) {
primary, alternates, err := registeredRedirectURIs(app)
if err != nil {
return authorizeResponse{}, err
}
if err := codersdk.ValidateRedirectURIScheme(registeredURL); err != nil {
return authorizeResponse{}, err
for _, u := range append([]*url.URL{primary}, alternates...) {
if err := codersdk.ValidateRedirectURIScheme(u); err != nil {
return authorizeResponse{}, err
}
}

callback := p.RedirectURL(vals, registeredURL, "redirect_uri")
callback := p.RedirectURL(vals, primary, alternates, "redirect_uri")
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
Expand Down
80 changes: 73 additions & 7 deletions coderd/oauth2provider/authorize_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,10 @@ func TestConsentScopes(t *testing.T) {
}
}

func appWithCallback(callback string) database.OAuth2ProviderApp {
return database.OAuth2ProviderApp{CallbackURL: callback}
}

// TestNewAuthorizeResponse covers the two preconditions the constructor exists
// to run together, and which of them is the server's fault.
func TestNewAuthorizeResponse(t *testing.T) {
Expand All @@ -504,7 +508,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)
Expand All @@ -517,7 +521,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)
Expand All @@ -531,7 +535,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.
Expand All @@ -548,7 +552,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)
Expand All @@ -559,7 +563,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")
Expand All @@ -570,7 +574,7 @@ 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)
Expand All @@ -587,7 +591,7 @@ func TestNewAuthorizeResponse(t *testing.T) {
response, err := newAuthorizeResponse(p, url.Values{
"redirect_uri": {presented},
"state": {"abc123"},
}, "http://127.0.0.1/callback")
}, appWithCallback("http://127.0.0.1/callback"))

require.NoError(t, err)
require.Empty(t, p.Errors)
Expand Down Expand Up @@ -728,3 +732,65 @@ func TestCarveOutDelivery(t *testing.T) {
})
}
}

func TestRegisteredRedirectURIs(t *testing.T) {
t.Parallel()

strs := func(t *testing.T, app database.OAuth2ProviderApp) []string {
t.Helper()
primary, alternates, err := registeredRedirectURIs(app)
require.NoError(t, err)
out := []string{primary.String()}
for _, u := range alternates {
out = append(out, u.String())
}
return out
}

t.Run("AdminAppHasOnlyTheCallback", func(t *testing.T) {
t.Parallel()
got := strs(t, database.OAuth2ProviderApp{
CallbackURL: "https://app.example.com/callback",
RedirectUris: []string{},
})
require.Equal(t, []string{"https://app.example.com/callback"}, got)
})

t.Run("PrimaryFirstAndDeduplicated", func(t *testing.T) {
t.Parallel()
got := strs(t, database.OAuth2ProviderApp{
CallbackURL: "cursor://anysphere.cursor-mcp/oauth/callback",
RedirectUris: []string{
"cursor://anysphere.cursor-mcp/oauth/callback",
"https://www.cursor.com/agents/mcp/oauth/callback",
},
})
require.Equal(t, []string{
"cursor://anysphere.cursor-mcp/oauth/callback",
"https://www.cursor.com/agents/mcp/oauth/callback",
}, got)
})

// An admin edit rewrites CallbackURL without touching RedirectUris.
t.Run("EditedCallbackIsIncluded", func(t *testing.T) {
t.Parallel()
got := strs(t, database.OAuth2ProviderApp{
CallbackURL: "https://new.example.com/callback",
RedirectUris: []string{"https://a.example.com/cb", "https://b.example.com/cb"},
})
require.Equal(t, []string{
"https://new.example.com/callback",
"https://a.example.com/cb",
"https://b.example.com/cb",
}, got)
})

t.Run("UnparsableEntryIsAnError", func(t *testing.T) {
t.Parallel()
_, _, err := registeredRedirectURIs(database.OAuth2ProviderApp{
CallbackURL: "https://app.example.com/callback",
RedirectUris: []string{"http://a b"},
})
require.Error(t, err)
})
}
Loading
Loading