package securityevents import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/tls" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math/big" "net/http" "net/url" "strings" "sync" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) const ( SessionRevokedEventType = "https://schemas.openid.net/secevent/caep/event-type/session-revoked" VerificationEventType = "https://schemas.openid.net/secevent/ssf/event-type/verification" StreamUpdatedEventType = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated" ) type Event struct { Issuer string Audience string JTI string TransactionID string IssuedAt time.Time EventType string EventTimestamp time.Time SubjectIssuer string ApplicationID string SubjectType string Subject string TenantID string InitiatingEntity string StreamID string State string StreamStatus string Reason string } type VerifierConfig struct { TransmitterIssuer string Audience string SubjectIssuer string TenantID string ApplicationID string TenantMode string StreamID string ClockSkew time.Duration HTTPClient *http.Client } type Verifier struct { config VerifierConfig client *http.Client mu sync.Mutex keys map[string]*ecdsa.PublicKey expiresAt time.Time metrics *Metrics clock func() time.Time } func (v *Verifier) SetMetrics(metrics *Metrics) { v.metrics = metrics } type transmitterMetadata struct { Issuer string `json:"issuer"` JWKSURI string `json:"jwks_uri"` } type jwkSet struct { Keys []struct { KID string `json:"kid"` KTY string `json:"kty"` Use string `json:"use"` Alg string `json:"alg"` Crv string `json:"crv"` X string `json:"x"` Y string `json:"y"` } `json:"keys"` } type protocolError struct { Code string } func (e *protocolError) Error() string { return e.Code } func NewVerifier(config VerifierConfig) (*Verifier, error) { config.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(config.TransmitterIssuer), "/") config.SubjectIssuer = strings.TrimRight(strings.TrimSpace(config.SubjectIssuer), "/") multiTenant := config.TenantMode == "multi_tenant" validBinding := multiTenant && uuid.Validate(config.ApplicationID) == nil || !multiTenant && uuid.Validate(config.TenantID) == nil if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || !validBinding { return nil, errors.New("SSF transmitter, audience, subject issuer, and identity binding are required") } if _, err := uuid.Parse(config.StreamID); err != nil { return nil, errors.New("SSF stream id must be a UUID") } if config.ClockSkew < 0 || config.ClockSkew > 5*time.Minute { return nil, errors.New("SSF clock skew is invalid") } client := config.HTTPClient if client == nil { transport := http.DefaultTransport.(*http.Transport).Clone() transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} transport.DisableCompression = true client = &http.Client{Timeout: 10 * time.Second, Transport: transport, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} } return &Verifier{config: config, client: client, keys: make(map[string]*ecdsa.PublicKey), clock: time.Now}, nil } func (v *Verifier) Verify(ctx context.Context, raw string) (Event, error) { parser := jwt.NewParser(jwt.WithValidMethods([]string{"ES256"})) unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{}) if err != nil || unverified == nil || unverified.Header["typ"] != "secevent+jwt" || unverified.Header["alg"] != "ES256" { return Event{}, &protocolError{Code: "invalid_request"} } unverifiedClaims, ok := unverified.Claims.(jwt.MapClaims) if !ok { return Event{}, &protocolError{Code: "invalid_request"} } if stringClaim(unverifiedClaims, "iss") != v.config.TransmitterIssuer { return Event{}, &protocolError{Code: "invalid_issuer"} } if !claimHasAudience(unverifiedClaims["aud"], v.config.Audience) { return Event{}, &protocolError{Code: "invalid_audience"} } kid, _ := unverified.Header["kid"].(string) if kid == "" { return Event{}, &protocolError{Code: "invalid_key"} } key, err := v.key(ctx, kid) if err != nil { return Event{}, &protocolError{Code: "invalid_key"} } token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) { return key, nil }, jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(v.config.TransmitterIssuer), jwt.WithAudience(v.config.Audience), jwt.WithIssuedAt(), jwt.WithLeeway(v.config.ClockSkew)) if err != nil || !token.Valid { if errors.Is(err, jwt.ErrTokenSignatureInvalid) { return Event{}, &protocolError{Code: "invalid_key"} } return Event{}, &protocolError{Code: "invalid_request"} } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return Event{}, &protocolError{Code: "invalid_request"} } if _, present := claims["sub"]; present { return Event{}, &protocolError{Code: "invalid_request"} } if _, present := claims["exp"]; present { return Event{}, &protocolError{Code: "invalid_request"} } jti := stringClaim(claims, "jti") if _, err := uuid.Parse(jti); err != nil { return Event{}, &protocolError{Code: "invalid_request"} } issuedAt, err := claims.GetIssuedAt() if err != nil || issuedAt == nil { return Event{}, &protocolError{Code: "invalid_request"} } events, ok := claims["events"].(map[string]any) if !ok || len(events) != 1 { return Event{}, &protocolError{Code: "invalid_request"} } event := Event{Issuer: v.config.TransmitterIssuer, Audience: v.config.Audience, JTI: jti, TransactionID: stringClaim(claims, "txn"), IssuedAt: issuedAt.Time} if payload, present := events[SessionRevokedEventType]; present { value, ok := payload.(map[string]any) if !ok || event.TransactionID == "" { return Event{}, &protocolError{Code: "invalid_request"} } if _, err := uuid.Parse(event.TransactionID); err != nil { return Event{}, &protocolError{Code: "invalid_request"} } event.EventType, event.EventTimestamp, event.InitiatingEntity = SessionRevokedEventType, unixClaim(value["event_timestamp"]), stringValue(value["initiating_entity"]) event.ApplicationID, event.SubjectType = stringValue(value["application_id"]), stringValue(value["subject_type"]) if event.EventTimestamp.IsZero() || event.EventTimestamp.After(v.now().Add(v.config.ClockSkew)) || !validInitiator(event.InitiatingEntity) || !v.readSessionSubject(claims["sub_id"], &event) { return Event{}, &protocolError{Code: "invalid_request"} } return event, nil } if payload, present := events[VerificationEventType]; present { value, ok := payload.(map[string]any) if !ok || !v.readStreamSubject(claims["sub_id"], &event) { return Event{}, &protocolError{Code: "invalid_request"} } event.EventType = VerificationEventType if rawState, present := value["state"]; present { var valid bool event.State, valid = rawState.(string) if !valid || len(event.State) > 1024 { return Event{}, &protocolError{Code: "invalid_request"} } } if len(event.State) > 1024 { return Event{}, &protocolError{Code: "invalid_request"} } return event, nil } if payload, present := events[StreamUpdatedEventType]; present { value, ok := payload.(map[string]any) if !ok || !v.readStreamSubject(claims["sub_id"], &event) { return Event{}, &protocolError{Code: "invalid_request"} } event.EventType = StreamUpdatedEventType event.StreamStatus, event.Reason = stringValue(value["status"]), stringValue(value["reason"]) if event.StreamStatus != "enabled" && event.StreamStatus != "paused" && event.StreamStatus != "disabled" { return Event{}, &protocolError{Code: "invalid_request"} } if len(event.Reason) > 1024 { return Event{}, &protocolError{Code: "invalid_request"} } return event, nil } return Event{}, &protocolError{Code: "invalid_request"} } func claimHasAudience(raw any, expected string) bool { switch value := raw.(type) { case string: return value == expected case []string: for _, audience := range value { if audience == expected { return true } } case []any: for _, item := range value { if audience, ok := item.(string); ok && audience == expected { return true } } } return false } func (v *Verifier) now() time.Time { if v.clock != nil { return v.clock().UTC() } return time.Now().UTC() } func (v *Verifier) readSessionSubject(raw any, event *Event) bool { subject, ok := raw.(map[string]any) if !ok || stringValue(subject["format"]) != "complex" { return false } user, userOK := subject["user"].(map[string]any) tenant, tenantOK := subject["tenant"].(map[string]any) if !userOK || !tenantOK || stringValue(user["format"]) != "iss_sub" || stringValue(tenant["format"]) != "opaque" { return false } event.SubjectIssuer, event.Subject, event.TenantID = strings.TrimRight(stringValue(user["iss"]), "/"), stringValue(user["sub"]), stringValue(tenant["id"]) if event.SubjectIssuer != v.config.SubjectIssuer || uuid.Validate(event.TenantID) != nil { return false } if v.config.TenantMode == "multi_tenant" { if event.ApplicationID != v.config.ApplicationID || event.SubjectType != "principal" && event.SubjectType != "tenant" || event.SubjectType == "tenant" && event.Subject != event.TenantID { return false } } else if event.ApplicationID != "" || event.SubjectType != "" || event.TenantID != v.config.TenantID { return false } return uuid.Validate(event.Subject) == nil } func (v *Verifier) readStreamSubject(raw any, event *Event) bool { subject, ok := raw.(map[string]any) if !ok || stringValue(subject["format"]) != "opaque" || stringValue(subject["id"]) != v.config.StreamID { return false } event.StreamID = v.config.StreamID return true } func (v *Verifier) key(ctx context.Context, kid string) (*ecdsa.PublicKey, error) { v.mu.Lock() if key := v.keys[kid]; key != nil && time.Now().Before(v.expiresAt) { v.mu.Unlock() return key, nil } v.mu.Unlock() if err := v.refresh(ctx); err != nil { if v.metrics != nil { v.metrics.ObserveJWKSRefreshFailure("ssf") } return nil, err } v.mu.Lock() defer v.mu.Unlock() key := v.keys[kid] if key == nil { return nil, errors.New("SSF signing key is unavailable") } return key, nil } func (v *Verifier) refresh(ctx context.Context) error { v.mu.Lock() defer v.mu.Unlock() configurationURL, err := ssfConfigurationURL(v.config.TransmitterIssuer) if err != nil { return err } var metadata transmitterMetadata if err := v.fetchJSON(ctx, configurationURL, &metadata); err != nil { return err } if strings.TrimRight(metadata.Issuer, "/") != v.config.TransmitterIssuer || !sameOrigin(metadata.JWKSURI, v.config.TransmitterIssuer) { return errors.New("SSF metadata is not bound to configured issuer") } var set jwkSet if err := v.fetchJSON(ctx, metadata.JWKSURI, &set); err != nil { return err } keys := make(map[string]*ecdsa.PublicKey) for _, item := range set.Keys { if item.KID == "" || item.KTY != "EC" || item.Alg != "ES256" || item.Crv != "P-256" || item.Use != "sig" { continue } x, xErr := decodeCoordinate(item.X) y, yErr := decodeCoordinate(item.Y) if xErr == nil && yErr == nil && elliptic.P256().IsOnCurve(x, y) { keys[item.KID] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} } } if len(keys) == 0 { return errors.New("SSF JWKS contains no ES256 keys") } v.keys, v.expiresAt = keys, time.Now().Add(5*time.Minute) return nil } func (v *Verifier) fetchJSON(ctx context.Context, endpoint string, target any) error { request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err } request.Header.Set("Accept", "application/json") response, err := v.client.Do(request) if err != nil { return err } defer response.Body.Close() if response.StatusCode != http.StatusOK { return fmt.Errorf("SSF endpoint returned HTTP %d", response.StatusCode) } payload, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) if err != nil || len(payload) > 1<<20 { return errors.New("SSF endpoint response is too large") } if err := json.Unmarshal(payload, target); err != nil { return errors.New("SSF endpoint returned invalid JSON") } return nil } func ssfConfigurationURL(issuer string) (string, error) { parsed, err := url.Parse(issuer) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", errors.New("SSF issuer is invalid") } path := strings.TrimSuffix(parsed.EscapedPath(), "/") parsed.Path, parsed.RawPath = "/.well-known/ssf-configuration"+path, "" parsed.RawQuery, parsed.Fragment = "", "" return parsed.String(), nil } func sameOrigin(candidate, issuer string) bool { candidateURL, candidateErr := url.Parse(candidate) issuerURL, issuerErr := url.Parse(issuer) return candidateErr == nil && issuerErr == nil && candidateURL.Scheme == issuerURL.Scheme && strings.EqualFold(candidateURL.Host, issuerURL.Host) && candidateURL.User == nil && candidateURL.Fragment == "" } func absoluteHTTPURL(raw string) bool { parsed, err := url.Parse(raw) return err == nil && parsed.IsAbs() && parsed.Hostname() != "" && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.User == nil && parsed.Fragment == "" } func decodeCoordinate(raw string) (*big.Int, error) { value, err := base64.RawURLEncoding.DecodeString(raw) if err != nil || len(value) != 32 { return nil, errors.New("invalid EC coordinate") } return new(big.Int).SetBytes(value), nil } func stringClaim(claims jwt.MapClaims, name string) string { return stringValue(claims[name]) } func stringValue(value any) string { result, _ := value.(string) return result } func unixClaim(value any) time.Time { switch typed := value.(type) { case float64: return time.Unix(int64(typed), 0).UTC() case json.Number: integer, err := typed.Int64() if err == nil { return time.Unix(integer, 0).UTC() } } return time.Time{} } func validInitiator(value string) bool { return value == "admin" || value == "user" || value == "policy" || value == "system" }