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,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