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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b6894996dc059e056097232586c6cc442344d2185c2cd435add21e5f78f57f4
4
- data.tar.gz: c2a7a5346ac290461292100403af6fda95ee301d346c8b8de23bf05c91fc6ee5
3
+ metadata.gz: '01761814c3988b2d5748c44b65122bb83678455e1ae1ee7f5daa1b949c1c0d65'
4
+ data.tar.gz: 42a25a49ff120d404827d22298440f1c0cb0b981c8a7c549aaf15e218fa666a8
5
5
  SHA512:
6
- metadata.gz: 7280d2c5f81ce34a3a979bd7bad01b767646075949bebf93ebd3c6277758f96f54f0132a59e119abfdd3a8c4ecd01b08f08f4bcd386b8747ed3b3d8348e2dcba
7
- data.tar.gz: 8f8e965dcb50315ec81c90f225fa916581bd02f00aba0d5fab9ddebb9d29528239dbff0e876c72854724f74820e6a1dbe1cc262d4bdfd3ab6957f3c2d6d60296
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
- unless logged_in?
77
- redirect_to login_path
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
- user = User.from_omniauth(request.env["omniauth.auth"])
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 => e
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 %>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.4.13"
2
+ VERSION = "0.5.0"
3
3
  end
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[authenticate admin? display_name].freeze
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.each do |m|
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.13
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