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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/supabase/rails/base_controller.rb +17 -0
  3. data/app/controllers/supabase/rails/oauth_controller.rb +53 -0
  4. data/app/controllers/supabase/rails/otp_controller.rb +55 -0
  5. data/app/controllers/supabase/rails/passwords_controller.rb +47 -0
  6. data/app/controllers/supabase/rails/registrations_controller.rb +46 -0
  7. data/app/controllers/supabase/rails/sessions_controller.rb +32 -0
  8. data/app/views/supabase/rails/oauth/_buttons.html.erb +8 -0
  9. data/app/views/supabase/rails/otp/new.html.erb +11 -0
  10. data/app/views/supabase/rails/otp/verify.html.erb +14 -0
  11. data/app/views/supabase/rails/passwords/edit.html.erb +9 -0
  12. data/app/views/supabase/rails/passwords/new.html.erb +11 -0
  13. data/app/views/supabase/rails/registrations/new.html.erb +12 -0
  14. data/app/views/supabase/rails/sessions/new.html.erb +15 -0
  15. data/app/views/supabase/rails/shared/_flash.html.erb +5 -0
  16. data/config/locales/en.yml +19 -0
  17. data/lib/generators/supabase/install/install_generator.rb +84 -0
  18. data/lib/generators/supabase/install/templates/app/controllers/concerns/authentication.rb.tt +9 -0
  19. data/lib/generators/supabase/install/templates/app/controllers/oauth_controller.rb.tt +4 -0
  20. data/lib/generators/supabase/install/templates/app/controllers/otp_controller.rb.tt +4 -0
  21. data/lib/generators/supabase/install/templates/app/controllers/passwords_controller.rb.tt +4 -0
  22. data/lib/generators/supabase/install/templates/app/controllers/registrations_controller.rb.tt +4 -0
  23. data/lib/generators/supabase/install/templates/app/controllers/sessions_controller.rb.tt +4 -0
  24. data/lib/generators/supabase/install/templates/app/models/current.rb.tt +5 -0
  25. data/lib/generators/supabase/install/templates/config/initializers/supabase.rb.tt +21 -0
  26. data/lib/generators/supabase/user_model/templates/app/models/user.rb.tt +11 -0
  27. data/lib/generators/supabase/user_model/templates/db/migrate/create_supabase_users.rb.tt +11 -0
  28. data/lib/generators/supabase/user_model/user_model_generator.rb +64 -0
  29. data/lib/generators/supabase/views/views_generator.rb +33 -0
  30. data/lib/supabase/rails/authentication.rb +744 -0
  31. data/lib/supabase/rails/context.rb +22 -5
  32. data/lib/supabase/rails/controller.rb +24 -2
  33. data/lib/supabase/rails/core.rb +9 -0
  34. data/lib/supabase/rails/engine.rb +35 -0
  35. data/lib/supabase/rails/errors.rb +68 -0
  36. data/lib/supabase/rails/middleware.rb +30 -7
  37. data/lib/supabase/rails/railtie.rb +28 -2
  38. data/lib/supabase/rails/routes.rb +89 -0
  39. data/lib/supabase/rails/session_store.rb +127 -0
  40. data/lib/supabase/rails/user.rb +38 -0
  41. data/lib/supabase/rails/version.rb +1 -1
  42. data/lib/supabase/rails/web/auth_client_factory.rb +101 -0
  43. data/lib/supabase/rails/web/auth_error_mapper.rb +65 -0
  44. data/lib/supabase/rails/web/cookie_credential_strategy.rb +200 -0
  45. data/lib/supabase/rails/web/redirect_validator.rb +92 -0
  46. data/lib/supabase/rails/web/refresh_coordinator.rb +78 -0
  47. data/lib/supabase/rails/web/request_scoped_storage.rb +132 -0
  48. data/lib/supabase/rails.rb +10 -0
  49. 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
@@ -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)