From f8d766b916f5c0a670debb9d18f376c7e625d9f4 Mon Sep 17 00:00:00 2001 From: wangbo Date: Sun, 12 Jul 2026 05:44:37 +0800 Subject: [PATCH] feat: enforce OIDC session introspection --- .env.example | 3 ++ apps/api/internal/auth/oidc.go | 80 ++++++++++++++++++++++++----- apps/api/internal/auth/oidc_test.go | 49 ++++++++++++++++++ apps/api/internal/config/config.go | 40 +++++++++------ apps/api/internal/httpapi/server.go | 5 +- 5 files changed, 145 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index add35ad..156951a 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,9 @@ OIDC_ROLE_PREFIX=gateway. OIDC_REQUIRED_SCOPES=gateway.access OIDC_JWKS_CACHE_TTL_SECONDS=300 OIDC_ACCEPT_LEGACY_HS256=true +OIDC_INTROSPECTION_ENABLED=false +OIDC_INTROSPECTION_CLIENT_ID= +OIDC_INTROSPECTION_CLIENT_SECRET= OIDC_CLIENT_ID= OIDC_REDIRECT_URI=http://localhost:5178/auth/callback AI_GATEWAY_WEB_BASE_PATH=/ diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index 2ccee16..9a1f1e0 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -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 { diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index d218921..ad625bf 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -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() diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 23e14b2..c8429a5 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -31,6 +31,9 @@ type Config struct { OIDCRequiredScopes []string OIDCJWKSCacheTTLSeconds int OIDCAcceptLegacyHS256 bool + OIDCIntrospectionEnabled bool + OIDCIntrospectionClientID string + OIDCIntrospectionClientSecret string PublicBaseURL string WebBaseURL string LocalGeneratedStorageDir string @@ -58,23 +61,26 @@ func Load() Config { env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/", ), - ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), - ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), - ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), - OIDCEnabled: env("OIDC_ENABLED", "false") == "true", - OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"), - OIDCAudience: env("OIDC_AUDIENCE", ""), - OIDCTenantID: env("OIDC_TENANT_ID", ""), - OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."), - OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")), - OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300), - OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true", - PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), - WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"), - LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), - LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)), - LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24), - TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", + ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), + ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), + ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), + OIDCEnabled: env("OIDC_ENABLED", "false") == "true", + OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"), + OIDCAudience: env("OIDC_AUDIENCE", ""), + OIDCTenantID: env("OIDC_TENANT_ID", ""), + OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."), + OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")), + OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300), + OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true", + OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true", + OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""), + OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""), + PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), + WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"), + LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), + LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)), + LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24), + TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL", strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks", ), diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 5e8b234..6d679f0 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -44,7 +44,10 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID, RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes, - JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, + JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, + IntrospectionEnabled: cfg.OIDCIntrospectionEnabled, + IntrospectionClientID: cfg.OIDCIntrospectionClientID, + IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret, }) if err != nil { panic("invalid OIDC configuration: " + err.Error())