Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
142 lines
6.5 KiB
Go
142 lines
6.5 KiB
Go
// Package oidc implements OIDC profile endpoints for KeyCape.
|
|
// Only profile-supported features are advertised — no implicit flow,
|
|
// no dynamic registration, no request objects.
|
|
package oidc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
|
|
"keycape/internal/domain"
|
|
)
|
|
|
|
// DiscoveryConfig holds the issuer and endpoint URLs for the discovery document.
|
|
// UserinfoEndpoint is optional; if empty it is omitted from the document.
|
|
type DiscoveryConfig struct {
|
|
Issuer string // e.g. "https://auth.netkingdom.local"
|
|
AuthorizationEndpoint string
|
|
TokenEndpoint string
|
|
JWKSUri string
|
|
UserinfoEndpoint string // optional, empty = not advertised
|
|
EndSessionEndpoint string // optional, empty = not advertised
|
|
IntrospectionEndpoint string // opt-in online access-token status
|
|
RevocationEndpoint string // opt-in access-token revocation
|
|
// Clients supplies the registered clients so scopes_supported reflects the
|
|
// scopes this deployment can actually grant, rather than a fixed list that
|
|
// omits every configured resource scope (KEY-WP-0017-T03). Nil or empty
|
|
// leaves only the baseline OIDC scopes advertised.
|
|
Clients map[string]*domain.Client
|
|
}
|
|
|
|
// discoveryDocument is the JSON shape of /.well-known/openid-configuration.
|
|
// Fields are ordered to match common OIDC implementations for readability.
|
|
// registration_endpoint is intentionally absent — no dynamic client registration.
|
|
type discoveryDocument struct {
|
|
Issuer string `json:"issuer"`
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
JWKSUri string `json:"jwks_uri"`
|
|
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
|
|
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
|
|
IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"`
|
|
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
|
|
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"`
|
|
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"`
|
|
ResponseTypesSupported []string `json:"response_types_supported"`
|
|
GrantTypesSupported []string `json:"grant_types_supported"`
|
|
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
|
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
|
ScopesSupported []string `json:"scopes_supported"`
|
|
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
|
ClaimsSupported []string `json:"claims_supported"`
|
|
SubjectTypesSupported []string `json:"subject_types_supported"`
|
|
RequestParameterSupported bool `json:"request_parameter_supported"`
|
|
ClaimsParameterSupported bool `json:"claims_parameter_supported"`
|
|
}
|
|
|
|
// discoveryHandler implements http.Handler for GET /.well-known/openid-configuration.
|
|
type discoveryHandler struct {
|
|
doc []byte
|
|
}
|
|
|
|
// NewDiscoveryHandler returns an http.Handler that serves the OIDC discovery document.
|
|
// The document is pre-serialised at construction time so every request is a cheap copy.
|
|
func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler {
|
|
d := discoveryDocument{
|
|
Issuer: cfg.Issuer,
|
|
AuthorizationEndpoint: cfg.AuthorizationEndpoint,
|
|
TokenEndpoint: cfg.TokenEndpoint,
|
|
JWKSUri: cfg.JWKSUri,
|
|
UserinfoEndpoint: cfg.UserinfoEndpoint,
|
|
EndSessionEndpoint: cfg.EndSessionEndpoint,
|
|
|
|
// Profile-locked values — not negotiable.
|
|
ResponseTypesSupported: []string{"code"},
|
|
GrantTypesSupported: []string{"authorization_code", "client_credentials"},
|
|
CodeChallengeMethodsSupported: []string{"S256"},
|
|
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
|
ScopesSupported: supportedScopes(cfg.Clients),
|
|
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "none"},
|
|
// Core profile claims are emitted on every token, so omitting one here
|
|
// is a contract defect rather than missing optional metadata. The
|
|
// scope-gated human claims and the optional cached tenant_roles claim
|
|
// follow them.
|
|
ClaimsSupported: []string{
|
|
"sub", "iss", "aud", "exp", "iat",
|
|
"tenant", "tenant_source", "principal_type", "roles", "groups", "assurance", "scope",
|
|
"nonce", "preferred_username", "email", "name", "tenant_roles",
|
|
},
|
|
SubjectTypesSupported: []string{"public"},
|
|
RequestParameterSupported: false,
|
|
ClaimsParameterSupported: false,
|
|
}
|
|
if cfg.IntrospectionEndpoint != "" {
|
|
d.IntrospectionEndpoint = cfg.IntrospectionEndpoint
|
|
d.IntrospectionEndpointAuthMethodsSupported = []string{"client_secret_basic"}
|
|
d.ClaimsSupported = append(d.ClaimsSupported, "jti")
|
|
}
|
|
if cfg.RevocationEndpoint != "" {
|
|
d.RevocationEndpoint = cfg.RevocationEndpoint
|
|
d.RevocationEndpointAuthMethodsSupported = []string{"client_secret_basic", "none"}
|
|
}
|
|
|
|
b, err := json.Marshal(d)
|
|
if err != nil {
|
|
// This can only fail if the struct contains un-marshallable types, which it does not.
|
|
panic("oidc: failed to marshal discovery document: " + err.Error())
|
|
}
|
|
return &discoveryHandler{doc: b}
|
|
}
|
|
|
|
func (h *discoveryHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "max-age=3600")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(h.doc)
|
|
}
|
|
|
|
// supportedScopes returns the baseline OIDC scopes plus every scope any
|
|
// registered client is allowed to request, sorted for a stable document. The
|
|
// baseline is advertised even with no clients configured, since it describes the
|
|
// profile rather than a particular deployment.
|
|
func supportedScopes(clients map[string]*domain.Client) []string {
|
|
seen := map[string]bool{}
|
|
scopes := []string{}
|
|
for _, scope := range []string{"openid", "profile", "email", "groups"} {
|
|
seen[scope] = true
|
|
scopes = append(scopes, scope)
|
|
}
|
|
extra := []string{}
|
|
for _, client := range clients {
|
|
for _, scope := range client.AllowedScopes {
|
|
if scope == "" || seen[scope] {
|
|
continue
|
|
}
|
|
seen[scope] = true
|
|
extra = append(extra, scope)
|
|
}
|
|
}
|
|
sort.Strings(extra)
|
|
return append(scopes, extra...)
|
|
}
|