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

Skip to content
Closed
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
8 changes: 7 additions & 1 deletion coderd/oauth2provider/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 55 additions & 8 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,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
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down
46 changes: 40 additions & 6 deletions coderd/oauth2provider/authorize_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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")
Expand All @@ -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
Expand Down
Loading
Loading