attio-ruby 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +164 -0
- data/.simplecov +17 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +27 -0
- data/CONTRIBUTING.md +333 -0
- data/INTEGRATION_TEST_STATUS.md +149 -0
- data/LICENSE +21 -0
- data/README.md +638 -0
- data/Rakefile +8 -0
- data/attio-ruby.gemspec +61 -0
- data/docs/CODECOV_SETUP.md +34 -0
- data/examples/basic_usage.rb +149 -0
- data/examples/oauth_flow.rb +843 -0
- data/examples/oauth_flow_README.md +84 -0
- data/examples/typed_records_example.rb +167 -0
- data/examples/webhook_server.rb +463 -0
- data/lib/attio/api_resource.rb +539 -0
- data/lib/attio/builders/name_builder.rb +181 -0
- data/lib/attio/client.rb +160 -0
- data/lib/attio/errors.rb +126 -0
- data/lib/attio/internal/record.rb +359 -0
- data/lib/attio/oauth/client.rb +219 -0
- data/lib/attio/oauth/scope_validator.rb +162 -0
- data/lib/attio/oauth/token.rb +158 -0
- data/lib/attio/resources/attribute.rb +332 -0
- data/lib/attio/resources/comment.rb +114 -0
- data/lib/attio/resources/company.rb +224 -0
- data/lib/attio/resources/entry.rb +208 -0
- data/lib/attio/resources/list.rb +196 -0
- data/lib/attio/resources/meta.rb +113 -0
- data/lib/attio/resources/note.rb +213 -0
- data/lib/attio/resources/object.rb +66 -0
- data/lib/attio/resources/person.rb +294 -0
- data/lib/attio/resources/task.rb +147 -0
- data/lib/attio/resources/thread.rb +99 -0
- data/lib/attio/resources/typed_record.rb +98 -0
- data/lib/attio/resources/webhook.rb +224 -0
- data/lib/attio/resources/workspace_member.rb +136 -0
- data/lib/attio/util/configuration.rb +166 -0
- data/lib/attio/util/id_extractor.rb +115 -0
- data/lib/attio/util/webhook_signature.rb +175 -0
- data/lib/attio/version.rb +6 -0
- data/lib/attio/webhook/event.rb +114 -0
- data/lib/attio/webhook/signature_verifier.rb +73 -0
- data/lib/attio.rb +123 -0
- metadata +402 -0
@@ -0,0 +1,219 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
require "securerandom"
|
5
|
+
require "base64"
|
6
|
+
|
7
|
+
module Attio
|
8
|
+
# OAuth authentication module for Attio API
|
9
|
+
module OAuth
|
10
|
+
# OAuth client for handling the OAuth 2.0 authorization flow
|
11
|
+
class Client
|
12
|
+
# OAuth endpoints
|
13
|
+
OAUTH_BASE_URL = "https://app.attio.com/authorize"
|
14
|
+
# Token exchange endpoint
|
15
|
+
TOKEN_URL = "https://app.attio.com/oauth/token"
|
16
|
+
# Default OAuth scopes requested if none specified
|
17
|
+
DEFAULT_SCOPES = %w[
|
18
|
+
record:read
|
19
|
+
record:write
|
20
|
+
object:read
|
21
|
+
object:write
|
22
|
+
list:read
|
23
|
+
list:write
|
24
|
+
webhook:read
|
25
|
+
webhook:write
|
26
|
+
user:read
|
27
|
+
].freeze
|
28
|
+
|
29
|
+
attr_reader :client_id, :client_secret, :redirect_uri
|
30
|
+
|
31
|
+
def initialize(client_id:, client_secret:, redirect_uri:)
|
32
|
+
@client_id = client_id
|
33
|
+
@client_secret = client_secret
|
34
|
+
@redirect_uri = redirect_uri
|
35
|
+
validate_config!
|
36
|
+
end
|
37
|
+
|
38
|
+
# Generate authorization URL for OAuth flow
|
39
|
+
def authorization_url(scopes: DEFAULT_SCOPES, state: nil, extras: {})
|
40
|
+
state ||= generate_state
|
41
|
+
scopes = validate_scopes(scopes)
|
42
|
+
|
43
|
+
params = {
|
44
|
+
client_id: client_id,
|
45
|
+
redirect_uri: redirect_uri,
|
46
|
+
response_type: "code",
|
47
|
+
scope: scopes.join(" "),
|
48
|
+
state: state
|
49
|
+
}.merge(extras)
|
50
|
+
|
51
|
+
uri = URI.parse(OAUTH_BASE_URL)
|
52
|
+
uri.query = URI.encode_www_form(params)
|
53
|
+
|
54
|
+
{
|
55
|
+
url: uri.to_s,
|
56
|
+
state: state
|
57
|
+
}
|
58
|
+
end
|
59
|
+
|
60
|
+
# Exchange authorization code for access token
|
61
|
+
def exchange_code_for_token(code:, state: nil)
|
62
|
+
raise ArgumentError, "Authorization code is required" if code.nil? || code.empty?
|
63
|
+
|
64
|
+
params = {
|
65
|
+
grant_type: "authorization_code",
|
66
|
+
code: code,
|
67
|
+
redirect_uri: redirect_uri,
|
68
|
+
client_id: client_id,
|
69
|
+
client_secret: client_secret
|
70
|
+
}
|
71
|
+
|
72
|
+
response = make_token_request(params)
|
73
|
+
Token.new(response.merge(client: self))
|
74
|
+
end
|
75
|
+
|
76
|
+
# Refresh an existing access token
|
77
|
+
def refresh_token(refresh_token)
|
78
|
+
raise ArgumentError, "Refresh token is required" if refresh_token.nil? || refresh_token.empty?
|
79
|
+
|
80
|
+
params = {
|
81
|
+
grant_type: "refresh_token",
|
82
|
+
refresh_token: refresh_token,
|
83
|
+
client_id: client_id,
|
84
|
+
client_secret: client_secret
|
85
|
+
}
|
86
|
+
|
87
|
+
response = make_token_request(params)
|
88
|
+
Token.new(response.merge(client: self))
|
89
|
+
end
|
90
|
+
|
91
|
+
# Revoke a token
|
92
|
+
def revoke_token(token)
|
93
|
+
token_value = token.is_a?(Token) ? token.access_token : token
|
94
|
+
|
95
|
+
params = {
|
96
|
+
token: token_value,
|
97
|
+
client_id: client_id,
|
98
|
+
client_secret: client_secret
|
99
|
+
}
|
100
|
+
|
101
|
+
# Use Faraday directly for OAuth endpoints
|
102
|
+
conn = create_oauth_connection
|
103
|
+
response = conn.post("/oauth/revoke") do |req|
|
104
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
105
|
+
req.body = URI.encode_www_form(params)
|
106
|
+
end
|
107
|
+
|
108
|
+
response.success?
|
109
|
+
rescue => e
|
110
|
+
# Log the error if debug mode is enabled
|
111
|
+
warn "OAuth token revocation failed: #{e.message}" if Attio.configuration.debug
|
112
|
+
false
|
113
|
+
end
|
114
|
+
|
115
|
+
# Validate token with introspection endpoint
|
116
|
+
def introspect_token(token)
|
117
|
+
token_value = token.is_a?(Token) ? token.access_token : token
|
118
|
+
|
119
|
+
params = {
|
120
|
+
token: token_value,
|
121
|
+
client_id: client_id,
|
122
|
+
client_secret: client_secret
|
123
|
+
}
|
124
|
+
|
125
|
+
# Use Faraday directly for OAuth endpoints
|
126
|
+
conn = create_oauth_connection
|
127
|
+
response = conn.post("/oauth/introspect") do |req|
|
128
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
129
|
+
req.body = URI.encode_www_form(params)
|
130
|
+
end
|
131
|
+
|
132
|
+
if response.success?
|
133
|
+
response.body
|
134
|
+
else
|
135
|
+
handle_oauth_error(response)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
def validate_config!
|
142
|
+
raise ArgumentError, "client_id is required" if client_id.nil? || client_id.empty?
|
143
|
+
raise ArgumentError, "client_secret is required" if client_secret.nil? || client_secret.empty?
|
144
|
+
raise ArgumentError, "redirect_uri is required" if redirect_uri.nil? || redirect_uri.empty?
|
145
|
+
|
146
|
+
unless redirect_uri.start_with?("http://", "https://")
|
147
|
+
raise ArgumentError, "redirect_uri must be a valid HTTP(S) URL"
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def validate_scopes(scopes)
|
152
|
+
scopes = Array(scopes).map(&:to_s)
|
153
|
+
return DEFAULT_SCOPES if scopes.empty?
|
154
|
+
|
155
|
+
invalid_scopes = scopes - ScopeValidator::VALID_SCOPES
|
156
|
+
unless invalid_scopes.empty?
|
157
|
+
raise ArgumentError, "Invalid scopes: #{invalid_scopes.join(", ")}"
|
158
|
+
end
|
159
|
+
|
160
|
+
scopes
|
161
|
+
end
|
162
|
+
|
163
|
+
def generate_state
|
164
|
+
SecureRandom.urlsafe_base64(32)
|
165
|
+
end
|
166
|
+
|
167
|
+
def make_token_request(params)
|
168
|
+
conn = Faraday.new do |faraday|
|
169
|
+
faraday.request :url_encoded
|
170
|
+
faraday.response :json, parser_options: {symbolize_names: true}
|
171
|
+
faraday.adapter Faraday.default_adapter
|
172
|
+
end
|
173
|
+
|
174
|
+
response = conn.post(TOKEN_URL, params) do |req|
|
175
|
+
req.headers["Accept"] = "application/json"
|
176
|
+
end
|
177
|
+
|
178
|
+
if response.success?
|
179
|
+
response.body
|
180
|
+
else
|
181
|
+
handle_oauth_error(response)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def create_oauth_connection
|
186
|
+
Faraday.new(url: "https://app.attio.com") do |faraday|
|
187
|
+
faraday.response :json, parser_options: {symbolize_names: true}
|
188
|
+
faraday.adapter Faraday.default_adapter
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def handle_oauth_error(response)
|
193
|
+
error_body = begin
|
194
|
+
response.body
|
195
|
+
rescue
|
196
|
+
{}
|
197
|
+
end
|
198
|
+
error_message = if error_body.is_a?(Hash)
|
199
|
+
error_body[:error_description] || error_body[:error] || "OAuth error"
|
200
|
+
else
|
201
|
+
"OAuth error"
|
202
|
+
end
|
203
|
+
|
204
|
+
case response.status
|
205
|
+
when 400
|
206
|
+
raise BadRequestError, error_message
|
207
|
+
when 401
|
208
|
+
raise AuthenticationError, error_message
|
209
|
+
when 403
|
210
|
+
raise ForbiddenError, error_message
|
211
|
+
when 404
|
212
|
+
raise NotFoundError, error_message
|
213
|
+
else
|
214
|
+
raise Error, "OAuth error: #{error_message} (status: #{response.status})"
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
module OAuth
|
5
|
+
# Validates and manages OAuth scopes for Attio API
|
6
|
+
class ScopeValidator
|
7
|
+
# Define all valid scopes with their descriptions
|
8
|
+
SCOPE_DEFINITIONS = {
|
9
|
+
# Record scopes
|
10
|
+
"record:read" => "Read access to records",
|
11
|
+
"record:write" => "Write access to records (includes read)",
|
12
|
+
|
13
|
+
# Object scopes
|
14
|
+
"object:read" => "Read access to objects and their configuration",
|
15
|
+
"object:write" => "Write access to objects (includes read)",
|
16
|
+
|
17
|
+
# List scopes
|
18
|
+
"list:read" => "Read access to lists and list entries",
|
19
|
+
"list:write" => "Write access to lists (includes read)",
|
20
|
+
|
21
|
+
# Webhook scopes
|
22
|
+
"webhook:read" => "Read access to webhooks",
|
23
|
+
"webhook:write" => "Write access to webhooks (includes read)",
|
24
|
+
|
25
|
+
# User scopes
|
26
|
+
"user:read" => "Read access to workspace members",
|
27
|
+
|
28
|
+
# Note scopes
|
29
|
+
"note:read" => "Read access to notes",
|
30
|
+
"note:write" => "Write access to notes (includes read)",
|
31
|
+
|
32
|
+
# Attribute scopes
|
33
|
+
"attribute:read" => "Read access to attributes",
|
34
|
+
"attribute:write" => "Write access to attributes (includes read)",
|
35
|
+
|
36
|
+
# Comment scopes
|
37
|
+
"comment:read" => "Read access to comments",
|
38
|
+
"comment:write" => "Write access to comments (includes read)",
|
39
|
+
|
40
|
+
# Task scopes
|
41
|
+
"task:read" => "Read access to tasks",
|
42
|
+
"task:write" => "Write access to tasks (includes read)"
|
43
|
+
}.freeze
|
44
|
+
|
45
|
+
# Array of all valid scope strings
|
46
|
+
VALID_SCOPES = SCOPE_DEFINITIONS.keys.freeze
|
47
|
+
|
48
|
+
# Scope hierarchy - write scopes include read scopes
|
49
|
+
SCOPE_HIERARCHY = {
|
50
|
+
"record:write" => ["record:read"],
|
51
|
+
"object:write" => ["object:read"],
|
52
|
+
"list:write" => ["list:read"],
|
53
|
+
"webhook:write" => ["webhook:read"],
|
54
|
+
"note:write" => ["note:read"],
|
55
|
+
"attribute:write" => ["attribute:read"],
|
56
|
+
"comment:write" => ["comment:read"],
|
57
|
+
"task:write" => ["task:read"]
|
58
|
+
}.freeze
|
59
|
+
|
60
|
+
class << self
|
61
|
+
# Validate that all provided scopes are valid
|
62
|
+
# @param scopes [Array<String>, String] Scopes to validate
|
63
|
+
# @return [Array<String>] Validated scopes
|
64
|
+
# @raise [InvalidScopeError] If any scope is invalid
|
65
|
+
def validate(scopes)
|
66
|
+
scopes = Array(scopes).map(&:to_s)
|
67
|
+
invalid_scopes = scopes - VALID_SCOPES
|
68
|
+
|
69
|
+
unless invalid_scopes.empty?
|
70
|
+
raise InvalidScopeError, "Invalid scopes: #{invalid_scopes.join(", ")}"
|
71
|
+
end
|
72
|
+
|
73
|
+
scopes
|
74
|
+
end
|
75
|
+
|
76
|
+
# Validate scopes and return true if valid
|
77
|
+
# @param scopes [Array<String>, String] Scopes to validate
|
78
|
+
# @return [true]
|
79
|
+
# @raise [InvalidScopeError] If any scope is invalid
|
80
|
+
def validate!(scopes)
|
81
|
+
validate(scopes)
|
82
|
+
true
|
83
|
+
end
|
84
|
+
|
85
|
+
def valid?(scope)
|
86
|
+
VALID_SCOPES.include?(scope.to_s)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Get the description for a scope
|
90
|
+
# @param scope [String] The scope to describe
|
91
|
+
# @return [String, nil] Description or nil if scope not found
|
92
|
+
def description(scope)
|
93
|
+
SCOPE_DEFINITIONS[scope.to_s]
|
94
|
+
end
|
95
|
+
|
96
|
+
# Check if a set of scopes includes a specific permission
|
97
|
+
def includes?(scopes, required_scope)
|
98
|
+
scopes = Array(scopes).map(&:to_s)
|
99
|
+
required = required_scope.to_s
|
100
|
+
|
101
|
+
return true if scopes.include?(required)
|
102
|
+
|
103
|
+
# Check if any scope in the set provides the required scope
|
104
|
+
scopes.any? do |scope|
|
105
|
+
implied_scopes = SCOPE_HIERARCHY[scope] || []
|
106
|
+
implied_scopes.include?(required)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# Expand scopes to include all implied scopes
|
111
|
+
def expand(scopes)
|
112
|
+
scopes = Array(scopes).map(&:to_s)
|
113
|
+
expanded = Set.new(scopes)
|
114
|
+
|
115
|
+
scopes.each do |scope|
|
116
|
+
implied = SCOPE_HIERARCHY[scope] || []
|
117
|
+
expanded.merge(implied)
|
118
|
+
end
|
119
|
+
|
120
|
+
expanded.to_a.sort
|
121
|
+
end
|
122
|
+
|
123
|
+
# Get minimal set of scopes (remove redundant read scopes)
|
124
|
+
def minimize(scopes)
|
125
|
+
scopes = Array(scopes).map(&:to_s)
|
126
|
+
minimized = scopes.dup
|
127
|
+
|
128
|
+
SCOPE_HIERARCHY.each do |write_scope, read_scopes|
|
129
|
+
if minimized.include?(write_scope)
|
130
|
+
minimized -= read_scopes
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
minimized.sort
|
135
|
+
end
|
136
|
+
|
137
|
+
# Group scopes by resource type
|
138
|
+
def group_by_resource(scopes)
|
139
|
+
scopes = Array(scopes).map(&:to_s)
|
140
|
+
grouped = {}
|
141
|
+
|
142
|
+
scopes.each do |scope|
|
143
|
+
resource = scope.split(":").first
|
144
|
+
grouped[resource] ||= []
|
145
|
+
grouped[resource] << scope
|
146
|
+
end
|
147
|
+
|
148
|
+
grouped
|
149
|
+
end
|
150
|
+
|
151
|
+
# Check if scopes are sufficient for an operation
|
152
|
+
def sufficient_for?(scopes, resource:, operation:)
|
153
|
+
required_scope = "#{resource}:#{operation}"
|
154
|
+
includes?(scopes, required_scope)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
# Raised when invalid scopes are provided
|
159
|
+
class InvalidScopeError < StandardError; end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
module OAuth
|
5
|
+
# Represents an OAuth access token with refresh capabilities
|
6
|
+
class Token
|
7
|
+
attr_reader :access_token, :refresh_token, :token_type, :expires_in,
|
8
|
+
:expires_at, :scope, :created_at, :client
|
9
|
+
|
10
|
+
def initialize(attributes = {})
|
11
|
+
# Since this doesn't inherit from Resources::Base, we need to normalize
|
12
|
+
normalized_attrs = normalize_attributes(attributes)
|
13
|
+
@access_token = normalized_attrs[:access_token]
|
14
|
+
@refresh_token = normalized_attrs[:refresh_token]
|
15
|
+
@token_type = normalized_attrs[:token_type] || "Bearer"
|
16
|
+
@expires_in = normalized_attrs[:expires_in]&.to_i
|
17
|
+
@scope = parse_scope(normalized_attrs[:scope])
|
18
|
+
@created_at = normalized_attrs[:created_at] || Time.now.utc
|
19
|
+
@client = normalized_attrs[:client]
|
20
|
+
|
21
|
+
calculate_expiration!
|
22
|
+
validate!
|
23
|
+
end
|
24
|
+
|
25
|
+
def expired?
|
26
|
+
return false if @expires_at.nil?
|
27
|
+
Time.now.utc >= @expires_at
|
28
|
+
end
|
29
|
+
|
30
|
+
def expires_soon?(threshold = 300)
|
31
|
+
return false if @expires_at.nil?
|
32
|
+
Time.now.utc >= (@expires_at - threshold)
|
33
|
+
end
|
34
|
+
|
35
|
+
def refresh!
|
36
|
+
raise InvalidTokenError, "No refresh token available" unless @refresh_token
|
37
|
+
raise InvalidTokenError, "No OAuth client configured" unless @client
|
38
|
+
|
39
|
+
new_token = @client.refresh_token(@refresh_token)
|
40
|
+
update_from(new_token)
|
41
|
+
self
|
42
|
+
end
|
43
|
+
|
44
|
+
def revoke!
|
45
|
+
raise InvalidTokenError, "No OAuth client configured" unless @client
|
46
|
+
|
47
|
+
@client.revoke_token(self)
|
48
|
+
@access_token = nil
|
49
|
+
@refresh_token = nil
|
50
|
+
true
|
51
|
+
end
|
52
|
+
|
53
|
+
# Convert token to hash representation
|
54
|
+
# @return [Hash] Token attributes as a hash
|
55
|
+
def to_h
|
56
|
+
{
|
57
|
+
access_token: @access_token,
|
58
|
+
refresh_token: @refresh_token,
|
59
|
+
token_type: @token_type,
|
60
|
+
expires_in: @expires_in,
|
61
|
+
expires_at: @expires_at&.iso8601,
|
62
|
+
scope: @scope,
|
63
|
+
created_at: @created_at.iso8601
|
64
|
+
}.compact
|
65
|
+
end
|
66
|
+
|
67
|
+
# Convert token to JSON string
|
68
|
+
# @param opts [Hash] Options to pass to JSON.generate
|
69
|
+
# @return [String] JSON representation of the token
|
70
|
+
def to_json(*opts)
|
71
|
+
JSON.generate(to_h, *opts)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Human-readable representation with masked token
|
75
|
+
# @return [String] Inspection string with partially masked token
|
76
|
+
def inspect
|
77
|
+
scope_str = @scope.is_a?(Array) ? @scope.join(" ") : @scope.to_s
|
78
|
+
"#<#{self.class.name}:#{object_id} " \
|
79
|
+
"token=#{@access_token ? "***" + @access_token[-4..] : "nil"} " \
|
80
|
+
"expires_at=#{@expires_at&.iso8601} " \
|
81
|
+
"scope=#{scope_str}>"
|
82
|
+
end
|
83
|
+
|
84
|
+
# Authorization header value
|
85
|
+
def authorization_header
|
86
|
+
"#{@token_type} #{@access_token}"
|
87
|
+
end
|
88
|
+
|
89
|
+
# Check if token has specific scope
|
90
|
+
def has_scope?(scope)
|
91
|
+
@scope.include?(scope.to_s)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Store token securely (subclasses can override)
|
95
|
+
def save
|
96
|
+
# Default implementation does nothing
|
97
|
+
# Subclasses can implement secure storage
|
98
|
+
self
|
99
|
+
end
|
100
|
+
|
101
|
+
# Load token from secure storage (class method)
|
102
|
+
def self.load(identifier = nil)
|
103
|
+
# Default implementation returns nil
|
104
|
+
# Subclasses can implement secure retrieval
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def calculate_expiration!
|
111
|
+
@expires_at = if @expires_in
|
112
|
+
@created_at + @expires_in
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def parse_scope(scope)
|
117
|
+
case scope
|
118
|
+
when String
|
119
|
+
scope.split(" ")
|
120
|
+
when Array
|
121
|
+
scope.map(&:to_s)
|
122
|
+
when NilClass
|
123
|
+
# If scope is not provided in the token response, return nil
|
124
|
+
# This indicates the token has all scopes that were authorized
|
125
|
+
nil
|
126
|
+
else
|
127
|
+
[]
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def validate!
|
132
|
+
raise InvalidTokenError, "Access token is required" if @access_token.nil? || @access_token.empty?
|
133
|
+
raise InvalidTokenError, "Invalid token type" unless %w[Bearer bearer].include?(@token_type)
|
134
|
+
end
|
135
|
+
|
136
|
+
def update_from(other_token)
|
137
|
+
@access_token = other_token.access_token
|
138
|
+
@refresh_token = other_token.refresh_token if other_token.refresh_token
|
139
|
+
@token_type = other_token.token_type
|
140
|
+
@expires_in = other_token.expires_in
|
141
|
+
@expires_at = other_token.expires_at
|
142
|
+
@scope = other_token.scope
|
143
|
+
@created_at = other_token.created_at
|
144
|
+
end
|
145
|
+
|
146
|
+
def normalize_attributes(attributes)
|
147
|
+
return {} unless attributes
|
148
|
+
|
149
|
+
attributes.each_with_object({}) do |(key, value), hash|
|
150
|
+
hash[key.to_sym] = value
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Raised when token validation fails
|
155
|
+
class InvalidTokenError < StandardError; end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|