lib/attio/oauth/client.rb



# frozen_string_literal: true

require "uri"
require "securerandom"
require "base64"

module Attio
  # OAuth authentication module for Attio API
  module OAuth
    # OAuth client for handling the OAuth 2.0 authorization flow
    class Client
      # OAuth endpoints
      OAUTH_BASE_URL = "https://app.attio.com/authorize"
      # Token exchange endpoint
      TOKEN_URL = "https://app.attio.com/oauth/token"
      # Default OAuth scopes requested if none specified
      DEFAULT_SCOPES = %w[
        record:read
        record:write
        object:read
        object:write
        list:read
        list:write
        webhook:read
        webhook:write
        user:read
      ].freeze

      attr_reader :client_id, :client_secret, :redirect_uri

      def initialize(client_id:, client_secret:, redirect_uri:)
        @client_id = client_id
        @client_secret = client_secret
        @redirect_uri = redirect_uri
        validate_config!
      end

      # Generate authorization URL for OAuth flow
      def authorization_url(scopes: DEFAULT_SCOPES, state: nil, extras: {})
        state ||= generate_state
        scopes = validate_scopes(scopes)

        params = {
          client_id: client_id,
          redirect_uri: redirect_uri,
          response_type: "code",
          scope: scopes.join(" "),
          state: state
        }.merge(extras)

        uri = URI.parse(OAUTH_BASE_URL)
        uri.query = URI.encode_www_form(params)

        {
          url: uri.to_s,
          state: state
        }
      end

      # Exchange authorization code for access token
      def exchange_code_for_token(code:, state: nil)
        raise ArgumentError, "Authorization code is required" if code.nil? || code.empty?

        params = {
          grant_type: "authorization_code",
          code: code,
          redirect_uri: redirect_uri,
          client_id: client_id,
          client_secret: client_secret
        }

        response = make_token_request(params)
        Token.new(response.merge(client: self))
      end

      # Refresh an existing access token
      def refresh_token(refresh_token)
        raise ArgumentError, "Refresh token is required" if refresh_token.nil? || refresh_token.empty?

        params = {
          grant_type: "refresh_token",
          refresh_token: refresh_token,
          client_id: client_id,
          client_secret: client_secret
        }

        response = make_token_request(params)
        Token.new(response.merge(client: self))
      end

      # Revoke a token
      def revoke_token(token)
        token_value = token.is_a?(Token) ? token.access_token : token

        params = {
          token: token_value,
          client_id: client_id,
          client_secret: client_secret
        }

        # Use Faraday directly for OAuth endpoints
        conn = create_oauth_connection
        response = conn.post("/oauth/revoke") do |req|
          req.headers["Content-Type"] = "application/x-www-form-urlencoded"
          req.body = URI.encode_www_form(params)
        end

        response.success?
      rescue => e
        # Log the error if debug mode is enabled
        warn "OAuth token revocation failed: #{e.message}" if Attio.configuration.debug
        false
      end

      # Validate token with introspection endpoint
      def introspect_token(token)
        token_value = token.is_a?(Token) ? token.access_token : token

        params = {
          token: token_value,
          client_id: client_id,
          client_secret: client_secret
        }

        # Use Faraday directly for OAuth endpoints
        conn = create_oauth_connection
        response = conn.post("/oauth/introspect") do |req|
          req.headers["Content-Type"] = "application/x-www-form-urlencoded"
          req.body = URI.encode_www_form(params)
        end

        if response.success?
          response.body
        else
          handle_oauth_error(response)
        end
      end

      private

      def validate_config!
        raise ArgumentError, "client_id is required" if client_id.nil? || client_id.empty?
        raise ArgumentError, "client_secret is required" if client_secret.nil? || client_secret.empty?
        raise ArgumentError, "redirect_uri is required" if redirect_uri.nil? || redirect_uri.empty?

        unless redirect_uri.start_with?("http://", "https://")
          raise ArgumentError, "redirect_uri must be a valid HTTP(S) URL"
        end
      end

      def validate_scopes(scopes)
        scopes = Array(scopes).map(&:to_s)
        return DEFAULT_SCOPES if scopes.empty?

        invalid_scopes = scopes - ScopeValidator::VALID_SCOPES
        unless invalid_scopes.empty?
          raise ArgumentError, "Invalid scopes: #{invalid_scopes.join(", ")}"
        end

        scopes
      end

      def generate_state
        SecureRandom.urlsafe_base64(32)
      end

      def make_token_request(params)
        conn = Faraday.new do |faraday|
          faraday.request :url_encoded
          faraday.response :json, parser_options: {symbolize_names: true}
          faraday.adapter Faraday.default_adapter
        end

        response = conn.post(TOKEN_URL, params) do |req|
          req.headers["Accept"] = "application/json"
        end

        if response.success?
          response.body
        else
          handle_oauth_error(response)
        end
      end

      def create_oauth_connection
        Faraday.new(url: "https://app.attio.com") do |faraday|
          faraday.response :json, parser_options: {symbolize_names: true}
          faraday.adapter Faraday.default_adapter
        end
      end

      def handle_oauth_error(response)
        error_body = begin
          response.body
        rescue
          {}
        end
        error_message = if error_body.is_a?(Hash)
          error_body[:error_description] || error_body[:error] || "OAuth error"
        else
          "OAuth error"
        end

        case response.status
        when 400
          raise BadRequestError, error_message
        when 401
          raise AuthenticationError, error_message
        when 403
          raise ForbiddenError, error_message
        when 404
          raise NotFoundError, error_message
        else
          raise Error, "OAuth error: #{error_message} (status: #{response.status})"
        end
      end
    end
  end
end