supabase-rb 3.1.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/supabase/auth/README.md +10 -4
- data/lib/supabase/auth/admin_api.rb +4 -0
- data/lib/supabase/auth/admin_mfa_api.rb +30 -0
- data/lib/supabase/auth/async/admin_api.rb +4 -1
- data/lib/supabase/auth/async/admin_mfa_api.rb +15 -0
- data/lib/supabase/auth/async/client.rb +2 -4
- data/lib/supabase/auth/async.rb +1 -0
- data/lib/supabase/auth/client.rb +71 -31
- data/lib/supabase/auth/helpers.rb +4 -0
- data/lib/supabase/auth/types.rb +14 -5
- data/lib/supabase/auth.rb +1 -0
- data/lib/supabase/client.rb +103 -22
- data/lib/supabase/client_options.rb +1 -1
- data/lib/supabase/functions/README.md +99 -12
- data/lib/supabase/functions/client.rb +72 -31
- data/lib/supabase/functions/types.rb +24 -3
- data/lib/supabase/postgrest/async/client.rb +2 -0
- data/lib/supabase/postgrest/client.rb +9 -2
- data/lib/supabase/postgrest/errors.rb +18 -6
- data/lib/supabase/postgrest/request_builder.rb +5 -11
- data/lib/supabase/realtime/README.md +111 -0
- data/lib/supabase/realtime/callback_safety.rb +41 -0
- data/lib/supabase/realtime/channel.rb +89 -23
- data/lib/supabase/realtime/client.rb +130 -44
- data/lib/supabase/realtime/errors.rb +0 -13
- data/lib/supabase/realtime/message.rb +13 -3
- data/lib/supabase/realtime/presence.rb +84 -32
- data/lib/supabase/realtime/push.rb +11 -2
- data/lib/supabase/realtime/timer.rb +72 -0
- data/lib/supabase/realtime.rb +2 -1
- data/lib/supabase/storage/README.md +117 -0
- data/lib/supabase/storage/async/client.rb +7 -4
- data/lib/supabase/storage/client.rb +16 -4
- data/lib/supabase/storage/file_api.rb +36 -9
- data/lib/supabase/storage/request.rb +3 -1
- data/lib/supabase/storage/utils.rb +15 -1
- data/lib/supabase/version.rb +1 -1
- data/lib/supabase.rb +0 -7
- metadata +33 -16
- data/lib/supabase/realtime/test_socket.rb +0 -65
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6f3e676fb9f80387385b5339aff40fcdc8b584b9c8e66f92072f2ac9d7ba356
|
|
4
|
+
data.tar.gz: f9972703bfd0828183e1a600c5cad893c4102c728b913384919b41da9e4deb06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 39871694f25f18c296f358f6f85325f18f31a1752e2bb79dc96c6ce0551f5d0c10a72c5994a5302761e6e4df170eadae058dc69fa04dac5764db2568198d1b1a
|
|
7
|
+
data.tar.gz: aa76e506b180682651570e8b8a3c1a091901655a5d95ac5d642b962dc33dfd09f2c77d131117eb70c42a1163c63cd341d8941d1db3064ce7504943e75e771f35
|
data/lib/supabase/auth/README.md
CHANGED
|
@@ -148,7 +148,8 @@ dedicated class gives callers a precise `rescue` target.
|
|
|
148
148
|
|
|
149
149
|
### Explicit JWT algorithm → digest mapping
|
|
150
150
|
|
|
151
|
-
`Supabase::Auth::Client::ALG_TO_DIGEST` is a frozen
|
|
151
|
+
`Supabase::Auth::Client::ALG_TO_DIGEST` is a frozen reference table of the
|
|
152
|
+
asymmetric algorithms and their digests:
|
|
152
153
|
|
|
153
154
|
```ruby
|
|
154
155
|
ALG_TO_DIGEST = {
|
|
@@ -158,9 +159,14 @@ ALG_TO_DIGEST = {
|
|
|
158
159
|
}.freeze
|
|
159
160
|
```
|
|
160
161
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
(`
|
|
162
|
+
The actual *acceptance* check in `get_claims` runs against the broader
|
|
163
|
+
`SUPPORTED_ALGORITHMS` constant, which mirrors PyJWT's default algorithm set
|
|
164
|
+
(`HS256/384/512`, `RS*`, `ES*` + `ES256K`, `PS*`, `EdDSA`/`Ed25519`). Symmetric
|
|
165
|
+
tokens (`HS256` or any token without a `kid` header) fall back to
|
|
166
|
+
`get_user(token)` for verification — same as supabase-py. Unknown `alg`
|
|
167
|
+
values raise `AuthInvalidJwtError("Algorithm not supported")`, matching the
|
|
168
|
+
PyJWT `NotImplementedError` message verbatim. EdDSA verification additionally
|
|
169
|
+
requires the optional `rbnacl` gem at runtime.
|
|
164
170
|
|
|
165
171
|
## Development
|
|
166
172
|
|
|
@@ -10,6 +10,9 @@ module Supabase
|
|
|
10
10
|
# @return [AdminOAuthApi] OAuth 2.1 client administration accessor
|
|
11
11
|
attr_reader :oauth
|
|
12
12
|
|
|
13
|
+
# @return [AdminMfaApi] MFA administration accessor
|
|
14
|
+
attr_reader :mfa
|
|
15
|
+
|
|
13
16
|
# @param url [String] The GoTrue API base URL
|
|
14
17
|
# @param headers [Hash] Headers including Authorization bearer token
|
|
15
18
|
# @param http_client [Faraday::Connection, nil] Optional custom Faraday client
|
|
@@ -19,6 +22,7 @@ module Supabase
|
|
|
19
22
|
def initialize(url:, headers: {}, http_client: nil, verify: true, proxy: nil, timeout: nil)
|
|
20
23
|
super(url: url, headers: headers, http_client: http_client, verify: verify, proxy: proxy, timeout: timeout)
|
|
21
24
|
@oauth = AdminOAuthApi.new(self)
|
|
25
|
+
@mfa = AdminMfaApi.new(self)
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
# Creates a new user via the admin API.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Auth
|
|
5
|
+
# Admin MFA namespace. Mirrors supabase-py's SyncGoTrueAdminMFAAPI.
|
|
6
|
+
# Accessed via {AdminApi#mfa}; delegates to the underscored implementations
|
|
7
|
+
# on AdminApi (same pattern as {AdminOAuthApi} / {AdminApi#oauth}).
|
|
8
|
+
class AdminMfaApi
|
|
9
|
+
# @param admin [AdminApi]
|
|
10
|
+
def initialize(admin)
|
|
11
|
+
@admin = admin
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Lists MFA factors for a user.
|
|
15
|
+
# @param user_id [String] user UUID
|
|
16
|
+
# @return [Types::AuthMFAAdminListFactorsResponse]
|
|
17
|
+
def list_factors(user_id:)
|
|
18
|
+
@admin._list_factors(user_id: user_id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Deletes an MFA factor for a user.
|
|
22
|
+
# @param user_id [String] user UUID
|
|
23
|
+
# @param id [String] factor UUID
|
|
24
|
+
# @return [Types::AuthMFAAdminDeleteFactorResponse]
|
|
25
|
+
def delete_factor(user_id:, id:)
|
|
26
|
+
@admin._delete_factor(user_id: user_id, id: id)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "async/http/faraday"
|
|
4
4
|
|
|
5
5
|
require_relative "admin_oauth_api"
|
|
6
|
+
require_relative "admin_mfa_api"
|
|
6
7
|
|
|
7
8
|
module Supabase
|
|
8
9
|
module Auth
|
|
@@ -11,11 +12,13 @@ module Supabase
|
|
|
11
12
|
#
|
|
12
13
|
# Inherits all admin methods (user CRUD, generate_link, invite, MFA admin,
|
|
13
14
|
# OAuth admin) and only swaps the Faraday adapter to async-http-faraday.
|
|
14
|
-
# `admin.oauth` returns an {Async::AdminOAuthApi}
|
|
15
|
+
# `admin.oauth` returns an {Async::AdminOAuthApi} and `admin.mfa` returns an
|
|
16
|
+
# {Async::AdminMfaApi} for naming consistency.
|
|
15
17
|
class AdminApi < Supabase::Auth::AdminApi
|
|
16
18
|
def initialize(url:, headers: {}, http_client: nil, verify: true, proxy: nil, timeout: nil)
|
|
17
19
|
super
|
|
18
20
|
@oauth = AdminOAuthApi.new(self)
|
|
21
|
+
@mfa = AdminMfaApi.new(self)
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
private
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Auth
|
|
5
|
+
module Async
|
|
6
|
+
# Async counterpart to {Supabase::Auth::AdminMfaApi}.
|
|
7
|
+
#
|
|
8
|
+
# Behavior is identical — it delegates to the wrapped {AdminApi}'s
|
|
9
|
+
# underscored MFA methods. The wrapped admin uses the async Faraday adapter,
|
|
10
|
+
# so calls inside `Async do ... end` yield to the reactor on I/O.
|
|
11
|
+
class AdminMfaApi < Supabase::Auth::AdminMfaApi
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -22,10 +22,8 @@ module Supabase
|
|
|
22
22
|
class Client < Supabase::Auth::Client
|
|
23
23
|
def initialize(url:, headers: {}, **options)
|
|
24
24
|
super
|
|
25
|
-
@api = Api.new(url: @url, headers: @headers, http_client: @http_client,
|
|
26
|
-
|
|
27
|
-
@admin = AdminApi.new(url: @url, headers: @headers, http_client: @http_client,
|
|
28
|
-
verify: @verify, proxy: @proxy, timeout: @timeout)
|
|
25
|
+
@api = Api.new(url: @url, headers: @headers, http_client: @http_client, verify: @verify, proxy: @proxy, timeout: @timeout)
|
|
26
|
+
@admin = AdminApi.new(url: @url, headers: @headers, http_client: @http_client, verify: @verify, proxy: @proxy, timeout: @timeout)
|
|
29
27
|
end
|
|
30
28
|
end
|
|
31
29
|
end
|
data/lib/supabase/auth/async.rb
CHANGED
data/lib/supabase/auth/client.rb
CHANGED
|
@@ -13,13 +13,25 @@ module Supabase
|
|
|
13
13
|
STORAGE_KEY = "supabase.auth.token"
|
|
14
14
|
EXPIRY_MARGIN = 10
|
|
15
15
|
JWKS_TTL = 600 # 10 minutes
|
|
16
|
-
# Explicit algorithm-to-digest mapping
|
|
16
|
+
# Explicit asymmetric algorithm-to-digest mapping (reference table).
|
|
17
17
|
ALG_TO_DIGEST = {
|
|
18
18
|
"RS256" => "SHA256", "RS384" => "SHA384", "RS512" => "SHA512",
|
|
19
19
|
"ES256" => "SHA256", "ES384" => "SHA384", "ES512" => "SHA512",
|
|
20
20
|
"PS256" => "SHA256", "PS384" => "SHA384", "PS512" => "SHA512"
|
|
21
21
|
}.freeze
|
|
22
22
|
|
|
23
|
+
# Full set of algorithms accepted by `get_claims`, matching the PyJWT
|
|
24
|
+
# defaults that supabase-py relies on via `get_algorithm_by_name`.
|
|
25
|
+
# EdDSA / Ed25519 verification additionally requires the optional
|
|
26
|
+
# `rbnacl` gem at runtime; the algorithm name is still accepted here.
|
|
27
|
+
SUPPORTED_ALGORITHMS = %w[
|
|
28
|
+
HS256 HS384 HS512
|
|
29
|
+
RS256 RS384 RS512
|
|
30
|
+
ES256 ES256K ES384 ES512
|
|
31
|
+
PS256 PS384 PS512
|
|
32
|
+
EdDSA Ed25519
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
23
35
|
DEFAULT_OPTIONS = {
|
|
24
36
|
auto_refresh_token: true,
|
|
25
37
|
persist_session: true,
|
|
@@ -27,7 +39,7 @@ module Supabase
|
|
|
27
39
|
flow_type: "implicit"
|
|
28
40
|
}.freeze
|
|
29
41
|
|
|
30
|
-
attr_reader :url, :headers, :admin, :mfa
|
|
42
|
+
attr_reader :url, :headers, :admin, :mfa, :logger
|
|
31
43
|
|
|
32
44
|
# @param url [String] GoTrue server URL
|
|
33
45
|
# @param headers [Hash] HTTP headers to include with every request
|
|
@@ -37,10 +49,12 @@ module Supabase
|
|
|
37
49
|
# @option options [String] :flow_type ("implicit") OAuth flow type ("implicit" or "pkce")
|
|
38
50
|
# @option options [SupportedStorage] :storage custom storage backend
|
|
39
51
|
# @option options [Faraday::Connection] :http_client custom HTTP client
|
|
52
|
+
# @option options [#warn] :logger optional logger; if nil, auto-refresh
|
|
53
|
+
# failures fall back to Kernel#warn ($stderr).
|
|
40
54
|
def initialize(url:, headers: {}, **options)
|
|
41
55
|
opts = DEFAULT_OPTIONS.merge(options)
|
|
42
56
|
@url = url
|
|
43
|
-
@headers = headers
|
|
57
|
+
@headers = Constants::DEFAULT_HEADERS.merge(headers)
|
|
44
58
|
@auto_refresh_token = opts[:auto_refresh_token]
|
|
45
59
|
@persist_session = opts[:persist_session]
|
|
46
60
|
@detect_session_in_url = opts[:detect_session_in_url]
|
|
@@ -51,6 +65,7 @@ module Supabase
|
|
|
51
65
|
@verify = opts.fetch(:verify, true)
|
|
52
66
|
@proxy = opts[:proxy]
|
|
53
67
|
@timeout = opts[:timeout]
|
|
68
|
+
@logger = opts[:logger]
|
|
54
69
|
|
|
55
70
|
@current_session = nil
|
|
56
71
|
@jwks = { "keys" => [] }
|
|
@@ -59,10 +74,8 @@ module Supabase
|
|
|
59
74
|
@refresh_token_timer = nil
|
|
60
75
|
@network_retries = 0
|
|
61
76
|
|
|
62
|
-
@api = Api.new(url: @url, headers: @headers, http_client: @http_client,
|
|
63
|
-
|
|
64
|
-
@admin = AdminApi.new(url: @url, headers: @headers, http_client: @http_client,
|
|
65
|
-
verify: @verify, proxy: @proxy, timeout: @timeout)
|
|
77
|
+
@api = Api.new(url: @url, headers: @headers, http_client: @http_client, verify: @verify, proxy: @proxy, timeout: @timeout)
|
|
78
|
+
@admin = AdminApi.new(url: @url, headers: @headers, http_client: @http_client, verify: @verify, proxy: @proxy, timeout: @timeout)
|
|
66
79
|
@mfa = MFAApi.new(self)
|
|
67
80
|
end
|
|
68
81
|
|
|
@@ -109,6 +122,11 @@ module Supabase
|
|
|
109
122
|
channel = options[:channel] || "sms"
|
|
110
123
|
captcha_token = options[:captcha_token]
|
|
111
124
|
|
|
125
|
+
if password.nil? && (email || phone)
|
|
126
|
+
raise Errors::AuthInvalidCredentialsError,
|
|
127
|
+
"Sign up requires a password; for passwordless sign-in use sign_in_with_otp"
|
|
128
|
+
end
|
|
129
|
+
|
|
112
130
|
if email
|
|
113
131
|
body = {
|
|
114
132
|
email: email,
|
|
@@ -378,14 +396,12 @@ module Supabase
|
|
|
378
396
|
# @option options [String] :scope ("global") sign-out scope: "global", "local", or "others"
|
|
379
397
|
def sign_out(options = {})
|
|
380
398
|
scope = options[:scope] || options["scope"] || "global"
|
|
381
|
-
session = get_session
|
|
382
399
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
end
|
|
400
|
+
begin
|
|
401
|
+
session = get_session
|
|
402
|
+
@admin.sign_out(session.access_token, scope) if session
|
|
403
|
+
rescue Errors::AuthApiError
|
|
404
|
+
# Suppress API errors from get_session/admin.sign_out so logout always clears local state.
|
|
389
405
|
end
|
|
390
406
|
|
|
391
407
|
unless scope == "others"
|
|
@@ -542,8 +558,8 @@ module Supabase
|
|
|
542
558
|
session = get_session
|
|
543
559
|
raise Errors::AuthSessionMissing unless session
|
|
544
560
|
|
|
545
|
-
|
|
546
|
-
|
|
561
|
+
_request("GET", "reauthenticate", jwt: session.access_token)
|
|
562
|
+
Types::AuthResponse.new(user: nil, session: nil)
|
|
547
563
|
end
|
|
548
564
|
|
|
549
565
|
# Send a password reset email. Does not require an active session.
|
|
@@ -614,10 +630,7 @@ module Supabase
|
|
|
614
630
|
session = get_session
|
|
615
631
|
raise Errors::AuthSessionMissing unless session
|
|
616
632
|
|
|
617
|
-
link_identity = _request("GET", url,
|
|
618
|
-
params: query,
|
|
619
|
-
jwt: session.access_token,
|
|
620
|
-
xform: ->(data) { Helpers.parse_link_identity_response(data) })
|
|
633
|
+
link_identity = _request("GET", url, params: query, jwt: session.access_token, xform: ->(data) { Helpers.parse_link_identity_response(data) })
|
|
621
634
|
Types::OAuthResponse.new(provider: provider, url: link_identity.url)
|
|
622
635
|
end
|
|
623
636
|
|
|
@@ -695,12 +708,14 @@ module Supabase
|
|
|
695
708
|
return Types::ClaimsResponse.new(claims: payload, headers: header, signature: signature)
|
|
696
709
|
end
|
|
697
710
|
|
|
711
|
+
# Mirror PyJWT's `get_algorithm_by_name` — reject unknown algs with the
|
|
712
|
+
# same message py would raise.
|
|
713
|
+
raise Errors::AuthInvalidJwtError, "Algorithm not supported" unless SUPPORTED_ALGORITHMS.include?(header["alg"])
|
|
714
|
+
|
|
698
715
|
# Asymmetric JWT - verify via JWKS using the jwt gem's decode
|
|
699
716
|
jwk_data = _fetch_jwks(header["kid"], jwks || { "keys" => [] })
|
|
700
717
|
jwk_set = JWT::JWK::Set.new({ "keys" => [jwk_data] })
|
|
701
718
|
|
|
702
|
-
raise Errors::AuthInvalidJwtError, "Unsupported algorithm: #{header["alg"]}" unless ALG_TO_DIGEST[header["alg"]]
|
|
703
|
-
|
|
704
719
|
begin
|
|
705
720
|
JWT.decode(token, nil, true, { algorithms: [header["alg"]], jwks: jwk_set })
|
|
706
721
|
rescue JWT::DecodeError => e
|
|
@@ -806,12 +821,14 @@ module Supabase
|
|
|
806
821
|
_call_refresh_token(session.refresh_token)
|
|
807
822
|
@network_retries = 0
|
|
808
823
|
end
|
|
809
|
-
rescue Errors::AuthRetryableError
|
|
824
|
+
rescue Errors::AuthRetryableError => e
|
|
825
|
+
_log_refresh_error("auto_refresh", e)
|
|
810
826
|
if @network_retries < Constants::MAX_RETRIES
|
|
811
827
|
_start_auto_refresh_token(200 * (Constants::RETRY_INTERVAL ** (@network_retries - 1)))
|
|
812
828
|
end
|
|
813
|
-
rescue StandardError
|
|
814
|
-
#
|
|
829
|
+
rescue StandardError => e
|
|
830
|
+
# Non-retryable failure: log once, do NOT reschedule (no infinite loop).
|
|
831
|
+
_log_refresh_error("auto_refresh", e)
|
|
815
832
|
end
|
|
816
833
|
end
|
|
817
834
|
@refresh_token_timer.start
|
|
@@ -838,7 +855,8 @@ module Supabase
|
|
|
838
855
|
begin
|
|
839
856
|
_call_refresh_token(refresh_token)
|
|
840
857
|
@network_retries = 0
|
|
841
|
-
rescue Errors::AuthRetryableError
|
|
858
|
+
rescue Errors::AuthRetryableError => e
|
|
859
|
+
_log_refresh_error("recover_and_refresh", e)
|
|
842
860
|
if @network_retries < Constants::MAX_RETRIES
|
|
843
861
|
if @refresh_token_timer
|
|
844
862
|
@refresh_token_timer.cancel
|
|
@@ -849,8 +867,9 @@ module Supabase
|
|
|
849
867
|
@refresh_token_timer.start
|
|
850
868
|
return
|
|
851
869
|
end
|
|
852
|
-
rescue StandardError
|
|
853
|
-
#
|
|
870
|
+
rescue StandardError => e
|
|
871
|
+
# Non-retryable failure: log once, do NOT reschedule (no infinite loop).
|
|
872
|
+
_log_refresh_error("recover_and_refresh", e)
|
|
854
873
|
end
|
|
855
874
|
end
|
|
856
875
|
_remove_session
|
|
@@ -882,6 +901,21 @@ module Supabase
|
|
|
882
901
|
Helpers.parse_auth_response(data)
|
|
883
902
|
end
|
|
884
903
|
|
|
904
|
+
# Log an auto-refresh failure. Used by both `_start_auto_refresh_token`
|
|
905
|
+
# and `_recover_and_refresh` so the diverged-from-py contract
|
|
906
|
+
# ("any refresh error is logged with class + message") is enforced in
|
|
907
|
+
# one place. Falls back to Kernel#warn when no logger is injected.
|
|
908
|
+
def _log_refresh_error(context, error)
|
|
909
|
+
msg = "[Supabase::Auth] refresh failed in #{context}: #{error.class}: #{error.message}"
|
|
910
|
+
if @logger.respond_to?(:warn)
|
|
911
|
+
@logger.warn(msg)
|
|
912
|
+
else
|
|
913
|
+
Kernel.warn(msg)
|
|
914
|
+
end
|
|
915
|
+
rescue StandardError
|
|
916
|
+
# Never let logging itself break the refresh loop.
|
|
917
|
+
end
|
|
918
|
+
|
|
885
919
|
def _list_factors
|
|
886
920
|
mfa.list_factors
|
|
887
921
|
end
|
|
@@ -949,17 +983,23 @@ module Supabase
|
|
|
949
983
|
|
|
950
984
|
private
|
|
951
985
|
|
|
986
|
+
# US-005 / Q1: parity with supabase-py — `_get_valid_session` checks only
|
|
987
|
+
# `expires_at` (matches `gotrue_client.py:_get_valid_session`, which after
|
|
988
|
+
# pydantic-validation does a single explicit `expires_at is None` check).
|
|
989
|
+
# Defence against missing access_token/refresh_token/user is deferred to
|
|
990
|
+
# the use site: `_call_refresh_token` raises `AuthSessionMissing` for an
|
|
991
|
+
# empty refresh token, `get_user`/admin calls return early on a nil
|
|
992
|
+
# access token, and `Types::Session.from_hash` tolerates a nil `user`.
|
|
952
993
|
def _get_valid_session(raw_session)
|
|
953
994
|
return nil unless raw_session
|
|
954
995
|
|
|
955
996
|
begin
|
|
956
997
|
data = raw_session.is_a?(String) ? JSON.parse(raw_session) : raw_session
|
|
957
998
|
return nil unless data
|
|
958
|
-
return nil unless data["access_token"] || data[:access_token]
|
|
959
|
-
return nil unless data["refresh_token"] || data[:refresh_token]
|
|
960
|
-
return nil unless data["expires_at"] || data[:expires_at]
|
|
961
999
|
|
|
962
1000
|
expires_at = data["expires_at"] || data[:expires_at]
|
|
1001
|
+
return nil if expires_at.nil?
|
|
1002
|
+
|
|
963
1003
|
begin
|
|
964
1004
|
expires_at = Integer(expires_at)
|
|
965
1005
|
data["expires_at"] = expires_at
|
|
@@ -102,6 +102,10 @@ module Supabase
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
begin
|
|
105
|
+
if exception.is_a?(Faraday::TimeoutError) || exception.response.nil?
|
|
106
|
+
return Errors::AuthRetryableError.new(exception.message, status: 0)
|
|
107
|
+
end
|
|
108
|
+
|
|
105
109
|
response = exception.response
|
|
106
110
|
status = response[:status]
|
|
107
111
|
|
data/lib/supabase/auth/types.rb
CHANGED
|
@@ -368,11 +368,20 @@ module Supabase
|
|
|
368
368
|
:factors,
|
|
369
369
|
keyword_init: true
|
|
370
370
|
) do
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
371
|
+
# Accepts both forms:
|
|
372
|
+
# - bare Array of factor hashes (supabase-py / current GoTrue response)
|
|
373
|
+
# - `{"factors" => [...]}` wrapped Hash (legacy)
|
|
374
|
+
def self.from_hash(data)
|
|
375
|
+
return nil if data.nil?
|
|
376
|
+
|
|
377
|
+
raw_factors = if data.is_a?(Array)
|
|
378
|
+
data
|
|
379
|
+
elsif data.is_a?(Hash)
|
|
380
|
+
data["factors"] || data[:factors] || []
|
|
381
|
+
else
|
|
382
|
+
[]
|
|
383
|
+
end
|
|
384
|
+
new(factors: raw_factors.map { |f| Factor.from_hash(f) })
|
|
376
385
|
end
|
|
377
386
|
end
|
|
378
387
|
|
data/lib/supabase/auth.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "auth/errors"
|
|
|
7
7
|
require_relative "auth/api"
|
|
8
8
|
require_relative "auth/admin_api"
|
|
9
9
|
require_relative "auth/admin_oauth_api"
|
|
10
|
+
require_relative "auth/admin_mfa_api"
|
|
10
11
|
require_relative "auth/helpers"
|
|
11
12
|
require_relative "auth/storage"
|
|
12
13
|
require_relative "auth/memory_storage"
|
data/lib/supabase/client.rb
CHANGED
|
@@ -71,7 +71,31 @@ module Supabase
|
|
|
71
71
|
|
|
72
72
|
@supabase_url = supabase_url.to_s.chomp("/")
|
|
73
73
|
@supabase_key = supabase_key
|
|
74
|
-
|
|
74
|
+
# Plain Hash → ClientOptions: paritet with supabase-py, where every
|
|
75
|
+
# option flows through a typed dataclass. The legacy nested
|
|
76
|
+
# `{ auth: {...}, postgrest: {...}, global: { headers: {...} } }` shape
|
|
77
|
+
# is kept as a raw Hash so existing callers don't break — anything
|
|
78
|
+
# else is canonicalized into a ClientOptions struct so the per-sub-
|
|
79
|
+
# client kwargs derivation has one code path.
|
|
80
|
+
legacy_hash_shape =
|
|
81
|
+
options.is_a?(Hash) &&
|
|
82
|
+
options.keys.any? { |k| %i[auth postgrest storage functions realtime global].include?(k.to_sym) }
|
|
83
|
+
|
|
84
|
+
@options =
|
|
85
|
+
if options.is_a?(Hash) && !legacy_hash_shape
|
|
86
|
+
ClientOptions.new(**options.transform_keys(&:to_sym))
|
|
87
|
+
elsif options.is_a?(Supabase::ClientOptions)
|
|
88
|
+
# Mirror supabase-py's `self.options = copy.copy(options)` followed by
|
|
89
|
+
# `self.options.headers = {**options.headers, ...}` — both the struct
|
|
90
|
+
# and its headers hash become unique to this client, so a downstream
|
|
91
|
+
# `client.options.headers["X"] = ...` mutation can't leak across
|
|
92
|
+
# clients constructed from the same `ClientOptions` instance (F-C?).
|
|
93
|
+
isolated = options.dup
|
|
94
|
+
isolated.headers = isolated.headers.dup
|
|
95
|
+
isolated
|
|
96
|
+
else
|
|
97
|
+
options
|
|
98
|
+
end
|
|
75
99
|
@async = async
|
|
76
100
|
|
|
77
101
|
configured_headers =
|
|
@@ -103,8 +127,7 @@ module Supabase
|
|
|
103
127
|
@auth.on_auth_state_change do |event, session|
|
|
104
128
|
next unless %w[SIGNED_IN TOKEN_REFRESHED SIGNED_OUT].include?(event)
|
|
105
129
|
|
|
106
|
-
|
|
107
|
-
propagate_auth(token)
|
|
130
|
+
apply_auth(session&.access_token)
|
|
108
131
|
end
|
|
109
132
|
@auth
|
|
110
133
|
end
|
|
@@ -139,10 +162,47 @@ module Supabase
|
|
|
139
162
|
postgrest.from(table)
|
|
140
163
|
end
|
|
141
164
|
|
|
165
|
+
# Alias for {#from}. Mirrors supabase-py's `Client.table(table_name)` so
|
|
166
|
+
# code ported from Python (`client.table("users").select("*")`) works
|
|
167
|
+
# unchanged.
|
|
168
|
+
# @see supabase-py supabase/_sync/client.py:128
|
|
169
|
+
alias table from
|
|
170
|
+
|
|
142
171
|
def rpc(func, params = {}, **opts)
|
|
143
172
|
postgrest.rpc(func, params, **opts)
|
|
144
173
|
end
|
|
145
174
|
|
|
175
|
+
# Realtime shortcuts on the umbrella — mirror supabase-py so callers can do
|
|
176
|
+
# `client.channel("public:users")` instead of `client.realtime.channel(...)`.
|
|
177
|
+
# The `realtime:` topic prefix is still optional (handled inside the
|
|
178
|
+
# Realtime client).
|
|
179
|
+
def channel(topic, params: nil)
|
|
180
|
+
realtime.channel(topic, params: params)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def get_channels
|
|
184
|
+
realtime.get_channels
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Unsubscribe a channel and drop it from the realtime registry. Mirrors
|
|
188
|
+
# supabase-py: the sync client blocks until the phx_leave frame is written;
|
|
189
|
+
# the async client (`async def remove_channel`) lets callers await it.
|
|
190
|
+
# Under `async: true` we get the same shape via {#dispatch_realtime} — the
|
|
191
|
+
# call returns an `Async::Task` the caller may `.wait` on (US-050), so a
|
|
192
|
+
# slow `Socket#send` never stalls the calling fiber.
|
|
193
|
+
# @see supabase-py supabase/_async/client.py:231
|
|
194
|
+
def remove_channel(channel)
|
|
195
|
+
dispatch_realtime { realtime.remove_channel(channel) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Unsubscribe every realtime channel registered on this client. Mirrors
|
|
199
|
+
# supabase-py's `Client.remove_all_channels`; same sync/async contract as
|
|
200
|
+
# {#remove_channel}.
|
|
201
|
+
# @see supabase-py supabase/_sync/client.py:234
|
|
202
|
+
def remove_all_channels
|
|
203
|
+
dispatch_realtime { realtime.remove_all_channels }
|
|
204
|
+
end
|
|
205
|
+
|
|
146
206
|
# Return a Postgrest client scoped to `name` without mutating self. Matches
|
|
147
207
|
# supabase-py: `client.schema("foo").from_("x")` queries the foo schema but
|
|
148
208
|
# leaves `client.from(...)` (and other call sites) on the default schema.
|
|
@@ -155,29 +215,47 @@ module Supabase
|
|
|
155
215
|
# Update the Authorization header used by every sub-client. Useful after
|
|
156
216
|
# auth.sign_in returns a fresh JWT — the apikey stays the same but the
|
|
157
217
|
# bearer token becomes the user's access token.
|
|
218
|
+
#
|
|
219
|
+
# Breaking change vs <=3.1.1: `set_auth(nil)` no longer drops the memoized
|
|
220
|
+
# auth sub-client (and with it any persisted session). Call `auth.sign_out`
|
|
221
|
+
# to clear session state.
|
|
158
222
|
def set_auth(token)
|
|
159
|
-
|
|
160
|
-
# Reset memoized sub-clients so they pick up the new header on next access.
|
|
161
|
-
# Realtime gets its own pathway (set_auth pushes access_token frames).
|
|
162
|
-
@auth = nil
|
|
163
|
-
@storage = nil
|
|
164
|
-
@functions = nil
|
|
165
|
-
@postgrest = nil
|
|
166
|
-
@realtime&.set_auth(token)
|
|
223
|
+
apply_auth(token)
|
|
167
224
|
self
|
|
168
225
|
end
|
|
169
226
|
|
|
170
227
|
private
|
|
171
228
|
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
229
|
+
# Single internal path shared by the public `#set_auth` and the
|
|
230
|
+
# `on_auth_state_change` listener installed on `#auth`. Refreshes the
|
|
231
|
+
# Authorization header used by every non-auth sub-client and resets their
|
|
232
|
+
# memoized instances so they pick up the new token on next access.
|
|
233
|
+
# `@auth` is intentionally preserved — clearing it would also discard the
|
|
234
|
+
# in-memory persisted session held by its storage backend.
|
|
235
|
+
#
|
|
236
|
+
# Under `async: true` the realtime fan-out is dispatched as a child
|
|
237
|
+
# `Async` task so the calling fiber returns immediately instead of
|
|
238
|
+
# waiting for every joined channel's `Socket#send` to drain — see
|
|
239
|
+
# spec/async/apply_auth_non_blocking_spec.rb (US-047 / US-048).
|
|
240
|
+
def apply_auth(token)
|
|
241
|
+
@headers["Authorization"] = "Bearer #{token || @supabase_key}"
|
|
242
|
+
@storage = @functions = @postgrest = nil
|
|
243
|
+
dispatch_realtime { @realtime&.set_auth(token) }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Shared dispatch for every umbrella → realtime call that may touch the
|
|
247
|
+
# socket (`set_auth` fan-out, `remove_channel`, `remove_all_channels`).
|
|
248
|
+
# The realtime client is thread-based, so its socket writes are plain
|
|
249
|
+
# blocking Ruby. Sync mode calls straight through. Under `async: true`
|
|
250
|
+
# the block runs in a child `Async` task: inside a reactor the calling
|
|
251
|
+
# fiber gets the task back immediately (`.wait` restores Python's `await`
|
|
252
|
+
# semantics); outside a reactor `Async { }` degrades to running inline,
|
|
253
|
+
# which matches the sync path.
|
|
254
|
+
def dispatch_realtime(&block)
|
|
255
|
+
return yield unless @async
|
|
256
|
+
|
|
257
|
+
require "async" unless defined?(Async)
|
|
258
|
+
Async(&block)
|
|
181
259
|
end
|
|
182
260
|
|
|
183
261
|
def auth_class
|
|
@@ -234,7 +312,7 @@ module Supabase
|
|
|
234
312
|
case key
|
|
235
313
|
when :auth
|
|
236
314
|
{ auto_refresh_token: o.auto_refresh_token, persist_session: o.persist_session,
|
|
237
|
-
storage: o.storage, flow_type: o.flow_type }.compact
|
|
315
|
+
storage: o.storage, flow_type: o.flow_type, http_client: o.http_client }.compact
|
|
238
316
|
when :postgrest
|
|
239
317
|
{ schema: o.schema, timeout: o.postgrest_client_timeout, http_client: o.http_client }.compact
|
|
240
318
|
when :storage
|
|
@@ -250,7 +328,10 @@ module Supabase
|
|
|
250
328
|
end
|
|
251
329
|
|
|
252
330
|
# Factory that matches supabase-py's `supabase.create_client()` signature.
|
|
331
|
+
# Routes through `Client.create` so a persisted session in the auth client's
|
|
332
|
+
# storage is restored at construction time and its access_token becomes the
|
|
333
|
+
# initial Authorization bearer — instead of the anon key. See F-C6 / US-021.
|
|
253
334
|
def self.create_client(supabase_url:, supabase_key:, options: {}, async: false)
|
|
254
|
-
Client.
|
|
335
|
+
Client.create(supabase_url: supabase_url, supabase_key: supabase_key, options: options, async: async)
|
|
255
336
|
end
|
|
256
337
|
end
|
|
@@ -23,7 +23,7 @@ module Supabase
|
|
|
23
23
|
class ClientOptions
|
|
24
24
|
DEFAULT_POSTGREST_TIMEOUT = 120
|
|
25
25
|
DEFAULT_STORAGE_TIMEOUT = 20
|
|
26
|
-
DEFAULT_FUNCTIONS_TIMEOUT =
|
|
26
|
+
DEFAULT_FUNCTIONS_TIMEOUT = 5
|
|
27
27
|
|
|
28
28
|
DEFAULT_HEADERS = { "X-Client-Info" => "supabase-rb/#{Supabase::VERSION}" }.freeze
|
|
29
29
|
|