feat: enforce OIDC session introspection
This commit is contained in:
@@ -32,6 +32,9 @@ OIDC_ROLE_PREFIX=gateway.
|
|||||||
OIDC_REQUIRED_SCOPES=gateway.access
|
OIDC_REQUIRED_SCOPES=gateway.access
|
||||||
OIDC_JWKS_CACHE_TTL_SECONDS=300
|
OIDC_JWKS_CACHE_TTL_SECONDS=300
|
||||||
OIDC_ACCEPT_LEGACY_HS256=true
|
OIDC_ACCEPT_LEGACY_HS256=true
|
||||||
|
OIDC_INTROSPECTION_ENABLED=false
|
||||||
|
OIDC_INTROSPECTION_CLIENT_ID=
|
||||||
|
OIDC_INTROSPECTION_CLIENT_SECRET=
|
||||||
OIDC_CLIENT_ID=
|
OIDC_CLIENT_ID=
|
||||||
OIDC_REDIRECT_URI=http://localhost:5178/auth/callback
|
OIDC_REDIRECT_URI=http://localhost:5178/auth/callback
|
||||||
AI_GATEWAY_WEB_BASE_PATH=/
|
AI_GATEWAY_WEB_BASE_PATH=/
|
||||||
|
|||||||
@@ -23,26 +23,31 @@ import (
|
|||||||
const maxOIDCResponseBytes = 1 << 20
|
const maxOIDCResponseBytes = 1 << 20
|
||||||
|
|
||||||
type OIDCConfig struct {
|
type OIDCConfig struct {
|
||||||
Issuer string
|
Issuer string
|
||||||
Audience string
|
Audience string
|
||||||
TenantID string
|
TenantID string
|
||||||
RolePrefix string
|
RolePrefix string
|
||||||
RequiredScopes []string
|
RequiredScopes []string
|
||||||
JWKSCacheTTL time.Duration
|
JWKSCacheTTL time.Duration
|
||||||
HTTPClient *http.Client
|
IntrospectionEnabled bool
|
||||||
|
IntrospectionClientID string
|
||||||
|
IntrospectionClientSecret string
|
||||||
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
type OIDCVerifier struct {
|
type OIDCVerifier struct {
|
||||||
config OIDCConfig
|
config OIDCConfig
|
||||||
client *http.Client
|
client *http.Client
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
keys map[string]any
|
keys map[string]any
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
|
introspectionEndpoint string
|
||||||
}
|
}
|
||||||
|
|
||||||
type discoveryDocument struct {
|
type discoveryDocument struct {
|
||||||
Issuer string `json:"issuer"`
|
Issuer string `json:"issuer"`
|
||||||
JWKSURI string `json:"jwks_uri"`
|
JWKSURI string `json:"jwks_uri"`
|
||||||
|
IntrospectionEndpoint string `json:"introspection_endpoint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsonWebKeySet struct {
|
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 == "" {
|
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")
|
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 {
|
if config.JWKSCacheTTL <= 0 {
|
||||||
config.JWKSCacheTTL = 5 * time.Minute
|
config.JWKSCacheTTL = 5 * time.Minute
|
||||||
}
|
}
|
||||||
@@ -123,6 +131,12 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
|||||||
if len(roles) == 0 {
|
if len(roles) == 0 {
|
||||||
return nil, oidcUnauthorized("mapped role is missing", nil)
|
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")
|
username := stringClaim(claims, "preferred_username")
|
||||||
if username == "" {
|
if username == "" {
|
||||||
username = stringClaim(claims, "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 {
|
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil {
|
||||||
return errors.New("OIDC discovery metadata is invalid")
|
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
|
var set jsonWebKeySet
|
||||||
if err := v.fetchJSON(ctx, discovery.JWKSURI, &set); err != nil {
|
if err := v.fetchJSON(ctx, discovery.JWKSURI, &set); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -195,9 +212,44 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
|
|||||||
}
|
}
|
||||||
v.keys = keys
|
v.keys = keys
|
||||||
v.expiresAt = time.Now().Add(v.config.JWKSCacheTTL)
|
v.expiresAt = time.Now().Add(v.config.JWKSCacheTTL)
|
||||||
|
v.introspectionEndpoint = discovery.IntrospectionEndpoint
|
||||||
return nil
|
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 {
|
func (v *OIDCVerifier) fetchJSON(ctx context.Context, endpoint string, output any) error {
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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 {
|
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ type Config struct {
|
|||||||
OIDCRequiredScopes []string
|
OIDCRequiredScopes []string
|
||||||
OIDCJWKSCacheTTLSeconds int
|
OIDCJWKSCacheTTLSeconds int
|
||||||
OIDCAcceptLegacyHS256 bool
|
OIDCAcceptLegacyHS256 bool
|
||||||
|
OIDCIntrospectionEnabled bool
|
||||||
|
OIDCIntrospectionClientID string
|
||||||
|
OIDCIntrospectionClientSecret string
|
||||||
PublicBaseURL string
|
PublicBaseURL string
|
||||||
WebBaseURL string
|
WebBaseURL string
|
||||||
LocalGeneratedStorageDir string
|
LocalGeneratedStorageDir string
|
||||||
@@ -58,23 +61,26 @@ func Load() Config {
|
|||||||
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
|
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
|
||||||
"/",
|
"/",
|
||||||
),
|
),
|
||||||
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
||||||
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
|
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
|
||||||
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
|
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
|
||||||
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
|
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
|
||||||
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
|
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
|
||||||
OIDCAudience: env("OIDC_AUDIENCE", ""),
|
OIDCAudience: env("OIDC_AUDIENCE", ""),
|
||||||
OIDCTenantID: env("OIDC_TENANT_ID", ""),
|
OIDCTenantID: env("OIDC_TENANT_ID", ""),
|
||||||
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
|
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
|
||||||
OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")),
|
OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")),
|
||||||
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
|
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
|
||||||
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
|
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
|
||||||
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
|
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
|
||||||
WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"),
|
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
|
||||||
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))),
|
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
|
||||||
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)),
|
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
|
||||||
LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24),
|
WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"),
|
||||||
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
|
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",
|
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
|
||||||
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
|
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
|
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
|
||||||
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
|
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
|
||||||
RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes,
|
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 {
|
if err != nil {
|
||||||
panic("invalid OIDC configuration: " + err.Error())
|
panic("invalid OIDC configuration: " + err.Error())
|
||||||
|
|||||||
Reference in New Issue
Block a user