Class: MagicLink

Inherits:
Object
  • Object
show all
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

Class Attribute Details

.cacheObject



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_ttlObject

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_nameObject



44
45
46
# File 'app/services/magic_link.rb', line 44

def token_name
  Studio.magic_link_token_name
end

.ttlObject



48
49
50
# File 'app/services/magic_link.rb', line 48

def ttl
  Studio.magic_link_ttl
end