diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 2d42f452..47c624c1 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -15,7 +15,6 @@ import ( "net/http" "net/url" "path" - "regexp" "slices" "strings" "time" @@ -1196,7 +1195,7 @@ func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL // If URLs are already configured, validate against them if len(client.CallbackURLs) > 0 { - matched, err := s.getCallbackURLFromList(client.CallbackURLs, inputCallbackURL) + matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL) if err != nil { return "", err } else if matched == "" { @@ -1219,7 +1218,7 @@ func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogout return client.LogoutCallbackURLs[0], nil } - matched, err := s.getCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL) + matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL) if err != nil { return "", err } else if matched == "" { @@ -1229,21 +1228,6 @@ func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogout return matched, nil } -func (s *OidcService) getCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) { - for _, callbackPattern := range urls { - regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$" - matched, err := regexp.MatchString(regexPattern, inputCallbackURL) - if err != nil { - return "", err - } - if matched { - return inputCallbackURL, nil - } - } - - return "", nil -} - func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error { // Add the new callback URL to the existing list client.CallbackURLs = append(client.CallbackURLs, callbackURL) diff --git a/backend/internal/utils/callback_url_util.go b/backend/internal/utils/callback_url_util.go new file mode 100644 index 00000000..5cd1347b --- /dev/null +++ b/backend/internal/utils/callback_url_util.go @@ -0,0 +1,199 @@ +package utils + +import ( + "net" + "net/url" + "path" + "regexp" + "strings" +) + +// GetCallbackURLFromList returns the first callback URL that matches the input callback URL +func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) { + // Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3: + // https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 + // + // The authorization server MUST allow any port to be specified at the + // time of the request for loopback IP redirect URIs, to accommodate + // clients that obtain an available ephemeral port from the operating + // system at the time of the request. + loopbackRedirect := "" + u, _ := url.Parse(inputCallbackURL) + + if u != nil && u.Scheme == "http" { + host := u.Hostname() + ip := net.ParseIP(host) + if host == "localhost" || (ip != nil && ip.IsLoopback()) { + loopbackRedirect = u.String() + u.Host = host + inputCallbackURL = u.String() + } + } + + for _, pattern := range urls { + matches, err := matchCallbackURL(pattern, inputCallbackURL) + if err != nil { + return "", err + } else if !matches { + continue + } + + if loopbackRedirect != "" { + return loopbackRedirect, nil + } + return inputCallbackURL, nil + } + + return "", nil +} + +// matchCallbackURL checks if the input callback URL matches the given pattern. +// It supports wildcard matching for paths and query parameters. +// +// The base URL (scheme, userinfo, host, port) and query parameters supports single '*' wildcards only, +// while the path supports both single '*' and double '**' wildcards. +func matchCallbackURL(pattern string, inputCallbackURL string) (matches bool, err error) { + if pattern == inputCallbackURL || pattern == "*" { + return true, nil + } + + // Strip fragment part + // The endpoint URI MUST NOT include a fragment component. + // https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2 + pattern, _, _ = strings.Cut(pattern, "#") + inputCallbackURL, _, _ = strings.Cut(inputCallbackURL, "#") + + // Store and strip query part + var patternQuery url.Values + if i := strings.Index(pattern, "?"); i >= 0 { + patternQuery, err = url.ParseQuery(pattern[i+1:]) + if err != nil { + return false, err + } + pattern = pattern[:i] + } + var inputQuery url.Values + if i := strings.Index(inputCallbackURL, "?"); i >= 0 { + inputQuery, err = url.ParseQuery(inputCallbackURL[i+1:]) + if err != nil { + return false, err + } + inputCallbackURL = inputCallbackURL[:i] + } + + // Split both pattern and input parts + patternParts, patternPath := splitParts(pattern) + inputParts, inputPath := splitParts(inputCallbackURL) + + // Verify everything except the path and query parameters + if len(patternParts) != len(inputParts) { + return false, nil + } + + for i, patternPart := range patternParts { + matched, err := path.Match(patternPart, inputParts[i]) + if err != nil || !matched { + return false, err + } + } + + // Verify path with wildcard support + matched, err := matchPath(patternPath, inputPath) + if err != nil || !matched { + return false, err + } + + // Verify query parameters + if len(patternQuery) != len(inputQuery) { + return false, nil + } + + for patternKey, patternValues := range patternQuery { + inputValues, exists := inputQuery[patternKey] + if !exists { + return false, nil + } + + if len(patternValues) != len(inputValues) { + return false, nil + } + + for i := range patternValues { + matched, err := path.Match(patternValues[i], inputValues[i]) + if err != nil || !matched { + return false, err + } + } + } + + return true, nil +} + +// matchPath matches the input path against the pattern with wildcard support +// Supported wildcards: +// +// '*' matches any sequence of characters except '/' +// '**' matches any sequence of characters including '/' +func matchPath(pattern string, input string) (matches bool, err error) { + var regexPattern strings.Builder + regexPattern.WriteString("^") + + runes := []rune(pattern) + n := len(runes) + + for i := 0; i < n; { + switch runes[i] { + case '*': + // Check if it's a ** (globstar) + if i+1 < n && runes[i+1] == '*' { + // globstar = .* (match slashes too) + regexPattern.WriteString(".*") + i += 2 + } else { + // single * = [^/]* (no slash) + regexPattern.WriteString(`[^/]*`) + i++ + } + default: + regexPattern.WriteString(regexp.QuoteMeta(string(runes[i]))) + i++ + } + } + + regexPattern.WriteString("$") + + matched, err := regexp.MatchString(regexPattern.String(), input) + return matched, err +} + +// splitParts splits the URL into parts by special characters and returns the path separately +func splitParts(s string) (parts []string, path string) { + split := func(r rune) bool { + return r == ':' || r == '/' || r == '[' || r == ']' || r == '@' || r == '.' + } + + pathStart := -1 + + // Look for scheme:// first + if i := strings.Index(s, "://"); i >= 0 { + // Look for the next slash after scheme:// + rest := s[i+3:] + if j := strings.IndexRune(rest, '/'); j >= 0 { + pathStart = i + 3 + j + } + } else { + // Otherwise, first slash is path start + pathStart = strings.IndexRune(s, '/') + } + + if pathStart >= 0 { + path = s[pathStart:] + base := s[:pathStart] + parts = strings.FieldsFunc(base, split) + } else { + parts = strings.FieldsFunc(s, split) + path = "" + } + + return parts, path +} diff --git a/backend/internal/utils/callback_url_util_test.go b/backend/internal/utils/callback_url_util_test.go new file mode 100644 index 00000000..52c8f518 --- /dev/null +++ b/backend/internal/utils/callback_url_util_test.go @@ -0,0 +1,784 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatchCallbackURL(t *testing.T) { + tests := []struct { + name string + pattern string + input string + shouldMatch bool + }{ + // Basic matching + { + "exact match", + "https://example.com/callback", + "https://example.com/callback", + true, + }, + { + "no match", + "https://example.org/callback", + "https://example.com/callback", + false, + }, + + // Scheme + { + "scheme mismatch", + "https://example.com/callback", + "http://example.com/callback", + false, + }, + { + "wildcard scheme", + "*://example.com/callback", + "https://example.com/callback", + true, + }, + + // Hostname + { + "hostname mismatch", + "https://example.com/callback", + "https://malicious.com/callback", + false, + }, + { + "wildcard subdomain", + "https://*.example.com/callback", + "https://subdomain.example.com/callback", + true, + }, + { + "partial wildcard in hostname prefix", + "https://app*.example.com/callback", + "https://app1.example.com/callback", + true, + }, + { + "partial wildcard in hostname suffix", + "https://*-prod.example.com/callback", + "https://api-prod.example.com/callback", + true, + }, + { + "partial wildcard in hostname middle", + "https://app-*-server.example.com/callback", + "https://app-staging-server.example.com/callback", + true, + }, + { + "subdomain wildcard doesn't match domain hijack attempt", + "https://*.example.com/callback", + "https://malicious.site?url=abc.example.com/callback", + false, + }, + { + "hostname mismatch with confusable characters", + "https://example.com/callback", + "https://examp1e.com/callback", + false, + }, + { + "hostname mismatch with homograph attack", + "https://example.com/callback", + "https://еxample.com/callback", + false, + }, + + // Port + { + "port mismatch", + "https://example.com:8080/callback", + "https://example.com:9090/callback", + false, + }, + { + "wildcard port", + "https://example.com:*/callback", + "https://example.com:8080/callback", + true, + }, + { + "partial wildcard in port prefix", + "https://example.com:80*/callback", + "https://example.com:8080/callback", + true, + }, + + // Path + { + "path mismatch", + "https://example.com/callback", + "https://example.com/other", + false, + }, + { + "wildcard path segment", + "https://example.com/api/*/callback", + "https://example.com/api/v1/callback", + true, + }, + { + "wildcard entire path", + "https://example.com/*", + "https://example.com/callback", + true, + }, + { + "partial wildcard in path prefix", + "https://example.com/test*", + "https://example.com/testcase", + true, + }, + { + "partial wildcard in path suffix", + "https://example.com/*-callback", + "https://example.com/oauth-callback", + true, + }, + { + "partial wildcard in path middle", + "https://example.com/api-*-v1/callback", + "https://example.com/api-internal-v1/callback", + true, + }, + { + "multiple partial wildcards in path", + "https://example.com/*/test*/callback", + "https://example.com/v1/testing/callback", + true, + }, + { + "multiple wildcard segments in path", + "https://example.com/**/callback", + "https://example.com/api/v1/foo/bar/callback", + true, + }, + { + "multiple wildcard segments in path", + "https://example.com/**/v1/**/callback", + "https://example.com/api/v1/foo/bar/callback", + true, + }, + { + "partial wildcard matching full path segment", + "https://example.com/foo-*", + "https://example.com/foo-bar", + true, + }, + + // Credentials + { + "username mismatch", + "https://user:pass@example.com/callback", + "https://admin:pass@example.com/callback", + false, + }, + { + "missing credentials", + "https://user:pass@example.com/callback", + "https://example.com/callback", + false, + }, + { + "unexpected credentials", + "https://example.com/callback", + "https://user:pass@example.com/callback", + false, + }, + { + "wildcard password", + "https://user:*@example.com/callback", + "https://user:secret123@example.com/callback", + true, + }, + { + "partial wildcard in username", + "https://admin*:pass@example.com/callback", + "https://admin123:pass@example.com/callback", + true, + }, + { + "partial wildcard in password", + "https://user:pass*@example.com/callback", + "https://user:password123@example.com/callback", + true, + }, + { + "wildcard password doesn't allow domain hijack", + "https://user:*@example.com/callback", + "https://user:password@malicious.site#example.com/callback", + false, + }, + { + "credentials with @ in password trying to hijack hostname", + "https://user:pass@example.com/callback", + "https://user:pass@evil.com@example.com/callback", + false, + }, + + // Query parameters + { + "extra query parameter", + "https://example.com/callback?code=*", + "https://example.com/callback?code=abc123&extra=value", + false, + }, + { + "missing query parameter", + "https://example.com/callback?code=*&state=*", + "https://example.com/callback?code=abc123", + false, + }, + { + "query parameter after fragment", + "https://example.com/callback?code=123", + "https://example.com/callback#section?code=123", + false, + }, + { + "query parameter name mismatch", + "https://example.com/callback?code=*", + "https://example.com/callback?token=abc123", + false, + }, + { + "wildcard query parameter", + "https://example.com/callback?code=*", + "https://example.com/callback?code=abc123", + true, + }, + { + "multiple query parameters", + "https://example.com/callback?code=*&state=*", + "https://example.com/callback?code=abc123&state=xyz789", + true, + }, + { + "query parameters in different order", + "https://example.com/callback?state=*&code=*", + "https://example.com/callback?code=abc123&state=xyz789", + true, + }, + { + "exact query parameter value", + "https://example.com/callback?mode=production", + "https://example.com/callback?mode=production", + true, + }, + { + "query parameter value mismatch", + "https://example.com/callback?mode=production", + "https://example.com/callback?mode=development", + false, + }, + { + "mixed exact and wildcard query parameters", + "https://example.com/callback?mode=production&code=*", + "https://example.com/callback?mode=production&code=abc123", + true, + }, + { + "mixed exact and wildcard with wrong exact value", + "https://example.com/callback?mode=production&code=*", + "https://example.com/callback?mode=development&code=abc123", + false, + }, + { + "multiple values for same parameter", + "https://example.com/callback?param=*¶m=*", + "https://example.com/callback?param=value1¶m=value2", + true, + }, + { + "unexpected query parameters", + "https://example.com/callback", + "https://example.com/callback?extra=value", + false, + }, + { + "query parameter with redirect to external site", + "https://example.com/callback?code=*", + "https://example.com/callback?code=123&redirect=https://evil.com", + false, + }, + { + "open redirect via encoded URL in query param", + "https://example.com/callback?state=*", + "https://example.com/callback?state=abc&next=//evil.com", + false, + }, + + // Fragment + { + "fragment ignored when both pattern and input have fragment", + "https://example.com/callback#fragment", + "https://example.com/callback#fragment", + true, + }, + { + "fragment ignored when pattern has fragment but input doesn't", + "https://example.com/callback#fragment", + "https://example.com/callback", + true, + }, + { + "fragment ignored when input has fragment but pattern doesn't", + "https://example.com/callback", + "https://example.com/callback#section", + true, + }, + + // Path traversal and injection attempts + { + "path traversal attempt", + "https://example.com/callback", + "https://example.com/../admin/callback", + false, + }, + { + "backslash instead of forward slash", + "https://example.com/callback", + "https://example.com\\callback", + false, + }, + { + "double slash in hostname (protocol smuggling)", + "https://example.com/callback", + "https://example.com//evil.com/callback", + false, + }, + { + "CRLF injection attempt in path", + "https://example.com/callback", + "https://example.com/callback%0d%0aLocation:%20https://evil.com", + false, + }, + { + "null byte injection", + "https://example.com/callback", + "https://example.com/callback%00.evil.com", + false, + }, + } + + for _, tt := range tests { + matches, err := matchCallbackURL(tt.pattern, tt.input) + require.NoError(t, err, tt.name) + assert.Equal(t, tt.shouldMatch, matches, tt.name) + + } +} + +func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) { + tests := []struct { + name string + urls []string + inputCallbackURL string + expectedURL string + expectMatch bool + }{ + { + name: "127.0.0.1 with dynamic port - exact match", + urls: []string{"http://127.0.0.1/callback"}, + inputCallbackURL: "http://127.0.0.1:8080/callback", + expectedURL: "http://127.0.0.1:8080/callback", + expectMatch: true, + }, + { + name: "127.0.0.1 with different port", + urls: []string{"http://127.0.0.1/callback"}, + inputCallbackURL: "http://127.0.0.1:9999/callback", + expectedURL: "http://127.0.0.1:9999/callback", + expectMatch: true, + }, + { + name: "IPv6 loopback with dynamic port", + urls: []string{"http://[::1]/callback"}, + inputCallbackURL: "http://[::1]:8080/callback", + expectedURL: "http://[::1]:8080/callback", + expectMatch: true, + }, + { + name: "IPv6 loopback without brackets in input", + urls: []string{"http://[::1]/callback"}, + inputCallbackURL: "http://::1:8080/callback", + expectedURL: "http://::1:8080/callback", + expectMatch: true, + }, + { + name: "localhost with dynamic port", + urls: []string{"http://localhost/callback"}, + inputCallbackURL: "http://localhost:8080/callback", + expectedURL: "http://localhost:8080/callback", + expectMatch: true, + }, + { + name: "https loopback doesn't trigger special handling", + urls: []string{"https://127.0.0.1/callback"}, + inputCallbackURL: "https://127.0.0.1:8080/callback", + expectedURL: "", + expectMatch: false, + }, + { + name: "loopback with path match", + urls: []string{"http://127.0.0.1/auth/*"}, + inputCallbackURL: "http://127.0.0.1:3000/auth/callback", + expectedURL: "http://127.0.0.1:3000/auth/callback", + expectMatch: true, + }, + { + name: "loopback with path mismatch", + urls: []string{"http://127.0.0.1/callback"}, + inputCallbackURL: "http://127.0.0.1:8080/different", + expectedURL: "", + expectMatch: false, + }, + { + name: "non-loopback IP", + urls: []string{"http://192.168.1.1/callback"}, + inputCallbackURL: "http://192.168.1.1:8080/callback", + expectedURL: "", + expectMatch: false, + }, + { + name: "wildcard matches loopback", + urls: []string{"*"}, + inputCallbackURL: "http://127.0.0.1:8080/callback", + expectedURL: "http://127.0.0.1:8080/callback", + expectMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GetCallbackURLFromList(tt.urls, tt.inputCallbackURL) + require.NoError(t, err) + if tt.expectMatch { + assert.Equal(t, tt.expectedURL, result) + } else { + assert.Empty(t, result) + } + }) + } +} + +func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) { + tests := []struct { + name string + urls []string + inputCallbackURL string + expectedURL string + expectMatch bool + }{ + { + name: "matches first pattern", + urls: []string{ + "https://example.com/callback", + "https://example.org/callback", + }, + inputCallbackURL: "https://example.com/callback", + expectedURL: "https://example.com/callback", + expectMatch: true, + }, + { + name: "matches second pattern", + urls: []string{ + "https://example.com/callback", + "https://example.org/callback", + }, + inputCallbackURL: "https://example.org/callback", + expectedURL: "https://example.org/callback", + expectMatch: true, + }, + { + name: "matches none", + urls: []string{ + "https://example.com/callback", + "https://example.org/callback", + }, + inputCallbackURL: "https://malicious.com/callback", + expectedURL: "", + expectMatch: false, + }, + { + name: "matches wildcard pattern", + urls: []string{ + "https://example.com/callback", + "https://*.example.org/callback", + }, + inputCallbackURL: "https://subdomain.example.org/callback", + expectedURL: "https://subdomain.example.org/callback", + expectMatch: true, + }, + { + name: "empty pattern list", + urls: []string{}, + inputCallbackURL: "https://example.com/callback", + expectedURL: "", + expectMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GetCallbackURLFromList(tt.urls, tt.inputCallbackURL) + require.NoError(t, err) + if tt.expectMatch { + assert.Equal(t, tt.expectedURL, result) + } else { + assert.Empty(t, result) + } + }) + } +} + +func TestMatchPath(t *testing.T) { + tests := []struct { + name string + pattern string + input string + shouldMatch bool + }{ + // Exact matches + { + name: "exact match", + pattern: "/callback", + input: "/callback", + shouldMatch: true, + }, + { + name: "exact mismatch", + pattern: "/callback", + input: "/other", + shouldMatch: false, + }, + { + name: "empty paths", + pattern: "", + input: "", + shouldMatch: true, + }, + + // Single wildcard (*) + { + name: "single wildcard matches segment", + pattern: "/api/*/callback", + input: "/api/v1/callback", + shouldMatch: true, + }, + { + name: "single wildcard doesn't match multiple segments", + pattern: "/api/*/callback", + input: "/api/v1/v2/callback", + shouldMatch: false, + }, + { + name: "single wildcard at end", + pattern: "/callback/*", + input: "/callback/test", + shouldMatch: true, + }, + { + name: "single wildcard at start", + pattern: "/*/callback", + input: "/api/callback", + shouldMatch: true, + }, + { + name: "multiple single wildcards", + pattern: "/*/test/*", + input: "/api/test/callback", + shouldMatch: true, + }, + { + name: "partial wildcard prefix", + pattern: "/test*", + input: "/testing", + shouldMatch: true, + }, + { + name: "partial wildcard suffix", + pattern: "/*-callback", + input: "/oauth-callback", + shouldMatch: true, + }, + { + name: "partial wildcard middle", + pattern: "/api-*-v1", + input: "/api-internal-v1", + shouldMatch: true, + }, + + // Double wildcard (**) + { + name: "double wildcard matches multiple segments", + pattern: "/api/**/callback", + input: "/api/v1/v2/v3/callback", + shouldMatch: true, + }, + { + name: "double wildcard matches single segment", + pattern: "/api/**/callback", + input: "/api/v1/callback", + shouldMatch: true, + }, + { + name: "double wildcard doesn't match when pattern has extra slashes", + pattern: "/api/**/callback", + input: "/api/callback", + shouldMatch: false, + }, + { + name: "double wildcard at end", + pattern: "/api/**", + input: "/api/v1/v2/callback", + shouldMatch: true, + }, + { + name: "double wildcard in middle", + pattern: "/api/**/v2/**/callback", + input: "/api/v1/v2/v3/v4/callback", + shouldMatch: true, + }, + + // Complex patterns + { + name: "mix of single and double wildcards", + pattern: "/*/api/**/callback", + input: "/app/api/v1/v2/callback", + shouldMatch: true, + }, + { + name: "wildcard with special characters", + pattern: "/callback-*", + input: "/callback-123", + shouldMatch: true, + }, + { + name: "path with query-like string (no special handling)", + pattern: "/callback?code=*", + input: "/callback?code=abc", + shouldMatch: true, + }, + + // Edge cases + { + name: "single wildcard matches empty segment", + pattern: "/api/*/callback", + input: "/api//callback", + shouldMatch: true, + }, + { + name: "pattern longer than input", + pattern: "/api/v1/callback", + input: "/api", + shouldMatch: false, + }, + { + name: "input longer than pattern", + pattern: "/api", + input: "/api/v1/callback", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matches, err := matchPath(tt.pattern, tt.input) + require.NoError(t, err) + assert.Equal(t, tt.shouldMatch, matches) + }) + } +} + +func TestSplitParts(t *testing.T) { + tests := []struct { + name string + input string + expectedParts []string + expectedPath string + }{ + { + name: "simple https URL", + input: "https://example.com/callback", + expectedParts: []string{"https", "example", "com"}, + expectedPath: "/callback", + }, + { + name: "URL with port", + input: "https://example.com:8080/callback", + expectedParts: []string{"https", "example", "com", "8080"}, + expectedPath: "/callback", + }, + { + name: "URL with subdomain", + input: "https://api.example.com/callback", + expectedParts: []string{"https", "api", "example", "com"}, + expectedPath: "/callback", + }, + { + name: "URL with credentials", + input: "https://user:pass@example.com/callback", + expectedParts: []string{"https", "user", "pass", "example", "com"}, + expectedPath: "/callback", + }, + { + name: "URL without path", + input: "https://example.com", + expectedParts: []string{"https", "example", "com"}, + expectedPath: "", + }, + { + name: "URL with deep path", + input: "https://example.com/api/v1/callback", + expectedParts: []string{"https", "example", "com"}, + expectedPath: "/api/v1/callback", + }, + { + name: "URL with path and query", + input: "https://example.com/callback?code=123", + expectedParts: []string{"https", "example", "com"}, + expectedPath: "/callback?code=123", + }, + { + name: "URL with trailing slash", + input: "https://example.com/", + expectedParts: []string{"https", "example", "com"}, + expectedPath: "/", + }, + { + name: "URL with multiple subdomains", + input: "https://api.v1.staging.example.com/callback", + expectedParts: []string{"https", "api", "v1", "staging", "example", "com"}, + expectedPath: "/callback", + }, + { + name: "URL with port and credentials", + input: "https://user:pass@example.com:8080/callback", + expectedParts: []string{"https", "user", "pass", "example", "com", "8080"}, + expectedPath: "/callback", + }, + { + name: "scheme with authority separator but no slash", + input: "http://example.com", + expectedParts: []string{"http", "example", "com"}, + expectedPath: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parts, path := splitParts(tt.input) + assert.Equal(t, tt.expectedParts, parts, "parts mismatch") + assert.Equal(t, tt.expectedPath, path, "path mismatch") + }) + } +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 9ec15916..fa05691f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -349,8 +349,8 @@ "login_code_email_success": "The login code has been sent to the user.", "send_email": "Send Email", "show_code": "Show Code", - "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.", - "logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.", + "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards are supported.", + "logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards are supported.", "api_key_expiration": "API Key Expiration", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.", "authorize_device": "Authorize Device", diff --git a/frontend/src/lib/components/form/form-input.svelte b/frontend/src/lib/components/form/form-input.svelte index 0ba8e81b..e7f09e9a 100644 --- a/frontend/src/lib/components/form/form-input.svelte +++ b/frontend/src/lib/components/form/form-input.svelte @@ -7,6 +7,7 @@ import { LucideExternalLink } from '@lucide/svelte'; import type { Snippet } from 'svelte'; import type { HTMLAttributes } from 'svelte/elements'; + import FormattedMessage from '../formatted-message.svelte'; let { input = $bindable(), @@ -40,7 +41,7 @@ {/if} {#if description}