Skip to content
Draft
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
fix(oauth2): allow custom URI schemes without reverse domain notation…
… for native apps

Change-Id: I4000cd39caa994efe0b76c4984e968f2963063ca
Signed-off-by: Thomas Kosiewski <tk@coder.com>
  • Loading branch information
ThomasK33 committed Aug 12, 2025
commit c2346ffb276b0d073dddd6091c8e4c345c9c566a
69 changes: 69 additions & 0 deletions coderd/httpapi/httpapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"flag"
"fmt"
"net/http"
"net/url"
"reflect"
"strings"
"time"
Expand All @@ -26,6 +27,61 @@ import (

var Validate *validator.Validate

// isValidOAuth2RedirectURI validates OAuth2 redirect URIs according to RFC 6749.
// It requires a proper scheme and host, rejecting malformed URIs that would be
// problematic for OAuth2 flows.
func isValidOAuth2RedirectURI(uri string) bool {
if uri == "" {
return false
}

parsed, err := url.Parse(uri)
if err != nil {
return false
}

// Must have a scheme
if parsed.Scheme == "" {
return false
}

// Reject patterns that look like "host:port" without proper scheme
// These get parsed as scheme="host" and path="port" which is ambiguous
if parsed.Host == "" && parsed.Path != "" && !strings.HasPrefix(uri, parsed.Scheme+"://") {
// Check if this looks like a host:port pattern (contains digits after colon)
if strings.Contains(parsed.Path, ":") {
return false
}
// Also reject if the "scheme" part looks like a hostname
if strings.Contains(parsed.Scheme, ".") || parsed.Scheme == "localhost" {
return false
}
}

// For standard schemes (http/https), host is required
if parsed.Scheme == "http" || parsed.Scheme == "https" {
if parsed.Host == "" {
return false
}
}

// Reject scheme-only URIs like "http://"
if parsed.Host == "" && parsed.Path == "" {
return false
}

// For custom schemes, we allow no host (like "myapp://callback")
// But if there's a host, it should be valid
if parsed.Host != "" {
// Basic host validation - should not be empty after parsing
if strings.TrimSpace(parsed.Host) == "" {
return false
}
}

return true
}

// This init is used to create a validator and register validation-specific
// functionality for the HTTP API.
//
Expand Down Expand Up @@ -113,6 +169,19 @@ func init() {
if err != nil {
panic(err)
}

oauth2RedirectURIValidator := func(fl validator.FieldLevel) bool {
f := fl.Field().Interface()
str, ok := f.(string)
if !ok {
return false
}
return isValidOAuth2RedirectURI(str)
}
err = Validate.RegisterValidation("oauth2_redirect_uri", oauth2RedirectURIValidator)
if err != nil {
panic(err)
}
}

// Is404Error returns true if the given error should return a 404 status code.
Expand Down
4 changes: 2 additions & 2 deletions codersdk/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func (c *Client) OAuth2ProviderApp(ctx context.Context, id uuid.UUID) (OAuth2Pro

type PostOAuth2ProviderAppRequest struct {
Name string `json:"name" validate:"required,oauth2_app_display_name"`
RedirectURIs []string `json:"redirect_uris" validate:"dive,http_url"`
RedirectURIs []string `json:"redirect_uris" validate:"dive,oauth2_redirect_uri"`
Icon string `json:"icon" validate:"omitempty"`
GrantTypes []OAuth2ProviderGrantType `json:"grant_types,omitempty" validate:"dive,oneof=authorization_code refresh_token client_credentials urn:ietf:params:oauth:grant-type:device_code"`
}
Expand Down Expand Up @@ -150,7 +150,7 @@ func (c *Client) PostOAuth2ProviderApp(ctx context.Context, app PostOAuth2Provid

type PutOAuth2ProviderAppRequest struct {
Name string `json:"name" validate:"required,oauth2_app_display_name"`
RedirectURIs []string `json:"redirect_uris" validate:"dive,http_url"`
RedirectURIs []string `json:"redirect_uris" validate:"dive,oauth2_redirect_uri"`
Icon string `json:"icon" validate:"omitempty"`
GrantTypes []OAuth2ProviderGrantType `json:"grant_types,omitempty" validate:"dive,oneof=authorization_code refresh_token client_credentials urn:ietf:params:oauth:grant-type:device_code"`
}
Expand Down
6 changes: 0 additions & 6 deletions codersdk/oauth2_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,6 @@ func isLoopbackAddress(hostname string) bool {

// isValidCustomScheme validates custom schemes for public clients (RFC 8252)
func isValidCustomScheme(scheme string) bool {
// For security and RFC compliance, require reverse domain notation
// Should contain at least one period and not be a well-known scheme
if !strings.Contains(scheme, ".") {
return false
}

// Block schemes that look like well-known protocols
wellKnownSchemes := []string{"http", "https", "ftp", "mailto", "tel", "sms"}
for _, wellKnown := range wellKnownSchemes {
Expand Down
Loading