- Full validation chain with PHP order and messages; repeated params last-wins - is_dev loopback port matching (matchOIDCRedirectURI); PHP http_build_query redirects, no iss - One-shot consent token burned under row lock; deny; SwitchAccount; JSON-mode ConsentRequest and DecideConsent - ClientRecord.Kind with ClientKindMCP/ClientKindOIDC; each flavour refuses the other kind at authorize, token and userinfo (D-08) - Smoke tests: start, resume/decide/switch, kind isolation, PHP encoding
133 lines
3.6 KiB
Go
133 lines
3.6 KiB
Go
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()
|
|
}
|