lib/attio/oauth/token.rb
# frozen_string_literal: true module Attio module OAuth # Represents an OAuth access token with refresh capabilities class Token attr_reader :access_token, :refresh_token, :token_type, :expires_in, :expires_at, :scope, :created_at, :client def initialize(attributes = {}) # Since this doesn't inherit from Resources::Base, we need to normalize normalized_attrs = normalize_attributes(attributes) @access_token = normalized_attrs[:access_token] @refresh_token = normalized_attrs[:refresh_token] @token_type = normalized_attrs[:token_type] || "Bearer" @expires_in = normalized_attrs[:expires_in]&.to_i @scope = parse_scope(normalized_attrs[:scope]) @created_at = normalized_attrs[:created_at] || Time.now.utc @client = normalized_attrs[:client] calculate_expiration! validate! end def expired? return false if @expires_at.nil? Time.now.utc >= @expires_at end def expires_soon?(threshold = 300) return false if @expires_at.nil? Time.now.utc >= (@expires_at - threshold) end def refresh! raise InvalidTokenError, "No refresh token available" unless @refresh_token raise InvalidTokenError, "No OAuth client configured" unless @client new_token = @client.refresh_token(@refresh_token) update_from(new_token) self end def revoke! raise InvalidTokenError, "No OAuth client configured" unless @client @client.revoke_token(self) @access_token = nil @refresh_token = nil true end # Convert token to hash representation # @return [Hash] Token attributes as a hash def to_h { access_token: @access_token, refresh_token: @refresh_token, token_type: @token_type, expires_in: @expires_in, expires_at: @expires_at&.iso8601, scope: @scope, created_at: @created_at.iso8601 }.compact end # Convert token to JSON string # @param opts [Hash] Options to pass to JSON.generate # @return [String] JSON representation of the token def to_json(*opts) JSON.generate(to_h, *opts) end # Human-readable representation with masked token # @return [String] Inspection string with partially masked token def inspect scope_str = @scope.is_a?(Array) ? @scope.join(" ") : @scope.to_s "#<#{self.class.name}:#{object_id} " \ "token=#{@access_token ? "***" + @access_token[-4..] : "nil"} " \ "expires_at=#{@expires_at&.iso8601} " \ "scope=#{scope_str}>" end # Authorization header value def authorization_header "#{@token_type} #{@access_token}" end # Check if token has specific scope def has_scope?(scope) @scope.include?(scope.to_s) end # Store token securely (subclasses can override) def save # Default implementation does nothing # Subclasses can implement secure storage self end # Load token from secure storage (class method) def self.load(identifier = nil) # Default implementation returns nil # Subclasses can implement secure retrieval nil end private def calculate_expiration! @expires_at = if @expires_in @created_at + @expires_in end end def parse_scope(scope) case scope when String scope.split(" ") when Array scope.map(&:to_s) when NilClass # If scope is not provided in the token response, return nil # This indicates the token has all scopes that were authorized nil else [] end end def validate! raise InvalidTokenError, "Access token is required" if @access_token.nil? || @access_token.empty? raise InvalidTokenError, "Invalid token type" unless %w[Bearer bearer].include?(@token_type) end def update_from(other_token) @access_token = other_token.access_token @refresh_token = other_token.refresh_token if other_token.refresh_token @token_type = other_token.token_type @expires_in = other_token.expires_in @expires_at = other_token.expires_at @scope = other_token.scope @created_at = other_token.created_at end def normalize_attributes(attributes) return {} unless attributes attributes.each_with_object({}) do |(key, value), hash| hash[key.to_sym] = value end end # Raised when token validation fails class InvalidTokenError < StandardError; end end end end