Module: Everywhere::AuthToken

Defined in:
lib/everywhere/auth_token.rb

Overview

The sealed envelopes the native auth flow passes around: the marker cookie that says "this browser session is completing a sign-in for the app", and the handoff token that carries the resulting cookie jar back into the app's web view.

Self-contained (encrypt-then-authenticate, expiry inside the payload) rather than a server-side store: the flow crosses processes — and in a load-balanced deploy, hosts — between the auth browser and the web view, and a shared store would be one more thing to configure. AES-256-GCM with the purpose as associated data, so a marker can never be replayed as a handoff token.

Defined Under Namespace

Classes: MissingSecret

Constant Summary collapse

CIPHER =
"aes-256-gcm"
NONCE_BYTES =
12
TAG_BYTES =
16

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.secretObject



31
32
33
34
35
36
37
38
# File 'lib/everywhere/auth_token.rb', line 31

def secret
  @secret ||= discover_secret or raise(MissingSecret, <<~MESSAGE)
    Native sign-in needs a secret to seal its handoff tokens. Rails apps use
    secret_key_base automatically; elsewhere set SECRET_KEY_BASE (or
    EVERYWHERE_AUTH_SECRET) in the environment, or assign
    Everywhere::AuthToken.secret at boot.
  MESSAGE
end

Class Method Details

.decrypt(token, purpose:, once: false) ⇒ Object

The payload, or nil for anything we can't fully vouch for: tampered, wrong purpose, expired, or (when once) already spent. Never raises on bad input — every one of these arrives from outside.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/everywhere/auth_token.rb', line 53

def decrypt(token, purpose:, once: false)
  raw = Base64.urlsafe_decode64(token.to_s)
  return nil if raw.bytesize <= NONCE_BYTES + TAG_BYTES

  decipher = OpenSSL::Cipher.new(CIPHER).decrypt
  decipher.key = key
  decipher.iv = raw.byteslice(0, NONCE_BYTES)
  decipher.auth_tag = raw.byteslice(NONCE_BYTES, TAG_BYTES)
  decipher.auth_data = purpose.to_s

  payload = JSON.parse(decipher.update(raw.byteslice(NONCE_BYTES + TAG_BYTES..)) + decipher.final)
  return nil unless payload.is_a?(Hash) && payload["exp"].to_i > now
  return nil if once && !claim(token, payload["exp"].to_i)

  payload
rescue ArgumentError, OpenSSL::Cipher::CipherError, JSON::ParserError
  nil
end

.encrypt(payload, purpose:, ttl:) ⇒ Object



40
41
42
43
44
45
46
47
48
# File 'lib/everywhere/auth_token.rb', line 40

def encrypt(payload, purpose:, ttl:)
  sealed = JSON.generate(payload.merge("exp" => now + ttl))
  cipher = OpenSSL::Cipher.new(CIPHER).encrypt
  cipher.key = key
  nonce = cipher.random_iv
  cipher.auth_data = purpose.to_s
  ciphertext = cipher.update(sealed) + cipher.final
  Base64.urlsafe_encode64(nonce + cipher.auth_tag + ciphertext, padding: false)
end

.reset!Object

Test hook — a new secret means a new key.



73
74
75
76
77
# File 'lib/everywhere/auth_token.rb', line 73

def reset!
  @secret = nil
  @key = nil
  @spent = nil
end