Module: BetterAuth::OAuthState

Defined in:
lib/better_auth/oauth_state.rb

Overview

Shared OAuth state storage matching upstream's opaque-state adaptation. State data is encrypted client-side for the cookie strategy or consumed from verification storage for the database strategy. Signed JWT state is still accepted so in-flight legacy sign-in and link flows keep working.

Defined Under Namespace

Classes: Error

Constant Summary collapse

INTERNAL_KEYS =
%w[
  callbackURL codeVerifier errorURL newUserURL expiresAt oauthState link requestSignUp
].freeze

Class Method Summary collapse

Class Method Details

Returns:

  • (Boolean)


136
137
138
# File 'lib/better_auth/oauth_state.rb', line 136

def cookie_strategy?(ctx)
  ctx.context.options.[:store_state_strategy].to_s == "cookie"
end

.generate(ctx, state_data) ⇒ Object



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/better_auth/oauth_state.rb', line 27

def generate(ctx, state_data)
  state = Crypto.random_string(32)
  payload = stringify_keys(state_data).merge("oauthState" => state)

  if cookie_strategy?(ctx)
    cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
    encrypted = Crypto.symmetric_encrypt(
      key: ctx.context.secret_config,
      data: JSON.generate(payload)
    )
    ctx.set_cookie(cookie.name, encrypted, cookie.attributes)
  else
    cookie = ctx.context.create_auth_cookie("state", max_age: 300)
    ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
    verification = ctx.context.internal_adapter.create_verification_value(
      identifier: state,
      value: JSON.generate(payload),
      expiresAt: Time.now + 600
    )
    raise Error, "state_generation_error" unless verification
  end

  state
end

.parse(ctx, state) ⇒ Object



52
53
54
55
56
57
58
59
60
61
# File 'lib/better_auth/oauth_state.rb', line 52

def parse(ctx, state)
  raise Error, "state_not_found" if state.to_s.empty?

  legacy = Crypto.verify_jwt(state.to_s, ctx.context.secret)
  return parse_legacy(ctx, state, legacy) if legacy

  cookie_strategy?(ctx) ? parse_cookie_state(ctx, state) : parse_database_state(ctx, state)
rescue JSON::ParserError
  raise Error, "state_invalid"
end

Raises:



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/better_auth/oauth_state.rb', line 73

def parse_cookie_state(ctx, state)
  cookie = ctx.context.create_auth_cookie("oauth_state")
  encrypted = ctx.get_cookie(cookie.name)
  raise Error, "state_mismatch" if encrypted.to_s.empty?

  data = begin
    decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: encrypted)
    JSON.parse(decrypted)
  rescue JSON::ParserError, ArgumentError, TypeError
    raise Error, "state_invalid"
  end

  expected = data["oauthState"] || data["state"]
  unless expected == state
    raise Error.new("state_mismatch", error_url: recovered_error_url(data))
  end

  Cookies.expire_cookie(ctx, cookie)
  validate_expiration!(data)
  data
end

.parse_database_state(ctx, state) ⇒ Object

Raises:



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/better_auth/oauth_state.rb', line 95

def parse_database_state(ctx, state)
  preview = ctx.context.internal_adapter.find_verification_value(state)
  raise Error, "state_mismatch" unless preview

  data = JSON.parse(preview.fetch("value"))
  error_url = recovered_error_url(data)
  expected = data["oauthState"] || data["state"]
  raise Error.new("state_mismatch", error_url: error_url) if expected && expected != state

  cookie = ctx.context.create_auth_cookie("state")
  stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
  valid = ctx.request ? stored == state : (stored.nil? || stored == state)
  raise Error.new("state_mismatch", error_url: error_url) unless valid

  consumed = ctx.context.internal_adapter.consume_verification_value(state)
  Cookies.expire_cookie(ctx, cookie) if ctx.request || stored
  raise Error.new("state_mismatch", error_url: error_url) unless consumed

  consumed_data = JSON.parse(consumed.fetch("value"))
  consumed_expected = consumed_data["oauthState"] || consumed_data["state"]
  same_payload = consumed.fetch("value") == preview.fetch("value")
  expected_matches = !consumed_expected || consumed_expected == state
  unless same_payload && expected_matches
    raise Error.new("state_mismatch", error_url: recovered_error_url(consumed_data) || error_url)
  end

  validate_expiration!(consumed_data)
  consumed_data
end

.parse_legacy(ctx, state, data) ⇒ Object

Raises:



63
64
65
66
67
68
69
70
71
# File 'lib/better_auth/oauth_state.rb', line 63

def parse_legacy(ctx, state, data)
  cookie = ctx.context.create_auth_cookie("state", max_age: 600)
  stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
  Cookies.expire_cookie(ctx, cookie) if ctx.request || stored
  valid = ctx.request ? stored == state : (stored.nil? || stored == state)
  return data if valid

  raise Error.new("state_mismatch", error_url: recovered_error_url(data))
end

.recovered_error_url(data) ⇒ Object



132
133
134
# File 'lib/better_auth/oauth_state.rb', line 132

def recovered_error_url(data)
  data["errorURL"] || data["errorCallbackURL"]
end

.stringify_keys(value) ⇒ Object



140
141
142
143
144
145
# File 'lib/better_auth/oauth_state.rb', line 140

def stringify_keys(value)
  return value.each_with_object({}) { |(key, object), result| result[key.to_s] = stringify_keys(object) } if value.is_a?(Hash)
  return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)

  value
end

.validate_expiration!(data) ⇒ Object

Raises:



125
126
127
128
129
130
# File 'lib/better_auth/oauth_state.rb', line 125

def validate_expiration!(data)
  expires_at = data["expiresAt"].to_i
  return data unless expires_at.positive? && expires_at < Time.now.to_i

  raise Error.new("state_mismatch", error_url: recovered_error_url(data))
end