From 38dc897f54663eac0462e02a11cad6b2cffcd76d Mon Sep 17 00:00:00 2001 From: vatsalpatel Date: Fri, 17 Apr 2026 21:49:03 +0530 Subject: [PATCH 1/2] fix: preserve "//" in redirect URLs with empty authority Go's net/url normalizes "scheme://" with empty authority to "scheme:" on String(), breaking iOS/Android custom-scheme deep links like "myapp://" emitted as "myapp:?code=...". Adds utilities.PreserveEmptyAuthority and applies it in all redirect URL builders in internal/api/verify.go and oauthserver/authorize.go. Closes #2423 --- internal/api/oauthserver/authorize.go | 4 +-- internal/api/verify.go | 6 ++--- internal/utilities/url.go | 20 ++++++++++++++ internal/utilities/url_test.go | 39 +++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 internal/utilities/url.go create mode 100644 internal/utilities/url_test.go diff --git a/internal/api/oauthserver/authorize.go b/internal/api/oauthserver/authorize.go index 42e14e335b..1f2cb918d4 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, u.String()) } // 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, u.String()) } // 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..1ea7752fb1 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, u.String()), 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, u.String()), 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, u.String()), 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..7bf3b10832 --- /dev/null +++ b/internal/utilities/url.go @@ -0,0 +1,20 @@ +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. +// +// Call with the original input string, the parsed URL, and the already- +// serialized output of u.String(). +func PreserveEmptyAuthority(rurl string, u *url.URL, formatted string) 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..ccfc737e5f --- /dev/null +++ b/internal/utilities/url_test.go @@ -0,0 +1,39 @@ +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"}, + {"fragment", "myapp://#frag", "myapp://?code=ABC#frag"}, + } + 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, u.String()) + assert.Equal(t, c.want, got) + }) + } +} From a631cd83de81ad00e8e61ed74105033374a25a65 Mon Sep 17 00:00:00 2001 From: vatsalpatel Date: Mon, 20 Apr 2026 09:05:28 +0530 Subject: [PATCH 2/2] fix: Cleanup implementation --- internal/api/oauthserver/authorize.go | 4 ++-- internal/api/verify.go | 6 +++--- internal/utilities/url.go | 6 ++---- internal/utilities/url_test.go | 7 ++++++- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/api/oauthserver/authorize.go b/internal/api/oauthserver/authorize.go index 1f2cb918d4..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 utilities.PreserveEmptyAuthority(authorization.RedirectURI, u, 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 utilities.PreserveEmptyAuthority(redirectURI, u, 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 1ea7752fb1..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 utilities.PreserveEmptyAuthority(rurl, u, 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 utilities.PreserveEmptyAuthority(rurl, u, 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 utilities.PreserveEmptyAuthority(rurl, u, 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 index 7bf3b10832..19a03dcfeb 100644 --- a/internal/utilities/url.go +++ b/internal/utilities/url.go @@ -9,10 +9,8 @@ import ( // 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. -// -// Call with the original input string, the parsed URL, and the already- -// serialized output of u.String(). -func PreserveEmptyAuthority(rurl string, u *url.URL, formatted string) string { +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) } diff --git a/internal/utilities/url_test.go b/internal/utilities/url_test.go index ccfc737e5f..c72d2e9c43 100644 --- a/internal/utilities/url_test.go +++ b/internal/utilities/url_test.go @@ -23,7 +23,12 @@ func TestPreserveEmptyAuthority(t *testing.T) { {"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) { @@ -32,7 +37,7 @@ func TestPreserveEmptyAuthority(t *testing.T) { q := u.Query() q.Set("code", "ABC") u.RawQuery = q.Encode() - got := PreserveEmptyAuthority(c.in, u, u.String()) + got := PreserveEmptyAuthority(c.in, u) assert.Equal(t, c.want, got) }) }