omniauth_openid_federation 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.md +22 -0
  4. data/README.md +822 -0
  5. data/SECURITY.md +129 -0
  6. data/examples/README_INTEGRATION_TESTING.md +399 -0
  7. data/examples/README_MOCK_OP.md +243 -0
  8. data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
  9. data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
  10. data/examples/app/models/user.rb.example +39 -0
  11. data/examples/config/initializers/devise.rb.example +97 -0
  12. data/examples/config/initializers/federation_endpoint.rb.example +206 -0
  13. data/examples/config/mock_op.yml.example +83 -0
  14. data/examples/config/open_id_connect_config.rb.example +210 -0
  15. data/examples/config/routes.rb.example +12 -0
  16. data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
  17. data/examples/integration_test_flow.rb +1334 -0
  18. data/examples/jobs/README.md +194 -0
  19. data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
  20. data/examples/jobs/federation_files_generation_job.rb.example +87 -0
  21. data/examples/mock_op_server.rb +775 -0
  22. data/examples/mock_rp_server.rb +435 -0
  23. data/lib/omniauth_openid_federation/access_token.rb +504 -0
  24. data/lib/omniauth_openid_federation/cache.rb +39 -0
  25. data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
  26. data/lib/omniauth_openid_federation/configuration.rb +135 -0
  27. data/lib/omniauth_openid_federation/constants.rb +13 -0
  28. data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
  29. data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
  30. data/lib/omniauth_openid_federation/errors.rb +52 -0
  31. data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
  32. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
  33. data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
  34. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
  35. data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
  36. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
  37. data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
  38. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
  39. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
  40. data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
  41. data/lib/omniauth_openid_federation/http_client.rb +70 -0
  42. data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
  43. data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
  44. data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
  45. data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
  46. data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
  47. data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
  48. data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
  49. data/lib/omniauth_openid_federation/jws.rb +416 -0
  50. data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
  51. data/lib/omniauth_openid_federation/logger.rb +99 -0
  52. data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
  53. data/lib/omniauth_openid_federation/railtie.rb +29 -0
  54. data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
  55. data/lib/omniauth_openid_federation/strategy.rb +2029 -0
  56. data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
  57. data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
  58. data/lib/omniauth_openid_federation/utils.rb +166 -0
  59. data/lib/omniauth_openid_federation/validators.rb +126 -0
  60. data/lib/omniauth_openid_federation/version.rb +3 -0
  61. data/lib/omniauth_openid_federation.rb +98 -0
  62. data/lib/tasks/omniauth_openid_federation.rake +376 -0
  63. data/sig/federation.rbs +218 -0
  64. data/sig/jwks.rbs +63 -0
  65. data/sig/omniauth_openid_federation.rbs +254 -0
  66. data/sig/strategy.rbs +60 -0
  67. metadata +352 -0
