Module: Supabase::Rails::Authentication
- Extended by:
- ActiveSupport::Concern
- Included in:
- BaseController
- Defined in:
- lib/supabase/rails/authentication.rb
Overview
Rails-8-shape Authentication concern (FR-W5, US-010).
‘include Supabase::Rails::Authentication` gives a controller the same surface as Rails 8’s built-in ‘bin/rails g authentication` output:
* `before_action :require_authentication` installed on inclusion
* `helper_method :authenticated?` registered on inclusion
* `allow_unauthenticated_access(only: ..., except: ...)` class macro
that delegates to `skip_before_action :require_authentication`
* Instance methods `authenticated?`, `require_authentication`,
`start_new_session_for(session)`, `terminate_session(scope:)`,
`authenticate_with_supabase(email:, password:)`, plus the
override hooks `request_authentication`, `after_authentication_url`,
`store_location_for_redirect`, `stored_location_for_redirect`.
Backing implementation talks to Supabase Auth via the per-request ‘Supabase::Auth::Client` (Web::AuthClientFactory) and the encrypted session cookie (SessionStore). `Current.user` and `Current.session` are populated on resume / `start_new_session_for` and cleared on `terminate_session`. The host app is expected to provide `Current` (`app/models/current.rb` from the Rails 8 generator).
‘:api` mode: the concern still mounts so a single controller class can serve both surfaces, but `start_new_session_for` raises (cookies don’t apply), ‘require_authentication` 401s instead of redirecting, and `terminate_session` is a local no-op.
Class Method Summary collapse
-
.expose_current_user? ⇒ Boolean
Whether ‘current_user` should be exposed as a `helper_method` on the including controller.
- .railtie_config ⇒ Object
-
.redact_email(email) ⇒ Object
Masks the local part of an email address for safe logging.
Instance Method Summary collapse
- #after_authentication_url ⇒ Object
-
#authenticate_with_supabase(email:, password:) ⇒ Object
Mirrors Rails 8’s ‘User.authenticate_by(email:, password:)`: returns a Supabase session on success, `nil` on a 4xx authentication failure (invalid credentials, weak password, etc), and raises only on upstream 5xx so the controller can surface a “try again” message.
-
#authenticated? ⇒ Boolean
— Rails-8 parity surface —.
-
#current_user ⇒ Object
Devise muscle-memory alias for ‘Current.user`.
-
#request_authentication ⇒ Object
— Override hooks (host apps customize by redefining) —.
- #require_authentication ⇒ Object
-
#start_new_session_for(supabase_session) ⇒ Object
Persists a Supabase session in the encrypted cookie and populates ‘Current.user` / `Current.session`.
- #store_location_for_redirect ⇒ Object
- #stored_location_for_redirect ⇒ Object
-
#supabase_exchange_code_for_session(code:, state: nil, redirect_to: nil) ⇒ Object
Complete an OAuth sign-in by exchanging the auth code for a session.
-
#supabase_resend(type:, email: nil, phone: nil) ⇒ Object
Resend an OTP / magic link for the given identifier + ‘type:`.
-
#supabase_reset_password(email:, redirect_to: nil) ⇒ Object
Trigger a password-reset email.
-
#supabase_sign_in_with_oauth(provider:, redirect_to:, scopes: nil) ⇒ Object
Begin an OAuth sign-in.
-
#supabase_sign_in_with_otp(email: nil, phone: nil, **opts) ⇒ Object
Send an OTP / magic link.
-
#supabase_sign_in_with_password(email:, password:) ⇒ Object
Sign-in with email/password.
-
#supabase_sign_up(email:, password:, data: nil, redirect_to: nil) ⇒ Object
Sign-up with email/password.
-
#supabase_update_user(attributes) ⇒ Object
Update the currently-authenticated user’s attributes (e.g. ‘email:`, `password:`, `data:` for `raw_user_meta_data`).
-
#supabase_verify_otp(token:, type:, email: nil, phone: nil) ⇒ Object
Verify an OTP token (‘token:`) of a given `type:` (e.g. `“email”`, `“sms”`, `“magiclink”`, `“recovery”`, `“invite”`, `“signup”`).
-
#terminate_session(scope: :local) ⇒ Object
Best-effort upstream sign-out, then clears the encrypted cookie and ‘Current`.
Class Method Details
.expose_current_user? ⇒ Boolean
Whether ‘current_user` should be exposed as a `helper_method` on the including controller. Resolved at include time from the Railtie config:
config.supabase.expose_current_user = true | false | nil
When ‘nil` (the default), derives from `config.supabase.mode`: `:web` → true (Devise muscle memory in views), `:api` → false (avoid clashing with apps that already define `current_user` for API consumers). Returns `false` when Rails is not loaded so the gem can still be included in non-Rails specs.
77 78 79 80 81 82 83 84 85 86 |
# File 'lib/supabase/rails/authentication.rb', line 77 def self.expose_current_user? cfg = railtie_config return false if cfg.nil? explicit = cfg.respond_to?(:expose_current_user) ? cfg.expose_current_user : nil return !!explicit unless explicit.nil? mode = cfg.respond_to?(:mode) ? cfg.mode : :api mode == :web end |
.railtie_config ⇒ Object
88 89 90 91 92 93 94 |
# File 'lib/supabase/rails/authentication.rb', line 88 def self.railtie_config return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application ::Rails.application.config.supabase rescue StandardError nil end |
.redact_email(email) ⇒ Object
Masks the local part of an email address for safe logging. ‘alice@example.com` → `a***@example.com`. Returns a placeholder when the input is nil/empty/malformed so the raw value never leaks to the log line.
100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/supabase/rails/authentication.rb', line 100 def self.redact_email(email) return "<missing>" if email.nil? str = email.to_s return "<blank>" if str.empty? local, _, domain = str.rpartition("@") return "<malformed>" if local.empty? || domain.empty? "#{local[0]}***@#{domain}" end |
Instance Method Details
#after_authentication_url ⇒ Object
479 480 481 |
# File 'lib/supabase/rails/authentication.rb', line 479 def after_authentication_url stored_location_for_redirect || root_url end |
#authenticate_with_supabase(email:, password:) ⇒ Object
Mirrors Rails 8’s ‘User.authenticate_by(email:, password:)`: returns a Supabase session on success, `nil` on a 4xx authentication failure (invalid credentials, weak password, etc), and raises only on upstream 5xx so the controller can surface a “try again” message.
169 170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/supabase/rails/authentication.rb', line 169 def authenticate_with_supabase(email:, password:) client = Supabase::Rails::Web::AuthClientFactory.build(request) client.sign_in_with_password(email: email, password: password).session rescue ::Supabase::Auth::Errors::AuthError => e mapped = Supabase::Rails::Web::AuthErrorMapper.translate(e) raise mapped if mapped.status.to_i >= 500 Supabase::Rails::Logging.log( :warn, "[supabase.rails.sign_in_failure] code=#{mapped.code} email=#{Supabase::Rails::Authentication.redact_email(email)}" ) nil end |
#authenticated? ⇒ Boolean
— Rails-8 parity surface —
114 115 116 |
# File 'lib/supabase/rails/authentication.rb', line 114 def authenticated? ::Current.user.present? end |
#current_user ⇒ Object
Devise muscle-memory alias for ‘Current.user`. Always defined as an instance method; only exposed to views via `helper_method` when `config.supabase.expose_current_user` resolves to true (default in `:web` mode). When the host app defines its own `current_user` on a parent controller, Ruby’s method lookup picks the host’s first.
123 124 125 |
# File 'lib/supabase/rails/authentication.rb', line 123 def current_user ::Current.user end |
#request_authentication ⇒ Object
— Override hooks (host apps customize by redefining) —
470 471 472 473 474 475 476 477 |
# File 'lib/supabase/rails/authentication.rb', line 470 def request_authentication if supabase_mode == :api head :unauthorized else store_location_for_redirect redirect_to new_session_path end end |
#require_authentication ⇒ Object
127 128 129 |
# File 'lib/supabase/rails/authentication.rb', line 127 def require_authentication resume_session || request_authentication end |
#start_new_session_for(supabase_session) ⇒ Object
Persists a Supabase session in the encrypted cookie and populates ‘Current.user` / `Current.session`. In `:api` mode this raises a ConfigError because cookies don’t apply — clients send JWTs via the ‘Authorization: Bearer` header.
135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/supabase/rails/authentication.rb', line 135 def start_new_session_for(supabase_session) if supabase_mode == :api raise Supabase::Rails::ConfigError. end Supabase::Rails::SessionStore.new(supabase_session_config) .write(request, supabase_session) ::Current.user = build_current_user(supabase_session) ::Current.session = supabase_session supabase_session end |
#store_location_for_redirect ⇒ Object
483 484 485 486 487 |
# File 'lib/supabase/rails/authentication.rb', line 483 def store_location_for_redirect return unless request.get? session[:return_to_after_authenticating] = request.url end |
#stored_location_for_redirect ⇒ Object
489 490 491 |
# File 'lib/supabase/rails/authentication.rb', line 489 def stored_location_for_redirect session.delete(:return_to_after_authenticating) end |
#supabase_exchange_code_for_session(code:, state: nil, redirect_to: nil) ⇒ Object
Complete an OAuth sign-in by exchanging the auth code for a session. ‘code:` is the OAuth code from the callback URL; `state:` is the value the provider echoed back from the start leg — used to find the matching `sb-oauth-state-<state>` signed cookie that holds the PKCE verifier. When the cookie is missing or `state` doesn’t match any issued state, returns ‘Result.failure(AuthError.pkce_missing_verifier)` (`code: PKCE_ERROR`, `status: 400`) without bothering the upstream. On success, calls `start_new_session_for(session)` internally so `Current.user` / `Current.session` are populated and the encrypted cookie is written.
382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 |
# File 'lib/supabase/rails/authentication.rb', line 382 def supabase_exchange_code_for_session(code:, state: nil, redirect_to: nil) if redirect_to Supabase::Rails::Web::RedirectValidator.validate( redirect_to, allowed_origins: supabase_allowed_redirect_origins ) end client = supabase_auth_client bind_oauth_state(client, state) if state unless pkce_verifier_present?(client) return Supabase::Rails::Result.failure( Supabase::Rails::AuthError.pkce_missing_verifier ) end response = client.exchange_code_for_session({ auth_code: code }) session = response.respond_to?(:session) ? response.session : nil start_new_session_for(session) if session Supabase::Rails::Result.success(session) rescue Supabase::Rails::AuthError => e Supabase::Rails::Result.failure(e) rescue ::Supabase::Auth::Errors::AuthError => e Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e)) end |
#supabase_resend(type:, email: nil, phone: nil) ⇒ Object
Resend an OTP / magic link for the given identifier + ‘type:`. Returns `Result.success(nil)` — the upstream `AuthOtpResponse` carries no actionable data; the host typically just re-renders the “Check your inbox” page.
306 307 308 309 310 311 312 313 314 315 316 |
# File 'lib/supabase/rails/authentication.rb', line 306 def supabase_resend(type:, email: nil, phone: nil) client = supabase_auth_client credentials = { type: type } credentials[:email] = email if email credentials[:phone] = phone if phone client.resend(credentials) Supabase::Rails::Result.success(nil) rescue ::Supabase::Auth::Errors::AuthError => e Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e)) end |
#supabase_reset_password(email:, redirect_to: nil) ⇒ Object
Trigger a password-reset email. Returns ‘Result.success(nil)` —the upstream call’s response carries no actionable data; the host typically just renders a “Check your inbox” page. When ‘redirect_to:` is supplied (the deep-link the recovery email points the user back to), it’s validated against ‘config.supabase.allowed_redirect_origins` via Web::RedirectValidator BEFORE the upstream call so an attacker-controlled URL short-circuits to `Result.failure(AuthError(INVALID_REDIRECT))` without bothering Supabase Auth. Upstream errors flow through Web::AuthErrorMapper.
420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 |
# File 'lib/supabase/rails/authentication.rb', line 420 def supabase_reset_password(email:, redirect_to: nil) if redirect_to Supabase::Rails::Web::RedirectValidator.validate( redirect_to, allowed_origins: supabase_allowed_redirect_origins ) end = {} [:redirect_to] = redirect_to if redirect_to client = supabase_auth_client client.reset_password_for_email(email, ) Supabase::Rails::Result.success(nil) rescue Supabase::Rails::AuthError => e Supabase::Rails::Result.failure(e) rescue ::Supabase::Auth::Errors::AuthError => e Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e)) end |
#supabase_sign_in_with_oauth(provider:, redirect_to:, scopes: nil) ⇒ Object
Begin an OAuth sign-in. Returns ‘Result.success(<authorize_url>)` on success — the controller is expected to `redirect_to result.value`. The PKCE verifier is stashed in a signed cookie keyed by a freshly generated `state`; that same `state` is appended as a query param to the `redirect_to:` URL so the OAuth provider echoes it back on the callback, where #supabase_exchange_code_for_session can pick it up. `scopes:` is forwarded verbatim to the OAuth provider via supabase-rb (space-separated string per the OAuth 2.0 spec).
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 |
# File 'lib/supabase/rails/authentication.rb', line 351 def supabase_sign_in_with_oauth(provider:, redirect_to:, scopes: nil) Supabase::Rails::Web::RedirectValidator.validate( redirect_to, allowed_origins: supabase_allowed_redirect_origins ) state = SecureRandom.urlsafe_base64(32) client = supabase_auth_client bind_oauth_state(client, state) = { redirect_to: append_state_to_redirect(redirect_to, state) } [:scopes] = scopes if scopes response = client.sign_in_with_oauth({ provider: provider, options: }) Supabase::Rails::Result.success(response.url) rescue Supabase::Rails::AuthError => e Supabase::Rails::Result.failure(e) rescue ::Supabase::Auth::Errors::AuthError => e Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e)) end |
#supabase_sign_in_with_otp(email: nil, phone: nil, **opts) ⇒ Object
Send an OTP / magic link. Provide either ‘email:` or `phone:`. `**opts` is forwarded as the upstream `:options` hash and accepts the full supabase-rb surface: `email_redirect_to:`, `should_create_user:`, `data:`, `channel:` (“sms” | “whatsapp”), `captcha_token:`. Returns `Result.success(AuthOtpResponse)` (delivery is pending —no session is established yet).
270 271 272 273 274 275 276 277 278 279 280 281 |
# File 'lib/supabase/rails/authentication.rb', line 270 def supabase_sign_in_with_otp(email: nil, phone: nil, **opts) client = supabase_auth_client credentials = {} credentials[:email] = email if email credentials[:phone] = phone if phone credentials[:options] = opts unless opts.empty? response = client.sign_in_with_otp(credentials) Supabase::Rails::Result.success(response) rescue ::Supabase::Auth::Errors::AuthError => e Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e)) end |
#supabase_sign_in_with_password(email:, password:) ⇒ Object
Sign-in with email/password. Returns ‘Result.success(session)` on success (and calls `start_new_session_for(session)` internally so `Current.user` / `Current.session` are populated and the encrypted cookie is written). On a 4xx upstream failure returns `Result.failure(AuthError.invalid_credentials)` — a generic “Invalid credentials” message regardless of whether email or password was the bad field (prevents user enumeration). On a 5xx upstream failure returns `Result.failure` with the upstream-mapped error (status 503, code `AUTH_UPSTREAM_ERROR`).
206 207 208 209 210 211 212 213 214 215 216 217 218 219 |
# File 'lib/supabase/rails/authentication.rb', line 206 def supabase_sign_in_with_password(email:, password:) client = supabase_auth_client response = client.sign_in_with_password(email: email, password: password) session = response.session start_new_session_for(session) if session Supabase::Rails::Result.success(session) rescue ::Supabase::Auth::Errors::AuthError => e failure = translate_sign_in_error(e) Supabase::Rails::Logging.log( :warn, "[supabase.rails.sign_in_failure] code=#{failure.code} email=#{Supabase::Rails::Authentication.redact_email(email)}" ) Supabase::Rails::Result.failure(failure) end |
#supabase_sign_up(email:, password:, data: nil, redirect_to: nil) ⇒ Object
Sign-up with email/password. Returns ‘Result.success(response)` on success — value is the upstream `Supabase::Auth::Types::AuthResponse` (`.user` always present, `.session` present when auto-sign-in is enabled, `nil` when email confirmation is pending). When a session is present `start_new_session_for(session)` is called internally so the cookie + `Current` are written. `data:` populates user metadata (`raw_user_meta_data`) and `redirect_to:` sets the post-confirmation redirect URL. Errors flow through Web::AuthErrorMapper so callers can branch on stable codes — notably `WEAK_PASSWORD` (422) is preserved so the host can render a specific UI; other 4xx errors are masked to `INVALID_CREDENTIALS` (401), and 5xx surfaces as `AUTH_UPSTREAM_ERROR` (503).
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
# File 'lib/supabase/rails/authentication.rb', line 233 def supabase_sign_up(email:, password:, data: nil, redirect_to: nil) client = supabase_auth_client = {} [:data] = data if data [:redirect_to] = redirect_to if redirect_to credentials = { email: email, password: password } credentials[:options] = unless .empty? response = client.sign_up(credentials) session = response.respond_to?(:session) ? response.session : nil start_new_session_for(session) if session Supabase::Rails::Result.success(response) rescue ::Supabase::Auth::Errors::AuthError => e Supabase::Rails::Result.failure(translate_sign_up_error(e)) end |
#supabase_update_user(attributes) ⇒ Object
Update the currently-authenticated user’s attributes (e.g. ‘email:`, `password:`, `data:` for `raw_user_meta_data`). Returns `Result.success(user)` on success where `user` is the upstream `Supabase::Auth::Types::User`. Requires an authenticated session —when the encrypted session cookie is missing or carries no access token, fast-fails with `Result.failure(AuthError.session_missing)` (`code: SESSION_MISSING`, `status: 401`) without calling the upstream. The current session is seeded into the per-request auth client’s storage so supabase-rb’s internal ‘get_session` resolves (its `@storage` is empty per-request in `:web` mode by design — FR-W6). Other upstream errors flow through Web::AuthErrorMapper.
452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 |
# File 'lib/supabase/rails/authentication.rb', line 452 def supabase_update_user(attributes) session_hash = Supabase::Rails::SessionStore.new(supabase_session_config).read(request) if session_hash.nil? || access_token_of(session_hash).to_s.empty? return Supabase::Rails::Result.failure(Supabase::Rails::AuthError.session_missing) end client = supabase_auth_client seed_storage_with_session(client, session_hash) response = client.update_user(attributes) user = response.respond_to?(:user) ? response.user : response Supabase::Rails::Result.success(user) rescue ::Supabase::Auth::Errors::AuthError => e Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e)) end |
#supabase_verify_otp(token:, type:, email: nil, phone: nil) ⇒ Object
Verify an OTP token (‘token:`) of a given `type:` (e.g. `“email”`, `“sms”`, `“magiclink”`, `“recovery”`, `“invite”`, `“signup”`). Returns `Result.success(session)` and calls `start_new_session_for` internally on success so `Current.user` / `Current.session` are populated and the encrypted cookie is written.
288 289 290 291 292 293 294 295 296 297 298 299 300 |
# File 'lib/supabase/rails/authentication.rb', line 288 def supabase_verify_otp(token:, type:, email: nil, phone: nil) client = supabase_auth_client params = { token: token, type: type } params[:email] = email if email params[:phone] = phone if phone response = client.verify_otp(params) session = response.respond_to?(:session) ? response.session : nil start_new_session_for(session) if session Supabase::Rails::Result.success(session) rescue ::Supabase::Auth::Errors::AuthError => e Supabase::Rails::Result.failure(Supabase::Rails::Web::AuthErrorMapper.translate(e)) end |
#terminate_session(scope: :local) ⇒ Object
Best-effort upstream sign-out, then clears the encrypted cookie and ‘Current`. `auth.sign_out` is rescued because the local clear is the source of truth — a failed upstream call still produces a signed-out user locally. In `:api` mode this is a local no-op (no cookie to clear; clients drop the JWT on their side).
153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/supabase/rails/authentication.rb', line 153 def terminate_session(scope: :local) return if supabase_mode == :api attempt_upstream_sign_out(scope) if ::Current.session ensure unless supabase_mode == :api Supabase::Rails::SessionStore.new(supabase_session_config).clear(request) ::Current.user = nil ::Current.session = nil end end |