Class: MagicLink
- Inherits:
-
Object
- Object
- MagicLink
- Defined in:
- app/services/magic_link.rb
Overview
Unified create-or-login magic link.
A magic link is a signed, short-lived, single-use token keyed on an EMAIL (the user may not exist yet — clicking the link either logs them in or creates the account). The token is a ‘message_verifier(token_name)` payload carrying the email + a sanitized return_to + a random jti.
Single-use is enforced with the jti: on ‘generate` we record the jti in Rails.cache (Redis, cross-process); on `consume` we delete it and reject if it was already gone (replay / second click). The signature already covers tamper + expiry; the jti closes the replay gap.
Token name + TTL come from Studio config (Studio.magic_link_token_name / Studio.magic_link_ttl) so each app can tune them; the jti cache entry is always given a few extra minutes so a still-valid token’s jti is present.
NOTE on test env: the test cache is :null_store, where writes/deletes are no-ops and ‘delete` always returns false — enforcing single-use there would reject every legitimate consume. So enforcement is skipped for non-tracking stores; the service unit test injects a real MemoryStore to exercise it.
Lifted into studio-engine (was turf-monster app/services/magic_link.rb).
Defined Under Namespace
Classes: InvalidToken, Result
Constant Summary collapse
- TOKEN_KEY =
Back-compat defaults. Behavior is driven by the ‘token_name` / `ttl` methods (which read Studio config); these constants remain so existing consumer code /tests referencing MagicLink::TTL keep working, and they equal the config defaults.
"magic_link_v1"- TTL =
15.minutes
Class Attribute Summary collapse
Class Method Summary collapse
-
.consume(token) ⇒ Object
Verifies signature + expiry + single-use.
-
.generate(email:, return_to: nil) ⇒ Object
Returns a signed token string.
-
.jti_ttl ⇒ Object
jti outlives the token so a valid token’s jti is always still present.
- .token_name ⇒ Object
- .ttl ⇒ Object
Class Attribute Details
.cache ⇒ Object
40 41 42 |
# File 'app/services/magic_link.rb', line 40 def cache @cache || Rails.cache end |
Class Method Details
.consume(token) ⇒ Object
Verifies signature + expiry + single-use. Returns a Result or raises InvalidToken. Idempotency is NOT offered — a consumed token is dead.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'app/services/magic_link.rb', line 75 def consume(token) raw = Base64.urlsafe_decode64(token.to_s) payload = verifier.verify(raw).with_indifferent_access raise InvalidToken, "unexpected token shape" unless payload[:v] == 1 && payload[:email].present? if enforce_single_use? # delete returns true only when the jti was still present raise InvalidToken, "link already used or expired" unless cache.delete(jti_key(payload[:jti])) elsif !Rails.env.test? # Single-use is disabled (non-tracking cache). Expected in :null_store # dev; in any other env it means replay protection is silently OFF — # tokens are replayable for their TTL. Surface it loudly. Rails.logger.warn("[MagicLink] single-use NOT enforced (cache=#{cache.class}); links are replayable until expiry") end Result.new(email: payload[:email], return_to: sanitize_path(payload[:return_to])) rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError # ArgumentError → malformed base64 (tampered/truncated token). raise InvalidToken, "invalid or expired link" end |
.generate(email:, return_to: nil) ⇒ Object
Returns a signed token string. ‘return_to` is sanitized to a local path. The MessageVerifier blob is standard base64 (can contain “/” and “+”), which breaks the `%r[^/]+` route constraint once the payload is large enough to emit a “/”. Wrap it URL-safe so the token is always [A-Za-z0-9_-]=, matching the route and surviving URL generation.
62 63 64 65 66 67 68 69 70 71 |
# File 'app/services/magic_link.rb', line 62 def generate(email:, return_to: nil) normalized = normalize_email(email) jti = SecureRandom.hex(16) cache.write(jti_key(jti), normalized, expires_in: jti_ttl) if enforce_single_use? raw = verifier.generate( { email: normalized, return_to: sanitize_path(return_to), jti: jti, v: 1 }, expires_in: ttl ) Base64.urlsafe_encode64(raw) end |
.jti_ttl ⇒ Object
jti outlives the token so a valid token’s jti is always still present.
53 54 55 |
# File 'app/services/magic_link.rb', line 53 def jti_ttl ttl + 5.minutes end |
.token_name ⇒ Object
44 45 46 |
# File 'app/services/magic_link.rb', line 44 def token_name Studio.magic_link_token_name end |
.ttl ⇒ Object
48 49 50 |
# File 'app/services/magic_link.rb', line 48 def ttl Studio.magic_link_ttl end |