clavis 0.7.2 → 0.8.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 +4 -4
- data/README.md +105 -0
- data/gemfiles/rails_80.gemfile +1 -0
- data/lib/clavis/controllers/concerns/authentication.rb +183 -113
- data/lib/clavis/errors.rb +19 -0
- data/lib/clavis/logging.rb +53 -0
- data/lib/clavis/providers/apple.rb +249 -15
- data/lib/clavis/providers/base.rb +163 -75
- data/lib/clavis/providers/facebook.rb +123 -10
- data/lib/clavis/providers/github.rb +47 -10
- data/lib/clavis/providers/google.rb +189 -6
- data/lib/clavis/providers/token_exchange_handler.rb +125 -0
- data/lib/clavis/security/csrf_protection.rb +92 -8
- data/lib/clavis/security/session_manager.rb +10 -0
- data/lib/clavis/version.rb +1 -1
- data/lib/clavis.rb +5 -0
- data/lib/generators/clavis/templates/initializer.rb +4 -2
- metadata +3 -2
| @@ -3,34 +3,63 @@ | |
| 3 3 | 
             
            require "jwt"
         | 
| 4 4 | 
             
            require "base64"
         | 
| 5 5 | 
             
            require "openssl"
         | 
| 6 | 
            +
            require "securerandom"
         | 
| 7 | 
            +
            require "net/http"
         | 
| 8 | 
            +
            require "json"
         | 
| 6 9 |  | 
| 7 10 | 
             
            module Clavis
         | 
| 8 11 | 
             
              module Providers
         | 
| 9 12 | 
             
                class Apple < Base
         | 
| 10 | 
            -
                  attr_reader :team_id, :key_id, :private_key, :private_key_path
         | 
| 13 | 
            +
                  attr_reader :team_id, :key_id, :private_key, :private_key_path, :authorized_client_ids, :client_options
         | 
| 11 14 |  | 
| 12 | 
            -
                   | 
| 13 | 
            -
                   | 
| 15 | 
            +
                  ISSUER = "https://appleid.apple.com"
         | 
| 16 | 
            +
                  APPLE_AUTH_URL = "#{ISSUER}/auth/authorize".freeze
         | 
| 17 | 
            +
                  APPLE_TOKEN_URL = "#{ISSUER}/auth/token".freeze
         | 
| 18 | 
            +
                  APPLE_JWKS_URL = "#{ISSUER}/auth/keys".freeze
         | 
| 19 | 
            +
                  DEFAULT_CLIENT_SECRET_EXPIRY = 300 # 5 minutes in seconds
         | 
| 14 20 |  | 
| 15 21 | 
             
                  def initialize(config = {})
         | 
| 16 22 | 
             
                    @team_id = config[:team_id] || ENV.fetch("APPLE_TEAM_ID", nil)
         | 
| 17 23 | 
             
                    @key_id = config[:key_id] || ENV.fetch("APPLE_KEY_ID", nil)
         | 
| 18 24 | 
             
                    @private_key = config[:private_key] || ENV.fetch("APPLE_PRIVATE_KEY", nil)
         | 
| 19 25 | 
             
                    @private_key_path = config[:private_key_path] || ENV.fetch("APPLE_PRIVATE_KEY_PATH", nil)
         | 
| 26 | 
            +
                    @authorized_client_ids = config[:authorized_client_ids] || []
         | 
| 27 | 
            +
                    @client_secret_expiry = config[:client_secret_expiry] || DEFAULT_CLIENT_SECRET_EXPIRY
         | 
| 28 | 
            +
                    @client_options = config[:client_options] || {}
         | 
| 20 29 |  | 
| 21 | 
            -
                     | 
| 22 | 
            -
                     | 
| 23 | 
            -
             | 
| 30 | 
            +
                    # Set up endpoints with potential overrides from client_options
         | 
| 31 | 
            +
                    endpoints = {
         | 
| 32 | 
            +
                      authorization_endpoint: @client_options[:authorize_url] || APPLE_AUTH_URL,
         | 
| 33 | 
            +
                      token_endpoint: @client_options[:token_url] || APPLE_TOKEN_URL,
         | 
| 34 | 
            +
                      userinfo_endpoint: nil # Apple doesn't have a userinfo endpoint
         | 
| 35 | 
            +
                    }
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                    # Override base URL if site is specified
         | 
| 38 | 
            +
                    if @client_options[:site]
         | 
| 39 | 
            +
                      base_uri = URI.parse(@client_options[:site])
         | 
| 40 | 
            +
                      auth_uri = URI.parse(endpoints[:authorization_endpoint])
         | 
| 41 | 
            +
                      token_uri = URI.parse(endpoints[:token_endpoint])
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                      # Only override the host, keep the paths
         | 
| 44 | 
            +
                      auth_uri.scheme = base_uri.scheme
         | 
| 45 | 
            +
                      auth_uri.host = base_uri.host
         | 
| 46 | 
            +
                      token_uri.scheme = base_uri.scheme
         | 
| 47 | 
            +
                      token_uri.host = base_uri.host
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                      endpoints[:authorization_endpoint] = auth_uri.to_s
         | 
| 50 | 
            +
                      endpoints[:token_endpoint] = token_uri.to_s
         | 
| 51 | 
            +
                    end
         | 
| 24 52 |  | 
| 53 | 
            +
                    config.merge!(endpoints)
         | 
| 25 54 | 
             
                    super
         | 
| 26 55 | 
             
                  end
         | 
| 27 56 |  | 
| 28 57 | 
             
                  def authorization_endpoint
         | 
