- 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
152 lines
3.8 KiB
Go
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
|
|
}
|