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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.md +22 -0
- data/README.md +822 -0
- data/SECURITY.md +129 -0
- data/examples/README_INTEGRATION_TESTING.md +399 -0
- data/examples/README_MOCK_OP.md +243 -0
- data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
- data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
- data/examples/app/models/user.rb.example +39 -0
- data/examples/config/initializers/devise.rb.example +97 -0
- data/examples/config/initializers/federation_endpoint.rb.example +206 -0
- data/examples/config/mock_op.yml.example +83 -0
- data/examples/config/open_id_connect_config.rb.example +210 -0
- data/examples/config/routes.rb.example +12 -0
- data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
- data/examples/integration_test_flow.rb +1334 -0
- data/examples/jobs/README.md +194 -0
- data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
- data/examples/jobs/federation_files_generation_job.rb.example +87 -0
- data/examples/mock_op_server.rb +775 -0
- data/examples/mock_rp_server.rb +435 -0
- data/lib/omniauth_openid_federation/access_token.rb +504 -0
- data/lib/omniauth_openid_federation/cache.rb +39 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
- data/lib/omniauth_openid_federation/configuration.rb +135 -0
- data/lib/omniauth_openid_federation/constants.rb +13 -0
- data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
- data/lib/omniauth_openid_federation/errors.rb +52 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
- data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
- data/lib/omniauth_openid_federation/http_client.rb +70 -0
- data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
- data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
- data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
- data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
- data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
- data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
- data/lib/omniauth_openid_federation/jws.rb +416 -0
- data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
- data/lib/omniauth_openid_federation/logger.rb +99 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
- data/lib/omniauth_openid_federation/railtie.rb +29 -0
- data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
- data/lib/omniauth_openid_federation/strategy.rb +2029 -0
- data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
- data/lib/omniauth_openid_federation/utils.rb +166 -0
- data/lib/omniauth_openid_federation/validators.rb +126 -0
- data/lib/omniauth_openid_federation/version.rb +3 -0
- data/lib/omniauth_openid_federation.rb +98 -0
- data/lib/tasks/omniauth_openid_federation.rake +376 -0
- data/sig/federation.rbs +218 -0
- data/sig/jwks.rbs +63 -0
- data/sig/omniauth_openid_federation.rbs +254 -0
- data/sig/strategy.rbs +60 -0
- 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
|