1313
1414class universal_oidc_mail_sso extends rcube_plugin
1515{
16- public $ task = 'login|mail|settings ' ;
16+ public $ task = 'login|mail|settings|logout ' ;
1717
1818 private const SESSION_OIDC_SUB = 'universal_oidc_mail_sso_oidc_sub ' ;
1919 private const SESSION_OIDC_EMAIL = 'universal_oidc_mail_sso_oidc_email ' ;
2020 private const SESSION_OIDC_NONCE = 'universal_oidc_mail_sso_oidc_nonce ' ;
2121 private const SESSION_OIDC_STATE = 'universal_oidc_mail_sso_oidc_state ' ;
2222 private const SESSION_OIDC_CODE_VERIFIER = 'universal_oidc_mail_sso_oidc_code_verifier ' ;
2323 private const SESSION_OIDC_GROUPS = 'universal_oidc_mail_sso_oidc_groups ' ;
24+ private const SESSION_OIDC_ID_TOKEN = 'universal_oidc_mail_sso_id_token ' ;
2425 private const SESSION_AUTLOGIN = 'universal_oidc_mail_sso_autologin ' ;
2526 private const SESSION_SETUP_RATE = 'universal_oidc_mail_sso_setup_rate ' ;
2627 private const PREF_OIDC_GROUPS = 'universal_oidc_mail_sso_groups ' ;
@@ -39,6 +40,7 @@ class universal_oidc_mail_sso extends rcube_plugin
3940 private ?Crypto $ crypto = null ;
4041 private ?string $ cryptoInitError = null ;
4142 private ?array $ policyCache = null ;
43+ private ?string $ pendingLogoutIdToken = null ;
4244
4345 public function init (): void
4446 {
@@ -57,6 +59,7 @@ public function init(): void
5759 $ this ->add_hook ('authenticate ' , [$ this , 'authenticate ' ]);
5860 $ this ->add_hook ('storage_connect ' , [$ this , 'storageConnect ' ]);
5961 $ this ->add_hook ('smtp_connect ' , [$ this , 'smtpConnect ' ]);
62+ $ this ->add_hook ('logout_after ' , [$ this , 'logoutAfter ' ]);
6063
6164 $ this ->register_action (self ::ACTION_LOGIN , [$ this , 'actionLogin ' ]);
6265 $ this ->register_action (self ::ACTION_CALLBACK , [$ this , 'actionCallback ' ]);
@@ -72,22 +75,43 @@ public function init(): void
7275 public function startup (array $ args ): array
7376 {
7477 $ action = rcube_utils::get_input_value ('_action ' , rcube_utils::INPUT_GPC );
78+ $ adminActions = [
79+ self ::ACTION_ADMIN ,
80+ self ::ACTION_ADMIN_SAVE_POLICY ,
81+ self ::ACTION_ADMIN_DELETE_USER ,
82+ self ::ACTION_ADMIN_SET_USER_STATUS ,
83+ ];
84+
85+ // Ensure direct admin action links always route to a valid auth flow.
86+ if (is_string ($ action ) && in_array ($ action , $ adminActions , true )) {
87+ if (empty ($ _SESSION ['user_id ' ])) {
88+ $ this ->redirectTo ($ this ->urlForAction (self ::ACTION_LOGIN ));
89+ }
90+
91+ if ($ this ->rc ->task !== 'settings ' ) {
92+ $ this ->redirectTo ($ this ->rc ->url (['task ' => 'settings ' , 'action ' => $ action ]));
93+ }
94+ }
7595
7696 // If admin plugin endpoints are accessed directly without a Roundcube
7797 // session, start OIDC login flow instead of failing with access errors.
7898 if ($ this ->rc ->task === 'settings '
7999 && is_string ($ action )
80- && in_array ($ action , [
81- self ::ACTION_ADMIN ,
82- self ::ACTION_ADMIN_SAVE_POLICY ,
83- self ::ACTION_ADMIN_DELETE_USER ,
84- self ::ACTION_ADMIN_SET_USER_STATUS ,
85- ], true )
100+ && in_array ($ action , $ adminActions , true )
86101 && empty ($ _SESSION ['user_id ' ])
87102 ) {
88103 $ this ->redirectTo ($ this ->urlForAction (self ::ACTION_LOGIN ));
89104 }
90105
106+ // Capture id_token on logout request. We'll redirect to OIDC
107+ // end_session_endpoint in logoutAfter(), after local logout completes.
108+ if ($ this ->rc ->task === 'logout ' ) {
109+ $ idToken = $ _SESSION [self ::SESSION_OIDC_ID_TOKEN ] ?? null ;
110+ if (is_string ($ idToken ) && $ idToken !== '' ) {
111+ $ this ->pendingLogoutIdToken = $ idToken ;
112+ }
113+ }
114+
91115 if ($ this ->rc ->task !== 'login ' || !empty ($ _SESSION ['user_id ' ])) {
92116 return $ args ;
93117 }
@@ -319,6 +343,7 @@ public function actionCallback(): void
319343 $ _SESSION [self ::SESSION_OIDC_SUB ] = $ sub ;
320344 $ _SESSION [self ::SESSION_OIDC_EMAIL ] = $ email ;
321345 $ _SESSION [self ::SESSION_OIDC_GROUPS ] = $ groups ;
346+ $ _SESSION [self ::SESSION_OIDC_ID_TOKEN ] = (string ) ($ token ['id_token ' ] ?? '' );
322347
323348 $ existingIdentity = $ this ->storage ->getIdentityBySub ($ sub );
324349 if (!empty ($ existingIdentity ['is_disabled ' ])) {
@@ -345,6 +370,27 @@ public function actionCallback(): void
345370 $ this ->redirectTo ($ this ->urlForAction (self ::ACTION_AUTOLOGIN ));
346371 }
347372
373+ public function logoutAfter (array $ args ): array
374+ {
375+ if ($ this ->pendingLogoutIdToken === null || $ this ->pendingLogoutIdToken === '' ) {
376+ return $ args ;
377+ }
378+
379+ try {
380+ $ oidc = $ this ->oidcClient ();
381+ $ discovery = $ oidc ->discover ();
382+ $ postLogoutRedirect = (string ) $ this ->cfg ('post_logout_redirect_uri ' , $ this ->externalBaseUrl () . '/ ' );
383+ $ url = $ oidc ->buildEndSessionUrl ($ discovery , $ this ->pendingLogoutIdToken , $ postLogoutRedirect );
384+ if ($ url !== '' ) {
385+ $ this ->redirectTo ($ url );
386+ }
387+ } catch (Throwable $ e ) {
388+ $ this ->log ('oidc_logout_redirect_failed ' , ['err ' => $ e ->getMessage ()]);
389+ }
390+
391+ return $ args ;
392+ }
393+
348394 public function actionAutologin (): void
349395 {
350396 $ oidcSub = $ _SESSION [self ::SESSION_OIDC_SUB ] ?? null ;
0 commit comments