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

Instance Method Summary collapse

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.

Returns:

  • (Boolean)


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_configObject



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_urlObject



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.(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 —

Returns:

  • (Boolean)


114
115
116
# File 'lib/supabase/rails/authentication.rb', line 114

def authenticated?
  ::Current.user.present?
end

#current_userObject

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_authenticationObject

— 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_authenticationObject



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.api_mode_cookie_unsupported
  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_redirectObject



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_redirectObject



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

  options = {}
  options[:redirect_to] = redirect_to if redirect_to

  client = supabase_auth_client
  client.reset_password_for_email(email, options)
  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 (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)

  options = { redirect_to: append_state_to_redirect(redirect_to, state) }
  options[:scopes] = scopes if scopes

  response = client.({ provider: provider, options: 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 (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.(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 (email:, password:)
  client = supabase_auth_client
  response = client.(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 = (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 (email:, password:, data: nil, redirect_to: nil)
  client = supabase_auth_client
  options = {}
  options[:data] = data if data
  options[:redirect_to] = redirect_to if redirect_to

  credentials = { email: email, password: password }
  credentials[:options] = options unless options.empty?

  response = client.(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((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