diff --git a/internal/api/oauthserver/authorize.go b/internal/api/oauthserver/authorize.go index 42e14e335b..a73b2050bb 100644 --- a/internal/api/oauthserver/authorize.go +++ b/internal/api/oauthserver/authorize.go @@ -606,7 +606,7 @@ func (s *Server) buildSuccessRedirectURL(authorization *models.OAuthServerAuthor q.Set("state", *authorization.State) } u.RawQuery = q.Encode() - return u.String() + return utilities.PreserveEmptyAuthority(authorization.RedirectURI, u) } // buildErrorRedirectURL builds an error redirect URL with the given parameters @@ -619,7 +619,7 @@ func (s *Server) buildErrorRedirectURL(redirectURI, errorCode, errorDescription, q.Set("state", state) } u.RawQuery = q.Encode() - return u.String() + return utilities.PreserveEmptyAuthority(redirectURI, u) } // buildAuthorizationURL safely joins a base URL with a path, handling slashes correctly diff --git a/internal/api/verify.go b/internal/api/verify.go index 212d7388eb..68a397eec4 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -510,7 +510,7 @@ func (a *API) prepErrorRedirectURL(err *HTTPError, r *http.Request, rurl string, // Add Supabase Auth identifier to help clients distinguish Supabase Auth redirects hq.Set("sb", "") u.Fragment = hq.Encode() - return u.String(), nil + return utilities.PreserveEmptyAuthority(rurl, u), nil } func (a *API) prepRedirectURL(message string, rurl string, flowType models.FlowType) (string, error) { @@ -528,7 +528,7 @@ func (a *API) prepRedirectURL(message string, rurl string, flowType models.FlowT // Add Supabase Auth identifier to help clients distinguish Supabase Auth redirects hq.Set("sb", "") u.Fragment = hq.Encode() - return u.String(), nil + return utilities.PreserveEmptyAuthority(rurl, u), nil } func (a *API) prepPKCERedirectURL(rurl, code string) (string, error) { @@ -539,7 +539,7 @@ func (a *API) prepPKCERedirectURL(rurl, code string) (string, error) { q := u.Query() q.Set("code", code) u.RawQuery = q.Encode() - return u.String(), nil + return utilities.PreserveEmptyAuthority(rurl, u), nil } func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, params *VerifyParams, user *models.User) (*models.User, error) { diff --git a/internal/utilities/url.go b/internal/utilities/url.go new file mode 100644 index 0000000000..19a03dcfeb --- /dev/null +++ b/internal/utilities/url.go @@ -0,0 +1,18 @@ +package utilities + +import ( + "net/url" + "strings" +) + +// PreserveEmptyAuthority restores the `//` in URLs that use "scheme://" with +// an empty authority (e.g. custom-scheme deep links like "myapp://"). Go's +// net/url package normalizes these to "scheme:" on String(), which breaks +// iOS/Android deep-link clients that register for the "scheme://" form. +func PreserveEmptyAuthority(rurl string, u *url.URL) string { + formatted := u.String() + if u.Scheme != "" && u.Host == "" && u.Path == "" && strings.HasPrefix(rurl, u.Scheme+"://") { + return strings.Replace(formatted, u.Scheme+":", u.Scheme+"://", 1) + } + return formatted +} diff --git a/internal/utilities/url_test.go b/internal/utilities/url_test.go new file mode 100644 index 0000000000..c72d2e9c43 --- /dev/null +++ b/internal/utilities/url_test.go @@ -0,0 +1,44 @@ +package utilities + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPreserveEmptyAuthority(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"custom scheme empty authority", "myapp://", "myapp://?code=ABC"}, + {"custom scheme with host", "myapp://host", "myapp://host?code=ABC"}, + {"scheme only no slashes", "myapp:", "myapp:?code=ABC"}, + {"https", "https://example.com", "https://example.com?code=ABC"}, + {"reverse dns empty authority", "com.example.app://", "com.example.app://?code=ABC"}, + {"reverse dns with path", "com.example.app://callback", "com.example.app://callback?code=ABC"}, + {"triple slash", "myapp:///callback", "myapp:///callback?code=ABC"}, + {"host port path", "myapp://host:1234/callback", "myapp://host:1234/callback?code=ABC"}, + {"existing query", "myapp://?x=1", "myapp://?code=ABC&x=1"}, + {"existing code param overwrites", "myapp://?code=OLD", "myapp://?code=ABC"}, + {"fragment", "myapp://#frag", "myapp://?code=ABC#frag"}, + // net/url lowercases the scheme per RFC 3986 ยง3.1. iOS URL-scheme + // matching is case-sensitive in practice, so mixed-case schemes are + // normalized to lowercase by the time they reach the client. + {"mixed case scheme", "MyApp://callback", "myapp://callback?code=ABC"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + u, err := url.Parse(c.in) + require.NoError(t, err) + q := u.Query() + q.Set("code", "ABC") + u.RawQuery = q.Encode() + got := PreserveEmptyAuthority(c.in, u) + assert.Equal(t, c.want, got) + }) + } +}