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 }