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
- .cookie_strategy?(ctx) ⇒ Boolean
- .generate(ctx, state_data) ⇒ Object
- .parse(ctx, state) ⇒ Object
- .parse_cookie_state(ctx, state) ⇒ Object
- .parse_database_state(ctx, state) ⇒ Object
- .parse_legacy(ctx, state, data) ⇒ Object
- .recovered_error_url(data) ⇒ Object
- .stringify_keys(value) ⇒ Object
- .validate_expiration!(data) ⇒ Object
Class Method Details
.cookie_strategy?(ctx) ⇒ Boolean
136 137 138 |
# File 'lib/better_auth/oauth_state.rb', line 136 def (ctx) ctx.context..account[: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 (ctx) = ctx.context.("oauth_state", max_age: 600) encrypted = Crypto.symmetric_encrypt( key: ctx.context.secret_config, data: JSON.generate(payload) ) ctx.(.name, encrypted, .attributes) else = ctx.context.("state", max_age: 300) ctx.(.name, state, ctx.context.secret, .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 (ctx) ? (ctx, state) : parse_database_state(ctx, state) rescue JSON::ParserError raise Error, "state_invalid" end |
.parse_cookie_state(ctx, state) ⇒ Object
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 (ctx, state) = ctx.context.("oauth_state") encrypted = ctx.(.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.(ctx, ) validate_expiration!(data) data end |
.parse_database_state(ctx, state) ⇒ Object
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 = ctx.context.("state") stored = ctx.(.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.(ctx, ) 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
63 64 65 66 67 68 69 70 71 |
# File 'lib/better_auth/oauth_state.rb', line 63 def parse_legacy(ctx, state, data) = ctx.context.("state", max_age: 600) stored = ctx.(.name, ctx.context.secret) Cookies.(ctx, ) 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
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 |