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
|
@@ -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(
|
|
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
|
-
|
|
19
|
-
|
|
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
|
data/lib/supabase/rails/core.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|