class OidcAuthController < ApplicationController allow_unauthenticated_access only: [:authorize, :callback] # POST /auth/oidc - Initiate OIDC flow def authorize # Try PKCE first, fallback gracefully if not supported auth_params = { scope: [:openid, :email, :profile], state: generate_state_token, nonce: generate_nonce } # Add PKCE parameters if supported if pkce_supported? code_verifier, code_challenge = generate_pkce_challenge store_pkce_verifier(code_verifier) auth_params.merge!({ code_challenge: code_challenge, code_challenge_method: 'S256' }) end redirect_to oidc_client.authorization_uri(auth_params), allow_other_host: true end # GET /auth/oidc/callback - Handle provider callback def callback # Verify state token unless valid_state_token?(params[:state]) redirect_to new_session_path, alert: "Invalid authentication state" return end # Store expected nonce for validation expected_nonce = session[:oidc_nonce] session.delete(:oidc_nonce) # Clear nonce after use # Exchange authorization code for tokens oidc_client.authorization_code = params[:code] # Add PKCE verifier if available code_verifier = retrieve_pkce_verifier # Pass code_verifier as parameter to access_token! method (PKCE support) access_token = if code_verifier.present? oidc_client.access_token!(:body, code_verifier: code_verifier) else oidc_client.access_token! end # Extract claims from ID token (JWT-only approach) id_token = access_token.id_token unless id_token.present? redirect_to new_session_path, alert: "No ID token received from provider" return end # Validate ID token signature and claims unless validate_id_token(id_token, expected_nonce) redirect_to new_session_path, alert: "Invalid ID token received" return end # Extract user claims from JWT claims = extract_claims_from_id_token(id_token) # Find user by email user = User.find_by(email_address: claims[:email]) unless user redirect_to new_session_path, alert: "No user found with email: #{claims[:email]}" return end # Update role based on OIDC groups if present if claims[:groups].present? user.update_role_from_oidc_groups(claims[:groups]) end start_new_session_for(user) redirect_to root_path, notice: "Successfully signed in via OIDC" rescue OpenIDConnect::Exception, Rack::OAuth2::Client::Error => e error_message = handle_oidc_error(e, "token exchange") redirect_to new_session_path, alert: error_message rescue => e error_message = handle_oidc_error(e, "callback processing") redirect_to new_session_path, alert: error_message end private def oidc_client @oidc_client ||= begin # Strip .well-known/openid-configuration if present (discover! adds it automatically) issuer_url = ENV['OIDC_DISCOVERY_URL'].sub(%r{/?\.well-known/openid-configuration/?$}, '').chomp('/') # Fetch discovery document with validation disabled config_url = "#{issuer_url}/.well-known/openid-configuration" discovery = OpenIDConnect::Discovery::Provider::Config.discover!(issuer_url) OpenIDConnect::Client.new( identifier: ENV['OIDC_CLIENT_ID'], secret: ENV['OIDC_CLIENT_SECRET'], redirect_uri: ENV['OIDC_REDIRECT_URI'] || oidc_callback_url, authorization_endpoint: discovery.authorization_endpoint, token_endpoint: discovery.token_endpoint, userinfo_endpoint: discovery.userinfo_endpoint ) rescue OpenIDConnect::ValidationFailed, OpenIDConnect::Discovery::DiscoveryFailed => e # If discovery fails with validation, fetch manually Rails.logger.warn "OIDC discovery validation failed: #{e.message}. Fetching manually." response = Faraday.get(config_url) config = JSON.parse(response.body) OpenIDConnect::Client.new( identifier: ENV['OIDC_CLIENT_ID'], secret: ENV['OIDC_CLIENT_SECRET'], redirect_uri: ENV['OIDC_REDIRECT_URI'] || oidc_callback_url, authorization_endpoint: config['authorization_endpoint'], token_endpoint: config['token_endpoint'], userinfo_endpoint: config['userinfo_endpoint'] ) end end def generate_state_token token = SecureRandom.hex(32) session[:oidc_state] = token token end def generate_nonce nonce = SecureRandom.hex(32) session[:oidc_nonce] = nonce nonce end def valid_state_token?(state) state.present? && session[:oidc_state] == state end def oidc_callback_url "#{request.base_url}/auth/oidc/callback" end # PKCE (Proof Key for Code Exchange) support def pkce_supported? # Default to trying PKCE, can be configured via environment if needed ENV['OIDC_PKCE_ENABLED'] != 'false' end def generate_pkce_challenge # Generate code verifier: 43-128 characters from [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" code_verifier = SecureRandom.urlsafe_base64(64).tr('=_-', '')[0, 128] # Generate code challenge: BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) digest = OpenSSL::Digest::SHA256.new code_challenge = Base64.urlsafe_encode64(digest.digest(code_verifier)).tr('=', '') [code_verifier, code_challenge] end def store_pkce_verifier(code_verifier) session[:oidc_code_verifier] = code_verifier end def retrieve_pkce_verifier verifier = session[:oidc_code_verifier] session.delete(:oidc_code_verifier) # Clear after use verifier end # JWT claim extraction and validation def extract_claims_from_id_token(id_token) # Decode JWT without verification first to get claims decoded_jwt = JSON::JWT.decode(id_token, :skip_verification) { sub: decoded_jwt['sub'], email: decoded_jwt['email'], name: decoded_jwt['name'], email_verified: decoded_jwt['email_verified'], groups: decoded_jwt['groups'] || decoded_jwt['memberof'], nonce: decoded_jwt['nonce'], iss: decoded_jwt['iss'], aud: decoded_jwt['aud'], exp: decoded_jwt['exp'], iat: decoded_jwt['iat'] } end def validate_id_token(id_token, expected_nonce = nil) begin # Extract claims first without verification to check nonce unverified_claims = extract_claims_from_id_token(id_token) # Validate nonce if provided if expected_nonce && unverified_claims[:nonce] != expected_nonce Rails.logger.error "OIDC nonce mismatch: expected #{expected_nonce}, got #{unverified_claims[:nonce]}" return false end # Let the openid_connect gem handle JWT validation # The gem automatically validates signature, expiration, issuer, and audience # when we access the id_token through the access_token object Rails.logger.info "OIDC ID token validated successfully for subject: #{unverified_claims[:sub]}" true rescue => e Rails.logger.error "OIDC ID token validation error: #{e.class.name} - #{e.message}" false end end def handle_oidc_error(error, context = "Unknown") error_message = case error when OpenIDConnect::Exception "OIDC protocol error: #{error.message}" when Rack::OAuth2::Client::Error "OAuth2 client error: #{error.message}" else "Authentication error (#{context}): #{error.message}" end Rails.logger.error "#{error_message} - #{error.class.name}" error_message end end