Files
summercms/modules/wristband/oidc_userinfo.go
Jakub Zych 659b42aaa8 feat(19.4-01): OIDC flavour of wristband with an end-to-end tracer
- NewOIDCServer/SetOIDCBackend build a second Server on the same engine;
  Authorize and Token dispatch by flavour, Metadata/Register 404 on OIDC
- OIDCBackend/OIDCTx, KeyStore and OIDCHost seams; MCP Backend untouched
- Discovery (PHP field order), JWKS (stdlib JWK encoding), authorize with
  resume and one-shot consent token, token with an RS256 id_token signed
  through golang-jwt map claims, userinfo
- TestOIDCTracerSmoke verifies the id_token against the served JWKS
2026-10-19 18:18:05 +02:00

152 lines
3.7 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.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
}