From 58d79b60377e84d74447c2eab33076c2c991106d Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 26 Dec 2023 18:19:57 -0300 Subject: [PATCH 01/34] chore: basic login and saml e2e tests --- apps/meteor/package.json | 1 + apps/meteor/tests/e2e/config/constants.ts | 5 + .../tests/e2e/containers/saml/Dockerfile | 35 +++ .../containers/saml/config/apache/cert_crt | 21 ++ .../containers/saml/config/apache/ports.conf | 9 + .../containers/saml/config/apache/private_key | 28 ++ .../saml/config/apache/simplesamlphp.conf | 23 ++ .../saml/config/simplesamlphp/authsources.php | 34 +++ .../saml/config/simplesamlphp/config.php | 111 +++++++ .../config/simplesamlphp/saml20-sp-remote.php | 24 ++ .../saml/config/simplesamlphp/server_crt | 21 ++ .../saml/config/simplesamlphp/server_pem | 28 ++ .../e2e/containers/saml/docker-compose.yml | 7 + .../tests/e2e/fixtures/collections/users.ts | 5 +- .../tests/e2e/fixtures/inject-initial-data.ts | 16 + apps/meteor/tests/e2e/fixtures/userStates.ts | 4 + apps/meteor/tests/e2e/login.spec.ts | 25 +- apps/meteor/tests/e2e/page-objects/auth.ts | 4 + apps/meteor/tests/e2e/saml.spec.ts | 279 ++++++++++++++++++ apps/meteor/tests/e2e/utils/getUserInfo.ts | 15 + yarn.lock | 17 ++ 21 files changed, 709 insertions(+), 3 deletions(-) create mode 100644 apps/meteor/tests/e2e/containers/saml/Dockerfile create mode 100644 apps/meteor/tests/e2e/containers/saml/config/apache/cert_crt create mode 100644 apps/meteor/tests/e2e/containers/saml/config/apache/ports.conf create mode 100644 apps/meteor/tests/e2e/containers/saml/config/apache/private_key create mode 100644 apps/meteor/tests/e2e/containers/saml/config/apache/simplesamlphp.conf create mode 100644 apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/authsources.php create mode 100644 apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php create mode 100644 apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-sp-remote.php create mode 100644 apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_crt create mode 100644 apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_pem create mode 100644 apps/meteor/tests/e2e/containers/saml/docker-compose.yml create mode 100644 apps/meteor/tests/e2e/saml.spec.ts create mode 100644 apps/meteor/tests/e2e/utils/getUserInfo.ts diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 5c26b2af58c1d..bcb5ea57ad6c8 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -167,6 +167,7 @@ "chai-dom": "^1.11.0", "chai-spies": "~1.0.0", "cross-env": "^7.0.3", + "docker-compose": "^0.24.3", "emojione-assets": "^4.5.0", "eslint": "~8.45.0", "eslint-config-prettier": "~8.8.0", diff --git a/apps/meteor/tests/e2e/config/constants.ts b/apps/meteor/tests/e2e/config/constants.ts index 3e9b693cc7dcd..c938b693ff450 100644 --- a/apps/meteor/tests/e2e/config/constants.ts +++ b/apps/meteor/tests/e2e/config/constants.ts @@ -15,3 +15,8 @@ export const ADMIN_CREDENTIALS = { password: 'rocketchat.internal.admin.test', username: 'rocketchat.internal.admin.test', } as const; + +export const DEFAULT_USER_CREDENTIALS = { + password: 'password', + bcrypt: '$2b$10$LNYaqDreDE7tt9EVEeaS9uw.C3hic9hcqFfIocMBPTMxJaDCC6QWW', +} as const; diff --git a/apps/meteor/tests/e2e/containers/saml/Dockerfile b/apps/meteor/tests/e2e/containers/saml/Dockerfile new file mode 100644 index 0000000000000..be87fdb742b65 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/Dockerfile @@ -0,0 +1,35 @@ +FROM php:7.1-apache + +# Utilities +RUN apt-get update && \ + apt-get -y install apt-transport-https git curl vim --no-install-recommends && \ + rm -r /var/lib/apt/lists/* + +# SimpleSAMLphp +ARG SIMPLESAMLPHP_VERSION=1.15.2 +RUN curl -s -L -o /tmp/simplesamlphp.tar.gz https://github.com/simplesamlphp/simplesamlphp/releases/download/v$SIMPLESAMLPHP_VERSION/simplesamlphp-$SIMPLESAMLPHP_VERSION.tar.gz && \ + tar xzf /tmp/simplesamlphp.tar.gz -C /tmp && \ + rm -f /tmp/simplesamlphp.tar.gz && \ + mv /tmp/simplesamlphp-* /var/www/simplesamlphp && \ + touch /var/www/simplesamlphp/modules/exampleauth/enable +COPY config/simplesamlphp/config.php /var/www/simplesamlphp/config +COPY config/simplesamlphp/authsources.php /var/www/simplesamlphp/config +COPY config/simplesamlphp/saml20-sp-remote.php /var/www/simplesamlphp/metadata +COPY config/simplesamlphp/server_crt /var/www/simplesamlphp/cert/server.crt +COPY config/simplesamlphp/server_pem /var/www/simplesamlphp/cert/server.pem + +# Apache +COPY config/apache/ports.conf /etc/apache2 +COPY config/apache/simplesamlphp.conf /etc/apache2/sites-available +COPY config/apache/cert_crt /etc/ssl/cert/cert.crt +COPY config/apache/private_key /etc/ssl/private/private.key +RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf && \ + a2enmod ssl && \ + a2dissite 000-default.conf default-ssl.conf && \ + a2ensite simplesamlphp.conf + +# Set work dir +WORKDIR /var/www/simplesamlphp + +# General setup +EXPOSE 8080 8443 \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/cert_crt b/apps/meteor/tests/e2e/containers/saml/config/apache/cert_crt new file mode 100644 index 0000000000000..4104eaf80f6e4 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/cert_crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJANdMEUvsTntJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzMjIxWhcNNDgwNjI1MTQzMjIxWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAyXRKD3KzV/hOqwThFRtA+eoitJpIIEmWbugPMC1G+7bFqxHmtxiuQhOw +yHrij35biiD8VpboY69Zep7n1QCfmIodh9uxdaNxFtzxjryRLzfP3MpPFkpBHCdV +HZWDP2TzIvOxWcnlLmikSnBrBM1nvhKSWjaFsjDAXMLXT0mceiDpQ0QQkDA6RAyx +JWWRJILjudBh56ukqvdz4eFWAAViZX5MUwCDxiBxtP3NIXVmODM7kDLqZ9+QcfpM +N5QvfcUjHP9584yrYiJ9N64Fy5vU2OH1RX5EsMHTtdqR4H5K6zNfVlgRSG170Mcj +ksTSbo1kcDCSuzTO82NrVkU+R78W/QIDAQABo1AwTjAdBgNVHQ4EFgQUkYMQMPqY +leTTtBHS1f7yNFmY86QwHwYDVR0jBBgwFoAUkYMQMPqYleTTtBHS1f7yNFmY86Qw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGZpbEWzYLoa5keg9rQDa +S2cf9rQFMNflwR7hQ6OtSXeP0JsQ6yFhRq3TgpBdbkmdealDJbyG8pWQxqwOBD/j +45jr+NsHap0HvQAg9pfq/QIqjH5osCGAHmNdHfP638FOpi0s6hAX11+nCW6ClHMO +ofn0fYXCBtM18qZvtoeB8swi6MlRXFLvvRL4tWzGugdQj+0f+ukk/GZAEitdpkuj +qFCHqfKwg9FJ4My5M8lIyB/P4+SK/ail/BoCJ/qGBXky2bob0MQuLysd35zrTA62 +j5IvPp1XZj/2KnuPtqMuFhNtE5wCnOEC1WG02ZVIfs4DAxX78z59VSEaFlnstT9k +aQ== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/ports.conf b/apps/meteor/tests/e2e/containers/saml/config/apache/ports.conf new file mode 100644 index 0000000000000..286af7fd4ec36 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/ports.conf @@ -0,0 +1,9 @@ +Listen 8080 + + + Listen 8443 + + + + Listen 8443 + \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/private_key b/apps/meteor/tests/e2e/containers/saml/config/apache/private_key new file mode 100644 index 0000000000000..f511efcd9b655 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/private_key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJdEoPcrNX+E6r +BOEVG0D56iK0mkggSZZu6A8wLUb7tsWrEea3GK5CE7DIeuKPfluKIPxWluhjr1l6 +nufVAJ+Yih2H27F1o3EW3PGOvJEvN8/cyk8WSkEcJ1UdlYM/ZPMi87FZyeUuaKRK +cGsEzWe+EpJaNoWyMMBcwtdPSZx6IOlDRBCQMDpEDLElZZEkguO50GHnq6Sq93Ph +4VYABWJlfkxTAIPGIHG0/c0hdWY4MzuQMupn35Bx+kw3lC99xSMc/3nzjKtiIn03 +rgXLm9TY4fVFfkSwwdO12pHgfkrrM19WWBFIbXvQxyOSxNJujWRwMJK7NM7zY2tW +RT5Hvxb9AgMBAAECggEAVT4KtHyxXJDqIL1gzICKvvUOmGMMD/VzXRx+iMEv3wTY +oWliuakM21LfpAUzZspty4XnoHAch0nET/l7WYr4/R+8HSed8IwnJyh4YhByUouI +PgGw81qaMGKIRotkTOfXZbu+GKMwgbGvivwEnLSZqDjNirS1X8/3JYkgeCFKv/X6 +K4Z1SKoebtA4oGDGZtxNpeGJ6TibaSO3PRW+oH3dD5j5ez1k+dlRasMnuKG76jBy +naz052t52UZxAnGkbZUU84VljO5R8x1jip+M6+qILC/PsI1hb45Dx5VRXNUBGP2s +/PRVFptNGmsIlXBYMSdOo9RJAIsZ1kTty34nIqeuYQKBgQD4Nv+QoeRXjG7iAmzj +JabcA+2FiHftvjdaYjahXQ/Ma6ns/vv//tVfxJaNyCxMObjIKhidsrf/AAn03FgT +LkL0Q2tTBNL9jejzHstnCKSSu/pSgqatsABm1cokC9qsuMjweMVy+tGjSsF6YgjI +2K0heQpF0bMBSYmyQU4aTLbytQKBgQDPxdWVn7ReCscDwpchx+zffKh5JOshW44z +GYBfVbugBljj9w6fDjKyQzk0jMGmuG9rvqfZzu9MpQhjBFKxL4aXPBN7bQOY3DKc +FuGSbIbV7ldPmZhOxqLy6NLyiry6eSQGNf1Q8YCF6bsUR/rvdKDtBLrbJObgRVbu +ChT2PXRYKQKBgFSEDZMGvMReqebE4qSZTm592+Nq60MFUL2y0V0yXc3CHxL2Y4Hw +GGFKg+T08rhlsxhc1RLlJqdqMPmyCT9Gsj+PsTyMWPdC2b3mj2We2MKpxPtRR0W+ +tvRM+U46xxOmu6y9wqV65+TM8IImXU1eEd1i5G+Pjn7ytjL+74Qe+PA9AoGAEasM +F5YmG10lQU+Z1HiQzwxlsy+Ngx+q/vNrNDAxLVF8253Vs3bcnsYSpkJV8Vx7tRjY +YzAyrzzVcr4aXhDhjBjCu1sw1B3de+KCOhZafPSwngc8qW5AyxE7Zv6fP+gvRQvw +R6LRwBF5JCde0mADk0Q0s4/2xhl/Y+ydjbb6HskCgYEAysuIUrDslGGsK57loxMO +FVz9SmLTZIJqkSW+l3dDHMG+BvnJFP+yf0Kr2zGbXRzOvNVWAFP2aU39RFDxbUIM +Nz7obLWrVKbQUDaU7fCbP4OBVuo9p4UM6j/PZy+3Cyps+GMTrC9wi4HbbNEWHyCW +xaNL9LNQ3B9hJG77htK1oRw= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/simplesamlphp.conf b/apps/meteor/tests/e2e/containers/saml/config/apache/simplesamlphp.conf new file mode 100644 index 0000000000000..a81c39d85f8b7 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/simplesamlphp.conf @@ -0,0 +1,23 @@ + + ServerName localhost + DocumentRoot /var/www/simplesamlphp + Alias /simplesaml /var/www/simplesamlphp/www + + + Require all granted + + + + + ServerName localhost + DocumentRoot /var/www/simplesamlphp + SSLEngine on + SSLCertificateFile /etc/ssl/cert/cert.crt + SSLCertificateKeyFile /etc/ssl/private/private.key + Alias /simplesaml /var/www/simplesamlphp/www + + + Require all granted + + + \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/authsources.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/authsources.php new file mode 100644 index 0000000000000..867d66049b3ac --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/authsources.php @@ -0,0 +1,34 @@ + array( + 'core:AdminPassword', + ), + + 'example-userpass' => array( + 'exampleauth:UserPass', + 'samluser1:password' => array( + 'uid' => array('1'), + 'username' => 'samluser1', + 'cn' => 'Saml User 1', + 'eduPersonAffiliation' => array('group1'), + 'email' => 'samluser1@example.com', + ), + 'samluser2:password' => array( + 'uid' => array('2'), + 'username' => 'samluser2', + 'cn' => 'Saml User 2', + 'eduPersonAffiliation' => array('group2'), + 'email' => 'user_for_saml_merge@email.com', + ), + 'samluser3:password' => array( + 'uid' => array('3'), + 'username' => 'user_for_saml_merge2', + 'cn' => 'Saml User 3', + 'eduPersonAffiliation' => array('group2'), + 'email' => 'samluser3@example.com', + ), + ), + +); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php new file mode 100644 index 0000000000000..79e73e6b58c43 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php @@ -0,0 +1,111 @@ + 'simplesaml/', + 'certdir' => 'cert/', + 'loggingdir' => 'log/', + 'datadir' => 'data/', + 'tempdir' => '/tmp/simplesaml', + 'debug' => true, + 'showerrors' => true, + 'errorreporting' => true, + 'debug.validatexml' => false, + 'auth.adminpassword' => 'secret', + 'admin.protectindexpage' => false, + 'admin.protectmetadata' => false, + 'secretsalt' => 'defaultsecretsalt', + 'technicalcontact_name' => 'Administrator', + 'technicalcontact_email' => 'na@example.org', + 'timezone' => null, + 'logging.level' => SimpleSAML_Logger::DEBUG, + 'logging.handler' => 'errorlog', + //'logging.format' => '%date{%b %d %H:%M:%S} %process %level %stat[%trackid] %msg', + 'logging.facility' => defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER, + 'logging.processname' => 'simplesamlphp', + 'logging.logfile' => 'simplesamlphp.log', + 'statistics.out' => array( + ), + 'database.dsn' => 'mysql:host=localhost;dbname=saml', + 'database.username' => 'simplesamlphp', + 'database.password' => 'secret', + 'database.prefix' => '', + 'database.persistent' => false, + 'database.slaves' => array( + ), + 'enable.saml20-idp' => true, + 'enable.shib13-idp' => true, + 'enable.adfs-idp' => false, + 'enable.wsfed-sp' => false, + 'enable.authmemcookie' => false, + 'session.duration' => 8 * (60 * 60), // 8 hours. + 'session.datastore.timeout' => (4 * 60 * 60), // 4 hours + 'session.state.timeout' => (60 * 60), // 1 hour + 'session.cookie.name' => 'SimpleSAMLSessionIDIdp', + 'session.cookie.lifetime' => 0, + 'session.cookie.path' => '/', + 'session.cookie.domain' => null, + 'session.cookie.secure' => false, + 'enable.http_post' => false, + 'session.phpsession.cookiename' => 'PHPSESSIDIDP', + 'session.phpsession.savepath' => null, + 'session.phpsession.httponly' => true, + 'session.authtoken.cookiename' => 'SimpleSAMLAuthTokenIdp', + 'session.rememberme.enable' => false, + 'session.rememberme.checked' => false, + 'session.rememberme.lifetime' => (14 * 86400), + 'language.available' => array( + 'en', 'no', 'nn', 'se', 'da', 'de', 'sv', 'fi', 'es', 'fr', 'it', 'nl', 'lb', 'cs', + 'sl', 'lt', 'hr', 'hu', 'pl', 'pt', 'pt-br', 'tr', 'ja', 'zh', 'zh-tw', 'ru', 'et', + 'he', 'id', 'sr', 'lv', 'ro', 'eu' + ), + 'language.rtl' => array('ar', 'dv', 'fa', 'ur', 'he'), + 'language.default' => 'en', + 'language.parameter.name' => 'language', + 'language.parameter.setcookie' => true, + 'language.cookie.name' => 'language', + 'language.cookie.domain' => null, + 'language.cookie.path' => '/', + 'language.cookie.lifetime' => (60 * 60 * 24 * 900), + 'attributes.extradictionary' => null, + 'theme.use' => 'default', + 'default-wsfed-idp' => 'urn:federation:pingfederate:localhost', + 'idpdisco.enableremember' => true, + 'idpdisco.rememberchecked' => true, + 'idpdisco.validate' => true, + 'idpdisco.extDiscoveryStorage' => null, + 'idpdisco.layout' => 'dropdown', + 'shib13.signresponse' => true, + 'authproc.idp' => array( + 30 => 'core:LanguageAdaptor', + 45 => array( + 'class' => 'core:StatisticsWithAttribute', + 'attributename' => 'realm', + 'type' => 'saml20-idp-SSO', + ), + 50 => 'core:AttributeLimit', + 99 => 'core:LanguageAdaptor', + ), + 'authproc.sp' => array( + 90 => 'core:LanguageAdaptor', + ), + 'metadata.sources' => array( + array('type' => 'flatfile'), + ), + 'store.type' => 'phpsession', + 'store.sql.dsn' => 'sqlite:/path/to/sqlitedatabase.sq3', + 'store.sql.username' => null, + 'store.sql.password' => null, + 'store.sql.prefix' => 'SimpleSAMLphp', + 'memcache_store.servers' => array( + array( + array('hostname' => 'localhost'), + ), + ), + 'memcache_store.prefix' => null, + 'memcache_store.expires' => 36 * (60 * 60), // 36 hours. + 'metadata.sign.enable' => false, + 'metadata.sign.privatekey' => null, + 'metadata.sign.privatekey_pass' => null, + 'metadata.sign.certificate' => null, + 'proxy' => null, + 'trusted.url.domains' => array(), +); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-sp-remote.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-sp-remote.php new file mode 100644 index 0000000000000..79591af7dfa3d --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-sp-remote.php @@ -0,0 +1,24 @@ + 'http://localhost:3000/_saml/metadata/test-sp', + 'contacts' => array ( + ), + 'metadata-set' => 'saml20-sp-remote', + 'AssertionConsumerService' => array ( + 0 => array ( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'Location' => 'http://localhost:3000/_saml/validate/test-sp', + 'index' => 1, + 'isDefault' => true, + ), + ), + 'SingleLogoutService' => array ( + 0 => array ( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'Location' => 'http://localhost:3000/_saml/logout/test-sp/', + 'ResponseLocation' => 'http://localhost:3000/_saml/logout/test-sp/', + ), + ), + 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', +); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_crt b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_crt new file mode 100644 index 0000000000000..f0623b9305e07 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+Cgav +Og8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+ +YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc ++TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyix +YFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8 +jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/C +YQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6b +lEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFs +X1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7 +yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7 +NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG +99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2n +aQ== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_pem b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_pem new file mode 100644 index 0000000000000..ba2bac5fe6308 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDNQIWjOA1vWHUz +SPM1FIKOE4GdH65VtWlpZ9dghH4CFYN0R7mvJj4KBq86Dxt8vJvLMV16GVh0NGCR +50QH8aMbxonDTqXSoXiMM4DDSQTKBYK7aZwftc7FG35gAfdNUdr8e7VbdaPOShuq +qotDyCQpZYzbt86ABnoaJ5okE3pUFIwxw97LcdYsGZz5Ngma/V1to7aMeEqHyl8r +DRbXZUzw/U8g7yC/g+G7+64liJ4FYqLEETLLSUePKLFgUJHXbF2HgIDjur3nxlEa +ecNQYVUTVCGBFpwkI5n1t3m32avwotpUFhMImjkRETyPKZpvl0+p7mop8mwJmKpa +CVuNSj23AgMBAAECggEABn4I/B20xxXcNzASiVZJvua9DdRHtmxTlkLznBj0x2oY +y1/Nbs3d3oFRn5uEuhBZOTcphsgwdRSHDXZsP3gUObew+d2N/zieUIj8hLDVlvJP +rU/s4U/l53Q0LiNByE9ThvL+zJLPCKJtd5uHZjB5fFm69+Q7gu8xg4xHIub+0pP5 +PHanmHCDrbgNN/oqlar4FZ2MXTgekW6Amyc/koE9hIn4Baa2Ke/B/AUGY4pMRLqp +TArt+GTVeWeoFY9QACUpaHpJhGb/Piou6tlU57e42cLoki1f0+SARsBBKyXA7BB1 +1fMH10KQYFA68dTYWlKzQau/K4xaqg4FKmtwF66GQQKBgQD9OpNUS7oRxMHVJaBR +TNWW+V1FXycqojekFpDijPb2X5CWV16oeWgaXp0nOHFdy9EWs3GtGpfZasaRVHsX +SHtPh4Nb8JqHdGE0/CD6t0+4Dns8Bn9cSqtdQB7R3Jn7IMXi9X/U8LDKo+A18/Jq +V8VgUngMny9YjMkQIbK8TRWkYQKBgQDPf4nxO6ju+tOHHORQty3bYDD0+OV3I0+L +0yz0uPreryBVi9nY43KakH52D7UZEwwsBjjGXD+WH8xEsmBWsGNXJu025PvzIJoz +lAEiXvMp/NmYp+tY4rDmO8RhyVocBqWHzh38m0IFOd4ByFD5nLEDrA3pDVo0aNgY +n0GwRysZFwKBgQDkCj3m6ZMUsUWEty+aR0EJhmKyODBDOnY09IVhH2S/FexVFzUN +LtfK9206hp/Awez3Ln2uT4Zzqq5K7fMzUniJdBWdVB004l8voeXpIe9OZuwfcBJ9 +gFi1zypx/uFDv421BzQpBN+QfOdKbvbdQVFjnqCxbSDr80yVlGMrI5fbwQKBgG09 +oRrepO7EIO8GN/GCruLK/ptKGkyhy3Q6xnVEmdb47hX7ncJA5IoZPmrblCVSUNsw +n11XHabksL8OBgg9rt8oQEThQv/aDzTOW9aDlJNragejiBTwq99aYeZ1gjo1CZq4 +2jKubpCfyZC4rGDtrIfZYi1q+S2UcQhtd8DdhwQbAoGAAM4EpDA4yHB5yiek1p/o +CbqRCta/Dx6Eyo0KlNAyPuFPAshupG4NBx7mT2ASfL+2VBHoi6mHSri+BDX5ryYF +fMYvp7URYoq7w7qivRlvvEg5yoYrK13F2+Gj6xJ4jEN9m0KdM/g3mJGq0HBTIQrp +Sm75WXsflOxuTn08LbgGc4s= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/docker-compose.yml b/apps/meteor/tests/e2e/containers/saml/docker-compose.yml new file mode 100644 index 0000000000000..6d8a00f8eba9c --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/docker-compose.yml @@ -0,0 +1,7 @@ +version: '3' +services: + testsamlidp_idp: + build: . + ports: + - "8080:8080" + - "8443:8443" diff --git a/apps/meteor/tests/e2e/fixtures/collections/users.ts b/apps/meteor/tests/e2e/fixtures/collections/users.ts index 661f096c87532..9237fd97926f0 100644 --- a/apps/meteor/tests/e2e/fixtures/collections/users.ts +++ b/apps/meteor/tests/e2e/fixtures/collections/users.ts @@ -1,7 +1,8 @@ import { faker } from '@faker-js/faker'; import type { IUser } from '@rocket.chat/core-typings'; -import type { IUserState } from '../userStates'; +import { DEFAULT_USER_CREDENTIALS } from '../../config/constants'; +import { type IUserState } from '../userStates'; type UserFixture = IUser & { username: string; @@ -23,7 +24,7 @@ export function createUserFixture(user: IUserState): UserFixture { utcOffset: -3, username, services: { - password: { bcrypt: '$2b$10$EMxaeQQbSw9JLL.YvOVPaOW8MKta6pgmp2BcN5Op4cC9bJiOqmUS.' }, + password: { bcrypt: DEFAULT_USER_CREDENTIALS.bcrypt }, email2fa: { enabled: true, changedAt: new Date() }, email: { verificationTokens: [ diff --git a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts index 11cea78b3f3df..a0590106dfd9d 100644 --- a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts +++ b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts @@ -57,6 +57,22 @@ export default async function injectInitialData() { _id: 'API_Enable_Rate_Limiter_Dev', value: false, }, + { + _id: 'SAML_Custom_Default_provider', + value: 'test-sp', + }, + { + _id: 'SAML_Custom_Default_issuer', + value: 'http://localhost:3000/_saml/metadata/test-sp', + }, + { + _id: 'SAML_Custom_Default_entry_point', + value: 'http://localhost:8080/simplesaml/saml2/idp/SSOService.php', + }, + { + _id: 'SAML_Custom_Default_idp_slo_redirect_url', + value: 'http://localhost:8080/simplesaml/saml2/idp/SingleLogoutService.php', + }, ].map((setting) => connection .db() diff --git a/apps/meteor/tests/e2e/fixtures/userStates.ts b/apps/meteor/tests/e2e/fixtures/userStates.ts index 7bcab213f8fce..f21405a94f02e 100644 --- a/apps/meteor/tests/e2e/fixtures/userStates.ts +++ b/apps/meteor/tests/e2e/fixtures/userStates.ts @@ -86,6 +86,10 @@ export const Users = { user1: generateContext('user1'), user2: generateContext('user2'), user3: generateContext('user3'), + samluser1: generateContext('samluser1'), + samluser2: generateContext('samluser2'), + userForSamlMerge: generateContext('user_for_saml_merge'), + userForSamlMerge2: generateContext('user_for_saml_merge2'), admin: generateContext('rocketchat.internal.admin.test'), }; diff --git a/apps/meteor/tests/e2e/login.spec.ts b/apps/meteor/tests/e2e/login.spec.ts index 958f5120f1422..41710fffa203c 100644 --- a/apps/meteor/tests/e2e/login.spec.ts +++ b/apps/meteor/tests/e2e/login.spec.ts @@ -1,13 +1,16 @@ import { faker } from '@faker-js/faker'; -import { Registration } from './page-objects'; +import { DEFAULT_USER_CREDENTIALS } from './config/constants'; +import { Utils, Registration } from './page-objects'; import { test, expect } from './utils/test'; test.describe.parallel('Login', () => { let poRegistration: Registration; + let poUtils: Utils; test.beforeEach(async ({ page }) => { poRegistration = new Registration(page); + poUtils = new Utils(page); await page.goto('/home'); }); @@ -27,4 +30,24 @@ test.describe.parallel('Login', () => { await expect(poRegistration.inputPassword).toBeInvalid(); }); }); + + test('Login with valid username and password', async () => { + await test.step('expect successful login', async () => { + await poRegistration.username.type('user1'); + await poRegistration.inputPassword.type(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.btnLogin.click(); + + await expect(poUtils.mainContent).toBeVisible(); + }); + }); + + test('Login with valid email and password', async () => { + await test.step('expect successful login', async () => { + await poRegistration.username.type('user1@email.com'); + await poRegistration.inputPassword.type(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.btnLogin.click(); + + await expect(poUtils.mainContent).toBeVisible(); + }); + }); }); diff --git a/apps/meteor/tests/e2e/page-objects/auth.ts b/apps/meteor/tests/e2e/page-objects/auth.ts index 9b47d2e44adca..ce80c1795e052 100644 --- a/apps/meteor/tests/e2e/page-objects/auth.ts +++ b/apps/meteor/tests/e2e/page-objects/auth.ts @@ -20,6 +20,10 @@ export class Registration { return this.page.locator('role=button[name="Login"]'); } + get btnLoginWithSaml(): Locator { + return this.page.locator('role=button[name="SAML"]'); + } + get goToRegister(): Locator { return this.page.locator('role=link[name="Create an account"]'); } diff --git a/apps/meteor/tests/e2e/saml.spec.ts b/apps/meteor/tests/e2e/saml.spec.ts new file mode 100644 index 0000000000000..3f097a7f25f45 --- /dev/null +++ b/apps/meteor/tests/e2e/saml.spec.ts @@ -0,0 +1,279 @@ +import child_process from 'child_process'; +import path from 'path'; + +import { Page } from '@playwright/test'; +import { v2 as compose } from 'docker-compose' +import { MongoClient } from 'mongodb'; + +import * as constants from './config/constants'; +import { createUserFixture } from './fixtures/collections/users'; +import { Users } from './fixtures/userStates'; +import { Registration } from './page-objects'; +import { getUserInfo } from './utils/getUserInfo'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.describe.parallel('SAML', () => { + let poRegistration: Registration; + + const containerPath = path.join(__dirname, 'containers', 'saml'); + + test.beforeAll(async ({ api }) => { + await compose.buildOne('testsamlidp_idp', { + cwd: containerPath, + }); + + await compose.upOne('testsamlidp_idp', { + cwd: containerPath, + }); + + // Reset saml users' data on mongo in the beforeAll hook to allow re-running the tests within the same playwright session + // This is needed because those tests will modify this data and running them a second time would trigger different code paths + const connection = await MongoClient.connect(constants.URL_MONGODB); + + const usernamesToDelete = [Users.userForSamlMerge, Users.userForSamlMerge2, Users.samluser1, Users.samluser2].map(({ data: { username }}) => username); + await connection + .db() + .collection('users') + .deleteMany({ + username: { + $in: usernamesToDelete, + } + }); + + const usersFixtures = [Users.userForSamlMerge, Users.userForSamlMerge2].map((user) => createUserFixture(user)); + await Promise.all( + usersFixtures.map((user) => + connection.db().collection('users').updateOne({ username: user.username }, { $set: user }, { upsert: true }), + ), + ); + + await Promise.all( + [ + { + _id: 'SAML_Custom_Default_logout_behaviour', + value: 'SAML', + }, + { + _id: 'SAML_Custom_Default_immutable_property', + value: 'EMail', + }, + { + _id: 'SAML_Custom_Default_mail_overwrite', + value: false, + }, + { + _id: 'SAML_Custom_Default', + value: false, + }, + ].map((setting) => + connection + .db() + .collection('rocketchat_settings') + .updateOne({ _id: setting._id }, { $set: { value: setting.value } }), + ), + ); + + // Only one setting updated through the API to avoid refreshing the service configurations several times + await expect((await setSettingValueById(api, 'SAML_Custom_Default', true)).status()).toBe(200); + }); + + test.afterAll(async () => { + await compose.down({ + cwd: containerPath, + }); + + // the compose CLI doesn't have any way to remove images, so try to remove it with a direct call to the docker cli, but ignore errors if it fails. + try { + child_process.spawn('docker', ['rmi', 'saml-testsamlidp_idp'], { + cwd: containerPath, + }); + } catch { + // ignore errors here + } + }); + + test.beforeEach(async ({ page }) => { + poRegistration = new Registration(page); + + await page.goto('/home'); + }); + + test('Login', async ({ page, api }) => { + await test.step('expect to have SAML login button available', async () => { + await expect(poRegistration.btnLoginWithSaml).toBeVisible(); + }); + + await test.step('expect to be redirected to the IdP for login', async () => { + await poRegistration.btnLoginWithSaml.click(); + + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + }); + + await test.step('expect to be redirected back on successful login', async () => { + await page.getByLabel('Username').fill('samluser1'); + await page.getByLabel('Password').fill('password'); + await page.locator('role=button[name="Login"]').click(); + + await expect(page).toHaveURL('/home'); + }); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'samluser1'); + + expect(user).toBeDefined(); + expect(user?.username).toBe('samluser1'); + expect(user?.name).toBe('Saml User 1'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser1@example.com'); + }); + }); + + const doLoginStep = async (page: Page, username: string) => { + await test.step('expect successful login', async () => { + await poRegistration.btnLoginWithSaml.click(); + // Redirect to Idp + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + + // Fill username and password + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password').fill('password'); + await page.locator('role=button[name="Login"]').click(); + + // Redirect back to rocket.chat + await expect(page).toHaveURL('/home'); + + await expect(page.getByLabel('User Menu')).toBeVisible(); + }); + }; + + const doLogoutStep = async (page: Page) => { + await test.step('logout', async () => { + await page.getByLabel('User Menu').click(); + await page.locator('//*[contains(@class, "rcx-option__content") and contains(text(), "Logout")]').click(); + + await expect(page).toHaveURL('/home'); + await expect(page.getByLabel('User Menu')).not.toBeVisible(); + }); + }; + + test('Logout - Rocket.Chat only', async ({ page, api }) => { + await test.step('Configure logout to only logout from Rocket.Chat', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_logout_behaviour', 'Local')).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser1'); + await doLogoutStep(page); + + await test.step('expect IdP to redirect back automatically on new login request', async () => { + await poRegistration.btnLoginWithSaml.click(); + + await expect(page).toHaveURL('/home'); + }); + }); + + test('Logout - Single Sign Out', async ({ page, api }) => { + await test.step('Configure logout to terminate SAML session', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_logout_behaviour', 'SAML')).status()).toBe(200); + }) + + await doLoginStep(page, 'samluser1'); + await doLogoutStep(page); + + await test.step('expect IdP to show login form on new login request', async () => { + await poRegistration.btnLoginWithSaml.click(); + + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + await expect(page.getByLabel('Username')).toBeVisible(); + }); + }); + + test('User Merge - By Email', async ({ page, api }) => { + await test.step('Configure SAML to identify users by email', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'EMail')).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser2'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'samluser2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge'); + expect(user?.username).toBe('samluser2'); + expect(user?.name).toBe('Saml User 2'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('user_for_saml_merge@email.com'); + }); + }); + + test('User Merge - By Username', async ({ page, api }) => { + await test.step('Configure SAML to identify users by username', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'Username')).status()).toBe(200); + await expect((await setSettingValueById(api, 'SAML_Custom_Default_mail_overwrite', false)).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser3'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'user_for_saml_merge2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge2'); + expect(user?.username).toBe('user_for_saml_merge2'); + expect(user?.name).toBe('Saml User 3'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('user_for_saml_merge2@email.com'); + }); + }); + + test('User Merge - By Username with Email Override', async ({ page, api }) => { + await test.step('Configure SAML to identify users by username', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'Username')).status()).toBe(200); + await expect((await setSettingValueById(api, 'SAML_Custom_Default_mail_overwrite', true)).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser3'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'user_for_saml_merge2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge2'); + expect(user?.username).toBe('user_for_saml_merge2'); + expect(user?.name).toBe('Saml User 3'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser3@example.com'); + }); + }); + + test.fixme('User Merge - By Custom Identifier', async () => { + // Test user merge with a custom identifier configured in the fieldmap + }); + + test.fixme('Signature Validation', async () => { + // Test login with signed responses + }); + + test.fixme('Login - User without username', async () => { + // Test login with a SAML user with no username + // Test different variations of the Immutable Property setting + }); + + test.fixme('Login - User without email', async () => { + // Test login with a SAML user with no email + // Test different variations of the Immutable Property setting + }); + + test.fixme('Login - User without name', async () => { + // Test login with a SAML user with no name + }); + + test.fixme('Login - User with channels attribute', async () => { + // Test login with a SAML user with a "channels" attribute + }); + + test.fixme('Data Sync - Custom Field Map', async () => { + // Test the data sync using a custom fieldmap setting + }); +}); diff --git a/apps/meteor/tests/e2e/utils/getUserInfo.ts b/apps/meteor/tests/e2e/utils/getUserInfo.ts new file mode 100644 index 0000000000000..13c592a7244b7 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/getUserInfo.ts @@ -0,0 +1,15 @@ +import { IUser } from '@rocket.chat/core-typings'; + +import type { BaseTest } from './test'; + +export const getUserInfo = async (api: BaseTest['api'], username: string): Promise => { + const response = await api.get(`/users.info?username=${username}`); + + if (response.status() !== 200) { + throw new Error('Failed to get user info.'); + } + + const data = await response.json(); + + return data.user; +} diff --git a/yarn.lock b/yarn.lock index da83e044e00f0..0ea65363d7900 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9623,6 +9623,7 @@ __metadata: date-fns: ^2.28.0 date.js: ~0.3.3 debug: ~4.1.1 + docker-compose: ^0.24.3 dompurify: ^2.3.8 ejson: ^2.2.3 emailreplyparser: ^0.0.5 @@ -20418,6 +20419,15 @@ __metadata: languageName: node linkType: hard +"docker-compose@npm:^0.24.3": + version: 0.24.3 + resolution: "docker-compose@npm:0.24.3" + dependencies: + yaml: ^2.2.2 + checksum: b2149eafb6e0a37ff4595044fe63d2fac23483afab06bca71cece78df4bae6f796b0a123854957addda77cc0559f205bc03cf3984ced816161e30e7f247d88e7 + languageName: node + linkType: hard + "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -41757,6 +41767,13 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.2.2": + version: 2.3.4 + resolution: "yaml@npm:2.3.4" + checksum: e6d1dae1c6383bcc8ba11796eef3b8c02d5082911c6723efeeb5ba50fc8e881df18d645e64de68e421b577296000bea9c75d6d9097c2f6699da3ae0406c030d8 + languageName: node + linkType: hard + "yamljs@npm:0.3.0": version: 0.3.0 resolution: "yamljs@npm:0.3.0" From 6312b733e50ef95650b2415cfb40236e8fd9bb42 Mon Sep 17 00:00:00 2001 From: Pierre Date: Thu, 28 Dec 2023 16:31:11 -0300 Subject: [PATCH 02/34] Clear SAML data after tests run to ensure the generated data won't affect other tests --- apps/meteor/tests/e2e/saml.spec.ts | 105 ++++++++++++++++------------- 1 file changed, 58 insertions(+), 47 deletions(-) diff --git a/apps/meteor/tests/e2e/saml.spec.ts b/apps/meteor/tests/e2e/saml.spec.ts index 3f097a7f25f45..22df9a45069fd 100644 --- a/apps/meteor/tests/e2e/saml.spec.ts +++ b/apps/meteor/tests/e2e/saml.spec.ts @@ -13,7 +13,60 @@ import { getUserInfo } from './utils/getUserInfo'; import { setSettingValueById } from './utils/setSettingValueById'; import { test, expect } from './utils/test'; -test.describe.parallel('SAML', () => { +const resetTestData = async (cleanupOnly = false) => { + // Reset saml users' data on mongo in the beforeAll hook to allow re-running the tests within the same playwright session + // This is needed because those tests will modify this data and running them a second time would trigger different code paths + const connection = await MongoClient.connect(constants.URL_MONGODB); + + const usernamesToDelete = [Users.userForSamlMerge, Users.userForSamlMerge2, Users.samluser1, Users.samluser2].map(({ data: { username }}) => username); + await connection + .db() + .collection('users') + .deleteMany({ + username: { + $in: usernamesToDelete, + } + }); + + if (cleanupOnly) { + return; + } + + const usersFixtures = [Users.userForSamlMerge, Users.userForSamlMerge2].map((user) => createUserFixture(user)); + await Promise.all( + usersFixtures.map((user) => + connection.db().collection('users').updateOne({ username: user.username }, { $set: user }, { upsert: true }), + ), + ); + + await Promise.all( + [ + { + _id: 'SAML_Custom_Default_logout_behaviour', + value: 'SAML', + }, + { + _id: 'SAML_Custom_Default_immutable_property', + value: 'EMail', + }, + { + _id: 'SAML_Custom_Default_mail_overwrite', + value: false, + }, + { + _id: 'SAML_Custom_Default', + value: false, + }, + ].map((setting) => + connection + .db() + .collection('rocketchat_settings') + .updateOne({ _id: setting._id }, { $set: { value: setting.value } }), + ), + ); +}; + +test.describe('SAML', () => { let poRegistration: Registration; const containerPath = path.join(__dirname, 'containers', 'saml'); @@ -27,52 +80,7 @@ test.describe.parallel('SAML', () => { cwd: containerPath, }); - // Reset saml users' data on mongo in the beforeAll hook to allow re-running the tests within the same playwright session - // This is needed because those tests will modify this data and running them a second time would trigger different code paths - const connection = await MongoClient.connect(constants.URL_MONGODB); - - const usernamesToDelete = [Users.userForSamlMerge, Users.userForSamlMerge2, Users.samluser1, Users.samluser2].map(({ data: { username }}) => username); - await connection - .db() - .collection('users') - .deleteMany({ - username: { - $in: usernamesToDelete, - } - }); - - const usersFixtures = [Users.userForSamlMerge, Users.userForSamlMerge2].map((user) => createUserFixture(user)); - await Promise.all( - usersFixtures.map((user) => - connection.db().collection('users').updateOne({ username: user.username }, { $set: user }, { upsert: true }), - ), - ); - - await Promise.all( - [ - { - _id: 'SAML_Custom_Default_logout_behaviour', - value: 'SAML', - }, - { - _id: 'SAML_Custom_Default_immutable_property', - value: 'EMail', - }, - { - _id: 'SAML_Custom_Default_mail_overwrite', - value: false, - }, - { - _id: 'SAML_Custom_Default', - value: false, - }, - ].map((setting) => - connection - .db() - .collection('rocketchat_settings') - .updateOne({ _id: setting._id }, { $set: { value: setting.value } }), - ), - ); + await resetTestData(); // Only one setting updated through the API to avoid refreshing the service configurations several times await expect((await setSettingValueById(api, 'SAML_Custom_Default', true)).status()).toBe(200); @@ -91,6 +99,9 @@ test.describe.parallel('SAML', () => { } catch { // ignore errors here } + + // Remove saml test users so they don't interfere with other tests + await resetTestData(true); }); test.beforeEach(async ({ page }) => { From afbc314dc481b18195d5c0535e1854dbff3b2b5b Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 29 Dec 2023 14:37:44 -0300 Subject: [PATCH 03/34] timeout? --- apps/meteor/tests/e2e/saml.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/meteor/tests/e2e/saml.spec.ts b/apps/meteor/tests/e2e/saml.spec.ts index 22df9a45069fd..faa6b25710c28 100644 --- a/apps/meteor/tests/e2e/saml.spec.ts +++ b/apps/meteor/tests/e2e/saml.spec.ts @@ -72,6 +72,11 @@ test.describe('SAML', () => { const containerPath = path.join(__dirname, 'containers', 'saml'); test.beforeAll(async ({ api }) => { + await resetTestData(); + + // Only one setting updated through the API to avoid refreshing the service configurations several times + await expect((await setSettingValueById(api, 'SAML_Custom_Default', true)).status()).toBe(200); + await compose.buildOne('testsamlidp_idp', { cwd: containerPath, }); @@ -79,11 +84,6 @@ test.describe('SAML', () => { await compose.upOne('testsamlidp_idp', { cwd: containerPath, }); - - await resetTestData(); - - // Only one setting updated through the API to avoid refreshing the service configurations several times - await expect((await setSettingValueById(api, 'SAML_Custom_Default', true)).status()).toBe(200); }); test.afterAll(async () => { @@ -112,7 +112,7 @@ test.describe('SAML', () => { test('Login', async ({ page, api }) => { await test.step('expect to have SAML login button available', async () => { - await expect(poRegistration.btnLoginWithSaml).toBeVisible(); + await expect(poRegistration.btnLoginWithSaml).toBeVisible({ timeout: 10000 }); }); await test.step('expect to be redirected to the IdP for login', async () => { From 636f9da11c6f71ceb1d7e8c35206eef40aef6c8e Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 2 Jan 2024 17:16:00 -0300 Subject: [PATCH 04/34] added test for oauth button --- .../tests/e2e/fixtures/inject-initial-data.ts | 4 +++ apps/meteor/tests/e2e/oauth.spec.ts | 26 +++++++++++++++++++ apps/meteor/tests/e2e/page-objects/auth.ts | 4 +++ 3 files changed, 34 insertions(+) create mode 100644 apps/meteor/tests/e2e/oauth.spec.ts diff --git a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts index a0590106dfd9d..e7e68790cf3dd 100644 --- a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts +++ b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts @@ -73,6 +73,10 @@ export default async function injectInitialData() { _id: 'SAML_Custom_Default_idp_slo_redirect_url', value: 'http://localhost:8080/simplesaml/saml2/idp/SingleLogoutService.php', }, + { + _id: 'Accounts_OAuth_Google', + value: false, + }, ].map((setting) => connection .db() diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts new file mode 100644 index 0000000000000..86fe98759d75c --- /dev/null +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -0,0 +1,26 @@ +import { Registration } from './page-objects'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.describe('OAuth', () => { + let poRegistration: Registration; + + test.beforeEach(async ({ page }) => { + poRegistration = new Registration(page); + + await page.goto('/home'); + }); + + test('Login Page', async ({ api }) => { + await test.step('expect OAuth button to be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); + await expect(poRegistration.btnLoginWithGoogle).toBeVisible({ timeout: 10000 }); + }); + + // await test.step('expect OAuth button to not be visible', async () => { + // await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); + + // await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); + // }); + }); +}); diff --git a/apps/meteor/tests/e2e/page-objects/auth.ts b/apps/meteor/tests/e2e/page-objects/auth.ts index ce80c1795e052..98421f6461ab7 100644 --- a/apps/meteor/tests/e2e/page-objects/auth.ts +++ b/apps/meteor/tests/e2e/page-objects/auth.ts @@ -24,6 +24,10 @@ export class Registration { return this.page.locator('role=button[name="SAML"]'); } + get btnLoginWithGoogle(): Locator { + return this.page.locator('role=button[name="Sign in with Google"]'); + } + get goToRegister(): Locator { return this.page.locator('role=link[name="Create an account"]'); } From cc4ba82573ed579c6be45d0d7616ece641fcc412 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 2 Jan 2024 18:14:45 -0300 Subject: [PATCH 05/34] watch meteor settings without Meteor.startup --- apps/meteor/app/meteor-accounts-saml/server/startup.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/meteor-accounts-saml/server/startup.ts b/apps/meteor/app/meteor-accounts-saml/server/startup.ts index 7a2bf16d3244e..556ab7df13e7d 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/startup.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/startup.ts @@ -1,4 +1,5 @@ import { Logger } from '@rocket.chat/logger'; +import debounce from 'lodash.debounce'; import { Meteor } from 'meteor/meteor'; import { settings } from '../../settings/server'; @@ -10,5 +11,6 @@ SAMLUtils.setLoggerInstance(logger); Meteor.startup(async () => { await addSettings('Default'); - settings.watchByRegex(/^SAML_.+/, loadSamlServiceProviders); }); + +settings.watchByRegex(/^SAML_.+/, debounce(loadSamlServiceProviders, 2000)); From 257c89ae444fe48314bca89ff9b1620b34ecedaa Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 3 Jan 2024 10:57:52 -0300 Subject: [PATCH 06/34] activated new test for disabling login services --- apps/meteor/tests/e2e/oauth.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index 86fe98759d75c..5a4b1da63c350 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -17,10 +17,10 @@ test.describe('OAuth', () => { await expect(poRegistration.btnLoginWithGoogle).toBeVisible({ timeout: 10000 }); }); - // await test.step('expect OAuth button to not be visible', async () => { - // await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); + await test.step('expect OAuth button to not be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); - // await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); - // }); + await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); + }); }); }); From cffcb32488e04660f790a6b3376c84c355647399 Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 3 Jan 2024 20:47:24 -0300 Subject: [PATCH 07/34] fix: login buttons remain visible until refresh after disabling authentication service --- .changeset/little-planes-wonder.md | 7 +++++++ .../rocketchat-mongo-config/server/index.js | 4 ++-- .../server/modules/watchers/watchers.module.ts | 3 +++ apps/meteor/server/services/meteor/service.ts | 9 ++++++--- ee/apps/ddp-streamer/src/DDPStreamer.ts | 5 ++++- packages/core-services/src/events/Events.ts | 15 ++++++++++++++- 6 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 .changeset/little-planes-wonder.md diff --git a/.changeset/little-planes-wonder.md b/.changeset/little-planes-wonder.md new file mode 100644 index 0000000000000..13c90d0efcdcb --- /dev/null +++ b/.changeset/little-planes-wonder.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/core-services': patch +'@rocket.chat/ddp-streamer': patch +'@rocket.chat/meteor': patch +--- + +Fixed an issue that caused login buttons to not be reactively removed from the login page when the related authentication service was disabled by an admin. diff --git a/apps/meteor/packages/rocketchat-mongo-config/server/index.js b/apps/meteor/packages/rocketchat-mongo-config/server/index.js index 65464a31095cd..684620d090542 100644 --- a/apps/meteor/packages/rocketchat-mongo-config/server/index.js +++ b/apps/meteor/packages/rocketchat-mongo-config/server/index.js @@ -4,8 +4,8 @@ import { PassThrough } from 'stream'; import { Email } from 'meteor/email'; import { Mongo } from 'meteor/mongo'; -const shouldDisableOplog = ['yes', 'true'].includes(String(process.env.USE_NATIVE_OPLOG).toLowerCase()); -if (!shouldDisableOplog) { +const shouldUseNativeOplog = ['yes', 'true'].includes(String(process.env.USE_NATIVE_OPLOG).toLowerCase()); +if (!shouldUseNativeOplog) { Package['disable-oplog'] = {}; } diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 88e465edc0188..7338a43e0e65a 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -341,6 +341,9 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb watcher.on(LoginServiceConfiguration.getCollectionName(), async ({ clientAction, id }) => { const data = await LoginServiceConfiguration.findOne>(id, { projection: { secret: 0 } }); if (!data) { + if (clientAction === 'removed') { + void broadcast('watch.loginServiceConfiguration', { clientAction, id }); + } return; } diff --git a/apps/meteor/server/services/meteor/service.ts b/apps/meteor/server/services/meteor/service.ts index 8b9462740c6c2..6c69634b82cb1 100644 --- a/apps/meteor/server/services/meteor/service.ts +++ b/apps/meteor/server/services/meteor/service.ts @@ -152,9 +152,12 @@ export class MeteorService extends ServiceClassInternal implements IMeteor { return; } - serviceConfigCallbacks.forEach((callbacks) => { - callbacks[clientAction === 'inserted' ? 'added' : 'changed']?.(id, data); - }); + // per the event definition, data will always be defined when clientAction is not 'removed' + if (data) { + serviceConfigCallbacks.forEach((callbacks) => { + callbacks[clientAction === 'inserted' ? 'added' : 'changed']?.(id, data); + }); + } }); } diff --git a/ee/apps/ddp-streamer/src/DDPStreamer.ts b/ee/apps/ddp-streamer/src/DDPStreamer.ts index bccb35d2b3263..1219c75363fe8 100644 --- a/ee/apps/ddp-streamer/src/DDPStreamer.ts +++ b/ee/apps/ddp-streamer/src/DDPStreamer.ts @@ -44,7 +44,10 @@ export class DDPStreamer extends ServiceClass { return; } - events.emit('meteor.loginServiceConfiguration', clientAction === 'inserted' ? 'added' : 'changed', data); + // per the event definition, data will always be defined when clientAction is not 'removed' + if (data) { + events.emit('meteor.loginServiceConfiguration', clientAction === 'inserted' ? 'added' : 'changed', data); + } }); this.onEvent('meteor.clientVersionUpdated', (versions): void => { diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index 67327c3ea2157..b4241b3c3987a 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -40,6 +40,19 @@ import type { AutoUpdateRecord } from '../types/IMeteor'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; +type LoginServiceConfigurationEvent = { + id: string; +} & ( + | { + clientAction: 'removed'; + data?: Partial; + } + | { + clientAction: Omit; + data: Partial; + } +); + export type EventSignatures = { 'room.video-conference': (params: { rid: string; callId: string }) => void; 'shutdown': (params: Record) => void; @@ -235,7 +248,7 @@ export type EventSignatures = { } ), ): void; - 'watch.loginServiceConfiguration'(data: { clientAction: ClientAction; data: Partial; id: string }): void; + 'watch.loginServiceConfiguration'(data: LoginServiceConfigurationEvent): void; 'watch.instanceStatus'(data: { clientAction: ClientAction; data?: undefined | Partial; From 74c850c112413e95f15eea34f57724734fe43f40 Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 5 Jan 2024 12:48:41 -0300 Subject: [PATCH 08/34] chore: convert login services files to typescript and refactor the refresh login services code --- apps/meteor/app/api/server/v1/settings.ts | 6 +- apps/meteor/app/cas/server/cas_rocketchat.js | 43 --- apps/meteor/app/cas/server/cas_server.js | 272 ------------------ apps/meteor/app/cas/server/index.ts | 2 - .../app/lib/server/methods/addOAuthService.ts | 2 +- .../lib/server/methods/refreshOAuthService.ts | 7 +- apps/meteor/app/lib/server/startup/index.ts | 1 - .../server/lib/settings.ts | 5 +- apps/meteor/ee/server/configuration/oauth.ts | 18 +- apps/meteor/server/configuration/cas.ts | 34 +++ apps/meteor/server/configuration/index.ts | 4 + apps/meteor/server/configuration/oauth.ts | 20 ++ apps/meteor/server/importPackages.ts | 1 - apps/meteor/server/lib/cas/createNewUser.ts | 63 ++++ .../server/lib/cas/findExistingCASUser.ts | 27 ++ apps/meteor/server/lib/cas/logger.ts | 3 + apps/meteor/server/lib/cas/loginHandler.ts | 118 ++++++++ apps/meteor/server/lib/cas/middleware.ts | 97 +++++++ .../meteor/server/lib/cas/updateCasService.ts | 30 ++ .../lib/oauth}/addOAuthService.ts | 2 +- .../lib/oauth/initCustomOAuthServices.ts | 56 ++++ apps/meteor/server/lib/oauth/logger.ts | 3 + .../server/lib/oauth/removeOAuthService.ts | 8 + .../lib/oauth/updateOAuthServices.ts} | 94 +----- .../meteor/server/lib/refreshLoginServices.ts | 11 + apps/meteor/server/main.ts | 3 +- packages/cas-validate/src/validate.ts | 12 +- packages/cas-validate/tsconfig.json | 1 + packages/tools/src/getObjectKeys.ts | 1 + packages/tools/src/index.ts | 1 + 30 files changed, 512 insertions(+), 433 deletions(-) delete mode 100644 apps/meteor/app/cas/server/cas_rocketchat.js delete mode 100644 apps/meteor/app/cas/server/cas_server.js delete mode 100644 apps/meteor/app/cas/server/index.ts create mode 100644 apps/meteor/server/configuration/cas.ts create mode 100644 apps/meteor/server/configuration/index.ts create mode 100644 apps/meteor/server/configuration/oauth.ts create mode 100644 apps/meteor/server/lib/cas/createNewUser.ts create mode 100644 apps/meteor/server/lib/cas/findExistingCASUser.ts create mode 100644 apps/meteor/server/lib/cas/logger.ts create mode 100644 apps/meteor/server/lib/cas/loginHandler.ts create mode 100644 apps/meteor/server/lib/cas/middleware.ts create mode 100644 apps/meteor/server/lib/cas/updateCasService.ts rename apps/meteor/{app/lib/server/functions => server/lib/oauth}/addOAuthService.ts (99%) create mode 100644 apps/meteor/server/lib/oauth/initCustomOAuthServices.ts create mode 100644 apps/meteor/server/lib/oauth/logger.ts create mode 100644 apps/meteor/server/lib/oauth/removeOAuthService.ts rename apps/meteor/{app/lib/server/startup/oAuthServicesUpdate.js => server/lib/oauth/updateOAuthServices.ts} (55%) create mode 100644 apps/meteor/server/lib/refreshLoginServices.ts create mode 100644 packages/tools/src/getObjectKeys.ts diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index cbaff50729ffe..cd2334ea89e3f 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -1,4 +1,4 @@ -import type { ISetting, ISettingColor } from '@rocket.chat/core-typings'; +import type { ISetting, ISettingColor, LoginServiceConfiguration } from '@rocket.chat/core-typings'; import { isSettingAction, isSettingColor } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; import { @@ -71,7 +71,9 @@ API.v1.addRoute( { authRequired: false }, { async get() { - const oAuthServicesEnabled = await ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetchAsync(); + const oAuthServicesEnabled = (await ServiceConfiguration.configurations + .find({}, { fields: { secret: 0 } }) + .fetchAsync()) as LoginServiceConfiguration[]; return API.v1.success({ services: oAuthServicesEnabled.map((service) => { diff --git a/apps/meteor/app/cas/server/cas_rocketchat.js b/apps/meteor/app/cas/server/cas_rocketchat.js deleted file mode 100644 index f0b62b6ccb8aa..0000000000000 --- a/apps/meteor/app/cas/server/cas_rocketchat.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Logger } from '@rocket.chat/logger'; -import { ServiceConfiguration } from 'meteor/service-configuration'; - -import { settings } from '../../settings/server'; - -export const logger = new Logger('CAS'); - -let timer; - -async function updateServices(/* record*/) { - if (typeof timer !== 'undefined') { - clearTimeout(timer); - } - - timer = setTimeout(async () => { - const data = { - // These will pe passed to 'node-cas' as options - enabled: settings.get('CAS_enabled'), - base_url: settings.get('CAS_base_url'), - login_url: settings.get('CAS_login_url'), - // Rocketchat Visuals - buttonLabelText: settings.get('CAS_button_label_text'), - buttonLabelColor: settings.get('CAS_button_label_color'), - buttonColor: settings.get('CAS_button_color'), - width: settings.get('CAS_popup_width'), - height: settings.get('CAS_popup_height'), - autoclose: settings.get('CAS_autoclose'), - }; - - // Either register or deregister the CAS login service based upon its configuration - if (data.enabled) { - logger.info('Enabling CAS login service'); - await ServiceConfiguration.configurations.upsertAsync({ service: 'cas' }, { $set: data }); - } else { - logger.info('Disabling CAS login service'); - await ServiceConfiguration.configurations.removeAsync({ service: 'cas' }); - } - }, 2000); -} - -settings.watchByRegex(/^CAS_.+/, async (key, value) => { - await updateServices(value); -}); diff --git a/apps/meteor/app/cas/server/cas_server.js b/apps/meteor/app/cas/server/cas_server.js deleted file mode 100644 index 60880c77d4f40..0000000000000 --- a/apps/meteor/app/cas/server/cas_server.js +++ /dev/null @@ -1,272 +0,0 @@ -import url from 'url'; - -import { validate } from '@rocket.chat/cas-validate'; -import { CredentialTokens, Rooms, Users } from '@rocket.chat/models'; -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; -import { RoutePolicy } from 'meteor/routepolicy'; -import { WebApp } from 'meteor/webapp'; -import _ from 'underscore'; - -import { createRoom } from '../../lib/server/functions/createRoom'; -import { _setRealName } from '../../lib/server/functions/setRealName'; -import { settings } from '../../settings/server'; -import { logger } from './cas_rocketchat'; - -RoutePolicy.declare('/_cas/', 'network'); - -const closePopup = function (res) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - const content = ''; - res.end(content, 'utf-8'); -}; - -const casTicket = function (req, token, callback) { - // get configuration - if (!settings.get('CAS_enabled')) { - logger.error('Got ticket validation request, but CAS is not enabled'); - callback(); - } - - // get ticket and validate. - const parsedUrl = url.parse(req.url, true); - const ticketId = parsedUrl.query.ticket; - const baseUrl = settings.get('CAS_base_url'); - const cas_version = parseFloat(settings.get('CAS_version')); - const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; - logger.debug(`Using CAS_base_url: ${baseUrl}`); - - validate( - { - base_url: baseUrl, - version: cas_version, - service: `${appUrl}/_cas/${token}`, - }, - ticketId, - async (err, status, username, details) => { - if (err) { - logger.error(`error when trying to validate: ${err.message}`); - } else if (status) { - logger.info(`Validated user: ${username}`); - const user_info = { username }; - - // CAS 2.0 attributes handling - if (details && details.attributes) { - _.extend(user_info, { attributes: details.attributes }); - } - await CredentialTokens.create(token, user_info); - } else { - logger.error(`Unable to validate ticket: ${ticketId}`); - } - // logger.debug("Received response: " + JSON.stringify(details, null , 4)); - - callback(); - }, - ); -}; - -const middleware = function (req, res, next) { - // Make sure to catch any exceptions because otherwise we'd crash - // the runner - try { - const barePath = req.url.substring(0, req.url.indexOf('?')); - const splitPath = barePath.split('/'); - - // Any non-cas request will continue down the default - // middlewares. - if (splitPath[1] !== '_cas') { - next(); - return; - } - - // get auth token - const credentialToken = splitPath[2]; - if (!credentialToken) { - closePopup(res); - return; - } - - // validate ticket - casTicket(req, credentialToken, () => { - closePopup(res); - }); - } catch (err) { - logger.error({ msg: 'Unexpected error', err }); - closePopup(res); - } -}; - -// Listen to incoming OAuth http requests -WebApp.connectHandlers.use((req, res, next) => { - middleware(req, res, next); -}); - -/* - * Register a server-side login handle. - * It is call after Accounts.callLoginMethod() is call from client. - * - */ -Accounts.registerLoginHandler('cas', async (options) => { - if (!options.cas) { - return undefined; - } - - // TODO: Sync wrapper due to the chain conversion to async models - const credentials = await CredentialTokens.findOneNotExpiredById(options.cas.credentialToken); - if (credentials === undefined) { - throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching login attempt found'); - } - - const result = credentials.userInfo; - const syncUserDataFieldMap = settings.get('CAS_Sync_User_Data_FieldMap').trim(); - const cas_version = parseFloat(settings.get('CAS_version')); - const sync_enabled = settings.get('CAS_Sync_User_Data_Enabled'); - const trustUsername = settings.get('CAS_trust_username'); - const verified = settings.get('Accounts_Verify_Email_For_External_Accounts'); - const userCreationEnabled = settings.get('CAS_Creation_User_Enabled'); - - // We have these - const ext_attrs = { - username: result.username, - }; - - // We need these - const int_attrs = { - email: undefined, - name: undefined, - username: undefined, - rooms: undefined, - }; - - // Import response attributes - if (cas_version >= 2.0) { - // Clean & import external attributes - _.each(result.attributes, (value, ext_name) => { - if (value) { - ext_attrs[ext_name] = value[0]; - } - }); - } - - // Source internal attributes - if (syncUserDataFieldMap) { - // Our mapping table: key(int_attr) -> value(ext_attr) - // Spoken: Source this internal attribute from these external attributes - const attr_map = JSON.parse(syncUserDataFieldMap); - - _.each(attr_map, (source, int_name) => { - // Source is our String to interpolate - if (source && typeof source.valueOf() === 'string') { - let replacedValue = source; - _.each(ext_attrs, (value, ext_name) => { - replacedValue = replacedValue.replace(`%${ext_name}%`, ext_attrs[ext_name]); - }); - - if (source !== replacedValue) { - int_attrs[int_name] = replacedValue; - logger.debug(`Sourced internal attribute: ${int_name} = ${replacedValue}`); - } else { - logger.debug(`Sourced internal attribute: ${int_name} skipped.`); - } - } - }); - } - - // Search existing user by its external service id - logger.debug(`Looking up user by id: ${result.username}`); - // First, look for a user that has logged in from CAS with this username before - let user = await Users.findOne({ 'services.cas.external_id': result.username }); - if (!user) { - // If that user was not found, check if there's any Rocket.Chat user with that username - // With this, CAS login will continue to work if the user is renamed on both sides and also if the user is renamed only on Rocket.Chat. - // It'll also allow non-CAS users to switch to CAS based login - if (trustUsername) { - const username = new RegExp(`^${result.username}$`, 'i'); - user = await Users.findOne({ username }); - if (user) { - // Update the user's external_id to reflect this new username. - await Users.updateOne({ _id: user._id }, { $set: { 'services.cas.external_id': result.username } }); - } - } - } - - if (user) { - logger.debug(`Using existing user for '${result.username}' with id: ${user._id}`); - if (sync_enabled) { - logger.debug('Syncing user attributes'); - // Update name - if (int_attrs.name) { - await _setRealName(user._id, int_attrs.name); - } - - // Update email - if (int_attrs.email) { - await Users.updateOne({ _id: user._id }, { $set: { emails: [{ address: int_attrs.email, verified }] } }); - } - } - } else if (userCreationEnabled) { - // Define new user - const newUser = { - username: result.username, - active: true, - globalRoles: ['user'], - emails: [], - services: { - cas: { - external_id: result.username, - version: cas_version, - attrs: int_attrs, - }, - }, - }; - - // Add username - if (int_attrs.username) { - _.extend(newUser, { - username: int_attrs.username, - }); - } - - // Add User.name - if (int_attrs.name) { - _.extend(newUser, { - name: int_attrs.name, - }); - } - - // Add email - if (int_attrs.email) { - _.extend(newUser, { - emails: [{ address: int_attrs.email, verified }], - }); - } - - // Create the user - logger.debug(`User "${result.username}" does not exist yet, creating it`); - const userId = Accounts.insertUserDoc({}, newUser); - - // Fetch and use it - user = await Users.findOneById(userId); - logger.debug(`Created new user for '${result.username}' with id: ${user._id}`); - // logger.debug(JSON.stringify(user, undefined, 4)); - - logger.debug(`Joining user to attribute channels: ${int_attrs.rooms}`); - if (int_attrs.rooms) { - const roomNames = int_attrs.rooms.split(','); - for await (const roomName of roomNames) { - if (roomName) { - let room = await Rooms.findOneByNameAndType(roomName, 'c'); - if (!room) { - room = await createRoom('c', roomName, user); - } - } - } - } - } else { - // Should fail as no user exist and can't be created - logger.debug(`User "${result.username}" does not exist yet, will fail as no user creation is enabled`); - throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching user account found'); - } - - return { userId: user._id }; -}); diff --git a/apps/meteor/app/cas/server/index.ts b/apps/meteor/app/cas/server/index.ts deleted file mode 100644 index 0ad22d77b198d..0000000000000 --- a/apps/meteor/app/cas/server/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import './cas_rocketchat'; -import './cas_server'; diff --git a/apps/meteor/app/lib/server/methods/addOAuthService.ts b/apps/meteor/app/lib/server/methods/addOAuthService.ts index abf1b7035af15..05b0e5a7e4e60 100644 --- a/apps/meteor/app/lib/server/methods/addOAuthService.ts +++ b/apps/meteor/app/lib/server/methods/addOAuthService.ts @@ -2,8 +2,8 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { addOAuthService } from '../../../../server/lib/oauth/addOAuthService'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { addOAuthService } from '../functions/addOAuthService'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/app/lib/server/methods/refreshOAuthService.ts b/apps/meteor/app/lib/server/methods/refreshOAuthService.ts index 9faa67f239a1e..e5b1c377a33e7 100644 --- a/apps/meteor/app/lib/server/methods/refreshOAuthService.ts +++ b/apps/meteor/app/lib/server/methods/refreshOAuthService.ts @@ -1,8 +1,7 @@ -import { Settings } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; +import { refreshLoginServices } from '../../../../server/lib/refreshLoginServices'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; declare module '@rocket.chat/ui-contexts' { @@ -29,8 +28,6 @@ Meteor.methods({ }); } - await ServiceConfiguration.configurations.removeAsync({}); - - await Settings.update({ _id: /^(Accounts_OAuth_|SAML_|CAS_).+/ }, { $set: { _updatedAt: new Date() } }, { multi: true }); + await refreshLoginServices(); }, }); diff --git a/apps/meteor/app/lib/server/startup/index.ts b/apps/meteor/app/lib/server/startup/index.ts index d4e5183ad7f5a..deadb8a44c06a 100644 --- a/apps/meteor/app/lib/server/startup/index.ts +++ b/apps/meteor/app/lib/server/startup/index.ts @@ -1,4 +1,3 @@ -import './oAuthServicesUpdate'; import './rateLimiter'; import './robots'; import './settingsOnLoadCdnPrefix'; diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts index 31bb8e37cfacb..dfc7151594d01 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts @@ -1,3 +1,4 @@ +import type { SAMLConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; @@ -17,8 +18,8 @@ import { defaultMetadataCertificateTemplate, } from './constants'; -const getSamlConfigs = function (service: string): Record { - const configs = { +const getSamlConfigs = function (service: string): SAMLConfiguration { + const configs: SAMLConfiguration = { buttonLabelText: settings.get(`${service}_button_label_text`), buttonLabelColor: settings.get(`${service}_button_label_color`), buttonColor: settings.get(`${service}_button_color`), diff --git a/apps/meteor/ee/server/configuration/oauth.ts b/apps/meteor/ee/server/configuration/oauth.ts index aa66a46caf699..4099b159918c9 100644 --- a/apps/meteor/ee/server/configuration/oauth.ts +++ b/apps/meteor/ee/server/configuration/oauth.ts @@ -21,8 +21,8 @@ interface IOAuthUserIdentity { } interface IOAuthSettings { - mapChannels: string; - mergeRoles: string; + mapChannels: boolean; + mergeRoles: boolean; rolesToSync: string; rolesClaim: string; groupsClaim: string; @@ -34,13 +34,13 @@ const logger = new Logger('EECustomOAuth'); function getOAuthSettings(serviceName: string): IOAuthSettings { return { - mapChannels: settings.get(`Accounts_OAuth_Custom-${serviceName}-map_channels`) as string, - mergeRoles: settings.get(`Accounts_OAuth_Custom-${serviceName}-merge_roles`) as string, - rolesToSync: settings.get(`Accounts_OAuth_Custom-${serviceName}-roles_to_sync`) as string, - rolesClaim: settings.get(`Accounts_OAuth_Custom-${serviceName}-roles_claim`) as string, - groupsClaim: settings.get(`Accounts_OAuth_Custom-${serviceName}-groups_claim`) as string, - channelsAdmin: settings.get(`Accounts_OAuth_Custom-${serviceName}-channels_admin`) as string, - channelsMap: settings.get(`Accounts_OAuth_Custom-${serviceName}-groups_channel_map`) as string, + mapChannels: settings.get(`Accounts_OAuth_Custom-${serviceName}-map_channels`), + mergeRoles: settings.get(`Accounts_OAuth_Custom-${serviceName}-merge_roles`), + rolesToSync: settings.get(`Accounts_OAuth_Custom-${serviceName}-roles_to_sync`), + rolesClaim: settings.get(`Accounts_OAuth_Custom-${serviceName}-roles_claim`), + groupsClaim: settings.get(`Accounts_OAuth_Custom-${serviceName}-groups_claim`), + channelsAdmin: settings.get(`Accounts_OAuth_Custom-${serviceName}-channels_admin`), + channelsMap: settings.get(`Accounts_OAuth_Custom-${serviceName}-groups_channel_map`), }; } diff --git a/apps/meteor/server/configuration/cas.ts b/apps/meteor/server/configuration/cas.ts new file mode 100644 index 0000000000000..3ac4ed43c3000 --- /dev/null +++ b/apps/meteor/server/configuration/cas.ts @@ -0,0 +1,34 @@ +import type { Awaited } from '@rocket.chat/core-typings'; +import debounce from 'lodash.debounce'; +import { RoutePolicy } from 'meteor/routepolicy'; +import { WebApp } from 'meteor/webapp'; + +import { settings } from '../../app/settings/server/cached'; +import { loginHandlerCAS } from '../lib/cas/loginHandler'; +import { middlewareCAS } from '../lib/cas/middleware'; +import { updateCasServices } from '../lib/cas/updateCasService'; + +const _updateCasServices = debounce(updateCasServices, 2000); + +settings.watchByRegex(/^CAS_.+/, async () => { + await _updateCasServices(); +}); + +RoutePolicy.declare('/_cas/', 'network'); + +// Listen to incoming OAuth http requests +WebApp.connectHandlers.use((req, res, next) => { + middlewareCAS(req, res, next); +}); + +// #TODO: Fix registerLoginHandler's type definitions (it accepts promises) +/* + * Register a server-side login handler. + * It is called after Accounts.callLoginMethod() is called from client. + * + */ +Accounts.registerLoginHandler('cas', (options) => { + const promise = loginHandlerCAS(options); + + return promise as unknown as Awaited; +}); diff --git a/apps/meteor/server/configuration/index.ts b/apps/meteor/server/configuration/index.ts new file mode 100644 index 0000000000000..cbed9faf3ee96 --- /dev/null +++ b/apps/meteor/server/configuration/index.ts @@ -0,0 +1,4 @@ +import './accounts_meld'; +import './cas'; +import './ldap'; +import './oauth'; diff --git a/apps/meteor/server/configuration/oauth.ts b/apps/meteor/server/configuration/oauth.ts new file mode 100644 index 0000000000000..1c0deea56b704 --- /dev/null +++ b/apps/meteor/server/configuration/oauth.ts @@ -0,0 +1,20 @@ +import debounce from 'lodash.debounce'; + +import { settings } from '../../app/settings/server/cached'; +import { initCustomOAuthServices } from '../lib/oauth/initCustomOAuthServices'; +import { removeOAuthService } from '../lib/oauth/removeOAuthService'; +import { updateOAuthServices } from '../lib/oauth/updateOAuthServices'; + +const _updateOAuthServices = debounce(updateOAuthServices, 2000); + +settings.watchByRegex(/^Accounts_OAuth_.+/, () => { + return _updateOAuthServices(); +}); + +settings.watchByRegex(/^Accounts_OAuth_Custom-[a-z0-9_]+/, (key, value) => { + if (!value) { + return removeOAuthService(key); + } +}); + +await initCustomOAuthServices(); diff --git a/apps/meteor/server/importPackages.ts b/apps/meteor/server/importPackages.ts index d92e02f350383..2b4e3106ed45f 100644 --- a/apps/meteor/server/importPackages.ts +++ b/apps/meteor/server/importPackages.ts @@ -6,7 +6,6 @@ import '../app/assets/server'; import '../app/authorization/server'; import '../app/autotranslate/server'; import '../app/bot-helpers/server'; -import '../app/cas/server'; import '../app/channel-settings/server'; import '../app/cloud/server'; import '../app/crowd/server'; diff --git a/apps/meteor/server/lib/cas/createNewUser.ts b/apps/meteor/server/lib/cas/createNewUser.ts new file mode 100644 index 0000000000000..4e6953d78a6b5 --- /dev/null +++ b/apps/meteor/server/lib/cas/createNewUser.ts @@ -0,0 +1,63 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Rooms, Users } from '@rocket.chat/models'; +import { pick } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; + +import { createRoom } from '../../../app/lib/server/functions/createRoom'; +import { logger } from './logger'; + +type CASUserOptions = { + attributes: Record; + casVersion: number; + flagEmailAsVerified: boolean; +}; + +export const createNewUser = async (username: string, { attributes, casVersion, flagEmailAsVerified }: CASUserOptions): Promise => { + // Define new user + const newUser = { + username: attributes.username ? attributes.username : username, + active: true, + globalRoles: ['user'], + emails: [attributes.email] + .filter((e) => e) + .map((address) => ({ + address, + verified: flagEmailAsVerified, + })), + services: { + cas: { + external_id: username, + version: casVersion, + attrs: attributes, + }, + }, + ...pick(attributes, 'name'), + }; + + // Create the user + logger.debug(`User "${username}" does not exist yet, creating it`); + const userId = Accounts.insertUserDoc({}, newUser); + + // Fetch and use it + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('Unexpected error: Unable to find user after its creation.'); + } + + logger.debug(`Created new user for '${username}' with id: ${user._id}`); + + logger.debug(`Joining user to attribute channels: ${attributes.rooms}`); + if (attributes.rooms) { + const roomNames = attributes.rooms.split(','); + for await (const roomName of roomNames) { + if (roomName) { + let room = await Rooms.findOneByNameAndType(roomName, 'c'); + if (!room) { + room = await createRoom('c', roomName, user); + } + } + } + } + + return user; +}; diff --git a/apps/meteor/server/lib/cas/findExistingCASUser.ts b/apps/meteor/server/lib/cas/findExistingCASUser.ts new file mode 100644 index 0000000000000..60b52965ee688 --- /dev/null +++ b/apps/meteor/server/lib/cas/findExistingCASUser.ts @@ -0,0 +1,27 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { settings } from '../../../app/settings/server'; + +export const findExistingCASUser = async (username: string): Promise => { + const casUser = await Users.findOne({ 'services.cas.external_id': username }); + if (casUser) { + return casUser; + } + + if (!settings.get('CAS_trust_username')) { + return; + } + + // If that user was not found, check if there's any Rocket.Chat user with that username + // With this, CAS login will continue to work if the user is renamed on both sides and also if the user is renamed only on Rocket.Chat. + // It'll also allow non-CAS users to switch to CAS based login + // #TODO: Remove regex based search + const regex = new RegExp(`^${username}$`, 'i'); + const user = await Users.findOne({ regex }); + if (user) { + // Update the user's external_id to reflect this new username. + await Users.updateOne({ _id: user._id }, { $set: { 'services.cas.external_id': username } }); + return user; + } +}; diff --git a/apps/meteor/server/lib/cas/logger.ts b/apps/meteor/server/lib/cas/logger.ts new file mode 100644 index 0000000000000..c2b4abe7a8023 --- /dev/null +++ b/apps/meteor/server/lib/cas/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('CAS'); diff --git a/apps/meteor/server/lib/cas/loginHandler.ts b/apps/meteor/server/lib/cas/loginHandler.ts new file mode 100644 index 0000000000000..34d6ac7559bdd --- /dev/null +++ b/apps/meteor/server/lib/cas/loginHandler.ts @@ -0,0 +1,118 @@ +import { CredentialTokens, Users } from '@rocket.chat/models'; +import { getObjectKeys } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; + +import { _setRealName } from '../../../app/lib/server/functions/setRealName'; +import { settings } from '../../../app/settings/server'; +import { createNewUser } from './createNewUser'; +import { findExistingCASUser } from './findExistingCASUser'; +import { logger } from './logger'; + +export const loginHandlerCAS = async (options: any): Promise => { + if (!options.cas) { + return undefined; + } + + // TODO: Sync wrapper due to the chain conversion to async models + const credentials = await CredentialTokens.findOneNotExpiredById(options.cas.credentialToken); + if (credentials === undefined || credentials === null) { + throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching login attempt found'); + } + + const result = credentials.userInfo; + const syncUserDataFieldMap = settings.get('CAS_Sync_User_Data_FieldMap').trim(); + const casVersion = parseFloat(settings.get('CAS_version') ?? '1.0'); + const syncEnabled = settings.get('CAS_Sync_User_Data_Enabled'); + const flagEmailAsVerified = settings.get('Accounts_Verify_Email_For_External_Accounts'); + const userCreationEnabled = settings.get('CAS_Creation_User_Enabled'); + + const { username, attributes: credentialsAttributes } = result as { username: string; attributes: Record }; + + // We have these + const externalAttributes: Record = { + username, + }; + + // We need these + const internalAttributes: Record = { + email: undefined, + name: undefined, + username: undefined, + rooms: undefined, + }; + + // Import response attributes + if (casVersion >= 2.0) { + // Clean & import external attributes + for await (const [externalName, value] of Object.entries(credentialsAttributes)) { + if (value) { + externalAttributes[externalName] = value[0]; + } + } + } + + // Source internal attributes + if (syncUserDataFieldMap) { + // Our mapping table: key(int_attr) -> value(ext_attr) + // Spoken: Source this internal attribute from these external attributes + const attributeMap = JSON.parse(syncUserDataFieldMap) as Record; + + for await (const [internalName, source] of Object.entries(attributeMap)) { + if (!source || typeof source.valueOf() !== 'string') { + continue; + } + + let replacedValue = source as string; + for await (const externalName of getObjectKeys(externalAttributes)) { + replacedValue = replacedValue.replace(`%${externalName}%`, externalAttributes[externalName]); + } + + if (source !== replacedValue) { + internalAttributes[internalName] = replacedValue; + logger.debug(`Sourced internal attribute: ${internalName} = ${replacedValue}`); + } else { + logger.debug(`Sourced internal attribute: ${internalName} skipped.`); + } + } + } + + // Search existing user by its external service id + logger.debug(`Looking up user by id: ${username}`); + // First, look for a user that has logged in from CAS with this username before + const user = await findExistingCASUser(username); + + if (user) { + logger.debug(`Using existing user for '${username}' with id: ${user._id}`); + if (syncEnabled) { + logger.debug('Syncing user attributes'); + // Update name + if (internalAttributes.name) { + await _setRealName(user._id, internalAttributes.name); + } + + // Update email + if (internalAttributes.email) { + await Users.updateOne( + { _id: user._id }, + { $set: { emails: [{ address: internalAttributes.email, verified: flagEmailAsVerified }] } }, + ); + } + } + + return { userId: user._id }; + } + + if (!userCreationEnabled) { + // Should fail as no user exist and can't be created + logger.debug(`User "${username}" does not exist yet, will fail as no user creation is enabled`); + throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching user account found'); + } + + const newUser = await createNewUser(username, { + attributes: internalAttributes, + casVersion, + flagEmailAsVerified, + }); + + return { userId: newUser._id }; +}; diff --git a/apps/meteor/server/lib/cas/middleware.ts b/apps/meteor/server/lib/cas/middleware.ts new file mode 100644 index 0000000000000..074177838f9a9 --- /dev/null +++ b/apps/meteor/server/lib/cas/middleware.ts @@ -0,0 +1,97 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import url from 'url'; + +import { validate } from '@rocket.chat/cas-validate'; +import type { ICredentialToken } from '@rocket.chat/core-typings'; +import { CredentialTokens } from '@rocket.chat/models'; +import _ from 'underscore'; + +import { settings } from '../../../app/settings/server'; +import { logger } from './logger'; + +const closePopup = function (res: ServerResponse): void { + res.writeHead(200, { 'Content-Type': 'text/html' }); + const content = ''; + res.end(content, 'utf-8'); +}; + +type IncomingMessageWithUrl = IncomingMessage & Required>; + +const casTicket = function (req: IncomingMessageWithUrl, token: string, callback: () => void): void { + // get configuration + if (!settings.get('CAS_enabled')) { + logger.error('Got ticket validation request, but CAS is not enabled'); + callback(); + } + + // get ticket and validate. + const parsedUrl = url.parse(req.url, true); + const ticketId = parsedUrl.query.ticket as string; + const baseUrl = settings.get('CAS_base_url'); + const version = parseFloat(settings.get('CAS_version') ?? '1.0') as 1.0 | 2.0; + const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; + logger.debug(`Using CAS_base_url: ${baseUrl}`); + + validate( + { + base_url: baseUrl, + version, + service: `${appUrl}/_cas/${token}`, + }, + ticketId, + async (err, status, username, details) => { + if (err) { + logger.error(`error when trying to validate: ${err.message}`); + } else if (status) { + logger.info(`Validated user: ${username}`); + const userInfo: Partial = { username: username as string }; + + // CAS 2.0 attributes handling + if (details?.attributes) { + _.extend(userInfo, { attributes: details.attributes }); + } + await CredentialTokens.create(token, userInfo); + } else { + logger.error(`Unable to validate ticket: ${ticketId}`); + } + // logger.debug("Received response: " + JSON.stringify(details, null , 4)); + + callback(); + }, + ); +}; + +export const middlewareCAS = function (req: IncomingMessage, res: ServerResponse, next: (err?: any) => void) { + // Make sure to catch any exceptions because otherwise we'd crash + // the runner + try { + if (!req.url) { + throw new Error('Invalid request url'); + } + + const barePath = req.url.substring(0, req.url.indexOf('?')); + const splitPath = barePath.split('/'); + + // Any non-cas request will continue down the default + // middlewares. + if (splitPath[1] !== '_cas') { + next(); + return; + } + + // get auth token + const credentialToken = splitPath[2]; + if (!credentialToken) { + closePopup(res); + return; + } + + // validate ticket + casTicket(req as IncomingMessageWithUrl, credentialToken, () => { + closePopup(res); + }); + } catch (err) { + logger.error({ msg: 'Unexpected error', err }); + closePopup(res); + } +}; diff --git a/apps/meteor/server/lib/cas/updateCasService.ts b/apps/meteor/server/lib/cas/updateCasService.ts new file mode 100644 index 0000000000000..5583eda22f83a --- /dev/null +++ b/apps/meteor/server/lib/cas/updateCasService.ts @@ -0,0 +1,30 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { settings } from '../../../app/settings/server/cached'; +import { logger } from './logger'; + +export async function updateCasServices(): Promise { + const data: Partial = { + // These will pe passed to 'node-cas' as options + enabled: settings.get('CAS_enabled'), + base_url: settings.get('CAS_base_url'), + login_url: settings.get('CAS_login_url'), + // Rocketchat Visuals + buttonLabelText: settings.get('CAS_button_label_text'), + buttonLabelColor: settings.get('CAS_button_label_color'), + buttonColor: settings.get('CAS_button_color'), + width: settings.get('CAS_popup_width'), + height: settings.get('CAS_popup_height'), + autoclose: settings.get('CAS_autoclose'), + }; + + // Either register or deregister the CAS login service based upon its configuration + if (data.enabled) { + logger.info('Enabling CAS login service'); + await ServiceConfiguration.configurations.upsertAsync({ service: 'cas' }, { $set: data }); + } else { + logger.info('Disabling CAS login service'); + await ServiceConfiguration.configurations.removeAsync({ service: 'cas' }); + } +} diff --git a/apps/meteor/app/lib/server/functions/addOAuthService.ts b/apps/meteor/server/lib/oauth/addOAuthService.ts similarity index 99% rename from apps/meteor/app/lib/server/functions/addOAuthService.ts rename to apps/meteor/server/lib/oauth/addOAuthService.ts index eb28c5a7e3eb2..2a49a23a1f4e1 100644 --- a/apps/meteor/app/lib/server/functions/addOAuthService.ts +++ b/apps/meteor/server/lib/oauth/addOAuthService.ts @@ -2,7 +2,7 @@ /* eslint comma-spacing: 0 */ import { capitalize } from '@rocket.chat/string-helpers'; -import { settingsRegistry } from '../../../settings/server'; +import { settingsRegistry } from '../../../app/settings/server'; export async function addOAuthService(name: string, values: { [k: string]: string | boolean | undefined } = {}): Promise { name = name.toLowerCase().replace(/[^a-z0-9_]/g, ''); diff --git a/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts new file mode 100644 index 0000000000000..3c909f6bc1f12 --- /dev/null +++ b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts @@ -0,0 +1,56 @@ +import { addOAuthService } from './addOAuthService'; + +export async function initCustomOAuthServices(): Promise { + // Add settings for custom OAuth providers to the settings so they get + // automatically added when they are defined in ENV variables + for await (const key of Object.keys(process.env)) { + if (/Accounts_OAuth_Custom_[a-zA-Z0-9_-]+$/.test(key)) { + // Most all shells actually prohibit the usage of - in environment variables + // So this will allow replacing - with _ and translate it back to the setting name + let name = key.replace('Accounts_OAuth_Custom_', ''); + + if (name.indexOf('_') > -1) { + name = name.replace(name.substr(name.indexOf('_')), ''); + } + + const serviceKey = `Accounts_OAuth_Custom_${name}`; + + if (key === serviceKey) { + const values = { + enabled: process.env[`${serviceKey}`] === 'true', + clientId: process.env[`${serviceKey}_id`], + clientSecret: process.env[`${serviceKey}_secret`], + serverURL: process.env[`${serviceKey}_url`], + tokenPath: process.env[`${serviceKey}_token_path`], + identityPath: process.env[`${serviceKey}_identity_path`], + authorizePath: process.env[`${serviceKey}_authorize_path`], + scope: process.env[`${serviceKey}_scope`], + accessTokenParam: process.env[`${serviceKey}_access_token_param`], + buttonLabelText: process.env[`${serviceKey}_button_label_text`], + buttonLabelColor: process.env[`${serviceKey}_button_label_color`], + loginStyle: process.env[`${serviceKey}_login_style`], + buttonColor: process.env[`${serviceKey}_button_color`], + tokenSentVia: process.env[`${serviceKey}_token_sent_via`], + identityTokenSentVia: process.env[`${serviceKey}_identity_token_sent_via`], + keyField: process.env[`${serviceKey}_key_field`], + usernameField: process.env[`${serviceKey}_username_field`], + nameField: process.env[`${serviceKey}_name_field`], + emailField: process.env[`${serviceKey}_email_field`], + rolesClaim: process.env[`${serviceKey}_roles_claim`], + groupsClaim: process.env[`${serviceKey}_groups_claim`], + channelsMap: process.env[`${serviceKey}_groups_channel_map`], + channelsAdmin: process.env[`${serviceKey}_channels_admin`], + mergeUsers: process.env[`${serviceKey}_merge_users`] === 'true', + mergeUsersDistinctServices: process.env[`${serviceKey}_merge_users_distinct_services`] === 'true', + mapChannels: process.env[`${serviceKey}_map_channels`], + mergeRoles: process.env[`${serviceKey}_merge_roles`] === 'true', + rolesToSync: process.env[`${serviceKey}_roles_to_sync`], + showButton: process.env[`${serviceKey}_show_button`] === 'true', + avatarField: process.env[`${serviceKey}_avatar_field`], + }; + + await addOAuthService(name, values); + } + } + } +} diff --git a/apps/meteor/server/lib/oauth/logger.ts b/apps/meteor/server/lib/oauth/logger.ts new file mode 100644 index 0000000000000..e1f0fc2a8aeb9 --- /dev/null +++ b/apps/meteor/server/lib/oauth/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('rocketchat:lib'); diff --git a/apps/meteor/server/lib/oauth/removeOAuthService.ts b/apps/meteor/server/lib/oauth/removeOAuthService.ts new file mode 100644 index 0000000000000..01660021fe6fe --- /dev/null +++ b/apps/meteor/server/lib/oauth/removeOAuthService.ts @@ -0,0 +1,8 @@ +import { ServiceConfiguration } from 'meteor/service-configuration'; + +export async function removeOAuthService(mainSettingId: string): Promise { + const serviceName = mainSettingId.replace('Accounts_OAuth_Custom-', ''); + await ServiceConfiguration.configurations.removeAsync({ + service: serviceName.toLowerCase(), + }); +} diff --git a/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js b/apps/meteor/server/lib/oauth/updateOAuthServices.ts similarity index 55% rename from apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js rename to apps/meteor/server/lib/oauth/updateOAuthServices.ts index b01ef2f9fb0c6..9f4a81d75d3ce 100644 --- a/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -1,14 +1,12 @@ -import { Logger } from '@rocket.chat/logger'; +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { getObjectKeys } from '@rocket.chat/tools'; import { ServiceConfiguration } from 'meteor/service-configuration'; -import _ from 'underscore'; -import { CustomOAuth } from '../../../custom-oauth/server/custom_oauth_server'; -import { settings } from '../../../settings/server'; -import { addOAuthService } from '../functions/addOAuthService'; +import { CustomOAuth } from '../../../app/custom-oauth/server/custom_oauth_server'; +import { settings } from '../../../app/settings/server/cached'; +import { logger } from './logger'; -const logger = new Logger('rocketchat:lib'); - -async function _OAuthServicesUpdate() { +export async function updateOAuthServices(): Promise { const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); for await (const [key, value] of filteredServices) { @@ -22,7 +20,7 @@ async function _OAuthServicesUpdate() { } if (value === true) { - const data = { + const data: Partial> = { clientId: settings.get(`${key}_id`), secret: settings.get(`${key}_secret`), }; @@ -108,7 +106,7 @@ async function _OAuthServicesUpdate() { } // If there's no data other than the service name, then put the service name in the data object so the operation won't fail - const keys = Object.keys(data).filter((key) => data[key] !== undefined); + const keys = getObjectKeys(data).filter((key) => data[key] !== undefined); if (!keys.length) { data.service = serviceName.toLowerCase(); } @@ -128,79 +126,3 @@ async function _OAuthServicesUpdate() { } } } - -const OAuthServicesUpdate = _.debounce(_OAuthServicesUpdate, 2000); - -async function OAuthServicesRemove(_id) { - const serviceName = _id.replace('Accounts_OAuth_Custom-', ''); - return ServiceConfiguration.configurations.removeAsync({ - service: serviceName.toLowerCase(), - }); -} - -settings.watchByRegex(/^Accounts_OAuth_.+/, () => { - return OAuthServicesUpdate(); // eslint-disable-line new-cap -}); - -settings.watchByRegex(/^Accounts_OAuth_Custom-[a-z0-9_]+/, (key, value) => { - if (!value) { - return OAuthServicesRemove(key); // eslint-disable-line new-cap - } -}); - -async function customOAuthServicesInit() { - // Add settings for custom OAuth providers to the settings so they get - // automatically added when they are defined in ENV variables - for await (const key of Object.keys(process.env)) { - if (/Accounts_OAuth_Custom_[a-zA-Z0-9_-]+$/.test(key)) { - // Most all shells actually prohibit the usage of - in environment variables - // So this will allow replacing - with _ and translate it back to the setting name - let name = key.replace('Accounts_OAuth_Custom_', ''); - - if (name.indexOf('_') > -1) { - name = name.replace(name.substr(name.indexOf('_')), ''); - } - - const serviceKey = `Accounts_OAuth_Custom_${name}`; - - if (key === serviceKey) { - const values = { - enabled: process.env[`${serviceKey}`] === 'true', - clientId: process.env[`${serviceKey}_id`], - clientSecret: process.env[`${serviceKey}_secret`], - serverURL: process.env[`${serviceKey}_url`], - tokenPath: process.env[`${serviceKey}_token_path`], - identityPath: process.env[`${serviceKey}_identity_path`], - authorizePath: process.env[`${serviceKey}_authorize_path`], - scope: process.env[`${serviceKey}_scope`], - accessTokenParam: process.env[`${serviceKey}_access_token_param`], - buttonLabelText: process.env[`${serviceKey}_button_label_text`], - buttonLabelColor: process.env[`${serviceKey}_button_label_color`], - loginStyle: process.env[`${serviceKey}_login_style`], - buttonColor: process.env[`${serviceKey}_button_color`], - tokenSentVia: process.env[`${serviceKey}_token_sent_via`], - identityTokenSentVia: process.env[`${serviceKey}_identity_token_sent_via`], - keyField: process.env[`${serviceKey}_key_field`], - usernameField: process.env[`${serviceKey}_username_field`], - nameField: process.env[`${serviceKey}_name_field`], - emailField: process.env[`${serviceKey}_email_field`], - rolesClaim: process.env[`${serviceKey}_roles_claim`], - groupsClaim: process.env[`${serviceKey}_groups_claim`], - channelsMap: process.env[`${serviceKey}_groups_channel_map`], - channelsAdmin: process.env[`${serviceKey}_channels_admin`], - mergeUsers: process.env[`${serviceKey}_merge_users`] === 'true', - mergeUsersDistinctServices: process.env[`${serviceKey}_merge_users_distinct_services`] === 'true', - mapChannels: process.env[`${serviceKey}_map_channels`], - mergeRoles: process.env[`${serviceKey}_merge_roles`] === 'true', - rolesToSync: process.env[`${serviceKey}_roles_to_sync`], - showButton: process.env[`${serviceKey}_show_button`] === 'true', - avatarField: process.env[`${serviceKey}_avatar_field`], - }; - - await addOAuthService(name, values); - } - } - } -} - -await customOAuthServicesInit(); diff --git a/apps/meteor/server/lib/refreshLoginServices.ts b/apps/meteor/server/lib/refreshLoginServices.ts new file mode 100644 index 0000000000000..41f3b647f05e8 --- /dev/null +++ b/apps/meteor/server/lib/refreshLoginServices.ts @@ -0,0 +1,11 @@ +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { loadSamlServiceProviders } from '../../app/meteor-accounts-saml/server/lib/settings'; +import { updateCasServices } from './cas/updateCasService'; +import { updateOAuthServices } from './oauth/updateOAuthServices'; + +export async function refreshLoginServices(): Promise { + await ServiceConfiguration.configurations.removeAsync({}); + + await Promise.allSettled([updateOAuthServices(), loadSamlServiceProviders(), updateCasServices()]); +} diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts index b9418fe438302..86e3c21b74ee2 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -14,8 +14,7 @@ import '../ee/server/startup'; import './startup'; import '../ee/server'; import './lib/pushConfig'; -import './configuration/accounts_meld'; -import './configuration/ldap'; +import './configuration'; import './methods/OEmbedCacheCleanup'; import './methods/addAllUserToRoom'; import './methods/addRoomLeader'; diff --git a/packages/cas-validate/src/validate.ts b/packages/cas-validate/src/validate.ts index bfb2e73af0b20..aa2c8b8be111a 100644 --- a/packages/cas-validate/src/validate.ts +++ b/packages/cas-validate/src/validate.ts @@ -13,15 +13,15 @@ export type CasOptions = { }; export type CasCallbackExtendedData = { - username?: unknown; - attributes?: unknown; + username: string; + attributes: Record; // eslint-disable-next-line @typescript-eslint/naming-convention - PGTIOU?: unknown; - ticket?: unknown; - proxies?: unknown; + PGTIOU?: string; + ticket: string; + proxies: string[]; }; -export type CasCallback = (err: any, status?: unknown, username?: unknown, extended?: CasCallbackExtendedData) => void; +export type CasCallback = (err: any, status?: unknown, username?: string, extended?: CasCallbackExtendedData) => void; function parseJasigAttributes(elemAttribute: Cheerio, cheerio: CheerioAPI): Record { // "Jasig Style" Attributes: diff --git a/packages/cas-validate/tsconfig.json b/packages/cas-validate/tsconfig.json index 26aeeb5e5cff5..49c73da90c821 100644 --- a/packages/cas-validate/tsconfig.json +++ b/packages/cas-validate/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.base.server.json", "compilerOptions": { "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, "rootDir": "./src", "outDir": "./dist" }, diff --git a/packages/tools/src/getObjectKeys.ts b/packages/tools/src/getObjectKeys.ts new file mode 100644 index 0000000000000..00b0f4d1e10a0 --- /dev/null +++ b/packages/tools/src/getObjectKeys.ts @@ -0,0 +1 @@ +export const getObjectKeys = (object: T) => Object.keys(object) as (keyof T)[]; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 6ec3e38d358a0..b1b53ab71a90a 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -1,3 +1,4 @@ +export * from './getObjectKeys'; export * from './normalizeLanguage'; export * from './pick'; export * from './stream'; From 19a482122579286465ea4ac8db6cbd17d96aa216 Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 5 Jan 2024 16:11:16 -0300 Subject: [PATCH 09/34] login service configuration type --- apps/meteor/app/api/server/v1/settings.ts | 9 +- apps/meteor/client/startup/customOAuth.ts | 14 ++- .../src/ILoginServiceConfiguration.ts | 107 ++++++++++++++++++ packages/rest-typings/src/v1/settings.ts | 44 +------ 4 files changed, 119 insertions(+), 55 deletions(-) diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index cd2334ea89e3f..ad104e38f2642 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -1,12 +1,7 @@ import type { ISetting, ISettingColor, LoginServiceConfiguration } from '@rocket.chat/core-typings'; import { isSettingAction, isSettingColor } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; -import { - isOauthCustomConfiguration, - isSettingsUpdatePropDefault, - isSettingsUpdatePropsActions, - isSettingsUpdatePropsColor, -} from '@rocket.chat/rest-typings'; +import { isSettingsUpdatePropDefault, isSettingsUpdatePropsActions, isSettingsUpdatePropsColor } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; import type { FindOptions } from 'mongodb'; @@ -77,7 +72,7 @@ API.v1.addRoute( return API.v1.success({ services: oAuthServicesEnabled.map((service) => { - if (!isOauthCustomConfiguration(service)) { + if (!service) { return service; } diff --git a/apps/meteor/client/startup/customOAuth.ts b/apps/meteor/client/startup/customOAuth.ts index 5b0e3dfb42615..965065fb9e3fb 100644 --- a/apps/meteor/client/startup/customOAuth.ts +++ b/apps/meteor/client/startup/customOAuth.ts @@ -1,3 +1,4 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; @@ -10,15 +11,16 @@ Meteor.startup(() => { }) .observe({ async added(record) { - const { isOauthCustomConfiguration } = await import('@rocket.chat/rest-typings'); - if (!isOauthCustomConfiguration(record)) { + const service = record as LoginServiceConfiguration | undefined; + + if (!service?.custom) { return; } - new CustomOAuth(record.service, { - serverURL: record.serverURL, - authorizePath: record.authorizePath, - scope: record.scope, + new CustomOAuth(service.service, { + serverURL: service.serverURL, + authorizePath: service.authorizePath, + scope: service.scope, }); }, }); diff --git a/packages/core-typings/src/ILoginServiceConfiguration.ts b/packages/core-typings/src/ILoginServiceConfiguration.ts index 0d082b43a324e..18169fedf9849 100644 --- a/packages/core-typings/src/ILoginServiceConfiguration.ts +++ b/packages/core-typings/src/ILoginServiceConfiguration.ts @@ -4,3 +4,110 @@ export interface ILoginServiceConfiguration { clientId: string; secret: string; } + +export type OAuthConfiguration = { + custom: boolean; + clientId: string; + secret: string; + serverURL: string; + tokenPath: string; + identityPath: string; + authorizePath: string; + scope: string; + accessTokenParam: string; + buttonLabelText: string; + buttonLabelColor: string; + loginStyle: '' | 'redirect' | 'popup'; + buttonColor: string; + tokenSentVia: 'header' | 'payload'; + identityTokenSentVia: 'default' | 'header' | 'payload'; + keyField: 'username' | 'email'; + usernameField: string; + emailField: string; + nameField: string; + avatarField: string; + rolesClaim: string; + groupsClaim: string; + channelsMap: string; + channelsAdmin: string; + mergeUsers: boolean; + mergeUsersDistinctServices: boolean; + mapChannels: boolean; + mergeRoles: boolean; + rolesToSync: string; + showButton: boolean; +}; + +export type FacebookOAuthConfiguration = Omit, 'clientId'> & { + appId: OAuthConfiguration['clientId']; +}; + +export type TwitterOAuthConfiguration = Omit, 'clientId'> & { + consumerKey: OAuthConfiguration['clientId']; +}; + +export type LinkedinOAuthConfiguration = Partial & { + clientConfig: { + requestPermissions: string[]; + }; +}; + +export type CASConfiguration = { + enabled: boolean; + base_url: string; + login_url: string; + buttonLabelText: string; + buttonLabelColor: string; + buttonColor: string; + width: number; + height: number; + autoclose: boolean; +}; + +export type SAMLConfiguration = { + buttonLabelText: string; + buttonLabelColor: string; + buttonColor: string; + clientConfig: { + provider: string; + }; + entryPoint: string; + idpSLORedirectURL: string; + usernameNormalize: 'None' | 'Lowercase'; + immutableProperty: 'Username' | 'EMail'; + generateUsername: boolean; + debug: boolean; + nameOverwrite: boolean; + mailOverwrite: boolean; + issuer: string; + logoutBehaviour: 'SAML' | 'Local'; + defaultUserRole: string; + secret: { + privateKey: string; + publicCert: string; + cert: string; + }; + signatureValidationType: 'All' | 'Response' | 'Assertion' | 'Either'; + userDataFieldMap: string; + allowedClockDrift: number; + channelsAttributeUpdate: boolean; + includePrivateChannelsInUpdate: boolean; + customAuthnContext: string; + authnContextComparison: 'better' | 'exact' | 'maximum' | 'minimum'; + identifierFormat: string; + nameIDPolicyTemplate: string; + authnContextTemplate: string; + authRequestTemplate: string; + logoutResponseTemplate: string; + logoutRequestTemplate: string; + metadataCertificateTemplate: string; + metadataTemplate: string; +}; + +export type LoginServiceConfiguration = ILoginServiceConfiguration & + Partial & + Partial & + Partial & + Partial & + Partial & + Partial; diff --git a/packages/rest-typings/src/v1/settings.ts b/packages/rest-typings/src/v1/settings.ts index cbc789e220516..9288aa417c619 100644 --- a/packages/rest-typings/src/v1/settings.ts +++ b/packages/rest-typings/src/v1/settings.ts @@ -1,4 +1,4 @@ -import type { ISetting, ISettingColor } from '@rocket.chat/core-typings'; +import type { ISetting, ISettingColor, LoginServiceConfiguration } from '@rocket.chat/core-typings'; import type { PaginatedResult } from '../helpers/PaginatedResult'; @@ -8,46 +8,6 @@ type SettingsUpdatePropsActions = { execute: boolean; }; -export type OauthCustomConfiguration = { - _id: string; - clientId?: string; - custom: boolean; - service?: string; - serverURL: string; - tokenPath: string; - identityPath: string; - authorizePath: string; - scope: string; - loginStyle: 'popup' | 'redirect'; - tokenSentVia: 'header' | 'payload'; - identityTokenSentVia: 'default' | 'header' | 'payload'; - keyField: 'username' | 'email'; - usernameField: string; - emailField: string; - nameField: string; - avatarField: string; - rolesClaim: string; - groupsClaim: string; - mapChannels: string; - channelsMap: string; - channelsAdmin: string; - mergeUsers: boolean; - mergeUsersDistinctServices: boolean; - mergeRoles: boolean; - accessTokenParam: string; - showButton: boolean; - - appId: string; - consumerKey?: string; - - clientConfig: unknown; - buttonLabelText: string; - buttonLabelColor: string; - buttonColor: string; -}; - -export const isOauthCustomConfiguration = (config: any): config is OauthCustomConfiguration => Boolean(config); - export const isSettingsUpdatePropsActions = (props: Partial): props is SettingsUpdatePropsActions => 'execute' in props; @@ -74,7 +34,7 @@ export type SettingsEndpoints = { '/v1/settings.oauth': { GET: () => { - services: Partial[]; + services: Partial[]; }; }; From 9b0ef662508912eb0869e0da7d83a30599623c56 Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 5 Jan 2024 16:22:38 -0300 Subject: [PATCH 10/34] added tests for oauth button --- .../tests/e2e/fixtures/inject-initial-data.ts | 4 +++ apps/meteor/tests/e2e/oauth.spec.ts | 26 +++++++++++++++++++ apps/meteor/tests/e2e/page-objects/auth.ts | 4 +++ 3 files changed, 34 insertions(+) create mode 100644 apps/meteor/tests/e2e/oauth.spec.ts diff --git a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts index 11cea78b3f3df..38835db4aaa6b 100644 --- a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts +++ b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts @@ -57,6 +57,10 @@ export default async function injectInitialData() { _id: 'API_Enable_Rate_Limiter_Dev', value: false, }, + { + _id: 'Accounts_OAuth_Google', + value: false, + }, ].map((setting) => connection .db() diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts new file mode 100644 index 0000000000000..995e66e7a4508 --- /dev/null +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -0,0 +1,26 @@ +import { Registration } from './page-objects'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.describe('OAuth', () => { + let poRegistration: Registration; + + test.beforeEach(async ({ page }) => { + poRegistration = new Registration(page); + + await page.goto('/home'); + }); + + test('Login Page', async ({ api }) => { + await test.step('expect OAuth button to be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); + await expect(poRegistration.btnLoginWithGoogle).toBeVisible({ timeout: 10000 }); + }); + + await test.step('expect OAuth button to not be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); + + await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); + }); + }); +}); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/page-objects/auth.ts b/apps/meteor/tests/e2e/page-objects/auth.ts index 9b47d2e44adca..d0a7e13d65055 100644 --- a/apps/meteor/tests/e2e/page-objects/auth.ts +++ b/apps/meteor/tests/e2e/page-objects/auth.ts @@ -20,6 +20,10 @@ export class Registration { return this.page.locator('role=button[name="Login"]'); } + get btnLoginWithGoogle(): Locator { + return this.page.locator('role=button[name="Sign in with Google"]'); + } + get goToRegister(): Locator { return this.page.locator('role=link[name="Create an account"]'); } From 6815b8a903e3822a3ffccc35d509d639539ed874 Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 5 Jan 2024 16:53:58 -0300 Subject: [PATCH 11/34] types --- apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts | 2 +- packages/core-typings/src/ILoginServiceConfiguration.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts index dfc7151594d01..366055c50f705 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts @@ -24,7 +24,7 @@ const getSamlConfigs = function (service: string): SAMLConfiguration { buttonLabelColor: settings.get(`${service}_button_label_color`), buttonColor: settings.get(`${service}_button_color`), clientConfig: { - provider: settings.get(`${service}_provider`), + provider: settings.get(`${service}_provider`), }, entryPoint: settings.get(`${service}_entry_point`), idpSLORedirectURL: settings.get(`${service}_idp_slo_redirect_url`), diff --git a/packages/core-typings/src/ILoginServiceConfiguration.ts b/packages/core-typings/src/ILoginServiceConfiguration.ts index 18169fedf9849..9af709af724e4 100644 --- a/packages/core-typings/src/ILoginServiceConfiguration.ts +++ b/packages/core-typings/src/ILoginServiceConfiguration.ts @@ -1,8 +1,6 @@ export interface ILoginServiceConfiguration { _id: string; service: string; - clientId: string; - secret: string; } export type OAuthConfiguration = { @@ -48,7 +46,7 @@ export type TwitterOAuthConfiguration = Omit, 'clien export type LinkedinOAuthConfiguration = Partial & { clientConfig: { - requestPermissions: string[]; + requestPermissions?: string[]; }; }; @@ -69,7 +67,7 @@ export type SAMLConfiguration = { buttonLabelColor: string; buttonColor: string; clientConfig: { - provider: string; + provider?: string; }; entryPoint: string; idpSLORedirectURL: string; From 766d4516afe92f0c44e4d929cb35b44d11a3f77d Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 7 Jan 2024 15:38:17 -0300 Subject: [PATCH 12/34] types --- apps/meteor/app/api/server/v1/settings.ts | 18 +++++++++++---- .../server/lib/settings.ts | 6 ++--- apps/meteor/client/startup/customOAuth.ts | 4 ++-- .../server/lib/oauth/updateOAuthServices.ts | 22 ++++++++++++------- .../src/ILoginServiceConfiguration.ts | 14 +++++++----- .../models/ILoginServiceConfigurationModel.ts | 4 ++-- 6 files changed, 43 insertions(+), 25 deletions(-) diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index ad104e38f2642..e764ab76df3f8 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -1,4 +1,11 @@ -import type { ISetting, ISettingColor, LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import type { + FacebookOAuthConfiguration, + ISetting, + ISettingColor, + LoginServiceConfiguration, + OAuthConfiguration, + TwitterOAuthConfiguration, +} from '@rocket.chat/core-typings'; import { isSettingAction, isSettingColor } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; import { isSettingsUpdatePropDefault, isSettingsUpdatePropsActions, isSettingsUpdatePropsColor } from '@rocket.chat/rest-typings'; @@ -68,7 +75,7 @@ API.v1.addRoute( async get() { const oAuthServicesEnabled = (await ServiceConfiguration.configurations .find({}, { fields: { secret: 0 } }) - .fetchAsync()) as LoginServiceConfiguration[]; + .fetchAsync()) as unknown as LoginServiceConfiguration[]; return API.v1.success({ services: oAuthServicesEnabled.map((service) => { @@ -76,14 +83,17 @@ API.v1.addRoute( return service; } - if (service.custom || (service.service && ['saml', 'cas', 'wordpress'].includes(service.service))) { + if ((service as OAuthConfiguration).custom || (service.service && ['saml', 'cas', 'wordpress'].includes(service.service))) { return { ...service }; } return { _id: service._id, name: service.service, - clientId: service.appId || service.clientId || service.consumerKey, + clientId: + (service as FacebookOAuthConfiguration).appId || + (service as OAuthConfiguration).clientId || + (service as TwitterOAuthConfiguration).consumerKey, buttonLabelText: service.buttonLabelText || '', buttonColor: service.buttonColor || '', buttonLabelColor: service.buttonLabelColor || '', diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts index 366055c50f705..43157e015fe0e 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts @@ -1,6 +1,6 @@ import type { SAMLConfiguration } from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings, settingsRegistry } from '../../../settings/server'; @@ -116,7 +116,7 @@ export const loadSamlServiceProviders = async function (): Promise { if (value === true) { const samlConfigs = getSamlConfigs(key); SAMLUtils.log(key); - await ServiceConfiguration.configurations.upsertAsync( + await LoginServiceConfiguration.updateOne( { service: serviceName.toLowerCase(), }, @@ -126,7 +126,7 @@ export const loadSamlServiceProviders = async function (): Promise { ); return configureSamlService(samlConfigs); } - await ServiceConfiguration.configurations.removeAsync({ + await LoginServiceConfiguration.deleteOne({ service: serviceName.toLowerCase(), }); return false; diff --git a/apps/meteor/client/startup/customOAuth.ts b/apps/meteor/client/startup/customOAuth.ts index 965065fb9e3fb..bb56af55cd3ab 100644 --- a/apps/meteor/client/startup/customOAuth.ts +++ b/apps/meteor/client/startup/customOAuth.ts @@ -1,4 +1,4 @@ -import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import type { ILoginServiceConfiguration, OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; @@ -11,7 +11,7 @@ Meteor.startup(() => { }) .observe({ async added(record) { - const service = record as LoginServiceConfiguration | undefined; + const service = record as unknown as (ILoginServiceConfiguration & OAuthConfiguration) | undefined; if (!service?.custom) { return; diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts index 9f4a81d75d3ce..4f25bca72b5cd 100644 --- a/apps/meteor/server/lib/oauth/updateOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -1,6 +1,12 @@ -import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import type { + FacebookOAuthConfiguration, + ILoginServiceConfiguration, + LinkedinOAuthConfiguration, + OAuthConfiguration, + TwitterOAuthConfiguration, +} from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration } from '@rocket.chat/models'; import { getObjectKeys } from '@rocket.chat/tools'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { CustomOAuth } from '../../../app/custom-oauth/server/custom_oauth_server'; import { settings } from '../../../app/settings/server/cached'; @@ -20,7 +26,7 @@ export async function updateOAuthServices(): Promise { } if (value === true) { - const data: Partial> = { + const data: Partial> = { clientId: settings.get(`${key}_id`), secret: settings.get(`${key}_secret`), }; @@ -85,16 +91,16 @@ export async function updateOAuthServices(): Promise { }); } if (serviceName === 'Facebook') { - data.appId = data.clientId; + (data as FacebookOAuthConfiguration).appId = data.clientId as string; delete data.clientId; } if (serviceName === 'Twitter') { - data.consumerKey = data.clientId; + (data as TwitterOAuthConfiguration).consumerKey = data.clientId as string; delete data.clientId; } if (serviceName === 'Linkedin') { - data.clientConfig = { + (data as LinkedinOAuthConfiguration).clientConfig = { requestPermissions: ['openid', 'email', 'profile'], }; } @@ -111,7 +117,7 @@ export async function updateOAuthServices(): Promise { data.service = serviceName.toLowerCase(); } - await ServiceConfiguration.configurations.upsertAsync( + await LoginServiceConfiguration.updateOne( { service: serviceName.toLowerCase(), }, @@ -120,7 +126,7 @@ export async function updateOAuthServices(): Promise { }, ); } else { - await ServiceConfiguration.configurations.removeAsync({ + await LoginServiceConfiguration.deleteOne({ service: serviceName.toLowerCase(), }); } diff --git a/packages/core-typings/src/ILoginServiceConfiguration.ts b/packages/core-typings/src/ILoginServiceConfiguration.ts index 9af709af724e4..1874eea5d8bd4 100644 --- a/packages/core-typings/src/ILoginServiceConfiguration.ts +++ b/packages/core-typings/src/ILoginServiceConfiguration.ts @@ -103,9 +103,11 @@ export type SAMLConfiguration = { }; export type LoginServiceConfiguration = ILoginServiceConfiguration & - Partial & - Partial & - Partial & - Partial & - Partial & - Partial; + ( + | Partial + | Partial + | Partial + | Partial + | Partial + | Partial + ); diff --git a/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts b/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts index e515040dfa1b6..ae8673bdc6e7e 100644 --- a/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts +++ b/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts @@ -1,8 +1,8 @@ -import type { ILoginServiceConfiguration } from '@rocket.chat/core-typings'; +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; import type { IBaseModel } from './IBaseModel'; // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ILoginServiceConfigurationModel extends IBaseModel { +export interface ILoginServiceConfigurationModel extends IBaseModel { // } From 6bdd9c69194b5ce427339c547b8429dd0196e932 Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 7 Jan 2024 15:42:10 -0300 Subject: [PATCH 13/34] review changes --- .../server/modules/watchers/watchers.module.ts | 7 ++++--- apps/meteor/server/services/meteor/service.ts | 9 +++------ ee/apps/ddp-streamer/src/DDPStreamer.ts | 5 +---- packages/core-services/src/events/Events.ts | 15 +-------------- 4 files changed, 9 insertions(+), 27 deletions(-) diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 7338a43e0e65a..7059ef82ffffa 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -340,10 +340,11 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb watcher.on(LoginServiceConfiguration.getCollectionName(), async ({ clientAction, id }) => { const data = await LoginServiceConfiguration.findOne>(id, { projection: { secret: 0 } }); + if (clientAction === 'removed') { + void broadcast('watch.loginServiceConfiguration', { clientAction, id, data: { _id: id } }); + } + if (!data) { - if (clientAction === 'removed') { - void broadcast('watch.loginServiceConfiguration', { clientAction, id }); - } return; } diff --git a/apps/meteor/server/services/meteor/service.ts b/apps/meteor/server/services/meteor/service.ts index 6c69634b82cb1..8b9462740c6c2 100644 --- a/apps/meteor/server/services/meteor/service.ts +++ b/apps/meteor/server/services/meteor/service.ts @@ -152,12 +152,9 @@ export class MeteorService extends ServiceClassInternal implements IMeteor { return; } - // per the event definition, data will always be defined when clientAction is not 'removed' - if (data) { - serviceConfigCallbacks.forEach((callbacks) => { - callbacks[clientAction === 'inserted' ? 'added' : 'changed']?.(id, data); - }); - } + serviceConfigCallbacks.forEach((callbacks) => { + callbacks[clientAction === 'inserted' ? 'added' : 'changed']?.(id, data); + }); }); } diff --git a/ee/apps/ddp-streamer/src/DDPStreamer.ts b/ee/apps/ddp-streamer/src/DDPStreamer.ts index 1219c75363fe8..bccb35d2b3263 100644 --- a/ee/apps/ddp-streamer/src/DDPStreamer.ts +++ b/ee/apps/ddp-streamer/src/DDPStreamer.ts @@ -44,10 +44,7 @@ export class DDPStreamer extends ServiceClass { return; } - // per the event definition, data will always be defined when clientAction is not 'removed' - if (data) { - events.emit('meteor.loginServiceConfiguration', clientAction === 'inserted' ? 'added' : 'changed', data); - } + events.emit('meteor.loginServiceConfiguration', clientAction === 'inserted' ? 'added' : 'changed', data); }); this.onEvent('meteor.clientVersionUpdated', (versions): void => { diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index b4241b3c3987a..67327c3ea2157 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -40,19 +40,6 @@ import type { AutoUpdateRecord } from '../types/IMeteor'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; -type LoginServiceConfigurationEvent = { - id: string; -} & ( - | { - clientAction: 'removed'; - data?: Partial; - } - | { - clientAction: Omit; - data: Partial; - } -); - export type EventSignatures = { 'room.video-conference': (params: { rid: string; callId: string }) => void; 'shutdown': (params: Record) => void; @@ -248,7 +235,7 @@ export type EventSignatures = { } ), ): void; - 'watch.loginServiceConfiguration'(data: LoginServiceConfigurationEvent): void; + 'watch.loginServiceConfiguration'(data: { clientAction: ClientAction; data: Partial; id: string }): void; 'watch.instanceStatus'(data: { clientAction: ClientAction; data?: undefined | Partial; From bbfbeb9ad4512a2c45fed6981f1321f55dcad356 Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 8 Jan 2024 00:04:14 -0300 Subject: [PATCH 14/34] debugging CI tests --- apps/meteor/server/modules/watchers/watchers.module.ts | 7 ++++--- apps/meteor/server/services/meteor/service.ts | 1 - apps/meteor/tests/e2e/oauth.spec.ts | 2 +- ee/apps/ddp-streamer/src/DDPStreamer.ts | 1 - packages/core-services/src/events/Events.ts | 2 +- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 7338a43e0e65a..6e9b9312f4063 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -340,10 +340,11 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb watcher.on(LoginServiceConfiguration.getCollectionName(), async ({ clientAction, id }) => { const data = await LoginServiceConfiguration.findOne>(id, { projection: { secret: 0 } }); + if (clientAction === 'removed') { + void broadcast('watch.loginServiceConfiguration', { clientAction, id }); + } + if (!data) { - if (clientAction === 'removed') { - void broadcast('watch.loginServiceConfiguration', { clientAction, id }); - } return; } diff --git a/apps/meteor/server/services/meteor/service.ts b/apps/meteor/server/services/meteor/service.ts index 6c69634b82cb1..95d2061e2f679 100644 --- a/apps/meteor/server/services/meteor/service.ts +++ b/apps/meteor/server/services/meteor/service.ts @@ -152,7 +152,6 @@ export class MeteorService extends ServiceClassInternal implements IMeteor { return; } - // per the event definition, data will always be defined when clientAction is not 'removed' if (data) { serviceConfigCallbacks.forEach((callbacks) => { callbacks[clientAction === 'inserted' ? 'added' : 'changed']?.(id, data); diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index 995e66e7a4508..e8ad6a6c7e544 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -20,7 +20,7 @@ test.describe('OAuth', () => { await test.step('expect OAuth button to not be visible', async () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); - await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); + await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible({ timeout: 10000 }); }); }); }); \ No newline at end of file diff --git a/ee/apps/ddp-streamer/src/DDPStreamer.ts b/ee/apps/ddp-streamer/src/DDPStreamer.ts index 1219c75363fe8..ef0f91988ece4 100644 --- a/ee/apps/ddp-streamer/src/DDPStreamer.ts +++ b/ee/apps/ddp-streamer/src/DDPStreamer.ts @@ -44,7 +44,6 @@ export class DDPStreamer extends ServiceClass { return; } - // per the event definition, data will always be defined when clientAction is not 'removed' if (data) { events.emit('meteor.loginServiceConfiguration', clientAction === 'inserted' ? 'added' : 'changed', data); } diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index b4241b3c3987a..6315bf43fa13f 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -45,7 +45,7 @@ type LoginServiceConfigurationEvent = { } & ( | { clientAction: 'removed'; - data?: Partial; + data?: never; } | { clientAction: Omit; From c3d8f4ab516689c8ce205fa2b52f456f161d652d Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 8 Jan 2024 08:46:38 -0300 Subject: [PATCH 15/34] lint --- apps/meteor/app/api/server/v1/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index e764ab76df3f8..8a6078eeb0318 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -4,7 +4,7 @@ import type { ISettingColor, LoginServiceConfiguration, OAuthConfiguration, - TwitterOAuthConfiguration, + TwitterOAuthConfiguration, } from '@rocket.chat/core-typings'; import { isSettingAction, isSettingColor } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; From 74f6b888a2fb4073811cdb45dd668226a339df5e Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 8 Jan 2024 13:09:55 -0300 Subject: [PATCH 16/34] use insert/update instead of upsert --- .../server/lib/settings.ts | 13 +----- .../server/lib/oauth/updateOAuthServices.ts | 24 +++-------- .../models/raw/LoginServiceConfiguration.ts | 40 ++++++++++++++++++- .../models/ILoginServiceConfigurationModel.ts | 4 +- 4 files changed, 48 insertions(+), 33 deletions(-) diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts index 43157e015fe0e..2086e934271e8 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts @@ -116,19 +116,10 @@ export const loadSamlServiceProviders = async function (): Promise { if (value === true) { const samlConfigs = getSamlConfigs(key); SAMLUtils.log(key); - await LoginServiceConfiguration.updateOne( - { - service: serviceName.toLowerCase(), - }, - { - $set: samlConfigs, - }, - ); + await LoginServiceConfiguration.createOrUpdateService(serviceName, samlConfigs); return configureSamlService(samlConfigs); } - await LoginServiceConfiguration.deleteOne({ - service: serviceName.toLowerCase(), - }); + await LoginServiceConfiguration.removeService(serviceName); return false; }), ) diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts index 4f25bca72b5cd..ed0ae5977d0d8 100644 --- a/apps/meteor/server/lib/oauth/updateOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -6,7 +6,6 @@ import type { TwitterOAuthConfiguration, } from '@rocket.chat/core-typings'; import { LoginServiceConfiguration } from '@rocket.chat/models'; -import { getObjectKeys } from '@rocket.chat/tools'; import { CustomOAuth } from '../../../app/custom-oauth/server/custom_oauth_server'; import { settings } from '../../../app/settings/server/cached'; @@ -25,6 +24,8 @@ export async function updateOAuthServices(): Promise { serviceName = key.replace('Accounts_OAuth_Custom-', ''); } + const serviceKey = serviceName.toLowerCase(); + if (value === true) { const data: Partial> = { clientId: settings.get(`${key}_id`), @@ -63,7 +64,7 @@ export async function updateOAuthServices(): Promise { data.rolesToSync = settings.get(`${key}-roles_to_sync`); data.showButton = settings.get(`${key}-show_button`); - new CustomOAuth(serviceName.toLowerCase(), { + new CustomOAuth(serviceKey, { serverURL: data.serverURL, tokenPath: data.tokenPath, identityPath: data.identityPath, @@ -111,24 +112,9 @@ export async function updateOAuthServices(): Promise { data.buttonColor = settings.get('Accounts_OAuth_Nextcloud_button_color'); } - // If there's no data other than the service name, then put the service name in the data object so the operation won't fail - const keys = getObjectKeys(data).filter((key) => data[key] !== undefined); - if (!keys.length) { - data.service = serviceName.toLowerCase(); - } - - await LoginServiceConfiguration.updateOne( - { - service: serviceName.toLowerCase(), - }, - { - $set: data, - }, - ); + await LoginServiceConfiguration.createOrUpdateService(serviceKey, data); } else { - await LoginServiceConfiguration.deleteOne({ - service: serviceName.toLowerCase(), - }); + await LoginServiceConfiguration.removeService(serviceKey); } } } diff --git a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts index a0db761ee5b84..e5afa11164111 100644 --- a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts +++ b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts @@ -1,6 +1,6 @@ -import type { ILoginServiceConfiguration, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { ILoginServiceConfiguration, LoginServiceConfiguration, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { ILoginServiceConfigurationModel } from '@rocket.chat/model-typings'; -import type { Collection, Db } from 'mongodb'; +import type { Collection, Db, DeleteResult } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -13,4 +13,40 @@ export class LoginServiceConfigurationRaw extends BaseRaw, + ): Promise { + const service = serviceName.toLowerCase(); + + const existing = await this.findOne({ service }); + if (!existing) { + const insertResult = await this.insertOne({ + service, + ...serviceData, + }); + + return insertResult.insertedId; + } + + if (Object.keys(serviceData).length > 0) { + await this.updateOne( + { + _id: existing._id, + }, + { + $set: serviceData, + }, + ); + } + + return existing._id; + } + + async removeService(serviceName: string): Promise { + const service = serviceName.toLowerCase(); + + return this.deleteOne({ service }); + } } diff --git a/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts b/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts index ae8673bdc6e7e..5a26607eda067 100644 --- a/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts +++ b/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts @@ -1,8 +1,10 @@ import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import type { DeleteResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ILoginServiceConfigurationModel extends IBaseModel { - // + createOrUpdateService(serviceName: string, serviceData: Partial): Promise; + removeService(serviceName: string): Promise; } From 84b130fab3acc71a78b2be7f374cbfba368aa11c Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 8 Jan 2024 13:15:03 -0300 Subject: [PATCH 17/34] avoid unnecessary query --- apps/meteor/server/modules/watchers/watchers.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 6e9b9312f4063..3bdbd6fee1e79 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -339,11 +339,12 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb }); watcher.on(LoginServiceConfiguration.getCollectionName(), async ({ clientAction, id }) => { - const data = await LoginServiceConfiguration.findOne>(id, { projection: { secret: 0 } }); if (clientAction === 'removed') { void broadcast('watch.loginServiceConfiguration', { clientAction, id }); + return; } + const data = await LoginServiceConfiguration.findOne>(id, { projection: { secret: 0 } }); if (!data) { return; } From 1288abea08573ea49fe9d0217204ca18789f2867 Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 8 Jan 2024 15:33:27 -0300 Subject: [PATCH 18/34] API e2e test --- .../tests/end-to-end/api/08-settings.js | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/meteor/tests/end-to-end/api/08-settings.js b/apps/meteor/tests/end-to-end/api/08-settings.js index de8a21ffac412..d517b60eea9dc 100644 --- a/apps/meteor/tests/end-to-end/api/08-settings.js +++ b/apps/meteor/tests/end-to-end/api/08-settings.js @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { before, describe, it } from 'mocha'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; +import { updateSetting } from '../../data/permissions.helper'; describe('[Settings]', function () { this.retries(0); @@ -84,6 +85,54 @@ describe('[Settings]', function () { }) .end(done); }); + + describe('With OAuth enabled', () => { + before((done) => { + updateSetting('Accounts_OAuth_Google', true).then(done); + }); + + it('should include the OAuth service in the response', (done) => { + // wait 3 seconds before getting the service list so the server has had time to update it + setTimeout(() => { + request + .get(api('service.configurations')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('configurations'); + + expect(res.body.configurations.find(({ service }) => service === 'google')).to.exist; + }) + .end(done); + }, 3000); + }); + }); + + describe('With OAuth disabled', () => { + before((done) => { + updateSetting('Accounts_OAuth_Google', false).then(done); + }); + + it('should not include the OAuth service in the response', (done) => { + // wait 3 seconds before getting the service list so the server has had time to update it + setTimeout(() => { + request + .get(api('service.configurations')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('configurations'); + + expect(res.body.configurations.find(({ service }) => service === 'google')).to.not.exist; + }) + .end(done); + }, 3000); + }); + }); }); describe('/settings.oauth', () => { From a9f3ebf2078d78240cd24895ae7f59cf5771bce0 Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 8 Jan 2024 15:33:38 -0300 Subject: [PATCH 19/34] avoid unnecessary query --- apps/meteor/server/modules/watchers/watchers.module.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 6e9b9312f4063..efa0866ab4a06 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -339,11 +339,13 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb }); watcher.on(LoginServiceConfiguration.getCollectionName(), async ({ clientAction, id }) => { - const data = await LoginServiceConfiguration.findOne>(id, { projection: { secret: 0 } }); if (clientAction === 'removed') { void broadcast('watch.loginServiceConfiguration', { clientAction, id }); + return; } + const data = await LoginServiceConfiguration.findOne>(id, { projection: { secret: 0 } }); + if (!data) { return; } From 000a14ba6f478bd5db2fac205723018b301858a2 Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 8 Jan 2024 16:52:03 -0300 Subject: [PATCH 20/34] logs for desperate CI debugging --- apps/meteor/server/lib/oauth/addOAuthService.ts | 2 ++ apps/meteor/server/lib/oauth/initCustomOAuthServices.ts | 2 ++ apps/meteor/server/lib/oauth/removeOAuthService.ts | 2 ++ apps/meteor/server/lib/oauth/updateOAuthServices.ts | 2 ++ apps/meteor/server/models/raw/LoginServiceConfiguration.ts | 4 ++++ apps/meteor/server/modules/watchers/watchers.module.ts | 1 + apps/meteor/server/services/meteor/service.ts | 2 ++ ee/apps/ddp-streamer/src/DDPStreamer.ts | 1 + ee/apps/ddp-streamer/src/configureServer.ts | 4 ++++ 9 files changed, 20 insertions(+) diff --git a/apps/meteor/server/lib/oauth/addOAuthService.ts b/apps/meteor/server/lib/oauth/addOAuthService.ts index 2a49a23a1f4e1..67974caf0624e 100644 --- a/apps/meteor/server/lib/oauth/addOAuthService.ts +++ b/apps/meteor/server/lib/oauth/addOAuthService.ts @@ -5,6 +5,8 @@ import { capitalize } from '@rocket.chat/string-helpers'; import { settingsRegistry } from '../../../app/settings/server'; export async function addOAuthService(name: string, values: { [k: string]: string | boolean | undefined } = {}): Promise { + console.log('DEBUGOAUTH', 'addOAuthService', name); + name = name.toLowerCase().replace(/[^a-z0-9_]/g, ''); name = capitalize(name); await settingsRegistry.add(`Accounts_OAuth_Custom-${name}`, values.enabled || false, { diff --git a/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts index 3c909f6bc1f12..1882d88fc9ca8 100644 --- a/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts @@ -1,6 +1,8 @@ import { addOAuthService } from './addOAuthService'; export async function initCustomOAuthServices(): Promise { + console.log('DEBUGOAUTH', 'initCustomOAuthServices'); + // Add settings for custom OAuth providers to the settings so they get // automatically added when they are defined in ENV variables for await (const key of Object.keys(process.env)) { diff --git a/apps/meteor/server/lib/oauth/removeOAuthService.ts b/apps/meteor/server/lib/oauth/removeOAuthService.ts index 01660021fe6fe..0964b8059707e 100644 --- a/apps/meteor/server/lib/oauth/removeOAuthService.ts +++ b/apps/meteor/server/lib/oauth/removeOAuthService.ts @@ -2,6 +2,8 @@ import { ServiceConfiguration } from 'meteor/service-configuration'; export async function removeOAuthService(mainSettingId: string): Promise { const serviceName = mainSettingId.replace('Accounts_OAuth_Custom-', ''); + console.log('DEBUGOAUTH', 'removeOAuthService', serviceName); + await ServiceConfiguration.configurations.removeAsync({ service: serviceName.toLowerCase(), }); diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts index ed0ae5977d0d8..701ace09ca094 100644 --- a/apps/meteor/server/lib/oauth/updateOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -12,6 +12,8 @@ import { settings } from '../../../app/settings/server/cached'; import { logger } from './logger'; export async function updateOAuthServices(): Promise { + console.log('DEBUGOAUTH', 'updateOAuthServices'); + const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); for await (const [key, value] of filteredServices) { diff --git a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts index e5afa11164111..061cfd014892b 100644 --- a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts +++ b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts @@ -18,10 +18,12 @@ export class LoginServiceConfigurationRaw extends BaseRaw, ): Promise { + console.log('DEBUGOAUTH', 'createOrUpdateService', serviceName); const service = serviceName.toLowerCase(); const existing = await this.findOne({ service }); if (!existing) { + console.log('DEBUGOAUTH', 'createOrUpdateService', 'INSERT'); const insertResult = await this.insertOne({ service, ...serviceData, @@ -31,6 +33,7 @@ export class LoginServiceConfigurationRaw extends BaseRaw 0) { + console.log('DEBUGOAUTH', 'createOrUpdateService', 'UPDATE'); await this.updateOne( { _id: existing._id, @@ -45,6 +48,7 @@ export class LoginServiceConfigurationRaw extends BaseRaw { + console.log('DEBUGOAUTH', 'removeService'); const service = serviceName.toLowerCase(); return this.deleteOne({ service }); diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 3bdbd6fee1e79..b91384305def8 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -339,6 +339,7 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb }); watcher.on(LoginServiceConfiguration.getCollectionName(), async ({ clientAction, id }) => { + console.log('DEBUGOAUTH', 'watcher', clientAction, id); if (clientAction === 'removed') { void broadcast('watch.loginServiceConfiguration', { clientAction, id }); return; diff --git a/apps/meteor/server/services/meteor/service.ts b/apps/meteor/server/services/meteor/service.ts index 95d2061e2f679..f2b0b175b7a22 100644 --- a/apps/meteor/server/services/meteor/service.ts +++ b/apps/meteor/server/services/meteor/service.ts @@ -145,6 +145,8 @@ export class MeteorService extends ServiceClassInternal implements IMeteor { if (disableOplog) { this.onEvent('watch.loginServiceConfiguration', ({ clientAction, id, data }) => { + console.log('DEBUGOAUTH', 'meteor service event', clientAction, id); + if (clientAction === 'removed') { serviceConfigCallbacks.forEach((callbacks) => { callbacks.removed?.(id); diff --git a/ee/apps/ddp-streamer/src/DDPStreamer.ts b/ee/apps/ddp-streamer/src/DDPStreamer.ts index 79905fc8206d9..20234807a6870 100644 --- a/ee/apps/ddp-streamer/src/DDPStreamer.ts +++ b/ee/apps/ddp-streamer/src/DDPStreamer.ts @@ -37,6 +37,7 @@ export class DDPStreamer extends ServiceClass { }); this.onEvent('watch.loginServiceConfiguration', ({ clientAction, id, data }) => { + console.log('DEBUGOAUTH', 'ddp-streamer event', clientAction, id); if (clientAction === 'removed') { events.emit('meteor.loginServiceConfiguration', 'removed', { _id: id, diff --git a/ee/apps/ddp-streamer/src/configureServer.ts b/ee/apps/ddp-streamer/src/configureServer.ts index ed187db498cc3..4a8d08040617c 100644 --- a/ee/apps/ddp-streamer/src/configureServer.ts +++ b/ee/apps/ddp-streamer/src/configureServer.ts @@ -20,9 +20,12 @@ MeteorService.getLoginServiceConfiguration() .catch((err) => console.error('DDPStreamer not able to retrieve login services configuration', err)); server.publish(loginServiceConfigurationPublication, async function () { + console.log('DEBUGOAUTH', 'publication'); loginServices.forEach((record) => this.added(loginServiceConfigurationCollection, record._id, record)); const fn = (action: string, record: any): void => { + console.log('DEBUGOAUTH', 'callback', action); + switch (action) { case 'added': case 'changed': @@ -38,6 +41,7 @@ server.publish(loginServiceConfigurationPublication, async function () { events.on(loginServiceConfigurationPublication, fn); this.onStop(() => { + console.log('DEBUGOAUTH', 'remove listener'); events.removeListener(loginServiceConfigurationPublication, fn); }); From 2a6eb51096c4ba5b4f78611573e1baa514855a4d Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 8 Jan 2024 17:57:36 -0300 Subject: [PATCH 21/34] more logs, timestamps --- apps/meteor/app/api/server/v1/settings.ts | 4 ++++ apps/meteor/server/lib/oauth/addOAuthService.ts | 2 +- apps/meteor/server/lib/oauth/initCustomOAuthServices.ts | 2 +- apps/meteor/server/lib/oauth/removeOAuthService.ts | 2 +- apps/meteor/server/lib/oauth/updateOAuthServices.ts | 2 +- .../meteor/server/models/raw/LoginServiceConfiguration.ts | 8 ++++---- apps/meteor/server/modules/watchers/watchers.module.ts | 2 +- apps/meteor/server/services/meteor/service.ts | 2 +- apps/meteor/tests/e2e/oauth.spec.ts | 3 +++ ee/apps/ddp-streamer/src/DDPStreamer.ts | 2 +- ee/apps/ddp-streamer/src/configureServer.ts | 6 +++--- 11 files changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 8a6078eeb0318..7b8fd275c6a14 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -197,6 +197,10 @@ API.v1.addRoute( return API.v1.success(); } + if (setting._id === 'Accounts_OAuth_Google') { + console.log('DEBUGOAUTH', new Date().toISOString(), 'change setting', (this.bodyParams as any)?.value); + } + if ( isSettingsUpdatePropDefault(this.bodyParams) && (await Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value)) diff --git a/apps/meteor/server/lib/oauth/addOAuthService.ts b/apps/meteor/server/lib/oauth/addOAuthService.ts index 67974caf0624e..6746d12df8143 100644 --- a/apps/meteor/server/lib/oauth/addOAuthService.ts +++ b/apps/meteor/server/lib/oauth/addOAuthService.ts @@ -5,7 +5,7 @@ import { capitalize } from '@rocket.chat/string-helpers'; import { settingsRegistry } from '../../../app/settings/server'; export async function addOAuthService(name: string, values: { [k: string]: string | boolean | undefined } = {}): Promise { - console.log('DEBUGOAUTH', 'addOAuthService', name); + console.log('DEBUGOAUTH', new Date().toISOString(), 'addOAuthService', name); name = name.toLowerCase().replace(/[^a-z0-9_]/g, ''); name = capitalize(name); diff --git a/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts index 1882d88fc9ca8..960bee9a76b62 100644 --- a/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts @@ -1,7 +1,7 @@ import { addOAuthService } from './addOAuthService'; export async function initCustomOAuthServices(): Promise { - console.log('DEBUGOAUTH', 'initCustomOAuthServices'); + console.log('DEBUGOAUTH', new Date().toISOString(), 'initCustomOAuthServices'); // Add settings for custom OAuth providers to the settings so they get // automatically added when they are defined in ENV variables diff --git a/apps/meteor/server/lib/oauth/removeOAuthService.ts b/apps/meteor/server/lib/oauth/removeOAuthService.ts index 0964b8059707e..7aa4a937e9673 100644 --- a/apps/meteor/server/lib/oauth/removeOAuthService.ts +++ b/apps/meteor/server/lib/oauth/removeOAuthService.ts @@ -2,7 +2,7 @@ import { ServiceConfiguration } from 'meteor/service-configuration'; export async function removeOAuthService(mainSettingId: string): Promise { const serviceName = mainSettingId.replace('Accounts_OAuth_Custom-', ''); - console.log('DEBUGOAUTH', 'removeOAuthService', serviceName); + console.log('DEBUGOAUTH', new Date().toISOString(), 'removeOAuthService', serviceName); await ServiceConfiguration.configurations.removeAsync({ service: serviceName.toLowerCase(), diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts index 701ace09ca094..c6e997a6f8992 100644 --- a/apps/meteor/server/lib/oauth/updateOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -12,7 +12,7 @@ import { settings } from '../../../app/settings/server/cached'; import { logger } from './logger'; export async function updateOAuthServices(): Promise { - console.log('DEBUGOAUTH', 'updateOAuthServices'); + console.log('DEBUGOAUTH', new Date().toISOString(), 'updateOAuthServices'); const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); diff --git a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts index 061cfd014892b..82d8520ca1f11 100644 --- a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts +++ b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts @@ -18,12 +18,12 @@ export class LoginServiceConfigurationRaw extends BaseRaw, ): Promise { - console.log('DEBUGOAUTH', 'createOrUpdateService', serviceName); + console.log('DEBUGOAUTH', new Date().toISOString(), 'createOrUpdateService', serviceName); const service = serviceName.toLowerCase(); const existing = await this.findOne({ service }); if (!existing) { - console.log('DEBUGOAUTH', 'createOrUpdateService', 'INSERT'); + console.log('DEBUGOAUTH', new Date().toISOString(), 'createOrUpdateService', 'INSERT'); const insertResult = await this.insertOne({ service, ...serviceData, @@ -33,7 +33,7 @@ export class LoginServiceConfigurationRaw extends BaseRaw 0) { - console.log('DEBUGOAUTH', 'createOrUpdateService', 'UPDATE'); + console.log('DEBUGOAUTH', new Date().toISOString(), 'createOrUpdateService', 'UPDATE'); await this.updateOne( { _id: existing._id, @@ -48,7 +48,7 @@ export class LoginServiceConfigurationRaw extends BaseRaw { - console.log('DEBUGOAUTH', 'removeService'); + console.log('DEBUGOAUTH', new Date().toISOString(), 'removeService', serviceName); const service = serviceName.toLowerCase(); return this.deleteOne({ service }); diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index b91384305def8..f1606f0a1857d 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -339,7 +339,7 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb }); watcher.on(LoginServiceConfiguration.getCollectionName(), async ({ clientAction, id }) => { - console.log('DEBUGOAUTH', 'watcher', clientAction, id); + console.log('DEBUGOAUTH', new Date().toISOString(), 'watcher', clientAction, id); if (clientAction === 'removed') { void broadcast('watch.loginServiceConfiguration', { clientAction, id }); return; diff --git a/apps/meteor/server/services/meteor/service.ts b/apps/meteor/server/services/meteor/service.ts index f2b0b175b7a22..806ee20bf9454 100644 --- a/apps/meteor/server/services/meteor/service.ts +++ b/apps/meteor/server/services/meteor/service.ts @@ -145,7 +145,7 @@ export class MeteorService extends ServiceClassInternal implements IMeteor { if (disableOplog) { this.onEvent('watch.loginServiceConfiguration', ({ clientAction, id, data }) => { - console.log('DEBUGOAUTH', 'meteor service event', clientAction, id); + console.log('DEBUGOAUTH', new Date().toISOString(), 'meteor service event', clientAction, id); if (clientAction === 'removed') { serviceConfigCallbacks.forEach((callbacks) => { diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index e8ad6a6c7e544..920967e3e802f 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -14,12 +14,15 @@ test.describe('OAuth', () => { test('Login Page', async ({ api }) => { await test.step('expect OAuth button to be visible', async () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); + console.log('DEBUGOAUTH', new Date().toISOString(), 'expect to be visible'); + await expect(poRegistration.btnLoginWithGoogle).toBeVisible({ timeout: 10000 }); }); await test.step('expect OAuth button to not be visible', async () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); + console.log('DEBUGOAUTH', new Date().toISOString(), 'expect to not be visible'); await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible({ timeout: 10000 }); }); }); diff --git a/ee/apps/ddp-streamer/src/DDPStreamer.ts b/ee/apps/ddp-streamer/src/DDPStreamer.ts index 20234807a6870..88d9ae6fd50da 100644 --- a/ee/apps/ddp-streamer/src/DDPStreamer.ts +++ b/ee/apps/ddp-streamer/src/DDPStreamer.ts @@ -37,7 +37,7 @@ export class DDPStreamer extends ServiceClass { }); this.onEvent('watch.loginServiceConfiguration', ({ clientAction, id, data }) => { - console.log('DEBUGOAUTH', 'ddp-streamer event', clientAction, id); + console.log('DEBUGOAUTH', new Date().toISOString(), 'ddp-streamer event', clientAction, id); if (clientAction === 'removed') { events.emit('meteor.loginServiceConfiguration', 'removed', { _id: id, diff --git a/ee/apps/ddp-streamer/src/configureServer.ts b/ee/apps/ddp-streamer/src/configureServer.ts index 4a8d08040617c..ae3d0ac258f07 100644 --- a/ee/apps/ddp-streamer/src/configureServer.ts +++ b/ee/apps/ddp-streamer/src/configureServer.ts @@ -20,11 +20,11 @@ MeteorService.getLoginServiceConfiguration() .catch((err) => console.error('DDPStreamer not able to retrieve login services configuration', err)); server.publish(loginServiceConfigurationPublication, async function () { - console.log('DEBUGOAUTH', 'publication'); + console.log('DEBUGOAUTH', new Date().toISOString(), 'publication'); loginServices.forEach((record) => this.added(loginServiceConfigurationCollection, record._id, record)); const fn = (action: string, record: any): void => { - console.log('DEBUGOAUTH', 'callback', action); + console.log('DEBUGOAUTH', new Date().toISOString(), 'callback', action); switch (action) { case 'added': @@ -41,7 +41,7 @@ server.publish(loginServiceConfigurationPublication, async function () { events.on(loginServiceConfigurationPublication, fn); this.onStop(() => { - console.log('DEBUGOAUTH', 'remove listener'); + console.log('DEBUGOAUTH', new Date().toISOString(), 'remove listener'); events.removeListener(loginServiceConfigurationPublication, fn); }); From a6ef7dc859a8ab4bbe30e0c71f77a12150cc7aaa Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 9 Jan 2024 12:19:05 -0300 Subject: [PATCH 22/34] websocket logs --- apps/meteor/tests/e2e/oauth.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index 920967e3e802f..80c5d3ca44e30 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -6,6 +6,13 @@ test.describe('OAuth', () => { let poRegistration: Registration; test.beforeEach(async ({ page }) => { + page.on('websocket', (ws) => { + console.log('DEBUGOAUTH', new Date().toISOString(), `WebSocket opened: ${ws.url()}>`); + // ws.on('framesent', event => console.log('DEBUGOAUTH', new Date().toISOString(), 'WebSocket Frame Sent', event.payload)); + ws.on('framereceived', event => console.log('DEBUGOAUTH', new Date().toISOString(), 'WebSocket Frame Received', event.payload)); + ws.on('close', () => console.log('DEBUGOAUTH', new Date().toISOString(), 'WebSocket closed')); + }); + poRegistration = new Registration(page); await page.goto('/home'); From 1ee828a3eacaffe7451d3b1e6ef58af618390b18 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 9 Jan 2024 18:08:47 -0300 Subject: [PATCH 23/34] yet more logs --- .../providers/UserProvider/UserProvider.tsx | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index b6e30134cdaa8..cb3142b75d31b 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -59,6 +59,14 @@ type UserProviderProps = { children: ReactNode; }; +const find = (...args: any[]) => { + console.log('DEBUGOAUTH', new Date().toISOString(), 'find on service configurations'); + const results = ServiceConfiguration.configurations.find(...args).fetch(); + + console.log('DEBUGOAUTH', new Date().toISOString(), 'results', results?.length); + return results; +}; + const UserProvider = ({ children }: UserProviderProps): ReactElement => { const isLdapEnabled = useSetting('LDAP_Enable'); const isCrowdEnabled = useSetting('CROWD_Enable'); @@ -142,26 +150,23 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { }); }, queryAllServices: createReactiveSubscriptionFactory(() => - ServiceConfiguration.configurations - .find( - { - showButton: { $ne: false }, + find( + { + showButton: { $ne: false }, + }, + { + sort: { + service: 1, }, - { - sort: { - service: 1, - }, - }, - ) - .fetch() - .map( - ({ appId: _, ...service }) => - ({ - title: capitalize(String((service as any).service || '')), - ...service, - ...(config[(service as any).service] ?? {}), - } as any), - ), + }, + ).map( + ({ appId: _, ...service }) => + ({ + title: capitalize(String((service as any).service || '')), + ...service, + ...(config[(service as any).service] ?? {}), + } as any), + ), ), }), [userId, user, loginMethod], From 4353af5c8e2b3c071bbd409e6ff92c35e2a1cdd6 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 9 Jan 2024 20:37:15 -0300 Subject: [PATCH 24/34] getting close --- .../providers/UserProvider/UserProvider.tsx | 26 +++++++++++ apps/meteor/tests/e2e/oauth.spec.ts | 44 ++++++++++++++++--- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index cb3142b75d31b..f7ebbb017dad1 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -67,6 +67,32 @@ const find = (...args: any[]) => { return results; }; +window.hudell_debug_func = () => { + console.log('DEBUGOAUTH', new Date().toISOString(), 'run debug query'); + + console.log( + 'DEBUGOAUTH', + new Date().toISOString(), + 'debug_func', + JSON.stringify( + ServiceConfiguration.configurations + .find( + { + showButton: { $ne: false }, + }, + { + sort: { + service: 1, + }, + }, + ) + .fetch(), + ), + ); + + console.log('DEBUGOAUTH', new Date().toISOString()); +}; + const UserProvider = ({ children }: UserProviderProps): ReactElement => { const isLdapEnabled = useSetting('LDAP_Enable'); const isCrowdEnabled = useSetting('CROWD_Enable'); diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index 80c5d3ca44e30..bc40e22f7e432 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -1,16 +1,45 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Registration } from './page-objects'; import { setSettingValueById } from './utils/setSettingValueById'; import { test, expect } from './utils/test'; test.describe('OAuth', () => { let poRegistration: Registration; + let pageCount = 0; test.beforeEach(async ({ page }) => { page.on('websocket', (ws) => { - console.log('DEBUGOAUTH', new Date().toISOString(), `WebSocket opened: ${ws.url()}>`); - // ws.on('framesent', event => console.log('DEBUGOAUTH', new Date().toISOString(), 'WebSocket Frame Sent', event.payload)); - ws.on('framereceived', event => console.log('DEBUGOAUTH', new Date().toISOString(), 'WebSocket Frame Received', event.payload)); - ws.on('close', () => console.log('DEBUGOAUTH', new Date().toISOString(), 'WebSocket closed')); + let isClosed = false; + pageCount++; + const pageId = pageCount; + page.on('close', () => { isClosed = true; }); + + console.log('DEBUGOAUTH', pageId, new Date().toISOString(), `WebSocket opened: ${ws.url()}>`); + // ws.on('framesent', event => console.log('DEBUGOAUTH', pageId, new Date().toISOString(), 'WebSocket Frame Sent', event.payload)); + ws.on('framereceived', async event => { + console.log('DEBUGOAUTH', pageId, new Date().toISOString(), 'WebSocket Frame Received', event.payload) + if (event.payload.includes('loginServiceConfiguration')) { + if (isClosed) { + console.log('DEBUGOAUTH', pageId, new Date().toISOString(), 'page closed'); + return; + } + await page.waitForTimeout(500); + + if (isClosed) { + console.log('DEBUGOAUTH', pageId, new Date().toISOString(), 'page closed'); + return; + } + await page.evaluate(() => { + console.log('DEBUGOAUTH - calling debug func'); + if ((window as any).hudell_debug_func) { + (window as any).hudell_debug_func(); + } else { + console.log('DEBUGOAUTH - debug func not found'); + } + }); + } + }); + ws.on('close', () => console.log('DEBUGOAUTH', pageId, new Date().toISOString(), 'WebSocket closed')); }); poRegistration = new Registration(page); @@ -18,19 +47,24 @@ test.describe('OAuth', () => { await page.goto('/home'); }); - test('Login Page', async ({ api }) => { + test('Login Page', async ({ page, api }) => { await test.step('expect OAuth button to be visible', async () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); + await page.waitForTimeout(3000); + console.log('DEBUGOAUTH', new Date().toISOString(), 'expect to be visible'); await expect(poRegistration.btnLoginWithGoogle).toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(3000); }); await test.step('expect OAuth button to not be visible', async () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); + await page.waitForTimeout(3000); console.log('DEBUGOAUTH', new Date().toISOString(), 'expect to not be visible'); await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(3000); }); }); }); \ No newline at end of file From 726979f78021fe0842771fef1d079d4a8ab3a4c9 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 9 Jan 2024 20:47:17 -0300 Subject: [PATCH 25/34] ts --- apps/meteor/client/providers/UserProvider/UserProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index f7ebbb017dad1..c4c98a9b61178 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -67,7 +67,7 @@ const find = (...args: any[]) => { return results; }; -window.hudell_debug_func = () => { +(window as any).hudell_debug_func = () => { console.log('DEBUGOAUTH', new Date().toISOString(), 'run debug query'); console.log( From b194acf3f35731317f56cd24ac89b37a7a317185 Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 10 Jan 2024 15:35:09 -0300 Subject: [PATCH 26/34] removed logs --- apps/meteor/app/api/server/v1/settings.ts | 4 -- .../client/models/CachedCollection.ts | 1 - .../providers/UserProvider/UserProvider.tsx | 69 +++++-------------- .../server/lib/oauth/addOAuthService.ts | 2 - .../lib/oauth/initCustomOAuthServices.ts | 2 - .../server/lib/oauth/removeOAuthService.ts | 1 - .../server/lib/oauth/updateOAuthServices.ts | 2 - .../models/raw/LoginServiceConfiguration.ts | 4 -- .../modules/watchers/watchers.module.ts | 1 - apps/meteor/server/services/meteor/service.ts | 2 - apps/meteor/tests/e2e/oauth.spec.ts | 44 +----------- ee/apps/ddp-streamer/src/DDPStreamer.ts | 1 - ee/apps/ddp-streamer/src/configureServer.ts | 4 -- 13 files changed, 21 insertions(+), 116 deletions(-) diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 7b8fd275c6a14..8a6078eeb0318 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -197,10 +197,6 @@ API.v1.addRoute( return API.v1.success(); } - if (setting._id === 'Accounts_OAuth_Google') { - console.log('DEBUGOAUTH', new Date().toISOString(), 'change setting', (this.bodyParams as any)?.value); - } - if ( isSettingsUpdatePropDefault(this.bodyParams) && (await Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value)) diff --git a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts index 9c970ccf697b2..ceba329c76faf 100644 --- a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts +++ b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts @@ -233,7 +233,6 @@ export class CachedCollection extends Emitter< async setupListener() { (Notifications[this.eventType] as any)(this.eventName, async (action: 'removed' | 'changed', record: any) => { - this.log('record received', action, record); const newRecord = this.handleReceived(record, action); if (!hasId(newRecord)) { diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index c4c98a9b61178..b6e30134cdaa8 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -59,40 +59,6 @@ type UserProviderProps = { children: ReactNode; }; -const find = (...args: any[]) => { - console.log('DEBUGOAUTH', new Date().toISOString(), 'find on service configurations'); - const results = ServiceConfiguration.configurations.find(...args).fetch(); - - console.log('DEBUGOAUTH', new Date().toISOString(), 'results', results?.length); - return results; -}; - -(window as any).hudell_debug_func = () => { - console.log('DEBUGOAUTH', new Date().toISOString(), 'run debug query'); - - console.log( - 'DEBUGOAUTH', - new Date().toISOString(), - 'debug_func', - JSON.stringify( - ServiceConfiguration.configurations - .find( - { - showButton: { $ne: false }, - }, - { - sort: { - service: 1, - }, - }, - ) - .fetch(), - ), - ); - - console.log('DEBUGOAUTH', new Date().toISOString()); -}; - const UserProvider = ({ children }: UserProviderProps): ReactElement => { const isLdapEnabled = useSetting('LDAP_Enable'); const isCrowdEnabled = useSetting('CROWD_Enable'); @@ -176,23 +142,26 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { }); }, queryAllServices: createReactiveSubscriptionFactory(() => - find( - { - showButton: { $ne: false }, - }, - { - sort: { - service: 1, + ServiceConfiguration.configurations + .find( + { + showButton: { $ne: false }, }, - }, - ).map( - ({ appId: _, ...service }) => - ({ - title: capitalize(String((service as any).service || '')), - ...service, - ...(config[(service as any).service] ?? {}), - } as any), - ), + { + sort: { + service: 1, + }, + }, + ) + .fetch() + .map( + ({ appId: _, ...service }) => + ({ + title: capitalize(String((service as any).service || '')), + ...service, + ...(config[(service as any).service] ?? {}), + } as any), + ), ), }), [userId, user, loginMethod], diff --git a/apps/meteor/server/lib/oauth/addOAuthService.ts b/apps/meteor/server/lib/oauth/addOAuthService.ts index 6746d12df8143..2a49a23a1f4e1 100644 --- a/apps/meteor/server/lib/oauth/addOAuthService.ts +++ b/apps/meteor/server/lib/oauth/addOAuthService.ts @@ -5,8 +5,6 @@ import { capitalize } from '@rocket.chat/string-helpers'; import { settingsRegistry } from '../../../app/settings/server'; export async function addOAuthService(name: string, values: { [k: string]: string | boolean | undefined } = {}): Promise { - console.log('DEBUGOAUTH', new Date().toISOString(), 'addOAuthService', name); - name = name.toLowerCase().replace(/[^a-z0-9_]/g, ''); name = capitalize(name); await settingsRegistry.add(`Accounts_OAuth_Custom-${name}`, values.enabled || false, { diff --git a/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts index 960bee9a76b62..3c909f6bc1f12 100644 --- a/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts @@ -1,8 +1,6 @@ import { addOAuthService } from './addOAuthService'; export async function initCustomOAuthServices(): Promise { - console.log('DEBUGOAUTH', new Date().toISOString(), 'initCustomOAuthServices'); - // Add settings for custom OAuth providers to the settings so they get // automatically added when they are defined in ENV variables for await (const key of Object.keys(process.env)) { diff --git a/apps/meteor/server/lib/oauth/removeOAuthService.ts b/apps/meteor/server/lib/oauth/removeOAuthService.ts index 7aa4a937e9673..383a5acffd924 100644 --- a/apps/meteor/server/lib/oauth/removeOAuthService.ts +++ b/apps/meteor/server/lib/oauth/removeOAuthService.ts @@ -2,7 +2,6 @@ import { ServiceConfiguration } from 'meteor/service-configuration'; export async function removeOAuthService(mainSettingId: string): Promise { const serviceName = mainSettingId.replace('Accounts_OAuth_Custom-', ''); - console.log('DEBUGOAUTH', new Date().toISOString(), 'removeOAuthService', serviceName); await ServiceConfiguration.configurations.removeAsync({ service: serviceName.toLowerCase(), diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts index c6e997a6f8992..ed0ae5977d0d8 100644 --- a/apps/meteor/server/lib/oauth/updateOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -12,8 +12,6 @@ import { settings } from '../../../app/settings/server/cached'; import { logger } from './logger'; export async function updateOAuthServices(): Promise { - console.log('DEBUGOAUTH', new Date().toISOString(), 'updateOAuthServices'); - const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); for await (const [key, value] of filteredServices) { diff --git a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts index 82d8520ca1f11..e5afa11164111 100644 --- a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts +++ b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts @@ -18,12 +18,10 @@ export class LoginServiceConfigurationRaw extends BaseRaw, ): Promise { - console.log('DEBUGOAUTH', new Date().toISOString(), 'createOrUpdateService', serviceName); const service = serviceName.toLowerCase(); const existing = await this.findOne({ service }); if (!existing) { - console.log('DEBUGOAUTH', new Date().toISOString(), 'createOrUpdateService', 'INSERT'); const insertResult = await this.insertOne({ service, ...serviceData, @@ -33,7 +31,6 @@ export class LoginServiceConfigurationRaw extends BaseRaw 0) { - console.log('DEBUGOAUTH', new Date().toISOString(), 'createOrUpdateService', 'UPDATE'); await this.updateOne( { _id: existing._id, @@ -48,7 +45,6 @@ export class LoginServiceConfigurationRaw extends BaseRaw { - console.log('DEBUGOAUTH', new Date().toISOString(), 'removeService', serviceName); const service = serviceName.toLowerCase(); return this.deleteOne({ service }); diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index f1606f0a1857d..3bdbd6fee1e79 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -339,7 +339,6 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb }); watcher.on(LoginServiceConfiguration.getCollectionName(), async ({ clientAction, id }) => { - console.log('DEBUGOAUTH', new Date().toISOString(), 'watcher', clientAction, id); if (clientAction === 'removed') { void broadcast('watch.loginServiceConfiguration', { clientAction, id }); return; diff --git a/apps/meteor/server/services/meteor/service.ts b/apps/meteor/server/services/meteor/service.ts index 806ee20bf9454..95d2061e2f679 100644 --- a/apps/meteor/server/services/meteor/service.ts +++ b/apps/meteor/server/services/meteor/service.ts @@ -145,8 +145,6 @@ export class MeteorService extends ServiceClassInternal implements IMeteor { if (disableOplog) { this.onEvent('watch.loginServiceConfiguration', ({ clientAction, id, data }) => { - console.log('DEBUGOAUTH', new Date().toISOString(), 'meteor service event', clientAction, id); - if (clientAction === 'removed') { serviceConfigCallbacks.forEach((callbacks) => { callbacks.removed?.(id); diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index bc40e22f7e432..10b3887f55a7b 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -5,43 +5,8 @@ import { test, expect } from './utils/test'; test.describe('OAuth', () => { let poRegistration: Registration; - let pageCount = 0; test.beforeEach(async ({ page }) => { - page.on('websocket', (ws) => { - let isClosed = false; - pageCount++; - const pageId = pageCount; - page.on('close', () => { isClosed = true; }); - - console.log('DEBUGOAUTH', pageId, new Date().toISOString(), `WebSocket opened: ${ws.url()}>`); - // ws.on('framesent', event => console.log('DEBUGOAUTH', pageId, new Date().toISOString(), 'WebSocket Frame Sent', event.payload)); - ws.on('framereceived', async event => { - console.log('DEBUGOAUTH', pageId, new Date().toISOString(), 'WebSocket Frame Received', event.payload) - if (event.payload.includes('loginServiceConfiguration')) { - if (isClosed) { - console.log('DEBUGOAUTH', pageId, new Date().toISOString(), 'page closed'); - return; - } - await page.waitForTimeout(500); - - if (isClosed) { - console.log('DEBUGOAUTH', pageId, new Date().toISOString(), 'page closed'); - return; - } - await page.evaluate(() => { - console.log('DEBUGOAUTH - calling debug func'); - if ((window as any).hudell_debug_func) { - (window as any).hudell_debug_func(); - } else { - console.log('DEBUGOAUTH - debug func not found'); - } - }); - } - }); - ws.on('close', () => console.log('DEBUGOAUTH', pageId, new Date().toISOString(), 'WebSocket closed')); - }); - poRegistration = new Registration(page); await page.goto('/home'); @@ -52,19 +17,14 @@ test.describe('OAuth', () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); await page.waitForTimeout(3000); - console.log('DEBUGOAUTH', new Date().toISOString(), 'expect to be visible'); - - await expect(poRegistration.btnLoginWithGoogle).toBeVisible({ timeout: 10000 }); - await page.waitForTimeout(3000); + await expect(poRegistration.btnLoginWithGoogle).toBeVisible(); }); await test.step('expect OAuth button to not be visible', async () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); await page.waitForTimeout(3000); - console.log('DEBUGOAUTH', new Date().toISOString(), 'expect to not be visible'); - await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible({ timeout: 10000 }); - await page.waitForTimeout(3000); + await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); }); }); }); \ No newline at end of file diff --git a/ee/apps/ddp-streamer/src/DDPStreamer.ts b/ee/apps/ddp-streamer/src/DDPStreamer.ts index 88d9ae6fd50da..79905fc8206d9 100644 --- a/ee/apps/ddp-streamer/src/DDPStreamer.ts +++ b/ee/apps/ddp-streamer/src/DDPStreamer.ts @@ -37,7 +37,6 @@ export class DDPStreamer extends ServiceClass { }); this.onEvent('watch.loginServiceConfiguration', ({ clientAction, id, data }) => { - console.log('DEBUGOAUTH', new Date().toISOString(), 'ddp-streamer event', clientAction, id); if (clientAction === 'removed') { events.emit('meteor.loginServiceConfiguration', 'removed', { _id: id, diff --git a/ee/apps/ddp-streamer/src/configureServer.ts b/ee/apps/ddp-streamer/src/configureServer.ts index ae3d0ac258f07..ed187db498cc3 100644 --- a/ee/apps/ddp-streamer/src/configureServer.ts +++ b/ee/apps/ddp-streamer/src/configureServer.ts @@ -20,12 +20,9 @@ MeteorService.getLoginServiceConfiguration() .catch((err) => console.error('DDPStreamer not able to retrieve login services configuration', err)); server.publish(loginServiceConfigurationPublication, async function () { - console.log('DEBUGOAUTH', new Date().toISOString(), 'publication'); loginServices.forEach((record) => this.added(loginServiceConfigurationCollection, record._id, record)); const fn = (action: string, record: any): void => { - console.log('DEBUGOAUTH', new Date().toISOString(), 'callback', action); - switch (action) { case 'added': case 'changed': @@ -41,7 +38,6 @@ server.publish(loginServiceConfigurationPublication, async function () { events.on(loginServiceConfigurationPublication, fn); this.onStop(() => { - console.log('DEBUGOAUTH', new Date().toISOString(), 'remove listener'); events.removeListener(loginServiceConfigurationPublication, fn); }); From 40bb2c90c403ae150740ad0cf46e6015d8b66f28 Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 10 Jan 2024 15:44:20 -0300 Subject: [PATCH 27/34] configure oauth before page load --- apps/meteor/tests/e2e/oauth.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index 10b3887f55a7b..5164e7f1f8708 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -8,22 +8,23 @@ test.describe('OAuth', () => { test.beforeEach(async ({ page }) => { poRegistration = new Registration(page); - - await page.goto('/home'); }); test('Login Page', async ({ page, api }) => { await test.step('expect OAuth button to be visible', async () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); - await page.waitForTimeout(3000); + await page.waitForTimeout(5000); + + await page.goto('/home'); await expect(poRegistration.btnLoginWithGoogle).toBeVisible(); }); await test.step('expect OAuth button to not be visible', async () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); - await page.waitForTimeout(3000); + await page.waitForTimeout(5000); + await page.goto('/home'); await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); }); }); From aaa9001742b0f864aefa42587b35b8f3f0a0f4dd Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 12 Jan 2024 13:25:42 -0300 Subject: [PATCH 28/34] moved existing authentication code to a new provider --- .../providers/AuthenticationProvider.tsx | 122 ++++++++++++++++++ .../client/providers/MeteorProvider.tsx | 45 ++++--- .../providers/UserProvider/UserProvider.tsx | 100 +------------- .../hooks/useLDAPAndCrowdCollisionWarning.tsx | 2 +- .../src/MockedAppRootBuilder.tsx | 4 - .../mock-providers/src/MockedUserContext.tsx | 6 - .../ui-contexts/src/AuthenticationContext.ts | 22 ++++ packages/ui-contexts/src/UserContext.ts | 21 --- .../ui-contexts/src/hooks/useLoginServices.ts | 6 +- .../src/hooks/useLoginWithPassword.ts | 4 +- .../src/hooks/useLoginWithService.ts | 8 +- .../src/hooks/useLoginWithToken.ts | 4 +- packages/ui-contexts/src/index.ts | 3 +- .../src/LoginServicesButton.tsx | 3 +- 14 files changed, 186 insertions(+), 164 deletions(-) create mode 100644 apps/meteor/client/providers/AuthenticationProvider.tsx create mode 100644 packages/ui-contexts/src/AuthenticationContext.ts diff --git a/apps/meteor/client/providers/AuthenticationProvider.tsx b/apps/meteor/client/providers/AuthenticationProvider.tsx new file mode 100644 index 0000000000000..e36902140e2aa --- /dev/null +++ b/apps/meteor/client/providers/AuthenticationProvider.tsx @@ -0,0 +1,122 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import type { LoginService } from '@rocket.chat/ui-contexts'; +import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts'; +import { Meteor } from 'meteor/meteor'; +import type { ContextType, ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; + +import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory'; +import { useLDAPAndCrowdCollisionWarning } from './UserProvider/hooks/useLDAPAndCrowdCollisionWarning'; + +const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); + +const config: Record> = { + 'apple': { title: 'Apple', icon: 'apple' }, + 'facebook': { title: 'Facebook', icon: 'facebook' }, + 'twitter': { title: 'Twitter', icon: 'twitter' }, + 'google': { title: 'Google', icon: 'google' }, + 'github': { title: 'Github', icon: 'github' }, + 'github_enterprise': { title: 'Github Enterprise', icon: 'github' }, + 'gitlab': { title: 'Gitlab', icon: 'gitlab' }, + 'dolphin': { title: 'Dolphin', icon: 'dophin' }, + 'drupal': { title: 'Drupal', icon: 'drupal' }, + 'nextcloud': { title: 'Nextcloud', icon: 'nextcloud' }, + 'tokenpass': { title: 'Tokenpass', icon: 'tokenpass' }, + 'meteor-developer': { title: 'Meteor', icon: 'meteor' }, + 'wordpress': { title: 'WordPress', icon: 'wordpress' }, + 'linkedin': { title: 'Linkedin', icon: 'linkedin' }, +}; + +export type LoginMethods = keyof typeof Meteor extends infer T ? (T extends `loginWith${string}` ? T : never) : never; + +type UserProviderProps = { + children: ReactNode; +}; + +const UserProvider = ({ children }: UserProviderProps): ReactElement => { + const isLdapEnabled = useSetting('LDAP_Enable'); + const isCrowdEnabled = useSetting('CROWD_Enable'); + + const loginMethod: LoginMethods = (isLdapEnabled && 'loginWithLDAP') || (isCrowdEnabled && 'loginWithCrowd') || 'loginWithPassword'; + + useLDAPAndCrowdCollisionWarning(); + + const contextValue = useMemo( + (): ContextType => ({ + loginWithToken: (token: string): Promise => + new Promise((resolve, reject) => + Meteor.loginWithToken(token, (err) => { + if (err) { + return reject(err); + } + resolve(undefined); + }), + ), + loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string): Promise => + new Promise((resolve, reject) => { + Meteor[loginMethod](user, password, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }), + loginWithService: (serviceConfig: T): (() => Promise) => { + const loginMethods: Record = { + 'meteor-developer': 'MeteorDeveloperAccount', + }; + + const { service: serviceName } = serviceConfig; + const clientConfig = ('clientConfig' in serviceConfig && serviceConfig.clientConfig) || {}; + + const loginWithService = `loginWith${loginMethods[serviceName] || capitalize(String(serviceName || ''))}`; + + const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService] as any; + + if (!method) { + return () => Promise.reject(new Error('Login method not found')); + } + + return () => + new Promise((resolve, reject) => { + method(clientConfig, (error: any): void => { + if (!error) { + resolve(true); + return; + } + reject(error); + }); + }); + }, + queryAllServices: createReactiveSubscriptionFactory(() => + ServiceConfiguration.configurations + .find( + { + showButton: { $ne: false }, + }, + { + sort: { + service: 1, + }, + }, + ) + .fetch() + .map( + ({ appId: _, ...service }) => + ({ + title: capitalize(String((service as any).service || '')), + ...service, + ...(config[(service as any).service] ?? {}), + } as any), + ), + ), + }), + [loginMethod], + ); + + return ; +}; + +export default UserProvider; diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index aa12af9055210..37bccd11c1b52 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { OmnichannelRoomIconProvider } from '../components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider'; import ActionManagerProvider from './ActionManagerProvider'; +import AuthenticationProvider from './AuthenticationProvider'; import AuthorizationProvider from './AuthorizationProvider'; import AvatarUrlProvider from './AvatarUrlProvider'; import { CallProvider } from './CallProvider'; @@ -36,27 +37,29 @@ const MeteorProvider: FC = ({ children }) => ( - - - - - - - - - - - {children} - - - - - - - - - - + + + + + + + + + + + + {children} + + + + + + + + + + + diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index b6e30134cdaa8..2044ddc23842c 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -1,7 +1,7 @@ import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; -import type { LoginService, SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { UserContext, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import { UserContext, useEndpoint } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import type { ContextType, ReactElement, ReactNode } from 'react'; import React, { useEffect, useMemo } from 'react'; @@ -14,31 +14,11 @@ import { useReactiveValue } from '../../hooks/useReactiveValue'; import { createReactiveSubscriptionFactory } from '../../lib/createReactiveSubscriptionFactory'; import { useCreateFontStyleElement } from '../../views/account/accessibility/hooks/useCreateFontStyleElement'; import { useEmailVerificationWarning } from './hooks/useEmailVerificationWarning'; -import { useLDAPAndCrowdCollisionWarning } from './hooks/useLDAPAndCrowdCollisionWarning'; const getUserId = (): string | null => Meteor.userId(); const getUser = (): IUser | null => Meteor.user() as IUser | null; -const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); - -const config: Record> = { - 'apple': { title: 'Apple', icon: 'apple' }, - 'facebook': { title: 'Facebook', icon: 'facebook' }, - 'twitter': { title: 'Twitter', icon: 'twitter' }, - 'google': { title: 'Google', icon: 'google' }, - 'github': { title: 'Github', icon: 'github' }, - 'github_enterprise': { title: 'Github Enterprise', icon: 'github' }, - 'gitlab': { title: 'Gitlab', icon: 'gitlab' }, - 'dolphin': { title: 'Dolphin', icon: 'dophin' }, - 'drupal': { title: 'Drupal', icon: 'drupal' }, - 'nextcloud': { title: 'Nextcloud', icon: 'nextcloud' }, - 'tokenpass': { title: 'Tokenpass', icon: 'tokenpass' }, - 'meteor-developer': { title: 'Meteor', icon: 'meteor' }, - 'wordpress': { title: 'WordPress', icon: 'wordpress' }, - 'linkedin': { title: 'Linkedin', icon: 'linkedin' }, -}; - const logout = (): Promise => new Promise((resolve, reject) => { const user = getUser(); @@ -53,16 +33,11 @@ const logout = (): Promise => }); }); -export type LoginMethods = keyof typeof Meteor extends infer T ? (T extends `loginWith${string}` ? T : never) : never; - type UserProviderProps = { children: ReactNode; }; const UserProvider = ({ children }: UserProviderProps): ReactElement => { - const isLdapEnabled = useSetting('LDAP_Enable'); - const isCrowdEnabled = useSetting('CROWD_Enable'); - const userId = useReactiveValue(getUserId); const user = useReactiveValue(getUser); const [userLanguage, setUserLanguage] = useLocalStorage('userLanguage', ''); @@ -73,9 +48,6 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { const createFontStyleElement = useCreateFontStyleElement(); createFontStyleElement(user?.settings?.preferences?.fontSize); - const loginMethod: LoginMethods = (isLdapEnabled && 'loginWithLDAP') || (isCrowdEnabled && 'loginWithCrowd') || 'loginWithPassword'; - - useLDAPAndCrowdCollisionWarning(); useEmailVerificationWarning(user ?? undefined); const contextValue = useMemo( @@ -96,75 +68,9 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { return ChatRoom.find(query, options).fetch(); }), - loginWithToken: (token: string): Promise => - new Promise((resolve, reject) => - Meteor.loginWithToken(token, (err) => { - if (err) { - return reject(err); - } - resolve(undefined); - }), - ), - loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string): Promise => - new Promise((resolve, reject) => { - Meteor[loginMethod](user, password, (error) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }), logout, - loginWithService: ({ service, clientConfig = {} }: T): (() => Promise) => { - const loginMethods = { - 'meteor-developer': 'MeteorDeveloperAccount', - } as const; - - const loginWithService = `loginWith${loginMethods[service] || capitalize(String(service || ''))}`; - - const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService] as any; - - if (!method) { - return () => Promise.reject(new Error('Login method not found')); - } - - return () => - new Promise((resolve, reject) => { - method(clientConfig, (error: any): void => { - if (!error) { - resolve(true); - return; - } - reject(error); - }); - }); - }, - queryAllServices: createReactiveSubscriptionFactory(() => - ServiceConfiguration.configurations - .find( - { - showButton: { $ne: false }, - }, - { - sort: { - service: 1, - }, - }, - ) - .fetch() - .map( - ({ appId: _, ...service }) => - ({ - title: capitalize(String((service as any).service || '')), - ...service, - ...(config[(service as any).service] ?? {}), - } as any), - ), - ), }), - [userId, user, loginMethod], + [userId, user], ); useEffect(() => { diff --git a/apps/meteor/client/providers/UserProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx b/apps/meteor/client/providers/UserProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx index fbcabc2825fbe..c4b8c765485e0 100644 --- a/apps/meteor/client/providers/UserProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx +++ b/apps/meteor/client/providers/UserProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx @@ -2,7 +2,7 @@ import { useSetting } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { useEffect } from 'react'; -import type { LoginMethods } from '../UserProvider'; +import type { LoginMethods } from '../../AuthenticationProvider'; export function useLDAPAndCrowdCollisionWarning() { const isLdapEnabled = useSetting('LDAP_Enable'); diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 15a4db77eb10e..1ec9ff09c283c 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -77,11 +77,7 @@ export class MockedAppRootBuilder { }; private user: ContextType = { - loginWithPassword: () => Promise.reject(new Error('not implemented')), logout: () => Promise.reject(new Error('not implemented')), - loginWithService: () => () => Promise.reject(new Error('not implemented')), - loginWithToken: () => Promise.reject(new Error('not implemented')), - queryAllServices: () => [() => () => undefined, () => []], queryPreference: () => [() => () => undefined, () => undefined], queryRoom: () => [() => () => undefined, () => undefined], querySubscription: () => [() => () => undefined, () => undefined], diff --git a/packages/mock-providers/src/MockedUserContext.tsx b/packages/mock-providers/src/MockedUserContext.tsx index 10abe3915b423..9fca50c0f9cd8 100644 --- a/packages/mock-providers/src/MockedUserContext.tsx +++ b/packages/mock-providers/src/MockedUserContext.tsx @@ -1,4 +1,3 @@ -import type { LoginService } from '@rocket.chat/ui-contexts'; import { UserContext } from '@rocket.chat/ui-contexts'; import React from 'react'; import type { ContextType } from 'react'; @@ -22,11 +21,6 @@ const userContextValue: ContextType = { querySubscriptions: () => [() => () => undefined, () => []], querySubscription: () => [() => () => undefined, () => undefined], queryRoom: () => [() => () => undefined, () => undefined], - - queryAllServices: () => [() => (): void => undefined, (): LoginService[] => []], - loginWithService: () => () => Promise.reject('loginWithService not implemented'), - loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), - loginWithToken: async () => Promise.reject('loginWithToken not implemented'), logout: () => Promise.resolve(), }; diff --git a/packages/ui-contexts/src/AuthenticationContext.ts b/packages/ui-contexts/src/AuthenticationContext.ts new file mode 100644 index 0000000000000..98ea11cbaea43 --- /dev/null +++ b/packages/ui-contexts/src/AuthenticationContext.ts @@ -0,0 +1,22 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { createContext } from 'react'; + +export type LoginService = LoginServiceConfiguration & { + icon?: string; + title?: string; +}; + +export type AuthenticationContextValue = { + loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string) => Promise; + loginWithToken: (user: string) => Promise; + + queryAllServices(): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => LoginService[]]; + loginWithService(service: T): () => Promise; +}; + +export const AuthenticationContext = createContext({ + queryAllServices: () => [() => (): void => undefined, (): LoginService[] => []], + loginWithService: () => () => Promise.reject('loginWithService not implemented'), + loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), + loginWithToken: async () => Promise.reject('loginWithToken not implemented'), +}); diff --git a/packages/ui-contexts/src/UserContext.ts b/packages/ui-contexts/src/UserContext.ts index 14b9644e6a3c4..001df9ab8ecdd 100644 --- a/packages/ui-contexts/src/UserContext.ts +++ b/packages/ui-contexts/src/UserContext.ts @@ -25,16 +25,6 @@ export type FindOptions = { sort?: Sort; }; -export type LoginService = { - clientConfig: unknown; - - title: string; - service: 'meteor-developer'; - - buttonLabelText?: string; - icon?: string; -}; - export type UserContextValue = { userId: string | null; user: IUser | null; @@ -56,13 +46,7 @@ export type UserContextValue = { query: SubscriptionQuery, options?: FindOptions, ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => SubscriptionWithRoom[]]; - - loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string) => Promise; - loginWithToken: (user: string) => Promise; logout: () => Promise; - - queryAllServices(): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => LoginService[]]; - loginWithService(service: T): () => Promise; }; export const UserContext = createContext({ @@ -72,10 +56,5 @@ export const UserContext = createContext({ querySubscription: () => [() => (): void => undefined, (): undefined => undefined], queryRoom: () => [() => (): void => undefined, (): undefined => undefined], querySubscriptions: () => [() => (): void => undefined, (): [] => []], - - queryAllServices: () => [() => (): void => undefined, (): LoginService[] => []], - loginWithService: () => () => Promise.reject('loginWithService not implemented'), - loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), - loginWithToken: async () => Promise.reject('loginWithToken not implemented'), logout: () => Promise.resolve(), }); diff --git a/packages/ui-contexts/src/hooks/useLoginServices.ts b/packages/ui-contexts/src/hooks/useLoginServices.ts index e14812bee04bd..6cfcb2cf17472 100644 --- a/packages/ui-contexts/src/hooks/useLoginServices.ts +++ b/packages/ui-contexts/src/hooks/useLoginServices.ts @@ -1,11 +1,11 @@ import { useContext, useMemo } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import type { LoginService } from '../UserContext'; -import { UserContext } from '../UserContext'; +import type { LoginService } from '../AuthenticationContext'; +import { AuthenticationContext } from '../AuthenticationContext'; export const useLoginServices = (): LoginService[] => { - const { queryAllServices } = useContext(UserContext); + const { queryAllServices } = useContext(AuthenticationContext); const [subscribe, getSnapshot] = useMemo(() => queryAllServices(), [queryAllServices]); return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useLoginWithPassword.ts b/packages/ui-contexts/src/hooks/useLoginWithPassword.ts index 8ca2cc0b07dee..fc0e01418ff8d 100644 --- a/packages/ui-contexts/src/hooks/useLoginWithPassword.ts +++ b/packages/ui-contexts/src/hooks/useLoginWithPassword.ts @@ -1,8 +1,8 @@ import { useContext } from 'react'; -import { UserContext } from '../UserContext'; +import { AuthenticationContext } from '../AuthenticationContext'; export const useLoginWithPassword = (): (( user: string | { username: string } | { email: string } | { id: string }, password: string, -) => Promise) => useContext(UserContext).loginWithPassword; +) => Promise) => useContext(AuthenticationContext).loginWithPassword; diff --git a/packages/ui-contexts/src/hooks/useLoginWithService.ts b/packages/ui-contexts/src/hooks/useLoginWithService.ts index 58320ca9db444..c3df01f8eed65 100644 --- a/packages/ui-contexts/src/hooks/useLoginWithService.ts +++ b/packages/ui-contexts/src/hooks/useLoginWithService.ts @@ -1,10 +1,10 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; import { useContext, useMemo } from 'react'; -import type { LoginService } from '../UserContext'; -import { UserContext } from '../UserContext'; +import { AuthenticationContext } from '../AuthenticationContext'; -export const useLoginWithService = (service: T): (() => Promise) => { - const { loginWithService } = useContext(UserContext); +export const useLoginWithService = (service: T): (() => Promise) => { + const { loginWithService } = useContext(AuthenticationContext); return useMemo(() => { return loginWithService(service); diff --git a/packages/ui-contexts/src/hooks/useLoginWithToken.ts b/packages/ui-contexts/src/hooks/useLoginWithToken.ts index 68efde730ad00..92c3c78a23a60 100644 --- a/packages/ui-contexts/src/hooks/useLoginWithToken.ts +++ b/packages/ui-contexts/src/hooks/useLoginWithToken.ts @@ -1,5 +1,5 @@ import { useContext } from 'react'; -import { UserContext } from '../UserContext'; +import { AuthenticationContext } from '../AuthenticationContext'; -export const useLoginWithToken = (): ((token: string) => Promise) => useContext(UserContext).loginWithToken; +export const useLoginWithToken = (): ((token: string) => Promise) => useContext(AuthenticationContext).loginWithToken; diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index fb2f2b84d3777..0870eb4417c8a 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -1,4 +1,5 @@ export { AttachmentContext, AttachmentContextValue } from './AttachmentContext'; +export { AuthenticationContextValue, AuthenticationContext, LoginService } from './AuthenticationContext'; export { AuthorizationContext, AuthorizationContextValue } from './AuthorizationContext'; export { AvatarUrlContext, AvatarUrlContextValue } from './AvatarUrlContext'; export { ConnectionStatusContext, ConnectionStatusContextValue } from './ConnectionStatusContext'; @@ -12,7 +13,7 @@ export { SettingsContext, SettingsContextValue, SettingsContextQuery } from './S export { ToastMessagesContext, ToastMessagesContextValue } from './ToastMessagesContext'; export { TooltipContext, TooltipContextValue } from './TooltipContext'; export { TranslationContext, TranslationContextValue } from './TranslationContext'; -export { UserContext, UserContextValue, LoginService } from './UserContext'; +export { UserContext, UserContextValue } from './UserContext'; export { DeviceContext, Device, IExperimentalHTMLAudioElement, DeviceContextValue } from './DeviceContext'; export { ActionManagerContext, IActionManager } from './ActionManagerContext'; diff --git a/packages/web-ui-registration/src/LoginServicesButton.tsx b/packages/web-ui-registration/src/LoginServicesButton.tsx index 92b78bbb64f81..cdba5e26474e9 100644 --- a/packages/web-ui-registration/src/LoginServicesButton.tsx +++ b/packages/web-ui-registration/src/LoginServicesButton.tsx @@ -11,7 +11,6 @@ const LoginServicesButton = ({ buttonLabelText, icon, title, - clientConfig, service, className, disabled, @@ -23,7 +22,7 @@ const LoginServicesButton = ({ setError?: Dispatch>; }): ReactElement => { const t = useTranslation(); - const handler = useLoginWithService({ service, buttonLabelText, title, clientConfig, ...props }); + const handler = useLoginWithService({ service, buttonLabelText, ...props }); const handleOnClick = useCallback(() => { handler().catch((e: { error?: LoginErrors }) => { From 328d2a4cfd7c6e4b189c7cb048530d3c056ca81d Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 12 Jan 2024 15:00:42 -0300 Subject: [PATCH 29/34] fix 'service.configurations' endpoint definition --- apps/meteor/app/api/server/v1/settings.ts | 4 ++-- packages/rest-typings/src/v1/settings.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 8a6078eeb0318..cab1511b3b341 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -7,7 +7,7 @@ import type { TwitterOAuthConfiguration, } from '@rocket.chat/core-typings'; import { isSettingAction, isSettingColor } from '@rocket.chat/core-typings'; -import { Settings } from '@rocket.chat/models'; +import { LoginServiceConfiguration as LoginServiceConfigurationModel, Settings } from '@rocket.chat/models'; import { isSettingsUpdatePropDefault, isSettingsUpdatePropsActions, isSettingsUpdatePropsColor } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; @@ -222,7 +222,7 @@ API.v1.addRoute( { async get() { return API.v1.success({ - configurations: await ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetchAsync(), + configurations: await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(), }); }, }, diff --git a/packages/rest-typings/src/v1/settings.ts b/packages/rest-typings/src/v1/settings.ts index 9288aa417c619..6da53c04ae684 100644 --- a/packages/rest-typings/src/v1/settings.ts +++ b/packages/rest-typings/src/v1/settings.ts @@ -55,10 +55,7 @@ export type SettingsEndpoints = { '/v1/service.configurations': { GET: () => { - configurations: Array<{ - appId: string; - secret: string; - }>; + configurations: Array; }; }; }; From b54e487045842bff68224d19b7f91a459082eb45 Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 12 Jan 2024 15:14:06 -0300 Subject: [PATCH 30/34] load login buttons from the rest API instead of the local collection --- .../providers/AuthenticationProvider.tsx | 56 ++++++++++--------- .../ui-contexts/src/AuthenticationContext.ts | 4 +- .../ui-contexts/src/hooks/useLoginServices.ts | 11 ---- packages/ui-contexts/src/index.ts | 1 - .../web-ui-registration/src/LoginServices.tsx | 6 +- 5 files changed, 37 insertions(+), 41 deletions(-) delete mode 100644 packages/ui-contexts/src/hooks/useLoginServices.ts diff --git a/apps/meteor/client/providers/AuthenticationProvider.tsx b/apps/meteor/client/providers/AuthenticationProvider.tsx index e36902140e2aa..26e6e640f5d75 100644 --- a/apps/meteor/client/providers/AuthenticationProvider.tsx +++ b/apps/meteor/client/providers/AuthenticationProvider.tsx @@ -1,11 +1,11 @@ import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; import type { LoginService } from '@rocket.chat/ui-contexts'; -import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts'; +import { AuthenticationContext, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; import { Meteor } from 'meteor/meteor'; import type { ContextType, ReactElement, ReactNode } from 'react'; import React, { useMemo } from 'react'; -import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory'; import { useLDAPAndCrowdCollisionWarning } from './UserProvider/hooks/useLDAPAndCrowdCollisionWarning'; const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); @@ -34,6 +34,12 @@ type UserProviderProps = { }; const UserProvider = ({ children }: UserProviderProps): ReactElement => { + const getServiceConfigurations = useEndpoint('GET', '/v1/service.configurations'); + + const { data: services } = useQuery(['service.configurations'], () => getServiceConfigurations(), { + staleTime: Infinity, + }); + const isLdapEnabled = useSetting('LDAP_Enable'); const isCrowdEnabled = useSetting('CROWD_Enable'); @@ -90,30 +96,30 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { }); }); }, - queryAllServices: createReactiveSubscriptionFactory(() => - ServiceConfiguration.configurations - .find( - { - showButton: { $ne: false }, - }, - { - sort: { - service: 1, - }, - }, - ) - .fetch() - .map( - ({ appId: _, ...service }) => - ({ - title: capitalize(String((service as any).service || '')), - ...service, - ...(config[(service as any).service] ?? {}), - } as any), - ), - ), + getLoginServices: () => { + const loginServices: LoginServiceConfiguration[] = + services?.configurations.filter((config) => !('showButton' in config) || config.showButton !== false) || []; + + return loginServices + .sort(({ service: service1 }, { service: service2 }) => service1.localeCompare(service2)) + .map((service) => { + const { appId: _, ...serviceData } = { + ...service, + appId: undefined, + }; + + const serviceConfig = config[service.service] || { + title: capitalize(service.service), + }; + + return { + ...serviceData, + ...serviceConfig, + }; + }); + }, }), - [loginMethod], + [loginMethod, services], ); return ; diff --git a/packages/ui-contexts/src/AuthenticationContext.ts b/packages/ui-contexts/src/AuthenticationContext.ts index 98ea11cbaea43..91865946f4ea7 100644 --- a/packages/ui-contexts/src/AuthenticationContext.ts +++ b/packages/ui-contexts/src/AuthenticationContext.ts @@ -10,12 +10,12 @@ export type AuthenticationContextValue = { loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string) => Promise; loginWithToken: (user: string) => Promise; - queryAllServices(): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => LoginService[]]; + getLoginServices: () => LoginService[]; loginWithService(service: T): () => Promise; }; export const AuthenticationContext = createContext({ - queryAllServices: () => [() => (): void => undefined, (): LoginService[] => []], + getLoginServices: () => [], loginWithService: () => () => Promise.reject('loginWithService not implemented'), loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), loginWithToken: async () => Promise.reject('loginWithToken not implemented'), diff --git a/packages/ui-contexts/src/hooks/useLoginServices.ts b/packages/ui-contexts/src/hooks/useLoginServices.ts deleted file mode 100644 index 6cfcb2cf17472..0000000000000 --- a/packages/ui-contexts/src/hooks/useLoginServices.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext, useMemo } from 'react'; -import { useSyncExternalStore } from 'use-sync-external-store/shim'; - -import type { LoginService } from '../AuthenticationContext'; -import { AuthenticationContext } from '../AuthenticationContext'; - -export const useLoginServices = (): LoginService[] => { - const { queryAllServices } = useContext(AuthenticationContext); - const [subscribe, getSnapshot] = useMemo(() => queryAllServices(), [queryAllServices]); - return useSyncExternalStore(subscribe, getSnapshot); -}; diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index 0870eb4417c8a..b54484f5988ce 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -42,7 +42,6 @@ export { useLayoutSizes } from './hooks/useLayoutSizes'; export { useLayoutHiddenActions } from './hooks/useLayoutHiddenActions'; export { useLoadLanguage } from './hooks/useLoadLanguage'; export { useLoginWithPassword } from './hooks/useLoginWithPassword'; -export { useLoginServices } from './hooks/useLoginServices'; export { useLoginWithService } from './hooks/useLoginWithService'; export { useLoginWithToken } from './hooks/useLoginWithToken'; export { useLogout } from './hooks/useLogout'; diff --git a/packages/web-ui-registration/src/LoginServices.tsx b/packages/web-ui-registration/src/LoginServices.tsx index 530dce7e9438b..01260de67c907 100644 --- a/packages/web-ui-registration/src/LoginServices.tsx +++ b/packages/web-ui-registration/src/LoginServices.tsx @@ -1,5 +1,6 @@ import { ButtonGroup, Divider } from '@rocket.chat/fuselage'; -import { useLoginServices, useSetting } from '@rocket.chat/ui-contexts'; +import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts'; +import { useContext } from 'react'; import type { Dispatch, ReactElement, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,7 +15,8 @@ const LoginServices = ({ setError: Dispatch>; }): ReactElement | null => { const { t } = useTranslation(); - const services = useLoginServices(); + const { getLoginServices } = useContext(AuthenticationContext); + const services = getLoginServices(); const showFormLogin = useSetting('Accounts_ShowFormLogin'); if (services.length === 0) { From 6827114fd3a3513345906fd4cac7d7c378286e48 Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 12 Jan 2024 15:42:57 -0300 Subject: [PATCH 31/34] missed references --- apps/meteor/client/sidebar/Sidebar.stories.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/meteor/client/sidebar/Sidebar.stories.tsx b/apps/meteor/client/sidebar/Sidebar.stories.tsx index f147ed86b4e40..d8c5788bae863 100644 --- a/apps/meteor/client/sidebar/Sidebar.stories.tsx +++ b/apps/meteor/client/sidebar/Sidebar.stories.tsx @@ -1,5 +1,5 @@ import type { ISetting } from '@rocket.chat/core-typings'; -import type { LoginService, SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { UserContext, SettingsContext } from '@rocket.chat/ui-contexts'; import type { Meta, Story } from '@storybook/react'; import type { ObjectId } from 'mongodb'; @@ -98,10 +98,6 @@ const userContextValue: ContextType = { querySubscription: () => [() => () => undefined, () => undefined], queryRoom: () => [() => () => undefined, () => undefined], - queryAllServices: () => [() => (): void => undefined, (): LoginService[] => []], - loginWithService: () => () => Promise.reject('loginWithService not implemented'), - loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), - loginWithToken: async () => Promise.reject('loginWithToken not implemented'), logout: () => Promise.resolve(), }; From d3a59a20c4c9ab4f3f46c5ddf7e4930a8655df60 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Thu, 18 Jan 2024 18:27:46 -0300 Subject: [PATCH 32/34] chore: remove client references to the service configurations collection (#31452) * moved the `service.configurations` request to a dedicated class * removed duplicated utilitary function * remove client references to service configuration collection * fixed missing imports and invalid type * Moved linkedin's requestCredential to meteorOverrides as the old meteor package can't reference loginServices directly --- .../app/custom-oauth/client/CustomOAuth.ts | 8 +- apps/meteor/client/lib/loginServices.ts | 147 ++++++++++++++++++ .../client/lib/wrapRequestCredentialFn.ts | 52 +++++++ .../client/meteorOverrides/login/facebook.ts | 45 ++++++ .../client/meteorOverrides/login/github.ts | 31 ++++ .../client/meteorOverrides/login/google.ts | 54 +++++++ .../client/meteorOverrides/login/linkedin.ts | 30 ++++ .../login/meteorDeveloperAccount.ts | 32 ++++ .../client/meteorOverrides/login/saml.ts | 6 +- .../client/meteorOverrides/login/twitter.ts | 45 ++++++ .../providers/AuthenticationProvider.tsx | 58 +------ apps/meteor/client/startup/customOAuth.ts | 33 ++-- apps/meteor/client/startup/iframeCommands.ts | 4 +- .../definition/externals/meteor/oauth.d.ts | 16 +- .../linkedin-oauth/linkedin-client.js | 49 +----- .../ui-contexts/src/AuthenticationContext.ts | 12 +- .../ui-contexts/src/hooks/useLoginServices.ts | 13 ++ packages/ui-contexts/src/index.ts | 1 + .../web-ui-registration/src/LoginServices.tsx | 6 +- 19 files changed, 505 insertions(+), 137 deletions(-) create mode 100644 apps/meteor/client/lib/loginServices.ts create mode 100644 apps/meteor/client/lib/wrapRequestCredentialFn.ts create mode 100644 packages/ui-contexts/src/hooks/useLoginServices.ts diff --git a/apps/meteor/app/custom-oauth/client/CustomOAuth.ts b/apps/meteor/app/custom-oauth/client/CustomOAuth.ts index 58c4142d13495..1d57d1969d939 100644 --- a/apps/meteor/app/custom-oauth/client/CustomOAuth.ts +++ b/apps/meteor/app/custom-oauth/client/CustomOAuth.ts @@ -1,14 +1,14 @@ -import type { OauthConfig } from '@rocket.chat/core-typings'; +import type { OAuthConfiguration, OauthConfig } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { capitalize } from '@rocket.chat/string-helpers'; import { Accounts } from 'meteor/accounts-base'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { OAuth } from 'meteor/oauth'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import type { IOAuthProvider } from '../../../client/definitions/IOAuthProvider'; import { overrideLoginMethod, type LoginCallback } from '../../../client/lib/2fa/overrideLoginMethod'; +import { loginServices } from '../../../client/lib/loginServices'; import { createOAuthTotpLoginMethod } from '../../../client/meteorOverrides/login/oauth'; import { isURL } from '../../../lib/utils/isURL'; @@ -86,7 +86,7 @@ export class CustomOAuth implements IOAuthProvider { options: Meteor.LoginWithExternalServiceOptions = {}, credentialRequestCompleteCallback: (credentialTokenOrError?: string | Error) => void, ) { - const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name }); + const config = await loginServices.loadLoginService(this.name); if (!config) { if (credentialRequestCompleteCallback) { credentialRequestCompleteCallback(new Accounts.ConfigError()); @@ -95,7 +95,7 @@ export class CustomOAuth implements IOAuthProvider { } const credentialToken = Random.secret(); - const loginStyle = OAuth._loginStyle(this.name, config, options); + const loginStyle = OAuth._loginStyle(this.name, config); const separator = this.authorizePath.indexOf('?') !== -1 ? '&' : '?'; diff --git a/apps/meteor/client/lib/loginServices.ts b/apps/meteor/client/lib/loginServices.ts new file mode 100644 index 0000000000000..ad5ee926ccc78 --- /dev/null +++ b/apps/meteor/client/lib/loginServices.ts @@ -0,0 +1,147 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import { capitalize } from '@rocket.chat/string-helpers'; +import type { LoginService } from '@rocket.chat/ui-contexts'; + +import { sdk } from '../../app/utils/client/lib/SDKClient'; + +type LoginServicesEvents = { + changed: undefined; + loaded: LoginServiceConfiguration[]; +}; + +type LoadState = 'loaded' | 'loading' | 'error' | 'none'; + +const maxRetries = 3; +const timeout = 10000; + +class LoginServices extends Emitter { + private retries = 0; + + private services: LoginServiceConfiguration[] = []; + + private serviceButtons: LoginService[] = []; + + private state: LoadState = 'none'; + + private config: Record> = { + 'apple': { title: 'Apple', icon: 'apple' }, + 'facebook': { title: 'Facebook', icon: 'facebook' }, + 'twitter': { title: 'Twitter', icon: 'twitter' }, + 'google': { title: 'Google', icon: 'google' }, + 'github': { title: 'Github', icon: 'github' }, + 'github_enterprise': { title: 'Github Enterprise', icon: 'github' }, + 'gitlab': { title: 'Gitlab', icon: 'gitlab' }, + 'dolphin': { title: 'Dolphin', icon: 'dophin' }, + 'drupal': { title: 'Drupal', icon: 'drupal' }, + 'nextcloud': { title: 'Nextcloud', icon: 'nextcloud' }, + 'tokenpass': { title: 'Tokenpass', icon: 'tokenpass' }, + 'meteor-developer': { title: 'Meteor', icon: 'meteor' }, + 'wordpress': { title: 'WordPress', icon: 'wordpress' }, + 'linkedin': { title: 'Linkedin', icon: 'linkedin' }, + }; + + private setServices(state: LoadState, services: LoginServiceConfiguration[]) { + this.services = services; + this.state = state; + + this.generateServiceButtons(); + + if (state === 'loaded') { + this.retries = 0; + this.emit('loaded', services); + } + } + + private generateServiceButtons(): void { + const filtered = this.services.filter((config) => !('showButton' in config) || config.showButton !== false) || []; + const sorted = filtered.sort(({ service: service1 }, { service: service2 }) => service1.localeCompare(service2)); + this.serviceButtons = sorted.map((service) => { + // Remove the appId attribute if present + const { appId: _, ...serviceData } = { + ...service, + appId: undefined, + }; + + // Get the hardcoded title and icon, or fallback to capitalizing the service name + const serviceConfig = this.config[service.service] || { + title: capitalize(service.service), + }; + + return { + ...serviceData, + ...serviceConfig, + }; + }); + + this.emit('changed'); + } + + public getLoginService = LoginServiceConfiguration>(serviceName: string): T | undefined { + if (!this.ready) { + return; + } + + return this.services.find(({ service }) => service === serviceName) as T | undefined; + } + + public async loadLoginService = LoginServiceConfiguration>( + serviceName: string, + ): Promise { + if (this.ready) { + return this.getLoginService(serviceName); + } + + return new Promise((resolve, reject) => { + this.onLoad(() => resolve(this.getLoginService(serviceName))); + + setTimeout(() => reject(new Error('LoadLoginService timeout')), timeout); + }); + } + + public get ready() { + return this.state === 'loaded'; + } + + public getLoginServiceButtons(): LoginService[] { + if (!this.ready) { + if (this.state === 'none') { + void this.loadServices(); + } + } + + return this.serviceButtons; + } + + public onLoad(callback: (services: LoginServiceConfiguration[]) => void) { + if (this.ready) { + return callback(this.services); + } + + void this.loadServices(); + this.once('loaded', callback); + } + + public async loadServices(): Promise { + if (this.state === 'error') { + if (this.retries >= maxRetries) { + return; + } + this.retries++; + } else if (this.state !== 'none') { + return; + } + + try { + this.state = 'loading'; + const { configurations } = await sdk.rest.get('/v1/service.configurations'); + + this.setServices('loaded', configurations); + } catch (e) { + this.setServices('error', []); + throw e; + } + } +} + +export const loginServices = new LoginServices(); diff --git a/apps/meteor/client/lib/wrapRequestCredentialFn.ts b/apps/meteor/client/lib/wrapRequestCredentialFn.ts new file mode 100644 index 0000000000000..12102187de30f --- /dev/null +++ b/apps/meteor/client/lib/wrapRequestCredentialFn.ts @@ -0,0 +1,52 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Accounts } from 'meteor/accounts-base'; +import type { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import { loginServices } from './loginServices'; + +type RequestCredentialOptions = Meteor.LoginWithExternalServiceOptions; +type RequestCredentialCallback = (credentialTokenOrError?: string | Error) => void; + +type RequestCredentialConfig> = { + config: T; + loginStyle: string; + options: RequestCredentialOptions; + credentialRequestCompleteCallback?: RequestCredentialCallback; +}; + +export function wrapRequestCredentialFn>( + serviceName: string, + fn: (params: RequestCredentialConfig) => void, +) { + const wrapped = async ( + options: RequestCredentialOptions, + credentialRequestCompleteCallback?: RequestCredentialCallback, + ): Promise => { + const config = await loginServices.loadLoginService(serviceName); + if (!config) { + credentialRequestCompleteCallback?.(new Accounts.ConfigError()); + return; + } + + const loginStyle = OAuth._loginStyle(serviceName, config, options); + fn({ + config, + loginStyle, + options, + credentialRequestCompleteCallback, + }); + }; + + return ( + options?: RequestCredentialOptions | RequestCredentialCallback, + credentialRequestCompleteCallback?: RequestCredentialCallback, + ) => { + if (!credentialRequestCompleteCallback && typeof options === 'function') { + void wrapped({}, options); + return; + } + + void wrapped(options as RequestCredentialOptions, credentialRequestCompleteCallback); + }; +} diff --git a/apps/meteor/client/meteorOverrides/login/facebook.ts b/apps/meteor/client/meteorOverrides/login/facebook.ts index 09875021238cb..72a91775818e8 100644 --- a/apps/meteor/client/meteorOverrides/login/facebook.ts +++ b/apps/meteor/client/meteorOverrides/login/facebook.ts @@ -1,7 +1,11 @@ +import type { FacebookOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { Facebook } from 'meteor/facebook-oauth'; import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; const { loginWithFacebook } = Meteor; @@ -9,3 +13,44 @@ const loginWithFacebookAndTOTP = createOAuthTotpLoginMethod(Facebook); Meteor.loginWithFacebook = (options, callback) => { overrideLoginMethod(loginWithFacebook, [options], callback, loginWithFacebookAndTOTP); }; + +Facebook.requestCredential = wrapRequestCredentialFn( + 'facebook', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Meteor.LoginWithExternalServiceOptions & { + absoluteUrlOptions?: Record; + params?: Record; + auth_type?: string; + }; + + const credentialToken = Random.secret(); + const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test(navigator.userAgent); + const display = mobile ? 'touch' : 'popup'; + + const scope = options?.requestPermissions ? options.requestPermissions.join(',') : 'email'; + + const API_VERSION = Meteor.settings?.public?.packages?.['facebook-oauth']?.apiVersion || '17.0'; + + const loginUrlParameters: Record = { + client_id: config.appId, + redirect_uri: OAuth._redirectUri('facebook', config, options.params, options.absoluteUrlOptions), + display, + scope, + state: OAuth._stateParam(loginStyle, credentialToken, options?.redirectUrl), + // Handle authentication type (e.g. for force login you need auth_type: "reauthenticate") + ...(options.auth_type && { auth_type: options.auth_type }), + }; + + const loginUrl = `https://www.facebook.com/v${API_VERSION}/dialog/oauth?${Object.keys(loginUrlParameters) + .map((param) => `${encodeURIComponent(param)}=${encodeURIComponent(loginUrlParameters[param])}`) + .join('&')}`; + + OAuth.launchLogin({ + loginService: 'facebook', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/github.ts b/apps/meteor/client/meteorOverrides/login/github.ts index 15e514ab6d568..2a1aa3903317f 100644 --- a/apps/meteor/client/meteorOverrides/login/github.ts +++ b/apps/meteor/client/meteorOverrides/login/github.ts @@ -1,7 +1,11 @@ +import { Random } from '@rocket.chat/random'; +import { Accounts } from 'meteor/accounts-base'; import { Github } from 'meteor/github-oauth'; import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; const { loginWithGithub } = Meteor; @@ -9,3 +13,30 @@ const loginWithGithubAndTOTP = createOAuthTotpLoginMethod(Github); Meteor.loginWithGithub = (options, callback) => { overrideLoginMethod(loginWithGithub, [options], callback, loginWithGithubAndTOTP); }; + +Github.requestCredential = wrapRequestCredentialFn('github', ({ config, loginStyle, options, credentialRequestCompleteCallback }) => { + const credentialToken = Random.secret(); + const scope = options?.requestPermissions || ['user:email']; + const flatScope = scope.map(encodeURIComponent).join('+'); + + let allowSignup = ''; + if (Accounts._options?.forbidClientAccountCreation) { + allowSignup = '&allow_signup=false'; + } + + const loginUrl = + `https://github.com/login/oauth/authorize` + + `?client_id=${config.clientId}` + + `&scope=${flatScope}` + + `&redirect_uri=${OAuth._redirectUri('github', config)}` + + `&state=${OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl)}${allowSignup}`; + + OAuth.launchLogin({ + loginService: 'github', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { width: 900, height: 450 }, + }); +}); diff --git a/apps/meteor/client/meteorOverrides/login/google.ts b/apps/meteor/client/meteorOverrides/login/google.ts index 149f55b00ace7..2742cade15d2c 100644 --- a/apps/meteor/client/meteorOverrides/login/google.ts +++ b/apps/meteor/client/meteorOverrides/login/google.ts @@ -1,8 +1,11 @@ +import { Random } from '@rocket.chat/random'; import { Accounts } from 'meteor/accounts-base'; import { Google } from 'meteor/google-oauth'; import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; import { overrideLoginMethod, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; declare module 'meteor/accounts-base' { @@ -10,6 +13,7 @@ declare module 'meteor/accounts-base' { namespace Accounts { export const _options: { restrictCreationByEmailDomain?: string | (() => string); + forbidClientAccountCreation?: boolean | undefined; }; } } @@ -70,3 +74,53 @@ const loginWithGoogleAndTOTP = ( Meteor.loginWithGoogle = (options, callback) => { overrideLoginMethod(loginWithGoogle, [options], callback, loginWithGoogleAndTOTP); }; + +Google.requestCredential = wrapRequestCredentialFn( + 'google', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const credentialToken = Random.secret(); + const options = requestOptions as Meteor.LoginWithExternalServiceOptions & { + loginUrlParameters?: { + include_granted_scopes?: boolean; + hd?: string; + }; + prompt?: string; + }; + + const scope = ['email', ...(options.requestPermissions || ['profile'])].join(' '); + + const loginUrlParameters: Record = { + ...options.loginUrlParameters, + ...(options.requestOfflineToken !== undefined && { + access_type: options.requestOfflineToken ? 'offline' : 'online', + }), + ...((options.prompt || options.forceApprovalPrompt) && { prompt: options.prompt || 'consent' }), + ...(options.loginHint && { login_hint: options.loginHint }), + response_type: 'code', + client_id: config.clientId, + scope, + redirect_uri: OAuth._redirectUri('google', config), + state: OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl), + }; + + Object.assign(loginUrlParameters, { + response_type: 'code', + client_id: config.clientId, + scope, + redirect_uri: OAuth._redirectUri('google', config), + state: OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl), + }); + const loginUrl = `https://accounts.google.com/o/oauth2/auth?${Object.keys(loginUrlParameters) + .map((param) => `${encodeURIComponent(param)}=${encodeURIComponent(loginUrlParameters[param])}`) + .join('&')}`; + + OAuth.launchLogin({ + loginService: 'google', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { height: 600 }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/linkedin.ts b/apps/meteor/client/meteorOverrides/login/linkedin.ts index 0f309ee360f60..a10b8182feec3 100644 --- a/apps/meteor/client/meteorOverrides/login/linkedin.ts +++ b/apps/meteor/client/meteorOverrides/login/linkedin.ts @@ -1,8 +1,12 @@ +import type { LinkedinOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; import { Linkedin } from 'meteor/pauli:linkedin-oauth'; import type { LoginCallback } from '../../lib/2fa/overrideLoginMethod'; import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; declare module 'meteor/meteor' { @@ -16,3 +20,29 @@ const loginWithLinkedinAndTOTP = createOAuthTotpLoginMethod(Linkedin); Meteor.loginWithLinkedin = (options, callback) => { overrideLoginMethod(loginWithLinkedin, [options], callback, loginWithLinkedinAndTOTP); }; + +Linkedin.requestCredential = wrapRequestCredentialFn( + 'linkedin', + ({ options, credentialRequestCompleteCallback, config, loginStyle }) => { + const credentialToken = Random.secret(); + + const { requestPermissions } = options; + const scope = (requestPermissions || ['openid', 'email', 'profile']).join('+'); + + const loginUrl = `https://www.linkedin.com/uas/oauth2/authorization?response_type=code&client_id=${ + config.clientId + }&redirect_uri=${OAuth._redirectUri('linkedin', config)}&state=${OAuth._stateParam(loginStyle, credentialToken)}&scope=${scope}`; + + OAuth.launchLogin({ + credentialRequestCompleteCallback, + credentialToken, + loginService: 'linkedin', + loginStyle, + loginUrl, + popupOptions: { + width: 390, + height: 628, + }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts b/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts index 9577194f40435..56823fee6b6a0 100644 --- a/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts +++ b/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts @@ -1,7 +1,9 @@ import { Meteor } from 'meteor/meteor'; import { MeteorDeveloperAccounts } from 'meteor/meteor-developer-oauth'; +import { OAuth } from 'meteor/oauth'; import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; const { loginWithMeteorDeveloperAccount } = Meteor; @@ -9,3 +11,33 @@ const loginWithMeteorDeveloperAccountAndTOTP = createOAuthTotpLoginMethod(Meteor Meteor.loginWithMeteorDeveloperAccount = (options, callback) => { overrideLoginMethod(loginWithMeteorDeveloperAccount, [options], callback, loginWithMeteorDeveloperAccountAndTOTP); }; + +MeteorDeveloperAccounts.requestCredential = wrapRequestCredentialFn( + 'meteor-developer', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Record; + + const credentialToken = Random.secret(); + + let loginUrl = + `${MeteorDeveloperAccounts._server}/oauth2/authorize?` + + `state=${OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl)}` + + `&response_type=code&` + + `client_id=${config.clientId}${options.details ? `&details=${options.details}` : ''}`; + + if (options.loginHint) { + loginUrl += `&user_email=${encodeURIComponent(options.loginHint)}`; + } + + loginUrl += `&redirect_uri=${OAuth._redirectUri('meteor-developer', config)}`; + + OAuth.launchLogin({ + loginService: 'meteor-developer', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { width: 497, height: 749 }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/saml.ts b/apps/meteor/client/meteorOverrides/login/saml.ts index 8972cfe4812f2..dd8b04c4006d7 100644 --- a/apps/meteor/client/meteorOverrides/login/saml.ts +++ b/apps/meteor/client/meteorOverrides/login/saml.ts @@ -1,9 +1,10 @@ +import type { SAMLConfiguration } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { type LoginCallback, callLoginMethod, handleLogin } from '../../lib/2fa/overrideLoginMethod'; +import { loginServices } from '../../lib/loginServices'; declare module 'meteor/meteor' { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -40,7 +41,8 @@ const { logout } = Meteor; Meteor.logout = async function (...args) { const { sdk } = await import('../../../app/utils/client/lib/SDKClient'); - const samlService = await ServiceConfiguration.configurations.findOneAsync({ service: 'saml' }); + // #TODO: Use SAML settings directly instead of relying on the login service + const samlService = await loginServices.loadLoginService('saml'); if (samlService) { const provider = (samlService.clientConfig as { provider?: string } | undefined)?.provider; if (provider) { diff --git a/apps/meteor/client/meteorOverrides/login/twitter.ts b/apps/meteor/client/meteorOverrides/login/twitter.ts index 955277b1ce561..e19ce234e5e91 100644 --- a/apps/meteor/client/meteorOverrides/login/twitter.ts +++ b/apps/meteor/client/meteorOverrides/login/twitter.ts @@ -1,7 +1,11 @@ +import type { TwitterOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; import { Twitter } from 'meteor/twitter-oauth'; import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; const { loginWithTwitter } = Meteor; @@ -9,3 +13,44 @@ const loginWithTwitterAndTOTP = createOAuthTotpLoginMethod(Twitter); Meteor.loginWithTwitter = (options, callback) => { overrideLoginMethod(loginWithTwitter, [options], callback, loginWithTwitterAndTOTP); }; + +Twitter.requestCredential = wrapRequestCredentialFn( + 'twitter', + ({ loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Record; + const credentialToken = Random.secret(); + + let loginPath = `_oauth/twitter/?requestTokenAndRedirect=true&state=${OAuth._stateParam( + loginStyle, + credentialToken, + options?.redirectUrl, + )}`; + + if (Meteor.isCordova) { + loginPath += '&cordova=true'; + if (/Android/i.test(navigator.userAgent)) { + loginPath += '&android=true'; + } + } + + // Support additional, permitted parameters + if (options) { + const hasOwn = Object.prototype.hasOwnProperty; + Twitter.validParamsAuthenticate.forEach((param: string) => { + if (hasOwn.call(options, param)) { + loginPath += `&${param}=${encodeURIComponent(options[param])}`; + } + }); + } + + const loginUrl = Meteor.absoluteUrl(loginPath); + + OAuth.launchLogin({ + loginService: 'twitter', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); + }, +); diff --git a/apps/meteor/client/providers/AuthenticationProvider.tsx b/apps/meteor/client/providers/AuthenticationProvider.tsx index 26e6e640f5d75..14e35edc8e38b 100644 --- a/apps/meteor/client/providers/AuthenticationProvider.tsx +++ b/apps/meteor/client/providers/AuthenticationProvider.tsx @@ -1,32 +1,13 @@ import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; -import type { LoginService } from '@rocket.chat/ui-contexts'; -import { AuthenticationContext, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; +import { capitalize } from '@rocket.chat/string-helpers'; +import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import type { ContextType, ReactElement, ReactNode } from 'react'; import React, { useMemo } from 'react'; +import { loginServices } from '../lib/loginServices'; import { useLDAPAndCrowdCollisionWarning } from './UserProvider/hooks/useLDAPAndCrowdCollisionWarning'; -const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); - -const config: Record> = { - 'apple': { title: 'Apple', icon: 'apple' }, - 'facebook': { title: 'Facebook', icon: 'facebook' }, - 'twitter': { title: 'Twitter', icon: 'twitter' }, - 'google': { title: 'Google', icon: 'google' }, - 'github': { title: 'Github', icon: 'github' }, - 'github_enterprise': { title: 'Github Enterprise', icon: 'github' }, - 'gitlab': { title: 'Gitlab', icon: 'gitlab' }, - 'dolphin': { title: 'Dolphin', icon: 'dophin' }, - 'drupal': { title: 'Drupal', icon: 'drupal' }, - 'nextcloud': { title: 'Nextcloud', icon: 'nextcloud' }, - 'tokenpass': { title: 'Tokenpass', icon: 'tokenpass' }, - 'meteor-developer': { title: 'Meteor', icon: 'meteor' }, - 'wordpress': { title: 'WordPress', icon: 'wordpress' }, - 'linkedin': { title: 'Linkedin', icon: 'linkedin' }, -}; - export type LoginMethods = keyof typeof Meteor extends infer T ? (T extends `loginWith${string}` ? T : never) : never; type UserProviderProps = { @@ -34,12 +15,6 @@ type UserProviderProps = { }; const UserProvider = ({ children }: UserProviderProps): ReactElement => { - const getServiceConfigurations = useEndpoint('GET', '/v1/service.configurations'); - - const { data: services } = useQuery(['service.configurations'], () => getServiceConfigurations(), { - staleTime: Infinity, - }); - const isLdapEnabled = useSetting('LDAP_Enable'); const isCrowdEnabled = useSetting('CROWD_Enable'); @@ -96,30 +71,13 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { }); }); }, - getLoginServices: () => { - const loginServices: LoginServiceConfiguration[] = - services?.configurations.filter((config) => !('showButton' in config) || config.showButton !== false) || []; - - return loginServices - .sort(({ service: service1 }, { service: service2 }) => service1.localeCompare(service2)) - .map((service) => { - const { appId: _, ...serviceData } = { - ...service, - appId: undefined, - }; - - const serviceConfig = config[service.service] || { - title: capitalize(service.service), - }; - - return { - ...serviceData, - ...serviceConfig, - }; - }); + + queryLoginServices: { + getCurrentValue: () => loginServices.getLoginServiceButtons(), + subscribe: (onStoreChange: () => void) => loginServices.on('changed', onStoreChange), }, }), - [loginMethod, services], + [loginMethod], ); return ; diff --git a/apps/meteor/client/startup/customOAuth.ts b/apps/meteor/client/startup/customOAuth.ts index 7d28958fcc93c..1b9060f84e3a7 100644 --- a/apps/meteor/client/startup/customOAuth.ts +++ b/apps/meteor/client/startup/customOAuth.ts @@ -1,27 +1,20 @@ -import type { ILoginServiceConfiguration, OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { CustomOAuth } from '../../app/custom-oauth/client/CustomOAuth'; +import { loginServices } from '../lib/loginServices'; Meteor.startup(() => { - ServiceConfiguration.configurations - .find({ - custom: true, - }) - .observe({ - async added(record) { - const service = record as unknown as (ILoginServiceConfiguration & OAuthConfiguration) | undefined; + loginServices.onLoad((services) => { + for (const service of services) { + if (!('custom' in service && service.custom)) { + return; + } - if (!service?.custom) { - return; - } - - new CustomOAuth(service.service, { - serverURL: service.serverURL, - authorizePath: service.authorizePath, - scope: service.scope, - }); - }, - }); + new CustomOAuth(service.service, { + serverURL: service.serverURL, + authorizePath: service.authorizePath, + scope: service.scope, + }); + } + }); }); diff --git a/apps/meteor/client/startup/iframeCommands.ts b/apps/meteor/client/startup/iframeCommands.ts index cb946ba44176c..f0db83ccdcbff 100644 --- a/apps/meteor/client/startup/iframeCommands.ts +++ b/apps/meteor/client/startup/iframeCommands.ts @@ -2,7 +2,6 @@ import type { UserStatus, IUser } from '@rocket.chat/core-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { LocationPathname } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { settings } from '../../app/settings/client'; import { AccountBox } from '../../app/ui-utils/client/lib/AccountBox'; @@ -10,6 +9,7 @@ import { sdk } from '../../app/utils/client/lib/SDKClient'; import { afterLogoutCleanUpCallback } from '../../lib/callbacks/afterLogoutCleanUpCallback'; import { capitalize, ltrim, rtrim } from '../../lib/utils/stringUtils'; import { baseURI } from '../lib/baseURI'; +import { loginServices } from '../lib/loginServices'; import { router } from '../providers/RouterProvider'; const commands = { @@ -55,7 +55,7 @@ const commands = { } if (typeof data.service === 'string' && window.ServiceConfiguration) { - const customOauth = ServiceConfiguration.configurations.findOne({ service: data.service }); + const customOauth = loginServices.getLoginService(data.service); if (customOauth) { const customLoginWith = (Meteor as any)[`loginWith${capitalize(customOauth.service, true)}`]; diff --git a/apps/meteor/definition/externals/meteor/oauth.d.ts b/apps/meteor/definition/externals/meteor/oauth.d.ts index 9573b2888f494..f07ede0b0b546 100644 --- a/apps/meteor/definition/externals/meteor/oauth.d.ts +++ b/apps/meteor/definition/externals/meteor/oauth.d.ts @@ -1,7 +1,6 @@ declare module 'meteor/oauth' { import type { IRocketChatRecord } from '@rocket.chat/core-typings'; import type { Mongo } from 'meteor/mongo'; - import type { Configuration } from 'meteor/service-configuration'; interface IOauthCredentials extends IRocketChatRecord { key: string; @@ -27,16 +26,21 @@ declare module 'meteor/oauth' { loginUrl: string; credentialRequestCompleteCallback?: (credentialTokenOrError?: string | Error) => void; credentialToken: string; - popupOptions: { - width: number; - height: number; + popupOptions?: { + width?: number; + height?: number; }; }): void; function _stateParam(loginStyle: string, credentialToken: string, redirectUrl?: string): string; - function _redirectUri(serviceName: string, config: Configuration, params?: any, absoluteUrlOptions?: any): string; + function _redirectUri( + serviceName: string, + config: { loginStyle?: string }, + params?: Record, + absoluteUrlOptions?: Record, + ): string; - function _loginStyle(serviceName: string, config: Configuration, options?: Meteor.LoginWithExternalServiceOptions): string; + function _loginStyle(serviceName: string, config: { loginStyle?: string }, options?: Meteor.LoginWithExternalServiceOptions): string; } } diff --git a/apps/meteor/packages/linkedin-oauth/linkedin-client.js b/apps/meteor/packages/linkedin-oauth/linkedin-client.js index 4803d69d34b1f..c93826bc742c7 100644 --- a/apps/meteor/packages/linkedin-oauth/linkedin-client.js +++ b/apps/meteor/packages/linkedin-oauth/linkedin-client.js @@ -1,7 +1,3 @@ -import { ServiceConfiguration } from 'meteor/service-configuration'; -import { Random } from '@rocket.chat/random'; -import { OAuth } from 'meteor/oauth'; - export const Linkedin = {}; // Request LinkedIn credentials for the user @@ -10,47 +6,6 @@ export const Linkedin = {}; // completion. Takes one argument, credentialToken on success, or Error on // error. Linkedin.requestCredential = async function (options, credentialRequestCompleteCallback) { - // support both (options, callback) and (callback). - if (!credentialRequestCompleteCallback && typeof options === 'function') { - credentialRequestCompleteCallback = options; - options = {}; - } - - const config = await ServiceConfiguration.configurations.findOneAsync({ service: 'linkedin' }); - if (!config) { - throw new Accounts.ConfigError('Service not configured'); - } - - const credentialToken = Random.secret(); - - let scope; - const { requestPermissions, ...otherOptionsToPassThrough } = options; - if (requestPermissions) { - scope = requestPermissions.join('+'); - } else { - // If extra permissions not passed, we need to request basic, available to all - scope = 'openid+email+profile'; - } - const loginStyle = OAuth._loginStyle('linkedin', config, options); - if (!otherOptionsToPassThrough.popupOptions) { - // the default dimensions (https://github.com/meteor/meteor/blob/release-1.6.1/packages/oauth/oauth_browser.js#L15) don't play well with the content shown by linkedin - // so override popup dimensions to something appropriate (might have to change if LinkedIn login page changes its layout) - otherOptionsToPassThrough.popupOptions = { - width: 390, - height: 628, - }; - } - - const loginUrl = `https://www.linkedin.com/uas/oauth2/authorization?response_type=code&client_id=${ - config.clientId - }&redirect_uri=${OAuth._redirectUri('linkedin', config)}&state=${OAuth._stateParam(loginStyle, credentialToken)}&scope=${scope}`; - - OAuth.launchLogin({ - credentialRequestCompleteCallback, - credentialToken, - loginService: 'linkedin', - loginStyle, - loginUrl, - ...otherOptionsToPassThrough, - }); + // This function will be replaced by meteorOverrides/login/linkedin.ts + throw new Error('Linkedin integration error - invalid reference to original requestCredential implementation.'); }; diff --git a/packages/ui-contexts/src/AuthenticationContext.ts b/packages/ui-contexts/src/AuthenticationContext.ts index 91865946f4ea7..3892be3dc631a 100644 --- a/packages/ui-contexts/src/AuthenticationContext.ts +++ b/packages/ui-contexts/src/AuthenticationContext.ts @@ -10,13 +10,21 @@ export type AuthenticationContextValue = { loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string) => Promise; loginWithToken: (user: string) => Promise; - getLoginServices: () => LoginService[]; loginWithService(service: T): () => Promise; + + queryLoginServices: { + getCurrentValue: () => LoginService[]; + subscribe: (onStoreChange: () => void) => () => void; + }; }; export const AuthenticationContext = createContext({ - getLoginServices: () => [], loginWithService: () => () => Promise.reject('loginWithService not implemented'), loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), loginWithToken: async () => Promise.reject('loginWithToken not implemented'), + + queryLoginServices: { + getCurrentValue: () => [], + subscribe: (_: () => void) => () => Promise.reject('queryLoginServices not implemented'), + }, }); diff --git a/packages/ui-contexts/src/hooks/useLoginServices.ts b/packages/ui-contexts/src/hooks/useLoginServices.ts new file mode 100644 index 0000000000000..dd016abd9d9ca --- /dev/null +++ b/packages/ui-contexts/src/hooks/useLoginServices.ts @@ -0,0 +1,13 @@ +import { useContext, useMemo } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; + +import { AuthenticationContext, type LoginService } from '../AuthenticationContext'; + +export const useLoginServices = (): LoginService[] => { + const { queryLoginServices } = useContext(AuthenticationContext); + const [subscribe, getSnapshot] = useMemo(() => { + return [queryLoginServices.subscribe, () => queryLoginServices.getCurrentValue()]; + }, [queryLoginServices]); + + return useSyncExternalStore(subscribe, getSnapshot); +}; diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index b54484f5988ce..0870eb4417c8a 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -42,6 +42,7 @@ export { useLayoutSizes } from './hooks/useLayoutSizes'; export { useLayoutHiddenActions } from './hooks/useLayoutHiddenActions'; export { useLoadLanguage } from './hooks/useLoadLanguage'; export { useLoginWithPassword } from './hooks/useLoginWithPassword'; +export { useLoginServices } from './hooks/useLoginServices'; export { useLoginWithService } from './hooks/useLoginWithService'; export { useLoginWithToken } from './hooks/useLoginWithToken'; export { useLogout } from './hooks/useLogout'; diff --git a/packages/web-ui-registration/src/LoginServices.tsx b/packages/web-ui-registration/src/LoginServices.tsx index 01260de67c907..530dce7e9438b 100644 --- a/packages/web-ui-registration/src/LoginServices.tsx +++ b/packages/web-ui-registration/src/LoginServices.tsx @@ -1,6 +1,5 @@ import { ButtonGroup, Divider } from '@rocket.chat/fuselage'; -import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts'; -import { useContext } from 'react'; +import { useLoginServices, useSetting } from '@rocket.chat/ui-contexts'; import type { Dispatch, ReactElement, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; @@ -15,8 +14,7 @@ const LoginServices = ({ setError: Dispatch>; }): ReactElement | null => { const { t } = useTranslation(); - const { getLoginServices } = useContext(AuthenticationContext); - const services = getLoginServices(); + const services = useLoginServices(); const showFormLogin = useSetting('Accounts_ShowFormLogin'); if (services.length === 0) { From 51c9e15a4123e7e97b23f915fa6f5598eace1b81 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 23 Jan 2024 14:09:03 -0300 Subject: [PATCH 33/34] merge fixes --- .../providers/AuthenticationProvider.tsx | 86 ------------------- apps/meteor/tests/e2e/oauth.spec.ts | 1 - packages/cas-validate/src/validate.ts | 10 +-- .../mock-providers/src/MockedUserContext.tsx | 1 + .../ui-contexts/src/AuthenticationContext.tsx | 30 ------- 5 files changed, 6 insertions(+), 122 deletions(-) delete mode 100644 apps/meteor/client/providers/AuthenticationProvider.tsx delete mode 100644 packages/ui-contexts/src/AuthenticationContext.tsx diff --git a/apps/meteor/client/providers/AuthenticationProvider.tsx b/apps/meteor/client/providers/AuthenticationProvider.tsx deleted file mode 100644 index 14e35edc8e38b..0000000000000 --- a/apps/meteor/client/providers/AuthenticationProvider.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; -import { capitalize } from '@rocket.chat/string-helpers'; -import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts'; -import { Meteor } from 'meteor/meteor'; -import type { ContextType, ReactElement, ReactNode } from 'react'; -import React, { useMemo } from 'react'; - -import { loginServices } from '../lib/loginServices'; -import { useLDAPAndCrowdCollisionWarning } from './UserProvider/hooks/useLDAPAndCrowdCollisionWarning'; - -export type LoginMethods = keyof typeof Meteor extends infer T ? (T extends `loginWith${string}` ? T : never) : never; - -type UserProviderProps = { - children: ReactNode; -}; - -const UserProvider = ({ children }: UserProviderProps): ReactElement => { - const isLdapEnabled = useSetting('LDAP_Enable'); - const isCrowdEnabled = useSetting('CROWD_Enable'); - - const loginMethod: LoginMethods = (isLdapEnabled && 'loginWithLDAP') || (isCrowdEnabled && 'loginWithCrowd') || 'loginWithPassword'; - - useLDAPAndCrowdCollisionWarning(); - - const contextValue = useMemo( - (): ContextType => ({ - loginWithToken: (token: string): Promise => - new Promise((resolve, reject) => - Meteor.loginWithToken(token, (err) => { - if (err) { - return reject(err); - } - resolve(undefined); - }), - ), - loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string): Promise => - new Promise((resolve, reject) => { - Meteor[loginMethod](user, password, (error) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }), - loginWithService: (serviceConfig: T): (() => Promise) => { - const loginMethods: Record = { - 'meteor-developer': 'MeteorDeveloperAccount', - }; - - const { service: serviceName } = serviceConfig; - const clientConfig = ('clientConfig' in serviceConfig && serviceConfig.clientConfig) || {}; - - const loginWithService = `loginWith${loginMethods[serviceName] || capitalize(String(serviceName || ''))}`; - - const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService] as any; - - if (!method) { - return () => Promise.reject(new Error('Login method not found')); - } - - return () => - new Promise((resolve, reject) => { - method(clientConfig, (error: any): void => { - if (!error) { - resolve(true); - return; - } - reject(error); - }); - }); - }, - - queryLoginServices: { - getCurrentValue: () => loginServices.getLoginServiceButtons(), - subscribe: (onStoreChange: () => void) => loginServices.on('changed', onStoreChange), - }, - }), - [loginMethod], - ); - - return ; -}; - -export default UserProvider; diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index 7ee4b6f7aa6cb..8d53fa9503b43 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Registration } from './page-objects'; import { setSettingValueById } from './utils/setSettingValueById'; import { test, expect } from './utils/test'; diff --git a/packages/cas-validate/src/validate.ts b/packages/cas-validate/src/validate.ts index 7e3b8b5b01816..cef47a50a2300 100644 --- a/packages/cas-validate/src/validate.ts +++ b/packages/cas-validate/src/validate.ts @@ -13,12 +13,12 @@ export type CasOptions = { }; export type CasCallbackExtendedData = { - username: string; - attributes: Record; + username?: string; + attributes?: Record; // eslint-disable-next-line @typescript-eslint/naming-convention - PGTIOU: string | undefined; - ticket: string; - proxies: string[]; + PGTIOU?: string; + ticket?: string; + proxies?: string[]; }; export type CasCallback = (err: any, status?: unknown, username?: string, extended?: CasCallbackExtendedData) => void; diff --git a/packages/mock-providers/src/MockedUserContext.tsx b/packages/mock-providers/src/MockedUserContext.tsx index 9fca50c0f9cd8..973a6768846e7 100644 --- a/packages/mock-providers/src/MockedUserContext.tsx +++ b/packages/mock-providers/src/MockedUserContext.tsx @@ -21,6 +21,7 @@ const userContextValue: ContextType = { querySubscriptions: () => [() => () => undefined, () => []], querySubscription: () => [() => () => undefined, () => undefined], queryRoom: () => [() => () => undefined, () => undefined], + logout: () => Promise.resolve(), }; diff --git a/packages/ui-contexts/src/AuthenticationContext.tsx b/packages/ui-contexts/src/AuthenticationContext.tsx deleted file mode 100644 index d4a448eecd15a..0000000000000 --- a/packages/ui-contexts/src/AuthenticationContext.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; -import { createContext } from 'react'; - -export type LoginService = LoginServiceConfiguration & { - title?: string; - icon?: string; -}; - -export type AuthenticationContextValue = { - loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string) => Promise; - loginWithToken: (user: string) => Promise; - - loginWithService(service: T): () => Promise; - - queryLoginServices: { - getCurrentValue: () => LoginService[]; - subscribe: (onStoreChange: () => void) => () => void; - }; -}; - -export const AuthenticationContext = createContext({ - loginWithService: () => () => Promise.reject('loginWithService not implemented'), - loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), - loginWithToken: async () => Promise.reject('loginWithToken not implemented'), - - queryLoginServices: { - getCurrentValue: () => [], - subscribe: (_: () => void) => () => Promise.reject('queryLoginServices not implemented'), - }, -}); From a7b64a2a196c38fd28908118a5a302b51dbbd2dc Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 23 Jan 2024 14:11:00 -0300 Subject: [PATCH 34/34] merge fixes --- apps/meteor/app/api/server/v1/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index ac360bf2c5f60..011988f5ba2e6 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -2,8 +2,8 @@ import type { FacebookOAuthConfiguration, ISetting, ISettingColor, - OAuthConfiguration, TwitterOAuthConfiguration, + OAuthConfiguration, } from '@rocket.chat/core-typings'; import { isSettingAction, isSettingColor } from '@rocket.chat/core-typings'; import { LoginServiceConfiguration as LoginServiceConfigurationModel, Settings } from '@rocket.chat/models';