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 }