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
@@ -10,7 +10,8 @@ module Supabase
10
10
  SupabaseContext = Data.define(
11
11
  :supabase, :supabase_admin,
12
12
  :user_claims, :jwt_claims,
13
- :auth_mode, :auth_key_name
13
+ :auth_mode, :auth_key_name,
14
+ :current_user
14
15
  )
15
16
 
16
17
  class Result
@@ -38,7 +39,7 @@ module Supabase
38
39
  end
39
40
  end
40
41
 
41
- def self.create_context(request, auth: :user, env: nil, supabase_options: nil)
42
+ def self.create_context(request, auth: :user, env: nil, supabase_options: nil, user_model: nil)
42
43
  headers = extract_headers(request)
43
44
  credentials = Core.extract_credentials(headers)
44
45
 
@@ -50,10 +51,15 @@ module Supabase
50
51
  return Result.failure(e)
51
52
  end
52
53
 
53
- build_context_result(auth_result, env: env, supabase_options: supabase_options)
54
+ build_context_result(
55
+ auth_result,
56
+ env: env,
57
+ supabase_options: supabase_options,
58
+ user_model: user_model
59
+ )
54
60
  end
55
61
 
56
- def self.build_context_result(auth_result, env:, supabase_options:)
62
+ def self.build_context_result(auth_result, env:, supabase_options:, user_model: nil)
57
63
  publishable_key_name = auth_result.auth_mode == :publishable ? auth_result.key_name : nil
58
64
  supabase = Core.create_context_client(
59
65
  auth: { token: auth_result.token, key_name: publishable_key_name },
@@ -68,6 +74,16 @@ module Supabase
68
74
  supabase_options: supabase_options
69
75
  )
70
76
 
77
+ # `current_user` is the default `Current.user` value (FR-W15).
78
+ # Built only when `user_model` is unset (the host hasn't opted into a
79
+ # shadow AR model via FR-W14). When `user_model` is set, the
80
+ # Authentication concern populates `Current.user` with the AR record
81
+ # in `start_new_session_for` and this stays nil.
82
+ current_user =
83
+ if user_model.nil? && auth_result.jwt_claims.is_a?(Hash)
84
+ User.from_claims(auth_result.jwt_claims)
85
+ end
86
+
71
87
  Result.success(
72
88
  SupabaseContext.new(
73
89
  supabase: supabase,
@@ -75,7 +91,8 @@ module Supabase
75
91
  user_claims: auth_result.user_claims,
76
92
  jwt_claims: auth_result.jwt_claims,
77
93
  auth_mode: auth_result.auth_mode,
78
- auth_key_name: auth_result.key_name
94
+ auth_key_name: auth_result.key_name,
95
+ current_user: current_user
79
96
  )
80
97
  )
81
98
  rescue EnvError => e
@@ -15,8 +15,30 @@ module Supabase
15
15
  request.env[Rails::CONTEXT_KEY]
16
16
  end
17
17
 
18
- def verify_supabase_auth(auth: nil, env: nil, supabase_options: nil)
19
- if auth.nil? && env.nil? && supabase_options.nil?
18
+ # `mode: :api` forces header-only credential extraction (FR-W1) even
19
+ # when the global mode is `:web`. Use this in `/api/v1/*` controllers
20
+ # inside a `:web` monolith so a single gem handles both surfaces — the
21
+ # cookie context (anonymous or user) set by the middleware is
22
+ # discarded and `Rails.create_context` re-runs against the request's
23
+ # `Authorization: Bearer` header. Without `mode: :api`, a request that
24
+ # carries only a session cookie would authenticate via the cookie
25
+ # (web-mode behavior); with it, the request must present a JWT in the
26
+ # header or `AuthError.invalid_credentials` is raised.
27
+ def verify_supabase_auth(mode: nil, auth: nil, env: nil, supabase_options: nil)
28
+ unless mode.nil? || Middleware::VALID_MODES.include?(mode)
29
+ raise ConfigError.invalid_mode(mode)
30
+ end
31
+
32
+ if mode.nil? && auth.nil? && env.nil? && supabase_options.nil?
33
+ raise AuthError.invalid_credentials if supabase_context.nil?
34
+
35
+ return supabase_context
36
+ end
37
+
38
+ # `mode: :web` is the no-op case — the middleware already extracted
39
+ # via cookie. Return the existing context (or raise) so a controller
40
+ # can declare web-mode intent without re-running extraction.
41
+ if mode == :web && auth.nil? && env.nil? && supabase_options.nil?
20
42
  raise AuthError.invalid_credentials if supabase_context.nil?