@@ -0,0 +1,87 @@
1
+ require_relative "../utils"
2
+ require_relative "../logger"
3
+ require_relative "../errors"
4
+ require_relative "../configuration"
5
+ require_relative "entity_statement"
6
+
7
+ # Helper methods for entity statement operations
8
+ module OmniauthOpenidFederation
9
+ module Federation
10
+ module EntityStatementHelper
11
+ # Parse entity statement and extract signed_jwks_uri and entity_jwks
12
+ # This is a common operation used in multiple places
13
+ #
14
+ # @param entity_statement_path [String] Path to entity statement file
15
+ # @return [Hash] Hash with :signed_jwks_uri and :entity_jwks keys, or nil if not found
16
+ # @raise [SecurityError] If path validation fails
17
+ # @raise [ValidationError] If parsing fails
18
+ def self.parse_for_signed_jwks(entity_statement_path)
19
+ # Determine allowed directories for file path validation
20
+ config = Configuration.config
21
+ allowed_dirs = if defined?(Rails) && Rails.root
22
+ [Rails.root.join("config").to_s]
23
+ elsif config.root_path
24
+ [File.join(config.root_path, "config")]
25
+ end
26
+
27
+ # Validate file path to prevent path traversal
28
+ validated_path = Utils.validate_file_path!(
29
+ entity_statement_path,
30
+ allowed_dirs: allowed_dirs
31
+ )
32
+
33
+ unless File.exist?(validated_path)
34
+ sanitized_path = Utils.sanitize_path(validated_path)
35
+ OmniauthOpenidFederation::Logger.warn("[EntityStatementHelper] Entity statement file not found: #{sanitized_path}")
36
+ return nil
37
+ end
38
+
39
+ begin
40
+ entity_statement_content = File.read(validated_path)
41
+ entity_statement = EntityStatement.new(entity_statement_content)
42
+ metadata = entity_statement.parse
43
+
44
+ signed_jwks_uri = metadata.dig(:metadata, :openid_provider, :signed_jwks_uri)
45
+ entity_jwks = metadata[:jwks]
46
+
47
+ {
48
+ signed_jwks_uri: signed_jwks_uri,
49
+ entity_jwks: entity_jwks,
50
+ metadata: metadata
51
+ }
52
+ rescue => e
53
+ sanitized_path = Utils.sanitize_path(validated_path)
54
+ OmniauthOpenidFederation::Logger.error("[EntityStatementHelper] Failed to parse entity statement from #{sanitized_path}: #{e.class} - #{e.message}")
55
+ raise ValidationError, "Failed to parse entity statement: #{e.message}", e.backtrace
56
+ end
57
+ end
58
+
59
+ # Parse entity statement from content string (JWT)
60
+ # This is used when entity statement is fetched from URL and cached in memory
61
+ #
62
+ # @param entity_statement_content [String] Entity statement JWT string
63
+ # @return [Hash] Hash with :signed_jwks_uri and :entity_jwks keys, or nil if not found
64
+ # @raise [ValidationError] If parsing fails
65
+ def self.parse_for_signed_jwks_from_content(entity_statement_content)
66
+ return nil unless entity_statement_content&.is_a?(String)
67
+
68
+ begin
69
+ entity_statement = EntityStatement.new(entity_statement_content)
70
+ metadata = entity_statement.parse
71
+
72
+ signed_jwks_uri = metadata.dig(:metadata, :openid_provider, :signed_jwks_uri)
73
+ entity_jwks = metadata[:jwks]
74
+
75
+ {
76
+ signed_jwks_uri: signed_jwks_uri,
77
+ entity_jwks: entity_jwks,
78
+ metadata: metadata
79
+ }
80
+ rescue => e
81
+ OmniauthOpenidFederation::Logger.error("[EntityStatementHelper] Failed to parse entity statement from content: #{e.class} - #{e.message}")
82
+ raise ValidationError, "Failed to parse entity statement: #{e.message}", e.backtrace
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,198 @@
1
+ require "jwt"
2
+ require "base64"
3
+ require_relative "../key_extractor"
4
+ require_relative "../logger"
5
+ require_relative "../errors"
6
+ require_relative "entity_statement_validator"
7
+
8
+ # Entity Statement Parser for OpenID Federation 1.0
9
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
10
+ # @see https://openid.net/specs/openid-federation-1_0.html#section-3 Section 3: Entity Statement
11
+ #
12
+ # Parses entity statement JWTs and extracts:
13
+ # - Header information (algorithm, key ID)
14
+ # - Claims (issuer, subject, expiration, issued at)
15
+ # - JWKS for signature validation
16
+ # - Provider metadata (endpoints, configuration)
17
+ #
18
+ # Supports optional signature validation using keys from the entity statement's own JWKS
19
+ # (self-signed entity statements).
20
+ module OmniauthOpenidFederation
21
+ module Federation
22
+ # Entity Statement Parser for OpenID Federation 1.0
23
+ #
24
+ # @example Parse an entity statement
25
+ # parser = EntityStatementParser.new(jwt_string, validate_signature: true)
26
+ # metadata = parser.parse
27
+ class EntityStatementParser
28
+ # Compatibility alias for backward compatibility
29
+ ParseError = OmniauthOpenidFederation::ValidationError
30
+ # Standard JWT has 3 parts: header.payload.signature
31
+ JWT_PARTS_COUNT = 3
32
+
33
+ # Parse entity statement JWT
34
+ #
35
+ # @param jwt_string [String] The JWT string to parse
36
+ # @param validate_signature [Boolean] Whether to validate the signature (default: false)
37
+ # @param validate_full [Boolean] Whether to perform full OpenID Federation validation (default: true)
38
+ # @param issuer_entity_configuration [Hash, EntityStatement, nil] Optional: Issuer's Entity Configuration for Subordinate Statement validation
39
+ # @return [Hash] Parsed entity statement with header, claims, and metadata
40
+ # @raise [ParseError] If parsing fails
41
+ def self.parse(jwt_string, validate_signature: false, validate_full: true, issuer_entity_configuration: nil)
42
+ new(jwt_string, validate_signature: validate_signature, validate_full: validate_full, issuer_entity_configuration: issuer_entity_configuration).parse
43
+ end
44
+
45
+ # Initialize parser
46
+ #
47
+ # @param jwt_string [String] The JWT string to parse
48
+ # @param validate_signature [Boolean] Whether to validate the signature
49
+ # @param validate_full [Boolean] Whether to perform full OpenID Federation validation
50
+ # @param issuer_entity_configuration [Hash, EntityStatement, nil] Optional: Issuer's Entity Configuration
51
+ def initialize(jwt_string, validate_signature: false, validate_full: true, issuer_entity_configuration: nil)
52
+ @jwt_string = jwt_string
53
+ @validate_signature = validate_signature
54
+ @validate_full = validate_full
55
+ @issuer_entity_configuration = issuer_entity_configuration
56
+ end
57
+
58
+ # Parse the entity statement
59
+ #
60
+ # @return [Hash] Parsed entity statement with header, claims, and metadata
61
+ # @raise [ParseError] If parsing fails
62
+ def parse
63
+ # Perform full OpenID Federation validation if requested
64
+ if @validate_full
65
+ validator = EntityStatementValidator.new(
66
+ jwt_string: @jwt_string,
67
+ issuer_entity_configuration: @issuer_entity_configuration
68
+ )
69
+ validated = validator.validate!
70
+ @header = validated[:header]
71
+ @payload = validated[:claims]
72
+ else
73
+ # Basic parsing without full validation (for backward compatibility)
74
+ jwt_parts = @jwt_string.split(".")
75
+ raise ParseError, "Invalid JWT format: expected #{JWT_PARTS_COUNT} parts, got #{jwt_parts.length}" if jwt_parts.length != JWT_PARTS_COUNT
76
+
77
+ # Decode header
78
+ @header = JSON.parse(Base64.urlsafe_decode64(jwt_parts[0]))
79
+
80
+ # Validate typ header per OpenID Federation 1.0 Section 3.1 and 3.2.1
81
+ # Entity Statement JWTs MUST have typ: "entity-statement+jwt"
82
+ typ = @header["typ"] || @header[:typ]
83
+ unless typ == "entity-statement+jwt"
84
+ raise ValidationError, "Invalid entity statement type: expected 'entity-statement+jwt', got '#{typ}'. Entity statements without the correct typ header MUST be rejected per OpenID Federation 1.0 Section 3.1."
85
+ end
86
+
87
+ # Decode payload
88
+ @payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
89
+ end
90
+
91
+ header = @header
92
+ payload = @payload
93
+
94
+ # Extract JWKS from entity statement for signature validation
95
+ entity_jwks = payload.fetch("jwks", {}).fetch("keys", [])
96
+
97
+ if @validate_signature && entity_jwks.any?
98
+ # For signature validation, we need the JWT parts
99
+ jwt_parts = @jwt_string.split(".")
100
+ validate_signature(jwt_parts, entity_jwks, header)
101
+ end
102
+
103
+ # Extract metadata for all entity types
104
+ metadata_section = payload.fetch("metadata", {})
105
+ provider_metadata = metadata_section.fetch("openid_provider", {})
106
+ rp_metadata = metadata_section.fetch("openid_relying_party", {})
107
+
108
+ result = {
109
+ header: header,
110
+ claims: payload,
111
+ issuer: payload["iss"],
112
+ sub: payload["sub"],
113
+ exp: payload["exp"],
114
+ iat: payload["iat"],
115
+ jwks: payload.fetch("jwks", {}),
116
+ metadata: {},
117
+ # Advanced claims (Entity Configuration specific)
118
+ authority_hints: payload["authority_hints"] || payload[:authority_hints],
119
+ trust_marks: payload["trust_marks"] || payload[:trust_marks],
120
+ trust_mark_issuers: payload["trust_mark_issuers"] || payload[:trust_mark_issuers],
121
+ trust_mark_owners: payload["trust_mark_owners"] || payload[:trust_mark_owners],
122
+ # Advanced claims (Subordinate Statement specific)
123
+ metadata_policy: payload["metadata_policy"] || payload[:metadata_policy],
124
+ metadata_policy_crit: payload["metadata_policy_crit"] || payload[:metadata_policy_crit],
125
+ constraints: payload["constraints"] || payload[:constraints],
126
+ source_endpoint: payload["source_endpoint"] || payload[:source_endpoint],
127
+ # Other claims
128
+ crit: payload["crit"] || payload[:crit],
129
+ # Determine statement type
130
+ is_entity_configuration: (payload["iss"] == payload["sub"]),
131
+ is_subordinate_statement: (payload["iss"] != payload["sub"])
132
+ }
133
+
134
+ # Extract OpenID Provider metadata if present
135
+ if provider_metadata.any?
136
+ result[:metadata][:openid_provider] = {
137
+ issuer: provider_metadata["issuer"],
138
+ authorization_endpoint: provider_metadata["authorization_endpoint"],
139
+ token_endpoint: provider_metadata["token_endpoint"],
140
+ userinfo_endpoint: provider_metadata["userinfo_endpoint"],
141
+ jwks_uri: provider_metadata["jwks_uri"],
142
+ signed_jwks_uri: provider_metadata["signed_jwks_uri"],
143
+ end_session_endpoint: provider_metadata["end_session_endpoint"],
144
+ client_registration_types_supported: provider_metadata["client_registration_types_supported"],
145
+ federation_registration_endpoint: provider_metadata["federation_registration_endpoint"]
146
+ }
147
+ end
148
+
149
+ # Extract OpenID Relying Party metadata if present
150
+ if rp_metadata.any?
151
+ result[:metadata][:openid_relying_party] = {
152
+ application_type: rp_metadata["application_type"],
153
+ redirect_uris: rp_metadata["redirect_uris"],
154
+ client_registration_types: rp_metadata["client_registration_types"],
155
+ signed_jwks_uri: rp_metadata["signed_jwks_uri"],
156
+ jwks_uri: rp_metadata["jwks_uri"],
157
+ organization_name: rp_metadata["organization_name"],
158
+ logo_uri: rp_metadata["logo_uri"],
159
+ grant_types: rp_metadata["grant_types"],
160
+ response_types: rp_metadata["response_types"],
161
+ scope: rp_metadata["scope"]
162
+ }
163
+ end
164
+
165
+ result
166
+ rescue JSON::ParserError => e
167
+ raise ValidationError, "Failed to parse entity statement: #{e.message}"
168
+ rescue ArgumentError => e
169
+ raise ValidationError, "Failed to decode entity statement: #{e.message}"
170
+ end
171
+
172
+ private
173
+
174
+ def validate_signature(jwt_parts, entity_jwks, header)
175
+ # Find the key used for signing
176
+ kid = header["kid"]
177
+ signing_key_data = entity_jwks.find { |key| key["kid"] == kid }
178
+
179
+ unless signing_key_data
180
+ raise ValidationError, "Signing key with kid '#{kid}' not found in entity statement JWKS"
181
+ end
182
+
183
+ # Convert JWK to OpenSSL key
184
+ public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
185
+
186
+ # Verify signature using the full JWT string
187
+ begin
188
+ JWT.decode(@jwt_string, public_key, true, {algorithm: "RS256"})
189
+ # Return decoded payload for validation
190
+ rescue => e
191
+ error_msg = "Entity statement signature validation failed for kid '#{kid}': #{e.class} - #{e.message}"
192
+ OmniauthOpenidFederation::Logger.error("[EntityStatementParser] #{error_msg}")
193
+ raise SignatureError, error_msg, e.backtrace
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end