Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions coderd/httpapi/queryparams.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
}

Expand Down
56 changes: 55 additions & 1 deletion coderd/httpapi/queryparams_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Comment thread
BobbyHo marked this conversation as resolved.
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")
})
}
20 changes: 20 additions & 0 deletions coderd/oauth2provider/authorize_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 89 additions & 1 deletion coderd/oauth2provider/authorize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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://<host>/callback and authorizes with
// http://<host>: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)
})
}
24 changes: 24 additions & 0 deletions coderd/oauth2provider/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
41 changes: 41 additions & 0 deletions codersdk/oauth2_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package codersdk_test

import (
"net/url"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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://[email protected]: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))
})
}
}
23 changes: 22 additions & 1 deletion codersdk/oauth2_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
BobbyHo marked this conversation as resolved.
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()
Comment thread
BobbyHo marked this conversation as resolved.
return p.String() == r.String()
}

func validateScheme(u *url.URL) error {
if u.Scheme == "" {
return xerrors.New("redirect URI must have a scheme")
Expand Down Expand Up @@ -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" ||
Expand Down
19 changes: 19 additions & 0 deletions codersdk/oauth2_validation_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 9 additions & 12 deletions docs/admin/integrations/oauth2-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`).
Comment thread
BobbyHo marked this conversation as resolved.
> 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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Loading