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
|