@@ -17,6 +17,7 @@ import (
1717 "gitea.dev/modules/setting"
1818 "gitea.dev/modules/test"
1919 "gitea.dev/modules/util"
20+ auth_service "gitea.dev/services/auth"
2021 "gitea.dev/services/auth/source/oauth2"
2122 "gitea.dev/services/contexttest"
2223
@@ -218,7 +219,7 @@ func TestWebAuthOAuth2(t *testing.T) {
218219 ctx .SetPathParamRaw ("provider" , "oidc-auth-source" )
219220 SignInOAuthCallback (ctx )
220221 require .Equal (t , http .StatusSeeOther , resp .Code )
221- assert .Nil (t , ctx .Session .Get (session .KeyOIDCIDToken ))
222+ assert .Empty (t , ctx .Session .Get (session .KeyOIDCIDToken ))
222223
223224 ctx , resp = contexttest .MockContext (t , "/user/logout" , mockOpt )
224225 ctx .Doer = oauthUser
@@ -229,6 +230,91 @@ func TestWebAuthOAuth2(t *testing.T) {
229230 assert .Empty (t , u .Query ().Get ("id_token_hint" ))
230231 })
231232
233+ t .Run ("OAuth2CallbackClearsStaleIDTokenOnRegen" , func (t * testing.T ) {
234+ // simulates a user who signed in before with an id_token (stale value already
235+ // in the session), then signs in again via a callback that yields no id_token
236+ mockOpt := contexttest.MockContextOption {SessionStore : session .NewMockMemStore ("dummy-sid-oidc-stale-token" )}
237+ ctx , resp := contexttest .MockContext (t , "/user/oauth2/..../callback?code=dummy-code" , mockOpt )
238+ require .NoError (t , ctx .Session .Set (session .KeyOIDCIDToken , "stale-id-token-from-prior-login" ))
239+ ctx .Doer = oauthUser
240+ handleOAuth2SignIn (ctx , authSource , oauthUser , goth.User {Provider : "oidc-auth-source" , UserID : "oauth-user" })
241+ require .Equal (t , http .StatusSeeOther , resp .Code )
242+ assert .Empty (t , ctx .Session .Get (session .KeyOIDCIDToken ), "stale id_token from a prior session must not survive regenerateSession" )
243+
244+ ctx , resp = contexttest .MockContext (t , "/user/logout" , mockOpt )
245+ ctx .Doer = oauthUser
246+ SignOut (ctx )
247+ assert .Equal (t , http .StatusSeeOther , resp .Code )
248+ u , err := url .Parse (test .RedirectURL (resp ))
249+ require .NoError (t , err )
250+ assert .Empty (t , u .Query ().Get ("id_token_hint" ), "logout must not reuse a stale id_token as id_token_hint" )
251+ })
252+
253+ t .Run ("OAuth2CallbackClearsStaleIDTokenOnRegenWith2FA" , func (t * testing.T ) {
254+ // same as OAuth2CallbackClearsStaleIDTokenOnRegen, but through the 2FA-required
255+ // branch in handleOAuth2SignIn, a separate call site from the !needs2FA one
256+ tfa := & auth_model.TwoFactor {UID : oauthUser .ID }
257+ require .NoError (t , auth_model .NewTwoFactor (t .Context (), tfa ))
258+ t .Cleanup (func () { _ = auth_model .DeleteTwoFactorByID (t .Context (), tfa .ID , oauthUser .ID ) })
259+
260+ mockOpt := contexttest.MockContextOption {SessionStore : session .NewMockMemStore ("dummy-sid-oidc-stale-token-2fa" )}
261+ ctx , resp := contexttest .MockContext (t , "/user/oauth2/..../callback?code=dummy-code" , mockOpt )
262+ require .NoError (t , ctx .Session .Set (session .KeyOIDCIDToken , "stale-id-token-from-prior-login" ))
263+ ctx .Doer = oauthUser
264+ handleOAuth2SignIn (ctx , authSource , oauthUser , goth.User {Provider : "oidc-auth-source" , UserID : "oauth-user" })
265+ require .Equal (t , http .StatusSeeOther , resp .Code )
266+ assert .Equal (t , "/user/two_factor" , test .RedirectURL (resp ))
267+ assert .Empty (t , ctx .Session .Get (session .KeyOIDCIDToken ), "stale id_token must not survive the 2FA-required regeneration branch" )
268+ })
269+
270+ t .Run ("PasswordSignInAfterOIDCClearsStaleIDTokenAndMethod" , func (t * testing.T ) {
271+ // a session that previously authenticated via OIDC (KeySignInMethod + KeyOIDCIDToken
272+ // set) must not keep redirecting to end_session_endpoint with a stale hint once the
273+ // same browser session re-authenticates via password: handleSignInFull's regenerateSession
274+ // call doesn't pass either key, so ClearSessionKeysForSignIn must be the one clearing them
275+ mockOpt := contexttest.MockContextOption {SessionStore : session .NewMockMemStore ("dummy-sid-password-after-oidc" )}
276+ ctx , _ := contexttest .MockContext (t , "/user/login" , mockOpt )
277+ require .NoError (t , ctx .Session .Set (session .KeySignInMethod , session .SignInMethodOAuth2 ))
278+ require .NoError (t , ctx .Session .Set (session .KeyOIDCIDToken , "stale-id-token-from-prior-oidc-login" ))
279+
280+ handleSignInFull (ctx , oauthUser , false )
281+ assert .NotEqual (t , session .SignInMethodOAuth2 , ctx .Session .Get (session .KeySignInMethod ), "password sign-in must not leave a stale OAuth2 sign-in method behind" )
282+ assert .Empty (t , ctx .Session .Get (session .KeyOIDCIDToken ), "password sign-in must not leave a stale id_token behind" )
283+
284+ ctx , resp := contexttest .MockContext (t , "/user/logout" , mockOpt )
285+ ctx .Doer = oauthUser
286+ SignOut (ctx )
287+ assert .Equal (t , http .StatusSeeOther , resp .Code )
288+ assert .Equal (t , "/" , test .RedirectURL (resp ), "must not redirect to the OIDC end_session_endpoint after a password sign-in" )
289+ })
290+
291+ t .Run ("OIDCIDTokenSurvives2FACompletion" , func (t * testing.T ) {
292+ // counterpart to PasswordSignInAfterOIDCClearsStaleIDTokenAndMethod: an OIDC sign-in
293+ // that required 2FA sets KeySignInMethod/KeyOIDCIDToken via handleTwoFactorRequired
294+ // *before* the 2FA step; handleSignIn (called on successful 2FA) must preserve them,
295+ // not just blindly clear on every sign-in completion
296+ mockOpt := contexttest.MockContextOption {SessionStore : session .NewMockMemStore ("dummy-sid-oidc-2fa-completion" )}
297+ ctx , _ := contexttest .MockContext (t , "/user/oauth2/..../callback?code=dummy-code" , mockOpt )
298+ handleTwoFactorRequired (ctx , oauthUser , false , map [string ]any {
299+ session .KeySignInMethod : session .SignInMethodOAuth2 ,
300+ session .KeyOIDCIDToken : "real-id-token-pending-2fa" ,
301+ })
302+
303+ // simulates what TwoFactorPost does on a correct passcode
304+ ctx , _ = contexttest .MockContext (t , "/user/twofa" , mockOpt )
305+ handleSignIn (ctx , oauthUser , false )
306+ assert .Equal (t , session .SignInMethodOAuth2 , ctx .Session .Get (session .KeySignInMethod ), "completing 2FA must not lose the OAuth2 sign-in method recorded before the 2FA step" )
307+ assert .Equal (t , "real-id-token-pending-2fa" , ctx .Session .Get (session .KeyOIDCIDToken ), "completing 2FA must not lose the id_token recorded before the 2FA step" )
308+
309+ ctx , resp := contexttest .MockContext (t , "/user/logout" , mockOpt )
310+ ctx .Doer = oauthUser
311+ SignOut (ctx )
312+ assert .Equal (t , http .StatusSeeOther , resp .Code )
313+ u , err := url .Parse (test .RedirectURL (resp ))
314+ require .NoError (t , err )
315+ assert .Equal (t , "real-id-token-pending-2fa" , u .Query ().Get ("id_token_hint" ), "logout after 2FA completion must still include the id_token_hint from the original OIDC sign-in" )
316+ })
317+
232318 t .Run ("PasswordSignInSkipsOIDC" , func (t * testing.T ) {
233319 // OAuth2-linked account signed in via password form must not hit end_session_endpoint.
234320 mockOpt := contexttest.MockContextOption {SessionStore : session .NewMockMemStore ("dummy-sid-password" )}
@@ -247,12 +333,36 @@ func TestOpenIDRequireTwoFactor(t *testing.T) {
247333
248334 user32 := unittest .AssertExistsAndLoadBean (t , & user_model.User {ID : 32 }) // has a webauthn credential
249335 ctx , resp := contexttest .MockContext (t , "/user/openid/connect" , mockOpt )
336+ require .NoError (t , ctx .Session .Set (session .KeySignInMethod , session .SignInMethodOAuth2 ))
337+ require .NoError (t , ctx .Session .Set (session .KeyOIDCIDToken , "stale-id-token-from-prior-oidc-login" ))
250338 openIDRequireTwoFactor (ctx , user32 , false , "https://example.com/id" )
251339 assert .Equal (t , "/user/webauthn" , test .RedirectURL (resp ))
252340 unittest .AssertNotExistsBean (t , & user_model.UserOpenID {UID : user32 .ID }) // not attached before the key answered
341+ assert .Empty (t , ctx .Session .Get (session .KeyOIDCIDToken ), "legacy OpenID 2FA flow must not carry forward a stale id_token from an earlier OIDC session" )
342+ assert .NotEqual (t , session .SignInMethodOAuth2 , ctx .Session .Get (session .KeySignInMethod ))
253343
254344 user2 := unittest .AssertExistsAndLoadBean (t , & user_model.User {ID : 2 })
255345 ctx , _ = contexttest .MockContext (t , "/user/openid/connect" , mockOpt )
256346 openIDRequireTwoFactor (ctx , user2 , false , "https://example.com/id" )
257347 assert .False (t , ctx .Written ())
258348}
349+
350+ func TestAutoSignInClearsStaleOIDCState (t * testing.T ) {
351+ require .NoError (t , unittest .PrepareTestDatabase ())
352+ user2 := unittest .AssertExistsAndLoadBean (t , & user_model.User {ID : 2 })
353+
354+ nt , token , err := auth_service .CreateAuthTokenForUserID (t .Context (), user2 .ID )
355+ require .NoError (t , err )
356+
357+ mockOpt := contexttest.MockContextOption {SessionStore : session .NewMockMemStore ("dummy-sid-autologin" )}
358+ ctx , _ := contexttest .MockContext (t , "/user/login" , mockOpt )
359+ require .NoError (t , ctx .Session .Set (session .KeySignInMethod , session .SignInMethodOAuth2 ))
360+ require .NoError (t , ctx .Session .Set (session .KeyOIDCIDToken , "stale-id-token-from-prior-oidc-login" ))
361+ ctx .Req .AddCookie (& http.Cookie {Name : setting .CookieRememberName , Value : nt .ID + ":" + token })
362+
363+ succeeded , err := autoSignIn (ctx )
364+ require .NoError (t , err )
365+ require .True (t , succeeded )
366+ assert .Empty (t , ctx .Session .Get (session .KeyOIDCIDToken ), "auto sign-in via remember-me cookie must not carry forward a stale id_token" )
367+ assert .NotEqual (t , session .SignInMethodOAuth2 , ctx .Session .Get (session .KeySignInMethod ))
368+ }
0 commit comments