feat: enforce OIDC session introspection
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user