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,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
|