studio-engine 0.4.13 → 0.5.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/CHANGELOG.md +25 -0
- data/app/controllers/concerns/solana/session_auth.rb +55 -0
- data/app/controllers/concerns/studio/error_handling.rb +85 -3
- data/app/controllers/magic_links_controller.rb +83 -0
- data/app/controllers/omniauth_callbacks_controller.rb +31 -2
- data/app/controllers/registrations_controller.rb +13 -0
- data/app/controllers/solana_sessions_controller.rb +52 -0
- data/app/mailers/application_mailer.rb +7 -0
- data/app/mailers/user_mailer.rb +14 -0
- data/app/models/current.rb +14 -0
- data/app/models/session_context.rb +85 -0
- data/app/services/google_oauth_validator.rb +98 -0
- data/app/services/magic_link.rb +115 -0
- data/app/views/user_mailer/magic_link.html.erb +14 -0
- data/app/views/user_mailer/magic_link.text.erb +9 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +49 -2
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '01761814c3988b2d5748c44b65122bb83678455e1ae1ee7f5daa1b949c1c0d65'
|
|
4
|
+
data.tar.gz: 42a25a49ff120d404827d22298440f1c0cb0b981c8a7c549aaf15e218fa666a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 44831601ac48a7dfcfb68dcf0173e7c10aa5be021935fed8c609a44aca3736393b4e556dbcf45ae9a1c4bb4691682caaedb21c7e5316859c720e164189a886da
|
|
7
|
+
data.tar.gz: 3bbc9bcf51bc62253d7e034f64f6e26e80222e8ae5cdaeef148efcd0e99967ddabcaaf002a80a29ac941f9e04185621c74a1969a3822071db68f70e0a1970cb2
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Both consumer Rails apps pin to a tag in their `Gemfile`; bumping the tag is a release.
|
|
4
4
|
|
|
5
|
+
## v0.5.0 (2026-06-02)
|
|
6
|
+
|
|
7
|
+
Promotes the **shared authentication core** out of Turf Monster into the engine so every Studio app runs one passwordless-first auth flow. This release is the **backend** half (services, POROs, concern helpers, base controllers, mailer); the shared wallet JS + Connect-Wallet modal land with the first consumer wiring. Turf Monster is **not** on this version yet — it stays on 0.4.x until its incremental migration.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`Studio.auth_methods`** config (default `%i[magic_link google wallet]`; `:password` opt-in) + `Studio.auth_method?(m)`. Login/signup surfaces render a field/button per enabled method. `:password` also re-arms the `User#authenticate` contract check.
|
|
11
|
+
- **`Studio.magic_link_ttl`** (15 min), **`Studio.magic_link_token_name`** (`"magic_link_v1"`), **`Studio.mailer_from`** config.
|
|
12
|
+
- **`SessionContext`** PORO — canonical guest/web2/web3 viewer state (`mode`, `to_h` camelCase for `Alpine.store('session')`). Wallet predicates are `respond_to?`-guarded so a wallet-less app is safe.
|
|
13
|
+
- **`Current`** baseline (`attribute :user`) — apps needing more request-scoped state override the file.
|
|
14
|
+
- **`MagicLink`** service — signed, single-use (jti in `Rails.cache`), URL-safe token; token name/TTL from config.
|
|
15
|
+
- **`GoogleOauthValidator`** — server-side `tokeninfo` re-check (audience / email_verified / expiry).
|
|
16
|
+
- **`Solana::SessionAuth`** concern — Rails-session adapter over `solana-studio`'s `Solana::AuthVerifier` (nonce delete-before-verify + host binding). solana-studio is host-provided; only loaded when wallet sign-in is on.
|
|
17
|
+
- **Base controllers** (generic; apps override for richer flows): `MagicLinksController` (create-or-login), `SolanaSessionsController` (nonce/verify), and an upgraded `OmniauthCallbacksController` (now runs `GoogleOauthValidator`).
|
|
18
|
+
- **`UserMailer#magic_link`** + `ApplicationMailer` (proc `from:` ← `Studio.mailer_from`) + app-name-aware templates.
|
|
19
|
+
- **Routes**: `Studio.routes` now draws `magic_link_request`/`magic_link` (when `:magic_link`) and `solana_nonce`/`solana_verify` (when `:wallet`), gated by `auth_method?`.
|
|
20
|
+
|
|
21
|
+
### Concern (`Studio::ErrorHandling`)
|
|
22
|
+
- `set_app_session` now binds a rotating `session[:session_token]` (OPSEC-045, guarded) and resets the `:onchain` flag; `clear_app_session` wipes both.
|
|
23
|
+
- `require_authentication` is now **format-aware** (HTML→redirect, JSON/Turbo→401, was a blind redirect that 406'd AJAX — OPSEC-046).
|
|
24
|
+
- New helpers: `set_current_context`, `verify_session_token` (guarded), `onchain_session?`, `wallet_context`, `client_session_payload` (identity-only baseline; apps override to merge balances).
|
|
25
|
+
|
|
26
|
+
### Breaking / Migration
|
|
27
|
+
- **`User.from_omniauth` contract is now `(auth, email_verified:)`** — the engine `OmniauthCallbacksController` passes the `GoogleOauthValidator` result. Consumers using the engine callback must update their `from_omniauth` to accept the kwarg.
|
|
28
|
+
- Consumers enabling `:magic_link` / `:wallet` must provide the User class methods the base controllers call (`User.find_by(email:)`, `User.from_solana_wallet(addr)`), the relevant columns (`email`, `email_verified_at`, `solana_address`, optional `session_token`), and `default_url_options` for mailer link generation.
|
|
29
|
+
|
|
5
30
|
## v0.4.13 (2026-06-02)
|
|
6
31
|
|
|
7
32
|
Promotes `components/_avatar_cropper` onto the shared crop-photo modal — completing the image-upload extraction started in v0.4.12. The avatar cropper is the **deferred-form-field** counterpart to `imageUploadHost`: it stages a cropped PNG on a hidden file input + shows a round preview, and the enclosing form (signup / profile edit) submits later (vs. `imageUploadHost`, which submits immediately).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Solana
|
|
2
|
+
# Rails-session adapter around solana_studio's pure
|
|
3
|
+
# Solana::AuthVerifier.verify!. Lives under app/controllers/concerns/ as
|
|
4
|
+
# `Solana::SessionAuth` (NOT `Solana::AuthVerifier`) — the gem owns that
|
|
5
|
+
# latter namespace, and Zeitwerk autoload skips colliding constants. By
|
|
6
|
+
# keeping this concern under a distinct module name, both pieces of code
|
|
7
|
+
# load reliably.
|
|
8
|
+
#
|
|
9
|
+
# Controllers `include Solana::SessionAuth` and call
|
|
10
|
+
# `verify_solana_signature!(message:, signature_b58:, pubkey_b58:, session:)`.
|
|
11
|
+
# This shim pulls/deletes the nonce from session (delete-before-verify =
|
|
12
|
+
# replay protection) and delegates the cryptography to the gem.
|
|
13
|
+
#
|
|
14
|
+
# Errors raised by the gem (`Solana::AuthVerifier::VerificationError`) are
|
|
15
|
+
# passed through unwrapped — controllers rescue that gem-defined constant
|
|
16
|
+
# directly.
|
|
17
|
+
#
|
|
18
|
+
# The `solana-studio` gem is provided by the consuming app (both McRitchie
|
|
19
|
+
# Studio + Turf Monster bundle it); this concern is only ever included when
|
|
20
|
+
# wallet sign-in is enabled, so a wallet-less app never loads ::Solana::AuthVerifier.
|
|
21
|
+
#
|
|
22
|
+
# Lifted into studio-engine (was turf-monster app/controllers/concerns/solana/session_auth.rb).
|
|
23
|
+
module SessionAuth
|
|
24
|
+
extend ActiveSupport::Concern
|
|
25
|
+
|
|
26
|
+
def verify_solana_signature!(message:, signature_b58:, pubkey_b58:, session:, expected_user_id: nil)
|
|
27
|
+
# OPSEC-005: when the caller is authenticated, require the signed
|
|
28
|
+
# message to embed `User-ID: <current_user.id>` so a signature
|
|
29
|
+
# captured against a *different* session's nonce can't be replayed
|
|
30
|
+
# into this user's flow. Login (solana_sessions#verify) calls without
|
|
31
|
+
# expected_user_id since there's no current_user yet — the nonce
|
|
32
|
+
# delete-before-verify below remains replay protection for that path.
|
|
33
|
+
if expected_user_id && !message.to_s.include?("User-ID: #{expected_user_id}")
|
|
34
|
+
raise ::Solana::AuthVerifier::VerificationError,
|
|
35
|
+
"Signed message missing User-ID binding for current session"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Delete nonce BEFORE verification to prevent replay
|
|
39
|
+
stored_nonce = session.delete(:solana_nonce)
|
|
40
|
+
nonce_at = session.delete(:solana_nonce_at)
|
|
41
|
+
|
|
42
|
+
# OPSEC-018: bind the signature to this host. The client builds the
|
|
43
|
+
# message with `window.location.host`; request.host_with_port is the
|
|
44
|
+
# server-side equal (hostname, plus port only when non-default).
|
|
45
|
+
::Solana::AuthVerifier.verify!(
|
|
46
|
+
message: message,
|
|
47
|
+
signature_b58: signature_b58,
|
|
48
|
+
pubkey_b58: pubkey_b58,
|
|
49
|
+
expected_host: request.host_with_port,
|
|
50
|
+
stored_nonce: stored_nonce,
|
|
51
|
+
nonce_at: nonce_at
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -8,7 +8,8 @@ module Studio
|
|
|
8
8
|
|
|
9
9
|
before_action :require_authentication
|
|
10
10
|
|
|
11
|
-
helper_method :current_user, :logged_in?, :sso_user_available?, :sso_display_name, :sso_source_app, :sso_hub_logo, :admin
|
|
11
|
+
helper_method :current_user, :logged_in?, :sso_user_available?, :sso_display_name, :sso_source_app, :sso_hub_logo, :admin?,
|
|
12
|
+
:onchain_session?, :wallet_context, :client_session_payload
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
private
|
|
@@ -22,6 +23,25 @@ module Studio
|
|
|
22
23
|
# App-specific session (only this app reads this key)
|
|
23
24
|
session[Studio.session_key] = user.id
|
|
24
25
|
|
|
26
|
+
# OPSEC-045: bind a rotating per-user token into the cookie. The
|
|
27
|
+
# verify_session_token filter compares it to user.session_token on every
|
|
28
|
+
# request, so a server-side rotation (email change, "log out everywhere")
|
|
29
|
+
# invalidates stolen sessions. Guarded for consumers whose User has no
|
|
30
|
+
# session_token column. Backfills a blank token (legacy rows / fixtures
|
|
31
|
+
# predate the column) so they aren't force-logged-out on the next request.
|
|
32
|
+
if user.respond_to?(:session_token)
|
|
33
|
+
if user.session_token.blank? && user.respond_to?(:update_column)
|
|
34
|
+
user.update_column(:session_token, SecureRandom.hex(32))
|
|
35
|
+
end
|
|
36
|
+
session[:session_token] = user.session_token
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The on-chain-session flag is a Phantom-wallet-signature privilege.
|
|
40
|
+
# Reset it on every login so a stale flag from an earlier wallet session
|
|
41
|
+
# can't leak into a later email/Google login. SolanaSessionsController#verify
|
|
42
|
+
# re-grants it for genuine wallet auth.
|
|
43
|
+
session.delete(:onchain)
|
|
44
|
+
|
|
25
45
|
# Only update shared awareness fields if this app is the source
|
|
26
46
|
# (don't overwrite the other app's sso_source when logging in via sso_continue)
|
|
27
47
|
if session[:sso_source].blank? || session[:sso_source] == Studio.app_name
|
|
@@ -37,6 +57,8 @@ module Studio
|
|
|
37
57
|
|
|
38
58
|
def clear_app_session
|
|
39
59
|
session.delete(Studio.session_key)
|
|
60
|
+
session.delete(:session_token)
|
|
61
|
+
session.delete(:onchain)
|
|
40
62
|
|
|
41
63
|
# Clear sso_* fields only if this app is the source
|
|
42
64
|
# (preserve them if the other app set them — they're still logged in there)
|
|
@@ -72,12 +94,72 @@ module Studio
|
|
|
72
94
|
current_user.present?
|
|
73
95
|
end
|
|
74
96
|
|
|
97
|
+
# Format-aware: a full-page HTML request gets the login redirect, but an
|
|
98
|
+
# AJAX/Turbo request gets a clean 401 (a blind redirect to the HTML login
|
|
99
|
+
# page makes Rails return 406 Not Acceptable for an `Accept: application/json`
|
|
100
|
+
# request — OPSEC-046). The JS fetch layer turns the 401 into a login modal.
|
|
75
101
|
def require_authentication
|
|
76
|
-
|
|
77
|
-
|
|
102
|
+
return if logged_in?
|
|
103
|
+
|
|
104
|
+
respond_to do |format|
|
|
105
|
+
format.html { redirect_to login_path }
|
|
106
|
+
format.json { render json: { error: "unauthenticated" }, status: :unauthorized }
|
|
107
|
+
format.turbo_stream { head :unauthorized }
|
|
108
|
+
format.any { head :unauthorized }
|
|
78
109
|
end
|
|
79
110
|
end
|
|
80
111
|
|
|
112
|
+
# Populates Current.* for the request lifecycle so audit/logging layers can
|
|
113
|
+
# attribute work to the viewer without param threading. Best-effort —
|
|
114
|
+
# never breaks the request path. Apps wire this as a before_action.
|
|
115
|
+
def set_current_context
|
|
116
|
+
Current.user = current_user if logged_in?
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# OPSEC-045: enforce session-token binding. No-ops for consumers whose User
|
|
122
|
+
# has no session_token. Runs early (apps wire it as a before_action ahead of
|
|
123
|
+
# require_authentication) so a stale session is cleared before current_user
|
|
124
|
+
# is read downstream.
|
|
125
|
+
def verify_session_token
|
|
126
|
+
return unless logged_in?
|
|
127
|
+
return unless current_user.respond_to?(:session_token)
|
|
128
|
+
|
|
129
|
+
user_token = current_user.session_token
|
|
130
|
+
cookie_token = session[:session_token]
|
|
131
|
+
return if user_token.present? && user_token == cookie_token
|
|
132
|
+
|
|
133
|
+
Rails.logger.info("[opsec-045] session_token mismatch user_id=#{current_user.id} — forcing re-login")
|
|
134
|
+
@current_user = nil
|
|
135
|
+
clear_app_session
|
|
136
|
+
respond_to do |format|
|
|
137
|
+
format.html { redirect_to login_path, alert: "Your session expired. Please sign in again." }
|
|
138
|
+
format.json { render json: { error: "session expired" }, status: :unauthorized }
|
|
139
|
+
format.any { head :unauthorized }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# True when this session authenticated via a live Solana wallet signature
|
|
144
|
+
# (not email/Google). Set by SolanaSessionsController#verify.
|
|
145
|
+
def onchain_session?
|
|
146
|
+
session[:onchain] == true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Canonical auth + wallet state for this request — the single source of truth
|
|
150
|
+
# the whole UI branches on (web3 / web2 / guest). Serialised into the page and
|
|
151
|
+
# mirrored client-side by Alpine.store('session'). See SessionContext.
|
|
152
|
+
def wallet_context
|
|
153
|
+
@wallet_context ||= SessionContext.new(user: current_user, onchain_session: onchain_session?)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Payload serialised into #session-context for Alpine.store('session').
|
|
157
|
+
# Baseline = identity only (SessionContext stays RPC-free). Apps override to
|
|
158
|
+
# merge on-chain balances/tokens they already preloaded for the request.
|
|
159
|
+
def client_session_payload
|
|
160
|
+
wallet_context.to_h
|
|
161
|
+
end
|
|
162
|
+
|
|
81
163
|
def require_admin
|
|
82
164
|
return redirect_to root_path, alert: "Not authorized" unless logged_in? && current_user.admin?
|
|
83
165
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Unified create-or-login email magic link (the passwordless email path).
|
|
2
|
+
#
|
|
3
|
+
# POST /magic_link — request a link (email [, return_to])
|
|
4
|
+
# GET /magic_link/:token — consume it: log in OR create the account
|
|
5
|
+
#
|
|
6
|
+
# create-or-login: clicking the link IS proof of email ownership, so an email
|
|
7
|
+
# that collides with a Google/wallet-only account that was never email-verified
|
|
8
|
+
# is safely logged in here and stamped email_verified_at (unlike from_omniauth,
|
|
9
|
+
# which refuses that collision precisely because it lacked this proof).
|
|
10
|
+
#
|
|
11
|
+
# This is the engine's GENERIC base. Apps that need richer post-consume routing
|
|
12
|
+
# (e.g. turf-monster's contest landing + picks rehydration + entry-tokens upsell)
|
|
13
|
+
# OVERRIDE this controller in the app and reuse the MagicLink service + the
|
|
14
|
+
# sign_in_existing / sign_up_new building blocks.
|
|
15
|
+
class MagicLinksController < ApplicationController
|
|
16
|
+
skip_before_action :require_authentication
|
|
17
|
+
|
|
18
|
+
# Respond uniformly for any well-formed email. Under create-or-login every
|
|
19
|
+
# address is "valid" (it logs in or signs up), so there is nothing to
|
|
20
|
+
# enumerate. A malformed email gets the same response with no mail sent.
|
|
21
|
+
def create
|
|
22
|
+
email = params[:email].to_s.strip.downcase
|
|
23
|
+
if email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
24
|
+
token = MagicLink.generate(email: email, return_to: safe_path(params[:return_to]))
|
|
25
|
+
UserMailer.magic_link(email, token).deliver_later
|
|
26
|
+
end
|
|
27
|
+
respond_to do |format|
|
|
28
|
+
format.json { render json: { success: true } }
|
|
29
|
+
format.html { redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link." }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def consume
|
|
34
|
+
# Keep the token out of Referer headers on the consume page's subresource
|
|
35
|
+
# loads. Single-use + short TTL is the primary defence; this closes the
|
|
36
|
+
# passive-leak gap. NOTE: aggressive email link-scanners (Outlook SafeLinks,
|
|
37
|
+
# Mimecast) may pre-fetch the link and burn the single-use token before the
|
|
38
|
+
# human clicks — a known magic-link tradeoff; documented for support.
|
|
39
|
+
response.set_header("Referrer-Policy", "no-referrer")
|
|
40
|
+
result = MagicLink.consume(params[:token])
|
|
41
|
+
user = User.find_by(email: result.email)
|
|
42
|
+
user ? sign_in_existing(user, result) : sign_up_new(result)
|
|
43
|
+
rescue MagicLink::InvalidToken
|
|
44
|
+
redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def sign_in_existing(user, result)
|
|
50
|
+
set_app_session(user)
|
|
51
|
+
user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at) && user.email_verified_at.blank?
|
|
52
|
+
redirect_to(safe_path(result.return_to) || root_path, notice: "Signed in. Welcome back!")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Build → configure_new_user → save!. There is no password — email auth is
|
|
56
|
+
# magic-link only (the password_digest column, if present, stays dormant).
|
|
57
|
+
def sign_up_new(result)
|
|
58
|
+
user = User.new(email: result.email)
|
|
59
|
+
Studio.configure_new_user.call(user)
|
|
60
|
+
rescue_and_log(target: user) do
|
|
61
|
+
user.save!
|
|
62
|
+
user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at)
|
|
63
|
+
set_app_session(user)
|
|
64
|
+
redirect_to(safe_path(result.return_to) || root_path, notice: Studio.welcome_message.call(user))
|
|
65
|
+
end
|
|
66
|
+
rescue ActiveRecord::RecordNotUnique
|
|
67
|
+
# Two valid tokens for the same brand-new email consumed near-simultaneously
|
|
68
|
+
# both miss find_by and race to save!; the loser hits the unique index.
|
|
69
|
+
# Benign — the account now exists, so just log the winner in.
|
|
70
|
+
existing = User.find_by(email: result.email)
|
|
71
|
+
return sign_in_existing(existing, result) if existing
|
|
72
|
+
|
|
73
|
+
redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
Rails.logger.error("[MagicLinksController#consume] signup failed #{e.class}: #{e.message}")
|
|
76
|
+
redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def safe_path(path)
|
|
80
|
+
p = path.to_s
|
|
81
|
+
p.start_with?("/") && !p.start_with?("//") ? p : nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -1,17 +1,46 @@
|
|
|
1
|
+
# Google OAuth callback (the web2 social path).
|
|
2
|
+
#
|
|
3
|
+
# Defense-in-depth: omniauth-google-oauth2 already verifies the id_token's JWT
|
|
4
|
+
# signature against Google's JWKS, but we additionally re-validate it server-side
|
|
5
|
+
# via Google's tokeninfo endpoint (GoogleOauthValidator) to assert audience +
|
|
6
|
+
# email_verified + expiry before trusting the identity. Only a Google-confirmed
|
|
7
|
+
# verified email is allowed to find-or-create an account.
|
|
8
|
+
#
|
|
9
|
+
# Engine GENERIC base. turf-monster OVERRIDES this controller for popup-mode,
|
|
10
|
+
# account merge, wallet-collision stashing, and funnel attribution.
|
|
1
11
|
class OmniauthCallbacksController < ApplicationController
|
|
2
12
|
skip_before_action :require_authentication
|
|
3
13
|
|
|
4
14
|
def create
|
|
5
|
-
|
|
15
|
+
auth = request.env["omniauth.auth"]
|
|
16
|
+
result = GoogleOauthValidator.new(id_token: id_token_from(auth)).validate!
|
|
17
|
+
unless result.ok?
|
|
18
|
+
Rails.logger.warn("[omniauth] google id_token rejected: #{result.reason}")
|
|
19
|
+
return redirect_to login_path, alert: "Google sign-in could not be verified. Please try again."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
user = User.from_omniauth(auth, email_verified: result.email_verified)
|
|
23
|
+
unless user.is_a?(User)
|
|
24
|
+
return redirect_to login_path, alert: "Google sign-in couldn't be completed. Please try another method."
|
|
25
|
+
end
|
|
26
|
+
|
|
6
27
|
rescue_and_log(target: user) do
|
|
7
28
|
set_app_session(user)
|
|
8
29
|
redirect_to root_path, notice: "Signed in with Google!"
|
|
9
30
|
end
|
|
10
|
-
rescue StandardError
|
|
31
|
+
rescue StandardError
|
|
11
32
|
redirect_to login_path, alert: "Google sign-in failed. Please try again."
|
|
12
33
|
end
|
|
13
34
|
|
|
14
35
|
def failure
|
|
15
36
|
redirect_to login_path, alert: "Google sign-in failed. Please try again."
|
|
16
37
|
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# The id_token lives at auth.extra.id_token (OmniAuth AuthHash) — read it
|
|
42
|
+
# tolerantly so a missing extras hash (test mock) doesn't raise.
|
|
43
|
+
def id_token_from(auth)
|
|
44
|
+
auth&.dig("extra", "id_token") || (auth.respond_to?(:extra) ? auth.extra&.id_token : nil)
|
|
45
|
+
end
|
|
17
46
|
end
|
|
@@ -6,6 +6,19 @@ class RegistrationsController < ApplicationController
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def create
|
|
9
|
+
# Passwordless apps: there is no create-account-by-form. Logging a user in
|
|
10
|
+
# straight from a signup POST would skip proof of email ownership, so we
|
|
11
|
+
# treat "sign up" as a magic-link request — the create-or-login link (which
|
|
12
|
+
# only fires after the recipient clicks it) does the account creation.
|
|
13
|
+
unless Studio.auth_method?(:password)
|
|
14
|
+
email = (params.dig(:user, :email) || params[:email]).to_s.strip.downcase
|
|
15
|
+
if email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
16
|
+
token = MagicLink.generate(email: email)
|
|
17
|
+
UserMailer.magic_link(email, token).deliver_later
|
|
18
|
+
end
|
|
19
|
+
return redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link."
|
|
20
|
+
end
|
|
21
|
+
|
|
9
22
|
@user = User.new(user_params)
|
|
10
23
|
Studio.configure_new_user.call(@user)
|
|
11
24
|
rescue_and_log(target: @user) do
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Solana / Phantom wallet sign-in (the web3 path).
|
|
2
|
+
#
|
|
3
|
+
# GET /auth/solana/nonce — issue a one-time nonce for the SIWS message
|
|
4
|
+
# POST /auth/solana/verify — verify the ed25519 signature, log in / sign up
|
|
5
|
+
#
|
|
6
|
+
# The cryptography lives in the solana-studio gem (Solana::AuthVerifier); the
|
|
7
|
+
# Solana::SessionAuth concern adapts it to the Rails session (delete-before-verify
|
|
8
|
+
# nonce burn + host binding). On success the session is granted the :onchain flag
|
|
9
|
+
# so SessionContext reports :web3 (can sign on-chain txs this session).
|
|
10
|
+
#
|
|
11
|
+
# Engine GENERIC base. Apps with a richer wallet identity (turf-monster: managed
|
|
12
|
+
# wallets, web2/web3 address split, account-linking, referral attribution)
|
|
13
|
+
# OVERRIDE this controller; it stays this simple for an app whose wallet is just
|
|
14
|
+
# an identity (one `solana_address` column).
|
|
15
|
+
class SolanaSessionsController < ApplicationController
|
|
16
|
+
include Solana::SessionAuth
|
|
17
|
+
skip_before_action :require_authentication
|
|
18
|
+
|
|
19
|
+
def nonce
|
|
20
|
+
session[:solana_nonce] = SecureRandom.hex(16)
|
|
21
|
+
session[:solana_nonce_at] = Time.current.to_i
|
|
22
|
+
render json: { nonce: session[:solana_nonce] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def verify
|
|
26
|
+
pubkey_b58 = verify_solana_signature!(
|
|
27
|
+
message: params[:message],
|
|
28
|
+
signature_b58: params[:signature],
|
|
29
|
+
pubkey_b58: params[:pubkey],
|
|
30
|
+
session: session
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
user = User.from_solana_wallet(pubkey_b58)
|
|
34
|
+
is_new = user.nil?
|
|
35
|
+
|
|
36
|
+
if is_new
|
|
37
|
+
user = User.new(solana_address: pubkey_b58)
|
|
38
|
+
Studio.configure_new_user.call(user)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
rescue_and_log(target: user) do
|
|
42
|
+
user.save! if user.new_record?
|
|
43
|
+
set_app_session(user)
|
|
44
|
+
session[:onchain] = true
|
|
45
|
+
render json: { success: true, redirect: root_path, new_user: is_new }
|
|
46
|
+
end
|
|
47
|
+
rescue ::Solana::AuthVerifier::VerificationError => e
|
|
48
|
+
render json: { error: e.message }, status: :unauthorized
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class ApplicationMailer < ActionMailer::Base
|
|
2
|
+
# Resolved per-message (proc default) so it picks up Studio.mailer_from set in
|
|
3
|
+
# the host's config/initializers/studio.rb, with an ENV fallback. No layout is
|
|
4
|
+
# forced — ActionMailer renders bare if the host ships no mailer layout, and a
|
|
5
|
+
# host that defines its own ApplicationMailer (e.g. turf-monster) wins outright.
|
|
6
|
+
default from: -> { Studio.mailer_from || ENV["MAILER_FROM"] || "no-reply@mcritchie.studio" }
|
|
7
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class UserMailer < ApplicationMailer
|
|
2
|
+
# Passwordless sign-in link. `email` is a raw string (the recipient may not
|
|
3
|
+
# have an account yet). Token is a signed MagicLink payload (email + return_to
|
|
4
|
+
# + jti, single-use). Clicking the link logs the recipient in or creates their
|
|
5
|
+
# account. App-name-aware so the same template serves every Studio app.
|
|
6
|
+
#
|
|
7
|
+
# Engine GENERIC base. An app needing richer copy (e.g. turf-monster's
|
|
8
|
+
# contest-aware variant) defines its own UserMailer, which wins.
|
|
9
|
+
def magic_link(email, token)
|
|
10
|
+
@app_name = Studio.app_name
|
|
11
|
+
@magic_url = magic_link_url(token: token)
|
|
12
|
+
mail(to: email, subject: "Your #{@app_name} sign-in link")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Request- and job-scoped context. ActiveSupport::CurrentAttributes auto-resets
|
|
2
|
+
# between requests (Rack middleware) and between Sidekiq/ActiveJob jobs.
|
|
3
|
+
#
|
|
4
|
+
# - `Current.user` — set by the engine's set_current_context before_action so
|
|
5
|
+
# downstream code (loggers, audit) can attribute work to the viewer without
|
|
6
|
+
# threading params through every layer.
|
|
7
|
+
#
|
|
8
|
+
# Baseline shipped by studio-engine. Apps that need more request-scoped state
|
|
9
|
+
# (e.g. turf-monster's `outbound_source` + admin vault-state memo) OVERRIDE this
|
|
10
|
+
# whole file — the non-isolated engine lets the host's app/models/current.rb win,
|
|
11
|
+
# and a subclass-style `attribute` list simply replaces this one.
|
|
12
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
13
|
+
attribute :user
|
|
14
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Canonical, single source of truth for the current viewer's auth + wallet state.
|
|
2
|
+
#
|
|
3
|
+
# `mode` is the 3-way value the entire UI branches on:
|
|
4
|
+
# :guest — not logged in
|
|
5
|
+
# :web2 — logged in with a custodial/managed wallet this session (email or
|
|
6
|
+
# Google login, or a Phantom account that did NOT authenticate via a
|
|
7
|
+
# wallet signature this session)
|
|
8
|
+
# :web3 — logged in AND authenticated via a live Phantom wallet signature
|
|
9
|
+
# this session (onchain_session?) — i.e. can sign on-chain txs now
|
|
10
|
+
#
|
|
11
|
+
# Mode is decided by the SESSION, not by account identity: a Phantom owner who
|
|
12
|
+
# logs in by email is :web2 for that session. `phantom_linked?` exposes the
|
|
13
|
+
# account-level fact separately, so the UI can still offer "Connect Phantom".
|
|
14
|
+
#
|
|
15
|
+
# Built once per request by ApplicationController#wallet_context, serialised
|
|
16
|
+
# into the page, and mirrored client-side by Alpine.store('session').
|
|
17
|
+
#
|
|
18
|
+
# Lifted into studio-engine (was turf-monster app/models). Wallet predicates are
|
|
19
|
+
# called through `respond_to?` so an app with wallet sign-in disabled (no
|
|
20
|
+
# #phantom_wallet? / #solana_address on User) still gets correct :guest/:web2.
|
|
21
|
+
class SessionContext
|
|
22
|
+
MODES = %i[guest web2 web3].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :user
|
|
25
|
+
|
|
26
|
+
def initialize(user:, onchain_session:)
|
|
27
|
+
@user = user
|
|
28
|
+
@onchain_session = onchain_session
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The canonical 3-way. Session-based — see class comment.
|
|
32
|
+
def mode
|
|
33
|
+
return :guest unless user
|
|
34
|
+
@onchain_session ? :web3 : :web2
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def guest?
|
|
38
|
+
mode == :guest
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def web2?
|
|
42
|
+
mode == :web2
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def web3?
|
|
46
|
+
mode == :web3
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def logged_in?
|
|
50
|
+
!guest?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Account-level fact, independent of `mode`: the account holds a self-custody
|
|
54
|
+
# (Phantom) wallet. A :web2-mode session can still be phantom_linked — that is
|
|
55
|
+
# exactly the "Phantom owner logged in by email" case.
|
|
56
|
+
def phantom_linked?
|
|
57
|
+
(user.respond_to?(:phantom_wallet?) && user.phantom_wallet?) || false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def user_id
|
|
61
|
+
user&.id
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Primary wallet address (web3 preferred), or nil when logged out / wallet-less.
|
|
65
|
+
def address
|
|
66
|
+
return nil unless user.respond_to?(:solana_address)
|
|
67
|
+
user.solana_address
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Shape consumed by the client Alpine.store('session'). Kept deliberately
|
|
71
|
+
# cheap — DB/session columns only, never an on-chain RPC call.
|
|
72
|
+
def to_h
|
|
73
|
+
{
|
|
74
|
+
loggedIn: logged_in?,
|
|
75
|
+
mode: mode,
|
|
76
|
+
phantomLinked: phantom_linked?,
|
|
77
|
+
userId: user_id,
|
|
78
|
+
address: address.to_s
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def as_json(*)
|
|
83
|
+
to_h
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# Server-side re-validation of a Google OAuth id_token via Google's tokeninfo
|
|
6
|
+
# endpoint, mirroring the pattern of re-fetching from the provider's API rather
|
|
7
|
+
# than trusting omniauth's parsed payload.
|
|
8
|
+
#
|
|
9
|
+
# The omniauth-google-oauth2 gem already verifies the JWT signature against
|
|
10
|
+
# Google's JWKS, so this is defense-in-depth — primarily it ensures:
|
|
11
|
+
# 1. The id_token's audience matches GOOGLE_CLIENT_ID (correct app)
|
|
12
|
+
# 2. email_verified is `true` per Google's own claim (closes the silent
|
|
13
|
+
# from_omniauth find-by-email link if Google hasn't confirmed the email)
|
|
14
|
+
# 3. The token isn't expired by Google's clock
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# result = GoogleOauthValidator.new(id_token: auth.extra.id_token).validate!
|
|
18
|
+
# if result.ok?
|
|
19
|
+
# # safe to use result.email, result.email_verified
|
|
20
|
+
# else
|
|
21
|
+
# # logging + reject
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Lifted into studio-engine (was turf-monster app/services/google_oauth_validator.rb).
|
|
25
|
+
class GoogleOauthValidator
|
|
26
|
+
TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo".freeze
|
|
27
|
+
NET_TIMEOUT_SECONDS = 5
|
|
28
|
+
|
|
29
|
+
Result = Struct.new(:ok, :email, :email_verified, :reason, keyword_init: true) do
|
|
30
|
+
def ok?
|
|
31
|
+
ok == true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(id_token:, expected_aud: ENV["GOOGLE_CLIENT_ID"])
|
|
36
|
+
@id_token = id_token
|
|
37
|
+
@expected_aud = expected_aud
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate!
|
|
41
|
+
# Test-mode affordance: OmniAuth.config.test_mode replaces the real
|
|
42
|
+
# OAuth flow with mock_auth, which doesn't carry a real id_token. The
|
|
43
|
+
# gem already verifies the mock signature/state internally; this
|
|
44
|
+
# validator has nothing real to re-check.
|
|
45
|
+
#
|
|
46
|
+
# We bypass on `OmniAuth.config.test_mode` (NOT just Rails.env.test?)
|
|
47
|
+
# because Playwright can run the e2e suite against a DEV server, and
|
|
48
|
+
# test_mode is the canonical "we are in a mock OAuth flow" signal.
|
|
49
|
+
# Production NEVER enables test_mode, so this widens the test surface
|
|
50
|
+
# without weakening the production guard.
|
|
51
|
+
test_mode = (defined?(OmniAuth) && OmniAuth.config.respond_to?(:test_mode) && OmniAuth.config.test_mode)
|
|
52
|
+
return Result.new(ok: true, email: nil, email_verified: true, reason: :test_skip) if @id_token.blank? && (Rails.env.test? || test_mode)
|
|
53
|
+
|
|
54
|
+
return Result.new(ok: false, reason: :missing_id_token) if @id_token.blank?
|
|
55
|
+
return Result.new(ok: false, reason: :missing_expected_aud) if @expected_aud.blank?
|
|
56
|
+
|
|
57
|
+
response = fetch_tokeninfo
|
|
58
|
+
return Result.new(ok: false, reason: :tokeninfo_unreachable) unless response
|
|
59
|
+
|
|
60
|
+
if response.code.to_i != 200
|
|
61
|
+
return Result.new(ok: false, reason: :tokeninfo_rejected)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
body = JSON.parse(response.body) rescue nil
|
|
65
|
+
return Result.new(ok: false, reason: :tokeninfo_parse_failed) unless body
|
|
66
|
+
|
|
67
|
+
# `email_verified` is a string "true"/"false" in tokeninfo responses.
|
|
68
|
+
email_verified = body["email_verified"].to_s == "true"
|
|
69
|
+
|
|
70
|
+
unless body["aud"] == @expected_aud
|
|
71
|
+
return Result.new(ok: false, email: body["email"], email_verified: email_verified, reason: :wrong_audience)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
unless email_verified
|
|
75
|
+
return Result.new(ok: false, email: body["email"], email_verified: false, reason: :email_not_verified)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
expiry = body["exp"].to_i
|
|
79
|
+
if expiry > 0 && expiry < Time.current.to_i
|
|
80
|
+
return Result.new(ok: false, email: body["email"], email_verified: true, reason: :expired)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Result.new(ok: true, email: body["email"], email_verified: true, reason: nil)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def fetch_tokeninfo
|
|
89
|
+
uri = URI(TOKENINFO_URL)
|
|
90
|
+
uri.query = URI.encode_www_form(id_token: @id_token)
|
|
91
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: NET_TIMEOUT_SECONDS, read_timeout: NET_TIMEOUT_SECONDS) do |http|
|
|
92
|
+
http.request(Net::HTTP::Get.new(uri.request_uri))
|
|
93
|
+
end
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
Rails.logger.warn("[GoogleOauthValidator] tokeninfo fetch failed: #{e.class}: #{e.message}")
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Unified create-or-login magic link.
|
|
2
|
+
#
|
|
3
|
+
# A magic link is a signed, short-lived, single-use token keyed on an EMAIL
|
|
4
|
+
# (the user may not exist yet — clicking the link either logs them in or
|
|
5
|
+
# creates the account). The token is a `message_verifier(token_name)` payload
|
|
6
|
+
# carrying the email + a sanitized return_to + a random jti.
|
|
7
|
+
#
|
|
8
|
+
# Single-use is enforced with the jti: on `generate` we record the jti in
|
|
9
|
+
# Rails.cache (Redis, cross-process); on `consume` we delete it and reject if
|
|
10
|
+
# it was already gone (replay / second click). The signature already covers
|
|
11
|
+
# tamper + expiry; the jti closes the replay gap.
|
|
12
|
+
#
|
|
13
|
+
# Token name + TTL come from Studio config (Studio.magic_link_token_name /
|
|
14
|
+
# Studio.magic_link_ttl) so each app can tune them; the jti cache entry is
|
|
15
|
+
# always given a few extra minutes so a still-valid token's jti is present.
|
|
16
|
+
#
|
|
17
|
+
# NOTE on test env: the test cache is :null_store, where writes/deletes are
|
|
18
|
+
# no-ops and `delete` always returns false — enforcing single-use there would
|
|
19
|
+
# reject every legitimate consume. So enforcement is skipped for non-tracking
|
|
20
|
+
# stores; the service unit test injects a real MemoryStore to exercise it.
|
|
21
|
+
#
|
|
22
|
+
# Lifted into studio-engine (was turf-monster app/services/magic_link.rb).
|
|
23
|
+
class MagicLink
|
|
24
|
+
class InvalidToken < StandardError; end
|
|
25
|
+
|
|
26
|
+
Result = Struct.new(:email, :return_to, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Test seam — defaults to Rails.cache. The service unit test sets this to
|
|
30
|
+
# an ActiveSupport::Cache::MemoryStore to assert single-use, then resets it.
|
|
31
|
+
attr_writer :cache
|
|
32
|
+
|
|
33
|
+
def cache
|
|
34
|
+
@cache || Rails.cache
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def token_name
|
|
38
|
+
Studio.magic_link_token_name
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ttl
|
|
42
|
+
Studio.magic_link_ttl
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# jti outlives the token so a valid token's jti is always still present.
|
|
46
|
+
def jti_ttl
|
|
47
|
+
ttl + 5.minutes
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns a signed token string. `return_to` is sanitized to a local path.
|
|
51
|
+
# The MessageVerifier blob is standard base64 (can contain "/" and "+"),
|
|
52
|
+
# which breaks the `%r{[^/]+}` route constraint once the payload is large
|
|
53
|
+
# enough to emit a "/". Wrap it URL-safe so the token is always
|
|
54
|
+
# [A-Za-z0-9_-]=, matching the route and surviving URL generation.
|
|
55
|
+
def generate(email:, return_to: nil)
|
|
56
|
+
normalized = normalize_email(email)
|
|
57
|
+
jti = SecureRandom.hex(16)
|
|
58
|
+
cache.write(jti_key(jti), normalized, expires_in: jti_ttl) if enforce_single_use?
|
|
59
|
+
raw = verifier.generate(
|
|
60
|
+
{ email: normalized, return_to: sanitize_path(return_to), jti: jti, v: 1 },
|
|
61
|
+
expires_in: ttl
|
|
62
|
+
)
|
|
63
|
+
Base64.urlsafe_encode64(raw)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Verifies signature + expiry + single-use. Returns a Result or raises
|
|
67
|
+
# InvalidToken. Idempotency is NOT offered — a consumed token is dead.
|
|
68
|
+
def consume(token)
|
|
69
|
+
raw = Base64.urlsafe_decode64(token.to_s)
|
|
70
|
+
payload = verifier.verify(raw).with_indifferent_access
|
|
71
|
+
raise InvalidToken, "unexpected token shape" unless payload[:v] == 1 && payload[:email].present?
|
|
72
|
+
|
|
73
|
+
if enforce_single_use?
|
|
74
|
+
# delete returns true only when the jti was still present
|
|
75
|
+
raise InvalidToken, "link already used or expired" unless cache.delete(jti_key(payload[:jti]))
|
|
76
|
+
elsif !Rails.env.test?
|
|
77
|
+
# Single-use is disabled (non-tracking cache). Expected in :null_store
|
|
78
|
+
# dev; in any other env it means replay protection is silently OFF —
|
|
79
|
+
# tokens are replayable for their TTL. Surface it loudly.
|
|
80
|
+
Rails.logger.warn("[MagicLink] single-use NOT enforced (cache=#{cache.class}); links are replayable until expiry")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Result.new(email: payload[:email], return_to: sanitize_path(payload[:return_to]))
|
|
84
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError
|
|
85
|
+
# ArgumentError → malformed base64 (tampered/truncated token).
|
|
86
|
+
raise InvalidToken, "invalid or expired link"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def verifier
|
|
92
|
+
Rails.application.message_verifier(token_name)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def jti_key(jti)
|
|
96
|
+
"magic_link/jti/#{jti}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def normalize_email(email)
|
|
100
|
+
email.to_s.strip.downcase
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Only same-origin absolute paths survive; everything else (protocol-relative
|
|
104
|
+
# "//evil", absolute URLs, blank) collapses to nil so callers fall back to a
|
|
105
|
+
# default redirect.
|
|
106
|
+
def sanitize_path(path)
|
|
107
|
+
p = path.to_s
|
|
108
|
+
p.start_with?("/") && !p.start_with?("//") ? p : nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def enforce_single_use?
|
|
112
|
+
!cache.is_a?(ActiveSupport::Cache::NullStore)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<h1>Your sign-in link</h1>
|
|
2
|
+
|
|
3
|
+
<p>Tap the button below to sign in to <%= @app_name %>. If you don't have an
|
|
4
|
+
account yet, we'll create one for you — no password needed:</p>
|
|
5
|
+
|
|
6
|
+
<p><%= link_to "Sign in to #{@app_name}", @magic_url %></p>
|
|
7
|
+
|
|
8
|
+
<p>If the button doesn't work, copy and paste this URL into your browser:</p>
|
|
9
|
+
<p><%= @magic_url %></p>
|
|
10
|
+
|
|
11
|
+
<p>Open this link on this device to finish. It expires shortly and can only be
|
|
12
|
+
used once. If you didn't request it, you can safely ignore this message.</p>
|
|
13
|
+
|
|
14
|
+
<p>— <%= @app_name %></p>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Open this link to sign in to <%= @app_name %>. If you don't have an account yet,
|
|
2
|
+
we'll create one for you — no password needed:
|
|
3
|
+
|
|
4
|
+
<%= @magic_url %>
|
|
5
|
+
|
|
6
|
+
Open this link on this device to finish. It expires shortly and can only be
|
|
7
|
+
used once. If you didn't request it, you can safely ignore this message.
|
|
8
|
+
|
|
9
|
+
— <%= @app_name %>
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -16,6 +16,23 @@ module Studio
|
|
|
16
16
|
mattr_accessor :sso_logo, default: nil
|
|
17
17
|
mattr_accessor :theme_logos, default: []
|
|
18
18
|
|
|
19
|
+
# ---- Authentication ------------------------------------------------------
|
|
20
|
+
# Which sign-in methods this app offers. The shared login/signup views render
|
|
21
|
+
# a button/field per enabled method (gate with Studio.auth_method?). Order is
|
|
22
|
+
# display order. Both McRitchie Studio + Turf Monster are passwordless; legacy
|
|
23
|
+
# email+password is opt-in via :password (which also re-arms the User#authenticate
|
|
24
|
+
# contract check — see validate_user_contract!).
|
|
25
|
+
mattr_accessor :auth_methods, default: %i[magic_link google wallet]
|
|
26
|
+
|
|
27
|
+
# Magic-link (passwordless email) tuning. token_name keys the MessageVerifier
|
|
28
|
+
# purpose; bump it to invalidate every outstanding link. See MagicLink service.
|
|
29
|
+
mattr_accessor :magic_link_ttl, default: 15.minutes
|
|
30
|
+
mattr_accessor :magic_link_token_name, default: "magic_link_v1"
|
|
31
|
+
|
|
32
|
+
# Default From: for engine-sent mail (magic links). Apps set this to their
|
|
33
|
+
# verified Resend sending address in config/initializers/studio.rb.
|
|
34
|
+
mattr_accessor :mailer_from, default: nil
|
|
35
|
+
|
|
19
36
|
# Theme role colors (7 roles)
|
|
20
37
|
mattr_accessor :theme_primary, default: "#8E82FE"
|
|
21
38
|
mattr_accessor :theme_dark, default: "#1A1535"
|
|
@@ -46,8 +63,11 @@ module Studio
|
|
|
46
63
|
# ActiveRecord defines them lazily — they don't appear on `.instance_methods`
|
|
47
64
|
# until the schema is introspected (typically first record access). Missing
|
|
48
65
|
# columns are caught by the User table schema, not by this validator.
|
|
49
|
-
REQUIRED_USER_INSTANCE_METHODS = %i[
|
|
66
|
+
REQUIRED_USER_INSTANCE_METHODS = %i[admin? display_name].freeze
|
|
50
67
|
REQUIRED_USER_CLASS_METHODS = %i[find_by].freeze
|
|
68
|
+
# #authenticate is only required when email+password sign-in is enabled.
|
|
69
|
+
# Passwordless apps (the default) never call it.
|
|
70
|
+
PASSWORD_USER_INSTANCE_METHODS = %i[authenticate].freeze
|
|
51
71
|
|
|
52
72
|
class UserContractError < StandardError; end
|
|
53
73
|
|
|
@@ -55,6 +75,11 @@ module Studio
|
|
|
55
75
|
yield self
|
|
56
76
|
end
|
|
57
77
|
|
|
78
|
+
# True when the given sign-in method is enabled for this app.
|
|
79
|
+
def self.auth_method?(method)
|
|
80
|
+
auth_methods.include?(method.to_sym)
|
|
81
|
+
end
|
|
82
|
+
|
|
58
83
|
# Verifies that the host app's User model satisfies the engine's expected
|
|
59
84
|
# contract. Raises Studio::UserContractError with a clear pointer to
|
|
60
85
|
# docs/USER_CONTRACT.md if anything required is missing. Called from
|
|
@@ -67,7 +92,9 @@ module Studio
|
|
|
67
92
|
REQUIRED_USER_CLASS_METHODS.each do |m|
|
|
68
93
|
missing << "User.#{m}" unless user_class.respond_to?(m)
|
|
69
94
|
end
|
|
70
|
-
REQUIRED_USER_INSTANCE_METHODS.
|
|
95
|
+
instance_methods = REQUIRED_USER_INSTANCE_METHODS.dup
|
|
96
|
+
instance_methods.concat(PASSWORD_USER_INSTANCE_METHODS) if auth_method?(:password)
|
|
97
|
+
instance_methods.each do |m|
|
|
71
98
|
missing << "User##{m}" unless user_class.instance_methods.include?(m)
|
|
72
99
|
end
|
|
73
100
|
|
|
@@ -122,6 +149,26 @@ module Studio
|
|
|
122
149
|
post "signup", to: "registrations#create"
|
|
123
150
|
get "auth/:provider/callback", to: "omniauth_callbacks#create"
|
|
124
151
|
get "auth/failure", to: "omniauth_callbacks#failure"
|
|
152
|
+
|
|
153
|
+
# Passwordless email (magic link). Helpers: magic_link_request_path (POST
|
|
154
|
+
# to request a link) + magic_link_path(token) / magic_link_url(token:)
|
|
155
|
+
# (the emailed consume link). The token is a URL-safe MessageVerifier blob
|
|
156
|
+
# but the constraint guards against a stray "." segment.
|
|
157
|
+
if Studio.auth_method?(:magic_link)
|
|
158
|
+
post "magic_link", to: "magic_links#create", as: :magic_link_request
|
|
159
|
+
get "magic_link/:token", to: "magic_links#consume", as: :magic_link,
|
|
160
|
+
constraints: { token: %r{[^/]+} }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Solana / Phantom wallet sign-in (nonce challenge + signature verify).
|
|
164
|
+
# The browser posts to these literal paths from the shared Connect-Wallet
|
|
165
|
+
# flow; app-specific surfaces (mobile deep-link callback, account-linking,
|
|
166
|
+
# OAuth popup) stay in the consuming app's routes.
|
|
167
|
+
if Studio.auth_method?(:wallet)
|
|
168
|
+
get "auth/solana/nonce", to: "solana_sessions#nonce", as: :solana_nonce
|
|
169
|
+
post "auth/solana/verify", to: "solana_sessions#verify", as: :solana_verify
|
|
170
|
+
end
|
|
171
|
+
|
|
125
172
|
resources :error_logs, only: [:index, :show]
|
|
126
173
|
|
|
127
174
|
# Admin
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -108,20 +108,29 @@ files:
|
|
|
108
108
|
- Gemfile
|
|
109
109
|
- LICENSE
|
|
110
110
|
- README.md
|
|
111
|
+
- app/controllers/concerns/solana/session_auth.rb
|
|
111
112
|
- app/controllers/concerns/studio/error_handling.rb
|
|
112
113
|
- app/controllers/error_logs_controller.rb
|
|
114
|
+
- app/controllers/magic_links_controller.rb
|
|
113
115
|
- app/controllers/navbar_controller.rb
|
|
114
116
|
- app/controllers/omniauth_callbacks_controller.rb
|
|
115
117
|
- app/controllers/registrations_controller.rb
|
|
116
118
|
- app/controllers/schema_controller.rb
|
|
117
119
|
- app/controllers/sessions_controller.rb
|
|
120
|
+
- app/controllers/solana_sessions_controller.rb
|
|
118
121
|
- app/controllers/theme_settings_controller.rb
|
|
119
122
|
- app/helpers/studio_theme_helper.rb
|
|
120
123
|
- app/jobs/error_log_cleanup_job.rb
|
|
124
|
+
- app/mailers/application_mailer.rb
|
|
125
|
+
- app/mailers/user_mailer.rb
|
|
121
126
|
- app/models/concerns/sluggable.rb
|
|
127
|
+
- app/models/current.rb
|
|
122
128
|
- app/models/error_log.rb
|
|
123
129
|
- app/models/image_cache.rb
|
|
130
|
+
- app/models/session_context.rb
|
|
124
131
|
- app/models/theme_setting.rb
|
|
132
|
+
- app/services/google_oauth_validator.rb
|
|
133
|
+
- app/services/magic_link.rb
|
|
125
134
|
- app/views/components/_admin_dropdown.html.erb
|
|
126
135
|
- app/views/components/_avatar.html.erb
|
|
127
136
|
- app/views/components/_avatar_cropper.html.erb
|
|
@@ -156,6 +165,8 @@ files:
|
|
|
156
165
|
- app/views/studio/modals/blocks/_progress_countdown.html.erb
|
|
157
166
|
- app/views/studio/modals/blocks/_success_card.html.erb
|
|
158
167
|
- app/views/theme_settings/edit.html.erb
|
|
168
|
+
- app/views/user_mailer/magic_link.html.erb
|
|
169
|
+
- app/views/user_mailer/magic_link.text.erb
|
|
159
170
|
- lib/studio-engine.rb
|
|
160
171
|
- lib/studio.rb
|
|
161
172
|
- lib/studio/color_scale.rb
|