| 29 | 
            -
                     | 
| 58 | 
            +
                    @authorize_endpoint_url
         | 
| 30 59 | 
             
                  end
         | 
| 31 60 |  | 
| 32 61 | 
             
                  def token_endpoint
         | 
| 33 | 
            -
                     | 
| 62 | 
            +
                    @token_endpoint_url
         | 
| 34 63 | 
             
                  end
         | 
| 35 64 |  | 
| 36 65 | 
             
                  def userinfo_endpoint
         | 
| @@ -46,13 +75,13 @@ module Clavis | |
| 46 75 | 
             
                  end
         | 
| 47 76 |  | 
| 48 77 | 
             
                  def refresh_token(_refresh_token)
         | 
| 49 | 
            -
                    #  | 
| 78 | 
            +
                    # Apple doesn't support the standard OAuth refresh token flow
         | 
| 79 | 
            +
                    # Instead, they use long-lived tokens that don't need refreshing
         | 
| 50 80 | 
             
                    raise Clavis::UnsupportedOperation, "Apple does not support refresh tokens"
         | 
| 51 81 | 
             
                  end
         | 
| 52 82 |  | 
| 53 | 
            -
                  # Using keyword arguments  | 
| 54 | 
            -
                   | 
| 55 | 
            -
                  def token_exchange(code:, **_kwargs)
         | 
| 83 | 
            +
                  # Using keyword arguments with support for state verification (for compatibility)
         | 
| 84 | 
            +
                  def token_exchange(code:, **kwargs)
         | 
| 56 85 | 
             
                    # Validate inputs
         | 
| 57 86 | 
             
                    raise Clavis::InvalidGrant unless Clavis::Security::InputValidator.valid_code?(code)
         | 
| 58 87 |  | 
| @@ -68,7 +97,30 @@ module Clavis | |
| 68 97 |  | 
| 69 98 | 
             
                    handle_token_error_response(response) if response.status != 200
         | 
| 70 99 |  | 
| 71 | 
            -
                    parse_token_response(response)
         | 
| 100 | 
            +
                    token_data = parse_token_response(response)
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                    # If id_token is present, verify and extract claims
         | 
| 103 | 
            +
                    if token_data[:id_token] && !token_data[:id_token].empty?
         | 
| 104 | 
            +
                      begin
         | 
| 105 | 
            +
                        token_data[:id_token_claims] = verify_and_decode_id_token(
         | 
| 106 | 
            +
                          token_data[:id_token],
         | 
| 107 | 
            +
                          kwargs[:nonce]
         | 
| 108 | 
            +
                        )
         | 
| 109 | 
            +
                      rescue StandardError => e
         | 
| 110 | 
            +
                        Clavis.logger.warn("Failed to verify ID token: #{e.message}")
         | 
| 111 | 
            +
                      end
         | 
| 112 | 
            +
                    end
         | 
| 113 | 
            +
             | 
| 114 | 
            +
                    # Process user data if available
         | 
| 115 | 
            +
                    if kwargs[:user_data] && !kwargs[:user_data].empty?
         | 
| 116 | 
            +
                      begin
         | 
| 117 | 
            +
                        token_data[:user_info] = JSON.parse(kwargs[:user_data])
         | 
| 118 | 
            +
                      rescue JSON::ParserError => e
         | 
| 119 | 
            +
                        Clavis.logger.warn("Failed to parse user data: #{e.message}")
         | 
| 120 | 
            +
                      end
         | 
| 121 | 
            +
                    end
         | 
| 122 | 
            +
             | 
| 123 | 
            +
                    token_data
         | 
| 72 124 | 
             
                  end
         | 
| 73 125 |  | 
| 74 126 | 
             
                  def get_user_info(_access_token)
         | 
| @@ -76,6 +128,58 @@ module Clavis | |
| 76 128 | 
             
                    raise Clavis::UnsupportedOperation, "Apple does not have a userinfo endpoint"
         | 
| 77 129 | 
             
                  end
         | 
| 78 130 |  | 
| 131 | 
            +
                  def authorize_url(state:, nonce:, scope: nil)
         | 
| 132 | 
            +
                    # Generate a more secure URL with form_post response mode
         | 
| 133 | 
            +
                    params = {
         | 
| 134 | 
            +
                      response_type: "code",
         | 
| 135 | 
            +
                      client_id: client_id,
         | 
| 136 | 
            +
                      redirect_uri: Clavis::Security::HttpsEnforcer.enforce_https(redirect_uri),
         | 
| 137 | 
            +
                      scope: scope || default_scopes,
         | 
| 138 | 
            +
                      state: state,
         | 
| 139 | 
            +
                      nonce: nonce,
         | 
| 140 | 
            +
                      response_mode: "form_post" # Required for getting user information
         | 
| 141 | 
            +
                    }
         | 
| 142 | 
            +
             | 
| 143 | 
            +
                    uri = URI.parse(authorization_endpoint)
         | 
| 144 | 
            +
                    uri.query = URI.encode_www_form(params)
         | 
| 145 | 
            +
             | 
| 146 | 
            +
                    # Enforce HTTPS
         | 
| 147 | 
            +
                    uri.scheme = "https" if Clavis.configuration.enforce_https && uri.scheme == "http"
         | 
| 148 | 
            +
             | 
| 149 | 
            +
                    uri.to_s
         | 
| 150 | 
            +
                  end
         | 
| 151 | 
            +
             | 
| 152 | 
            +
                  def process_callback(code, user_data = nil)
         | 
