@@ -41,6 +41,15 @@ local plugin_name = "openid-connect"
4141local STATE_MISMATCH_ERR =
4242 " state from argument does not match state restored from session"
4343
44+ -- prefix of the resty.openidc error returned when the redirect_uri is
45+ -- requested without an authorization response, e.g. an OAuth2 error
46+ -- redirect (RFC 6749 section 4.1.2.1) instead of a code
47+ local UNHANDLED_REDIRECT_URI_ERR = " unhandled request to the redirect_uri"
48+
49+ -- max consecutive restarts of the authentication flow from failed
50+ -- authorization callbacks
51+ local MAX_AUTH_FLOW_RESTARTS = 3
52+
4453
4554-- Session config is passed as-is to resty.session.start(); the only
4655-- translation is the legacy session.cookie.lifetime alias from the
@@ -1294,34 +1303,88 @@ function _M.rewrite(plugin_conf, ctx)
12941303 build_session_opts (conf .session ))
12951304
12961305 if err then
1297- if session then
1298- session :close ()
1299- end
13001306 if err == " unauthorized request" then
1307+ if session then
1308+ session :close ()
1309+ end
13011310 if conf .unauth_action == " pass" then
13021311 return nil
13031312 end
13041313 return 401
13051314 end
13061315
1307- -- Stale authorization callback: the session holds no authorization
1308- -- state for the state in the callback, e.g. an already completed
1309- -- callback was replayed, or the state was pruned after too many
1310- -- concurrent flows. (Concurrent logins in several tabs are handled
1311- -- by resty.openidc itself since 1.9.0, which keeps one
1312- -- authorization state per in-flight flow.) The client is a browser
1313- -- mid-navigation, so instead of a dead-end 500, send it back to the
1314- -- original URL that resty.openidc returns alongside the error: a
1315- -- fresh flow starts from there and completes without any user
1316- -- interaction while the ID provider still holds an SSO session.
1317- if err == STATE_MISMATCH_ERR and target_url
1316+ -- Recoverable authorization-callback failures: a stale state
1317+ -- (replayed or pruned callback), or the ID provider redirecting
1318+ -- back with error=temporarily_unavailable, e.g. Keycloak after
1319+ -- its login session expired. The client is a browser
1320+ -- mid-navigation, so restart the authentication flow by sending
1321+ -- it back to the original URL instead of dead-ending with a 500.
1322+ -- Other OAuth2 error codes (access_denied, login_required, ...)
1323+ -- reflect a deliberate outcome and are not retried.
1324+ local restart_reason
1325+ local restart_url = target_url
1326+ if err == STATE_MISMATCH_ERR then
1327+ -- state already matched by resty.openidc; no in-flight flow
1328+ -- for it, so its session-level original_url is all we have
1329+ restart_reason = " state mismatch (replayed or pruned callback)"
1330+ elseif session and core .string .has_prefix (err , UNHANDLED_REDIRECT_URI_ERR ) then
1331+ local uri_args = ngx .req .get_uri_args ()
1332+ -- resty.openidc bails on this path before validating state, so
1333+ -- match it here as it would: rejects a forged callback, and
1334+ -- recovers the original_url of the flow it belongs to (each
1335+ -- in-flight flow keeps its own since 1.9.0)
1336+ local authorization_state
1337+ if uri_args .error == " temporarily_unavailable" and uri_args .state then
1338+ local states = session :get (" authorization_states" )
1339+ authorization_state = states and states [uri_args .state ]
1340+ if not authorization_state
1341+ and uri_args .state == session :get (" state" ) then
1342+ authorization_state = {
1343+ original_url = session :get (" original_url" )
1344+ }
1345+ end
1346+ end
1347+ if authorization_state then
1348+ restart_reason = " authorization callback reported a " ..
1349+ " temporarily unavailable identity provider" ..
1350+ (type (uri_args .error_description ) == " string" and
1351+ (" (" .. uri_args .error_description .. " )" ) or " " )
1352+ restart_url = authorization_state .original_url or target_url
1353+ end
1354+ end
1355+
1356+ if restart_reason and restart_url and session
13181357 and ngx .req .get_method () == " GET" then
1319- core .log .warn (" OIDC state mismatch (replayed or pruned " ,
1320- " callback), restarting the authentication flow" )
1321- core .response .set_header (" Location" , target_url )
1322- return 302
1358+ -- bound the redirect loop in case the failure is not
1359+ -- transient; the counter is reset once a request
1360+ -- authenticates
1361+ local restarts = session :get (" auth_flow_restarts" ) or 0
1362+ if restarts < MAX_AUTH_FLOW_RESTARTS then
1363+ session :set (" auth_flow_restarts" , restarts + 1 )
1364+ local ok , save_err = session :save ()
1365+ if not ok then
1366+ session :close ()
1367+ core .log .error (" OIDC authentication failed: " , err ,
1368+ " (could not persist the restart " ,
1369+ " counter: " , save_err , " )" )
1370+ return 500
1371+ end
1372+ session :close ()
1373+ core .log .warn (" OIDC " , restart_reason ,
1374+ " , restarting the authentication flow" )
1375+ core .response .set_header (" Location" , restart_url )
1376+ return 302
1377+ end
1378+ session :close ()
1379+ core .log .error (" OIDC authentication failed: " , err ,
1380+ " (giving up after " , restarts ,
1381+ " restarts of the authentication flow)" )
1382+ return 500
13231383 end
13241384
1385+ if session then
1386+ session :close ()
1387+ end
13251388 core .log .error (" OIDC authentication failed: " , err )
13261389 return 500
13271390 end
@@ -1365,6 +1428,15 @@ function _M.rewrite(plugin_conf, ctx)
13651428 if enc_id_token and conf .set_raw_id_token_header then
13661429 core .request .set_header (ctx , " X-Raw-ID-Token" , enc_id_token )
13671430 end
1431+
1432+ -- a successful authentication resets the restart budget
1433+ if session :get (" auth_flow_restarts" ) then
1434+ session :set (" auth_flow_restarts" , nil )
1435+ local ok , save_err = session :save ()
1436+ if not ok then
1437+ core .log .error (" failed to save session: " , save_err )
1438+ end
1439+ end
13681440 end
13691441 end
13701442 if session then
0 commit comments