@@ -46,10 +46,20 @@ def exchange
4646
4747 def refresh
4848 refresh_token_value = cookies [ COOKIE_NAME ]
49- return render_oauth_error ( :invalid_grant , status : :unauthorized ) if refresh_token_value . blank?
49+ if refresh_token_value . blank?
50+ report_refresh_failure ( :cookie_absent )
51+ return render_oauth_error ( :invalid_grant , status : :unauthorized )
52+ end
5053
5154 access_token = Doorkeeper ::AccessToken . by_refresh_token ( refresh_token_value )
52- return render_oauth_error ( :invalid_grant , status : :unauthorized ) if access_token . nil?
55+ if access_token . nil?
56+ # The cookie carried a refresh token but no access token row matches it.
57+ # The most likely cause is that the row was deleted out from under the
58+ # cookie (e.g. the nightly CleanupDbService pruning a revoked token) — this
59+ # is the case we most want to catch for the "logged out overnight" reports.
60+ report_refresh_failure ( :token_not_found )
61+ return render_oauth_error ( :invalid_grant , status : :unauthorized )
62+ end
5363
5464 frontend_app = OAuthApplication . find_by ( is_intercode_frontend : true )
5565 return render_oauth_error ( :invalid_client , status : :bad_request ) unless frontend_app
@@ -59,7 +69,7 @@ def refresh
5969 credentials = Doorkeeper ::OAuth ::Client ::Credentials . new ( frontend_app . uid , nil )
6070 request = Doorkeeper ::OAuth ::RefreshTokenRequest . new ( Doorkeeper . config , access_token , credentials )
6171
62- respond_with_token_request ( request )
72+ respond_with_token_request ( request , refreshed_token : access_token )
6373 end
6474
6575 def sign_out
@@ -74,9 +84,39 @@ def sign_out
7484
7585 private
7686
77- def respond_with_token_request ( request )
87+ # Records *why* a cookie-backed refresh failed, so we can diagnose the
88+ # convention-site "logged out overnight" reports from real data instead of
89+ # guesswork. Deliberately logs no token material — just the failure reason
90+ # and (when a row exists) its lifecycle timestamps, which let us distinguish
91+ # a deleted row from one that was revoked by rotation. Surfaces to Sentry and
92+ # Rollbar with a filterable `oauth_refresh_failure` tag.
93+ def report_refresh_failure ( reason , access_token : nil , doorkeeper_error : nil )
94+ context = { reason : reason , doorkeeper_error : doorkeeper_error } . compact
95+
96+ if access_token
97+ context . merge! (
98+ resource_owner_id : access_token . resource_owner_id ,
99+ application_id : access_token . application_id ,
100+ token_created_at : access_token . created_at ,
101+ token_revoked_at : access_token . revoked_at ,
102+ token_expires_in : access_token . expires_in ,
103+ has_previous_refresh_token : access_token . previous_refresh_token . present?
104+ )
105+ end
106+
107+ ErrorReporting . info ( "oauth_session refresh failed" , tags : { oauth_refresh_failure : reason . to_s } , **context )
108+ end
109+
110+ def respond_with_token_request ( request , refreshed_token : nil )
78111 response = request . authorize
79112 if response . is_a? ( Doorkeeper ::OAuth ::ErrorResponse )
113+ # `refreshed_token` is only passed by the refresh flow: the access token row
114+ # exists but Doorkeeper rejected the grant (e.g. it was already revoked, or
115+ # this is refresh-token reuse). Capturing the row's state lets us tell a
116+ # rotation/race problem apart from the row being deleted entirely.
117+ if refreshed_token
118+ report_refresh_failure ( :grant_rejected , access_token : refreshed_token , doorkeeper_error : response . name )
119+ end
80120 clear_refresh_cookie
81121 render json : response . body , status : response . status
82122 return
0 commit comments