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.
@@ -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