package wristband import ( "fmt" "net/url" "strings" ) // phpURLEncode ports PHP urlencode (the encoder behind http_build_query's // default PHP_QUERY_RFC1738): A-Z a-z 0-9 . _ - pass, a space becomes +, // every other byte becomes an uppercase %XX triplet, so a tilde is %7E. // The MCP flavour's rfc3986Escape must not be used for OIDC redirects // (D-14, Pitfall 2). func phpURLEncode(s string) string { var b strings.Builder b.Grow(len(s)) for i := 0; i < len(s); i++ { c := s[i] switch { case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-': b.WriteByte(c) case c == ' ': b.WriteByte('+') default: fmt.Fprintf(&b, "%%%02X", c) } } return b.String() } // appendPHPQuery appends pairs to uri in the given order with // http_build_query semantics, using & when uri already carries a query and // ? otherwise (PHP str_contains($uri, '?') ? '&' : '?'). func appendPHPQuery(uri string, pairs [][2]string) string { parts := make([]string, len(pairs)) for i, p := range pairs { parts[i] = phpURLEncode(p[0]) + "=" + phpURLEncode(p[1]) } sep := "?" if strings.Contains(uri, "?") { sep = "&" } return uri + sep + strings.Join(parts, "&") } // phpURLDecode ports PHP urldecode: + becomes a space, a % followed by two // hex digits becomes that byte, anything else (a lone or malformed %) is // kept as it is. func phpURLDecode(s string) string { var b strings.Builder b.Grow(len(s)) for i := 0; i < len(s); i++ { c := s[i] switch { case c == '+': b.WriteByte(' ') case c == '%' && i+2 < len(s) && isHex(s[i+1]) && isHex(s[i+2]): b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2])) i += 2 default: b.WriteByte(c) } } return b.String() } func isHex(c byte) bool { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') } func unhex(c byte) byte { switch { case c >= '0' && c <= '9': return c - '0' case c >= 'a' && c <= 'f': return c - 'a' + 10 default: return c - 'A' + 10 } } // loopbackHosts are the hosts an is_dev client may vary the port on. var loopbackHosts = map[string]bool{"localhost": true, "127.0.0.1": true, "::1": true} // matchOIDCRedirectURI ports RedirectUriMatcher::matches (T-19.4-01): the // presented URI matches a registered one byte for byte. Only for an is_dev // client, an http URI on localhost, 127.0.0.1 or [::1] also matches a // registered URI that differs only in the port (scheme, user info, host, // path, query and fragment identical). No registered URIs match nothing; // scheme-relative, https-versus-http, trailing-slash and lookalike-host // variants never match. func matchOIDCRedirectURI(registered []string, presented string, isDev bool) bool { if presented == "" { return false } for _, uri := range registered { if uri != "" && constantEqual(uri, presented) { return true } } if !isDev { return false } for _, uri := range registered { if uri != "" && isLoopbackPortVariant(uri, presented) { return true } } return false } func isLoopbackPortVariant(registered, presented string) bool { a, err := url.Parse(registered) if err != nil { return false } b, err := url.Parse(presented) if err != nil { return false } if a.Scheme != "http" || b.Scheme != "http" || a.Opaque != "" || b.Opaque != "" { return false } host := a.Hostname() if host == "" || host != b.Hostname() || !loopbackHosts[host] { return false } return a.User.String() == b.User.String() && a.EscapedPath() == b.EscapedPath() && a.RawQuery == b.RawQuery && a.ForceQuery == b.ForceQuery && a.EscapedFragment() == b.EscapedFragment() }