supabase-auth 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/README.md +233 -0
- data/lib/supabase/auth/admin_api.rb +123 -0
- data/lib/supabase/auth/api.rb +115 -0
- data/lib/supabase/auth/client.rb +1209 -0
- data/lib/supabase/auth/constants.rb +32 -0
- data/lib/supabase/auth/errors.rb +206 -0
- data/lib/supabase/auth/helpers.rb +223 -0
- data/lib/supabase/auth/memory_storage.rb +25 -0
- data/lib/supabase/auth/storage.rb +19 -0
- data/lib/supabase/auth/timer.rb +40 -0
- data/lib/supabase/auth/types.rb +435 -0
- data/lib/supabase/auth/version.rb +7 -0
- data/lib/supabase/auth.rb +18 -0
- data/lib/supabase-auth.rb +3 -0
- metadata +159 -0
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Supabase
|
|
8
|
+
module Auth
|
|
9
|
+
# Client for Supabase Auth (GoTrue) API.
|
|
10
|
+
# Handles authentication flows including sign-up, sign-in, session management,
|
|
11
|
+
# OAuth, OTP, MFA, and identity management.
|
|
12
|
+
class Client
|
|
13
|
+
STORAGE_KEY = "supabase.auth.token"
|
|
14
|
+
EXPIRY_MARGIN = 10
|
|
15
|
+
JWKS_TTL = 600 # 10 minutes
|
|
16
|
+
# Explicit algorithm-to-digest mapping; Python uses PyJWT's dynamic get_algorithm_by_name (F-008).
|
|
17
|
+
ALG_TO_DIGEST = {
|
|
18
|
+
"RS256" => "SHA256", "RS384" => "SHA384", "RS512" => "SHA512",
|
|
19
|
+
"ES256" => "SHA256", "ES384" => "SHA384", "ES512" => "SHA512",
|
|
20
|
+
"PS256" => "SHA256", "PS384" => "SHA384", "PS512" => "SHA512"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
DEFAULT_OPTIONS = {
|
|
24
|
+
auto_refresh_token: true,
|
|
25
|
+
persist_session: true,
|
|
26
|
+
detect_session_in_url: true,
|
|
27
|
+
flow_type: "implicit"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :url, :headers, :admin, :mfa
|
|
31
|
+
|
|
32
|
+
# @param url [String] GoTrue server URL
|
|
33
|
+
# @param headers [Hash] HTTP headers to include with every request
|
|
34
|
+
# @param options [Hash] configuration options
|
|
35
|
+
# @option options [Boolean] :auto_refresh_token (true) automatically refresh tokens
|
|
36
|
+
# @option options [Boolean] :persist_session (true) persist session to storage
|
|
37
|
+
# @option options [String] :flow_type ("implicit") OAuth flow type ("implicit" or "pkce")
|
|
38
|
+
# @option options [SupportedStorage] :storage custom storage backend
|
|
39
|
+
# @option options [Faraday::Connection] :http_client custom HTTP client
|
|
40
|
+
def initialize(url:, headers: {}, **options)
|
|
41
|
+
opts = DEFAULT_OPTIONS.merge(options)
|
|
42
|
+
@url = url
|
|
43
|
+
@headers = headers
|
|
44
|
+
@auto_refresh_token = opts[:auto_refresh_token]
|
|
45
|
+
@persist_session = opts[:persist_session]
|
|
46
|
+
@detect_session_in_url = opts[:detect_session_in_url]
|
|
47
|
+
@flow_type = opts[:flow_type].to_s
|
|
48
|
+
@storage_key = opts[:storage_key] || STORAGE_KEY
|
|
49
|
+
@storage = opts[:storage] || MemoryStorage.new
|
|
50
|
+
@http_client = opts[:http_client]
|
|
51
|
+
|
|
52
|
+
@current_session = nil
|
|
53
|
+
@jwks = { "keys" => [] }
|
|
54
|
+
@jwks_cached_at = nil
|
|
55
|
+
@state_change_emitters = {}
|
|
56
|
+
@refresh_token_timer = nil
|
|
57
|
+
@network_retries = 0
|
|
58
|
+
|
|
59
|
+
@api = Api.new(url: @url, headers: @headers, http_client: @http_client)
|
|
60
|
+
@admin = AdminApi.new(url: @url, headers: @headers, http_client: @http_client)
|
|
61
|
+
@mfa = MFAApi.new(self)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Initialize the client, optionally from a URL or from storage.
|
|
65
|
+
# @param url [String, nil] optional redirect URL to initialize from
|
|
66
|
+
def init(url: nil)
|
|
67
|
+
if url && _is_implicit_grant_flow(url)
|
|
68
|
+
initialize_from_url(url)
|
|
69
|
+
else
|
|
70
|
+
initialize_from_storage
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Recover session from storage and refresh if needed.
|
|
75
|
+
def initialize_from_storage
|
|
76
|
+
_recover_and_refresh
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# --- Public API ---
|
|
80
|
+
|
|
81
|
+
# Sign up a new user with email/phone and password.
|
|
82
|
+
# @param credentials [Hash] sign-up credentials
|
|
83
|
+
# @option credentials [String] :email user email
|
|
84
|
+
# @option credentials [String] :phone user phone number
|
|
85
|
+
# @option credentials [String] :password user password
|
|
86
|
+
# @option credentials [Hash] :options additional options (data, captcha_token, redirect_to, channel)
|
|
87
|
+
# @return [Types::AuthResponse]
|
|
88
|
+
# @raise [Errors::AuthInvalidCredentialsError] if neither email nor phone provided
|
|
89
|
+
def sign_up(credentials)
|
|
90
|
+
_remove_session
|
|
91
|
+
|
|
92
|
+
email = credentials[:email] || credentials["email"]
|
|
93
|
+
phone = credentials[:phone] || credentials["phone"]
|
|
94
|
+
password = credentials[:password] || credentials["password"]
|
|
95
|
+
options = credentials[:options] || credentials["options"] || {}
|
|
96
|
+
redirect_to = options[:redirect_to] || options[:email_redirect_to]
|
|
97
|
+
user_data = options[:data] || {}
|
|
98
|
+
channel = options[:channel] || "sms"
|
|
99
|
+
captcha_token = options[:captcha_token]
|
|
100
|
+
|
|
101
|
+
if email
|
|
102
|
+
body = {
|
|
103
|
+
email: email,
|
|
104
|
+
password: password,
|
|
105
|
+
data: user_data,
|
|
106
|
+
gotrue_meta_security: { captcha_token: captcha_token }
|
|
107
|
+
}
|
|
108
|
+
elsif phone
|
|
109
|
+
body = {
|
|
110
|
+
phone: phone,
|
|
111
|
+
password: password,
|
|
112
|
+
data: user_data,
|
|
113
|
+
channel: channel,
|
|
114
|
+
gotrue_meta_security: { captcha_token: captcha_token }
|
|
115
|
+
}
|
|
116
|
+
else
|
|
117
|
+
raise Errors::AuthInvalidCredentialsError,
|
|
118
|
+
"You must provide either an email or phone number and a password"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
data = _request("POST", "signup", body: body, redirect_to: redirect_to)
|
|
122
|
+
response = Helpers.parse_auth_response(data)
|
|
123
|
+
|
|
124
|
+
if response.session
|
|
125
|
+
_save_session(response.session)
|
|
126
|
+
_notify_all_subscribers("SIGNED_IN", response.session)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
response
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Sign in with email/phone and password.
|
|
133
|
+
# @param credentials [Hash] sign-in credentials (:email or :phone, and :password)
|
|
134
|
+
# @return [Types::AuthResponse]
|
|
135
|
+
# @raise [Errors::AuthInvalidCredentialsError] if credentials are missing
|
|
136
|
+
def sign_in_with_password(credentials)
|
|
137
|
+
_remove_session
|
|
138
|
+
|
|
139
|
+
email = credentials[:email] || credentials["email"]
|
|
140
|
+
phone = credentials[:phone] || credentials["phone"]
|
|
141
|
+
password = credentials[:password] || credentials["password"]
|
|
142
|
+
options = credentials[:options] || credentials["options"] || {}
|
|
143
|
+
data_attr = options[:data] || {}
|
|
144
|
+
captcha_token = options[:captcha_token]
|
|
145
|
+
|
|
146
|
+
unless (email || phone) && password
|
|
147
|
+
raise Errors::AuthInvalidCredentialsError,
|
|
148
|
+
"An email or phone number and password are required"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
body = {
|
|
152
|
+
password: password,
|
|
153
|
+
data: data_attr,
|
|
154
|
+
gotrue_meta_security: { captcha_token: captcha_token }
|
|
155
|
+
}
|
|
156
|
+
body[:email] = email if email
|
|
157
|
+
body[:phone] = phone if phone
|
|
158
|
+
|
|
159
|
+
data = _request("POST", "token", body: body, params: { "grant_type" => "password" })
|
|
160
|
+
response = Helpers.parse_auth_response(data)
|
|
161
|
+
|
|
162
|
+
if response.session
|
|
163
|
+
_save_session(response.session)
|
|
164
|
+
_notify_all_subscribers("SIGNED_IN", response.session)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
response
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Sign in with OTP (magic link for email, SMS for phone).
|
|
171
|
+
# @param credentials [Hash] (:email or :phone, optional :options)
|
|
172
|
+
# @return [Types::AuthOtpResponse]
|
|
173
|
+
# @raise [Errors::AuthInvalidCredentialsError] if neither email nor phone provided
|
|
174
|
+
def sign_in_with_otp(credentials)
|
|
175
|
+
_remove_session
|
|
176
|
+
|
|
177
|
+
email = credentials[:email] || credentials["email"]
|
|
178
|
+
phone = credentials[:phone] || credentials["phone"]
|
|
179
|
+
options = credentials[:options] || credentials["options"] || {}
|
|
180
|
+
|
|
181
|
+
unless email || phone
|
|
182
|
+
raise Errors::AuthInvalidCredentialsError,
|
|
183
|
+
"An email or phone number is required"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
email_redirect_to = options[:email_redirect_to]
|
|
187
|
+
should_create_user = options.key?(:should_create_user) ? options[:should_create_user] : true
|
|
188
|
+
data_attr = options[:data]
|
|
189
|
+
channel = options[:channel] || "sms"
|
|
190
|
+
captcha_token = options[:captcha_token]
|
|
191
|
+
|
|
192
|
+
if email
|
|
193
|
+
body = {
|
|
194
|
+
email: email,
|
|
195
|
+
data: data_attr,
|
|
196
|
+
create_user: should_create_user,
|
|
197
|
+
gotrue_meta_security: { captcha_token: captcha_token }
|
|
198
|
+
}
|
|
199
|
+
data = _request("POST", "otp", body: body, redirect_to: email_redirect_to)
|
|
200
|
+
return Helpers.parse_auth_otp_response(data)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if phone
|
|
204
|
+
body = {
|
|
205
|
+
phone: phone,
|
|
206
|
+
data: data_attr,
|
|
207
|
+
create_user: should_create_user,
|
|
208
|
+
channel: channel,
|
|
209
|
+
gotrue_meta_security: { captcha_token: captcha_token }
|
|
210
|
+
}
|
|
211
|
+
data = _request("POST", "otp", body: body)
|
|
212
|
+
return Helpers.parse_auth_otp_response(data)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Verify an OTP token.
|
|
217
|
+
# @param params [Hash] verification params (:type, :token, :email or :phone)
|
|
218
|
+
# @return [Types::AuthResponse]
|
|
219
|
+
def verify_otp(params)
|
|
220
|
+
_remove_session
|
|
221
|
+
|
|
222
|
+
type = params[:type] || params["type"]
|
|
223
|
+
phone = params[:phone] || params["phone"]
|
|
224
|
+
email = params[:email] || params["email"]
|
|
225
|
+
token = params[:token] || params["token"]
|
|
226
|
+
token_hash = params[:token_hash] || params["token_hash"]
|
|
227
|
+
options = params[:options] || params["options"] || {}
|
|
228
|
+
captcha_token = options[:captcha_token]
|
|
229
|
+
|
|
230
|
+
body = {
|
|
231
|
+
type: type,
|
|
232
|
+
gotrue_meta_security: { captcha_token: captcha_token }
|
|
233
|
+
}
|
|
234
|
+
body[:token] = token if token
|
|
235
|
+
body[:phone] = phone if phone
|
|
236
|
+
body[:email] = email if email
|
|
237
|
+
body[:token_hash] = token_hash if token_hash
|
|
238
|
+
|
|
239
|
+
redirect_to = options[:redirect_to]
|
|
240
|
+
|
|
241
|
+
data = _request("POST", "verify", body: body, redirect_to: redirect_to)
|
|
242
|
+
response = Helpers.parse_auth_response(data)
|
|
243
|
+
|
|
244
|
+
if response.session
|
|
245
|
+
_save_session(response.session)
|
|
246
|
+
_notify_all_subscribers("SIGNED_IN", response.session)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
response
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Get the current session, refreshing it if necessary.
|
|
253
|
+
# @return [Types::Session, nil]
|
|
254
|
+
def get_session
|
|
255
|
+
current_session = nil
|
|
256
|
+
if @persist_session
|
|
257
|
+
maybe_session = @storage.get_item(@storage_key)
|
|
258
|
+
current_session = _get_valid_session(maybe_session)
|
|
259
|
+
_remove_session unless current_session
|
|
260
|
+
else
|
|
261
|
+
current_session = @current_session
|
|
262
|
+
end
|
|
263
|
+
return nil unless current_session
|
|
264
|
+
|
|
265
|
+
time_now = Time.now.to_i
|
|
266
|
+
has_expired = current_session.expires_at ? current_session.expires_at <= time_now + EXPIRY_MARGIN : false
|
|
267
|
+
|
|
268
|
+
if has_expired
|
|
269
|
+
_call_refresh_token(current_session.refresh_token)
|
|
270
|
+
else
|
|
271
|
+
current_session
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Get the current user. Uses session token if jwt is nil.
|
|
276
|
+
# @param jwt [String, nil] optional access token
|
|
277
|
+
# @return [Types::UserResponse, nil]
|
|
278
|
+
def get_user(jwt = nil)
|
|
279
|
+
unless jwt
|
|
280
|
+
session = get_session
|
|
281
|
+
return nil unless session
|
|
282
|
+
jwt = session.access_token
|
|
283
|
+
end
|
|
284
|
+
access_token = jwt
|
|
285
|
+
return nil unless access_token
|
|
286
|
+
|
|
287
|
+
data = _request("GET", "user", jwt: access_token)
|
|
288
|
+
Helpers.parse_user_response(data)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Get identities linked to the current user.
|
|
292
|
+
# @return [Types::IdentitiesResponse]
|
|
293
|
+
# @raise [Errors::AuthSessionMissing] if no active session
|
|
294
|
+
def get_user_identities
|
|
295
|
+
session = get_session
|
|
296
|
+
raise Errors::AuthSessionMissing unless session
|
|
297
|
+
|
|
298
|
+
user_response = get_user(session.access_token)
|
|
299
|
+
identities = user_response&.user&.identities || []
|
|
300
|
+
Types::IdentitiesResponse.new(identities: identities)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Set session from existing access and refresh tokens.
|
|
304
|
+
# @param access_token [String] JWT access token
|
|
305
|
+
# @param refresh_token [String] refresh token
|
|
306
|
+
# @return [Types::AuthResponse]
|
|
307
|
+
# @raise [Errors::AuthInvalidJwtError] if token is malformed
|
|
308
|
+
# @raise [Errors::AuthSessionMissing] if token expired and no refresh token
|
|
309
|
+
def set_session(access_token, refresh_token)
|
|
310
|
+
time_now = Time.now.to_i
|
|
311
|
+
expires_at = time_now
|
|
312
|
+
has_expired = true
|
|
313
|
+
session = nil
|
|
314
|
+
|
|
315
|
+
if access_token && access_token.split(".").length > 1
|
|
316
|
+
payload = Helpers.decode_jwt(access_token)[:payload]
|
|
317
|
+
exp = payload["exp"]
|
|
318
|
+
if exp
|
|
319
|
+
expires_at = exp.to_i
|
|
320
|
+
has_expired = expires_at <= time_now
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
if has_expired
|
|
325
|
+
raise Errors::AuthSessionMissing unless refresh_token && !refresh_token.empty?
|
|
326
|
+
|
|
327
|
+
response = _refresh_access_token(refresh_token)
|
|
328
|
+
return Types::AuthResponse.new unless response.session
|
|
329
|
+
|
|
330
|
+
session = response.session
|
|
331
|
+
else
|
|
332
|
+
user_response = get_user(access_token)
|
|
333
|
+
session = Types::Session.new(
|
|
334
|
+
access_token: access_token,
|
|
335
|
+
refresh_token: refresh_token,
|
|
336
|
+
token_type: "bearer",
|
|
337
|
+
expires_in: expires_at - time_now,
|
|
338
|
+
expires_at: expires_at,
|
|
339
|
+
user: user_response.user
|
|
340
|
+
)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
_save_session(session)
|
|
344
|
+
_notify_all_subscribers("TOKEN_REFRESHED", session)
|
|
345
|
+
Types::AuthResponse.new(session: session, user: session.user)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Refresh the current session using a refresh token.
|
|
349
|
+
# @param refresh_token [String, nil] optional refresh token (uses current session's if nil)
|
|
350
|
+
# @return [Types::AuthResponse]
|
|
351
|
+
# @raise [Errors::AuthSessionMissing] if no refresh token available
|
|
352
|
+
def refresh_session(refresh_token = nil)
|
|
353
|
+
unless refresh_token
|
|
354
|
+
session = get_session
|
|
355
|
+
refresh_token = session.refresh_token if session
|
|
356
|
+
end
|
|
357
|
+
raise Errors::AuthSessionMissing unless refresh_token
|
|
358
|
+
|
|
359
|
+
session = _call_refresh_token(refresh_token)
|
|
360
|
+
Types::AuthResponse.new(session: session, user: session.user)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Sign out the current user.
|
|
364
|
+
# @param options [Hash] sign-out options
|
|
365
|
+
# @option options [String] :scope ("global") sign-out scope: "global", "local", or "others"
|
|
366
|
+
def sign_out(options = {})
|
|
367
|
+
scope = options[:scope] || options["scope"] || "global"
|
|
368
|
+
session = get_session
|
|
369
|
+
|
|
370
|
+
if session
|
|
371
|
+
begin
|
|
372
|
+
@admin.sign_out(session.access_token, scope)
|
|
373
|
+
rescue Errors::AuthApiError
|
|
374
|
+
# Suppress API errors from admin sign_out
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
unless scope == "others"
|
|
379
|
+
_remove_session
|
|
380
|
+
_notify_all_subscribers("SIGNED_OUT", nil)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Sign in anonymously (creates an anonymous user).
|
|
385
|
+
# @return [Types::AuthResponse]
|
|
386
|
+
def sign_in_anonymously(credentials = nil)
|
|
387
|
+
_remove_session
|
|
388
|
+
|
|
389
|
+
credentials ||= { options: {} }
|
|
390
|
+
options = credentials[:options] || credentials["options"] || {}
|
|
391
|
+
data_attr = options[:data] || {}
|
|
392
|
+
captcha_token = options[:captcha_token]
|
|
393
|
+
|
|
394
|
+
data = _request("POST", "signup", body: {
|
|
395
|
+
data: data_attr,
|
|
396
|
+
gotrue_meta_security: { captcha_token: captcha_token }
|
|
397
|
+
})
|
|
398
|
+
response = Helpers.parse_auth_response(data)
|
|
399
|
+
|
|
400
|
+
if response.session
|
|
401
|
+
_save_session(response.session)
|
|
402
|
+
_notify_all_subscribers("SIGNED_IN", response.session)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
response
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Sign in with a third-party ID token (e.g., Google, Apple).
|
|
409
|
+
# @param credentials [Hash] (:provider, :token, optional :nonce)
|
|
410
|
+
# @return [Types::AuthResponse]
|
|
411
|
+
def sign_in_with_id_token(credentials)
|
|
412
|
+
_remove_session
|
|
413
|
+
|
|
414
|
+
provider = credentials[:provider] || credentials["provider"]
|
|
415
|
+
token = credentials[:token] || credentials["token"]
|
|
416
|
+
access_token = credentials[:access_token] || credentials["access_token"]
|
|
417
|
+
nonce = credentials[:nonce] || credentials["nonce"]
|
|
418
|
+
options = credentials[:options] || credentials["options"] || {}
|
|
419
|
+
captcha_token = options[:captcha_token]
|
|
420
|
+
|
|
421
|
+
body = {
|
|
422
|
+
provider: provider,
|
|
423
|
+
id_token: token,
|
|
424
|
+
access_token: access_token,
|
|
425
|
+
nonce: nonce,
|
|
426
|
+
gotrue_meta_security: { captcha_token: captcha_token }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
data = _request("POST", "token", body: body, params: { "grant_type" => "id_token" })
|
|
430
|
+
response = Helpers.parse_auth_response(data)
|
|
431
|
+
|
|
432
|
+
if response.session
|
|
433
|
+
_save_session(response.session)
|
|
434
|
+
_notify_all_subscribers("SIGNED_IN", response.session)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
response
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Sign in with SSO (SAML).
|
|
441
|
+
# @param credentials [Hash] (:domain or :provider_id, optional :options)
|
|
442
|
+
# @return [Types::SSOResponse]
|
|
443
|
+
def sign_in_with_sso(credentials)
|
|
444
|
+
_remove_session
|
|
445
|
+
|
|
446
|
+
domain = credentials[:domain] || credentials["domain"]
|
|
447
|
+
provider_id = credentials[:provider_id] || credentials["provider_id"]
|
|
448
|
+
options = credentials[:options] || credentials["options"] || {}
|
|
449
|
+
redirect_to = options[:redirect_to]
|
|
450
|
+
captcha_token = options[:captcha_token]
|
|
451
|
+
skip_http_redirect = options.fetch(:skip_http_redirect, true)
|
|
452
|
+
|
|
453
|
+
if domain
|
|
454
|
+
data = _request("POST", "sso", body: {
|
|
455
|
+
domain: domain,
|
|
456
|
+
skip_http_redirect: skip_http_redirect,
|
|
457
|
+
gotrue_meta_security: { captcha_token: captcha_token },
|
|
458
|
+
redirect_to: redirect_to
|
|
459
|
+
})
|
|
460
|
+
return Helpers.parse_sso_response(data)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
if provider_id
|
|
464
|
+
data = _request("POST", "sso", body: {
|
|
465
|
+
provider_id: provider_id,
|
|
466
|
+
skip_http_redirect: skip_http_redirect,
|
|
467
|
+
gotrue_meta_security: { captcha_token: captcha_token },
|
|
468
|
+
redirect_to: redirect_to
|
|
469
|
+
})
|
|
470
|
+
return Helpers.parse_sso_response(data)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
raise Errors::AuthInvalidCredentialsError,
|
|
474
|
+
"You must provide either a domain or provider_id"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Sign in with OAuth provider. Returns URL to redirect the user to.
|
|
478
|
+
# @param credentials [Hash] (:provider, optional :options)
|
|
479
|
+
# @return [Types::OAuthResponse]
|
|
480
|
+
def sign_in_with_oauth(credentials)
|
|
481
|
+
_remove_session
|
|
482
|
+
|
|
483
|
+
provider = credentials[:provider] || credentials["provider"]
|
|
484
|
+
options = credentials[:options] || credentials["options"] || {}
|
|
485
|
+
redirect_to = options[:redirect_to]
|
|
486
|
+
scopes = options[:scopes]
|
|
487
|
+
params = (options[:query_params] || {}).dup
|
|
488
|
+
params["redirect_to"] = redirect_to if redirect_to
|
|
489
|
+
params["scopes"] = scopes if scopes
|
|
490
|
+
|
|
491
|
+
url_with_qs, _ = _get_url_for_provider("#{@url}/authorize", provider, params)
|
|
492
|
+
Types::OAuthResponse.new(provider: provider, url: url_with_qs)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Resend an OTP or magic link.
|
|
496
|
+
# @param credentials [Hash] (:email or :phone, :type)
|
|
497
|
+
# @raise [Errors::AuthInvalidCredentialsError] if neither email nor phone provided
|
|
498
|
+
def resend(credentials)
|
|
499
|
+
phone = credentials[:phone] || credentials["phone"]
|
|
500
|
+
email = credentials[:email] || credentials["email"]
|
|
501
|
+
type = credentials[:type] || credentials["type"]
|
|
502
|
+
options = credentials[:options] || credentials["options"] || {}
|
|
503
|
+
captcha_token = options[:captcha_token]
|
|
504
|
+
email_redirect_to = options[:email_redirect_to]
|
|
505
|
+
|
|
506
|
+
unless email || phone
|
|
507
|
+
raise Errors::AuthInvalidCredentialsError,
|
|
508
|
+
"An email or phone number is required"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
body = {
|
|
512
|
+
type: type,
|
|
513
|
+
gotrue_meta_security: { captcha_token: captcha_token }
|
|
514
|
+
}
|
|
515
|
+
# Match Python: email takes priority; only one of email/phone is sent
|
|
516
|
+
if email
|
|
517
|
+
body[:email] = email
|
|
518
|
+
else
|
|
519
|
+
body[:phone] = phone
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
data = _request("POST", "resend", body: body, redirect_to: email ? email_redirect_to : nil)
|
|
523
|
+
Helpers.parse_auth_otp_response(data)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Reauthenticate the current user (requires active session).
|
|
527
|
+
# @raise [Errors::AuthSessionMissing] if no active session
|
|
528
|
+
def reauthenticate
|
|
529
|
+
session = get_session
|
|
530
|
+
raise Errors::AuthSessionMissing unless session
|
|
531
|
+
|
|
532
|
+
data = _request("GET", "reauthenticate", jwt: session.access_token)
|
|
533
|
+
Helpers.parse_auth_response(data)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Send a password reset email. Does not require an active session.
|
|
537
|
+
# @param email [String] user email
|
|
538
|
+
# @param options [Hash] optional :redirect_to
|
|
539
|
+
def reset_password_for_email(email, options = {})
|
|
540
|
+
redirect_to = options[:redirect_to]
|
|
541
|
+
captcha_token = options[:captcha_token]
|
|
542
|
+
body = {
|
|
543
|
+
email: email,
|
|
544
|
+
gotrue_meta_security: { captcha_token: captcha_token }
|
|
545
|
+
}
|
|
546
|
+
_request("POST", "recover", body: body, redirect_to: redirect_to)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Alias for {#reset_password_for_email}.
|
|
550
|
+
# @param email [String] user email
|
|
551
|
+
# @param options [Hash] optional :redirect_to
|
|
552
|
+
def reset_password_email(email:, **options)
|
|
553
|
+
reset_password_for_email(email, options)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Update the current user's attributes.
|
|
557
|
+
# @param attributes [Hash] user attributes to update (e.g., email, password, data)
|
|
558
|
+
# @return [Types::UserResponse]
|
|
559
|
+
# @raise [Errors::AuthSessionMissing] if no active session
|
|
560
|
+
def update_user(attributes, options = {})
|
|
561
|
+
session = get_session
|
|
562
|
+
raise Errors::AuthSessionMissing unless session
|
|
563
|
+
|
|
564
|
+
redirect_to = options[:email_redirect_to]
|
|
565
|
+
data = _request("PUT", "user", jwt: session.access_token, body: attributes, redirect_to: redirect_to)
|
|
566
|
+
response = Helpers.parse_user_response(data)
|
|
567
|
+
|
|
568
|
+
updated_session = Types::Session.new(
|
|
569
|
+
access_token: session.access_token,
|
|
570
|
+
refresh_token: session.refresh_token,
|
|
571
|
+
token_type: session.token_type,
|
|
572
|
+
expires_in: session.expires_in,
|
|
573
|
+
expires_at: session.expires_at,
|
|
574
|
+
provider_token: session.provider_token,
|
|
575
|
+
provider_refresh_token: session.provider_refresh_token,
|
|
576
|
+
user: response.user
|
|
577
|
+
)
|
|
578
|
+
_save_session(updated_session)
|
|
579
|
+
_notify_all_subscribers("USER_UPDATED", updated_session)
|
|
580
|
+
|
|
581
|
+
response
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Link an OAuth identity to the current user.
|
|
585
|
+
# @param credentials [Hash] (:provider, optional :options)
|
|
586
|
+
# @return [Types::LinkIdentityResponse]
|
|
587
|
+
# @raise [Errors::AuthSessionMissing] if no active session
|
|
588
|
+
def link_identity(credentials)
|
|
589
|
+
provider = credentials[:provider] || credentials["provider"]
|
|
590
|
+
options = credentials[:options] || credentials["options"] || {}
|
|
591
|
+
redirect_to = options[:redirect_to]
|
|
592
|
+
scopes = options[:scopes]
|
|
593
|
+
params = (options[:query_params] || {}).dup
|
|
594
|
+
params["redirect_to"] = redirect_to if redirect_to
|
|
595
|
+
params["scopes"] = scopes if scopes
|
|
596
|
+
params["skip_http_redirect"] = "true"
|
|
597
|
+
|
|
598
|
+
url = "user/identities/authorize"
|
|
599
|
+
_, query = _get_url_for_provider(url, provider, params)
|
|
600
|
+
|
|
601
|
+
session = get_session
|
|
602
|
+
raise Errors::AuthSessionMissing unless session
|
|
603
|
+
|
|
604
|
+
_request("GET", url,
|
|
605
|
+
params: query,
|
|
606
|
+
jwt: session.access_token,
|
|
607
|
+
xform: ->(data) { Helpers.parse_link_identity_response(data) })
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Unlink an identity from the current user.
|
|
611
|
+
# @param identity [Types::UserIdentity, Hash] identity to unlink (must have :identity_id)
|
|
612
|
+
# @raise [Errors::AuthSessionMissing] if no active session
|
|
613
|
+
def unlink_identity(identity)
|
|
614
|
+
session = get_session
|
|
615
|
+
raise Errors::AuthSessionMissing unless session
|
|
616
|
+
|
|
617
|
+
identity_id = identity.respond_to?(:identity_id) ? identity.identity_id : identity[:identity_id]
|
|
618
|
+
_request("DELETE", "user/identities/#{identity_id}", jwt: session.access_token)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Subscribe to auth state changes.
|
|
622
|
+
# @yield [event, session] called when auth state changes
|
|
623
|
+
# @yieldparam event [String] event type (SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, etc.)
|
|
624
|
+
# @yieldparam session [Types::Session, nil] current session
|
|
625
|
+
# @return [Types::Subscription] subscription with #unsubscribe method
|
|
626
|
+
def on_auth_state_change(&callback)
|
|
627
|
+
id = SecureRandom.uuid
|
|
628
|
+
|
|
629
|
+
unsubscribe = -> { @state_change_emitters.delete(id) }
|
|
630
|
+
|
|
631
|
+
subscription = Types::Subscription.new(
|
|
632
|
+
id: id,
|
|
633
|
+
callback: callback,
|
|
634
|
+
unsubscribe: unsubscribe
|
|
635
|
+
)
|
|
636
|
+
@state_change_emitters[id] = subscription
|
|
637
|
+
|
|
638
|
+
subscription
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Initialize session from an OAuth redirect URL.
|
|
642
|
+
# @param url [String] the redirect URL containing auth tokens or error
|
|
643
|
+
# @return [nil]
|
|
644
|
+
# @raise [Errors::AuthImplicitGrantRedirectError] if URL contains an error
|
|
645
|
+
def initialize_from_url(url)
|
|
646
|
+
if _is_implicit_grant_flow(url)
|
|
647
|
+
session, redirect_type = _get_session_from_url(url)
|
|
648
|
+
_save_session(session)
|
|
649
|
+
_notify_all_subscribers("SIGNED_IN", session)
|
|
650
|
+
_notify_all_subscribers("PASSWORD_RECOVERY", session) if redirect_type == "recovery"
|
|
651
|
+
end
|
|
652
|
+
nil
|
|
653
|
+
rescue StandardError => e
|
|
654
|
+
_remove_session
|
|
655
|
+
raise e
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Get JWT claims from the current session. Validates symmetric JWTs via get_user,
|
|
659
|
+
# asymmetric JWTs via JWKS endpoint.
|
|
660
|
+
# @return [Types::ClaimsResponse, nil] claims response, or nil if no session
|
|
661
|
+
def get_claims(jwt: nil, jwks: nil)
|
|
662
|
+
token = jwt
|
|
663
|
+
unless token
|
|
664
|
+
session = get_session
|
|
665
|
+
return nil unless session
|
|
666
|
+
token = session.access_token
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
decoded = Helpers.decode_jwt(token)
|
|
670
|
+
payload = decoded[:payload]
|
|
671
|
+
header = decoded[:header]
|
|
672
|
+
signature = decoded[:signature]
|
|
673
|
+
raw_header = decoded[:raw]["header"]
|
|
674
|
+
raw_payload = decoded[:raw]["payload"]
|
|
675
|
+
|
|
676
|
+
Helpers.validate_exp(payload["exp"])
|
|
677
|
+
|
|
678
|
+
# If symmetric algorithm (no kid or HS256), fallback to get_user
|
|
679
|
+
if !header["kid"] || header["alg"] == "HS256"
|
|
680
|
+
get_user(token)
|
|
681
|
+
return Types::ClaimsResponse.new(claims: payload, headers: header, signature: signature)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# Asymmetric JWT - verify via JWKS
|
|
685
|
+
jwk_data = _fetch_jwks(header["kid"], jwks || { "keys" => [] })
|
|
686
|
+
signing_key = JWT::JWK.new(jwk_data).verify_key
|
|
687
|
+
|
|
688
|
+
digest = ALG_TO_DIGEST[header["alg"]]
|
|
689
|
+
raise Errors::AuthInvalidJwtError, "Unsupported algorithm: #{header["alg"]}" unless digest
|
|
690
|
+
|
|
691
|
+
is_valid = signing_key.verify(digest, signature, "#{raw_header}.#{raw_payload}")
|
|
692
|
+
raise Errors::AuthInvalidJwtError, "Invalid JWT signature" unless is_valid
|
|
693
|
+
|
|
694
|
+
Types::ClaimsResponse.new(claims: payload, headers: header, signature: signature)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# --- PKCE helpers ---
|
|
698
|
+
|
|
699
|
+
def _is_implicit_grant_flow(url)
|
|
700
|
+
parsed = URI.parse(url)
|
|
701
|
+
params = URI.decode_www_form(parsed.query || "").to_h
|
|
702
|
+
params.key?("access_token") || params.key?("error_description")
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def _get_url_for_provider(url, provider, params = {})
|
|
706
|
+
params = params.dup
|
|
707
|
+
if @flow_type == "pkce"
|
|
708
|
+
code_verifier = Helpers.generate_pkce_verifier
|
|
709
|
+
code_challenge = Helpers.generate_pkce_challenge(code_verifier)
|
|
710
|
+
@storage.set_item("#{@storage_key}-code-verifier", code_verifier)
|
|
711
|
+
code_challenge_method = code_verifier == code_challenge ? "plain" : "s256"
|
|
712
|
+
params["code_challenge"] = code_challenge
|
|
713
|
+
params["code_challenge_method"] = code_challenge_method
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
params["provider"] = provider
|
|
717
|
+
query = URI.encode_www_form(params)
|
|
718
|
+
["#{url}?#{query}", params]
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Exchange an authorization code for a session (PKCE flow).
|
|
722
|
+
# @param params [Hash] (:auth_code, optional :code_verifier, :redirect_to)
|
|
723
|
+
# @return [Types::AuthResponse]
|
|
724
|
+
def exchange_code_for_session(params)
|
|
725
|
+
code_verifier = params[:code_verifier] || params["code_verifier"] ||
|
|
726
|
+
@storage.get_item("#{@storage_key}-code-verifier")
|
|
727
|
+
|
|
728
|
+
data = _request("POST", "token",
|
|
729
|
+
body: {
|
|
730
|
+
auth_code: params[:auth_code] || params["auth_code"],
|
|
731
|
+
code_verifier: code_verifier
|
|
732
|
+
},
|
|
733
|
+
params: { "grant_type" => "pkce" },
|
|
734
|
+
redirect_to: params[:redirect_to] || params["redirect_to"])
|
|
735
|
+
response = Helpers.parse_auth_response(data)
|
|
736
|
+
|
|
737
|
+
@storage.remove_item("#{@storage_key}-code-verifier")
|
|
738
|
+
|
|
739
|
+
if response.session
|
|
740
|
+
_save_session(response.session)
|
|
741
|
+
_notify_all_subscribers("SIGNED_IN", response.session)
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
response
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# --- Internal accessors for test access ---
|
|
748
|
+
|
|
749
|
+
def _flow_type
|
|
750
|
+
@flow_type
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def _flow_type=(value)
|
|
754
|
+
@flow_type = value
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def _storage
|
|
758
|
+
@storage
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def _storage_key
|
|
762
|
+
@storage_key
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def _jwks
|
|
766
|
+
@jwks
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def _url
|
|
770
|
+
@url
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def _auto_refresh_token=(value)
|
|
774
|
+
@auto_refresh_token = value
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def _start_auto_refresh_token(value = nil)
|
|
778
|
+
if @refresh_token_timer
|
|
779
|
+
@refresh_token_timer.cancel
|
|
780
|
+
@refresh_token_timer = nil
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
return nil if value.nil? || value <= 0 || !@auto_refresh_token
|
|
784
|
+
|
|
785
|
+
@refresh_token_timer = Timer.new(value / 1000.0) do
|
|
786
|
+
@network_retries += 1
|
|
787
|
+
begin
|
|
788
|
+
session = get_session
|
|
789
|
+
if session
|
|
790
|
+
_call_refresh_token(session.refresh_token)
|
|
791
|
+
@network_retries = 0
|
|
792
|
+
end
|
|
793
|
+
rescue Errors::AuthRetryableError
|
|
794
|
+
if @network_retries < Constants::MAX_RETRIES
|
|
795
|
+
_start_auto_refresh_token(200 * (Constants::RETRY_INTERVAL ** (@network_retries - 1)))
|
|
796
|
+
end
|
|
797
|
+
rescue StandardError
|
|
798
|
+
# Swallow other errors
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
@refresh_token_timer.start
|
|
802
|
+
nil
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def _recover_and_refresh
|
|
806
|
+
raw_session = @storage.get_item(@storage_key)
|
|
807
|
+
current_session = _get_valid_session(raw_session)
|
|
808
|
+
|
|
809
|
+
unless current_session
|
|
810
|
+
_remove_session if raw_session
|
|
811
|
+
return
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
time_now = Time.now.to_i
|
|
815
|
+
expires_at = current_session.expires_at
|
|
816
|
+
expires_at = expires_at.to_i if expires_at
|
|
817
|
+
|
|
818
|
+
if expires_at && expires_at < time_now + EXPIRY_MARGIN
|
|
819
|
+
refresh_token = current_session.refresh_token
|
|
820
|
+
if @auto_refresh_token && refresh_token
|
|
821
|
+
@network_retries += 1
|
|
822
|
+
begin
|
|
823
|
+
_call_refresh_token(refresh_token)
|
|
824
|
+
@network_retries = 0
|
|
825
|
+
rescue Errors::AuthRetryableError
|
|
826
|
+
if @network_retries < Constants::MAX_RETRIES
|
|
827
|
+
if @refresh_token_timer
|
|
828
|
+
@refresh_token_timer.cancel
|
|
829
|
+
end
|
|
830
|
+
@refresh_token_timer = Timer.new(
|
|
831
|
+
(200 * (Constants::RETRY_INTERVAL ** (@network_retries - 1))) / 1000.0
|
|
832
|
+
) { _recover_and_refresh }
|
|
833
|
+
@refresh_token_timer.start
|
|
834
|
+
return
|
|
835
|
+
end
|
|
836
|
+
rescue StandardError
|
|
837
|
+
# Swallow other errors
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
_remove_session
|
|
841
|
+
return
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
# Session still valid — restore it
|
|
845
|
+
if @persist_session
|
|
846
|
+
_save_session(current_session)
|
|
847
|
+
end
|
|
848
|
+
_notify_all_subscribers("SIGNED_IN", current_session)
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def _call_refresh_token(refresh_token)
|
|
852
|
+
raise Errors::AuthSessionMissing unless refresh_token && !refresh_token.empty?
|
|
853
|
+
|
|
854
|
+
response = _refresh_access_token(refresh_token)
|
|
855
|
+
raise Errors::AuthSessionMissing unless response.session
|
|
856
|
+
|
|
857
|
+
_save_session(response.session)
|
|
858
|
+
_notify_all_subscribers("TOKEN_REFRESHED", response.session)
|
|
859
|
+
response.session
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def _refresh_access_token(refresh_token)
|
|
863
|
+
data = _request("POST", "token",
|
|
864
|
+
body: { refresh_token: refresh_token },
|
|
865
|
+
params: { "grant_type" => "refresh_token" })
|
|
866
|
+
Helpers.parse_auth_response(data)
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
def _list_factors
|
|
870
|
+
mfa.list_factors
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def _remove_session
|
|
874
|
+
if @persist_session
|
|
875
|
+
@storage.remove_item(@storage_key)
|
|
876
|
+
end
|
|
877
|
+
@current_session = nil
|
|
878
|
+
if @refresh_token_timer
|
|
879
|
+
@refresh_token_timer.cancel
|
|
880
|
+
@refresh_token_timer = nil
|
|
881
|
+
end
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def _save_session(session)
|
|
885
|
+
@current_session = session
|
|
886
|
+
|
|
887
|
+
expire_at = session.expires_at
|
|
888
|
+
if expire_at
|
|
889
|
+
time_now = Time.now.to_i
|
|
890
|
+
expire_in = expire_at - time_now
|
|
891
|
+
refresh_duration_before_expires = expire_in > EXPIRY_MARGIN ? EXPIRY_MARGIN : 0.5
|
|
892
|
+
value = (expire_in - refresh_duration_before_expires) * 1000
|
|
893
|
+
_start_auto_refresh_token(value)
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
if @persist_session && session.expires_at
|
|
897
|
+
session_data = {
|
|
898
|
+
access_token: session.access_token,
|
|
899
|
+
refresh_token: session.refresh_token,
|
|
900
|
+
token_type: session.token_type,
|
|
901
|
+
expires_in: session.expires_in,
|
|
902
|
+
expires_at: session.expires_at,
|
|
903
|
+
provider_token: session.provider_token,
|
|
904
|
+
provider_refresh_token: session.provider_refresh_token
|
|
905
|
+
}
|
|
906
|
+
if session.user
|
|
907
|
+
user = session.user
|
|
908
|
+
session_data[:user] = {
|
|
909
|
+
id: user.id, aud: user.aud, role: user.role,
|
|
910
|
+
email: user.email, phone: user.phone,
|
|
911
|
+
email_confirmed_at: user.email_confirmed_at&.iso8601,
|
|
912
|
+
phone_confirmed_at: user.phone_confirmed_at&.iso8601,
|
|
913
|
+
confirmed_at: user.confirmed_at&.iso8601,
|
|
914
|
+
last_sign_in_at: user.last_sign_in_at&.iso8601,
|
|
915
|
+
app_metadata: user.app_metadata, user_metadata: user.user_metadata,
|
|
916
|
+
identities: user.identities&.map { |i|
|
|
917
|
+
{
|
|
918
|
+
id: i.id, identity_id: i.identity_id, user_id: i.user_id,
|
|
919
|
+
identity_data: i.identity_data, provider: i.provider,
|
|
920
|
+
last_sign_in_at: i.last_sign_in_at&.iso8601,
|
|
921
|
+
created_at: i.created_at&.iso8601, updated_at: i.updated_at&.iso8601
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
factors: user.factors&.map { |f|
|
|
925
|
+
{
|
|
926
|
+
id: f.id, friendly_name: f.friendly_name, factor_type: f.factor_type,
|
|
927
|
+
status: f.status,
|
|
928
|
+
created_at: f.created_at&.iso8601, updated_at: f.updated_at&.iso8601
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
created_at: user.created_at&.iso8601, updated_at: user.updated_at&.iso8601,
|
|
932
|
+
new_email: user.new_email, new_phone: user.new_phone,
|
|
933
|
+
invited_at: user.invited_at&.iso8601,
|
|
934
|
+
is_anonymous: user.is_anonymous,
|
|
935
|
+
confirmation_sent_at: user.confirmation_sent_at&.iso8601,
|
|
936
|
+
recovery_sent_at: user.recovery_sent_at&.iso8601,
|
|
937
|
+
email_change_sent_at: user.email_change_sent_at&.iso8601,
|
|
938
|
+
action_link: user.action_link
|
|
939
|
+
}
|
|
940
|
+
end
|
|
941
|
+
@storage.set_item(@storage_key, JSON.generate(session_data))
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
def _notify_all_subscribers(event, session)
|
|
946
|
+
@state_change_emitters.each_value do |sub|
|
|
947
|
+
sub.callback&.call(event, session)
|
|
948
|
+
end
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def _request(method, path, jwt: nil, body: nil, params: {}, headers: {}, redirect_to: nil, xform: nil)
|
|
952
|
+
@api._request(method, path, jwt: jwt, body: body, params: params, headers: headers,
|
|
953
|
+
redirect_to: redirect_to, xform: xform)
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
private
|
|
957
|
+
|
|
958
|
+
def _get_valid_session(raw_session)
|
|
959
|
+
return nil unless raw_session
|
|
960
|
+
|
|
961
|
+
begin
|
|
962
|
+
data = raw_session.is_a?(String) ? JSON.parse(raw_session) : raw_session
|
|
963
|
+
return nil unless data
|
|
964
|
+
return nil unless data["access_token"] || data[:access_token]
|
|
965
|
+
return nil unless data["refresh_token"] || data[:refresh_token]
|
|
966
|
+
return nil unless data["expires_at"] || data[:expires_at]
|
|
967
|
+
|
|
968
|
+
expires_at = data["expires_at"] || data[:expires_at]
|
|
969
|
+
begin
|
|
970
|
+
expires_at = Integer(expires_at)
|
|
971
|
+
data["expires_at"] = expires_at
|
|
972
|
+
rescue ArgumentError, TypeError
|
|
973
|
+
return nil
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
Types::Session.from_hash(data)
|
|
977
|
+
rescue StandardError
|
|
978
|
+
nil
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
def _get_session_from_url(url)
|
|
983
|
+
parsed = URI.parse(url)
|
|
984
|
+
params = URI.decode_www_form(parsed.query || "").to_h
|
|
985
|
+
|
|
986
|
+
if params["error_description"]
|
|
987
|
+
error_code = params["error_code"]
|
|
988
|
+
error = params["error"]
|
|
989
|
+
raise Errors::AuthImplicitGrantRedirectError.new("No error_code detected.") unless error_code
|
|
990
|
+
raise Errors::AuthImplicitGrantRedirectError.new("No error detected.") unless error
|
|
991
|
+
raise Errors::AuthImplicitGrantRedirectError.new(
|
|
992
|
+
params["error_description"],
|
|
993
|
+
details: { error: error, code: error_code }
|
|
994
|
+
)
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
provider_token = params["provider_token"]
|
|
998
|
+
provider_refresh_token = params["provider_refresh_token"]
|
|
999
|
+
access_token = params["access_token"]
|
|
1000
|
+
raise Errors::AuthImplicitGrantRedirectError.new("No access_token detected.") unless access_token
|
|
1001
|
+
|
|
1002
|
+
expires_in = params["expires_in"]
|
|
1003
|
+
raise Errors::AuthImplicitGrantRedirectError.new("No expires_in detected.") unless expires_in
|
|
1004
|
+
expires_in = expires_in.to_i
|
|
1005
|
+
|
|
1006
|
+
refresh_token = params["refresh_token"]
|
|
1007
|
+
raise Errors::AuthImplicitGrantRedirectError.new("No refresh_token detected.") unless refresh_token
|
|
1008
|
+
|
|
1009
|
+
token_type = params["token_type"]
|
|
1010
|
+
raise Errors::AuthImplicitGrantRedirectError.new("No token_type detected.") unless token_type
|
|
1011
|
+
|
|
1012
|
+
time_now = Time.now.to_i
|
|
1013
|
+
expires_at = time_now + expires_in
|
|
1014
|
+
|
|
1015
|
+
user_response = get_user(access_token)
|
|
1016
|
+
session = Types::Session.new(
|
|
1017
|
+
provider_token: provider_token,
|
|
1018
|
+
provider_refresh_token: provider_refresh_token,
|
|
1019
|
+
access_token: access_token,
|
|
1020
|
+
refresh_token: refresh_token,
|
|
1021
|
+
token_type: token_type,
|
|
1022
|
+
expires_in: expires_in,
|
|
1023
|
+
expires_at: expires_at,
|
|
1024
|
+
user: user_response.user
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
redirect_type = params["type"]
|
|
1028
|
+
[session, redirect_type]
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
def _fetch_jwks(kid, jwks)
|
|
1032
|
+
# Try supplied keys first
|
|
1033
|
+
jwk = (jwks["keys"] || []).find { |k| k["kid"] == kid }
|
|
1034
|
+
return jwk if jwk
|
|
1035
|
+
|
|
1036
|
+
# Try cache
|
|
1037
|
+
if @jwks && @jwks_cached_at && (Time.now.to_f - @jwks_cached_at) < JWKS_TTL
|
|
1038
|
+
jwk = (@jwks["keys"] || []).find { |k| k["kid"] == kid }
|
|
1039
|
+
return jwk if jwk
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
# Fetch from well-known endpoint
|
|
1043
|
+
response = _request("GET", ".well-known/jwks.json", xform: ->(d) { Helpers.parse_jwks(d) })
|
|
1044
|
+
if response
|
|
1045
|
+
@jwks = response
|
|
1046
|
+
@jwks_cached_at = Time.now.to_f
|
|
1047
|
+
|
|
1048
|
+
jwk = (response["keys"] || []).find { |k| k["kid"] == kid }
|
|
1049
|
+
raise Errors::AuthInvalidJwtError, "No matching signing key found in JWKS" unless jwk
|
|
1050
|
+
return jwk
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
raise Errors::AuthInvalidJwtError, "JWT has no valid kid"
|
|
1054
|
+
end
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
# MFA (Multi-Factor Authentication) API.
|
|
1058
|
+
# Access via {Client#mfa}.
|
|
1059
|
+
class MFAApi
|
|
1060
|
+
# @param client [Client] parent client instance
|
|
1061
|
+
def initialize(client)
|
|
1062
|
+
@client = client
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
# Enroll a new MFA factor.
|
|
1066
|
+
# @param params [Hash] (:factor_type, optional :friendly_name, :issuer)
|
|
1067
|
+
# @return [Types::AuthMFAEnrollResponse]
|
|
1068
|
+
def enroll(params)
|
|
1069
|
+
factor_type = params[:factor_type] || params["factor_type"]
|
|
1070
|
+
friendly_name = params[:friendly_name] || params["friendly_name"]
|
|
1071
|
+
|
|
1072
|
+
session = @client.get_session
|
|
1073
|
+
raise Errors::AuthSessionMissing unless session
|
|
1074
|
+
|
|
1075
|
+
body = { factor_type: factor_type, friendly_name: friendly_name }
|
|
1076
|
+
|
|
1077
|
+
if factor_type == "phone"
|
|
1078
|
+
body[:phone] = params[:phone] || params["phone"]
|
|
1079
|
+
else
|
|
1080
|
+
body[:issuer] = params[:issuer] || params["issuer"]
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
data = @client._request("POST", "factors", jwt: session.access_token, body: body)
|
|
1084
|
+
response = Types::AuthMFAEnrollResponse.from_hash(data)
|
|
1085
|
+
|
|
1086
|
+
if factor_type == "totp" && response.totp&.qr_code
|
|
1087
|
+
response.totp.qr_code = "data:image/svg+xml;utf-8,#{response.totp.qr_code}"
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
response
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
# Create an MFA challenge for a factor.
|
|
1094
|
+
# @param params [Hash] (:factor_id)
|
|
1095
|
+
# @return [Types::AuthMFAChallengeResponse]
|
|
1096
|
+
def challenge(params)
|
|
1097
|
+
factor_id = params[:factor_id] || params["factor_id"]
|
|
1098
|
+
channel = params[:channel] || params["channel"]
|
|
1099
|
+
|
|
1100
|
+
session = @client.get_session
|
|
1101
|
+
raise Errors::AuthSessionMissing unless session
|
|
1102
|
+
|
|
1103
|
+
data = @client._request("POST", "factors/#{factor_id}/challenge",
|
|
1104
|
+
jwt: session.access_token, body: { channel: channel })
|
|
1105
|
+
Types::AuthMFAChallengeResponse.from_hash(data)
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
# Verify an MFA challenge.
|
|
1109
|
+
# @param params [Hash] (:factor_id, :challenge_id, :code)
|
|
1110
|
+
# @return [Types::AuthMFAVerifyResponse]
|
|
1111
|
+
def verify(params)
|
|
1112
|
+
factor_id = params[:factor_id] || params["factor_id"]
|
|
1113
|
+
challenge_id = params[:challenge_id] || params["challenge_id"]
|
|
1114
|
+
code = params[:code] || params["code"]
|
|
1115
|
+
|
|
1116
|
+
session = @client.get_session
|
|
1117
|
+
raise Errors::AuthSessionMissing unless session
|
|
1118
|
+
|
|
1119
|
+
body = { factor_id: factor_id, challenge_id: challenge_id, code: code }
|
|
1120
|
+
data = @client._request("POST", "factors/#{factor_id}/verify",
|
|
1121
|
+
jwt: session.access_token, body: body)
|
|
1122
|
+
response = Types::AuthMFAVerifyResponse.from_hash(data)
|
|
1123
|
+
|
|
1124
|
+
# Save the new session from the verify response
|
|
1125
|
+
new_session = Types::Session.from_hash(data)
|
|
1126
|
+
if new_session&.access_token
|
|
1127
|
+
@client.send(:_save_session, new_session)
|
|
1128
|
+
@client.send(:_notify_all_subscribers, "MFA_CHALLENGE_VERIFIED", new_session)
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
response
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
# Challenge and verify in one step.
|
|
1135
|
+
# @param params [Hash] (:factor_id, :code, :channel)
|
|
1136
|
+
# @return [Types::AuthMFAVerifyResponse]
|
|
1137
|
+
def challenge_and_verify(params)
|
|
1138
|
+
challenge_response = challenge(
|
|
1139
|
+
factor_id: params[:factor_id] || params["factor_id"],
|
|
1140
|
+
channel: params[:channel] || params["channel"]
|
|
1141
|
+
)
|
|
1142
|
+
verify(
|
|
1143
|
+
factor_id: params[:factor_id] || params["factor_id"],
|
|
1144
|
+
challenge_id: challenge_response.id,
|
|
1145
|
+
code: params[:code] || params["code"]
|
|
1146
|
+
)
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
# Unenroll an MFA factor.
|
|
1150
|
+
# @param params [Hash] (:factor_id)
|
|
1151
|
+
# @return [Types::AuthMFAUnenrollResponse]
|
|
1152
|
+
def unenroll(params)
|
|
1153
|
+
factor_id = params[:factor_id] || params["factor_id"]
|
|
1154
|
+
|
|
1155
|
+
session = @client.get_session
|
|
1156
|
+
raise Errors::AuthSessionMissing unless session
|
|
1157
|
+
|
|
1158
|
+
data = @client._request("DELETE", "factors/#{factor_id}",
|
|
1159
|
+
jwt: session.access_token)
|
|
1160
|
+
Types::AuthMFAUnenrollResponse.from_hash(data)
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
# List all MFA factors for the current user.
|
|
1164
|
+
# @return [Types::AuthMFAListFactorsResponse] with :all, :totp, :phone arrays
|
|
1165
|
+
def list_factors
|
|
1166
|
+
user_response = @client.get_user
|
|
1167
|
+
factors = user_response&.user&.factors || []
|
|
1168
|
+
|
|
1169
|
+
totp = factors.select { |f| f.factor_type == "totp" && f.status == "verified" }
|
|
1170
|
+
phone = factors.select { |f| f.factor_type == "phone" && f.status == "verified" }
|
|
1171
|
+
|
|
1172
|
+
Types::AuthMFAListFactorsResponse.new(
|
|
1173
|
+
all: factors,
|
|
1174
|
+
totp: totp,
|
|
1175
|
+
phone: phone
|
|
1176
|
+
)
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
# Get the authenticator assurance level from the current session JWT.
|
|
1180
|
+
# @return [Types::AuthMFAGetAuthenticatorAssuranceLevelResponse]
|
|
1181
|
+
def get_authenticator_assurance_level
|
|
1182
|
+
session = @client.get_session
|
|
1183
|
+
|
|
1184
|
+
unless session
|
|
1185
|
+
return Types::AuthMFAGetAuthenticatorAssuranceLevelResponse.new(
|
|
1186
|
+
current_level: nil,
|
|
1187
|
+
next_level: nil,
|
|
1188
|
+
current_authentication_methods: []
|
|
1189
|
+
)
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
decoded = Helpers.decode_jwt(session.access_token)
|
|
1193
|
+
payload = decoded[:payload]
|
|
1194
|
+
|
|
1195
|
+
aal = payload["aal"]
|
|
1196
|
+
amr = payload["amr"] || []
|
|
1197
|
+
|
|
1198
|
+
verified_factors = (session.user&.factors || []).select { |f| f.status == "verified" }
|
|
1199
|
+
next_level = verified_factors.any? ? "aal2" : aal
|
|
1200
|
+
|
|
1201
|
+
Types::AuthMFAGetAuthenticatorAssuranceLevelResponse.new(
|
|
1202
|
+
current_level: aal,
|
|
1203
|
+
next_level: next_level,
|
|
1204
|
+
current_authentication_methods: amr
|
|
1205
|
+
)
|
|
1206
|
+
end
|
|
1207
|
+
end
|
|
1208
|
+
end
|
|
1209
|
+
end
|