diff --git a/CHANGELOG b/CHANGELOG index 5027e12b..adbd44f2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ === master +* Support hmac_secret rotation in jwt_refresh feature (jeremyevans) (#365) + * Support hmac_secret rotation in single_session feature (jeremyevans) (#365) * Support hmac_secret rotation in remember feature (jeremyevans) (#365) diff --git a/lib/rodauth/features/jwt_refresh.rb b/lib/rodauth/features/jwt_refresh.rb index 170f2c93..535c606f 100644 --- a/lib/rodauth/features/jwt_refresh.rb +++ b/lib/rodauth/features/jwt_refresh.rb @@ -114,7 +114,7 @@ def _account_from_refresh_token(token) unless key && (id.to_s == session_value.to_s) && (actual = get_active_refresh_token(id, token_id)) && - timing_safe_eql?(key, convert_token_key(actual)) && + (timing_safe_eql?(key, convert_token_key(actual)) || (hmac_secret_rotation? && timing_safe_eql?(key, compute_old_hmac(actual)))) && jwt_refresh_token_match?(key) return end @@ -150,7 +150,9 @@ def jwt_refresh_token_match?(key) # If allowing with expired jwt access token, check the expired session contains # hmac matching submitted and active refresh token. - timing_safe_eql?(compute_hmac(session[jwt_refresh_token_data_session_key].to_s + key), session[jwt_refresh_token_hmac_session_key].to_s) + s = session[jwt_refresh_token_hmac_session_key].to_s + h = session[jwt_refresh_token_data_session_key].to_s + key + timing_safe_eql?(compute_hmac(h), s) || (hmac_secret_rotation? && timing_safe_eql?(compute_old_hmac(h), s)) end def get_active_refresh_token(account_id, token_id) diff --git a/spec/jwt_refresh_spec.rb b/spec/jwt_refresh_spec.rb index f8707d7d..cced6acc 100644 --- a/spec/jwt_refresh_spec.rb +++ b/spec/jwt_refresh_spec.rb @@ -178,11 +178,17 @@ [false, true].each do |hs| it "generates and refreshes Refresh Tokens #{'with hmac_secret' if hs}" do - initial_secret = secret = SecureRandom.random_bytes(32) if hs + if hs + initial_secret = secret = SecureRandom.random_bytes(32) + old_secret = nil + end rt = nil rodauth do enable :login, :logout, :jwt_refresh - hmac_secret{secret} if hs + if hs + hmac_secret{secret} + hmac_old_secret{old_secret} + end jwt_secret '1' skip_status_checks? hs after_refresh_token{rt = json_response['refresh_token']} @@ -281,6 +287,31 @@ @authorization = res.last['access_token'] res = json_request("/") res.must_equal [200, {'hello'=>'world'}] + seventh_refresh_token = jwt_refresh_login.last['refresh_token'] + + # Refresh secret works when rotating + old_secret = secret + secret = SecureRandom.random_bytes(32) + res = json_request("/jwt-refresh", :refresh_token=>seventh_refresh_token) + jwt_refresh_validate(res) + + # And still gives us a valid access token + @authorization = res.last['access_token'] + res = json_request("/") + res.must_equal [200, {'hello'=>'world'}] + eighth_refresh_token = jwt_refresh_login.last['refresh_token'] + + # Refresh secret works after rotating + old_secret = nil + res = json_request("/jwt-refresh", :refresh_token=>eighth_refresh_token) + jwt_refresh_validate(res) + + # Refresh secret doesn't work if neither secret matches + secret = SecureRandom.random_bytes(32) + old_secret = SecureRandom.random_bytes(32) + res = json_request("/jwt-refresh", :refresh_token=>eighth_refresh_token) + res.first.must_equal 400 + res.must_equal [400, {'error'=>'invalid JWT refresh token'}] end end end @@ -430,6 +461,66 @@ json_request('/').must_equal [200, {"authenticated_by"=>["password"]}] end + it "should allow refreshing token when providing expired access token when rotating hmac secret" do + period = -2 + secret = SecureRandom.random_bytes(32) + old_secret = nil + rodauth do + enable :login, :logout, :jwt_refresh, :close_account + hmac_secret{secret} + hmac_old_secret{old_secret} + jwt_secret '1' + jwt_access_token_period{period} + allow_refresh_with_expired_jwt_access_token? true + end + roda(:jwt) do |r| + r.rodauth + rodauth.require_authentication + response['Content-Type'] = 'application/json' + {'authenticated_by' => rodauth.authenticated_by}.to_json + end + + res = jwt_refresh_login + refresh_token = res.last['refresh_token'] + period = 1800 + + old_secret = secret + secret = SecureRandom.random_bytes(32) + res = json_request("/jwt-refresh", :refresh_token=>refresh_token) + jwt_refresh_validate(res) + + json_request('/').must_equal [200, {"authenticated_by"=>["password"]}] + end + + it "should not allow refreshing token when providing expired access token when rotating hmac secret with invalid old secret" do + period = -2 + secret = SecureRandom.random_bytes(32) + old_secret = nil + rodauth do + enable :login, :logout, :jwt_refresh, :close_account + hmac_secret{secret} + hmac_old_secret{old_secret} + jwt_secret '1' + jwt_access_token_period{period} + allow_refresh_with_expired_jwt_access_token? true + end + roda(:jwt) do |r| + r.rodauth + rodauth.require_authentication + response['Content-Type'] = 'application/json' + {'authenticated_by' => rodauth.authenticated_by}.to_json + end + + res = jwt_refresh_login + refresh_token = res.last['refresh_token'] + period = 1800 + + old_secret = SecureRandom.random_bytes(32) + secret = SecureRandom.random_bytes(32) + res = json_request("/jwt-refresh", :refresh_token=>refresh_token) + res.must_equal [400, {"error"=>"invalid JWT refresh token"}] + end + it "should allow refreshing token when providing expired access token if configured and prefix is not correct" do period = -2 rodauth do