diff --git a/app/apple/server/index.js b/app/apple/server/index.js new file mode 100644 index 0000000000000..bfc42742322d0 --- /dev/null +++ b/app/apple/server/index.js @@ -0,0 +1,2 @@ +import './startup.js'; +import './loginHandler.js'; diff --git a/app/apple/server/loginHandler.js b/app/apple/server/loginHandler.js new file mode 100644 index 0000000000000..95bfee852c9c8 --- /dev/null +++ b/app/apple/server/loginHandler.js @@ -0,0 +1,33 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; + +import { handleIdentityToken } from './tokenHandler'; +import { settings } from '../../settings'; + +Accounts.registerLoginHandler('apple', (loginRequest) => { + if (!loginRequest.identityToken) { + return; + } + + if (!settings.get('Accounts_OAuth_Apple')) { + return; + } + + const identityResult = handleIdentityToken(loginRequest); + + if (!identityResult.error) { + const result = Accounts.updateOrCreateUserFromExternalService('apple', identityResult.serviceData, identityResult.options); + + // Ensure processing succeeded + if (result === undefined || result.userId === undefined) { + return { + type: 'apple', + error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'User creation failed from Apple response token'), + }; + } + + return result; + } + + return identityResult; +}); diff --git a/app/apple/server/startup.js b/app/apple/server/startup.js new file mode 100644 index 0000000000000..c0b04ed323349 --- /dev/null +++ b/app/apple/server/startup.js @@ -0,0 +1,35 @@ +import _ from 'underscore'; +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { settings } from '../../settings'; + +settings.addGroup('OAuth', function() { + this.section('Apple', function() { + this.add('Accounts_OAuth_Apple', false, { type: 'boolean', public: true }); + }); +}); + +const configureService = _.debounce(Meteor.bindEnvironment(() => { + if (!settings.get('Accounts_OAuth_Apple')) { + return ServiceConfiguration.configurations.remove({ + service: 'apple', + }); + } + + ServiceConfiguration.configurations.upsert({ + service: 'apple', + }, { + $set: { + // We'll hide this button on Web Client + showButton: false, + enabled: settings.get('Accounts_OAuth_Apple'), + }, + }); +}), 1000); + +Meteor.startup(() => { + settings.get('Accounts_OAuth_Apple', () => { + configureService(); + }); +}); diff --git a/app/apple/server/tokenHandler.js b/app/apple/server/tokenHandler.js new file mode 100644 index 0000000000000..8594757a426c5 --- /dev/null +++ b/app/apple/server/tokenHandler.js @@ -0,0 +1,77 @@ +import { jws } from 'jsrsasign'; +import NodeRSA from 'node-rsa'; +import { HTTP } from 'meteor/http'; +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import { Match, check } from 'meteor/check'; + +const isValidAppleJWT = (identityToken, header) => { + const applePublicKeys = HTTP.get('https://appleid.apple.com/auth/keys').data.keys; + const { kid } = header; + + const key = applePublicKeys.find((k) => k.kid === kid); + + const pubKey = new NodeRSA(); + pubKey.importKey( + { n: Buffer.from(key.n, 'base64'), e: Buffer.from(key.e, 'base64') }, + 'components-public', + ); + const userKey = pubKey.exportKey(['public']); + + try { + return jws.JWS.verify(identityToken, userKey, { + typ: 'JWT', + alg: 'RS256', + }); + } catch { + return false; + } +}; + +export const handleIdentityToken = ({ identityToken, fullName, email }) => { + check(identityToken, String); + check(fullName, Match.Maybe(Object)); + check(email, Match.Maybe(String)); + + const decodedToken = jws.JWS.parse(identityToken); + + if (!isValidAppleJWT(identityToken, decodedToken.headerObj)) { + return { + type: 'apple', + error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'identityToken is a invalid JWT'), + }; + } + + const profile = {}; + + const { givenName, familyName } = fullName; + if (givenName && familyName) { + profile.name = `${ givenName } ${ familyName }`; + } + + const { iss, iat, exp } = decodedToken.payloadObj; + + if (!iss) { + return { + type: 'apple', + error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'Insufficient data in auth response token'), + }; + } + + // Collect basic auth provider details + const serviceData = { + id: iss, + did: iss.split(':').pop(), + issuedAt: new Date(iat * 1000), + expiresAt: new Date(exp * 1000), + }; + + if (email) { + serviceData.email = email; + } + + return { + serviceData, + options: { profile }, + }; +}; diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index e42f1ea0f9c67..f91cd2b5d6426 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -102,6 +102,7 @@ "Accounts_iframe_url": "Iframe URL", "Accounts_LoginExpiration": "Login Expiration in Days", "Accounts_ManuallyApproveNewUsers": "Manually Approve New Users", + "Accounts_OAuth_Apple": "Sign in with Apple", "Accounts_OAuth_Custom_Authorize_Path": "Authorize Path", "Accounts_OAuth_Custom_Avatar_Field": "Avatar field", "Accounts_OAuth_Custom_Button_Color": "Button Color", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 796ad3437c51a..18a2d530ab65b 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -97,6 +97,7 @@ "Accounts_iframe_url": "URL do Iframe", "Accounts_LoginExpiration": "Expiração do Login em Dias", "Accounts_ManuallyApproveNewUsers": "Aprovar Manualmente novos Usuários", + "Accounts_OAuth_Apple": "Iniciar sessão com a Apple", "Accounts_OAuth_Custom_Authorize_Path": "Path de Autorização", "Accounts_OAuth_Custom_Avatar_Field": "Avatar", "Accounts_OAuth_Custom_Button_Color": "Cor do Botão", diff --git a/server/importPackages.js b/server/importPackages.js index 0b6c7085aef6c..dd943686d5e7b 100644 --- a/server/importPackages.js +++ b/server/importPackages.js @@ -3,6 +3,7 @@ import '../app/sms'; import '../app/2fa/server'; import '../app/analytics/server'; import '../app/api/server'; +import '../app/apple/server'; import '../app/assets/server'; import '../app/authorization'; import '../app/autolinker/server';