supabase-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +233 -0
- data/lib/supabase/auth/admin_api.rb +123 -0
- data/lib/supabase/auth/api.rb +115 -0
- data/lib/supabase/auth/client.rb +1209 -0
- data/lib/supabase/auth/constants.rb +32 -0
- data/lib/supabase/auth/errors.rb +206 -0
- data/lib/supabase/auth/helpers.rb +223 -0
- data/lib/supabase/auth/memory_storage.rb +25 -0
- data/lib/supabase/auth/storage.rb +19 -0
- data/lib/supabase/auth/timer.rb +40 -0
- data/lib/supabase/auth/types.rb +435 -0
- data/lib/supabase/auth/version.rb +7 -0
- data/lib/supabase/auth.rb +18 -0
- data/lib/supabase-auth.rb +3 -0
- metadata +159 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Auth
|
|
5
|
+
module Constants
|
|
6
|
+
GOTRUE_URL = "http://localhost:9999"
|
|
7
|
+
|
|
8
|
+
DEFAULT_HEADERS = {
|
|
9
|
+
"X-Client-Info" => "gotrue-rb/#{VERSION}"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
EXPIRY_MARGIN = 10 # seconds
|
|
13
|
+
|
|
14
|
+
MAX_RETRIES = 10
|
|
15
|
+
|
|
16
|
+
RETRY_INTERVAL = 2 # deciseconds
|
|
17
|
+
|
|
18
|
+
STORAGE_KEY = "supabase.auth.token"
|
|
19
|
+
|
|
20
|
+
API_VERSION_HEADER_NAME = "X-Supabase-Api-Version"
|
|
21
|
+
|
|
22
|
+
API_VERSIONS = {
|
|
23
|
+
"2024-01-01" => {
|
|
24
|
+
"timestamp" => Time.new(2024, 1, 1).to_f,
|
|
25
|
+
"name" => "2024-01-01"
|
|
26
|
+
}.freeze
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
BASE64URL_REGEX = /\A([a-z0-9_-]{4})*($|[a-z0-9_-]{3}$|[a-z0-9_-]{2}$)\z/i
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Auth
|
|
5
|
+
module Errors
|
|
6
|
+
# Base error class for all Supabase Auth errors.
|
|
7
|
+
class AuthError < StandardError
|
|
8
|
+
attr_reader :status, :code
|
|
9
|
+
|
|
10
|
+
def initialize(message, status: nil, code: nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@status = status
|
|
13
|
+
@code = code
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
{ message: message, status: @status, code: @code }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised for GoTrue server API errors (4xx/5xx responses).
|
|
22
|
+
class AuthApiError < AuthError
|
|
23
|
+
def initialize(message, status:, code: nil)
|
|
24
|
+
super(message, status: status, code: code)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised for unexpected or unrecognized errors.
|
|
29
|
+
class AuthUnknownError < AuthError
|
|
30
|
+
attr_reader :original_error
|
|
31
|
+
|
|
32
|
+
def initialize(message, original_error: nil)
|
|
33
|
+
super(message, status: nil, code: nil)
|
|
34
|
+
@original_error = original_error
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Intermediate class for custom auth errors with name and status.
|
|
39
|
+
class CustomAuthError < AuthError
|
|
40
|
+
attr_reader :name
|
|
41
|
+
|
|
42
|
+
def initialize(message, name:, status:, code: nil)
|
|
43
|
+
super(message, status: status, code: code)
|
|
44
|
+
@name = name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
{ name: @name, message: message, status: @status }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Raised when an auth session is required but not present.
|
|
53
|
+
class AuthSessionMissing < CustomAuthError
|
|
54
|
+
def initialize(message = "Auth session missing!")
|
|
55
|
+
super(message, name: "AuthSessionMissingError", status: 400)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Raised when credentials are missing or invalid.
|
|
60
|
+
class AuthInvalidCredentialsError < CustomAuthError
|
|
61
|
+
def initialize(message)
|
|
62
|
+
super(message, name: "AuthInvalidCredentialsError", status: 400)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Raised when an implicit grant redirect contains an error.
|
|
67
|
+
class AuthImplicitGrantRedirectError < CustomAuthError
|
|
68
|
+
attr_reader :details
|
|
69
|
+
|
|
70
|
+
def initialize(message, details: nil)
|
|
71
|
+
super(message, name: "AuthImplicitGrantRedirectError", status: 500)
|
|
72
|
+
@details = details
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
{ name: @name, message: message, status: @status, details: @details }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Raised for retryable errors (network issues, 502/503/504).
|
|
81
|
+
class AuthRetryableError < CustomAuthError
|
|
82
|
+
def initialize(message, status: 0)
|
|
83
|
+
super(message, name: "AuthRetryableError", status: status)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Raised when a password does not meet strength requirements.
|
|
88
|
+
class AuthWeakPassword < CustomAuthError
|
|
89
|
+
attr_reader :reasons
|
|
90
|
+
|
|
91
|
+
def initialize(message, status: 422, reasons: [])
|
|
92
|
+
super(message, name: "AuthWeakPasswordError", status: status, code: "weak_password")
|
|
93
|
+
@reasons = reasons
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_h
|
|
97
|
+
{ name: @name, message: message, status: @status, reasons: @reasons }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Raised when a JWT is invalid or malformed.
|
|
102
|
+
class AuthInvalidJwtError < CustomAuthError
|
|
103
|
+
def initialize(message)
|
|
104
|
+
super(message, name: "AuthInvalidJwtError", status: 400, code: "invalid_jwt")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Raised for PKCE flow errors.
|
|
109
|
+
class AuthPKCEError < AuthError
|
|
110
|
+
def initialize(message)
|
|
111
|
+
super(message, status: 400, code: "pkce_error")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Alias for AuthSessionMissing (matches Python's AuthSessionMissingError)
|
|
116
|
+
AuthSessionMissingError = AuthSessionMissing
|
|
117
|
+
# Alias for AuthWeakPassword (matches Python's AuthWeakPasswordError)
|
|
118
|
+
AuthWeakPasswordError = AuthWeakPassword
|
|
119
|
+
|
|
120
|
+
# All known GoTrue error codes.
|
|
121
|
+
ERROR_CODES = %w[
|
|
122
|
+
unexpected_failure
|
|
123
|
+
validation_failed
|
|
124
|
+
bad_json
|
|
125
|
+
email_exists
|
|
126
|
+
phone_exists
|
|
127
|
+
bad_jwt
|
|
128
|
+
not_admin
|
|
129
|
+
no_authorization
|
|
130
|
+
user_not_found
|
|
131
|
+
session_not_found
|
|
132
|
+
flow_state_not_found
|
|
133
|
+
flow_state_expired
|
|
134
|
+
signup_disabled
|
|
135
|
+
user_banned
|
|
136
|
+
provider_email_needs_verification
|
|
137
|
+
invite_not_found
|
|
138
|
+
bad_oauth_state
|
|
139
|
+
bad_oauth_callback
|
|
140
|
+
oauth_provider_not_supported
|
|
141
|
+
unexpected_audience
|
|
142
|
+
single_identity_not_deletable
|
|
143
|
+
email_conflict_identity_not_deletable
|
|
144
|
+
identity_already_exists
|
|
145
|
+
email_provider_disabled
|
|
146
|
+
phone_provider_disabled
|
|
147
|
+
too_many_enrolled_mfa_factors
|
|
148
|
+
mfa_factor_name_conflict
|
|
149
|
+
mfa_factor_not_found
|
|
150
|
+
mfa_ip_address_mismatch
|
|
151
|
+
mfa_challenge_expired
|
|
152
|
+
mfa_verification_failed
|
|
153
|
+
mfa_verification_rejected
|
|
154
|
+
insufficient_aal
|
|
155
|
+
captcha_failed
|
|
156
|
+
saml_provider_disabled
|
|
157
|
+
manual_linking_disabled
|
|
158
|
+
sms_send_failed
|
|
159
|
+
email_not_confirmed
|
|
160
|
+
phone_not_confirmed
|
|
161
|
+
reauth_nonce_missing
|
|
162
|
+
saml_relay_state_not_found
|
|
163
|
+
saml_relay_state_expired
|
|
164
|
+
saml_idp_not_found
|
|
165
|
+
saml_assertion_no_user_id
|
|
166
|
+
saml_assertion_no_email
|
|
167
|
+
user_already_exists
|
|
168
|
+
sso_provider_not_found
|
|
169
|
+
saml_metadata_fetch_failed
|
|
170
|
+
saml_idp_already_exists
|
|
171
|
+
sso_domain_already_exists
|
|
172
|
+
saml_entity_id_mismatch
|
|
173
|
+
conflict
|
|
174
|
+
provider_disabled
|
|
175
|
+
user_sso_managed
|
|
176
|
+
reauthentication_needed
|
|
177
|
+
same_password
|
|
178
|
+
reauthentication_not_valid
|
|
179
|
+
otp_expired
|
|
180
|
+
otp_disabled
|
|
181
|
+
identity_not_found
|
|
182
|
+
weak_password
|
|
183
|
+
over_request_rate_limit
|
|
184
|
+
over_email_send_rate_limit
|
|
185
|
+
over_sms_send_rate_limit
|
|
186
|
+
bad_code_verifier
|
|
187
|
+
anonymous_provider_disabled
|
|
188
|
+
hook_timeout
|
|
189
|
+
hook_timeout_after_retry
|
|
190
|
+
hook_payload_over_size_limit
|
|
191
|
+
hook_payload_invalid_content_type
|
|
192
|
+
request_timeout
|
|
193
|
+
mfa_phone_enroll_not_enabled
|
|
194
|
+
mfa_phone_verify_not_enabled
|
|
195
|
+
mfa_totp_enroll_not_enabled
|
|
196
|
+
mfa_totp_verify_not_enabled
|
|
197
|
+
mfa_webauthn_enroll_not_enabled
|
|
198
|
+
mfa_webauthn_verify_not_enabled
|
|
199
|
+
mfa_verified_factor_exists
|
|
200
|
+
invalid_credentials
|
|
201
|
+
email_address_not_authorized
|
|
202
|
+
email_address_invalid
|
|
203
|
+
].freeze
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "date"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module Supabase
|
|
11
|
+
module Auth
|
|
12
|
+
module Helpers
|
|
13
|
+
API_VERSION_HEADER_NAME = "X-Supabase-Api-Version"
|
|
14
|
+
API_VERSION_REGEX = /\A2[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[0-1])\z/
|
|
15
|
+
BASE64URL_REGEX = /\A([a-z0-9_-]{4})*($|[a-z0-9_-]{3}$|[a-z0-9_-]{2}$)\z/i
|
|
16
|
+
PKCE_CHARSET = (("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a + %w[- . _ ~]).freeze
|
|
17
|
+
API_VERSION_2024_01_01_TIMESTAMP = Time.new(2024, 1, 1).to_f
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def decode_jwt(token)
|
|
22
|
+
parts = token.split(".")
|
|
23
|
+
raise Errors::AuthInvalidJwtError, "Invalid JWT structure" unless parts.length == 3
|
|
24
|
+
|
|
25
|
+
parts.each do |part|
|
|
26
|
+
raise Errors::AuthInvalidJwtError, "JWT not in base64url format" unless part.match?(BASE64URL_REGEX)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
header = JSON.parse(str_from_base64url(parts[0]))
|
|
30
|
+
payload = JSON.parse(str_from_base64url(parts[1]))
|
|
31
|
+
signature = base64url_to_bytes(parts[2])
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
header: header,
|
|
35
|
+
payload: payload,
|
|
36
|
+
signature: signature,
|
|
37
|
+
raw: { "header" => parts[0], "payload" => parts[1] }
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def str_from_base64url(base64url)
|
|
42
|
+
padded = base64url + "=" * (-base64url.length % 4)
|
|
43
|
+
Base64.urlsafe_decode64(padded)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def base64url_to_bytes(base64url)
|
|
47
|
+
padded = base64url + "=" * (-base64url.length % 4)
|
|
48
|
+
Base64.urlsafe_decode64(padded)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def generate_pkce_verifier(length = 64)
|
|
52
|
+
raise ArgumentError, "PKCE verifier length must be between 43 and 128 characters" if length < 43 || length > 128
|
|
53
|
+
|
|
54
|
+
Array.new(length) { PKCE_CHARSET.sample(random: SecureRandom) }.join
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def generate_pkce_challenge(code_verifier)
|
|
58
|
+
digest = Digest::SHA256.digest(code_verifier)
|
|
59
|
+
Base64.urlsafe_encode64(digest, padding: false)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_response_api_version(response)
|
|
63
|
+
headers = response.respond_to?(:headers) ? response.headers : {}
|
|
64
|
+
api_version = headers[API_VERSION_HEADER_NAME]
|
|
65
|
+
return nil if api_version.nil? || api_version.empty?
|
|
66
|
+
return nil unless api_version.match?(API_VERSION_REGEX)
|
|
67
|
+
|
|
68
|
+
Date.strptime(api_version, "%Y-%m-%d").to_time
|
|
69
|
+
rescue ArgumentError, TypeError, Date::Error
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get_error_code(error)
|
|
74
|
+
return nil unless error.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
error["error_code"] || error[:error_code]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def is_http_url(url)
|
|
80
|
+
return false if url.nil? || url.empty?
|
|
81
|
+
|
|
82
|
+
uri = URI.parse(url)
|
|
83
|
+
%w[http https].include?(uri.scheme)
|
|
84
|
+
rescue URI::InvalidURIError
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def is_valid_uuid(value)
|
|
89
|
+
return false unless value.is_a?(String)
|
|
90
|
+
|
|
91
|
+
/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i.match?(value)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_exp(exp)
|
|
95
|
+
raise Errors::AuthInvalidJwtError, "JWT has no expiration time" if exp.nil? || exp == 0
|
|
96
|
+
|
|
97
|
+
raise Errors::AuthInvalidJwtError, "JWT has expired" if exp <= Time.now.to_f
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def handle_exception(exception)
|
|
101
|
+
unless exception.is_a?(Faraday::ClientError) || exception.is_a?(Faraday::ServerError)
|
|
102
|
+
return Errors::AuthRetryableError.new(exception.message, status: 0)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
response = exception.response
|
|
107
|
+
status = response[:status]
|
|
108
|
+
|
|
109
|
+
if [502, 503, 504].include?(status)
|
|
110
|
+
return Errors::AuthRetryableError.new(exception.message, status: status)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
data = JSON.parse(response[:body] || "{}")
|
|
114
|
+
error_code = nil
|
|
115
|
+
response_api_version = nil
|
|
116
|
+
|
|
117
|
+
if response[:headers]
|
|
118
|
+
mock_response = Struct.new(:headers).new(response[:headers])
|
|
119
|
+
response_api_version = parse_response_api_version(mock_response)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if response_api_version &&
|
|
123
|
+
response_api_version.to_f >= API_VERSION_2024_01_01_TIMESTAMP &&
|
|
124
|
+
data.is_a?(Hash) && !data.empty? && data["code"].is_a?(String)
|
|
125
|
+
error_code = data["code"]
|
|
126
|
+
elsif data.is_a?(Hash) && !data.empty? && data["error_code"].is_a?(String)
|
|
127
|
+
error_code = data["error_code"]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if error_code == "weak_password"
|
|
131
|
+
reasons = data.dig("weak_password", "reasons") || []
|
|
132
|
+
return Errors::AuthWeakPassword.new(
|
|
133
|
+
get_error_message(data),
|
|
134
|
+
status: status,
|
|
135
|
+
reasons: reasons
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if error_code.nil? && data.is_a?(Hash) && data["weak_password"].is_a?(Hash) && !data["weak_password"].empty?
|
|
140
|
+
reasons = data["weak_password"]["reasons"] || []
|
|
141
|
+
return Errors::AuthWeakPassword.new(
|
|
142
|
+
get_error_message(data),
|
|
143
|
+
status: status,
|
|
144
|
+
reasons: reasons
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
Errors::AuthApiError.new(
|
|
149
|
+
get_error_message(data),
|
|
150
|
+
status: status || 500,
|
|
151
|
+
code: error_code
|
|
152
|
+
)
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
Errors::AuthUnknownError.new(exception.message, original_error: e)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def get_error_message(error)
|
|
159
|
+
props = %w[msg message error_description error]
|
|
160
|
+
if error.is_a?(Hash)
|
|
161
|
+
props.each { |prop| return error[prop] if error.key?(prop) }
|
|
162
|
+
else
|
|
163
|
+
props.each { |prop| return error.send(prop) if error.respond_to?(prop) }
|
|
164
|
+
end
|
|
165
|
+
error.to_s
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def parse_auth_response(data)
|
|
169
|
+
session = nil
|
|
170
|
+
if data["access_token"] && data["refresh_token"] && data["expires_in"]
|
|
171
|
+
session = Types::Session.from_hash(data)
|
|
172
|
+
end
|
|
173
|
+
user_data = data["user"] || data
|
|
174
|
+
user = user_data ? Types::User.from_hash(user_data) : nil
|
|
175
|
+
Types::AuthResponse.new(session: session, user: user)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def parse_auth_otp_response(data)
|
|
179
|
+
Types::AuthOtpResponse.from_hash(data)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def parse_link_identity_response(data)
|
|
183
|
+
Types::LinkIdentityResponse.from_hash(data)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def parse_link_response(data)
|
|
187
|
+
link_keys = Types::GenerateLinkProperties.members.map(&:to_s)
|
|
188
|
+
props_hash = link_keys.each_with_object({}) { |k, h| h[k.to_sym] = data[k] }
|
|
189
|
+
properties = Types::GenerateLinkProperties.new(**props_hash)
|
|
190
|
+
user_data = data.reject { |k, _| link_keys.include?(k) }
|
|
191
|
+
user = Types::User.from_hash(user_data)
|
|
192
|
+
Types::GenerateLinkResponse.new(properties: properties, user: user)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse_user_response(data)
|
|
196
|
+
data = { "user" => data } unless data.key?("user")
|
|
197
|
+
Types::UserResponse.from_hash(data)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def parse_sso_response(data)
|
|
201
|
+
Types::SSOResponse.from_hash(data)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def parse_jwks(response)
|
|
205
|
+
if !response.key?("keys") || response["keys"].empty?
|
|
206
|
+
raise Errors::AuthInvalidJwtError, "JWKS is empty"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
{ "keys" => response["keys"] }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def parse_error_body(body)
|
|
213
|
+
return {} if body.nil? || body.empty?
|
|
214
|
+
|
|
215
|
+
JSON.parse(body)
|
|
216
|
+
rescue JSON::ParserError
|
|
217
|
+
{}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private_class_method :str_from_base64url, :base64url_to_bytes, :get_error_message, :parse_error_body
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Auth
|
|
5
|
+
class MemoryStorage < SupportedStorage
|
|
6
|
+
attr_reader :storage
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@storage = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def get_item(key)
|
|
13
|
+
@storage[key]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def set_item(key, value)
|
|
17
|
+
@storage[key] = value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def remove_item(key)
|
|
21
|
+
@storage.delete(key)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Auth
|
|
5
|
+
class SupportedStorage
|
|
6
|
+
def get_item(_key)
|
|
7
|
+
raise NotImplementedError, "#{self.class}#get_item must be implemented"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def set_item(_key, _value)
|
|
11
|
+
raise NotImplementedError, "#{self.class}#set_item must be implemented"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def remove_item(_key)
|
|
15
|
+
raise NotImplementedError, "#{self.class}#remove_item must be implemented"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Auth
|
|
5
|
+
class Timer
|
|
6
|
+
# @param interval [Float] delay in seconds before firing the callback
|
|
7
|
+
# @param block [Proc] the callback to execute after the delay
|
|
8
|
+
def initialize(interval, &block)
|
|
9
|
+
@interval = interval
|
|
10
|
+
@block = block
|
|
11
|
+
@thread = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def start
|
|
15
|
+
@thread = Thread.new do
|
|
16
|
+
sleep @interval
|
|
17
|
+
@block.call
|
|
18
|
+
rescue StandardError
|
|
19
|
+
# Swallow errors in timer thread (matches Python daemon thread behavior)
|
|
20
|
+
end
|
|
21
|
+
@thread
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cancel
|
|
25
|
+
if @thread
|
|
26
|
+
# Don't kill the current thread (e.g. when the callback triggers
|
|
27
|
+
# a new timer via _save_session → _start_auto_refresh_token).
|
|
28
|
+
# Python's threading.Timer.cancel() only prevents future execution;
|
|
29
|
+
# it never terminates an already-running callback.
|
|
30
|
+
@thread.kill unless @thread == Thread.current
|
|
31
|
+
@thread = nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def alive?
|
|
36
|
+
@thread&.alive? || false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|