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

169 lines
5.8 KiB
Go

package wristband
import (
"net/http"
"slices"
"strings"
)
// discoveryDocument is the OpenID Provider Configuration in the PHP
// provider's field order (DiscoveryDocument.php). encoding/json keeps
// declaration order, so this struct is the wire order.
type discoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
JwksURI string `json:"jwks_uri"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
ScopesSupported []string `json:"scopes_supported"`
ClaimsSupported []string `json:"claims_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
ResponseModesSupported []string `json:"response_modes_supported"`
}
type jwksDocument struct {
Keys []jwkDocument `json:"keys"`
}
// serverMessage is the body of a fail-loud 500 (PHP abort(500, ...)).
type serverMessage struct {
Message string `json:"message"`
}
const (
discoveryCacheControl = "max-age=3600, public"
msgIssuerNotConfigured = "The OpenID Connect issuer is not configured."
msgKeysUnavailable = "Signing keys are unavailable."
)
// oidcIssuer is the configured issuer without trailing slashes (PHP
// rtrim($issuer, '/')).
func (s *Server) oidcIssuer() string {
return strings.TrimRight(s.opts.Issuer, "/")
}
// writeServerMessage answers a fixed 500 message. Underlying errors never
// reach the body (T-19.4-09).
func writeServerMessage(w http.ResponseWriter, message string) {
writeExactJSON(w, http.StatusInternalServerError, serverMessage{Message: message}, map[string]string{"Cache-Control": "no-cache, private"})
}
// Discovery handles GET /.well-known/openid-configuration on an OIDC
// server (404 on an MCP server). It writes the PHP field set in PHP order
// with Cache-Control: max-age=3600, public. subject_types_supported comes
// from OIDCOptions (public and pairwise, D-15) and
// id_token_signing_alg_values_supported lists the algorithms the published
// key ring holds (D-12). refresh_token and offline_access are advertised
// only when OIDCOptions.RefreshEnabled.
func (s *Server) Discovery(w http.ResponseWriter, r *http.Request) {
if s.flavour != flavourOIDC {
w.WriteHeader(http.StatusNotFound)
return
}
iss := s.oidcIssuer()
if iss == "" {
writeServerMessage(w, msgIssuerNotConfigured)
return
}
if !s.oidcReady() {
writeServerMessage(w, msgKeysUnavailable)
return
}
keys, err := s.publishedKeys(r)
if err != nil {
writeServerMessage(w, msgKeysUnavailable)
return
}
var algs []string
for _, alg := range []string{AlgRS256, AlgES256} {
for _, k := range keys {
if k.Alg == alg {
algs = append(algs, alg)
break
}
}
}
if algs == nil {
algs = []string{}
}
grants := []string{"authorization_code"}
scopes := slices.Clone(s.oidc.ScopesSupported)
if s.oidc.RefreshEnabled {
grants = append(grants, "refresh_token")
if !slices.Contains(scopes, "offline_access") {
scopes = append(scopes, "offline_access")
}
}
doc := discoveryDocument{
Issuer: iss,
AuthorizationEndpoint: iss + "/oauth/authorize",
TokenEndpoint: iss + "/oauth/token",
UserinfoEndpoint: iss + "/oauth/userinfo",
JwksURI: iss + "/oauth/jwks",
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: grants,
SubjectTypesSupported: nonNil(s.oidc.SubjectTypesSupported),
IDTokenSigningAlgValuesSupported: algs,
ScopesSupported: nonNil(scopes),
ClaimsSupported: nonNil(s.oidc.ClaimsSupported),
CodeChallengeMethodsSupported: []string{"S256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
ResponseModesSupported: []string{"query"},
}
writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": discoveryCacheControl})
}
// JWKS handles GET /oauth/jwks on an OIDC server (404 on an MCP server):
// the active key plus retired keys inside their grace window, with
// Cache-Control: max-age=3600, public. A key-store failure answers 500
// "Signing keys are unavailable." without the error text (T-19.4-09).
func (s *Server) JWKS(w http.ResponseWriter, r *http.Request) {
if s.flavour != flavourOIDC {
w.WriteHeader(http.StatusNotFound)
return
}
if !s.oidcReady() {
writeServerMessage(w, msgKeysUnavailable)
return
}
keys, err := s.publishedKeys(r)
if err != nil {
writeServerMessage(w, msgKeysUnavailable)
return
}
doc := jwksDocument{Keys: make([]jwkDocument, 0, len(keys))}
for _, k := range keys {
j, err := jwkFor(k)
if err != nil {
writeServerMessage(w, msgKeysUnavailable)
return
}
doc.Keys = append(doc.Keys, j)
}
writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": discoveryCacheControl})
}
// publishedKeys asks for the active key first, so an empty ring gets its
// first key lazily (as the PHP key ring does), then reads the published
// ring.
func (s *Server) publishedKeys(r *http.Request) ([]PublicKey, error) {
ctx := r.Context()
if _, err := s.keys.ActiveSigningKey(ctx); err != nil {
return nil, err
}
return s.keys.PublishedKeys(ctx, s.now())
}
func nonNil(ss []string) []string {
if ss == nil {
return []string{}
}
return ss
}