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 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 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), "/") if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || config.TenantID == "" { return nil, errors.New("SSF transmitter, audience, subject issuer, and tenant 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"]) 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 || event.TenantID != v.config.TenantID { return false } _, err := uuid.Parse(event.Subject) return err == 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" }