supabase-rails 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/supabase/rails/base_controller.rb +17 -0
- data/app/controllers/supabase/rails/oauth_controller.rb +53 -0
- data/app/controllers/supabase/rails/otp_controller.rb +55 -0
- data/app/controllers/supabase/rails/passwords_controller.rb +47 -0
- data/app/controllers/supabase/rails/registrations_controller.rb +46 -0
- data/app/controllers/supabase/rails/sessions_controller.rb +32 -0
- data/app/views/supabase/rails/oauth/_buttons.html.erb +8 -0
- data/app/views/supabase/rails/otp/new.html.erb +11 -0
- data/app/views/supabase/rails/otp/verify.html.erb +14 -0
- data/app/views/supabase/rails/passwords/edit.html.erb +9 -0
- data/app/views/supabase/rails/passwords/new.html.erb +11 -0
- data/app/views/supabase/rails/registrations/new.html.erb +12 -0
- data/app/views/supabase/rails/sessions/new.html.erb +15 -0
- data/app/views/supabase/rails/shared/_flash.html.erb +5 -0
- data/config/locales/en.yml +19 -0
- data/lib/generators/supabase/install/install_generator.rb +84 -0
- data/lib/generators/supabase/install/templates/app/controllers/concerns/authentication.rb.tt +9 -0
- data/lib/generators/supabase/install/templates/app/controllers/oauth_controller.rb.tt +4 -0
- data/lib/generators/supabase/install/templates/app/controllers/otp_controller.rb.tt +4 -0
- data/lib/generators/supabase/install/templates/app/controllers/passwords_controller.rb.tt +4 -0
- data/lib/generators/supabase/install/templates/app/controllers/registrations_controller.rb.tt +4 -0
- data/lib/generators/supabase/install/templates/app/controllers/sessions_controller.rb.tt +4 -0
- data/lib/generators/supabase/install/templates/app/models/current.rb.tt +5 -0
- data/lib/generators/supabase/install/templates/config/initializers/supabase.rb.tt +21 -0
- data/lib/generators/supabase/user_model/templates/app/models/user.rb.tt +11 -0
- data/lib/generators/supabase/user_model/templates/db/migrate/create_supabase_users.rb.tt +11 -0
- data/lib/generators/supabase/user_model/user_model_generator.rb +64 -0
- data/lib/generators/supabase/views/views_generator.rb +33 -0
- data/lib/supabase/rails/authentication.rb +744 -0
- data/lib/supabase/rails/context.rb +22 -5
- data/lib/supabase/rails/controller.rb +24 -2
- data/lib/supabase/rails/core.rb +9 -0
- data/lib/supabase/rails/engine.rb +35 -0
- data/lib/supabase/rails/errors.rb +68 -0
- data/lib/supabase/rails/middleware.rb +30 -7
- data/lib/supabase/rails/railtie.rb +28 -2
- data/lib/supabase/rails/routes.rb +89 -0
- data/lib/supabase/rails/session_store.rb +127 -0
- data/lib/supabase/rails/user.rb +38 -0
- data/lib/supabase/rails/version.rb +1 -1
- data/lib/supabase/rails/web/auth_client_factory.rb +101 -0
- data/lib/supabase/rails/web/auth_error_mapper.rb +65 -0
- data/lib/supabase/rails/web/cookie_credential_strategy.rb +200 -0
- data/lib/supabase/rails/web/redirect_validator.rb +92 -0
- data/lib/supabase/rails/web/refresh_coordinator.rb +78 -0
- data/lib/supabase/rails/web/request_scoped_storage.rb +132 -0
- data/lib/supabase/rails.rb +10 -0
- metadata +54 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../env"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "request_scoped_storage"
|
|
6
|
+
|
|
7
|
+
module Supabase
|
|
8
|
+
module Rails
|
|
9
|
+
module Web
|
|
10
|
+
# Constructs one `Supabase::Auth::Client` per request, cached in
|
|
11
|
+
# `request.env["supabase.rails.auth_client"]` so every helper that
|
|
12
|
+
# touches Supabase Auth within a single request shares the same
|
|
13
|
+
# instance (and therefore the same `RequestScopedStorage`).
|
|
14
|
+
#
|
|
15
|
+
# The client is wired with the invariants documented in FR-W6 / FR-W10:
|
|
16
|
+
#
|
|
17
|
+
# * `storage:` — per-request `RequestScopedStorage`
|
|
18
|
+
# * `auto_refresh_token:` — `false` (no Timer threads in workers;
|
|
19
|
+
# refresh is inline via US-006)
|
|
20
|
+
# * `flow_type:` — `"pkce"` (required for OAuth)
|
|
21
|
+
# * `persist_session:` — `true` (storage is request-scoped, so
|
|
22
|
+
# "persist" means "within this request")
|
|
23
|
+
module AuthClientFactory
|
|
24
|
+
ENV_KEY = "supabase.rails.auth_client"
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def build(request, env: nil, supabase_options: nil)
|
|
29
|
+
cached = request.env[ENV_KEY]
|
|
30
|
+
return cached unless cached.nil?
|
|
31
|
+
|
|
32
|
+
request.env[ENV_KEY] = build_client(
|
|
33
|
+
request,
|
|
34
|
+
env: env,
|
|
35
|
+
supabase_options: supabase_options
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build_client(request, env:, supabase_options:)
|
|
40
|
+
resolved_env = env.is_a?(SupabaseEnv) ? env : Env.resolve(env || {})
|
|
41
|
+
publishable_key = resolve_publishable_key(resolved_env.publishable_keys)
|
|
42
|
+
|
|
43
|
+
# `auto_refresh_token: false` is a hard invariant for every
|
|
44
|
+
# `Supabase::Auth::Client` constructed by this gem (PRD §FR-W10,
|
|
45
|
+
# US-008). `supabase-rb` would otherwise spawn a background Timer
|
|
46
|
+
# thread per session that outlives the request and leaks across
|
|
47
|
+
# Puma worker fork cycles. Refresh is performed inline by
|
|
48
|
+
# `Web::CookieCredentialStrategy` (US-006).
|
|
49
|
+
# `spec/supabase/rails/auto_refresh_token_invariant_spec.rb`
|
|
50
|
+
# enforces this by grepping the gem source.
|
|
51
|
+
::Supabase::Auth::Client.new(
|
|
52
|
+
url: auth_url(resolved_env.url),
|
|
53
|
+
headers: build_headers(publishable_key, supabase_options),
|
|
54
|
+
storage: RequestScopedStorage.new(request),
|
|
55
|
+
auto_refresh_token: false,
|
|
56
|
+
flow_type: "pkce",
|
|
57
|
+
persist_session: true
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def auth_url(base_url)
|
|
62
|
+
"#{base_url.to_s.chomp('/')}/auth/v1"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_publishable_key(keys)
|
|
66
|
+
key = keys["default"]
|
|
67
|
+
return key unless key.nil? || key.to_s.empty?
|
|
68
|
+
|
|
69
|
+
raise EnvError.missing_default_publishable_key
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_headers(publishable_key, supabase_options)
|
|
73
|
+
base = {
|
|
74
|
+
"apikey" => publishable_key,
|
|
75
|
+
"Authorization" => "Bearer #{publishable_key}"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
global = option_value(supabase_options, :global) || {}
|
|
79
|
+
extra = option_value(global, :headers) || {}
|
|
80
|
+
|
|
81
|
+
base.merge(stringify_keys(extra))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def option_value(hash, key)
|
|
85
|
+
return nil unless hash.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stringify_keys(hash)
|
|
91
|
+
return {} unless hash.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private_class_method :build_client, :auth_url, :resolve_publishable_key,
|
|
97
|
+
:build_headers, :option_value, :stringify_keys
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module Supabase
|
|
6
|
+
module Rails
|
|
7
|
+
module Web
|
|
8
|
+
# Translates `Supabase::Auth::Errors::*` (raised by `supabase-rb`) into
|
|
9
|
+
# the gem-stable `Supabase::Rails::AuthError` surface (`#code` + `#status`)
|
|
10
|
+
# per FR-W9. Controllers and the middleware error-shaping pipeline can
|
|
11
|
+
# rely on these stable codes/statuses without inspecting upstream classes.
|
|
12
|
+
#
|
|
13
|
+
# Ordering note: the dispatch is a `case/when`, so the most specific
|
|
14
|
+
# classes must come before their ancestors. `AuthRetryableError`,
|
|
15
|
+
# `AuthSessionMissing`, `AuthInvalidCredentialsError`,
|
|
16
|
+
# `AuthInvalidJwtError`, and `AuthWeakPassword` all inherit from
|
|
17
|
+
# `CustomAuthError < AuthError`; `AuthApiError`, `AuthPKCEError`, and
|
|
18
|
+
# `AuthUnknownError` inherit directly from `AuthError`. None of them
|
|
19
|
+
# inherit from each other, so the within-leaf order is cosmetic.
|
|
20
|
+
module AuthErrorMapper
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# @param err [Supabase::Auth::Errors::AuthError, StandardError]
|
|
24
|
+
# @return [Supabase::Rails::AuthError]
|
|
25
|
+
def translate(err)
|
|
26
|
+
message = err.respond_to?(:message) ? err.message.to_s : err.to_s
|
|
27
|
+
|
|
28
|
+
case err
|
|
29
|
+
when ::Supabase::Auth::Errors::AuthInvalidCredentialsError
|
|
30
|
+
AuthError.new(message, AuthError::INVALID_CREDENTIALS, 401)
|
|
31
|
+
when ::Supabase::Auth::Errors::AuthInvalidJwtError
|
|
32
|
+
AuthError.new(message, AuthError::INVALID_CREDENTIALS, 401)
|
|
33
|
+
when ::Supabase::Auth::Errors::AuthSessionMissing
|
|
34
|
+
AuthError.new(message, AuthError::SESSION_MISSING, 401)
|
|
35
|
+
when ::Supabase::Auth::Errors::AuthWeakPassword
|
|
36
|
+
AuthError.new(message, AuthError::WEAK_PASSWORD, 422)
|
|
37
|
+
when ::Supabase::Auth::Errors::AuthPKCEError
|
|
38
|
+
AuthError.new(message, AuthError::PKCE_ERROR, 400)
|
|
39
|
+
when ::Supabase::Auth::Errors::AuthRetryableError
|
|
40
|
+
AuthError.new(message, AuthError::AUTH_RETRYABLE, 503)
|
|
41
|
+
when ::Supabase::Auth::Errors::AuthApiError
|
|
42
|
+
map_api_error(message, err.status)
|
|
43
|
+
when ::Supabase::Auth::Errors::AuthUnknownError
|
|
44
|
+
AuthError.new(message, AuthError::AUTH_GENERIC_ERROR, 500)
|
|
45
|
+
else
|
|
46
|
+
AuthError.new(message, AuthError::AUTH_GENERIC_ERROR, 500)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def map_api_error(message, status)
|
|
54
|
+
if status.is_a?(Integer) && status >= 500
|
|
55
|
+
AuthError.new(message, AuthError::AUTH_UPSTREAM_ERROR, 503)
|
|
56
|
+
else
|
|
57
|
+
upstream = status.is_a?(Integer) ? status : 400
|
|
58
|
+
AuthError.new(message, AuthError::AUTH_API_ERROR, upstream)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../context"
|
|
4
|
+
require_relative "../logging"
|
|
5
|
+
require_relative "../session_store"
|
|
6
|
+
require_relative "auth_client_factory"
|
|
7
|
+
require_relative "refresh_coordinator"
|
|
8
|
+
|
|
9
|
+
module Supabase
|
|
10
|
+
module Rails
|
|
11
|
+
module Web
|
|
12
|
+
# Web-mode credential extraction from the encrypted session cookie.
|
|
13
|
+
#
|
|
14
|
+
# Reads the Supabase session via {SessionStore}, then:
|
|
15
|
+
#
|
|
16
|
+
# * missing/unparseable cookie → anonymous context
|
|
17
|
+
# * cookie missing access_token / → anonymous context
|
|
18
|
+
# expires_at, or non-numeric exp
|
|
19
|
+
# * `expires_at > now + 10s` → JWT verify the
|
|
20
|
+
# access_token and build
|
|
21
|
+
# a `:user` context
|
|
22
|
+
# * `expires_at <= now + 10s` + refresh_token → inline refresh via
|
|
23
|
+
# `auth.refresh_session`
|
|
24
|
+
# - success → write new cookie + use
|
|
25
|
+
# the new access_token
|
|
26
|
+
# - AuthApiError 400/401 → clear cookie, anon
|
|
27
|
+
# - upstream 5xx / network → Result.failure with
|
|
28
|
+
# AuthError(status: 503)
|
|
29
|
+
# * refresh_token missing on a near-expired → clear cookie, anon
|
|
30
|
+
# cookie
|
|
31
|
+
#
|
|
32
|
+
# The `Authorization: Bearer` header is intentionally ignored — in
|
|
33
|
+
# `:web` mode the cookie is the sole credential source. Per-controller
|
|
34
|
+
# `verify_supabase_auth(mode: :api)` (US-024) re-runs the API path.
|
|
35
|
+
class CookieCredentialStrategy
|
|
36
|
+
REFRESH_LEEWAY_SECONDS = 10
|
|
37
|
+
|
|
38
|
+
RackRequest = Struct.new(:env)
|
|
39
|
+
private_constant :RackRequest
|
|
40
|
+
|
|
41
|
+
def initialize(env: nil, supabase_options: nil, session: nil, session_store: nil, user_model: nil)
|
|
42
|
+
@env_overrides = env
|
|
43
|
+
@supabase_options = supabase_options
|
|
44
|
+
@session_store = session_store || SessionStore.new(session)
|
|
45
|
+
@user_model = user_model
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call(rack_env)
|
|
49
|
+
session = read_session(rack_env)
|
|
50
|
+
|
|
51
|
+
return anonymous_context(rack_env) if session.nil?
|
|
52
|
+
|
|
53
|
+
access_token = session["access_token"]
|
|
54
|
+
expires_at = session["expires_at"]
|
|
55
|
+
|
|
56
|
+
return anonymous_context(rack_env) unless valid_token?(access_token)
|
|
57
|
+
return anonymous_context(rack_env) unless valid_expiry?(expires_at)
|
|
58
|
+
return refresh_or_clear(rack_env, session) if needs_refresh?(expires_at)
|
|
59
|
+
|
|
60
|
+
user_context(rack_env, access_token)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def read_session(rack_env)
|
|
66
|
+
@session_store.read(request_for(rack_env))
|
|
67
|
+
rescue StandardError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def request_for(rack_env)
|
|
72
|
+
require "action_dispatch/http/request"
|
|
73
|
+
ActionDispatch::Request.new(rack_env)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def valid_token?(token)
|
|
77
|
+
token.is_a?(String) && !token.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def valid_expiry?(expires_at)
|
|
81
|
+
expires_at.is_a?(Numeric)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def needs_refresh?(expires_at)
|
|
85
|
+
expires_at <= Time.now.to_i + REFRESH_LEEWAY_SECONDS
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def refresh_or_clear(rack_env, session)
|
|
89
|
+
Logging.log(:info, "[supabase.rails.web.refresh] refresh starting")
|
|
90
|
+
refresh_token = session["refresh_token"]
|
|
91
|
+
|
|
92
|
+
unless valid_token?(refresh_token)
|
|
93
|
+
Logging.log(:warn, "[supabase.rails.web.refresh] clearing session cookie (no refresh_token)")
|
|
94
|
+
clear_cookie(rack_env)
|
|
95
|
+
return anonymous_context(rack_env)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
outcome = RefreshCoordinator.synchronize(refresh_token) do
|
|
99
|
+
current = read_session(rack_env)
|
|
100
|
+
if current && fresh_enough?(current)
|
|
101
|
+
current
|
|
102
|
+
else
|
|
103
|
+
perform_refresh(rack_env, refresh_token)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
apply_outcome(rack_env, outcome)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fresh_enough?(session)
|
|
111
|
+
valid_token?(session["access_token"]) &&
|
|
112
|
+
valid_expiry?(session["expires_at"]) &&
|
|
113
|
+
!needs_refresh?(session["expires_at"])
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def perform_refresh(rack_env, refresh_token)
|
|
117
|
+
request = request_for(rack_env)
|
|
118
|
+
client = AuthClientFactory.build(
|
|
119
|
+
request,
|
|
120
|
+
env: @env_overrides,
|
|
121
|
+
supabase_options: @supabase_options
|
|
122
|
+
)
|
|
123
|
+
response = client.refresh_session(refresh_token)
|
|
124
|
+
session_to_hash(response.session)
|
|
125
|
+
rescue ::Supabase::Auth::Errors::AuthApiError => e
|
|
126
|
+
[400, 401].include?(e.status) ? :invalid : :transient
|
|
127
|
+
rescue ::Supabase::Auth::Errors::AuthSessionMissing
|
|
128
|
+
:invalid
|
|
129
|
+
rescue ::Supabase::Auth::Errors::AuthError
|
|
130
|
+
:transient
|
|
131
|
+
rescue StandardError
|
|
132
|
+
:transient
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def session_to_hash(session)
|
|
136
|
+
return nil if session.nil?
|
|
137
|
+
return session.transform_keys(&:to_s) if session.is_a?(Hash)
|
|
138
|
+
return session.to_h.transform_keys(&:to_s) if session.respond_to?(:to_h)
|
|
139
|
+
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def apply_outcome(rack_env, outcome)
|
|
144
|
+
case outcome
|
|
145
|
+
when Hash
|
|
146
|
+
write_cookie(rack_env, outcome)
|
|
147
|
+
user_context(rack_env, outcome["access_token"])
|
|
148
|
+
when :invalid
|
|
149
|
+
Logging.log(:warn, "[supabase.rails.web.refresh] clearing session cookie (refresh invalid)")
|
|
150
|
+
clear_cookie(rack_env)
|
|
151
|
+
anonymous_context(rack_env)
|
|
152
|
+
when :transient
|
|
153
|
+
Logging.log(:error, "[supabase.rails.web.refresh] upstream refresh unavailable (5xx/network)")
|
|
154
|
+
Result.failure(AuthError.refresh_unavailable)
|
|
155
|
+
else
|
|
156
|
+
Logging.log(:warn, "[supabase.rails.web.refresh] clearing session cookie (refresh unknown outcome)")
|
|
157
|
+
clear_cookie(rack_env)
|
|
158
|
+
anonymous_context(rack_env)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def write_cookie(rack_env, session_hash)
|
|
163
|
+
@session_store.write(request_for(rack_env), session_hash)
|
|
164
|
+
rescue StandardError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def clear_cookie(rack_env)
|
|
169
|
+
@session_store.clear(request_for(rack_env))
|
|
170
|
+
rescue StandardError
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def anonymous_context(rack_env)
|
|
175
|
+
Rails.create_context(
|
|
176
|
+
RackRequest.new(rack_env),
|
|
177
|
+
auth: :none,
|
|
178
|
+
env: @env_overrides,
|
|
179
|
+
supabase_options: @supabase_options,
|
|
180
|
+
user_model: @user_model
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def user_context(rack_env, access_token)
|
|
185
|
+
# Overlay the cookie's access_token onto the env so the existing
|
|
186
|
+
# `create_context` pipeline (extract_headers → verify_credentials
|
|
187
|
+
# → build_context_result) handles JWT verify and client build.
|
|
188
|
+
shim_env = rack_env.merge("HTTP_AUTHORIZATION" => "Bearer #{access_token}")
|
|
189
|
+
Rails.create_context(
|
|
190
|
+
RackRequest.new(shim_env),
|
|
191
|
+
auth: :user,
|
|
192
|
+
env: @env_overrides,
|
|
193
|
+
supabase_options: @supabase_options,
|
|
194
|
+
user_model: @user_model
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module Supabase
|
|
8
|
+
module Rails
|
|
9
|
+
module Web
|
|
10
|
+
# Validates a `redirect_to` target against an origin allowlist (FR-W11,
|
|
11
|
+
# US-015). Prevents open-redirect vulnerabilities introduced via
|
|
12
|
+
# attacker-controlled `?redirect_to=` query params on OAuth start and
|
|
13
|
+
# callback URLs.
|
|
14
|
+
#
|
|
15
|
+
# A target is accepted when it is either:
|
|
16
|
+
# * a path (no scheme/host), e.g. `/dashboard` or `/foo?a=1`, OR
|
|
17
|
+
# * an absolute URL whose origin (`scheme://host[:port]`) matches an
|
|
18
|
+
# entry in `allowed_origins`.
|
|
19
|
+
#
|
|
20
|
+
# Anything else raises `Supabase::Rails::AuthError(code: "INVALID_REDIRECT")`.
|
|
21
|
+
# Origin matching is case-insensitive on scheme + host; the port must
|
|
22
|
+
# match exactly (or be the scheme default on both sides).
|
|
23
|
+
module RedirectValidator
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param uri [String, URI::Generic, nil] the candidate redirect target.
|
|
27
|
+
# @param allowed_origins [Array<String>] list of allowed origins
|
|
28
|
+
# (`"https://myapp.com"` style) — typically
|
|
29
|
+
# `config.supabase.allowed_redirect_origins`, falling back to
|
|
30
|
+
# `[request.host]` at the call site.
|
|
31
|
+
# @return [String] the input target, unchanged, when valid.
|
|
32
|
+
# @raise [Supabase::Rails::AuthError] when the target is off-allowlist
|
|
33
|
+
# or malformed (`code: "INVALID_REDIRECT"`, `status: 400`).
|
|
34
|
+
def validate(uri, allowed_origins:)
|
|
35
|
+
raise AuthError.invalid_redirect(uri) if uri.nil?
|
|
36
|
+
|
|
37
|
+
raw = uri.to_s
|
|
38
|
+
raise AuthError.invalid_redirect(raw) if raw.empty?
|
|
39
|
+
|
|
40
|
+
parsed = parse(raw)
|
|
41
|
+
raise AuthError.invalid_redirect(raw) if parsed.nil?
|
|
42
|
+
|
|
43
|
+
return raw if path_only?(parsed)
|
|
44
|
+
return raw if origin_allowed?(parsed, allowed_origins)
|
|
45
|
+
|
|
46
|
+
raise AuthError.invalid_redirect(raw)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse(raw)
|
|
53
|
+
URI.parse(raw)
|
|
54
|
+
rescue URI::InvalidURIError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A "path-only" target has no scheme AND no host — `URI.parse("/foo")`
|
|
59
|
+
# yields `scheme=nil, host=nil, path="/foo"`. Protocol-relative URLs
|
|
60
|
+
# like `//evil.com/x` have `host="evil.com"` and are NOT path-only;
|
|
61
|
+
# they fall through to origin matching (and get rejected unless the
|
|
62
|
+
# host happens to be allowlisted). Scheme-only targets like
|
|
63
|
+
# `javascript:alert(1)` have `scheme="javascript", host=nil` and are
|
|
64
|
+
# also NOT path-only — rejected by origin matching.
|
|
65
|
+
def path_only?(uri)
|
|
66
|
+
uri.scheme.nil? && uri.host.nil? && !uri.path.to_s.empty?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def origin_allowed?(uri, allowed_origins)
|
|
70
|
+
return false if uri.scheme.nil? || uri.host.nil?
|
|
71
|
+
return false unless allowed_origins.is_a?(Array)
|
|
72
|
+
|
|
73
|
+
target_origin = origin_of(uri)
|
|
74
|
+
allowed_origins.any? do |entry|
|
|
75
|
+
allowed = parse(entry.to_s)
|
|
76
|
+
next false if allowed.nil? || allowed.scheme.nil? || allowed.host.nil?
|
|
77
|
+
|
|
78
|
+
origin_of(allowed) == target_origin
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def origin_of(uri)
|
|
83
|
+
scheme = uri.scheme.to_s.downcase
|
|
84
|
+
host = uri.host.to_s.downcase
|
|
85
|
+
port = uri.port || URI.scheme_list[scheme.upcase]&.default_port
|
|
86
|
+
"#{scheme}://#{host}:#{port}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Supabase
|
|
6
|
+
module Rails
|
|
7
|
+
module Web
|
|
8
|
+
# In-process mutex pool keyed by `SHA256(refresh_token)`. Two threads
|
|
9
|
+
# holding the same refresh token cooperate on a single outbound
|
|
10
|
+
# `auth.refresh_session` call instead of racing.
|
|
11
|
+
#
|
|
12
|
+
# Documented limitation: across clustered Puma workers (separate
|
|
13
|
+
# processes) two simultaneous requests for the same user can each
|
|
14
|
+
# acquire their own worker-local mutex and both refresh. In practice
|
|
15
|
+
# browsers serialize cookie-bearing requests; acceptable for v0.2 and
|
|
16
|
+
# revisitable if telemetry shows refresh contention.
|
|
17
|
+
#
|
|
18
|
+
# Entries are reference-counted: the SHA256 -> Mutex entry is dropped
|
|
19
|
+
# when the last holder releases it, so the hash does not grow
|
|
20
|
+
# unboundedly in long-lived workers.
|
|
21
|
+
module RefreshCoordinator
|
|
22
|
+
@entries = {}
|
|
23
|
+
@entries_mutex = Mutex.new
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Yield the block while holding the per-token mutex. Returns the
|
|
28
|
+
# block's return value.
|
|
29
|
+
def synchronize(refresh_token)
|
|
30
|
+
key = digest(refresh_token)
|
|
31
|
+
mutex = checkout(key)
|
|
32
|
+
begin
|
|
33
|
+
mutex.synchronize { yield }
|
|
34
|
+
ensure
|
|
35
|
+
checkin(key)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def digest(refresh_token)
|
|
40
|
+
Digest::SHA256.hexdigest(refresh_token.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Number of live (refcounted) entries — for tests / introspection.
|
|
44
|
+
def entry_count
|
|
45
|
+
@entries_mutex.synchronize { @entries.size }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Test-only escape hatch. Forcibly drops every entry so a leaked
|
|
49
|
+
# mutex from a previous example can't bleed into the next.
|
|
50
|
+
def reset!
|
|
51
|
+
@entries_mutex.synchronize { @entries.clear }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def checkout(key)
|
|
58
|
+
@entries_mutex.synchronize do
|
|
59
|
+
entry = @entries[key] ||= { mutex: Mutex.new, refs: 0 }
|
|
60
|
+
entry[:refs] += 1
|
|
61
|
+
entry[:mutex]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def checkin(key)
|
|
66
|
+
@entries_mutex.synchronize do
|
|
67
|
+
entry = @entries[key]
|
|
68
|
+
return unless entry
|
|
69
|
+
|
|
70
|
+
entry[:refs] -= 1
|
|
71
|
+
@entries.delete(key) if entry[:refs] <= 0
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module Supabase
|
|
6
|
+
module Rails
|
|
7
|
+
module Web
|
|
8
|
+
# Per-request Supabase Auth storage.
|
|
9
|
+
#
|
|
10
|
+
# Implements the `Supabase::Auth::SupportedStorage` duck type
|
|
11
|
+
# (`get_item` / `set_item` / `remove_item`). The backing Hash lives in
|
|
12
|
+
# `request.env["supabase.rails.auth_storage"]` and is allocated lazily
|
|
13
|
+
# on first access — concurrent requests in the same worker each get
|
|
14
|
+
# their own slot, so PKCE verifiers and session state never leak across
|
|
15
|
+
# threads or users.
|
|
16
|
+
#
|
|
17
|
+
# PKCE verifiers must survive the OAuth round-trip (the verifier is
|
|
18
|
+
# written during `sign_in_with_oauth` on one request and consumed at
|
|
19
|
+
# `/callback` on another). When `#oauth_state` is set, writes to any
|
|
20
|
+
# key whose name ends with `code-verifier` are mirrored into a signed
|
|
21
|
+
# cookie `sb-oauth-state-<state>` with a 10-minute expiry; reads fall
|
|
22
|
+
# back to that cookie when the per-request hash misses. Keying the
|
|
23
|
+
# cookie by the OAuth `state` param binds the verifier to the specific
|
|
24
|
+
# round-trip and prevents mix-ups across concurrent OAuth attempts.
|
|
25
|
+
class RequestScopedStorage < ::Supabase::Auth::SupportedStorage
|
|
26
|
+
ENV_KEY = "supabase.rails.auth_storage"
|
|
27
|
+
COOKIE_NAME_PREFIX = "sb-oauth-state-"
|
|
28
|
+
COOKIE_TTL_SECONDS = 600
|
|
29
|
+
PKCE_KEY_SUFFIX = "code-verifier"
|
|
30
|
+
|
|
31
|
+
attr_accessor :oauth_state
|
|
32
|
+
attr_reader :request
|
|
33
|
+
|
|
34
|
+
def initialize(request, oauth_state: nil)
|
|
35
|
+
super()
|
|
36
|
+
@request = request
|
|
37
|
+
@oauth_state = oauth_state
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_item(key)
|
|
41
|
+
value = backing_hash[key]
|
|
42
|
+
return value unless value.nil?
|
|
43
|
+
|
|
44
|
+
read_pkce_cookie(key)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def set_item(key, value)
|
|
48
|
+
backing_hash[key] = value
|
|
49
|
+
write_pkce_cookie(value) if pkce_key?(key) && oauth_state_present?
|
|
50
|
+
value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def remove_item(key)
|
|
54
|
+
existed = backing_hash.key?(key)
|
|
55
|
+
value = backing_hash.delete(key)
|
|
56
|
+
clear_pkce_cookie if pkce_key?(key) && oauth_state_present?
|
|
57
|
+
existed ? value : nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The backing per-request hash, lazily allocated in `request.env`.
|
|
61
|
+
# Subsequent `RequestScopedStorage.new(request)` calls for the same
|
|
62
|
+
# request observe the same hash (memoized by env key).
|
|
63
|
+
def backing_hash
|
|
64
|
+
env[ENV_KEY] ||= {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def env
|
|
70
|
+
@request.env
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def pkce_key?(key)
|
|
74
|
+
key.is_a?(String) && key.end_with?(PKCE_KEY_SUFFIX)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def oauth_state_present?
|
|
78
|
+
!oauth_state.nil? && !oauth_state.to_s.empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def read_pkce_cookie(key)
|
|
82
|
+
return nil unless pkce_key?(key) && oauth_state_present?
|
|
83
|
+
|
|
84
|
+
jar = signed_jar
|
|
85
|
+
jar && jar[cookie_name]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write_pkce_cookie(value)
|
|
89
|
+
jar = signed_jar
|
|
90
|
+
return if jar.nil?
|
|
91
|
+
|
|
92
|
+
jar[cookie_name] = {
|
|
93
|
+
value: value,
|
|
94
|
+
expires: Time.now + COOKIE_TTL_SECONDS,
|
|
95
|
+
httponly: true,
|
|
96
|
+
same_site: :lax,
|
|
97
|
+
path: "/",
|
|
98
|
+
secure: default_secure
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def clear_pkce_cookie
|
|
103
|
+
jar = base_jar
|
|
104
|
+
return if jar.nil?
|
|
105
|
+
|
|
106
|
+
jar.delete(cookie_name, path: "/")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def cookie_name
|
|
110
|
+
"#{COOKIE_NAME_PREFIX}#{oauth_state}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def signed_jar
|
|
114
|
+
jar = base_jar
|
|
115
|
+
return nil if jar.nil?
|
|
116
|
+
|
|
117
|
+
jar.respond_to?(:signed) ? jar.signed : jar
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def base_jar
|
|
121
|
+
return nil unless @request.respond_to?(:cookie_jar)
|
|
122
|
+
|
|
123
|
+
@request.cookie_jar
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def default_secure
|
|
127
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.production?
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
data/lib/supabase/rails.rb
CHANGED
|
@@ -6,8 +6,10 @@ require_relative "rails/logging"
|
|
|
6
6
|
require_relative "rails/env"
|
|
7
7
|
require_relative "rails/jwt"
|
|
8
8
|
require_relative "rails/core"
|
|
9
|
+
require_relative "rails/user"
|
|
9
10
|
require_relative "rails/context"
|
|
10
11
|
require_relative "rails/cors"
|
|
12
|
+
require_relative "rails/session_store"
|
|
11
13
|
|
|
12
14
|
module Supabase
|
|
13
15
|
module Rails
|
|
@@ -15,6 +17,14 @@ module Supabase
|
|
|
15
17
|
end
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
require_relative "rails/web/request_scoped_storage"
|
|
21
|
+
require_relative "rails/web/auth_client_factory"
|
|
22
|
+
require_relative "rails/web/refresh_coordinator"
|
|
23
|
+
require_relative "rails/web/auth_error_mapper"
|
|
24
|
+
require_relative "rails/web/redirect_validator"
|
|
18
25
|
require_relative "rails/middleware"
|
|
19
26
|
require_relative "rails/controller"
|
|
27
|
+
require_relative "rails/authentication"
|
|
28
|
+
require_relative "rails/routes"
|
|
29
|
+
require_relative "rails/engine" if defined?(::Rails::Engine)
|
|
20
30
|
require_relative "rails/railtie" if defined?(::Rails::Railtie)
|