Files
summercms/modules/wristband/oidc_userinfo.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

152 lines
3.8 KiB
Go

package wristband
import (
"bytes"
"encoding/json"
"net/http"
"strings"
)
// UserInfo handles GET /oauth/userinfo on an OIDC server (404 on an MCP
// server). A valid bearer access token answers sub (the same subject the
// id_token carried for that client) plus the identity claims of the
// token's scopes, filtered by OIDCOptions.IdentityClaims. Every failure is
// one uniform 401 with an empty body and WWW-Authenticate: Bearer
// error="invalid_token" (T-19.4-10).
func (s *Server) UserInfo(w http.ResponseWriter, r *http.Request) {
if s.flavour != flavourOIDC {
w.WriteHeader(http.StatusNotFound)
return
}
if !s.oidcReady() {
w.WriteHeader(http.StatusInternalServerError)
return
}
presented := bearerToken(r)
if presented == "" || !strings.HasPrefix(presented, s.oidc.AccessTokenPrefix) {
writeUserInfoUnauthorized(w)
return
}
ctx := r.Context()
var claims map[string]any
var sub string
err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error {
now := s.now()
tok, err := tx.AccessTokenByHash(ctx, sha256Hex(presented))
if err != nil {
return err
}
if tok == nil || tok.RevokedAt != nil || !tok.ExpiresAt.After(now) {
return nil
}
client, err := tx.OIDCClientByClientID(ctx, tok.ClientID)
if err != nil {
return err
}
if client == nil || client.Kind != ClientKindOIDC || !client.IsActive {
return nil
}
identity, ok, err := tx.IdentityClaims(ctx, tok.UserID, tok.Scopes)
if err != nil {
return err
}
if !ok {
return nil
}
subject, err := subjectFor(ctx, tx, client, tok.UserID)
if err != nil {
return err
}
if err := tx.TouchAccessToken(ctx, tok.ID, now); err != nil {
return err
}
claims, sub = identity, subject
return nil
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if sub == "" {
writeUserInfoUnauthorized(w)
return
}
body, err := orderedClaimsJSON(sub, claims, s.oidc.IdentityClaims)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
h := w.Header()
h.Set("Content-Type", "application/json")
h.Set("Cache-Control", "no-store, private")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
func writeUserInfoUnauthorized(w http.ResponseWriter) {
h := w.Header()
h.Set("WWW-Authenticate", `Bearer error="invalid_token"`)
h.Set("Cache-Control", "no-store, private")
w.WriteHeader(http.StatusUnauthorized)
}
// bearerToken mirrors Laravel's Request::bearerToken: the text after the
// last "Bearer " (any case) in the Authorization header, cut at a comma.
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
idx := strings.LastIndex(strings.ToLower(h), "bearer ")
if idx < 0 {
return ""
}
tok := h[idx+len("bearer "):]
if i := strings.IndexByte(tok, ','); i >= 0 {
tok = tok[:i]
}
return tok
}
// orderedClaimsJSON writes sub and the allowlisted identity claims in the
// allowlist's order (the PHP ClaimBuilder order). Protocol claim names are
// never copied from the identity map (T-19.4-11).
func orderedClaimsJSON(sub string, identity map[string]any, allowed []string) ([]byte, error) {
var buf bytes.Buffer
enc := func(v any) error {
var b bytes.Buffer
e := json.NewEncoder(&b)
e.SetEscapeHTML(false)
if err := e.Encode(v); err != nil {
return err
}
buf.Write(bytes.TrimSuffix(b.Bytes(), []byte("\n")))
return nil
}
buf.WriteByte('{')
if err := enc("sub"); err != nil {
return nil, err
}
buf.WriteByte(':')
if err := enc(sub); err != nil {
return nil, err
}
for _, name := range allowed {
if protocolClaims[name] {
continue
}
v, ok := identity[name]
if !ok {
continue
}
buf.WriteByte(',')
if err := enc(name); err != nil {
return nil, err
}
buf.WriteByte(':')
if err := enc(v); err != nil {
return nil, err
}
}
buf.WriteByte('}')
return buf.Bytes(), nil
}