- 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
169 lines
5.8 KiB
Go
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
|
|
}
|