studio-engine 0.30.0 → 0.31.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 +157 -1
- data/README.md +3 -2
- data/app/controllers/concerns/studio/error_handling.rb +7 -2
- data/app/controllers/concerns/studio/link_consumption.rb +131 -11
- data/app/controllers/concerns/studio/magic_link_issuing.rb +15 -20
- data/app/controllers/magic_links_controller.rb +13 -40
- data/app/controllers/registrations_controller.rb +3 -1
- data/app/controllers/studio/links_controller.rb +17 -19
- data/app/controllers/studio/local_reviews_controller.rb +4 -4
- data/app/mailers/user_mailer.rb +8 -7
- data/app/models/studio/link.rb +55 -2
- data/app/views/studio/_confirm_interstitial.html.erb +4 -3
- data/db/migrate/20260620000002_allow_null_image_cache_owner.rb +10 -0
- data/lib/studio/link_resolution.rb +139 -0
- data/lib/studio/link_token.rb +13 -4
- data/lib/studio/theme_resolver.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +47 -27
- metadata +3 -4
- data/app/services/magic_link.rb +0 -122
- data/app/views/magic_links/confirm.html.erb +0 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c36ecfe3e213e5621f205743728461c24d02912ed7e7bbf5109e0c15afd3225e
|
|
4
|
+
data.tar.gz: d92755cdb2ed3f7b8dca941bb514e2c725314056afbc83ee7fb190a4cc4a993c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 17c9f349695792402a9cbcb245159ebcba107b355edcdc9c5ae3b3d89d23303d0bd3a4bd96846d1d23c23417fbff47a0f73e0db887bd8880faeb4687e7e48c12
|
|
7
|
+
data.tar.gz: c65f117c47ced22be63a3747f0cd2b283f00a80c40802947a1003ada976749242ebd8340a511d89c9806d9449c6a3525eb1cbebb5fdd47d37cf334465d2e2644
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,160 @@
|
|
|
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`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
|
|
4
4
|
|
|
5
|
+
## 0.31.0 — 2026-08-08
|
|
6
|
+
|
|
7
|
+
**One magic-link token format, one door, and a click that stops breaking your
|
|
8
|
+
session.** Two operator-reported faults, one root: the engine shipped two
|
|
9
|
+
magic-link stores side by side, and the legacy one could not support the
|
|
10
|
+
behavior the other needed.
|
|
11
|
+
|
|
12
|
+
The links themselves were the visible half — a `:signed` app mailed a
|
|
13
|
+
~350-character `MessageVerifier` blob at `/magic_link/<token>`, wrapping four
|
|
14
|
+
lines of an email. The invisible half was worse: **clicking a link a second time
|
|
15
|
+
dumped a signed-in visitor on the login page.** Their cookie survived, but the
|
|
16
|
+
destination said otherwise, and the destination is what people believe. Every
|
|
17
|
+
dead token — used, expired, or unrecognized — funnelled into the same
|
|
18
|
+
`redirect_to login_path, alert:` no matter who was holding a session.
|
|
19
|
+
|
|
20
|
+
Fixing the second required retiring the first. An EXPIRED `MessageVerifier`
|
|
21
|
+
token cannot be decoded, so a `:signed` app could not tell whose dead link it
|
|
22
|
+
was holding — and "is this the visitor's own link?" is the question the whole
|
|
23
|
+
new behavior turns on. A `Studio::Link` row keeps the email past expiry.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`Studio::LinkResolution`** — the click decision table as one pure,
|
|
28
|
+
dependency-free module (`lib/studio/link_resolution.rb`), so every cell is
|
|
29
|
+
unit-testable without a controller or a database. Three inputs (did this
|
|
30
|
+
caller burn the link, whose email it carries, who is signed in) resolve to one
|
|
31
|
+
of three actions:
|
|
32
|
+
|
|
33
|
+
| | nobody signed in | the link's own user | somebody else |
|
|
34
|
+
|------------------|------------------|---------------------|------------------|
|
|
35
|
+
| **live** (burned)| `:authenticate` | `:continue` | `:authenticate` |
|
|
36
|
+
| **used/expired** | `:dead` → login | `:dead` → return_to | `:dead` → home |
|
|
37
|
+
| **unknown token**| `:dead` → login | — | `:dead` → home |
|
|
38
|
+
|
|
39
|
+
The invariant running through it: **a dead link never touches the session.**
|
|
40
|
+
- **`:continue`** — a second click on your own still-live link burns the token
|
|
41
|
+
(so a forwarded email stays unusable) and then does nothing else. It
|
|
42
|
+
deliberately does NOT re-authenticate: a host that rotates the session on
|
|
43
|
+
sign-in would otherwise charge a re-click every scrap of session state the
|
|
44
|
+
visitor had built up. From their side it is indistinguishable from following a
|
|
45
|
+
plain link, which is the point.
|
|
46
|
+
- **Dead-link notices name the address and the reason** ("That sign-in link for
|
|
47
|
+
x@y.com has expired.") and, when a session is open, say so plainly ("You are
|
|
48
|
+
still signed in as a@b.com") instead of implying a logout that never happened.
|
|
49
|
+
- **`Studio::Link#burn`** — the non-raising sibling of `#consume!`, returning
|
|
50
|
+
whether THIS caller won the atomic single-use race, plus `#dead_status`
|
|
51
|
+
(`:used` / `:expired`) for the message. Winning the burn IS the proof the link
|
|
52
|
+
was live; a prior `live?` read is not.
|
|
53
|
+
- **`Studio::Link::MissingTable`** — a named error, pointing at the migration to
|
|
54
|
+
copy, in place of a bare `PG::UndefinedTable`. Every consumer pins the engine
|
|
55
|
+
`~> 0.x`, which admits any release below 1.0, so an app that never installed
|
|
56
|
+
the table picks up the row store on its next `bundle update` with nobody
|
|
57
|
+
having adopted anything deliberately. Its boot stays clean (nothing touches
|
|
58
|
+
the table until someone signs in), so without this the failure lands as an
|
|
59
|
+
unreadable adapter error on a real person's sign-in. Guarded by
|
|
60
|
+
`table_exists?`, not a message match, so it holds on any adapter and never
|
|
61
|
+
swallows an unrelated statement failure.
|
|
62
|
+
- **`Studio::LinkToken::TOKEN_LENGTH` / `TOKEN_LENGTH_BOUNDS` / `TOKEN_FORMAT`** —
|
|
63
|
+
the house standard, now asserted rather than described: every token is exactly
|
|
64
|
+
16 URL-safe characters, inside a 10-20 character bound.
|
|
65
|
+
- **`Studio::LinkConsumption`** gains the whole flow (`preview_magic_link` for
|
|
66
|
+
the inert GET, `consume_magic_link` for the burning POST) plus overridable
|
|
67
|
+
hooks: `link_continue`, `link_dead`, `link_login_path`, `link_home_path`.
|
|
68
|
+
Apps customize by overriding a hook, never by re-deciding.
|
|
69
|
+
- **Two new suites**: `test/lib/studio/link_resolution_test.rb` (the table, cell
|
|
70
|
+
by cell, with the dead-link invariant swept across all nine cells) and
|
|
71
|
+
`test/integration/magic_link_flow_test.rb` (the same flow through a real HTTP
|
|
72
|
+
round trip against a real database — token burn, session cookie, scanner
|
|
73
|
+
prefetch, account switch, open-redirect refusal, and the burn race).
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- **`Studio.magic_link_store` now reads `:database` and nothing else.** Assigning
|
|
78
|
+
`:signed` **raises at boot** with the migration to install, rather than
|
|
79
|
+
silently downgrading — an app that booted anyway would mint rows against a
|
|
80
|
+
table it never migrated and 500 on a real person's sign-in.
|
|
81
|
+
- **`Studio.magic_link_via_l_route?`** follows `draw_link_routes` alone.
|
|
82
|
+
- **`RegistrationsController`** mints through `Studio::MagicLinkIssuing` like
|
|
83
|
+
every other issuer, instead of calling the store directly.
|
|
84
|
+
|
|
85
|
+
### Removed
|
|
86
|
+
|
|
87
|
+
- **`MagicLink`** (`app/services/magic_link.rb`), the stateless MessageVerifier
|
|
88
|
+
service, and its jti-in-Rails.cache replay guard.
|
|
89
|
+
- **`GET`/`POST /magic_link/:token`** and `MagicLinksController#confirm` /
|
|
90
|
+
`#consume`. `POST /magic_link` (request a link) stays. The token-bearing door
|
|
91
|
+
is `/l/<token>`, and only that.
|
|
92
|
+
- **`app/views/magic_links/confirm.html.erb`** — the `/l` interstitial
|
|
93
|
+
(`studio/links/confirm`) renders the same shared body.
|
|
94
|
+
- **`Studio.magic_link_token_name`** is vestigial: the accessor remains so an
|
|
95
|
+
un-updated initializer still boots, but nothing reads it. Delete the line.
|
|
96
|
+
|
|
97
|
+
### Consumer migration (required)
|
|
98
|
+
|
|
99
|
+
**Until step 2 lands, the app's magic-link sign-in is broken** — no
|
|
100
|
+
`studio_links` table means the first mint raises `Studio::Link::MissingTable`.
|
|
101
|
+
Do these two in this order; they do not commute.
|
|
102
|
+
|
|
103
|
+
1. Delete `config.magic_link_store` and `config.magic_link_token_name` from
|
|
104
|
+
`config/initializers/studio.rb`. **First**, because a leftover `= :signed`
|
|
105
|
+
raises while the initializer loads — and `Studio.configure` yields during
|
|
106
|
+
boot, so no rake task (including the migration install below) can run until
|
|
107
|
+
the line is gone.
|
|
108
|
+
2. Install the `studio_links` table with `bin/rails
|
|
109
|
+
studio_engine:install:migrations && bin/rails db:migrate` — install ALL of
|
|
110
|
+
them, per `docs/NEW_APP_SETUP.md` § 5. Do **not** hand-copy the migration:
|
|
111
|
+
the task installs it as `<timestamp>_create_studio_links.studio_engine.rb`, a
|
|
112
|
+
hand copy keeps its own name, and both declare `class CreateStudioLinks`, so
|
|
113
|
+
`db:migrate` dies on `ActiveRecord::DuplicateMigrationNameError`.
|
|
114
|
+
3. Replace any `MagicLink.generate` / `MagicLink.consume` call (including in
|
|
115
|
+
test helpers) with `Studio::Link.create_magic_link` / `Studio::Link#burn`.
|
|
116
|
+
4. An app overriding the link controllers gets the new behavior by calling
|
|
117
|
+
`consume_magic_link` / `preview_magic_link` and overriding hooks. In
|
|
118
|
+
particular, move any `reset_session` into `sign_in_existing` only — the
|
|
119
|
+
`:continue` path must not reach it.
|
|
120
|
+
## 0.30.1 — 2026-08-08
|
|
121
|
+
|
|
122
|
+
**`NEW_APP_SETUP.md` § 5 gave a command that does not exist.** 0.30.0 documented
|
|
123
|
+
the engine migration install as `bin/rails studio:install:migrations`; the real
|
|
124
|
+
task is **`studio_engine:install:migrations`**, and the copied files land with a
|
|
125
|
+
`.studio_engine.rb` suffix, not `.studio.rb`. The wrong spelling was inferred
|
|
126
|
+
from a consumer file that had been hand-copied rather than generated, and it
|
|
127
|
+
fails loudly (`Unrecognized command`) for anyone who follows the guide.
|
|
128
|
+
|
|
129
|
+
**And one of the copied migrations could fail the whole run.** The task copies
|
|
130
|
+
FOUR *reference* migrations, and `allow_null_image_cache_owner` runs
|
|
131
|
+
`change_column_null :image_caches` — which raised on any app without that table,
|
|
132
|
+
taking the entire `db:migrate` down with it. moms-app hit exactly that.
|
|
133
|
+
|
|
134
|
+
The obvious answer — "review what was copied and delete what doesn't apply" — is
|
|
135
|
+
wrong, and this release does NOT tell you to do it. `install:migrations` builds
|
|
136
|
+
its skip-list from the files **present**, so a deleted copy comes back with a
|
|
137
|
+
fresh timestamp on your next upgrade and fails again. The guard therefore lives
|
|
138
|
+
in the migration, and § 5 now says the simple thing: **install all of them.**
|
|
139
|
+
|
|
140
|
+
### Fixed
|
|
141
|
+
|
|
142
|
+
- **`allow_null_image_cache_owner` no longer fails `db:migrate` on an app without
|
|
143
|
+
an `image_caches` table.** It ALTERS an app-owned table the engine cannot assume
|
|
144
|
+
exists, and unguarded it raised and took the whole migration run down with it.
|
|
145
|
+
It now no-ops when the table is absent, and still relaxes the owner columns when
|
|
146
|
+
it is present — both halves pinned by
|
|
147
|
+
`test/integration/image_cache_migration_guard_test.rb`.
|
|
148
|
+
|
|
149
|
+
Deleting the copied migration was never a workaround: `install:migrations`
|
|
150
|
+
builds its skip-list from the files **present**, so a deleted copy is re-copied
|
|
151
|
+
with a fresh timestamp on the next upgrade and fails again. Verified by
|
|
152
|
+
re-running the task against a real consumer.
|
|
153
|
+
- `NEW_APP_SETUP.md` § 5: correct task name, correct file suffix, and — now that
|
|
154
|
+
the migration guards itself — the simple instruction to install ALL of them and
|
|
155
|
+
re-run after each upgrade.
|
|
156
|
+
- `EMAIL_TRANSPORT.md`: was pointing at `railties:install:migrations`, which
|
|
157
|
+
copies the migrations of EVERY railtie in the bundle. Scoped to `studio_engine:`.
|
|
158
|
+
|
|
5
159
|
## 0.30.0 — 2026-08-08
|
|
6
160
|
|
|
7
161
|
**The hub's link sidebar becomes the engine's out-of-the-box navigation.** New
|
|
@@ -94,7 +248,9 @@ are **unchanged** — their production hard-close still stands, which is also wh
|
|
|
94
248
|
- **`NEW_APP_SETUP.md` § 9 no longer ships a hand-rolled banner to copy** — it
|
|
95
249
|
renders the shared partial, and documents the QA/email reality.
|
|
96
250
|
- **`NEW_APP_SETUP.md` § 5 now installs the engine migrations FIRST**
|
|
97
|
-
(
|
|
251
|
+
(~~`bin/rails studio:install:migrations`~~ — **erratum, 0.30.1:** that command
|
|
252
|
+
does not exist; the correct task is `studio_engine:install:migrations`).
|
|
253
|
+
Omitting them is silent:
|
|
98
254
|
`Studio::Email.deliver` records mail only when `studio_email_deliveries` exists
|
|
99
255
|
and otherwise falls back to a plain `deliver_later`, so the app drops every
|
|
100
256
|
captured email and shows an empty inbox. Exactly the mcritchie-industries bug,
|
data/README.md
CHANGED
|
@@ -36,7 +36,6 @@ Studio.configure do |config|
|
|
|
36
36
|
config.welcome_message = ->(user) { "Welcome, #{user.display_name}!" }
|
|
37
37
|
config.auth_methods = %i[magic_link google]
|
|
38
38
|
config.registration_params = [:name, :email]
|
|
39
|
-
config.magic_link_token_name = "magic_link_my_app_v1"
|
|
40
39
|
config.mailer_from = Studio.mailer_from_for_transport(
|
|
41
40
|
ses_from: "My App <team@example.com>"
|
|
42
41
|
)
|
|
@@ -76,7 +75,9 @@ Rails.application.routes.draw do
|
|
|
76
75
|
end
|
|
77
76
|
```
|
|
78
77
|
|
|
79
|
-
This draws the enabled auth routes (`/login`, `/signup`, `/logout`,
|
|
78
|
+
This draws the enabled auth routes (`/login`, `/signup`, `/logout`, `POST /magic_link` to request a link, `GET`/`POST /l/:token` for the link itself, Solana routes), OAuth callbacks, optional SSO routes, `/error_logs`, and `/admin/theme`. Magic-link emails point at the inert `GET /l/:token` confirmation page; the single-use token is burned only by the CSRF-protected `POST` to `link_consume_path`.
|
|
79
|
+
|
|
80
|
+
**Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
|
|
80
81
|
|
|
81
82
|
In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
|
|
82
83
|
|
|
@@ -189,13 +189,18 @@ module Studio
|
|
|
189
189
|
raise e
|
|
190
190
|
rescue StandardError => e
|
|
191
191
|
error_log = create_error_log(e)
|
|
192
|
+
# `slug` is NOT in the engine's User contract (REQUIRED_USER_INSTANCE_METHODS
|
|
193
|
+
# is admin? + display_name), and `target` is routinely a User. Unguarded,
|
|
194
|
+
# the LOGGER raises NoMethodError and MASKS the original exception — the
|
|
195
|
+
# exact inverse of what this method exists to do. The name is a convenience
|
|
196
|
+
# column; its absence must never cost the record.
|
|
192
197
|
if target
|
|
193
198
|
error_log.target = target
|
|
194
|
-
error_log.target_name = target.slug
|
|
199
|
+
error_log.target_name = target.slug if target.respond_to?(:slug)
|
|
195
200
|
end
|
|
196
201
|
if parent
|
|
197
202
|
error_log.parent = parent
|
|
198
|
-
error_log.parent_name = parent.slug
|
|
203
|
+
error_log.parent_name = parent.slug if parent.respond_to?(:slug)
|
|
199
204
|
end
|
|
200
205
|
error_log.save!
|
|
201
206
|
@_error_logged = true
|
|
@@ -1,9 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Studio
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# The magic-link click, start to finish — shared by Studio::LinksController
|
|
5
|
+
# and by any app that draws its own token route (turf-monster's contest
|
|
6
|
+
# landing). Two entry points bracket the single-use burn:
|
|
7
|
+
#
|
|
8
|
+
# preview_magic_link(link) — the GET. NEVER burns. Returns :live for a link
|
|
9
|
+
# that is still good (the caller renders the scanner-safe interstitial),
|
|
10
|
+
# and otherwise settles the click here and returns :handled.
|
|
11
|
+
# consume_magic_link(link) — the POST. The one and only place a token burns.
|
|
12
|
+
#
|
|
13
|
+
# Both route their answer through Studio::LinkResolution, the pure decision
|
|
14
|
+
# table, so "what should this click do" has exactly one owner and the GET and
|
|
15
|
+
# the POST can never disagree about it. The invariant that table enforces:
|
|
16
|
+
# **a dead link never touches the session.**
|
|
17
|
+
#
|
|
18
|
+
# Apps customize by overriding the hooks at the bottom, not by re-deciding:
|
|
19
|
+
# sign_in_existing / sign_up_new (the authenticate path), link_continue (the
|
|
20
|
+
# viewer's own live link), link_dead (no session mutation, ever), plus
|
|
21
|
+
# link_login_path / link_home_path for apps whose sign-in page is not
|
|
22
|
+
# `login_path`.
|
|
23
|
+
#
|
|
24
|
+
# `link` is anything responding to #email, #return_to, #live?, #burn and
|
|
25
|
+
# #dead_status — in practice a Studio::Link, or nil for an unknown token.
|
|
7
26
|
#
|
|
8
27
|
# Relies on the host ApplicationController contract from Studio::ErrorHandling:
|
|
9
28
|
# set_app_session, rescue_and_log, current_user, plus root_path / login_path.
|
|
@@ -12,12 +31,62 @@ module Studio
|
|
|
12
31
|
|
|
13
32
|
private
|
|
14
33
|
|
|
34
|
+
# --- entry points ---------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
# GET. Inert by construction: email scanners, link-preview fetchers and the
|
|
37
|
+
# Gmail image proxy all issue a GET against an emailed URL, so burning here
|
|
38
|
+
# would spend the token before the human ever clicked. A dead link is
|
|
39
|
+
# settled immediately rather than sent through a spinner that only POSTs to
|
|
40
|
+
# learn the same thing.
|
|
41
|
+
def preview_magic_link(link)
|
|
42
|
+
return :live if link&.live?
|
|
43
|
+
|
|
44
|
+
link_dead(resolve_link_click(link, status: dead_status_for(link)), link)
|
|
45
|
+
:handled
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# POST. Burns first — the burn is atomic, so winning it IS the proof the
|
|
49
|
+
# link was live — then acts on what the burn returned.
|
|
50
|
+
def consume_magic_link(link)
|
|
51
|
+
claimed = link.present? && link.burn
|
|
52
|
+
outcome = resolve_link_click(link, status: claimed ? :claimed : dead_status_for(link))
|
|
53
|
+
|
|
54
|
+
case outcome.action
|
|
55
|
+
when :authenticate
|
|
56
|
+
user = User.find_by(email: link.email)
|
|
57
|
+
user ? sign_in_existing(user, link) : sign_up_new(link)
|
|
58
|
+
when :continue
|
|
59
|
+
link_continue(link, outcome)
|
|
60
|
+
else
|
|
61
|
+
link_dead(outcome, link)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_link_click(link, status:)
|
|
66
|
+
Studio::LinkResolution.call(
|
|
67
|
+
status: status,
|
|
68
|
+
link_email: link&.email,
|
|
69
|
+
session_email: current_user&.email
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def dead_status_for(link)
|
|
74
|
+
link.nil? ? :unknown : link.dead_status
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# --- outcomes -------------------------------------------------------------
|
|
78
|
+
|
|
15
79
|
def sign_in_existing(user, result)
|
|
16
80
|
set_app_session(user)
|
|
17
81
|
# Clicking the link proves email ownership, so verify any account that
|
|
18
82
|
# reached here without it (e.g. a Google/wallet-only signup).
|
|
19
|
-
|
|
20
|
-
|
|
83
|
+
#
|
|
84
|
+
# rescue_and_log because this is a WRITE, and the session is already
|
|
85
|
+
# established above it: a consumer app with an extra User validation would
|
|
86
|
+
# otherwise turn a routine sign-in into a 500 the visitor sees while
|
|
87
|
+
# actually signed in, with nothing in ErrorLog to explain it.
|
|
88
|
+
rescue_and_log(target: user) { verify_email_ownership(user) }
|
|
89
|
+
redirect_to(link_destination(:return_to, result), notice: "Signed in. Welcome back!")
|
|
21
90
|
end
|
|
22
91
|
|
|
23
92
|
# Build → configure_new_user → save!. No password — email auth is link-only.
|
|
@@ -26,9 +95,9 @@ module Studio
|
|
|
26
95
|
Studio.configure_new_user.call(user)
|
|
27
96
|
rescue_and_log(target: user) do
|
|
28
97
|
user.save!
|
|
29
|
-
|
|
98
|
+
verify_email_ownership(user)
|
|
30
99
|
set_app_session(user)
|
|
31
|
-
redirect_to(
|
|
100
|
+
redirect_to(link_destination(:return_to, result), notice: Studio.welcome_message.call(user))
|
|
32
101
|
end
|
|
33
102
|
rescue ActiveRecord::RecordNotUnique
|
|
34
103
|
# Two valid tokens for the same brand-new email consumed near-simultaneously
|
|
@@ -37,10 +106,61 @@ module Studio
|
|
|
37
106
|
existing = User.find_by(email: result.email)
|
|
38
107
|
return sign_in_existing(existing, result) if existing
|
|
39
108
|
|
|
40
|
-
redirect_to
|
|
109
|
+
redirect_to link_login_path, alert: "We couldn't finish creating your account. Please try again."
|
|
41
110
|
rescue StandardError => e
|
|
42
111
|
Rails.logger.error("[Studio::LinkConsumption#sign_up_new] signup failed #{e.class}: #{e.message}")
|
|
43
|
-
redirect_to
|
|
112
|
+
redirect_to link_login_path, alert: "We couldn't finish creating your account. Please try again."
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The viewer's own still-live link. The token burns (nobody replays it
|
|
116
|
+
# later) but the session is left exactly as it stands — re-authenticating
|
|
117
|
+
# would buy nothing and would cost a host that rotates the session on
|
|
118
|
+
# sign-in every scrap of state the visitor had built up. From the visitor's
|
|
119
|
+
# side this is indistinguishable from following a plain link, which is the
|
|
120
|
+
# entire point.
|
|
121
|
+
def link_continue(result, _outcome)
|
|
122
|
+
# Same write, same guard as sign_in_existing — and here the stakes are
|
|
123
|
+
# sharper still: this path exists to be invisible, so an unlogged 500 on a
|
|
124
|
+
# re-click would be the loudest thing about it.
|
|
125
|
+
rescue_and_log(target: current_user) { verify_email_ownership(current_user) }
|
|
126
|
+
redirect_to link_destination(:return_to, result)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A used, expired, or unrecognized link. Touching the session here is the
|
|
130
|
+
# bug this whole concern was written to remove: the visitor's session has
|
|
131
|
+
# nothing to do with the state of a token someone mailed them.
|
|
132
|
+
def link_dead(outcome, result)
|
|
133
|
+
path = link_destination(outcome.destination, result)
|
|
134
|
+
return redirect_to(path) if outcome.silent?
|
|
135
|
+
|
|
136
|
+
redirect_to path, outcome.level => outcome.message
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# --- hooks + helpers ------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def link_destination(destination, result)
|
|
142
|
+
case destination
|
|
143
|
+
when :login then link_login_path
|
|
144
|
+
when :home then link_home_path
|
|
145
|
+
else safe_path(result&.return_to) || link_home_path
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Where a visitor with NO session lands when the link is dead. Apps whose
|
|
150
|
+
# sign-in page is not `login_path` (turf-monster: signin_path) override this.
|
|
151
|
+
def link_login_path
|
|
152
|
+
login_path
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Where a click lands when the link's own destination is not ours to follow.
|
|
156
|
+
def link_home_path
|
|
157
|
+
root_path
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def verify_email_ownership(user)
|
|
161
|
+
return unless user.respond_to?(:email_verified_at) && user.email_verified_at.blank?
|
|
162
|
+
|
|
163
|
+
user.update!(email_verified_at: Time.current)
|
|
44
164
|
end
|
|
45
165
|
|
|
46
166
|
# Only same-origin absolute paths survive; everything else collapses to nil.
|
|
@@ -2,36 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
module Studio
|
|
4
4
|
# Minting a magic link, and building the URL that consumes it — the two halves
|
|
5
|
-
# of one decision
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
5
|
+
# of one decision, kept in one place so they can never drift apart. Handing
|
|
6
|
+
# out the wrong URL for a token yields an "invalid or expired link" on a link
|
|
7
|
+
# that was perfectly valid: the failure mode this concern exists to prevent.
|
|
8
|
+
#
|
|
9
|
+
# Since 0.31.0 there is one store (a Studio::Link row) and one token format
|
|
10
|
+
# (Studio::LinkToken.generate — 16 URL-safe characters), so the only remaining
|
|
11
|
+
# question is the PATH: the standard /l/<token>, or an app's own token route
|
|
12
|
+
# (turf-monster keeps /magic_link/<token> because /l is already its
|
|
13
|
+
# landing-page namespace).
|
|
10
14
|
#
|
|
11
15
|
# Included by every issuer: MagicLinksController (the request-a-link flow),
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# wholesale brings its own issuing
|
|
16
|
+
# RegistrationsController (the passwordless signup POST), UserMailer (the
|
|
17
|
+
# emailed link), and Studio::LocalReviewsController (the dev-only local-review
|
|
18
|
+
# link). An app that overrides those wholesale brings its own issuing.
|
|
15
19
|
module MagicLinkIssuing
|
|
16
20
|
extend ActiveSupport::Concern
|
|
17
21
|
|
|
18
22
|
private
|
|
19
23
|
|
|
20
|
-
# Mint a token
|
|
21
|
-
# stateless MessageVerifier link; :database mints a Studio::Link row (the
|
|
22
|
-
# short, unified scheme). `return_to` is sanitized by both stores.
|
|
24
|
+
# Mint a token. `return_to` is sanitized to a same-origin path by the store.
|
|
23
25
|
def issue_magic_link(email, return_to)
|
|
24
|
-
|
|
25
|
-
Studio::Link.create_magic_link(email: email, return_to: return_to).token
|
|
26
|
-
else
|
|
27
|
-
MagicLink.generate(email: email, return_to: return_to)
|
|
28
|
-
end
|
|
26
|
+
Studio::Link.create_magic_link(email: email, return_to: return_to).token
|
|
29
27
|
end
|
|
30
28
|
|
|
31
|
-
# The URL that CONSUMES the token this app mints
|
|
32
|
-
# the :database scheme, the legacy /magic_link/<token> for :signed (and for
|
|
33
|
-
# a :database app that keeps its own /magic_link route, e.g. turf-monster,
|
|
34
|
-
# whose /l is already its landing-page namespace).
|
|
29
|
+
# The URL that CONSUMES the token this app mints.
|
|
35
30
|
def magic_link_url_for(token)
|
|
36
31
|
Studio.magic_link_via_l_route? ? link_url(token: token) : magic_link_url(token: token)
|
|
37
32
|
end
|
|
@@ -1,24 +1,21 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Requesting a magic link — the passwordless email path's front door.
|
|
2
2
|
#
|
|
3
|
-
# POST /magic_link
|
|
4
|
-
# GET /magic_link/:token — "Confirm sign-in" interstitial (does NOT consume)
|
|
5
|
-
# POST /magic_link/:token — consume it: log in OR create the account
|
|
3
|
+
# POST /magic_link — request a link (email [, return_to])
|
|
6
4
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# which
|
|
5
|
+
# That is the whole controller. The token-bearing half lives at /l/<token>
|
|
6
|
+
# (Studio::LinksController): one token format, one place it burns. Before
|
|
7
|
+
# 0.31.0 this class also owned a /magic_link/:token confirm+consume pair for
|
|
8
|
+
# the retired :signed store, which was a second door onto the same lock.
|
|
11
9
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
10
|
+
# create-or-login: clicking the emailed link IS proof of email ownership, so an
|
|
11
|
+
# email that collides with a Google/wallet-only account that was never
|
|
12
|
+
# email-verified is safely signed in at consume time and stamped
|
|
13
|
+
# email_verified_at (unlike from_omniauth, which refuses that collision
|
|
14
|
+
# precisely because it lacked this proof).
|
|
16
15
|
class MagicLinksController < ApplicationController
|
|
17
|
-
include Studio::LinkConsumption
|
|
18
16
|
include Studio::MagicLinkIssuing
|
|
19
17
|
|
|
20
18
|
skip_before_action :require_authentication
|
|
21
|
-
layout false, only: :confirm
|
|
22
19
|
|
|
23
20
|
# Respond uniformly for any well-formed email. Under create-or-login every
|
|
24
21
|
# address is "valid" (it logs in or signs up), so there is nothing to
|
|
@@ -26,7 +23,7 @@ class MagicLinksController < ApplicationController
|
|
|
26
23
|
def create
|
|
27
24
|
email = params[:email].to_s.strip.downcase
|
|
28
25
|
if email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
29
|
-
token = issue_magic_link(email,
|
|
26
|
+
token = issue_magic_link(email, Studio::LinkToken.sanitize_path(params[:return_to]))
|
|
30
27
|
Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
|
|
31
28
|
end
|
|
32
29
|
respond_to do |format|
|
|
@@ -35,31 +32,7 @@ class MagicLinksController < ApplicationController
|
|
|
35
32
|
end
|
|
36
33
|
end
|
|
37
34
|
|
|
38
|
-
#
|
|
39
|
-
# preview clients frequently prefetch emailed URLs with GET/HEAD; if GET burned
|
|
40
|
-
# the token, the human's first real click could already be invalid. The page
|
|
41
|
-
# renders a CSRF-protected form that a browser auto-POSTs to #consume.
|
|
42
|
-
def confirm
|
|
43
|
-
# strict-origin strips the token-bearing path from subresource Referer
|
|
44
|
-
# headers while preserving a usable Origin header for Rails' CSRF origin
|
|
45
|
-
# check on the consume POST.
|
|
46
|
-
response.set_header("Referrer-Policy", "strict-origin")
|
|
47
|
-
@token = params[:token]
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# POST /magic_link/:token is the authoritative consume. This is the only place
|
|
51
|
-
# the single-use token is burned.
|
|
52
|
-
def consume
|
|
53
|
-
response.set_header("Referrer-Policy", "strict-origin")
|
|
54
|
-
result = MagicLink.consume(params[:token])
|
|
55
|
-
user = User.find_by(email: result.email)
|
|
56
|
-
user ? sign_in_existing(user, result) : sign_up_new(result)
|
|
57
|
-
rescue MagicLink::InvalidToken
|
|
58
|
-
redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# issue_magic_link (mint in the configured store) comes from
|
|
35
|
+
# issue_magic_link (mint a Studio::Link row) comes from
|
|
62
36
|
# Studio::MagicLinkIssuing, shared with UserMailer — which builds the URL that
|
|
63
37
|
# consumes it — so the mint and its landing URL cannot drift apart.
|
|
64
|
-
# sign_in_existing / sign_up_new / safe_path come from Studio::LinkConsumption.
|
|
65
38
|
end
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
class RegistrationsController < ApplicationController
|
|
2
|
+
include Studio::MagicLinkIssuing
|
|
3
|
+
|
|
2
4
|
skip_before_action :require_authentication
|
|
3
5
|
|
|
4
6
|
def new
|
|
@@ -13,7 +15,7 @@ class RegistrationsController < ApplicationController
|
|
|
13
15
|
unless Studio.auth_method?(:password)
|
|
14
16
|
email = (params.dig(:user, :email) || params[:email]).to_s.strip.downcase
|
|
15
17
|
if email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
16
|
-
token =
|
|
18
|
+
token = issue_magic_link(email, nil)
|
|
17
19
|
Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
|
|
18
20
|
end
|
|
19
21
|
return redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Studio
|
|
2
|
-
# The
|
|
3
|
-
# Studio::Link#kind:
|
|
2
|
+
# The short-token link entry point — GET/POST /l/<token>, the only magic-link
|
|
3
|
+
# door the engine draws. Dispatches by Studio::Link#kind:
|
|
4
4
|
#
|
|
5
5
|
# magic_link → scanner-safe confirm interstitial (GET, inert) that auto-POSTs
|
|
6
6
|
# to #consume, the ONLY place the single-use token is burned +
|
|
@@ -9,6 +9,10 @@ module Studio
|
|
|
9
9
|
# the link's target (or root). Reusable + safe to prefetch, so
|
|
10
10
|
# GET does the work (no POST step).
|
|
11
11
|
#
|
|
12
|
+
# Every decision about what a magic-link click DOES lives in
|
|
13
|
+
# Studio::LinkConsumption / Studio::LinkResolution, not here — including the
|
|
14
|
+
# invariant that a dead link leaves the visitor's session untouched.
|
|
15
|
+
#
|
|
12
16
|
# Namespaced (not top-level Links) because mcritchie-studio already owns a
|
|
13
17
|
# public /links linktree (top-level LinksController). Apps needing richer
|
|
14
18
|
# post-consume routing (contest landing, picks rehydration, age-gate) define
|
|
@@ -25,30 +29,24 @@ module Studio
|
|
|
25
29
|
response.set_header("Referrer-Policy", "strict-origin")
|
|
26
30
|
@link = Studio::Link.find_by(token: params[:token])
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
when "magic_link"
|
|
30
|
-
@token = params[:token]
|
|
31
|
-
render :confirm
|
|
32
|
-
when "referral"
|
|
32
|
+
if @link&.kind == "referral"
|
|
33
33
|
capture_referral(@link)
|
|
34
|
-
redirect_to(@link.target || root_path)
|
|
35
|
-
else
|
|
36
|
-
redirect_to login_path, alert: "That link is invalid or has expired. Request a fresh one below."
|
|
34
|
+
return redirect_to(@link.target || root_path)
|
|
37
35
|
end
|
|
36
|
+
|
|
37
|
+
# A magic link, or a token with no row behind it. preview_magic_link is
|
|
38
|
+
# inert — it never burns — and settles a dead link itself rather than
|
|
39
|
+
# sending the visitor through a spinner that only POSTs to learn the same.
|
|
40
|
+
@token = params[:token]
|
|
41
|
+
render :confirm if preview_magic_link(@link&.kind == "magic_link" ? @link : nil) == :live
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
# POST /l/:token — authoritative magic-link consume. Only magic_link kinds are
|
|
41
|
-
# consumable here; referral links are reusable and handled entirely on GET
|
|
45
|
+
# consumable here; referral links are reusable and handled entirely on GET,
|
|
46
|
+
# so a referral token scoped out below reads as an unknown token.
|
|
42
47
|
def consume
|
|
43
48
|
response.set_header("Referrer-Policy", "strict-origin")
|
|
44
|
-
|
|
45
|
-
raise Studio::Link::InvalidToken, "not a magic link" unless link&.kind == "magic_link"
|
|
46
|
-
|
|
47
|
-
link.consume! # burns the single-use token; raises if already used / expired
|
|
48
|
-
user = User.find_by(email: link.email)
|
|
49
|
-
user ? sign_in_existing(user, link) : sign_up_new(link)
|
|
50
|
-
rescue Studio::Link::InvalidToken
|
|
51
|
-
redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
|
|
49
|
+
consume_magic_link(Studio::Link.magic_links.find_by(token: params[:token]))
|
|
52
50
|
end
|
|
53
51
|
|
|
54
52
|
private
|
|
@@ -30,10 +30,10 @@ module Studio
|
|
|
30
30
|
email = Studio::LinkToken.normalize_email(params[:email])
|
|
31
31
|
return redirect_to(login_path, alert: MISSING_EMAIL) unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
32
32
|
|
|
33
|
-
# return_to is passed through raw:
|
|
34
|
-
# path on the way in (Studio::Link.create_magic_link
|
|
35
|
-
#
|
|
36
|
-
#
|
|
33
|
+
# return_to is passed through raw: the store sanitizes it to a same-origin
|
|
34
|
+
# path on the way in (Studio::Link.create_magic_link calls
|
|
35
|
+
# Studio::LinkToken.sanitize_path). Re-sanitizing here would be a second
|
|
36
|
+
# spelling of a rule that already has one owner — and a mutation run
|
|
37
37
|
# confirmed it guards nothing the store does not already guard.
|
|
38
38
|
token = issue_magic_link(email, params[:return_to])
|
|
39
39
|
redirect_to magic_link_url_for(token), allow_other_host: false
|
data/app/mailers/user_mailer.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
class UserMailer < ApplicationMailer
|
|
2
|
-
# magic_link_url_for — the URL that consumes the token
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# cannot drift apart.
|
|
2
|
+
# magic_link_url_for — the URL that consumes the token. Shared with the
|
|
3
|
+
# issuers that MINT it (MagicLinksController, RegistrationsController,
|
|
4
|
+
# Studio::LocalReviewsController) so the two halves cannot drift apart.
|
|
6
5
|
include Studio::MagicLinkIssuing
|
|
7
6
|
|
|
8
7
|
# Branded shell (banner + card) for engine-sent UserMailer emails. An app with
|
|
@@ -10,9 +9,11 @@ class UserMailer < ApplicationMailer
|
|
|
10
9
|
layout "branded_mailer"
|
|
11
10
|
|
|
12
11
|
# Passwordless sign-in link. `email` is a raw string (the recipient may not
|
|
13
|
-
# have an account yet).
|
|
14
|
-
#
|
|
15
|
-
#
|
|
12
|
+
# have an account yet). The token is a Studio::Link row's short token — 16
|
|
13
|
+
# URL-safe characters, single-use, expiring — and the email + return_to it
|
|
14
|
+
# stands for stay in the row, off the wire. Clicking the link logs the
|
|
15
|
+
# recipient in or creates their account. App-name-aware so the same template
|
|
16
|
+
# serves every Studio app.
|
|
16
17
|
#
|
|
17
18
|
# Engine GENERIC base. An app needing richer copy (e.g. turf-monster's
|
|
18
19
|
# contest-aware variant) defines its own UserMailer, which wins.
|
data/app/models/studio/link.rb
CHANGED
|
@@ -11,13 +11,20 @@ module Studio
|
|
|
11
11
|
# discriminator. Replaces the engine's stateless MessageVerifier MagicLink
|
|
12
12
|
# service for mcritchie-studio so both apps share one short-token scheme.
|
|
13
13
|
#
|
|
14
|
-
# Like Studio::EmailDelivery, the table lives in each consumer app
|
|
15
|
-
#
|
|
14
|
+
# Like Studio::EmailDelivery, the table lives in each consumer app — installed
|
|
15
|
+
# by `bin/rails studio_engine:install:migrations`, never hand-copied (a hand
|
|
16
|
+
# copy collides with the task's own copy on `class CreateStudioLinks`). This
|
|
17
|
+
# model is shipped by the gem.
|
|
16
18
|
class Link < ApplicationRecord
|
|
17
19
|
self.table_name = "studio_links"
|
|
18
20
|
|
|
19
21
|
class InvalidToken < StandardError; end
|
|
20
22
|
|
|
23
|
+
# The app enabled :magic_link but never installed the table. Raised in place
|
|
24
|
+
# of a bare PG::UndefinedTable so the first person to hit it reads the fix
|
|
25
|
+
# instead of an adapter error — see mint!.
|
|
26
|
+
class MissingTable < StandardError; end
|
|
27
|
+
|
|
21
28
|
belongs_to :linkable, polymorphic: true, optional: true
|
|
22
29
|
|
|
23
30
|
validates :kind, inclusion: { in: Studio::LinkToken::KINDS }
|
|
@@ -76,6 +83,16 @@ module Studio
|
|
|
76
83
|
|
|
77
84
|
# create! with a fresh random token, retrying the (astronomically rare)
|
|
78
85
|
# unique-index collision a couple of times before surfacing the error.
|
|
86
|
+
#
|
|
87
|
+
# The MissingTable rescue exists because every consumer pins the engine as
|
|
88
|
+
# `~> 0.x`, which admits any release below 1.0 — so an app that never
|
|
89
|
+
# installed this table picks up the row store on its next `bundle update`
|
|
90
|
+
# whether or not anyone adopted it deliberately. Its boot is fine (nothing
|
|
91
|
+
# touches the table until someone signs in), and the failure then lands as
|
|
92
|
+
# a bare PG::UndefinedTable on a real person's sign-in. Naming the fix
|
|
93
|
+
# there is the difference between a five-minute repair and an outage
|
|
94
|
+
# nobody can read. The check is `table_exists?`, not a message match, so
|
|
95
|
+
# it holds on any adapter and never swallows an unrelated failure.
|
|
79
96
|
def mint!(attrs)
|
|
80
97
|
3.times do
|
|
81
98
|
return create!(attrs.merge(token: Studio::LinkToken.generate))
|
|
@@ -83,6 +100,14 @@ module Studio
|
|
|
83
100
|
next
|
|
84
101
|
end
|
|
85
102
|
create!(attrs.merge(token: Studio::LinkToken.generate))
|
|
103
|
+
rescue ActiveRecord::StatementInvalid
|
|
104
|
+
raise if table_exists?
|
|
105
|
+
|
|
106
|
+
raise MissingTable,
|
|
107
|
+
"studio-engine magic links need the studio_links table, and #{Studio.app_name} has no " \
|
|
108
|
+
"such table. Run `bin/rails studio_engine:install:migrations && bin/rails db:migrate` " \
|
|
109
|
+
"(install ALL of them). Do not hand-copy the migration — it collides with the task's " \
|
|
110
|
+
"own copy on `class CreateStudioLinks`."
|
|
86
111
|
end
|
|
87
112
|
end
|
|
88
113
|
|
|
@@ -106,6 +131,34 @@ module Studio
|
|
|
106
131
|
self
|
|
107
132
|
end
|
|
108
133
|
|
|
134
|
+
# The non-raising sibling of #consume!, and the one the click flow uses.
|
|
135
|
+
# Returns whether THIS caller won the burn — false for a link that was
|
|
136
|
+
# already used, has expired, or lost the atomic race to a concurrent click.
|
|
137
|
+
#
|
|
138
|
+
# Why a boolean and not the exception: "the link was dead" is not an error
|
|
139
|
+
# here, it is one of the two normal outcomes, and the branch it feeds
|
|
140
|
+
# (Studio::LinkResolution) needs the answer as data. Reloads on a loss so
|
|
141
|
+
# the caller reads the row's settled state (consumed_at / expires_at) rather
|
|
142
|
+
# than the copy it held before the race.
|
|
143
|
+
def burn
|
|
144
|
+
consume!
|
|
145
|
+
true
|
|
146
|
+
rescue InvalidToken
|
|
147
|
+
reload
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# How a failed burn should be described. Only meaningful once #burn has
|
|
152
|
+
# returned false (or on a link that was never burned at all).
|
|
153
|
+
def dead_status
|
|
154
|
+
return :used if single_use? && consumed?
|
|
155
|
+
return :expired if expired?
|
|
156
|
+
|
|
157
|
+
# Neither flag is set but the burn did not land: a concurrent click won
|
|
158
|
+
# it between our read and our write. Same story for the reader.
|
|
159
|
+
:used
|
|
160
|
+
end
|
|
161
|
+
|
|
109
162
|
def single_use?
|
|
110
163
|
Studio::LinkToken.single_use?(kind)
|
|
111
164
|
end
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
<%#
|
|
2
2
|
Shared scanner-safe sign-in interstitial. The GET that renders this is inert;
|
|
3
3
|
the page auto-POSTs the CSRF-protected form to `consume_path` (the only place
|
|
4
|
-
a single-use token is burned).
|
|
5
|
-
(consume_path:
|
|
6
|
-
|
|
4
|
+
a single-use token is burned). Rendered by Studio::LinksController#show
|
|
5
|
+
(consume_path: link_consume_path) — the engine's only token door since 0.31.0
|
|
6
|
+
— and by any app drawing its own token route. Rendered with layout false
|
|
7
|
+
(full document).
|
|
7
8
|
|
|
8
9
|
Local: consume_path — the POST target that burns the token + signs in.
|
|
9
10
|
%>
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
# migration; each consumer app installs its own copy (the table is app-owned).
|
|
4
4
|
class AllowNullImageCacheOwner < ActiveRecord::Migration[7.2]
|
|
5
5
|
def change
|
|
6
|
+
# No-op on an app that doesn't use ImageCache. `image_caches` is app-owned,
|
|
7
|
+
# so an app can install the engine's migrations without having that table —
|
|
8
|
+
# and unguarded, this raised and failed the whole `db:migrate`.
|
|
9
|
+
#
|
|
10
|
+
# Deleting the copy is NOT a workaround: install:migrations builds its
|
|
11
|
+
# skip-list from the files PRESENT, so a deleted copy is re-copied with a
|
|
12
|
+
# fresh timestamp on the next upgrade and fails again. The guard has to live
|
|
13
|
+
# here, in the migration, or it doesn't hold.
|
|
14
|
+
return unless table_exists?(:image_caches)
|
|
15
|
+
|
|
6
16
|
change_column_null :image_caches, :owner_type, true
|
|
7
17
|
change_column_null :image_caches, :owner_id, true
|
|
8
18
|
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "link_token"
|
|
4
|
+
|
|
5
|
+
module Studio
|
|
6
|
+
# What a magic-link click should DO — the whole decision as one pure table,
|
|
7
|
+
# free of ActiveRecord and of the controller, so every cell is unit-testable.
|
|
8
|
+
#
|
|
9
|
+
# A click has three inputs: whether this caller BURNED the link (won the
|
|
10
|
+
# single-use race), whose email the link carries, and who is already signed
|
|
11
|
+
# in. Six cells fall out of that, and one invariant runs through them:
|
|
12
|
+
#
|
|
13
|
+
# **A dead link never touches the session.**
|
|
14
|
+
#
|
|
15
|
+
# Before this table, every dead token — used, expired, or unknown — ended at
|
|
16
|
+
# `redirect_to login_path, alert: "invalid or has expired"`, whatever session
|
|
17
|
+
# the visitor was holding. So clicking your own link a second time dumped you
|
|
18
|
+
# on a sign-in page, which reads as being logged out. It never was: the cookie
|
|
19
|
+
# survived, but the destination said otherwise. Now a dead link is at worst a
|
|
20
|
+
# notice, and at best silent.
|
|
21
|
+
#
|
|
22
|
+
# The table (rows = link state, columns = who is signed in):
|
|
23
|
+
#
|
|
24
|
+
# | nobody | the link's own user | somebody else
|
|
25
|
+
# -------------+-----------------+---------------------+------------------
|
|
26
|
+
# live (burned)| :authenticate | :continue | :authenticate
|
|
27
|
+
# used/expired | :dead → login | :dead → return_to | :dead → home
|
|
28
|
+
# unknown token| :dead → login | — | :dead → home
|
|
29
|
+
#
|
|
30
|
+
# `:continue` is the cell the operator asked for by name: a second click on
|
|
31
|
+
# your own still-live link must be "no material difference — just a redirect".
|
|
32
|
+
# It burns the token (so nobody replays it later) and deliberately does NOT
|
|
33
|
+
# re-authenticate, because a host that rotates the session on sign-in — as
|
|
34
|
+
# turf-monster does — would otherwise charge a re-click the price of every
|
|
35
|
+
# scrap of session state the visitor had built up.
|
|
36
|
+
module LinkResolution
|
|
37
|
+
# How the click found the link.
|
|
38
|
+
# :claimed — this caller burned a live link (the only status that authenticates)
|
|
39
|
+
# :used — the row exists and was already consumed, or lost the burn race
|
|
40
|
+
# :expired — the row exists and is past expires_at
|
|
41
|
+
# :unknown — no row for this token, or the token is not a magic link
|
|
42
|
+
STATUSES = %i[claimed used expired unknown].freeze
|
|
43
|
+
|
|
44
|
+
# :authenticate — establish a session for the link's email (sign in or sign up)
|
|
45
|
+
# :continue — the viewer already IS the link's user: keep the session as
|
|
46
|
+
# it stands and land them on the link's destination
|
|
47
|
+
# :dead — do not touch the session at all
|
|
48
|
+
ACTIONS = %i[authenticate continue dead].freeze
|
|
49
|
+
|
|
50
|
+
# Where the click lands.
|
|
51
|
+
# :return_to — the link's own destination (falling back to home)
|
|
52
|
+
# :home — the app root; used when the link belongs to someone else,
|
|
53
|
+
# so its destination is not ours to follow
|
|
54
|
+
# :login — the sign-in page; only ever for a visitor with no session
|
|
55
|
+
DESTINATIONS = %i[return_to home login].freeze
|
|
56
|
+
|
|
57
|
+
Outcome = Struct.new(:action, :destination, :message, :level, keyword_init: true) do
|
|
58
|
+
def authenticate?
|
|
59
|
+
action == :authenticate
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def continue?
|
|
63
|
+
action == :continue
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def dead?
|
|
67
|
+
action == :dead
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A silent outcome shows the visitor nothing — the "no material
|
|
71
|
+
# difference" re-click.
|
|
72
|
+
def silent?
|
|
73
|
+
message.nil?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
module_function
|
|
78
|
+
|
|
79
|
+
# @param status [Symbol] one of STATUSES
|
|
80
|
+
# @param link_email [String, nil] the email the link signs in (nil = unknown token)
|
|
81
|
+
# @param session_email [String, nil] the currently signed-in user's email
|
|
82
|
+
# @return [Outcome]
|
|
83
|
+
def call(status:, link_email: nil, session_email: nil)
|
|
84
|
+
raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
|
|
85
|
+
|
|
86
|
+
own = own_link?(link_email, session_email)
|
|
87
|
+
|
|
88
|
+
if status == :claimed
|
|
89
|
+
return Outcome.new(action: :continue, destination: :return_to) if own
|
|
90
|
+
|
|
91
|
+
# Nobody signed in, or somebody else signed in: both establish a session
|
|
92
|
+
# for the link's email. The second case is the deliberate account switch
|
|
93
|
+
# — a live link is proof of ownership, so it outranks the open session.
|
|
94
|
+
return Outcome.new(action: :authenticate, destination: :return_to)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Dead from here down: no branch below may write to the session.
|
|
98
|
+
return Outcome.new(action: :dead, destination: :return_to) if own
|
|
99
|
+
|
|
100
|
+
if Studio::LinkToken.normalize_email(session_email).empty?
|
|
101
|
+
Outcome.new(action: :dead, destination: :login, level: :alert,
|
|
102
|
+
message: dead_message(status: status, link_email: link_email))
|
|
103
|
+
else
|
|
104
|
+
Outcome.new(action: :dead, destination: :home, level: :notice,
|
|
105
|
+
message: dead_message(status: status, link_email: link_email,
|
|
106
|
+
session_email: session_email))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Same email, both sides present. A blank on either side is never a match —
|
|
111
|
+
# an unknown token (no email) must not read as "your own link" just because
|
|
112
|
+
# nobody is signed in.
|
|
113
|
+
def own_link?(link_email, session_email)
|
|
114
|
+
link = Studio::LinkToken.normalize_email(link_email)
|
|
115
|
+
seat = Studio::LinkToken.normalize_email(session_email)
|
|
116
|
+
!link.empty? && link == seat
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The notice a dead link earns. It names the address the link was for and
|
|
120
|
+
# why it failed — the detail that turns "something went wrong" into a
|
|
121
|
+
# decision the reader can act on — and, when a session is open, says so
|
|
122
|
+
# plainly, because the whole point is that nothing was lost.
|
|
123
|
+
def dead_message(status:, link_email: nil, session_email: nil)
|
|
124
|
+
addressee = Studio::LinkToken.normalize_email(link_email)
|
|
125
|
+
who = addressee.empty? ? "" : " for #{addressee}"
|
|
126
|
+
why = case status
|
|
127
|
+
when :expired then "has expired"
|
|
128
|
+
when :used then "was already used"
|
|
129
|
+
else "is no longer valid"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
base = "That sign-in link#{who} #{why}."
|
|
133
|
+
seat = Studio::LinkToken.normalize_email(session_email)
|
|
134
|
+
return "#{base} Request a fresh one below." if seat.empty?
|
|
135
|
+
|
|
136
|
+
"#{base} You are still signed in as #{seat} — request a fresh link to switch accounts."
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/studio/link_token.rb
CHANGED
|
@@ -18,10 +18,19 @@ module Studio
|
|
|
18
18
|
# they are deliberately NOT single-use.
|
|
19
19
|
SINGLE_USE_KINDS = %w[magic_link].freeze
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
21
|
+
# THE HOUSE TOKEN STANDARD: 12 random bytes → exactly 16 URL-safe
|
|
22
|
+
# characters (e.g. "PP-PDbEj5V3-aNh4"). 96 bits of entropy — short enough
|
|
23
|
+
# that the whole link fits on one line of an email, far too large to
|
|
24
|
+
# brute-force, especially for a single-use token that expires in minutes.
|
|
25
|
+
#
|
|
26
|
+
# 16 sits mid-range in the house bound of 10-20 characters (TOKEN_LENGTH_
|
|
27
|
+
# BOUNDS), which is the number a reader should sanity-check a link against.
|
|
28
|
+
# urlsafe_base64 emits 4 characters per 3 bytes with no padding, so the
|
|
29
|
+
# length is exact, not approximate — every token is the same width.
|
|
30
|
+
TOKEN_BYTES = 12
|
|
31
|
+
TOKEN_LENGTH = 16
|
|
32
|
+
TOKEN_LENGTH_BOUNDS = (10..20).freeze
|
|
33
|
+
TOKEN_FORMAT = /\A[A-Za-z0-9_-]+\z/
|
|
25
34
|
|
|
26
35
|
module_function
|
|
27
36
|
|
|
@@ -48,7 +48,8 @@ module Studio
|
|
|
48
48
|
"--color-cta-hover" => ColorScale.darken(primary, 0.30),
|
|
49
49
|
"--color-success" => colors[:success] || "#4BAF50",
|
|
50
50
|
"--color-warning" => colors[:warning] || "#FF7C47",
|
|
51
|
-
"--color-danger" => colors[:danger] || "#EF4444"
|
|
51
|
+
"--color-danger" => colors[:danger] || "#EF4444",
|
|
52
|
+
"--color-accent" => colors[:accent] || "#F72585"
|
|
52
53
|
}
|
|
53
54
|
end
|
|
54
55
|
|
|
@@ -92,7 +93,8 @@ module Studio
|
|
|
92
93
|
"--color-cta-hover" => ColorScale.darken(primary, 0.30),
|
|
93
94
|
"--color-success" => colors[:success] || "#4BAF50",
|
|
94
95
|
"--color-warning" => colors[:warning] || "#FF7C47",
|
|
95
|
-
"--color-danger" => colors[:danger] || "#EF4444"
|
|
96
|
+
"--color-danger" => colors[:danger] || "#EF4444",
|
|
97
|
+
"--color-accent" => colors[:accent] || "#F72585"
|
|
96
98
|
}
|
|
97
99
|
end
|
|
98
100
|
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -9,6 +9,7 @@ require "studio/username_generator"
|
|
|
9
9
|
require "studio/s3"
|
|
10
10
|
require "studio/image_cache"
|
|
11
11
|
require "studio/link_token"
|
|
12
|
+
require "studio/link_resolution"
|
|
12
13
|
require "studio/email"
|
|
13
14
|
require "studio/email_smoke"
|
|
14
15
|
require "studio/mail_transport"
|
|
@@ -80,18 +81,40 @@ module Studio
|
|
|
80
81
|
# end
|
|
81
82
|
mattr_accessor :sidebar_sections, default: []
|
|
82
83
|
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
# How long a freshly minted magic link stays live.
|
|
85
|
+
mattr_accessor :magic_link_ttl, default: 15.minutes
|
|
86
|
+
|
|
87
|
+
# RETIRED (0.31.0) — kept only so an initializer that still sets it boots.
|
|
88
|
+
# It named the MessageVerifier purpose for the old :signed store, which no
|
|
89
|
+
# longer exists. Delete the line from your initializer.
|
|
86
90
|
mattr_accessor :magic_link_token_name, default: "magic_link_v1"
|
|
87
91
|
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
|
|
92
|
+
# RETIRED (0.31.0) — magic links are ALWAYS Studio::Link rows now, so this
|
|
93
|
+
# reads :database and nothing else. Assigning :signed raises rather than
|
|
94
|
+
# silently downgrading: that store minted a ~350-character MessageVerifier
|
|
95
|
+
# blob whose EXPIRED form cannot be decoded, so an app on it could not tell
|
|
96
|
+
# whose dead link it was holding — which is exactly the fact
|
|
97
|
+
# Studio::LinkResolution needs to leave a live session alone. Requires the
|
|
98
|
+
# studio_links table, installed by `bin/rails studio_engine:install:migrations`
|
|
99
|
+
# (never hand-copied — a hand copy collides with the task's own copy on
|
|
100
|
+
# `class CreateStudioLinks`).
|
|
101
|
+
mattr_reader :magic_link_store, default: :database
|
|
102
|
+
|
|
103
|
+
# `to_s.to_sym`, not `to_sym`: this runs from an initializer, and nil or an
|
|
104
|
+
# Integer would raise NoMethodError — swallowing the explanation below with a
|
|
105
|
+
# message that says nothing about what to do. A blank falls through to the
|
|
106
|
+
# raise instead, so the operator reads the actual instruction.
|
|
107
|
+
def self.magic_link_store=(value)
|
|
108
|
+
return if value.to_s.to_sym == :database
|
|
109
|
+
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"Studio.magic_link_store = #{value.inspect} is retired (studio-engine 0.31.0). " \
|
|
112
|
+
"Magic links are Studio::Link rows served at /l/<token>. Delete this line from " \
|
|
113
|
+
"config/initializers/studio.rb, then install the table with " \
|
|
114
|
+
"`bin/rails studio_engine:install:migrations && bin/rails db:migrate` — in that " \
|
|
115
|
+
"order, because this raise fires while the initializer loads and no rake task can " \
|
|
116
|
+
"boot until the line is gone."
|
|
117
|
+
end
|
|
95
118
|
|
|
96
119
|
# Whether Studio.routes draws the magic_link + solana wallet routes. An app that
|
|
97
120
|
# already defines its own auth routes (e.g. turf-monster, which has battle-tested
|
|
@@ -229,13 +252,13 @@ module Studio
|
|
|
229
252
|
false
|
|
230
253
|
end
|
|
231
254
|
|
|
232
|
-
# True when the emailed/inbox magic-link URL is the short /l/<token> —
|
|
233
|
-
#
|
|
234
|
-
# the
|
|
235
|
-
#
|
|
236
|
-
#
|
|
255
|
+
# True when the emailed/inbox magic-link URL is the short /l/<token> — the
|
|
256
|
+
# standard. False means this app draws its own token route instead and owns
|
|
257
|
+
# the matching consume: turf-monster keeps /magic_link/<token> because /l is
|
|
258
|
+
# already its landing-page namespace. Either way the TOKEN is the same short
|
|
259
|
+
# Studio::Link token; only the path in front of it differs.
|
|
237
260
|
def self.magic_link_via_l_route?
|
|
238
|
-
|
|
261
|
+
draw_link_routes
|
|
239
262
|
end
|
|
240
263
|
|
|
241
264
|
# The floor every developer-desk tool sits on: the local email inbox
|
|
@@ -393,18 +416,15 @@ module Studio
|
|
|
393
416
|
get "_studio/local_review", to: "studio/local_reviews#show", as: :studio_local_review
|
|
394
417
|
end
|
|
395
418
|
|
|
396
|
-
# Passwordless email (magic link).
|
|
397
|
-
#
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
-
#
|
|
401
|
-
#
|
|
419
|
+
# Passwordless email (magic link) — the REQUEST half only. Helper:
|
|
420
|
+
# magic_link_request_path (POST an email address, get a link mailed).
|
|
421
|
+
#
|
|
422
|
+
# The token-bearing half moved to /l/<token> below (0.31.0). There is one
|
|
423
|
+
# token format now — a short Studio::Link row — and one place that burns
|
|
424
|
+
# it, so the old /magic_link/:token confirm+consume pair would have been a
|
|
425
|
+
# second door onto the same lock.
|
|
402
426
|
if Studio.draw_auth_routes && Studio.auth_method?(:magic_link)
|
|
403
|
-
post "magic_link",
|
|
404
|
-
get "magic_link/:token", to: "magic_links#confirm", as: :magic_link,
|
|
405
|
-
constraints: { token: %r{[^/]+} }
|
|
406
|
-
post "magic_link/:token", to: "magic_links#consume", as: :magic_link_consume,
|
|
407
|
-
constraints: { token: %r{[^/]+} }
|
|
427
|
+
post "magic_link", to: "magic_links#create", as: :magic_link_request
|
|
408
428
|
end
|
|
409
429
|
|
|
410
430
|
# Unified short-token links — /l/<token> for magic sign-in links + referral
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.31.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -220,7 +220,6 @@ files:
|
|
|
220
220
|
- app/models/studio/model_page.rb
|
|
221
221
|
- app/models/theme_setting.rb
|
|
222
222
|
- app/services/google_oauth_validator.rb
|
|
223
|
-
- app/services/magic_link.rb
|
|
224
223
|
- app/services/studio/email_image.rb
|
|
225
224
|
- app/views/components/_admin_dropdown.html.erb
|
|
226
225
|
- app/views/components/_avatar.html.erb
|
|
@@ -247,7 +246,6 @@ files:
|
|
|
247
246
|
- app/views/layouts/studio/_flash.html.erb
|
|
248
247
|
- app/views/layouts/studio/_head.html.erb
|
|
249
248
|
- app/views/layouts/studio/_smooth_load.html.erb
|
|
250
|
-
- app/views/magic_links/confirm.html.erb
|
|
251
249
|
- app/views/navbar/show.html.erb
|
|
252
250
|
- app/views/registrations/new.html.erb
|
|
253
251
|
- app/views/schema/index.html.erb
|
|
@@ -336,6 +334,7 @@ files:
|
|
|
336
334
|
- lib/studio/engine.rb
|
|
337
335
|
- lib/studio/environment_banner.rb
|
|
338
336
|
- lib/studio/image_cache.rb
|
|
337
|
+
- lib/studio/link_resolution.rb
|
|
339
338
|
- lib/studio/link_token.rb
|
|
340
339
|
- lib/studio/mail_transport.rb
|
|
341
340
|
- lib/studio/redis.rb
|
data/app/services/magic_link.rb
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
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
|
-
# Back-compat defaults. Behavior is driven by the `token_name` / `ttl` methods
|
|
25
|
-
# (which read Studio config); these constants remain so existing consumer code
|
|
26
|
-
# /tests referencing MagicLink::TTL keep working, and they equal the config
|
|
27
|
-
# defaults.
|
|
28
|
-
TOKEN_KEY = "magic_link_v1"
|
|
29
|
-
TTL = 15.minutes
|
|
30
|
-
|
|
31
|
-
class InvalidToken < StandardError; end
|
|
32
|
-
|
|
33
|
-
Result = Struct.new(:email, :return_to, keyword_init: true)
|
|
34
|
-
|
|
35
|
-
class << self
|
|
36
|
-
# Test seam — defaults to Rails.cache. The service unit test sets this to
|
|
37
|
-
# an ActiveSupport::Cache::MemoryStore to assert single-use, then resets it.
|
|
38
|
-
attr_writer :cache
|
|
39
|
-
|
|
40
|
-
def cache
|
|
41
|
-
@cache || Rails.cache
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def token_name
|
|
45
|
-
Studio.magic_link_token_name
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def ttl
|
|
49
|
-
Studio.magic_link_ttl
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# jti outlives the token so a valid token's jti is always still present.
|
|
53
|
-
def jti_ttl
|
|
54
|
-
ttl + 5.minutes
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Returns a signed token string. `return_to` is sanitized to a local path.
|
|
58
|
-
# The MessageVerifier blob is standard base64 (can contain "/" and "+"),
|
|
59
|
-
# which breaks the `%r{[^/]+}` route constraint once the payload is large
|
|
60
|
-
# enough to emit a "/". Wrap it URL-safe so the token is always
|
|
61
|
-
# [A-Za-z0-9_-]=, matching the route and surviving URL generation.
|
|
62
|
-
def generate(email:, return_to: nil)
|
|
63
|
-
normalized = normalize_email(email)
|
|
64
|
-
jti = SecureRandom.hex(16)
|
|
65
|
-
cache.write(jti_key(jti), normalized, expires_in: jti_ttl) if enforce_single_use?
|
|
66
|
-
raw = verifier.generate(
|
|
67
|
-
{ email: normalized, return_to: sanitize_path(return_to), jti: jti, v: 1 },
|
|
68
|
-
expires_in: ttl
|
|
69
|
-
)
|
|
70
|
-
Base64.urlsafe_encode64(raw)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Verifies signature + expiry + single-use. Returns a Result or raises
|
|
74
|
-
# InvalidToken. Idempotency is NOT offered — a consumed token is dead.
|
|
75
|
-
def consume(token)
|
|
76
|
-
raw = Base64.urlsafe_decode64(token.to_s)
|
|
77
|
-
payload = verifier.verify(raw).with_indifferent_access
|
|
78
|
-
raise InvalidToken, "unexpected token shape" unless payload[:v] == 1 && payload[:email].present?
|
|
79
|
-
|
|
80
|
-
if enforce_single_use?
|
|
81
|
-
# delete returns true only when the jti was still present
|
|
82
|
-
raise InvalidToken, "link already used or expired" unless cache.delete(jti_key(payload[:jti]))
|
|
83
|
-
elsif !Rails.env.test?
|
|
84
|
-
# Single-use is disabled (non-tracking cache). Expected in :null_store
|
|
85
|
-
# dev; in any other env it means replay protection is silently OFF —
|
|
86
|
-
# tokens are replayable for their TTL. Surface it loudly.
|
|
87
|
-
Rails.logger.warn("[MagicLink] single-use NOT enforced (cache=#{cache.class}); links are replayable until expiry")
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
Result.new(email: payload[:email], return_to: sanitize_path(payload[:return_to]))
|
|
91
|
-
rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError
|
|
92
|
-
# ArgumentError → malformed base64 (tampered/truncated token).
|
|
93
|
-
raise InvalidToken, "invalid or expired link"
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
private
|
|
97
|
-
|
|
98
|
-
def verifier
|
|
99
|
-
Rails.application.message_verifier(token_name)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def jti_key(jti)
|
|
103
|
-
"magic_link/jti/#{jti}"
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def normalize_email(email)
|
|
107
|
-
email.to_s.strip.downcase
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Only same-origin absolute paths survive; everything else (protocol-relative
|
|
111
|
-
# "//evil", absolute URLs, blank) collapses to nil so callers fall back to a
|
|
112
|
-
# default redirect.
|
|
113
|
-
def sanitize_path(path)
|
|
114
|
-
p = path.to_s
|
|
115
|
-
p.start_with?("/") && !p.start_with?("//") ? p : nil
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def enforce_single_use?
|
|
119
|
-
!cache.is_a?(ActiveSupport::Cache::NullStore)
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|