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