supabase-rails 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ecccb50074bb2287f87d4da6d42dd824d0857351c778c91ac6a13e4ad09d7258
4
+ data.tar.gz: 8e2e06f0425dcdedb6fdcedc19db9f44d731d09eb90709b136716dd819217e2e
5
+ SHA512:
6
+ metadata.gz: b08cac9795f94d01611d95f8b3115f6978b151054b0879ae865e5bf888de93bf21b8f15b3a811d0b985bea95e398a7d4788c0439bbce8f1b8d53b36e8f292ce2
7
+ data.tar.gz: 93b55c0c766f4f7a145769861b8818e182f93b5856f568f2c6f00f143ffc6b036de191fc7cb6a7e24827b7a83d76e86069f0c00131ab7cc9c438976cc9dd865f
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core"
4
+ require_relative "env"
5
+ require_relative "errors"
6
+ require_relative "logging"
7
+
8
+ module Supabase
9
+ module Rails
10
+ SupabaseContext = Data.define(
11
+ :supabase, :supabase_admin,
12
+ :user_claims, :jwt_claims,
13
+ :auth_mode, :auth_key_name
14
+ )
15
+
16
+ class Result
17
+ attr_reader :value, :error
18
+
19
+ def initialize(value:, error:)
20
+ @value = value
21
+ @error = error
22
+ end
23
+
24
+ def success?
25
+ @error.nil?
26
+ end
27
+
28
+ def failure?
29
+ !success?
30
+ end
31
+
32
+ def self.success(value)
33
+ new(value: value, error: nil)
34
+ end
35
+
36
+ def self.failure(error)
37
+ new(value: nil, error: error)
38
+ end
39
+ end
40
+
41
+ def self.create_context(request, auth: :user, env: nil, supabase_options: nil)
42
+ headers = extract_headers(request)
43
+ credentials = Core.extract_credentials(headers)
44
+
45
+ auth_result =
46
+ begin
47
+ Core.verify_credentials(credentials, auth: auth, env: env)
48
+ rescue AuthError => e
49
+ Logging.log(:warn, "[#{e.code}] #{e.message}")
50
+ return Result.failure(e)
51
+ end
52
+
53
+ build_context_result(auth_result, env: env, supabase_options: supabase_options)
54
+ end
55
+
56
+ def self.build_context_result(auth_result, env:, supabase_options:)
57
+ publishable_key_name = auth_result.auth_mode == :publishable ? auth_result.key_name : nil
58
+ supabase = Core.create_context_client(
59
+ auth: { token: auth_result.token, key_name: publishable_key_name },
60
+ env: env,
61
+ supabase_options: supabase_options
62
+ )
63
+
64
+ admin_key_name = auth_result.auth_mode == :secret ? auth_result.key_name : nil
65
+ supabase_admin = Core.create_admin_client(
66
+ auth: { key_name: admin_key_name },
67
+ env: env,
68
+ supabase_options: supabase_options
69
+ )
70
+
71
+ Result.success(
72
+ SupabaseContext.new(
73
+ supabase: supabase,
74
+ supabase_admin: supabase_admin,
75
+ user_claims: auth_result.user_claims,
76
+ jwt_claims: auth_result.jwt_claims,
77
+ auth_mode: auth_result.auth_mode,
78
+ auth_key_name: auth_result.key_name
79
+ )
80
+ )
81
+ rescue EnvError => e
82
+ wrapped = AuthError.new(e.message, e.code, 500)
83
+ Logging.log(:error, "[#{wrapped.code}] #{wrapped.message}")
84
+ Result.failure(wrapped)
85
+ rescue ::Supabase::SupabaseException, ArgumentError => e
86
+ wrapped = AuthError.create_supabase_client_error
87
+ Logging.log(:error, "[#{wrapped.code}] #{e.class}: #{e.message}")
88
+ Result.failure(wrapped)
89
+ end
90
+
91
+ def self.extract_headers(request)
92
+ return {} if request.nil?
93
+ return request if request.is_a?(Hash)
94
+
95
+ if request.respond_to?(:headers)
96
+ headers = request.headers
97
+ return {
98
+ "Authorization" => header_value(headers, "Authorization"),
99
+ "apikey" => header_value(headers, "apikey")
100
+ }
101
+ end
102
+
103
+ if request.respond_to?(:env)
104
+ env = request.env
105
+ return {
106
+ "Authorization" => env["HTTP_AUTHORIZATION"],
107
+ "apikey" => env["HTTP_APIKEY"]
108
+ }
109
+ end
110
+
111
+ {}
112
+ end
113
+
114
+ def self.header_value(headers, name)
115
+ return nil if headers.nil?
116
+ return headers[name] if headers.respond_to?(:[])
117
+
118
+ nil
119
+ end
120
+
121
+ class << self
122
+ private :build_context_result, :extract_headers, :header_value
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../rails"
5
+
6
+ module Supabase
7
+ module Rails
8
+ module Controller
9
+ def self.included(base)
10
+ base.helper_method(:supabase_context) if base.respond_to?(:helper_method)
11
+ base.rescue_from(AuthError, with: :render_supabase_auth_error) if base.respond_to?(:rescue_from)
12
+ end
13
+
14
+ def supabase_context
15
+ request.env[Rails::CONTEXT_KEY]
16
+ end
17
+
18
+ def verify_supabase_auth(auth: nil, env: nil, supabase_options: nil)
19
+ if auth.nil? && env.nil? && supabase_options.nil?
20
+ raise AuthError.invalid_credentials if supabase_context.nil?
21
+
22
+ return supabase_context
23
+ end
24
+
25
+ result = Rails.create_context(
26
+ request,
27
+ auth: auth || :user,
28
+ env: env,
29
+ supabase_options: supabase_options
30
+ )
31
+
32
+ raise result.error if result.failure?
33
+
34
+ request.env[Rails::CONTEXT_KEY] = result.value
35
+ end
36
+
37
+ private
38
+
39
+ def render_supabase_auth_error(error)
40
+ render(
41
+ json: { message: error.message, code: error.code },
42
+ status: error.status
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "supabase"
5
+
6
+ require_relative "env"
7
+ require_relative "errors"
8
+ require_relative "jwt"
9
+
10
+ module Supabase
11
+ module Rails
12
+ Credentials = Struct.new(:token, :apikey, keyword_init: true)
13
+
14
+ AuthResult = Struct.new(
15
+ :auth_mode, :token, :user_claims, :jwt_claims, :key_name,
16
+ keyword_init: true
17
+ )
18
+
19
+ UserClaims = Struct.new(
20
+ :id, :role, :email, :app_metadata, :user_metadata,
21
+ keyword_init: true
22
+ )
23
+
24
+ module Core
25
+ module_function
26
+
27
+ def extract_credentials(headers)
28
+ Credentials.new(
29
+ token: extract_bearer_token(lookup_header(headers, "authorization")),
30
+ apikey: stringify(lookup_header(headers, "apikey"))
31
+ )
32
+ end
33
+
34
+ def verify_credentials(credentials, auth: :user, env: nil)
35
+ resolved_env = env.is_a?(SupabaseEnv) ? env : Env.resolve(env || {})
36
+
37
+ modes = Array(auth)
38
+ modes = [:user] if modes.empty?
39
+
40
+ modes.each do |mode|
41
+ result = try_mode(mode, credentials, resolved_env)
42
+ return result if result
43
+ end
44
+
45
+ raise AuthError.invalid_credentials
46
+ end
47
+
48
+ def create_context_client(auth: nil, env: nil, supabase_options: nil)
49
+ resolved_env = env.is_a?(SupabaseEnv) ? env : Env.resolve(env || {})
50
+ token, key_name = extract_auth_fields(auth)
51
+
52
+ anon_key = resolve_publishable_key(resolved_env.publishable_keys, key_name)
53
+
54
+ ::Supabase::Client.new(
55
+ supabase_url: resolved_env.url,
56
+ supabase_key: anon_key,
57
+ options: build_client_options(supabase_options || {}, token)
58
+ )
59
+ end
60
+
61
+ def create_admin_client(auth: nil, env: nil, supabase_options: nil)
62
+ resolved_env = env.is_a?(SupabaseEnv) ? env : Env.resolve(env || {})
63
+ _token, key_name = extract_auth_fields(auth)
64
+
65
+ secret_key = resolve_secret_key(resolved_env.secret_keys, key_name)
66
+
67
+ ::Supabase::Client.new(
68
+ supabase_url: resolved_env.url,
69
+ supabase_key: secret_key,
70
+ options: build_client_options(supabase_options || {}, nil)
71
+ )
72
+ end
73
+
74
+ def extract_auth_fields(auth)
75
+ return [nil, nil] if auth.nil?
76
+
77
+ if auth.respond_to?(:token) && auth.respond_to?(:key_name)
78
+ return [auth.token, auth.key_name]
79
+ end
80
+
81
+ token = auth[:token]
82
+ token = auth["token"] if token.nil?
83
+
84
+ key_name =
85
+ if auth.respond_to?(:key?) && auth.key?(:key_name)
86
+ auth[:key_name]
87
+ elsif auth.respond_to?(:key?) && auth.key?("key_name")
88
+ auth["key_name"]
89
+ end
90
+
91
+ [token, key_name]
92
+ end
93
+
94
+ def resolve_publishable_key(keys, key_name)
95
+ name = key_name || "default"
96
+ anon_key = keys[name]
97
+
98
+ if anon_key.nil? || anon_key.to_s.empty?
99
+ raise(
100
+ name == "default" ? EnvError.missing_default_publishable_key : EnvError.missing_publishable_key(name)
101
+ )
102
+ end
103
+
104
+ anon_key
105
+ end
106
+
107
+ def resolve_secret_key(keys, key_name)
108
+ name = key_name || "default"
109
+ secret_key = keys[name]
110
+
111
+ if secret_key.nil? || secret_key.to_s.empty?
112
+ raise(
113
+ name == "default" ? EnvError.missing_default_secret_key : EnvError.missing_secret_key(name)
114
+ )
115
+ end
116
+
117
+ secret_key
118
+ end
119
+
120
+ def build_client_options(supabase_options, token)
121
+ global = option_value(supabase_options, :global) || {}
122
+ raw_headers = option_value(global, :headers) || {}
123
+
124
+ safe_headers = raw_headers.reject do |k, _|
125
+ key = k.to_s.downcase
126
+ key == "authorization" || key == "apikey"
127
+ end
128
+ safe_headers = safe_headers.merge("Authorization" => "Bearer #{token}") if token && !token.to_s.empty?
129
+
130
+ auth_opts = option_value(supabase_options, :auth) || {}
131
+ auth_opts = auth_opts.merge(
132
+ persist_session: false,
133
+ auto_refresh_token: false,
134
+ detect_session_in_url: false
135
+ )
136
+
137
+ new_global = global.merge(headers: safe_headers)
138
+
139
+ supabase_options.merge(global: new_global, auth: auth_opts)
140
+ end
141
+
142
+ def option_value(hash, key)
143
+ return nil unless hash.is_a?(Hash)
144
+
145
+ hash.key?(key) ? hash[key] : hash[key.to_s]
146
+ end
147
+
148
+ def lookup_header(headers, name)
149
+ return nil if headers.nil?
150
+
151
+ target = name.downcase
152
+
153
+ if headers.respond_to?(:each_pair)
154
+ headers.each_pair do |key, value|
155
+ return value if key.to_s.downcase == target
156
+ end
157
+ elsif headers.respond_to?(:each)
158
+ headers.each do |key, value|
159
+ return value if key.to_s.downcase == target
160
+ end
161
+ end
162
+
163
+ nil
164
+ end
165
+
166
+ def extract_bearer_token(authorization)
167
+ return nil if authorization.nil?
168
+
169
+ str = authorization.to_s
170
+ return nil if str.length < 7
171
+ return nil unless str[0, 6].casecmp("Bearer").zero?
172
+ return nil unless str[6] == " "
173
+
174
+ token = str[7..].to_s.strip
175
+ token.empty? ? nil : token
176
+ end
177
+
178
+ def stringify(value)
179
+ return nil if value.nil?
180
+
181
+ str = value.to_s
182
+ str.empty? ? nil : str
183
+ end
184
+
185
+ def parse_auth_mode(mode)
186
+ str = mode.to_s
187
+ colon = str.index(":")
188
+ return [str.to_sym, nil] if colon.nil?
189
+
190
+ base = str[0, colon].to_sym
191
+ key_name = str[(colon + 1)..]
192
+ key_name = nil if key_name.nil? || key_name.empty?
193
+ [base, key_name]
194
+ end
195
+
196
+ def try_mode(mode, credentials, env)
197
+ base, key_name = parse_auth_mode(mode)
198
+
199
+ case base
200
+ when :none
201
+ AuthResult.new(
202
+ auth_mode: :none, token: nil,
203
+ user_claims: nil, jwt_claims: nil, key_name: nil
204
+ )
205
+ when :publishable
206
+ try_apikey_mode(:publishable, env.publishable_keys, credentials.apikey, key_name)
207
+ when :secret
208
+ try_apikey_mode(:secret, env.secret_keys, credentials.apikey, key_name)
209
+ when :user
210
+ try_user_mode(credentials, env)
211
+ end
212
+ end
213
+
214
+ def try_apikey_mode(mode_sym, keys, apikey, key_name)
215
+ return nil if apikey.nil? || apikey.to_s.empty?
216
+
217
+ if key_name == "*"
218
+ keys.each do |name, value|
219
+ next if value.nil? || value.empty?
220
+ return build_apikey_result(mode_sym, name) if secure_compare(apikey, value)
221
+ end
222
+ return nil
223
+ end
224
+
225
+ name = key_name || "default"
226
+ value = keys[name]
227
+ return nil if value.nil? || value.empty?
228
+
229
+ return build_apikey_result(mode_sym, name) if secure_compare(apikey, value)
230
+
231
+ nil
232
+ end
233
+
234
+ def build_apikey_result(mode_sym, name)
235
+ AuthResult.new(
236
+ auth_mode: mode_sym, token: nil,
237
+ user_claims: nil, jwt_claims: nil, key_name: name
238
+ )
239
+ end
240
+
241
+ def try_user_mode(credentials, env)
242
+ token = credentials.token
243
+ return nil if token.nil? || token.to_s.empty?
244
+ # `sb_*` is Supabase's secret-key format, not a JWT — skip so it's matched by :secret mode instead.
245
+ return nil if token.start_with?("sb_")
246
+
247
+ claims = JWT.verify(token, env: env)
248
+
249
+ AuthResult.new(
250
+ auth_mode: :user,
251
+ token: token,
252
+ user_claims: claims[:user_claims],
253
+ jwt_claims: claims[:jwt_claims],
254
+ key_name: nil
255
+ )
256
+ end
257
+
258
+ def secure_compare(a, b)
259
+ a_str = a.to_s
260
+ b_str = b.to_s
261
+ # Length pre-check is required: fixed_length_secure_compare raises on mismatch.
262
+ return false if a_str.bytesize != b_str.bytesize
263
+
264
+ OpenSSL.fixed_length_secure_compare(a_str, b_str)
265
+ end
266
+
267
+ private_class_method :extract_auth_fields, :resolve_publishable_key, :resolve_secret_key,
268
+ :build_client_options, :option_value, :lookup_header,
269
+ :extract_bearer_token, :stringify, :parse_auth_mode,
270
+ :try_mode, :try_apikey_mode, :build_apikey_result,
271
+ :try_user_mode
272
+ # `secure_compare` stays public: it's exercised directly by security_spec as a tested invariant.
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Rails
5
+ module CORS
6
+ SUPABASE_HEADERS = %w[
7
+ authorization
8
+ x-client-info
9
+ apikey
10
+ content-type
11
+ x-retry-count
12
+ ].join(", ").freeze
13
+
14
+ SUPABASE_METHODS = %w[
15
+ GET
16
+ POST
17
+ PUT
18
+ PATCH
19
+ DELETE
20
+ OPTIONS
21
+ ].join(", ").freeze
22
+
23
+ DEFAULT_HEADERS = {
24
+ "Access-Control-Allow-Origin" => "*",
25
+ "Access-Control-Allow-Headers" => SUPABASE_HEADERS,
26
+ "Access-Control-Allow-Methods" => SUPABASE_METHODS
27
+ }.freeze
28
+
29
+ class << self
30
+ def build_headers(config = nil)
31
+ return {} if config == false
32
+ return config if config.is_a?(Hash)
33
+
34
+ DEFAULT_HEADERS
35
+ end
36
+
37
+ def add_headers(headers, config = nil)
38
+ return headers if config == false
39
+
40
+ cors = build_headers(config)
41
+ merged = headers.dup
42
+ cors.each { |key, value| merged[key] = value }
43
+ merged
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ require_relative "errors"
7
+
8
+ module Supabase
9
+ module Rails
10
+ SupabaseEnv = Data.define(:url, :publishable_keys, :secret_keys, :jwks)
11
+
12
+ module Env
13
+ module_function
14
+
15
+ def resolve(overrides = {})
16
+ overrides = symbolize_overrides(overrides)
17
+
18
+ url = overrides.fetch(:url) { ENV["SUPABASE_URL"] }
19
+ raise EnvError.missing_supabase_url if url.nil? || url.to_s.empty?
20
+
21
+ SupabaseEnv.new(
22
+ url: url,
23
+ publishable_keys: overrides[:publishable_keys] || resolve_keys("SUPABASE_PUBLISHABLE_KEY", "SUPABASE_PUBLISHABLE_KEYS"),
24
+ secret_keys: overrides[:secret_keys] || resolve_keys("SUPABASE_SECRET_KEY", "SUPABASE_SECRET_KEYS"),
25
+ jwks: overrides.key?(:jwks) ? overrides[:jwks] : resolve_jwks
26
+ )
27
+ end
28
+
29
+ def symbolize_overrides(overrides)
30
+ return {} if overrides.nil?
31
+ overrides.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
32
+ end
33
+
34
+ def resolve_keys(singular_var, plural_var)
35
+ plural = ENV[plural_var]
36
+ return parse_keys(plural) if plural && !plural.empty?
37
+
38
+ singular = ENV[singular_var]
39
+ return { "default" => singular } if singular && !singular.empty?
40
+
41
+ {}
42
+ end
43
+
44
+ def parse_keys(raw)
45
+ return {} if raw.nil? || raw.empty?
46
+
47
+ parsed = JSON.parse(raw)
48
+ return {} unless parsed.is_a?(Hash)
49
+
50
+ parsed
51
+ rescue JSON::ParserError
52
+ {}
53
+ end
54
+
55
+ def resolve_jwks
56
+ raw_jwks = ENV["SUPABASE_JWKS"]
57
+ return parse_jwks(raw_jwks) if raw_jwks && !raw_jwks.strip.empty?
58
+
59
+ raw_jwks_url = ENV["SUPABASE_JWKS_URL"]
60
+ return parse_jwks_url(raw_jwks_url) if raw_jwks_url && !raw_jwks_url.strip.empty?
61
+
62
+ nil
63
+ end
64
+
65
+ def parse_jwks(raw)
66
+ return nil if raw.nil? || raw.empty?
67
+
68
+ parsed = JSON.parse(raw)
69
+ return { "keys" => parsed } if parsed.is_a?(Array)
70
+ return parsed if parsed.is_a?(Hash) && parsed["keys"].is_a?(Array)
71
+
72
+ nil
73
+ rescue JSON::ParserError
74
+ nil
75
+ end
76
+
77
+ def parse_jwks_url(raw)
78
+ return nil if raw.nil?
79
+
80
+ trimmed = raw.strip
81
+ return nil if trimmed.empty?
82
+
83
+ uri = URI.parse(trimmed)
84
+ return nil if uri.host.nil? || uri.host.empty?
85
+
86
+ return uri if uri.scheme == "https"
87
+ return uri if uri.scheme == "http" && loopback_host?(uri.host)
88
+
89
+ nil
90
+ rescue URI::InvalidURIError
91
+ nil
92
+ end
93
+
94
+ def loopback_host?(hostname)
95
+ return false if hostname.nil?
96
+ return true if hostname == "localhost"
97
+ return true if hostname.end_with?(".localhost")
98
+ return true if hostname == "[::1]" || hostname == "::1"
99
+ return true if /\A127\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/.match?(hostname)
100
+
101
+ false
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Rails
5
+ class EnvError < StandardError
6
+ ENV_GENERIC_ERROR = "ENV_ERROR"
7
+ MISSING_SUPABASE_URL = "MISSING_SUPABASE_URL"
8
+ MISSING_PUBLISHABLE_KEY = "MISSING_PUBLISHABLE_KEY"
9
+ MISSING_DEFAULT_PUBLISHABLE_KEY = "MISSING_DEFAULT_PUBLISHABLE_KEY"
10
+ MISSING_SECRET_KEY = "MISSING_SECRET_KEY"
11
+ MISSING_DEFAULT_SECRET_KEY = "MISSING_DEFAULT_SECRET_KEY"
12
+
13
+ attr_reader :code, :status
14
+
15
+ def initialize(message, code = ENV_GENERIC_ERROR)
16
+ super(message)
17
+ @code = code
18
+ @status = 500
19
+ end
20
+
21
+ def self.missing_supabase_url
22
+ new("SUPABASE_URL is required but not set", MISSING_SUPABASE_URL)
23
+ end
24
+
25
+ def self.missing_publishable_key(name)
26
+ new(
27
+ %(No "#{name}" publishable key found. Include a "#{name}" entry in SUPABASE_PUBLISHABLE_KEYS.),
28
+ MISSING_PUBLISHABLE_KEY
29
+ )
30
+ end
31
+
32
+ def self.missing_default_publishable_key
33
+ new(
34
+ 'No default publishable key found. Set SUPABASE_PUBLISHABLE_KEY or include a "default" entry in SUPABASE_PUBLISHABLE_KEYS.',
35
+ MISSING_DEFAULT_PUBLISHABLE_KEY
36
+ )
37
+ end
38
+
39
+ def self.missing_secret_key(name)
40
+ new(
41
+ %(No "#{name}" secret key found. Include a "#{name}" entry in SUPABASE_SECRET_KEYS.),
42
+ MISSING_SECRET_KEY
43
+ )
44
+ end
45
+
46
+ def self.missing_default_secret_key
47
+ new(
48
+ 'No default secret key found. Set SUPABASE_SECRET_KEY or include a "default" entry in SUPABASE_SECRET_KEYS.',
49
+ MISSING_DEFAULT_SECRET_KEY
50
+ )
51
+ end
52
+ end
53
+
54
+ class AuthError < StandardError
55
+ AUTH_GENERIC_ERROR = "AUTH_ERROR"
56
+ INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
57
+ CREATE_SUPABASE_CLIENT_ERROR = "CREATE_SUPABASE_CLIENT_ERROR"
58
+
59
+ attr_reader :code, :status
60
+
61
+ def initialize(message, code = AUTH_GENERIC_ERROR, status = 401)
62
+ super(message)
63
+ @code = code
64
+ @status = status
65
+ end
66
+
67
+ def self.invalid_credentials
68
+ new("Invalid credentials", INVALID_CREDENTIALS, 401)
69
+ end
70
+
71
+ def self.create_supabase_client_error
72
+ new("Failed to create Supabase client", CREATE_SUPABASE_CLIENT_ERROR, 500)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "jwt"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ require_relative "env"
9
+ require_relative "errors"
10
+
11
+ module Supabase
12
+ module Rails
13
+ module JWT
14
+ ALGORITHMS = %w[RS256 ES256 HS256].freeze
15
+ LEEWAY_SECONDS = 30
16
+ CACHE_TTL_SECONDS = 600
17
+ MISS_COOLDOWN_SECONDS = 30
18
+
19
+ @cache_mutex = Mutex.new
20
+ @cache = {}
21
+
22
+ class << self
23
+ def verify(token, env:)
24
+ raise AuthError.invalid_credentials if token.nil? || token.to_s.empty?
25
+
26
+ jwks_source = env.jwks
27
+ if jwks_source.nil?
28
+ raise AuthError.new(
29
+ "JWKS not configured for user auth mode",
30
+ AuthError::AUTH_GENERIC_ERROR,
31
+ 500
32
+ )
33
+ end
34
+
35
+ jwks = resolve_jwks(jwks_source)
36
+ payload = decode(token, jwks)
37
+
38
+ unless payload.is_a?(Hash) && payload["sub"].is_a?(String)
39
+ raise AuthError.invalid_credentials
40
+ end
41
+
42
+ { user_claims: build_user_claims(payload), jwt_claims: payload }
43
+ rescue AuthError
44
+ raise
45
+ rescue StandardError
46
+ raise AuthError.invalid_credentials
47
+ end
48
+
49
+ def _reset_cache!
50
+ @cache_mutex.synchronize { @cache.clear }
51
+ end
52
+
53
+ private
54
+
55
+ def decode(token, jwks)
56
+ payload, _header = ::JWT.decode(
57
+ token, nil, true,
58
+ algorithms: ALGORITHMS,
59
+ jwks: jwks,
60
+ leeway: LEEWAY_SECONDS,
61
+ allow_nil_kid: true
62
+ )
63
+ payload
64
+ end
65
+
66
+ def build_user_claims(jwt_claims)
67
+ UserClaims.new(
68
+ id: jwt_claims["sub"],
69
+ role: jwt_claims["role"],
70
+ email: jwt_claims["email"],
71
+ app_metadata: jwt_claims["app_metadata"],
72
+ user_metadata: jwt_claims["user_metadata"]
73
+ )
74
+ end
75
+
76
+ def resolve_jwks(source)
77
+ return source if source.is_a?(Hash)
78
+
79
+ if source.is_a?(URI::HTTPS)
80
+ return fetch_with_cache(source)
81
+ end
82
+
83
+ if source.is_a?(URI::HTTP) && Env.loopback_host?(source.host)
84
+ return fetch_with_cache(source)
85
+ end
86
+
87
+ raise AuthError.invalid_credentials
88
+ end
89
+
90
+ def fetch_with_cache(url)
91
+ url_str = url.to_s
92
+
93
+ @cache_mutex.synchronize do
94
+ entry = @cache[url_str]
95
+ now = current_time
96
+ return entry[:value] if entry && entry[:value] && !ttl_expired?(entry[:fetched_at], now)
97
+ if entry && entry[:last_miss_at] && !cooldown_expired?(entry[:last_miss_at], now)
98
+ raise AuthError.invalid_credentials
99
+ end
100
+
101
+ begin
102
+ fetched = fetch_remote(url)
103
+ @cache[url_str] = { value: fetched, fetched_at: current_time }
104
+ fetched
105
+ rescue StandardError => e
106
+ slot = (@cache[url_str] ||= {})
107
+ slot[:last_miss_at] = current_time
108
+ raise e
109
+ end
110
+ end
111
+ end
112
+
113
+ def fetch_remote(url)
114
+ response = Net::HTTP.get_response(url)
115
+ raise AuthError.invalid_credentials unless response.is_a?(Net::HTTPSuccess)
116
+
117
+ parsed = JSON.parse(response.body)
118
+ raise AuthError.invalid_credentials unless parsed.is_a?(Hash) && parsed["keys"].is_a?(Array)
119
+
120
+ parsed
121
+ end
122
+
123
+ def ttl_expired?(fetched_at, now)
124
+ (now - fetched_at) >= CACHE_TTL_SECONDS
125
+ end
126
+
127
+ def cooldown_expired?(last_miss_at, now)
128
+ (now - last_miss_at) >= MISS_COOLDOWN_SECONDS
129
+ end
130
+
131
+ def current_time
132
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Rails
5
+ module Logging
6
+ @mutex = Mutex.new
7
+ @logger = nil
8
+
9
+ class << self
10
+ def logger
11
+ @mutex.synchronize { @logger }
12
+ end
13
+
14
+ def logger=(value)
15
+ @mutex.synchronize { @logger = value }
16
+ end
17
+
18
+ def log(level, message)
19
+ current = logger
20
+ return if current.nil?
21
+ return unless current.respond_to?(level)
22
+
23
+ current.public_send(level, message)
24
+ rescue StandardError
25
+ nil
26
+ end
27
+ end
28
+ end
29
+
30
+ def self.logger
31
+ Logging.logger
32
+ end
33
+
34
+ def self.logger=(value)
35
+ Logging.logger = value
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../rails"
5
+
6
+ module Supabase
7
+ module Rails
8
+ class Middleware
9
+ def initialize(app, auth: :user, env: nil, supabase_options: nil, cors: nil)
10
+ @app = app
11
+ @auth = auth
12
+ @env_overrides = env
13
+ @supabase_options = supabase_options
14
+ @cors = cors
15
+ end
16
+
17
+ def call(env)
18
+ if cors_enabled? && env["REQUEST_METHOD"] == "OPTIONS"
19
+ return [204, CORS.add_headers({}, @cors), []]
20
+ end
21
+
22
+ return @app.call(env) if env[Rails::CONTEXT_KEY]
23
+
24
+ result = Rails.create_context(
25
+ RackRequest.new(env),
26
+ auth: @auth,
27
+ env: @env_overrides,
28
+ supabase_options: @supabase_options
29
+ )
30
+
31
+ return error_response(result.error) if result.failure?
32
+
33
+ env[Rails::CONTEXT_KEY] = result.value
34
+ status, headers, body = @app.call(env)
35
+ headers = CORS.add_headers(headers, @cors) if cors_enabled?
36
+ [status, headers, body]
37
+ end
38
+
39
+ private
40
+
41
+ def cors_enabled?
42
+ @cors != false
43
+ end
44
+
45
+ def error_response(error)
46
+ body = JSON.generate(message: error.message, code: error.code)
47
+ headers = { "Content-Type" => "application/json" }
48
+ headers = CORS.add_headers(headers, @cors) if cors_enabled?
49
+ [error.status, headers, [body]]
50
+ end
51
+
52
+ RackRequest = Struct.new(:env)
53
+ private_constant :RackRequest
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Supabase
6
+ module Rails
7
+ class Railtie < ::Rails::Railtie
8
+ config.supabase = ActiveSupport::OrderedOptions.new
9
+ config.supabase.auth = :user
10
+ config.supabase.cors = nil
11
+ config.supabase.env = nil
12
+ config.supabase.supabase_options = nil
13
+ config.supabase.insert_middleware = true
14
+
15
+ initializer "supabase.middleware" do |app|
16
+ cfg = app.config.supabase
17
+ next unless cfg.insert_middleware
18
+
19
+ app.middleware.use Supabase::Rails::Middleware,
20
+ auth: cfg.auth,
21
+ env: cfg.env,
22
+ supabase_options: cfg.supabase_options,
23
+ cors: cfg.cors
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails/version"
4
+ require_relative "rails/errors"
5
+ require_relative "rails/logging"
6
+ require_relative "rails/env"
7
+ require_relative "rails/jwt"
8
+ require_relative "rails/core"
9
+ require_relative "rails/context"
10
+ require_relative "rails/cors"
11
+
12
+ module Supabase
13
+ module Rails
14
+ CONTEXT_KEY = "supabase.context"
15
+ end
16
+ end
17
+
18
+ require_relative "rails/middleware"
19
+ require_relative "rails/controller"
20
+ require_relative "rails/railtie" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: supabase-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Supabase
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jwt
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.13'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.13'
54
+ description: Rack middleware and controller concern that resolve a per-request Supabase
55
+ context — JWT verification, API-key validation, and RLS-scoped clients — for Rails
56
+ apps.
57
+ email:
58
+ - support@supabase.io
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - lib/supabase/rails.rb
64
+ - lib/supabase/rails/context.rb
65
+ - lib/supabase/rails/controller.rb
66
+ - lib/supabase/rails/core.rb
67
+ - lib/supabase/rails/cors.rb
68
+ - lib/supabase/rails/env.rb
69
+ - lib/supabase/rails/errors.rb
70
+ - lib/supabase/rails/jwt.rb
71
+ - lib/supabase/rails/logging.rb
72
+ - lib/supabase/rails/middleware.rb
73
+ - lib/supabase/rails/railtie.rb
74
+ - lib/supabase/rails/version.rb
75
+ homepage: https://github.com/supabase-ruby/supabase-rails
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ homepage_uri: https://github.com/supabase-ruby/supabase-rails
80
+ source_code_uri: https://github.com/supabase-ruby/supabase-rails
81
+ changelog_uri: https://github.com/supabase-ruby/supabase-rails/blob/main/CHANGELOG.md
82
+ rubygems_mfa_required: 'true'
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.2.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.6.9
98
+ specification_version: 4
99
+ summary: Supabase integration for Ruby on Rails
100
+ test_files: []