feat: enforce OIDC session introspection

This commit is contained in:
2026-07-12 05:44:37 +08:00
parent b9c0e1a7a5
commit f8d766b916
5 changed files with 145 additions and 32 deletions
+66 -14
View File
@@ -23,26 +23,31 @@ import (
const maxOIDCResponseBytes = 1 << 20
type OIDCConfig struct {
Issuer string
Audience string
TenantID string
RolePrefix string
RequiredScopes []string
JWKSCacheTTL time.Duration
HTTPClient *http.Client
Issuer string
Audience string
TenantID string
RolePrefix string
RequiredScopes []string
JWKSCacheTTL time.Duration
IntrospectionEnabled bool
IntrospectionClientID string
IntrospectionClientSecret string
HTTPClient *http.Client
}
type OIDCVerifier struct {
config OIDCConfig
client *http.Client
mu sync.Mutex
keys map[string]any
expiresAt time.Time
config OIDCConfig
client *http.Client
mu sync.Mutex
keys map[string]any
expiresAt time.Time
introspectionEndpoint string
}
type discoveryDocument struct {
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
IntrospectionEndpoint string `json:"introspection_endpoint"`
}
type jsonWebKeySet struct {
@@ -69,6 +74,9 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
return nil, errors.New("issuer, audience, tenant and role prefix are required")
}
if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
return nil, errors.New("introspection client credentials are required when introspection is enabled")
}
if config.JWKSCacheTTL <= 0 {
config.JWKSCacheTTL = 5 * time.Minute
}
@@ -123,6 +131,12 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if len(roles) == 0 {
return nil, oidcUnauthorized("mapped role is missing", nil)
}
if v.config.IntrospectionEnabled {
active, err := v.introspect(ctx, raw)
if err != nil || !active {
return nil, oidcUnauthorized("token is inactive", err)
}
}
username := stringClaim(claims, "preferred_username")
if username == "" {
username = stringClaim(claims, "username")
@@ -176,6 +190,9 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil {
return errors.New("OIDC discovery metadata is invalid")
}
if v.config.IntrospectionEnabled && validatePublicURL(discovery.IntrospectionEndpoint) != nil {
return errors.New("OIDC introspection metadata is invalid")
}
var set jsonWebKeySet
if err := v.fetchJSON(ctx, discovery.JWKSURI, &set); err != nil {
return err
@@ -195,9 +212,44 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
}
v.keys = keys
v.expiresAt = time.Now().Add(v.config.JWKSCacheTTL)
v.introspectionEndpoint = discovery.IntrospectionEndpoint
return nil
}
func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error) {
v.mu.Lock()
endpoint := v.introspectionEndpoint
v.mu.Unlock()
if endpoint == "" {
return false, errors.New("OIDC introspection endpoint is unavailable")
}
form := url.Values{"token": {raw}, "token_type_hint": {"access_token"}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return false, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.SetBasicAuth(v.config.IntrospectionClientID, v.config.IntrospectionClientSecret)
response, err := v.client.Do(request)
if err != nil {
return false, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOIDCResponseBytes))
return false, fmt.Errorf("OIDC introspection returned HTTP %d", response.StatusCode)
}
var result struct {
Active bool `json:"active"`
}
decoder := json.NewDecoder(io.LimitReader(response.Body, maxOIDCResponseBytes))
if err := decoder.Decode(&result); err != nil {
return false, errors.New("OIDC introspection response is invalid")
}
return result.Active, nil
}
func (v *OIDCVerifier) fetchJSON(ctx context.Context, endpoint string, output any) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
+49
View File
@@ -104,6 +104,55 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
}
}
func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
active := true
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer, "jwks_uri": issuer + "/jwks",
"introspection_endpoint": issuer + "/introspect",
})
case "/jwks":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{rsaJWK("rsa-key", &key.PublicKey)}})
case "/introspect":
clientID, clientSecret, ok := request.BasicAuth()
if !ok || clientID != "gateway-api" || clientSecret != "introspection-secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if err := request.ParseForm(); err != nil || request.Form.Get("token") == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"active": active})
default:
http.NotFound(w, request)
}
}))
defer server.Close()
issuer = server.URL
verifier, err := NewOIDCVerifier(OIDCConfig{
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
IntrospectionEnabled: true, IntrospectionClientID: "gateway-api",
IntrospectionClientSecret: "introspection-secret",
})
if err != nil {
t.Fatal(err)
}
raw := signedOIDCToken(t, issuer, "rsa-key", jwt.SigningMethodRS256, key, nil)
if _, err := verifier.Verify(context.Background(), raw); err != nil {
t.Fatalf("active token rejected: %v", err)
}
active = false
if _, err := verifier.Verify(context.Background(), raw); err == nil {
t.Fatal("inactive token was accepted")
}
}
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {
t.Helper()
now := time.Now()