Files
summercms/modules/wristband/oidc_redirect.go
Jakub Zych dfa3c3e8b4 feat(19.4-01): OIDC authorize, consent and switch account match the PHP provider
- 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
2026-10-19 18:21:11 +02:00

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()
}