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,744 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "jwt"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ require_relative "context"
9
+ require_relative "errors"
10
+ require_relative "logging"
11
+ require_relative "session_store"
12
+ require_relative "user"
13
+ require_relative "web/auth_client_factory"
14
+ require_relative "web/auth_error_mapper"
15
+ require_relative "web/redirect_validator"
16
+
17
+ module Supabase
18
+ module Rails
19
+ # Rails-8-shape Authentication concern (FR-W5, US-010).
20
+ #
21
+ # `include Supabase::Rails::Authentication` gives a controller the same
22
+ # surface as Rails 8's built-in `bin/rails g authentication` output:
23
+ #
24
+ # * `before_action :require_authentication` installed on inclusion
25
+ # * `helper_method :authenticated?` registered on inclusion
26
+ # * `allow_unauthenticated_access(only: ..., except: ...)` class macro
27
+ # that delegates to `skip_before_action :require_authentication`
28
+ # * Instance methods `authenticated?`, `require_authentication`,
29
+ # `start_new_session_for(session)`, `terminate_session(scope:)`,
30
+ # `authenticate_with_supabase(email:, password:)`, plus the
31
+ # override hooks `request_authentication`, `after_authentication_url`,
32
+ # `store_location_for_redirect`, `stored_location_for_redirect`.
33
+ #
34
+ # Backing implementation talks to Supabase Auth via the per-request
35
+ # `Supabase::Auth::Client` ({Web::AuthClientFactory}) and the encrypted
36
+ # session cookie ({SessionStore}). `Current.user` and `Current.session`
37
+ # are populated on resume / `start_new_session_for` and cleared on
38
+ # `terminate_session`. The host app is expected to provide `Current`
39
+ # (`app/models/current.rb` from the Rails 8 generator).
40
+ #
41
+ # `:api` mode: the concern still mounts so a single controller class can
42
+ # serve both surfaces, but `start_new_session_for` raises (cookies don't
43
+ # apply), `require_authentication` 401s instead of redirecting, and
44
+ # `terminate_session` is a local no-op.
45
+ module Authentication
46
+ extend ActiveSupport::Concern
47
+
48
+ included do
49
+ before_action :require_authentication
50
+ before_action :populate_current_attributes
51
+ helper_method :authenticated?
52
+
53
+ helper_method :current_user if Supabase::Rails::Authentication.expose_current_user?
54
+ end
55
+
56
+ class_methods do
57
+ # Rails-8 class macro:
58
+ #
59
+ # class SessionsController < ApplicationController
60
+ # allow_unauthenticated_access only: %i[new create]
61
+ # end
62
+ def allow_unauthenticated_access(**options)
63
+ skip_before_action :require_authentication, **options
64
+ end
65
+ end
66
+
67
+ # Whether `current_user` should be exposed as a `helper_method` on the
68
+ # including controller. Resolved at include time from the Railtie config:
69
+ #
70
+ # config.supabase.expose_current_user = true | false | nil
71
+ #
72
+ # When `nil` (the default), derives from `config.supabase.mode`:
73
+ # `:web` → true (Devise muscle memory in views), `:api` → false (avoid
74
+ # clashing with apps that already define `current_user` for API
75
+ # consumers). Returns `false` when Rails is not loaded so the gem can
76
+ # still be included in non-Rails specs.
77
+ def self.expose_current_user?
78
+ cfg = railtie_config
79
+ return false if cfg.nil?
80
+
81
+ explicit = cfg.respond_to?(:expose_current_user) ? cfg.expose_current_user : nil
82
+ return !!explicit unless explicit.nil?
83
+
84
+ mode = cfg.respond_to?(:mode) ? cfg.mode : :api
85
+ mode == :web
86
+ end
87
+
88
+ def self.railtie_config
89
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
90
+
91
+ ::Rails.application.config.supabase
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ # Masks the local part of an email address for safe logging.
97
+ # `alice@example.com` → `a***@example.com`.
98
+ # Returns a placeholder when the input is nil/empty/malformed so the
99
+ # raw value never leaks to the log line.
100
+ def self.redact_email(email)
101
+ return "<missing>" if email.nil?
102
+
103
+ str = email.to_s
104
+ return "<blank>" if str.empty?
105
+
106
+ local, _, domain = str.rpartition("@")
107
+ return "<malformed>" if local.empty? || domain.empty?
108
+
109
+ "#{local[0]}***@#{domain}"
110
+ end
111
+
112
+ # --- Rails-8 parity surface ---
113
+
114
+ def authenticated?
115
+ ::Current.user.present?
116
+ end
117
+
118
+ # Devise muscle-memory alias for `Current.user`. Always defined as an
119
+ # instance method; only exposed to views via `helper_method` when
120
+ # `config.supabase.expose_current_user` resolves to true (default in
121
+ # `:web` mode). When the host app defines its own `current_user` on a
122
+ # parent controller, Ruby's method lookup picks the host's first.
123
+ def current_user
124
+ ::Current.user
125
+ end
126
+
127
+ def require_authentication
128
+ resume_session || request_authentication
129
+ end
130
+
131
+ # Persists a Supabase session in the encrypted cookie and populates
132
+ # `Current.user` / `Current.session`. In `:api` mode this raises a
133
+ # {ConfigError} because cookies don't apply — clients send JWTs via
134
+ # the `Authorization: Bearer` header.
135
+ def start_new_session_for(supabase_session)
136
+ if supabase_mode == :api
137
+ raise Supabase::Rails::ConfigError.api_mode_cookie_unsupported
138
+ end
139
+
140
+ Supabase::Rails::SessionStore.new(supabase_session_config)
141
+ .write(request, supabase_session)
142
+
143
+ ::Current.user = build_current_user(supabase_session)
144
+ ::Current.session = supabase_session
145
+ supabase_session
146
+ end
147
+
148
+ # Best-effort upstream sign-out, then clears the encrypted cookie and
149
+ # `Current`. `auth.sign_out` is rescued because the local clear is the
150
+ # source of truth — a failed upstream call still produces a signed-out
151
+ # user locally. In `:api` mode this is a local no-op (no cookie to
152
+ # clear; clients drop the JWT on their side).
153
+ def terminate_session(scope: :local)
154
+ return if supabase_mode == :api
155
+
156
+ attempt_upstream_sign_out(scope) if ::Current.session
157
+ ensure
158
+ unless supabase_mode == :api
159
+ Supabase::Rails::SessionStore.new(supabase_session_config).clear(request)
160
+ ::Current.user = nil
161
+ ::Current.session = nil
162
+ end
163
+ end
164
+
165
+ # Mirrors Rails 8's `User.authenticate_by(email:, password:)`:
166
+ # returns a Supabase session on success, `nil` on a 4xx authentication
167
+ # failure (invalid credentials, weak password, etc), and raises only
168
+ # on upstream 5xx so the controller can surface a "try again" message.
169
+ def authenticate_with_supabase(email:, password:)
170
+ client = Supabase::Rails::Web::AuthClientFactory.build(request)
171
+ client.sign_in_with_password(email: email, password: password).session
172
+ rescue ::Supabase::Auth::Errors::AuthError => e
173
+ mapped = Supabase::Rails::Web::AuthErrorMapper.translate(e)
174
+ raise mapped if mapped.status.to_i >= 500
175
+
176
+ Supabase::Rails::Logging.log(
177
+ :warn,
178
+ "[supabase.rails.sign_in_failure] code=#{mapped.code} email=#{Supabase::Rails::Authentication.redact_email(email)}"
179
+ )
180
+ nil
181
+ end
182
+
183
+ # --- FR-W7 low-level helpers (US-012) ---
184
+ #
185
+ # `supabase_*` helpers return a {Result} wrapping either the upstream
186
+ # value (session struct, auth response, etc.) or a {AuthError}. Lets
187
+ # controllers compose custom flows without subclassing the gem's base
188
+ # controllers. Each helper:
189
+ #
190
+ # * builds a per-request {Web::AuthClientFactory} client
191
+ # * dispatches the upstream call
192
+ # * on success calls `start_new_session_for` when a session is returned
193
+ # * routes any `Supabase::Auth::Errors::AuthError` through
194
+ # {Web::AuthErrorMapper} so the failure carries a stable
195
+ # `code` + `status`
196
+
197
+ # Sign-in with email/password. Returns `Result.success(session)` on
198
+ # success (and calls `start_new_session_for(session)` internally so
199
+ # `Current.user` / `Current.session` are populated and the encrypted
200
+ # cookie is written). On a 4xx upstream failure returns
201
+ # `Result.failure(AuthError.invalid_credentials)` — a generic
202
+ # "Invalid credentials" message regardless of whether email or
203
+ # password was the bad field (prevents user enumeration). On a 5xx
204
+ # upstream failure returns `Result.failure` with the upstream-mapped
205
+ # error (status 503, code `AUTH_UPSTREAM_ERROR`).
206
+ def supabase_sign_in_with_password(email:, password:)
207
+ client = supabase_auth_client
208
+ response = client.sign_in_with_password(email: email, password: password)
209
+ session = response.session
210
+ start_new_session_for(session) if session
211
+ Supabase::Rails::Result.success(session)
212
+ rescue ::Supabase::Auth::Errors::AuthError => e
213
+ failure = translate_sign_in_error(e)
214
+ Supabase::Rails::Logging.log(
215
+ :warn,
216
+ "[supabase.rails.sign_in_failure] code=#{failure.code} email=#{Supabase::Rails::Authentication.redact_email(email)}"
217
+ )
218
+ Supabase::Rails::Result.failure(failure)
219
+ end
220
+
221
+ # Sign-up with email/password. Returns `Result.success(response)` on
222
+ # success — value is the upstream `Supabase::Auth::Types::AuthResponse`
223
+ # (`.user` always present, `.session` present when auto-sign-in is
224
+ # enabled, `nil` when email confirmation is pending). When a session
225
+ # is present `start_new_session_for(session)` is called internally so
226
+ # the cookie + `Current` are written. `data:` populates user metadata
227
+ # (`raw_user_meta_data`) and `redirect_to:` sets the post-confirmation
228
+ # redirect URL. Errors flow through {Web::AuthErrorMapper} so callers
229
+ # can branch on stable codes — notably `WEAK_PASSWORD` (422) is
230
+ # preserved so the host can render a specific UI; other 4xx errors
231
+ # are masked to `INVALID_CREDENTIALS` (401), and 5xx surfaces as
232
+ # `AUTH_UPSTREAM_ERROR` (503).
233
+ def supabase_sign_up(email:, password:, data: nil, redirect_to: nil)
234
+ client = supabase_auth_client
235
+ options = {}
236
+ options[:data] = data if data
237
+ options[:redirect_to] = redirect_to if redirect_to
238
+
239
+ credentials = { email: email, password: password }
240
+ credentials[:options] = options unless options.empty?
241
+
242
+ response = client.sign_up(credentials)
243
+ session = response.respond_to?(:session) ? response.session : nil
244
+ start_new_session_for(session) if session
245
+ Supabase::Rails::Result.success(response)
246
+ rescue ::Supabase::Auth::Errors::AuthError => e
247
+ Supabase::Rails::Result.failure(translate_sign_up_error(e))
248
+ end
249
+
250
+ # --- US-013 OTP / magic-link helpers (FR-W7) ---
251
+ #
252
+ # Three helpers cover the passwordless / second-factor flows:
253
+ #
254
+ # * `supabase_sign_in_with_otp` — kicks off the delivery (magic link
255
+ # for email, SMS code for phone). No session is created yet; returns
256
+ # `Result.success(AuthOtpResponse)` so the caller can render a
257
+ # "Check your inbox / Enter the code" page.
258
+ # * `supabase_verify_otp` — exchanges the code/token for a session,
259
+ # calls `start_new_session_for` on success.
260
+ # * `supabase_resend` — re-triggers the delivery for a given
261
+ # identifier + type. Returns `Result.success(nil)` (the upstream
262
+ # `AuthOtpResponse` carries no data the host needs).
263
+
264
+ # Send an OTP / magic link. Provide either `email:` or `phone:`.
265
+ # `**opts` is forwarded as the upstream `:options` hash and accepts the
266
+ # full supabase-rb surface: `email_redirect_to:`, `should_create_user:`,
267
+ # `data:`, `channel:` ("sms" | "whatsapp"), `captcha_token:`.
268
+ # Returns `Result.success(AuthOtpResponse)` (delivery is pending —
269
+ # no session is established yet).
270
+ def supabase_sign_in_with_otp(email: nil, phone: nil, **opts)
271
+ client = supabase_auth_client
272
+ credentials = {}
273
+ credentials[:email] = email if email
274
+ credentials[:phone] = phone if phone
275
+ credentials[:options] = opts unless opts.empty?
276
+
277
+ response = client.sign_in_with_otp(credentials)
278
+ Supabase::Rails::Result.success(response)
279
+ rescue ::Supabase::Auth::Errors::AuthError => e
280
+ Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e))
281
+ end
282
+
283
+ # Verify an OTP token (`token:`) of a given `type:` (e.g. `"email"`,
284
+ # `"sms"`, `"magiclink"`, `"recovery"`, `"invite"`, `"signup"`).
285
+ # Returns `Result.success(session)` and calls `start_new_session_for`
286
+ # internally on success so `Current.user` / `Current.session` are
287
+ # populated and the encrypted cookie is written.
288
+ def supabase_verify_otp(token:, type:, email: nil, phone: nil)
289
+ client = supabase_auth_client
290
+ params = { token: token, type: type }
291
+ params[:email] = email if email
292
+ params[:phone] = phone if phone
293
+
294
+ response = client.verify_otp(params)
295
+ session = response.respond_to?(:session) ? response.session : nil
296
+ start_new_session_for(session) if session
297
+ Supabase::Rails::Result.success(session)
298
+ rescue ::Supabase::Auth::Errors::AuthError => e
299
+ Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e))
300
+ end
301
+
302
+ # Resend an OTP / magic link for the given identifier + `type:`.
303
+ # Returns `Result.success(nil)` — the upstream `AuthOtpResponse`
304
+ # carries no actionable data; the host typically just re-renders the
305
+ # "Check your inbox" page.
306
+ def supabase_resend(type:, email: nil, phone: nil)
307
+ client = supabase_auth_client
308
+ credentials = { type: type }
309
+ credentials[:email] = email if email
310
+ credentials[:phone] = phone if phone
311
+
312
+ client.resend(credentials)
313
+ Supabase::Rails::Result.success(nil)
314
+ rescue ::Supabase::Auth::Errors::AuthError => e
315
+ Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e))
316
+ end
317
+
318
+ # --- US-014 OAuth helpers with PKCE (FR-W7) ---
319
+ #
320
+ # OAuth in PKCE mode is a two-leg flow split across two HTTP requests:
321
+ #
322
+ # 1. The host's "start" controller calls `supabase_sign_in_with_oauth`
323
+ # and redirects the browser to the returned URL. Internally the
324
+ # helper generates a random `state`, binds the per-request
325
+ # `RequestScopedStorage` to it, and lets supabase-rb write the
326
+ # PKCE verifier — which our storage mirrors into a signed cookie
327
+ # `sb-oauth-state-<state>` with a 10-minute TTL.
328
+ # 2. The OAuth provider redirects back to the app's callback URL
329
+ # with `code=<auth_code>` and `state=<state>` query params. The
330
+ # callback controller calls `supabase_exchange_code_for_session`
331
+ # with both. The helper rebinds storage to the callback `state`
332
+ # so the verifier lookup falls back to the signed cookie, then
333
+ # exchanges the code for a session and calls
334
+ # `start_new_session_for` on success.
335
+ #
336
+ # Binding the verifier cookie to the `state` value gives us two
337
+ # properties for free: concurrent OAuth flows in the same browser
338
+ # (different tabs) don't clobber each other (each state = unique
339
+ # cookie); and a callback that returns a `state` we never issued
340
+ # silently misses the cookie lookup → `PKCE_ERROR` (instead of a
341
+ # confusing upstream 400 from supabase-rb).
342
+
343
+ # Begin an OAuth sign-in. Returns `Result.success(<authorize_url>)`
344
+ # on success — the controller is expected to `redirect_to result.value`.
345
+ # The PKCE verifier is stashed in a signed cookie keyed by a freshly
346
+ # generated `state`; that same `state` is appended as a query param
347
+ # to the `redirect_to:` URL so the OAuth provider echoes it back on
348
+ # the callback, where {#supabase_exchange_code_for_session} can pick
349
+ # it up. `scopes:` is forwarded verbatim to the OAuth provider via
350
+ # supabase-rb (space-separated string per the OAuth 2.0 spec).
351
+ def supabase_sign_in_with_oauth(provider:, redirect_to:, scopes: nil)
352
+ Supabase::Rails::Web::RedirectValidator.validate(
353
+ redirect_to,
354
+ allowed_origins: supabase_allowed_redirect_origins
355
+ )
356
+
357
+ state = SecureRandom.urlsafe_base64(32)
358
+ client = supabase_auth_client
359
+ bind_oauth_state(client, state)
360
+
361
+ options = { redirect_to: append_state_to_redirect(redirect_to, state) }
362
+ options[:scopes] = scopes if scopes
363
+
364
+ response = client.sign_in_with_oauth({ provider: provider, options: options })
365
+ Supabase::Rails::Result.success(response.url)
366
+ rescue Supabase::Rails::AuthError => e
367
+ Supabase::Rails::Result.failure(e)
368
+ rescue ::Supabase::Auth::Errors::AuthError => e
369
+ Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e))
370
+ end
371
+
372
+ # Complete an OAuth sign-in by exchanging the auth code for a session.
373
+ # `code:` is the OAuth code from the callback URL; `state:` is the
374
+ # value the provider echoed back from the start leg — used to find
375
+ # the matching `sb-oauth-state-<state>` signed cookie that holds the
376
+ # PKCE verifier. When the cookie is missing or `state` doesn't match
377
+ # any issued state, returns `Result.failure(AuthError.pkce_missing_verifier)`
378
+ # (`code: PKCE_ERROR`, `status: 400`) without bothering the upstream.
379
+ # On success, calls `start_new_session_for(session)` internally so
380
+ # `Current.user` / `Current.session` are populated and the encrypted
381
+ # cookie is written.
382
+ def supabase_exchange_code_for_session(code:, state: nil, redirect_to: nil)
383
+ if redirect_to
384
+ Supabase::Rails::Web::RedirectValidator.validate(
385
+ redirect_to,
386
+ allowed_origins: supabase_allowed_redirect_origins
387
+ )
388
+ end
389
+
390
+ client = supabase_auth_client
391
+ bind_oauth_state(client, state) if state
392
+
393
+ unless pkce_verifier_present?(client)
394
+ return Supabase::Rails::Result.failure(
395
+ Supabase::Rails::AuthError.pkce_missing_verifier
396
+ )
397
+ end
398
+
399
+ response = client.exchange_code_for_session({ auth_code: code })
400
+ session = response.respond_to?(:session) ? response.session : nil
401
+ start_new_session_for(session) if session
402
+ Supabase::Rails::Result.success(session)
403
+ rescue Supabase::Rails::AuthError => e
404
+ Supabase::Rails::Result.failure(e)
405
+ rescue ::Supabase::Auth::Errors::AuthError => e
406
+ Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e))
407
+ end
408
+
409
+ # --- US-016 password reset + user update helpers (FR-W7) ---
410
+
411
+ # Trigger a password-reset email. Returns `Result.success(nil)` —
412
+ # the upstream call's response carries no actionable data; the host
413
+ # typically just renders a "Check your inbox" page. When `redirect_to:`
414
+ # is supplied (the deep-link the recovery email points the user back
415
+ # to), it's validated against `config.supabase.allowed_redirect_origins`
416
+ # via {Web::RedirectValidator} BEFORE the upstream call so an
417
+ # attacker-controlled URL short-circuits to
418
+ # `Result.failure(AuthError(INVALID_REDIRECT))` without bothering
419
+ # Supabase Auth. Upstream errors flow through {Web::AuthErrorMapper}.
420
+ def supabase_reset_password(email:, redirect_to: nil)
421
+ if redirect_to
422
+ Supabase::Rails::Web::RedirectValidator.validate(
423
+ redirect_to,
424
+ allowed_origins: supabase_allowed_redirect_origins
425
+ )
426
+ end
427
+
428
+ options = {}
429
+ options[:redirect_to] = redirect_to if redirect_to
430
+
431
+ client = supabase_auth_client
432
+ client.reset_password_for_email(email, options)
433
+ Supabase::Rails::Result.success(nil)
434
+ rescue Supabase::Rails::AuthError => e
435
+ Supabase::Rails::Result.failure(e)
436
+ rescue ::Supabase::Auth::Errors::AuthError => e
437
+ Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e))
438
+ end
439
+
440
+ # Update the currently-authenticated user's attributes (e.g. `email:`,
441
+ # `password:`, `data:` for `raw_user_meta_data`). Returns
442
+ # `Result.success(user)` on success where `user` is the upstream
443
+ # `Supabase::Auth::Types::User`. Requires an authenticated session —
444
+ # when the encrypted session cookie is missing or carries no access
445
+ # token, fast-fails with
446
+ # `Result.failure(AuthError.session_missing)` (`code: SESSION_MISSING`,
447
+ # `status: 401`) without calling the upstream. The current session is
448
+ # seeded into the per-request auth client's storage so supabase-rb's
449
+ # internal `get_session` resolves (its `@storage` is empty per-request
450
+ # in `:web` mode by design — FR-W6). Other upstream errors flow
451
+ # through {Web::AuthErrorMapper}.
452
+ def supabase_update_user(attributes)
453
+ session_hash = Supabase::Rails::SessionStore.new(supabase_session_config).read(request)
454
+ if session_hash.nil? || access_token_of(session_hash).to_s.empty?
455
+ return Supabase::Rails::Result.failure(Supabase::Rails::AuthError.session_missing)
456
+ end
457
+
458
+ client = supabase_auth_client
459
+ seed_storage_with_session(client, session_hash)
460
+
461
+ response = client.update_user(attributes)
462
+ user = response.respond_to?(:user) ? response.user : response
463
+ Supabase::Rails::Result.success(user)
464
+ rescue ::Supabase::Auth::Errors::AuthError => e
465
+ Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e))
466
+ end
467
+
468
+ # --- Override hooks (host apps customize by redefining) ---
469
+
470
+ def request_authentication
471
+ if supabase_mode == :api
472
+ head :unauthorized
473
+ else
474
+ store_location_for_redirect
475
+ redirect_to new_session_path
476
+ end
477
+ end
478
+
479
+ def after_authentication_url
480
+ stored_location_for_redirect || root_url
481
+ end
482
+
483
+ def store_location_for_redirect
484
+ return unless request.get?
485
+
486
+ session[:return_to_after_authenticating] = request.url
487
+ end
488
+
489
+ def stored_location_for_redirect
490
+ session.delete(:return_to_after_authenticating)
491
+ end
492
+
493
+ private
494
+
495
+ # Runs after `:require_authentication` (FR-W15). Populates
496
+ # `Current.user` and `Current.session` from `supabase_context` so that
497
+ # views can read `Current.user.email` directly — including on actions
498
+ # that opted out via `allow_unauthenticated_access` (where
499
+ # `:require_authentication` was skipped). Uses `||=` so an explicit
500
+ # assignment by `start_new_session_for` earlier in the request stays.
501
+ def populate_current_attributes
502
+ ctx = supabase_context
503
+ return unless ctx
504
+
505
+ ::Current.user = current_user_from_context(ctx) if ::Current.user.nil?
506
+
507
+ if ::Current.session.nil? && ctx.respond_to?(:jwt_claims)
508
+ ::Current.session = ctx.jwt_claims
509
+ end
510
+ end
511
+
512
+ def current_user_from_context(ctx)
513
+ name = supabase_user_model
514
+
515
+ if name && !name.to_s.empty?
516
+ claims = ctx.respond_to?(:jwt_claims) ? ctx.jwt_claims : nil
517
+ return nil if claims.nil? || (claims.respond_to?(:empty?) && claims.empty?)
518
+
519
+ model = name.is_a?(Class) ? name : Object.const_get(name.to_s)
520
+ model.from_supabase(claims)
521
+ else
522
+ ctx.respond_to?(:current_user) ? ctx.current_user : nil
523
+ end
524
+ end
525
+
526
+ def supabase_auth_client
527
+ Supabase::Rails::Web::AuthClientFactory.build(request)
528
+ end
529
+
530
+ # Seeds the per-request auth client's storage with the current
531
+ # session hash from the encrypted cookie so supabase-rb's internal
532
+ # `get_session` (called from `update_user` and other session-bound
533
+ # endpoints) returns the session. The auth client's `@storage` is a
534
+ # fresh `Web::RequestScopedStorage` per request (FR-W6) — empty by
535
+ # design — so without this seeding the upstream raises
536
+ # `AuthSessionMissing` even when the cookie is present and valid.
537
+ # supabase-rb's `_get_valid_session` accepts the Hash directly (no
538
+ # JSON encoding needed); see supabase-rb's `auth/client.rb`.
539
+ def seed_storage_with_session(client, session_hash)
540
+ storage = client.instance_variable_get(:@storage)
541
+ storage_key = client.instance_variable_get(:@storage_key)
542
+ return unless storage && storage_key
543
+
544
+ storage.set_item(storage_key, session_hash)
545
+ end
546
+
547
+ # Reaches into the auth client's `@storage` to set `oauth_state`.
548
+ # supabase-rb doesn't expose `storage` via `attr_reader` (US-005),
549
+ # but the `RequestScopedStorage` we injected at construction has a
550
+ # public `oauth_state=` accessor that the PKCE cookie-mirroring path
551
+ # reads on every `set_item`/`get_item`. Binding before the upstream
552
+ # call is what activates the per-state cookie isolation.
553
+ def bind_oauth_state(client, state)
554
+ storage = client.instance_variable_get(:@storage)
555
+ storage.oauth_state = state if storage.respond_to?(:oauth_state=)
556
+ end
557
+
558
+ # True if the per-request storage (or its signed-cookie fallback)
559
+ # holds a PKCE verifier under the auth client's storage_key. The
560
+ # `RequestScopedStorage#get_item` call returns the in-memory value
561
+ # when present, and otherwise falls back to the signed cookie when
562
+ # `oauth_state` is set. Used by {#supabase_exchange_code_for_session}
563
+ # to fast-fail with `PKCE_ERROR` instead of forwarding a nil
564
+ # `code_verifier` to the upstream.
565
+ def pkce_verifier_present?(client)
566
+ storage = client.instance_variable_get(:@storage)
567
+ return false if storage.nil?
568
+
569
+ storage_key = client.instance_variable_get(:@storage_key)
570
+ verifier = storage.get_item("#{storage_key}-code-verifier")
571
+ !verifier.nil? && !verifier.to_s.empty?
572
+ end
573
+
574
+ # Appends (or replaces) a `state=<state>` query param on the
575
+ # caller-supplied `redirect_to` URL so the OAuth provider echoes it
576
+ # back to the callback. Falls back to the original string on a
577
+ # malformed URI so we never raise out of `supabase_sign_in_with_oauth`
578
+ # — host apps that pass a bad `redirect_to` get a useful upstream
579
+ # error rather than a `URI::InvalidURIError`.
580
+ def append_state_to_redirect(redirect_to, state)
581
+ return redirect_to if redirect_to.nil?
582
+
583
+ uri = URI.parse(redirect_to.to_s)
584
+ query = URI.decode_www_form(uri.query.to_s).reject { |k, _| k == "state" }
585
+ query << ["state", state]
586
+ uri.query = URI.encode_www_form(query)
587
+ uri.to_s
588
+ rescue URI::InvalidURIError
589
+ redirect_to
590
+ end
591
+
592
+ # Sign-in error policy (US-012 AC-4): all 4xx upstream errors are
593
+ # masked to the generic `AuthError.invalid_credentials` ("Invalid
594
+ # credentials", 401) so the response leaks no information about
595
+ # which field was wrong. 5xx surface verbatim through the mapper so
596
+ # the host can show a "try again" message.
597
+ def translate_sign_in_error(err)
598
+ mapped = Supabase::Rails::Web::AuthErrorMapper.translate(err)
599
+ return mapped if mapped.status.to_i >= 500
600
+
601
+ Supabase::Rails::AuthError.invalid_credentials
602
+ end
603
+
604
+ # Sign-up error policy (US-012 AC-3): preserves the mapper's
605
+ # specific code/status for failures the user can act on
606
+ # (`WEAK_PASSWORD`, `PKCE_ERROR`, `SESSION_MISSING`, weak-password
607
+ # 422, 5xx infra). Generic 4xx credential failures (bad email,
608
+ # already-registered conflicts surfacing as 400/401) are masked to
609
+ # `INVALID_CREDENTIALS` for parity with the sign-in helper.
610
+ def translate_sign_up_error(err)
611
+ mapped = Supabase::Rails::Web::AuthErrorMapper.translate(err)
612
+ return mapped if mapped.status.to_i >= 500
613
+
614
+ case mapped.code
615
+ when Supabase::Rails::AuthError::WEAK_PASSWORD,
616
+ Supabase::Rails::AuthError::PKCE_ERROR,
617
+ Supabase::Rails::AuthError::SESSION_MISSING
618
+ mapped
619
+ else
620
+ Supabase::Rails::AuthError.invalid_credentials
621
+ end
622
+ end
623
+
624
+ # Resume the request's session by populating `Current.user` from the
625
+ # middleware-installed context. When `config.supabase.user_model` is
626
+ # set (FR-W14 shadow user), the context's `current_user` is nil by
627
+ # design — {Context.build_context_result} skips the value-object build
628
+ # so the host's AR record is the single source of truth. In that mode
629
+ # we re-derive the user per-request via `<Model>.from_supabase(claims)`
630
+ # — a cheap by-PK lookup that returns the same AR row a `belongs_to
631
+ # :user` query would resolve. When `user_model` is unset, we fall back
632
+ # to `ctx.current_user` (the `Supabase::Rails::User` value object).
633
+ def resume_session
634
+ ctx = supabase_context
635
+ return false if ctx.nil?
636
+
637
+ user = current_user_from_context(ctx)
638
+ return false if user.nil?
639
+
640
+ ::Current.user = user
641
+ true
642
+ end
643
+
644
+ def attempt_upstream_sign_out(scope)
645
+ return unless supabase_context
646
+ return unless supabase_context.respond_to?(:supabase)
647
+
648
+ client = supabase_context.supabase
649
+ return if client.nil?
650
+
651
+ client.auth.sign_out(scope: scope)
652
+ rescue ::Supabase::Auth::Errors::AuthError
653
+ # Best-effort — cookie clear is the source of truth.
654
+ end
655
+
656
+ def supabase_context
657
+ request.env[Supabase::Rails::CONTEXT_KEY]
658
+ end
659
+
660
+ def supabase_mode
661
+ cfg = supabase_railtie_config
662
+ cfg && cfg.respond_to?(:mode) ? (cfg.mode || :api) : :api
663
+ end
664
+
665
+ def supabase_session_config
666
+ cfg = supabase_railtie_config
667
+ cfg && cfg.respond_to?(:session) ? cfg.session : nil
668
+ end
669
+
670
+ def supabase_user_model
671
+ cfg = supabase_railtie_config
672
+ cfg && cfg.respond_to?(:user_model) ? cfg.user_model : nil
673
+ end
674
+
675
+ # Resolves the OAuth redirect allowlist (FR-W11). Reads
676
+ # `config.supabase.allowed_redirect_origins` and falls back to
677
+ # `[request.host]` (same-origin only) when the config is empty so a
678
+ # host that hasn't configured the list still gets a safe default.
679
+ # The fallback derives the scheme from the request so `:web` apps on
680
+ # plain HTTP in development work too.
681
+ def supabase_allowed_redirect_origins
682
+ cfg = supabase_railtie_config
683
+ configured = cfg.respond_to?(:allowed_redirect_origins) ? cfg.allowed_redirect_origins : nil
684
+ return configured if configured.is_a?(Array) && !configured.empty?
685
+
686
+ host = request.respond_to?(:host) ? request.host : nil
687
+ return [] if host.nil? || host.to_s.empty?
688
+
689
+ scheme = request.respond_to?(:scheme) ? request.scheme : "https"
690
+ port = request.respond_to?(:port) ? request.port : nil
691
+ origin = "#{scheme}://#{host}"
692
+ origin = "#{origin}:#{port}" if port && !default_port_for?(scheme, port)
693
+ [origin]
694
+ end
695
+
696
+ def default_port_for?(scheme, port)
697
+ case scheme.to_s.downcase
698
+ when "https" then port.to_i == 443
699
+ when "http" then port.to_i == 80
700
+ else false
701
+ end
702
+ end
703
+
704
+ def supabase_railtie_config
705
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
706
+
707
+ ::Rails.application.config.supabase
708
+ rescue StandardError
709
+ nil
710
+ end
711
+
712
+ def build_current_user(supabase_session)
713
+ claims = jwt_claims_from(supabase_session)
714
+ name = supabase_user_model
715
+
716
+ if name && !name.to_s.empty?
717
+ model = name.is_a?(Class) ? name : Object.const_get(name.to_s)
718
+ model.from_supabase(claims)
719
+ else
720
+ Supabase::Rails::User.from_claims(claims)
721
+ end
722
+ end
723
+
724
+ def jwt_claims_from(supabase_session)
725
+ token = access_token_of(supabase_session)
726
+ return {} if token.nil? || token.to_s.empty?
727
+
728
+ payload, _header = ::JWT.decode(token, nil, false)
729
+ payload.is_a?(Hash) ? payload : {}
730
+ rescue StandardError
731
+ {}
732
+ end
733
+
734
+ def access_token_of(supabase_session)
735
+ if supabase_session.is_a?(Hash)
736
+ return supabase_session["access_token"] || supabase_session[:access_token]
737
+ end
738
+ return supabase_session.access_token if supabase_session.respond_to?(:access_token)
739
+
740
+ nil
741
+ end
742
+ end
743
+ end
744
+ end