21
43
 
22
44
  return supabase_context
@@ -128,6 +128,15 @@ module Supabase
128
128
  safe_headers = safe_headers.merge("Authorization" => "Bearer #{token}") if token && !token.to_s.empty?
129
129
 
130
130
  auth_opts = option_value(supabase_options, :auth) || {}
131
+ # `auto_refresh_token: false` is a hard invariant for every
132
+ # `Supabase::Auth::Client` constructed by this gem (PRD §FR-W10,
133
+ # US-008). `supabase-rb` would otherwise spawn a background Timer
134
+ # thread per session that outlives the request and leaks across
135
+ # Puma worker fork cycles. Refresh is performed inline by the
136
+ # web-mode credential strategy (`Web::CookieCredentialStrategy`).
137
+ # `spec/supabase/rails/auto_refresh_token_invariant_spec.rb`
138
+ # enforces this by grepping the gem source — do not relax it
139
+ # without removing that spec.
131
140
  auth_opts = auth_opts.merge(
132
141
  persist_session: false,
133
142
  auto_refresh_token: false,
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch"
4
+ require "rails/engine"
5
+ require_relative "routes"
6
+
7
+ module Supabase
8
+ module Rails
9
+ # Internal `::Rails::Engine` (FR-W8 / US-017). Its only job is to add the
10
+ # gem's top-level `app/controllers`, `app/views`, and `config/locales`
11
+ # directories to the host app's autoload + I18n load paths. It is NOT
12
+ # mounted by users — there's no `mount Supabase::Rails::Engine`. Routes
13
+ # for the auth surface come from the `supabase_authentication_routes`
14
+ # helper (FR-W12 / US-022) that the install generator emits into the
15
+ # host's `config/routes.rb`.
16
+ #
17
+ # We deliberately do NOT call `isolate_namespace` so that the gem's
18
+ # controllers inherit from the host's `::ApplicationController` (picking
19
+ # up CSRF, layouts, helpers, and any global before_actions) and so the
20
+ # views resolve under the host's view-path lookup rules.
21
+ #
22
+ # `Engine.root` is left at the framework default — Rails walks up from
23
+ # this file until it finds the gem root (the directory containing
24
+ # `lib/supabase/rails.rb`), which is exactly what we want.
25
+ class Engine < ::Rails::Engine
26
+ # Install `supabase_authentication_routes` onto the routing DSL.
27
+ # Runs before `:set_routes_reloader` (the Rails-internal initializer
28
+ # that loads `config/routes.rb`), so the helper is available the first
29
+ # time the host's routes are drawn at boot.
30
+ initializer "supabase.rails.routes_helper", before: :set_routes_reloader do
31
+ ActionDispatch::Routing::Mapper.include(Supabase::Rails::Routes)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -51,10 +51,46 @@ module Supabase
51
51
  end
52
52
  end
53
53
 
54
+ class ConfigError < StandardError
55
+ CONFIG_GENERIC_ERROR = "CONFIG_ERROR"
56
+ INVALID_MODE = "INVALID_MODE"
57
+ API_MODE_COOKIE_UNSUPPORTED = "API_MODE_COOKIE_UNSUPPORTED"
58
+
59
+ attr_reader :code
60
+
61
+ def initialize(message, code = CONFIG_GENERIC_ERROR)
62
+ super(message)
63
+ @code = code
64
+ end
65
+
66
+ def self.invalid_mode(value)
67
+ new(
68
+ %(Invalid `config.supabase.mode = #{value.inspect}`. Must be :api or :web.),
69
+ INVALID_MODE
70
+ )
71
+ end
72
+
73
+ def self.api_mode_cookie_unsupported
74
+ new(
75
+ "`start_new_session_for` is not supported in :api mode. Cookies don't apply " \
76
+ "to JWT-bearer flows — clients send the `Authorization: Bearer` header instead.",
77
+ API_MODE_COOKIE_UNSUPPORTED
78
+ )
79
+ end
80
+ end
81
+
54
82
  class AuthError < StandardError
55
83
  AUTH_GENERIC_ERROR = "AUTH_ERROR"
56
84
  INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
57
85
  CREATE_SUPABASE_CLIENT_ERROR = "CREATE_SUPABASE_CLIENT_ERROR"
86
+ REFRESH_UNAVAILABLE = "REFRESH_UNAVAILABLE"
87
+ PKCE_ERROR = "PKCE_ERROR"
88
+ WEAK_PASSWORD = "WEAK_PASSWORD"
89
+ SESSION_MISSING = "SESSION_MISSING"
90
+ INVALID_REDIRECT = "INVALID_REDIRECT"
91
+ AUTH_API_ERROR = "AUTH_API_ERROR"
92
+ AUTH_UPSTREAM_ERROR = "AUTH_UPSTREAM_ERROR"
93
+ AUTH_RETRYABLE = "AUTH_RETRYABLE"
58
94
 
59
95
  attr_reader :code, :status
60
96
 
@@ -71,6 +107,38 @@ module Supabase
71
107
  def self.create_supabase_client_error
72
108
  new("Failed to create Supabase client", CREATE_SUPABASE_CLIENT_ERROR, 500)
73
109
  end
110
+
111
+ def self.refresh_unavailable
112
+ new(
113
+ "Supabase Auth is temporarily unavailable. Please try again.",
114
+ REFRESH_UNAVAILABLE,
115
+ 503
116
+ )
117
+ end
118
+
119
+ def self.pkce_missing_verifier
120
+ new(
121
+ "PKCE verifier missing or expired. Restart the OAuth flow.",
122
+ PKCE_ERROR,
123
+ 400
124
+ )
125
+ end
126
+
127
+ def self.invalid_redirect(uri)
128
+ new(
129
+ %(redirect target #{uri.inspect} is not in `config.supabase.allowed_redirect_origins`),
130
+ INVALID_REDIRECT,
131
+ 400
132
+ )
133
+ end
134
+
135
+ def self.session_missing
136
+ new(
137
+ "Authentication required. No active Supabase session.",
138
+ SESSION_MISSING,
139
+ 401
140
+ )
141
+ end
74
142
  end
75
143
  end
76
144
  end
@@ -2,16 +2,24 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "../rails"
5
+ require_relative "web/cookie_credential_strategy"
5
6
 
6
7
  module Supabase
7
8
  module Rails
8
9
  class Middleware
9
- def initialize(app, auth: :user, env: nil, supabase_options: nil, cors: nil)
10
+ VALID_MODES = %i[api web].freeze
11
+
12
+ def initialize(app, mode: :api, auth: :user, env: nil, supabase_options: nil, cors: nil, session: nil, user_model: nil)
13
+ raise ConfigError.invalid_mode(mode) unless VALID_MODES.include?(mode)
14
+
10
15
  @app = app
16
+ @mode = mode
11
17
  @auth = auth
12
18
  @env_overrides = env
13
19
  @supabase_options = supabase_options
14
20
  @cors = cors
21
+ @session = session
22
+ @user_model = user_model
15
23
  end
16
24
 
17
25
  def call(env)
@@ -21,12 +29,7 @@ module Supabase
21
29
 
22
30
  return @app.call(env) if env[Rails::CONTEXT_KEY]
23
31
 
24
- result = Rails.create_context(
25
- RackRequest.new(env),
26
- auth: @auth,
27
- env: @env_overrides,
28
- supabase_options: @supabase_options
29
- )
32
+ result = build_context(env)
30
33
 
31
34
  return error_response(result.error) if result.failure?
32
35
 
@@ -38,6 +41,26 @@ module Supabase
38
41
 
39
42
  private
40
43
 
44
+ def build_context(env)
45
+ case @mode
46
+ when :api
47
+ Rails.create_context(
48
+ RackRequest.new(env),
49
+ auth: @auth,
50
+ env: @env_overrides,
51
+ supabase_options: @supabase_options,
52
+ user_model: @user_model
53
+ )
54
+ when :web
55
+ Web::CookieCredentialStrategy.new(
56
+ env: @env_overrides,
57
+ supabase_options: @supabase_options,
58
+ session: @session,
59
+ user_model: @user_model
60
+ ).call(env)
61
+ end
62
+ end
63
+
41
64
  def cors_enabled?
42
65
  @cors != false
43
66
  end
@@ -6,21 +6,47 @@ module Supabase
6
6
  module Rails
7
7
  class Railtie < ::Rails::Railtie
8
8
  config.supabase = ActiveSupport::OrderedOptions.new
9
+ config.supabase.mode = :api
9
10
  config.supabase.auth = :user
10
11
  config.supabase.cors = nil
11
12
  config.supabase.env = nil
12
13
  config.supabase.supabase_options = nil
13
14
  config.supabase.insert_middleware = true
15
+ config.supabase.user_model = nil
16
+ # nil = derive from mode (true in :web, false in :api). See
17
+ # `Supabase::Rails::Authentication.expose_current_user?`.
18
+ config.supabase.expose_current_user = nil
19
+ # Empty default; the OAuth helpers (US-015) fall back to
20
+ # `[request.host]` (same-origin) at runtime when this is empty.
21
+ config.supabase.allowed_redirect_origins = []
22
+ # Empty default; the `supabase/rails/oauth/_buttons.html.erb` partial
23
+ # iterates this list to render one "Sign in with <provider>" link
24
+ # per entry. Set in the host's initializer (e.g. `%i[google github]`).
25
+ config.supabase.oauth_providers = []
26
+ config.supabase.session = {
27
+ cookie_name: Supabase::Rails::SessionStore::DEFAULT_COOKIE_NAME,
28
+ same_site: Supabase::Rails::SessionStore::DEFAULT_SAME_SITE,
29
+ secure: nil,
30
+ domain: nil,
31
+ path: Supabase::Rails::SessionStore::DEFAULT_PATH
32
+ }
14
33
 
15
- initializer "supabase.middleware" do |app|
34
+ # Insert after config/initializers/*.rb so host overrides of
35
+ # `config.supabase.*` (mode, auth, session, etc.) take effect — the
36
+ # default ordering would read the unchanged Railtie defaults before
37
+ # the host initializer ran.
38
+ initializer "supabase.middleware", after: :load_config_initializers do |app|
16
39
  cfg = app.config.supabase
17
40
  next unless cfg.insert_middleware
18
41
 
19
42
  app.middleware.use Supabase::Rails::Middleware,
43
+ mode: cfg.mode,
20
44
  auth: cfg.auth,
21
45
  env: cfg.env,
22
46
  supabase_options: cfg.supabase_options,
23
- cors: cfg.cors
47
+ cors: cfg.cors,
48
+ session: cfg.session,
49
+ user_model: cfg.user_model
24
50
  end
25
51
  end
26
52
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Rails
5
+ # `supabase_authentication_routes` routing-DSL helper (FR-W12 / US-022).
6
+ #
7
+ # Installed onto `ActionDispatch::Routing::Mapper` by
8
+ # {Supabase::Rails::Engine} so hosts can write a single line inside
9
+ # `Rails.application.routes.draw do ... end` to mount the entire auth
10
+ # surface that the gem's controllers (US-017) and views (US-020) expect.
11
+ #
12
+ # Expansion (default — no filter):
13
+ #
14
+ # * `resource :session, only: %i[new create destroy]`
15
+ # * `resource :registration, only: %i[new create]`
16
+ # * `resources :passwords, only: %i[new create edit update], param: :token`
17
+ # * `resources :otp, only: %i[new create]` with a `verify` collection
18
+ # route responding to both GET (render the code-entry form) and POST
19
+ # (submit the code). Named `verify_otp_index_path`.
20
+ # * `GET /oauth/:provider/authorize` → `oauth_authorize_path(provider)`
21
+ # * `GET /oauth/callback` → `oauth_callback_path`
22
+ #
23
+ # `only:` / `except:` filter by group symbol — one of `:session`,
24
+ # `:registration`, `:passwords`, `:otp`, `:oauth` (per OQ-W4 lean-yes).
25
+ # A host that only wants password resets can write:
26
+ #
27
+ # supabase_authentication_routes only: %i[passwords]
28
+ #
29
+ # The helper does NOT name controllers — by default `resource :session`
30
+ # routes to `::SessionsController`, which is exactly the top-level
31
+ # wrapper the install generator (US-018) creates. Hosts that nest auth
32
+ # under a scope can wrap the call:
33
+ #
34
+ # scope module: "auth" do
35
+ # supabase_authentication_routes
36
+ # end
37
+ module Routes
38
+ GROUPS = %i[session registration passwords otp oauth].freeze
39
+
40
+ def supabase_authentication_routes(only: nil, except: nil)
41
+ groups = Routes.filter(only: only, except: except)
42
+
43
+ if groups.include?(:session)
44
+ resource :session, only: %i[new create destroy]
45
+ end
46
+
47
+ if groups.include?(:registration)
48
+ resource :registration, only: %i[new create]
49
+ end
50
+
51
+ if groups.include?(:passwords)
52
+ resources :passwords, only: %i[new create edit update], param: :token
53
+ end
54
+
55
+ if groups.include?(:otp)
56
+ resources :otp, only: %i[new create] do
57
+ collection do
58
+ match :verify, via: %i[get post], as: :verify
59
+ end
60
+ end
61
+ end
62
+
63
+ if groups.include?(:oauth)
64
+ get "/oauth/:provider/authorize", to: "oauth#authorize", as: :oauth_authorize
65
+ get "/oauth/callback", to: "oauth#callback", as: :oauth_callback
66
+ end
67
+ end
68
+
69
+ def self.filter(only: nil, except: nil)
70
+ validate_filter!(:only, only) if only
71
+ validate_filter!(:except, except) if except
72
+
73
+ groups = GROUPS.dup
74
+ groups &= Array(only).map(&:to_sym) if only
75
+ groups -= Array(except).map(&:to_sym) if except
76
+ groups
77
+ end
78
+
79
+ def self.validate_filter!(name, value)
80
+ unknown = Array(value).map(&:to_sym) - GROUPS
81
+ return if unknown.empty?
82
+
83
+ raise ArgumentError,
84
+ "supabase_authentication_routes: unknown #{name}: #{unknown.inspect}. " \
85
+ "Valid groups: #{GROUPS.inspect}"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Supabase
6
+ module Rails
7
+ # Single source of truth for the encrypted session cookie used in `:web`
8
+ # mode. Wraps Rails' `ActionDispatch::Cookies::EncryptedCookieJar` (keyed
9
+ # by the host app's `secret_key_base`, so no new secret is introduced) so
10
+ # the middleware (read + refresh-rewrite) and the `Authentication`
11
+ # concern (`start_new_session_for` / `terminate_session`) read and write
12
+ # the same cookie consistently.
13
+ #
14
+ # Decrypted payload contains only the credential fields needed to verify
15
+ # / refresh the session — access_token, refresh_token, expires_at,
16
+ # expires_in, token_type. The upstream `Supabase::Auth::Types::Session`
17
+ # struct also exposes a `user` field (and `provider_token` /
18
+ # `provider_refresh_token`), but those are intentionally NOT persisted:
19
+ # the user object is reconstructed from the access_token's JWT claims at
20
+ # request time, and a full Session would overflow Rails' 4 KB cookie
21
+ # limit on real Supabase responses (which embed identities, metadata,
22
+ # etc.).
23
+ class SessionStore
24
+ DEFAULT_COOKIE_NAME = "sb-session"
25
+ DEFAULT_SAME_SITE = :lax
26
+ DEFAULT_PATH = "/"
27
+ PERSISTED_KEYS = %w[access_token refresh_token expires_at expires_in token_type].freeze
28
+
29
+ attr_reader :cookie_name, :same_site, :secure, :domain, :path
30
+
31
+ def initialize(config = nil)
32
+ cfg = normalize_config(config)
33
+
34
+ @cookie_name = cfg[:cookie_name] || DEFAULT_COOKIE_NAME
35
+ @same_site = cfg[:same_site] || DEFAULT_SAME_SITE
36
+ @path = cfg[:path] || DEFAULT_PATH
37
+ @domain = cfg[:domain]
38
+ @secure = cfg.key?(:secure) && !cfg[:secure].nil? ? cfg[:secure] : default_secure
39
+ end
40
+
41
+ # Decrypts the session cookie. Returns a Hash mirroring
42
+ # `Supabase::Auth::Types::Session`, or `nil` if the cookie is missing,
43
+ # tampered, or otherwise unreadable. A tampered ciphertext is treated
44
+ # as a missing cookie (no exception bubbles up).
45
+ def read(request)
46
+ raw = encrypted_jar(request)[cookie_name]
47
+ return nil if raw.nil?
48
+ return nil unless raw.is_a?(Hash)
49
+
50
+ raw
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
55
+ # Encrypts and persists a Supabase session as a cookie. Accepts either a
56
+ # `Supabase::Auth::Types::Session` (or any struct with `#to_h`) or a Hash.
57
+ def write(response, session)
58
+ encrypted_jar(response)[cookie_name] = cookie_options.merge(value: serialize_session(session))
59
+ end
60
+
61
+ # Past-dates the cookie so the browser drops it. Deletion happens on the
62
+ # base jar — the encrypted layer wraps read/write encoding only.
63
+ def clear(response)
64
+ base_jar(response).delete(cookie_name, delete_options)
65
+ end
66
+
67
+ private
68
+
69
+ def normalize_config(config)
70
+ case config
71
+ when nil then {}
72
+ when Hash then config.transform_keys(&:to_sym)
73
+ else
74
+ if config.respond_to?(:to_h)
75
+ config.to_h.transform_keys(&:to_sym)
76
+ else
77
+ raise ArgumentError, "SessionStore config must be a Hash (got #{config.class})"
78
+ end
79
+ end
80
+ end
81
+
82
+ def default_secure
83
+ defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.production?
84
+ end
85
+
86
+ def encrypted_jar(req_or_resp)
87
+ jar = base_jar(req_or_resp)
88
+ jar.respond_to?(:encrypted) ? jar.encrypted : jar
89
+ end
90
+
91
+ def base_jar(req_or_resp)
92
+ req_or_resp.cookie_jar
93
+ end
94
+
95
+ def serialize_session(session)
96
+ hash =
97
+ if session.is_a?(Hash)
98
+ session
99
+ elsif session.respond_to?(:to_h)
100
+ session.to_h
101
+ else
102
+ raise ArgumentError,
103
+ "session must be a Hash or respond to #to_h (got #{session.class})"
104
+ end
105
+
106
+ hash.transform_keys(&:to_s).slice(*PERSISTED_KEYS)
107
+ end
108
+
109
+ def cookie_options
110
+ opts = {
111
+ httponly: true,
112
+ same_site: same_site,
113
+ secure: secure,
114
+ path: path
115
+ }
116
+ opts[:domain] = domain unless domain.nil?
117
+ opts
118
+ end
119
+
120
+ def delete_options
121
+ opts = { path: path }
122
+ opts[:domain] = domain unless domain.nil?
123
+ opts
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Rails
5
+ # Immutable value object representing the authenticated Supabase user.
6
+ #
7
+ # Built from verified JWT claims by the middleware and exposed via
8
+ # `supabase_context.current_user`. When `config.supabase.user_model`
9
+ # is unset (default), this is also what populates `Current.user` for
10
+ # the Authentication concern (FR-W15). When the host app opts into a
11
+ # shadow AR `User` model (FR-W14), the concern substitutes the AR
12
+ # record for `Current.user` but `supabase_context.current_user`
13
+ # remains this value object.
14
+ #
15
+ # All fields are read straight from the verified JWT payload — no
16
+ # extra `auth.get_user` round-trip. Use `supabase_context.supabase
17
+ # .auth.get_user` when the full `Supabase::Auth::Types::User` is
18
+ # required.
19
+ class User < Data.define(:id, :email, :role, :app_metadata, :user_metadata, :raw)
20
+ def self.from_claims(claims)
21
+ claims = {} unless claims.is_a?(Hash)
22
+ new(
23
+ id: claims["sub"],
24
+ email: claims["email"],
25
+ role: claims["role"],
26
+ app_metadata: claims["app_metadata"],
27
+ user_metadata: claims["user_metadata"],
28
+ raw: claims
29
+ )
30
+ end
31
+
32
+ def initialize(**)
33
+ super
34
+ freeze
35
+ end
36
+ end
37
+ end
38
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Supabase
4
4
  module Rails
5
- VERSION = "0.1.0"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end