Skip to content

Commit 38dc897

Browse files
committed
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
1 parent ab445f1 commit 38dc897

4 files changed

Lines changed: 64 additions & 5 deletions

File tree

internal/api/oauthserver/authorize.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,7 @@ func (s *Server) buildSuccessRedirectURL(authorization *models.OAuthServerAuthor
606606
q.Set("state", *authorization.State)
607607
}
608608
u.RawQuery = q.Encode()
609-
return u.String()
609+
return utilities.PreserveEmptyAuthority(authorization.RedirectURI, u, u.String())
610610
}
611611

612612
// buildErrorRedirectURL builds an error redirect URL with the given parameters
@@ -619,7 +619,7 @@ func (s *Server) buildErrorRedirectURL(redirectURI, errorCode, errorDescription,
619619
q.Set("state", state)
620620
}
621621
u.RawQuery = q.Encode()
622-
return u.String()
622+
return utilities.PreserveEmptyAuthority(redirectURI, u, u.String())
623623
}
624624

625625
// buildAuthorizationURL safely joins a base URL with a path, handling slashes correctly

internal/api/verify.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ func (a *API) prepErrorRedirectURL(err *HTTPError, r *http.Request, rurl string,
510510
// Add Supabase Auth identifier to help clients distinguish Supabase Auth redirects
511511
hq.Set("sb", "")
512512
u.Fragment = hq.Encode()
513-
return u.String(), nil
513+
return utilities.PreserveEmptyAuthority(rurl, u, u.String()), nil
514514
}
515515

516516
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
528528
// Add Supabase Auth identifier to help clients distinguish Supabase Auth redirects
529529
hq.Set("sb", "")
530530
u.Fragment = hq.Encode()
531-
return u.String(), nil
531+
return utilities.PreserveEmptyAuthority(rurl, u, u.String()), nil
532532
}
533533

534534
func (a *API) prepPKCERedirectURL(rurl, code string) (string, error) {
@@ -539,7 +539,7 @@ func (a *API) prepPKCERedirectURL(rurl, code string) (string, error) {
539539
q := u.Query()
540540
q.Set("code", code)
541541
u.RawQuery = q.Encode()
542-
return u.String(), nil
542+
return utilities.PreserveEmptyAuthority(rurl, u, u.String()), nil
543543
}
544544

545545
func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, params *VerifyParams, user *models.User) (*models.User, error) {

internal/utilities/url.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package utilities
2+
3+
import (
4+
"net/url"
5+
"strings"
6+
)
7+
8+
// PreserveEmptyAuthority restores the `//` in URLs that use "scheme://" with
9+
// an empty authority (e.g. custom-scheme deep links like "myapp://"). Go's
10+
// net/url package normalizes these to "scheme:" on String(), which breaks
11+
// iOS/Android deep-link clients that register for the "scheme://" form.
12+
//
13+
// Call with the original input string, the parsed URL, and the already-
14+
// serialized output of u.String().
15+
func PreserveEmptyAuthority(rurl string, u *url.URL, formatted string) string {
16+
if u.Scheme != "" && u.Host == "" && u.Path == "" && strings.HasPrefix(rurl, u.Scheme+"://") {
17+
return strings.Replace(formatted, u.Scheme+":", u.Scheme+"://", 1)
18+
}
19+
return formatted
20+
}

internal/utilities/url_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package utilities
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestPreserveEmptyAuthority(t *testing.T) {
12+
cases := []struct {
13+
name string
14+
in string
15+
want string
16+
}{
17+
{"custom scheme empty authority", "myapp://", "myapp://?code=ABC"},
18+
{"custom scheme with host", "myapp://host", "myapp://host?code=ABC"},
19+
{"scheme only no slashes", "myapp:", "myapp:?code=ABC"},
20+
{"https", "https://example.com", "https://example.com?code=ABC"},
21+
{"reverse dns empty authority", "com.example.app://", "com.example.app://?code=ABC"},
22+
{"reverse dns with path", "com.example.app://callback", "com.example.app://callback?code=ABC"},
23+
{"triple slash", "myapp:///callback", "myapp:///callback?code=ABC"},
24+
{"host port path", "myapp://host:1234/callback", "myapp://host:1234/callback?code=ABC"},
25+
{"existing query", "myapp://?x=1", "myapp://?code=ABC&x=1"},
26+
{"fragment", "myapp://#frag", "myapp://?code=ABC#frag"},
27+
}
28+
for _, c := range cases {
29+
t.Run(c.name, func(t *testing.T) {
30+
u, err := url.Parse(c.in)
31+
require.NoError(t, err)
32+
q := u.Query()
33+
q.Set("code", "ABC")
34+
u.RawQuery = q.Encode()
35+
got := PreserveEmptyAuthority(c.in, u, u.String())
36+
assert.Equal(t, c.want, got)
37+
})
38+
}
39+
}

0 commit comments

Comments
 (0)