Class: OmniAuth::Strategies::OpenIDFederation
- Inherits:
-
OAuth2
- Object
- OAuth2
- OmniAuth::Strategies::OpenIDFederation
- Defined in:
- lib/omniauth_openid_federation/strategy.rb
Constant Summary collapse
- JWT_PARTS_COUNT =
Constants for token format validation
3- JWE_PARTS_COUNT =
Standard JWT has 3 parts: header.payload.signature
5- STATE_BYTES =
Constants for random value generation
32- NONCE_BYTES =
Number of hex bytes for state parameter (CSRF protection)
32
Instance Method Summary collapse
- #auth_hash ⇒ Object
- #authorize_uri ⇒ Object
-
#callback_phase ⇒ Object
Override callback_phase to handle token exchange with OpenIDConnect::Client.
- #client ⇒ Object
-
#client_jwk_signing_key ⇒ Object
Override client_jwk_signing_key to automatically extract from client entity statement This automates client JWKS extraction according to OpenID Federation spec The underlying openid_connect gem will use this for client authentication (private_key_jwt) This method is called when the option is accessed, ensuring automatic extraction.
-
#fail!(error_type, exception = nil) ⇒ void
Override fail! to instrument all authentication failures This catches failures from OmniAuth middleware (like AuthenticityTokenProtection) as well as failures from within the strategy.
-
#oidc_client ⇒ Object
Store reference to OpenID Connect client for ID token operations.
-
#options ⇒ Object
Override options accessor to ensure client_jwk_signing_key is dynamically extracted This ensures the underlying openid_connect gem gets the extracted value when accessing options.client_jwk_signing_key.
- #raw_info ⇒ Object
-
#request_phase ⇒ Object
Override request_phase to use signed request objects (RFC 9101).
Instance Method Details
#auth_hash ⇒ Object
371 372 373 374 375 376 377 378 379 380 381 382 383 384 |
# File 'lib/omniauth_openid_federation/strategy.rb', line 371 def auth_hash OmniAuth::AuthHash.new( provider: "openid_federation", uid: uid, info: info, credentials: { token: @access_token&.access_token, refresh_token: @access_token&.refresh_token, expires_at: @access_token&.expires_in ? Time.now.to_i + @access_token.expires_in : nil, expires: @access_token&.expires_in ? true : false }, extra: extra ) end |
#authorize_uri ⇒ Object
386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 |
# File 'lib/omniauth_openid_federation/strategy.rb', line 386 def request_params = request.params # Security: Only validate user input from HTTP requests, not config values # Note: Rack params can return arrays for multi-value parameters sanitized_params = {} request_params.each do |key, value| next unless value key_str = key.to_s next if key_str.length > 256 # For arrays (multi-value params), sanitize each element and limit size if value.is_a?(Array) # Prevent DoS: limit array size if value.length > 100 next end # Sanitize each element sanitized_array = value.map { |v| OmniauthOpenidFederation::Validators.sanitize_request_param(v) }.compact next if sanitized_array.empty? # Keep as array for acr_values (handled by normalize_acr_values) # Convert to space-separated string for other parameters (ui_locales, claims_locales) sanitized_params[key_str] = if key_str == "acr_values" sanitized_array else sanitized_array.join(" ") end else sanitized = OmniauthOpenidFederation::Validators.sanitize_request_param(value) sanitized_params[key_str] = sanitized if sanitized end end request_params = sanitized_params # Apply custom proc to modify params before adding to signed request object if .prepare_request_object_params.respond_to?(:call) request_params = .prepare_request_object_params.call(request_params.dup) || request_params request_params = {} unless request_params.is_a?(Hash) end # Enforce signed request objects (RFC 9101) - unsigned requests are not allowed = . || {} = OmniauthOpenidFederation::Validators.normalize_hash() private_key = [:private_key] OmniauthOpenidFederation::Validators.validate_private_key!(private_key) resolved_issuer = .issuer unless OmniauthOpenidFederation::StringHelpers.present?(resolved_issuer) resolved_issuer = .issuer = resolved_issuer if resolved_issuer end audience_value = resolve_audience(, resolved_issuer) unless OmniauthOpenidFederation::StringHelpers.present?(audience_value) error_msg = "Audience is required for signed request objects. " \ "Set audience option, provide entity statement with provider issuer, or configure token_endpoint" OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}") raise OmniauthOpenidFederation::ConfigurationError, error_msg end state_value = new_state nonce_value = .send_nonce ? new_nonce : nil configured_redirect_uri = [:redirect_uri] || callback_url # Automatic registration uses entity identifier as client_id (OpenID Federation Section 12.1) client_registration_type = .client_registration_type || :explicit client_id_for_request = [:identifier] client_entity_statement = nil if client_registration_type == :automatic client_entity_statement = load_client_entity_statement( .client_entity_statement_path, .client_entity_statement_url ) entity_identifier = extract_entity_identifier_from_statement(client_entity_statement, .client_entity_identifier) unless OmniauthOpenidFederation::StringHelpers.present?(entity_identifier) error_msg = "Failed to extract entity identifier from client entity statement. " \ "Set client_entity_identifier option or ensure entity statement has 'sub' claim" OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}") raise OmniauthOpenidFederation::ConfigurationError, error_msg end client_id_for_request = entity_identifier if client.respond_to?(:identifier=) client.identifier = entity_identifier elsif client.respond_to?(:client_id=) client.client_id = entity_identifier end OmniauthOpenidFederation::Logger.debug("[Strategy] Using automatic registration with entity identifier: #{entity_identifier}") end signing_key_source = .signing_key_source || .key_source || :local jwks = [:jwks] || ["jwks"] # Extract already-sanitized user input params (sanitized above) validated_state = state_value.to_s.strip validated_nonce = nonce_value&.to_s&.strip validated_login_hint = request_params["login_hint"] validated_ui_locales = request_params["ui_locales"] validated_claims_locales = request_params["claims_locales"] # Config values are trusted (no sanitization needed) validated_client_id = client_id_for_request.to_s.strip validated_redirect_uri = configured_redirect_uri.to_s.strip validated_scope = Array(.scope).join(" ").strip validated_response_type = .response_type.to_s.strip validated_prompt = .prompt&.to_s&.strip validated_hd = .hd&.to_s&.strip validated_response_mode = .response_mode&.to_s&.strip validated_issuer = (resolved_issuer || .issuer)&.to_s&.strip validated_audience = audience_value&.to_s&.strip normalized_acr_values = OmniauthOpenidFederation::Validators.normalize_acr_values(request_params["acr_values"], skip_sanitization: true) || nil jws_builder = OmniauthOpenidFederation::Jws.new( client_id: validated_client_id, redirect_uri: validated_redirect_uri, scope: validated_scope, issuer: validated_issuer, audience: validated_audience, state: validated_state, nonce: validated_nonce, response_type: validated_response_type, response_mode: validated_response_mode, login_hint: validated_login_hint, ui_locales: validated_ui_locales, claims_locales: validated_claims_locales, prompt: validated_prompt, hd: validated_hd, acr_values: normalized_acr_values, extra_params: . || {}, private_key: [:private_key], jwks: jwks, entity_statement_path: .entity_statement_path, key_source: signing_key_source, client_entity_statement: client_entity_statement ) # Add dynamic request object params from HTTP request (already sanitized above) .request_object_params&.each do |key| key_str = key.to_s next if key_str.length > 256 value = request_params[key_str] jws_builder.add_claim(key_str.to_sym, value) if value end # RFC 9101: Only 'request' parameter in query, all params in JWT = signed_request_object = jws_builder.sign( provider_metadata: , always_encrypt: .always_encrypt_request_object ) unless OmniauthOpenidFederation::StringHelpers.present?(signed_request_object) error_msg = "Failed to generate signed request object - authentication cannot proceed without signed request" OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}") raise OmniauthOpenidFederation::SecurityError, error_msg end # Build URL manually to ensure RFC 9101 compliance (only 'request' param in query) auth_endpoint = client. unless OmniauthOpenidFederation::StringHelpers.present?(auth_endpoint) error_msg = "Authorization endpoint not configured. Provide authorization_endpoint in client_options or entity statement" OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}") raise OmniauthOpenidFederation::ConfigurationError, error_msg end begin uri = URI.parse(auth_endpoint) rescue URI::InvalidURIError => e error_msg = "Invalid authorization endpoint URI format: #{e.message}" OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}") raise OmniauthOpenidFederation::ConfigurationError, error_msg end max_string_length = ::OmniauthOpenidFederation::Configuration.config.max_string_length if signed_request_object.length > max_string_length OmniauthOpenidFederation::Logger.warn("[Strategy] Request object exceeds maximum length") end uri.query = URI.encode_www_form(request: signed_request_object) uri.to_s end |
#callback_phase ⇒ Object
Override callback_phase to handle token exchange with OpenIDConnect::Client
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 |
# File 'lib/omniauth_openid_federation/strategy.rb', line 275 def callback_phase # Security: Validate user input from HTTP request state_param_raw = request.params["state"] code_param_raw = request.params["code"] error_param_raw = request.params["error"] error_description_raw = request.params["error_description"] state_param = state_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(state_param_raw) : nil code_param = code_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(code_param_raw) : nil error_param = error_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(error_param_raw) : nil error_description_param = error_description_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(error_description_raw) : nil if error_param error_msg = "Authorization error: #{error_param}" error_msg += " - #{error_description_param}" if error_description_param OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break( stage: "callback_phase", error_message: error_msg, error_class: "AuthorizationError", request_info: { remote_ip: request.env["REMOTE_ADDR"], user_agent: request.env["HTTP_USER_AGENT"], path: request.path } ) env["omniauth_openid_federation.instrumented"] = true fail!(:authorization_error, OmniauthOpenidFederation::ValidationError.new(error_msg)) return end # CSRF protection: constant-time state comparison state_session = session["omniauth.state"] if OmniauthOpenidFederation::StringHelpers.blank?(state_param) || state_session.nil? || !Rack::Utils.secure_compare(state_param.to_s, state_session.to_s) # Instrument CSRF detection OmniauthOpenidFederation::Instrumentation.notify_csrf_detected( state_param: state_param ? "[PRESENT]" : "[MISSING]", state_session: state_session ? "[PRESENT]" : "[MISSING]", request_info: { remote_ip: request.env["REMOTE_ADDR"], user_agent: request.env["HTTP_USER_AGENT"], path: request.path } ) # Mark as instrumented to prevent double instrumentation in fail! env["omniauth_openid_federation.instrumented"] = true fail!(:csrf_detected, OmniauthOpenidFederation::SecurityError.new("CSRF detected")) return end # Clear state from session session.delete("omniauth.state") if OmniauthOpenidFederation::StringHelpers.blank?(code_param) # Instrument unexpected authentication break OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break( stage: "callback_phase", error_message: "Missing authorization code", error_class: "ValidationError", request_info: { remote_ip: request.env["REMOTE_ADDR"], user_agent: request.env["HTTP_USER_AGENT"], path: request.path } ) # Mark as instrumented to prevent double instrumentation in fail! env["omniauth_openid_federation.instrumented"] = true fail!(:missing_code, OmniauthOpenidFederation::ValidationError.new("Missing authorization code")) return end begin @access_token = (code_param) rescue => e # Instrument unexpected authentication break OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break( stage: "token_exchange", error_message: e., error_class: e.class.name, request_info: { remote_ip: request.env["REMOTE_ADDR"], user_agent: request.env["HTTP_USER_AGENT"], path: request.path } ) # Mark as instrumented to prevent double instrumentation in fail! env["omniauth_openid_federation.instrumented"] = true fail!(:token_exchange_error, e) return end env["omniauth.auth"] = auth_hash call_app! end |
#client ⇒ Object
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/omniauth_openid_federation/strategy.rb', line 118 def client @client ||= begin = . || {} # Automatically resolve endpoints, issuer, scheme, and host from entity statement metadata if available # This allows endpoints and issuer to be discovered from entity statement without manual configuration # client_options still takes precedence for overrides resolved_endpoints = () # Merge resolved endpoints with client_options (client_options takes precedence) # resolved_endpoints may contain: endpoints, issuer, scheme, host # client_options will override any resolved values = resolved_endpoints.merge() # Build base URL from scheme, host, and port base_url = build_base_url() # For automatic registration, identifier is the entity identifier (determined at request time) # For explicit registration, identifier comes from client_options # Note: For automatic registration, the actual entity identifier will be extracted # in authorize_uri and used in the request object. The client identifier here is # used for client assertion at the token endpoint, which should also use the entity identifier. # However, since the client is cached, we'll handle this in authorize_uri by updating # the client's identifier if needed. client_identifier = [:identifier] || ["identifier"] # Create OpenID Connect client (extends OAuth2::Client, so compatible with OmniAuth::Strategies::OAuth2) # Build endpoints - use resolved values or nil if not available auth_endpoint = build_endpoint(base_url, [:authorization_endpoint] || ["authorization_endpoint"]) token_endpoint = build_endpoint(base_url, [:token_endpoint] || ["token_endpoint"]) userinfo_endpoint = build_endpoint(base_url, [:userinfo_endpoint] || ["userinfo_endpoint"]) jwks_uri_endpoint = build_endpoint(base_url, [:jwks_uri] || ["jwks_uri"]) # Validate that at least authorization_endpoint is present (required) unless OmniauthOpenidFederation::StringHelpers.present?(auth_endpoint) error_msg = "Authorization endpoint not configured. Provide authorization_endpoint in client_options or entity statement" OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}") raise OmniauthOpenidFederation::ConfigurationError, error_msg end oidc_client = ::OpenIDConnect::Client.new( identifier: client_identifier, secret: nil, # We use private_key_jwt, so no secret needed redirect_uri: [:redirect_uri] || ["redirect_uri"], authorization_endpoint: auth_endpoint, token_endpoint: token_endpoint, userinfo_endpoint: userinfo_endpoint, jwks_uri: jwks_uri_endpoint ) # Store private key for client assertion (private_key_jwt authentication) oidc_client.private_key = [:private_key] || ["private_key"] # Store strategy options on client for AccessToken to access later # This allows AccessToken to get configuration without relying on Devise # Ensure all entity statement options are included (to_h might not include all options) = .to_h.dup # Explicitly include entity statement options that AccessToken needs [:entity_statement_path] = .entity_statement_path if .entity_statement_path [:entity_statement_url] = .entity_statement_url if .entity_statement_url [:entity_statement_fingerprint] = .entity_statement_fingerprint if .entity_statement_fingerprint [:issuer] = .issuer if .issuer oidc_client.instance_variable_set(:@strategy_options, ) # OpenIDConnect::Client extends OAuth2::Client, so it's compatible with OmniAuth::Strategies::OAuth2 oidc_client end end |
#client_jwk_signing_key ⇒ Object
Override client_jwk_signing_key to automatically extract from client entity statement This automates client JWKS extraction according to OpenID Federation spec The underlying openid_connect gem will use this for client authentication (private_key_jwt) This method is called when the option is accessed, ensuring automatic extraction
93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/omniauth_openid_federation/strategy.rb', line 93 def client_jwk_signing_key # Return manually configured value if present (allows override) configured_value = .client_jwk_signing_key return configured_value if OmniauthOpenidFederation::StringHelpers.present?(configured_value) # Automatically extract from client entity statement if available extracted_value = extract_client_jwk_signing_key return extracted_value if OmniauthOpenidFederation::StringHelpers.present?(extracted_value) # Return nil if not available (allows fallback to other authentication methods) nil end |
#fail!(error_type, exception = nil) ⇒ void
This method returns an undefined value.
Override fail! to instrument all authentication failures This catches failures from OmniAuth middleware (like AuthenticityTokenProtection) as well as failures from within the strategy
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'lib/omniauth_openid_federation/strategy.rb', line 199 def fail!(error_type, exception = nil) # Determine if this error has already been instrumented # Errors instrumented before calling fail! will have a flag set already_instrumented = env["omniauth_openid_federation.instrumented"] == true unless already_instrumented # Extract error information = exception&. || error_type.to_s error_class = exception&.class&.name || "UnknownError" # Determine the phase (request or callback) phase = request.path.end_with?("/callback") ? "callback_phase" : "request_phase" # Build request info request_info = { remote_ip: request.env["REMOTE_ADDR"], user_agent: request.env["HTTP_USER_AGENT"], path: request.path, method: request.request_method } # Instrument based on error type case error_type.to_sym when :authenticity_error # OmniAuth CSRF protection error (from middleware) OmniauthOpenidFederation::Instrumentation.notify_authenticity_error( error_type: error_type.to_s, error_message: , error_class: error_class, phase: phase, request_info: request_info ) when :csrf_detected # This should already be instrumented before calling fail!, but instrument here as fallback # (e.g., if fail! is called directly without prior instrumentation) OmniauthOpenidFederation::Instrumentation.notify_csrf_detected( error_type: error_type.to_s, error_message: , phase: phase, request_info: request_info ) when :missing_code, :token_exchange_error # These should already be instrumented before calling fail!, but instrument here as fallback # (e.g., if fail! is called directly without prior instrumentation) OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break( stage: phase, error_message: , error_class: error_class, error_type: error_type.to_s, request_info: request_info ) else # Unknown error type - instrument as unexpected authentication break OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break( stage: phase, error_message: , error_class: error_class, error_type: error_type.to_s, request_info: request_info ) end end # Mark as instrumented to prevent double instrumentation env["omniauth_openid_federation.instrumented"] = true # Call parent fail! method super end |
#oidc_client ⇒ Object
Store reference to OpenID Connect client for ID token operations
188 189 190 |
# File 'lib/omniauth_openid_federation/strategy.rb', line 188 def oidc_client client end |
#options ⇒ Object
Override options accessor to ensure client_jwk_signing_key is dynamically extracted This ensures the underlying openid_connect gem gets the extracted value when accessing options.client_jwk_signing_key
108 109 110 111 112 113 114 115 116 |
# File 'lib/omniauth_openid_federation/strategy.rb', line 108 def opts = super # Dynamically set client_jwk_signing_key if not already set and we can extract it if opts[:client_jwk_signing_key].nil? && (opts[:client_entity_statement_path] || opts[:client_entity_statement_url]) extracted = extract_client_jwk_signing_key opts[:client_jwk_signing_key] = extracted if OmniauthOpenidFederation::StringHelpers.present?(extracted) end opts end |
#raw_info ⇒ Object
588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 |
# File 'lib/omniauth_openid_federation/strategy.rb', line 588 def raw_info @raw_info ||= begin # Use access token from callback_phase (already exchanged) # If not available, exchange it now (fallback for direct calls) access_token = @access_token access_token ||= (request.params["code"]) id_token = decode_id_token(access_token.id_token) id_token_claims = id_token.raw_attributes || {} if .fetch_userinfo begin userinfo = access_token.userinfo! userinfo_hash = decode_userinfo(userinfo) id_token_claims.merge(userinfo_hash) rescue => e error_msg = "Failed to fetch or decode userinfo: #{e.class} - #{e.message}" OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}") OmniauthOpenidFederation::Logger.warn("[Strategy] Falling back to ID token claims only") id_token_claims end else OmniauthOpenidFederation::Logger.debug("[Strategy] Userinfo fetching disabled, using ID token claims only") id_token_claims end end end |
#request_phase ⇒ Object
Override request_phase to use signed request objects (RFC 9101)
270 271 272 |
# File 'lib/omniauth_openid_federation/strategy.rb', line 270 def request_phase redirect end |