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 +7 -0
- data/lib/supabase/rails/context.rb +125 -0
- data/lib/supabase/rails/controller.rb +47 -0
- data/lib/supabase/rails/core.rb +275 -0
- data/lib/supabase/rails/cors.rb +48 -0
- data/lib/supabase/rails/env.rb +105 -0
- data/lib/supabase/rails/errors.rb +76 -0
- data/lib/supabase/rails/jwt.rb +137 -0
- data/lib/supabase/rails/logging.rb +38 -0
- data/lib/supabase/rails/middleware.rb +56 -0
- data/lib/supabase/rails/railtie.rb +27 -0
- data/lib/supabase/rails/version.rb +7 -0
- data/lib/supabase/rails.rb +20 -0
- metadata +100 -0
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,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: []
|