| 153 | 
            +
                    clean_code = code.to_s.gsub(/\A["']|["']\Z/, "")
         | 
| 154 | 
            +
                    token_data = token_exchange(code: clean_code, user_data: user_data)
         | 
| 155 | 
            +
             | 
| 156 | 
            +
                    # Extract user info from id_token and/or user_data
         | 
| 157 | 
            +
                    user_info = extract_user_info(token_data)
         | 
| 158 | 
            +
             | 
| 159 | 
            +
                    # For OpenID Connect, use sub claim as identifier
         | 
| 160 | 
            +
                    uid = if token_data[:id_token_claims]&.dig(:sub)
         | 
| 161 | 
            +
                            token_data[:id_token_claims][:sub]
         | 
| 162 | 
            +
                          else
         | 
| 163 | 
            +
                            # Generate a hash as fallback
         | 
| 164 | 
            +
                            data_for_hash = "#{provider_name}:#{token_data[:access_token] || ""}:#{user_info[:email] || ""}"
         | 
| 165 | 
            +
                            Digest::SHA1.hexdigest(data_for_hash)[0..19]
         | 
| 166 | 
            +
                          end
         | 
| 167 | 
            +
             | 
| 168 | 
            +
                    {
         | 
| 169 | 
            +
                      provider: provider_name,
         | 
| 170 | 
            +
                      uid: uid,
         | 
| 171 | 
            +
                      info: user_info,
         | 
| 172 | 
            +
                      credentials: {
         | 
| 173 | 
            +
                        token: token_data[:access_token],
         | 
| 174 | 
            +
                        refresh_token: token_data[:refresh_token],
         | 
| 175 | 
            +
                        expires_at: token_data[:expires_at],
         | 
| 176 | 
            +
                        expires: token_data[:expires_at] && !token_data[:expires_at].nil?
         | 
| 177 | 
            +
                      },
         | 
| 178 | 
            +
                      id_token: token_data[:id_token],
         | 
| 179 | 
            +
                      id_token_claims: token_data[:id_token_claims] || {}
         | 
| 180 | 
            +
                    }
         | 
| 181 | 
            +
                  end
         | 
| 182 | 
            +
             | 
| 79 183 | 
             
                  protected
         | 
| 80 184 |  | 
| 81 185 | 
             
                  def validate_configuration!
         | 
| @@ -115,8 +219,8 @@ module Clavis | |
| 115 219 | 
             
                      claims = {
         | 
| 116 220 | 
             
                        iss: team_id,
         | 
| 117 221 | 
             
                        iat: now,
         | 
| 118 | 
            -
                        exp: now +  | 
| 119 | 
            -
                        aud:  | 
| 222 | 
            +
                        exp: now + @client_secret_expiry,
         | 
| 223 | 
            +
                        aud: ISSUER,
         | 
| 120 224 | 
             
                        sub: client_id
         | 
| 121 225 | 
             
                      }
         | 
| 122 226 |  | 
| @@ -130,6 +234,136 @@ module Clavis | |
| 130 234 | 
             
                      raise
         | 
| 131 235 | 
             
                    end
         | 
| 132 236 | 
             
                  end
         | 
| 237 | 
            +
             | 
| 238 | 
            +
                  def fetch_jwk(kid)
         | 
| 239 | 
            +
                    uri = URI.parse(APPLE_JWKS_URL)
         | 
| 240 | 
            +
             | 
| 241 | 
            +
                    begin
         | 
| 242 | 
            +
                      response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
         | 
| 243 | 
            +
                        http.open_timeout = 5
         | 
| 244 | 
            +
                        http.read_timeout = 5
         | 
| 245 | 
            +
                        http.get(uri.path)
         | 
| 246 | 
            +
                      end
         | 
| 247 | 
            +
             | 
| 248 | 
            +
                      return nil unless response.is_a?(Net::HTTPSuccess)
         | 
| 249 | 
            +
             | 
| 250 | 
            +
                      jwks = JSON.parse(response.body)
         | 
| 251 | 
            +
                      jwks["keys"].find { |key| key["kid"] == kid }
         | 
| 252 | 
            +
                    rescue StandardError => e
         | 
| 253 | 
            +
                      Clavis.logger.error("Error fetching Apple JWK: #{e.message}")
         | 
| 254 | 
            +
                      nil
         | 
| 255 | 
            +
                    end
         | 
| 256 | 
            +
                  end
         | 
| 257 | 
            +
             | 
| 258 | 
            +
                  def verify_and_decode_id_token(id_token, expected_nonce = nil)
         | 
| 259 | 
            +
                    # Basic decode to get header and payload without verification
         | 
| 260 | 
            +
                    segments = id_token.split(".")
         | 
| 261 | 
            +
                    return {} if segments.length < 2
         | 
| 262 | 
            +
             | 
| 263 | 
            +
                    # Decode header to get kid
         | 
| 264 | 
            +
                    header_segment = segments[0]
         | 
| 265 | 
            +
                    # Add padding if needed
         | 
| 266 | 
            +
                    header_segment += "=" * ((4 - (header_segment.length % 4)) % 4)
         | 
| 267 | 
            +
                    header_json = Base64.urlsafe_decode64(header_segment)
         | 
| 268 | 
            +
                    header = JSON.parse(header_json)
         | 
| 269 | 
            +
                    kid = header["kid"]
         | 
| 270 | 
            +
             | 
| 271 | 
            +
                    # Decode payload for basic verification
         | 
| 272 | 
            +
                    payload_segment = segments[1]
         | 
| 273 | 
            +
                    # Add padding if needed
         | 
| 274 | 
            +
                    payload_segment += "=" * ((4 - (payload_segment.length % 4)) % 4)
         | 
| 275 | 
            +
                    payload_json = Base64.urlsafe_decode64(payload_segment)
         | 
| 276 | 
            +
                    payload = JSON.parse(payload_json, symbolize_names: true)
         | 
| 277 | 
            +
             | 
| 278 | 
            +
                    # Verify JWT claims
         | 
| 279 | 
            +
                    verify_issuer(payload)
         | 
| 280 | 
            +
                    verify_audience(payload)
         | 
| 281 | 
            +
                    verify_expiration(payload)
         | 
| 282 | 
            +
                    verify_issued_at(payload)
         | 
| 283 | 
            +
                    verify_nonce(payload, expected_nonce) if expected_nonce
         | 
| 284 | 
            +
             | 
| 285 | 
            +
                    # Optional: Verify signature with JWKS
         | 
| 286 | 
            +
                    if kid
         | 
| 287 | 
            +
                      jwk = fetch_jwk(kid)
         | 
| 288 | 
            +
                      if jwk
         | 
| 289 | 
            +
                        # Convert JWK to PEM format for verification
         | 
| 290 | 
            +
                        Clavis.logger.info("JWT signature verification with JWK is not implemented yet")
         | 
| 291 | 
            +
                        # Future implementation would verify the JWT signature here
         | 
| 292 | 
            +
                      end
         | 
| 293 | 
            +
                    end
         | 
| 294 | 
            +
             | 
| 295 | 
            +
                    # Return the verified claims
         | 
| 296 | 
            +
                    payload
         | 
| 297 | 
            +
                  rescue StandardError => e
         | 
| 298 | 
            +
                    Clavis.logger.error("ID token verification failed: #{e.message}")
         | 
| 299 | 
            +
                    {}
         | 
| 300 | 
            +
                  end
         | 
| 301 | 
            +
             | 
| 302 | 
            +
                  def verify_issuer(payload)
         | 
| 303 | 
            +
                    return if payload[:iss] == ISSUER
         | 
| 304 | 
            +
             | 
| 305 | 
            +
                    raise Clavis::InvalidToken, "Invalid issuer: expected #{ISSUER}, got #{payload[:iss]}"
         | 
| 306 | 
            +
                  end
         | 
| 307 | 
            +
             | 
| 308 | 
            +
                  def verify_audience(payload)
         | 
| 309 | 
            +
                    valid_audiences = [client_id] + authorized_client_ids
         | 
| 310 | 
            +
                    return if valid_audiences.include?(payload[:aud])
         | 
| 311 | 
            +
             | 
| 312 | 
            +
                    raise Clavis::InvalidToken, "Invalid audience: #{payload[:aud]}"
         | 
| 313 | 
            +
                  end
         | 
| 314 | 
            +
             | 
| 315 | 
            +
                  def verify_expiration(payload)
         | 
| 316 | 
            +
                    return if payload[:exp] && payload[:exp] > Time.now.to_i
         | 
| 317 | 
            +
             | 
| 318 | 
            +
                    raise Clavis::InvalidToken, "Token expired"
         | 
| 319 | 
            +
                  end
         | 
| 320 | 
            +
             | 
| 321 | 
            +
                  def verify_issued_at(payload)
         | 
| 322 | 
            +
                    return if payload[:iat] && payload[:iat] <= Time.now.to_i
         | 
| 323 | 
            +
             | 
| 324 | 
            +
                    raise Clavis::InvalidToken, "Invalid issued at time"
         | 
| 325 | 
            +
                  end
         | 
| 326 | 
            +
             | 
| 327 | 
            +
                  def verify_nonce(payload, expected_nonce)
         | 
| 328 | 
            +
                    return if payload[:nonce] && payload[:nonce] == expected_nonce
         | 
| 329 | 
            +
             | 
| 330 | 
            +
                    raise Clavis::InvalidToken, "Nonce mismatch"
         | 
| 331 | 
            +
                  end
         | 
| 332 | 
            +
             | 
| 333 | 
            +
                  def extract_user_info(token_data)
         | 
| 334 | 
            +
                    info = {}
         | 
| 335 | 
            +
             | 
| 336 | 
            +
                    # Extract from ID token claims (prioritized for security)
         | 
| 337 | 
            +
                    if token_data[:id_token_claims]
         | 
| 338 | 
            +
                      claims = token_data[:id_token_claims]
         | 
| 339 | 
            +
                      info[:email] = claims[:email]
         | 
| 340 | 
            +
                      info[:email_verified] = [true, "true"].include?(claims[:email_verified])
         | 
| 341 | 
            +
                      info[:is_private_email] = [true, "true"].include?(claims[:is_private_email])
         | 
| 342 | 
            +
                      info[:sub] = claims[:sub]
         | 
| 343 | 
            +
                    end
         | 
| 344 | 
            +
             | 
| 345 | 
            +
                    # Extract from user_info if available (comes from form_post)
         | 
| 346 | 
            +
                    if token_data[:user_info]
         | 
| 347 | 
            +
                      user_data = token_data[:user_info]
         | 
| 348 | 
            +
                      if user_data["name"]
         | 
| 349 | 
            +
                        info[:first_name] = user_data["name"]["firstName"]
         | 
| 350 | 
            +
                        info[:last_name] = user_data["name"]["lastName"]
         | 
| 351 | 
            +
                        # Combine name parts if available
         | 
| 352 | 
            +
                        if info[:first_name] || info[:last_name]
         | 
| 353 | 
            +
                          info[:name] = [info[:first_name], info[:last_name]].compact.join(" ")
         | 
| 354 | 
            +
                        end
         | 
| 355 | 
            +
                      end
         | 
| 356 | 
            +
             | 
| 357 | 
            +
                      # Only use email from user_data if not already set from ID token
         | 
| 358 | 
            +
                      # This prevents email spoofing attacks
         | 
| 359 | 
            +
                      info[:email] ||= user_data["email"]
         | 
| 360 | 
            +
                    end
         | 
| 361 | 
            +
             | 
| 362 | 
            +
                    # If no name was set but we have email, use that as name
         | 
| 363 | 
            +
                    info[:name] ||= info[:email]
         | 
| 364 | 
            +
             | 
| 365 | 
            +
                    info
         | 
| 366 | 
            +
                  end
         | 
| 133 367 | 
             
                end
         | 
| 134 368 | 
             
              end
         | 
| 135 369 | 
             
            end
         | 
| @@ -16,10 +16,13 @@ require "clavis/security/redirect_uri_validator" | |
| 16 16 | 
             
            require "clavis/security/csrf_protection"
         | 
| 17 17 | 
             
            require "openssl"
         | 
| 18 18 | 
             
            require "jwt"
         | 
| 19 | 
            +
            require "clavis/providers/token_exchange_handler"
         | 
| 19 20 |  | 
| 20 21 | 
             
            module Clavis
         | 
| 21 22 | 
             
              module Providers
         | 
| 22 23 | 
             
                class Base
         | 
| 24 | 
            +
                  include Clavis::Providers::TokenExchangeHandler
         | 
| 25 | 
            +
             | 
| 23 26 | 
             
                  attr_reader :client_id, :client_secret, :redirect_uri, :authorize_endpoint_url,
         | 
| 24 27 | 
             
                              :token_endpoint_url, :userinfo_endpoint_url, :scope
         | 
| 25 28 |  | 
| @@ -86,39 +89,37 @@ module Clavis | |
| 86 89 | 
             
                    Clavis::Security::InputValidator.sanitize_hash(token_data)
         | 
| 87 90 | 
             
                  end
         | 
| 88 91 |  | 
| 89 | 
            -
                  def token_exchange( | 
| 90 | 
            -
                     | 
| 91 | 
            -
                     | 
| 92 | 
            -
             | 
| 93 | 
            -
                    # raise Clavis::InvalidState if expected_state && !Clavis::Security::InputValidator.valid_state?(expected_state)
         | 
| 94 | 
            -
             | 
| 95 | 
            -
                    params = {
         | 
| 96 | 
            -
                      grant_type: "authorization_code",
         | 
| 97 | 
            -
                      code: code,
         | 
| 98 | 
            -
                      redirect_uri: redirect_uri,
         | 
| 99 | 
            -
                      client_id: client_id,
         | 
| 100 | 
            -
                      client_secret: client_secret
         | 
| 101 | 
            -
                    }
         | 
| 92 | 
            +
                  def token_exchange(options = {})
         | 
| 93 | 
            +
                    code = options[:code]
         | 
| 94 | 
            +
                    redirect_uri = options[:redirect_uri] || @redirect_uri
         | 
| 102 95 |  | 
| 103 | 
            -
                     | 
| 96 | 
            +
                    Clavis::Logging.debug("#{provider_name}#token_exchange - Starting with options: #{options.inspect}")
         | 
| 104 97 |  | 
| 105 | 
            -
                     | 
| 106 | 
            -
             | 
| 107 | 
            -
                      handle_token_error_response(response)
         | 
| 108 | 
            -
                    end
         | 
| 98 | 
            +
                    # Set up the token exchange parameters
         | 
| 99 | 
            +
                    params = build_token_exchange_params(code, redirect_uri)
         | 
| 109 100 |  | 
| 110 | 
            -
                     | 
| 101 | 
            +
                    # Make the token exchange request
         | 
| 102 | 
            +
                    begin
         | 
| 103 | 
            +
                      response = make_token_request(params)
         | 
| 104 | 
            +
                      token_response = parse_response(response)
         | 
| 111 105 |  | 
| 112 | 
            -
             | 
| 113 | 
            -
             | 
| 106 | 
            +
                      # Check for error
         | 
| 107 | 
            +
                      handle_error_response(token_response, response.status)
         | 
| 114 108 |  | 
| 115 | 
            -
             | 
| 116 | 
            -
             | 
| 117 | 
            -
                    #   raise Clavis::InvalidToken, "Invalid token response format"
         | 
| 118 | 
            -
                    # end
         | 
| 109 | 
            +
                      # Log the token exchange
         | 
| 110 | 
            +
                      Clavis::Logging.log_token_exchange(provider_name, true)
         | 
| 119 111 |  | 
| 120 | 
            -
             | 
| 121 | 
            -
             | 
| 112 | 
            +
                      # Return the token data
         | 
| 113 | 
            +
                      process_id_token_if_present(token_response)
         | 
| 114 | 
            +
                    rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
         | 
| 115 | 
            +
                      handle_connection_error(e)
         | 
| 116 | 
            +
                    rescue Faraday::Error => e
         | 
| 117 | 
            +
                      handle_faraday_error(e)
         | 
| 118 | 
            +
                    rescue JSON::ParserError => e
         | 
| 119 | 
            +
                      handle_parser_error(e)
         | 
| 120 | 
            +
                    rescue StandardError => e
         | 
| 121 | 
            +
                      handle_standard_error(e)
         | 
| 122 | 
            +
                    end
         | 
| 122 123 | 
             
                  end
         | 
| 123 124 |  | 
| 124 125 | 
             
                  def get_user_info(access_token)
         | 
| @@ -191,59 +192,109 @@ module Clavis | |
| 191 192 | 
             
                    uri.to_s
         | 
| 192 193 | 
             
                  end
         | 
| 193 194 |  | 
| 194 | 
            -
                  def process_callback(code)
         | 
| 195 | 
            -
                    #  | 
| 196 | 
            -
             | 
| 195 | 
            +
                  def process_callback(code, user_data = nil)
         | 
| 196 | 
            +
                    Clavis::Logging.debug("#{provider_name}#process_callback - Starting with code: #{code.inspect}")
         | 
| 197 | 
            +
             | 
| 198 | 
            +
                    # Normalize the code by removing quote characters and trimming whitespace
         | 
| 199 | 
            +
                    clean_code = code.gsub(/^"|"$/, "").strip
         | 
| 200 | 
            +
                    Clavis::Logging.debug("#{provider_name}#process_callback - Cleaned code: #{clean_code}")
         | 
| 197 201 |  | 
| 202 | 
            +
                    Clavis::Logging.debug("#{provider_name}#process_callback - Calling token_exchange")
         | 
| 198 203 | 
             
                    token_data = token_exchange(code: clean_code)
         | 
| 204 | 
            +
                    Clavis::Logging.debug("#{provider_name}#process_callback - Token data received: #{token_data.inspect}")
         | 
| 199 205 |  | 
| 206 | 
            +
                    # If we have a token, try to get user info
         | 
| 200 207 | 
             
                    user_info = {}
         | 
| 201 | 
            -
                    if token_data[:access_token] | 
| 208 | 
            +
                    if token_data[:access_token]
         | 
| 202 209 | 
             
                      begin
         | 
| 210 | 
            +
                        # Debug log excluding sensitive information
         | 
| 211 | 
            +
                        token_data.except(:access_token, :refresh_token, :id_token)
         | 
| 212 | 
            +
                        Clavis::Logging.debug(
         | 
| 213 | 
            +
                          "#{provider_name}#process_callback - Calling get_user_info with " \
         | 
| 214 | 
            +
                          "access_token: #{token_data[:access_token].inspect}"
         | 
| 215 | 
            +
                        )
         | 
| 203 216 | 
             
                        user_info = get_user_info(token_data[:access_token])
         | 
| 204 | 
            -
             | 
| 205 | 
            -
             | 
| 217 | 
            +
                        Clavis::Logging.debug("#{provider_name}#process_callback - User info received: #{user_info.inspect}")
         | 
| 218 | 
            +
                      rescue Clavis::UnsupportedOperation => e
         | 
| 219 | 
            +
                        Clavis::Logging.debug("#{provider_name}#process_callback - UnsupportedOperation: #{e.message}")
         | 
| 220 | 
            +
                        user_info = {}
         | 
| 221 | 
            +
                      rescue StandardError => e
         | 
| 222 | 
            +
                        # Debug log error from user info
         | 
| 223 | 
            +
                        Clavis::Logging.debug(
         | 
| 224 | 
            +
                          "#{provider_name}#process_callback - Error getting user info: " \
         | 
| 225 | 
            +
                          "#{e.class.name}: #{e.message}"
         | 
| 226 | 
            +
                        )
         | 
| 227 | 
            +
                        Clavis::Logging.debug("#{provider_name}#process_callback - Error backtrace: #{e.backtrace.join("\n")}")
         | 
| 228 | 
            +
                        raise
         | 
| 206 229 | 
             
                      end
         | 
| 230 | 
            +
                    else
         | 
| 231 | 
            +
                      Clavis::Logging.debug("#{provider_name}#process_callback - No access_token available, skipping user_info")
         | 
| 207 232 | 
             
                    end
         | 
| 208 233 |  | 
| 209 | 
            -
                    #  | 
| 210 | 
            -
                    #  | 
| 211 | 
            -
                    uid = if  | 
| 234 | 
            +
                    # Determine a unique identifier (UID) for the user
         | 
| 235 | 
            +
                    # Prefer ID token claims as the most reliable source
         | 
| 236 | 
            +
                    uid = if token_data[:id_token_claims] && token_data[:id_token_claims][:sub]
         | 
| 237 | 
            +
                            Clavis::Logging.debug("#{provider_name}#process_callback - Using sub from id_token_claims as UID")
         | 
| 212 238 | 
             
                            token_data[:id_token_claims][:sub]
         | 
| 213 | 
            -
                          elsif user_info[:sub] | 
| 239 | 
            +
                          elsif user_info[:sub]
         | 
| 240 | 
            +
                            Clavis::Logging.debug("#{provider_name}#process_callback - Using sub from user_info as UID")
         | 
| 214 241 | 
             
                            user_info[:sub]
         | 
| 215 | 
            -
                          elsif user_info[:id] | 
| 242 | 
            +
                          elsif user_info[:id]
         | 
| 243 | 
            +
                            Clavis::Logging.debug("#{provider_name}#process_callback - Using id from user_info as UID")
         | 
| 216 244 | 
             
                            user_info[:id]
         | 
| 217 245 | 
             
                          else
         | 
| 218 246 | 
             
                            # Generate a hash of some token data for consistent ids
         | 
| 247 | 
            +
                            Clavis::Logging.debug("#{provider_name}#process_callback - Generating fallback UID")
         | 
| 219 248 | 
             
                            data_for_hash = "#{provider_name}:#{token_data[:access_token] || ""}:#{user_info[:email] || ""}"
         | 
| 220 | 
            -
                            Digest::SHA1.hexdigest(data_for_hash)[0 | 
| 249 | 
            +
                            Digest::SHA1.hexdigest(data_for_hash)[0...20] # Use first 20 characters for a longer UID
         | 
| 221 250 | 
             
                          end
         | 
| 222 251 |  | 
| 223 | 
            -
                    #  | 
| 252 | 
            +
                    Clavis::Logging.debug("#{provider_name}#process_callback - UID determined: #{uid}")
         | 
| 253 | 
            +
             | 
| 254 | 
            +
                    # Merge data from ID token if available
         | 
| 224 255 | 
             
                    id_token_claims = {}
         | 
| 225 | 
            -
                    if token_data[:id_token] | 
| 256 | 
            +
                    if token_data[:id_token]
         | 
| 226 257 | 
             
                      begin
         | 
| 258 | 
            +
                        Clavis::Logging.debug("#{provider_name}#process_callback - Decoding ID token")
         | 
| 227 259 | 
             
                        id_token_claims = decode_id_token(token_data[:id_token])
         | 
| 228 | 
            -
             | 
| 229 | 
            -
             | 
| 260 | 
            +
                        Clavis::Logging.debug("#{provider_name}#process_callback - ID token claims: #{id_token_claims.inspect}")
         | 
| 261 | 
            +
                      rescue StandardError => e
         | 
| 262 | 
            +
                        Clavis::Logging.debug("#{provider_name}#process_callback - Error decoding ID token: #{e.message}")
         | 
| 263 | 
            +
                        # Don't fail if we can't decode the ID token
         | 
| 230 264 | 
             
                      end
         | 
| 231 265 | 
             
                    end
         | 
| 232 266 |  | 
| 233 | 
            -
                    #  | 
| 234 | 
            -
                     | 
| 267 | 
            +
                    # Debug log ID token claims
         | 
| 268 | 
            +
                    Clavis::Logging.debug(
         | 
| 269 | 
            +
                      "#{provider_name}#process_id_token_if_present - ID token claims: " \
         | 
| 270 | 
            +
                      "#{id_token_claims.inspect}"
         | 
| 271 | 
            +
                    )
         | 
| 272 | 
            +
             | 
| 273 | 
            +
                    # Do any provider-specific user data processing
         | 
| 274 | 
            +
                    processed_user_data = process_user_data(user_data) if user_data
         | 
| 275 | 
            +
             | 
| 276 | 
            +
                    # Build the standardized auth hash
         | 
| 277 | 
            +
                    result = {
         | 
| 235 278 | 
             
                      provider: provider_name,
         | 
| 236 279 | 
             
                      uid: uid,
         | 
| 237 | 
            -
                      info: user_info,
         | 
| 280 | 
            +
                      info: user_info.merge(processed_user_data || {}),
         | 
| 238 281 | 
             
                      credentials: {
         | 
| 239 282 | 
             
                        token: token_data[:access_token],
         | 
| 240 283 | 
             
                        refresh_token: token_data[:refresh_token],
         | 
| 241 284 | 
             
                        expires_at: token_data[:expires_at],
         | 
| 242 | 
            -
                        expires:  | 
| 243 | 
            -
                      } | 
| 244 | 
            -
                      id_token: token_data[:id_token],
         | 
| 245 | 
            -
                      id_token_claims: id_token_claims
         | 
| 285 | 
            +
                        expires: !token_data[:expires_at].nil?
         | 
| 286 | 
            +
                      }
         | 
| 246 287 | 
             
                    }
         | 
| 288 | 
            +
             | 
| 289 | 
            +
                    # Add ID token if available
         | 
| 290 | 
            +
                    result[:id_token] = token_data[:id_token] if token_data[:id_token]
         | 
| 291 | 
            +
                    if token_data[:id_token_claims] || !id_token_claims.empty?
         | 
| 292 | 
            +
                      result[:id_token_claims] =
         | 
| 293 | 
            +
                        token_data[:id_token_claims] || id_token_claims
         | 
| 294 | 
            +
                    end
         | 
| 295 | 
            +
             | 
| 296 | 
            +
                    Clavis::Logging.debug("#{provider_name}#process_callback - Returning auth hash: #{result.inspect}")
         | 
| 297 | 
            +
                    result
         | 
| 247 298 | 
             
                  end
         | 
| 248 299 |  | 
| 249 300 | 
             
                  protected
         | 
| @@ -269,23 +320,35 @@ module Clavis | |
| 269 320 | 
             
                    Clavis::Security::HttpsEnforcer.create_http_client
         | 
| 270 321 | 
             
                  end
         | 
| 271 322 |  | 
| 272 | 
            -
                  def parse_token_response( | 
| 273 | 
            -
                    #  | 
| 274 | 
            -
                    if  | 
| 275 | 
            -
             | 
| 276 | 
            -
             | 
| 277 | 
            -
             | 
| 278 | 
            -
             | 
| 279 | 
            -
             | 
| 280 | 
            -
             | 
| 281 | 
            -
             | 
| 282 | 
            -
             | 
| 283 | 
            -
             | 
| 284 | 
            -
             | 
| 285 | 
            -
             | 
| 286 | 
            -
             | 
| 287 | 
            -
             | 
| 288 | 
            -
             | 
| 323 | 
            +
                  def parse_token_response(response_or_body)
         | 
| 324 | 
            +
                    # Handle both response objects and direct body inputs
         | 
| 325 | 
            +
                    response_body = if response_or_body.respond_to?(:body)
         | 
| 326 | 
            +
                                      # It's a response object
         | 
| 327 | 
            +
                                      response_or_body.body
         | 
| 328 | 
            +
                                    else
         | 
| 329 | 
            +
                                      # It's already a body
         | 
| 330 | 
            +
                                      response_or_body
         | 
| 331 | 
            +
                                    end
         | 
| 332 | 
            +
             | 
| 333 | 
            +
                    # Process the body based on its type
         | 
| 334 | 
            +
                    data = if response_body.is_a?(Hash)
         | 
| 335 | 
            +
                             response_body
         | 
| 336 | 
            +
                           elsif response_body.is_a?(String) && !response_body.empty?
         | 
| 337 | 
            +
                             # For tests that might not use Faraday's JSON middleware
         | 
| 338 | 
            +
                             begin
         | 
| 339 | 
            +
                               JSON.parse(response_body)
         | 
| 340 | 
            +
                             rescue JSON::ParserError
         | 
| 341 | 
            +
                               # Try to parse as form-encoded for non-JSON responses
         | 
| 342 | 
            +
                               begin
         | 
| 343 | 
            +
                                 Rack::Utils.parse_nested_query(response_body)
         | 
| 344 | 
            +
                               rescue StandardError
         | 
| 345 | 
            +
                                 {}
         | 
| 346 | 
            +
                               end
         | 
| 347 | 
            +
                             end
         | 
| 348 | 
            +
                           else
         | 
| 349 | 
            +
                             # Empty or nil response
         | 
| 350 | 
            +
                             {}
         | 
| 351 | 
            +
                           end
         | 
| 289 352 |  | 
| 290 353 | 
             
                    # Symbolize keys for consistency
         | 
| 291 354 | 
             
                    result = data.transform_keys(&:to_sym)
         | 
| @@ -294,16 +357,12 @@ module Clavis | |
| 294 357 | 
             
                    result[:token_type] ||= "Bearer" # Default token type
         | 
| 295 358 |  | 
| 296 359 | 
             
                    # Handle expires_in
         | 
| 297 | 
            -
                    if result[:expires_in] | 
| 298 | 
            -
                      #  | 
| 299 | 
            -
                       | 
| 360 | 
            +
                    if result[:expires_in]
         | 
| 361 | 
            +
                      # Convert to integer if possible
         | 
| 362 | 
            +
                      expires_in = result[:expires_in].to_i
         | 
| 363 | 
            +
                      result[:expires_at] = Time.now.to_i + expires_in if expires_in.positive?
         | 
| 300 364 | 
             
                    end
         | 
| 301 365 |  | 
| 302 | 
            -
                    # Validate token response (disable for debugging)
         | 
| 303 | 
            -
                    # unless Clavis::Security::InputValidator.valid_token_response?(result)
         | 
| 304 | 
            -
                    #  return {}
         | 
| 305 | 
            -
                    # end
         | 
| 306 | 
            -
             | 
| 307 366 | 
             
                    result
         | 
| 308 367 | 
             
                  end
         | 
| 309 368 |  | 
| @@ -400,6 +459,35 @@ module Clavis | |
| 400 459 | 
             
                    Rails.application.credentials.dig(:clavis, :providers, provider_name, key)
         | 
| 401 460 | 
             
                  end
         | 
| 402 461 |  | 
| 462 | 
            +
                  # Process ID token if present in the token response
         | 
| 463 | 
            +
                  def process_id_token_if_present(token_data)
         | 
| 464 | 
            +
                    # If there's an ID token in the response, and we're dealing with an OpenID provider,
         | 
| 465 | 
            +
                    # try to extract claims from it
         | 
| 466 | 
            +
                    if openid_provider? && token_data[:id_token] && !token_data[:id_token].to_s.empty?
         | 
| 467 | 
            +
                      begin
         | 
| 468 | 
            +
                        Clavis::Logging.debug("#{provider_name}#process_id_token_if_present - Processing ID token")
         | 
| 469 | 
            +
                        id_token_claims = decode_id_token(token_data[:id_token])
         | 
| 470 | 
            +
                        token_data[:id_token_claims] = id_token_claims
         | 
| 471 | 
            +
             | 
| 472 | 
            +
                        # Log claims with line breaks to avoid line length issues
         | 
| 473 | 
            +
                        log_message = "#{provider_name}#process_id_token_if_present - "
         | 
| 474 | 
            +
                        log_message += "ID token claims: #{id_token_claims.inspect}"
         | 
| 475 | 
            +
                        Clavis::Logging.debug(log_message)
         | 
| 476 | 
            +
                      rescue StandardError => e
         | 
| 477 | 
            +
                        # Log error with line breaks to avoid line length issues
         | 
| 478 | 
            +
                        log_message = "#{provider_name}#process_id_token_if_present - "
         | 
| 479 | 
            +
                        log_message += "Error decoding ID token: #{e.message}"
         | 
| 480 | 
            +
                        Clavis::Logging.debug(log_message)
         | 
| 481 | 
            +
             | 
| 482 | 
            +
                        # Continue with empty claims if we can't decode the token
         | 
| 483 | 
            +
                        token_data[:id_token_claims] = {}
         | 
| 484 | 
            +
                      end
         | 
| 485 | 
            +
                    end
         | 
| 486 | 
            +
             | 
| 487 | 
            +
                    # Return the token data, possibly with ID token claims
         | 
| 488 | 
            +
                    token_data
         | 
| 489 | 
            +
                  end
         | 
| 490 | 
            +
             | 
| 403 491 | 
             
                  private
         | 
| 404 492 |  | 
| 405 493 | 
             
                  def set_provider_name
         |