diff --git a/packages/api/playground/index.html b/packages/api/playground/index.html index be12f66c3..609135019 100644 --- a/packages/api/playground/index.html +++ b/packages/api/playground/index.html @@ -45,6 +45,9 @@ +
+ +
@@ -80,4 +83,4 @@

API response

- \ No newline at end of file + diff --git a/packages/api/playground/playground.js b/packages/api/playground/playground.js index c06999add..e280d9a4c 100644 --- a/packages/api/playground/playground.js +++ b/packages/api/playground/playground.js @@ -27,8 +27,25 @@ async function loginWithPassword() { } } +async function loginWithRocketChatOAuth() { + try { + await api.auth.loginWithRocketChatOAuth(); + } catch (e) { + printResult(e); + } +} + async function printResult(result) { - window.document.getElementById("output").innerHTML = "\n" + JSON.stringify(result, null, 2); + const outputElement = window.document.getElementById("output"); + if (result instanceof Error) { + if (outputElement) { + const escapedMessage = escapeHTML(result.message); + const escapedStack = escapeHTML(result.stack); + outputElement.innerHTML = `\nError: ${escapedMessage}\nStack: ${escapedStack}`; + } + } else { + outputElement.innerHTML = "\n" + JSON.stringify(result, null, 2); + } } const msgListener = msg => { @@ -82,7 +99,10 @@ const callApi = async (e) => { window.addEventListener('DOMContentLoaded', () => { console.log('Ready') document.getElementById("loginWithPassword").addEventListener("click", loginWithPassword) - + document + .getElementById("loginWithRocketChatOAuth") + .addEventListener("click", loginWithRocketChatOAuth); + const hostInput = document.getElementById("hostUrl") const roomInput = document.getElementById("roomId") const host = hostInput.value; @@ -95,3 +115,12 @@ window.addEventListener('DOMContentLoaded', () => { document.getElementById("call-api").addEventListener("click", callApi) }) +function escapeHTML(str) { + return str.replace( + /[&<>'"]/g, + (tag) => + ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[ + tag + ] || tag) + ); +} diff --git a/packages/auth/playground/index.html b/packages/auth/playground/index.html index 1f2fcec8f..e88241e2d 100644 --- a/packages/auth/playground/index.html +++ b/packages/auth/playground/index.html @@ -47,6 +47,9 @@ +
+ +
@@ -58,4 +61,4 @@
- \ No newline at end of file + diff --git a/packages/auth/playground/playground.js b/packages/auth/playground/playground.js index 059a587c3..1ebca08ff 100644 --- a/packages/auth/playground/playground.js +++ b/packages/auth/playground/playground.js @@ -41,14 +41,31 @@ async function loginWithOAuth() { } } +async function loginWithRocketChatOAuth() { + try { + await auth.loginWithRocketChatOAuth(); + } catch (e) { + printResult(e); + } +} + async function printResult(result) { - window.document.getElementById("output").innerHTML = "\n" + JSON.stringify(result, null, 2); + const outputElement = window.document.getElementById("output"); + if (result instanceof Error) { + if (outputElement) { + const escapedMessage = escapeHTML(result.message); + const escapedStack = escapeHTML(result.stack); + outputElement.innerHTML = `\nError: ${escapedMessage}\nStack: ${escapedStack}`; + } + } else { + outputElement.innerHTML = "\n" + JSON.stringify(result, null, 2); + } } window.document.body.onload = () => { document.getElementById("loginWithPassword").addEventListener("click", loginWithPassword) document.getElementById("loginWithOAuth").addEventListener("click", loginWithOAuth) - + document.getElementById("loginWithRocketChatOAuth").addEventListener("click", loginWithRocketChatOAuth); const hostInput = document.getElementById("hostUrl") const host = hostInput.value; auth = rocketChatAuth({ @@ -66,3 +83,13 @@ window.document.body.onload = () => { document.getElementById("logoutBtn").addEventListener("click", () => auth.logout()) } + +function escapeHTML(str) { + return str.replace( + /[&<>'"]/g, + (tag) => + ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[ + tag + ] || tag) + ); +} diff --git a/packages/auth/src/RocketChatAuth.ts b/packages/auth/src/RocketChatAuth.ts index 029502d51..77783df59 100644 --- a/packages/auth/src/RocketChatAuth.ts +++ b/packages/auth/src/RocketChatAuth.ts @@ -1,198 +1,227 @@ import loginWithPassword from "./loginWithPassword"; -import loginWithOAuthService from "./loginWithOAuthService"; import loginWithOAuthServiceToken from "./loginWithOAuthServiceToken"; import loginWithResumeToken from "./loginWithResumeToken"; import { IRocketChatAuthOptions } from "./IRocketChatAuthOptions"; import { Api, ApiError } from "./Api"; +import loginWithRocketChatOAuth from "./loginWithRocketChatOAuth"; class RocketChatAuth { - host: string; - api: Api; - currentUser: any; - lastFetched: Date; - authListeners: ((user: object | null) => void )[] = []; - deleteToken: () => Promise; - saveToken: (token: string) => Promise; - getToken: () => Promise; - constructor({ host, saveToken, getToken, deleteToken, autoLogin = true }: IRocketChatAuthOptions) { - this.host = host; - this.api = new Api(host); - this.lastFetched = new Date(0); - this.currentUser = null; - this.getToken = getToken; - this.saveToken = saveToken; - this.deleteToken = deleteToken; - if (autoLogin) { - this.load(); - } - } + host: string; + api: Api; + currentUser: any; + lastFetched: Date; + authListeners: ((user: object | null) => void)[] = []; + deleteToken: () => Promise; + saveToken: (token: string) => Promise; + getToken: () => Promise; + constructor({ + host, + saveToken, + getToken, + deleteToken, + autoLogin = true, + }: IRocketChatAuthOptions) { + this.host = host; + this.api = new Api(host); + this.lastFetched = new Date(0); + this.currentUser = null; + this.getToken = getToken; + this.saveToken = saveToken; + this.deleteToken = deleteToken; + if (autoLogin) { + this.load(); + } + } - /** - * Add a callback that will be called when user login status changes - * @param callback - */ - async onAuthChange(callback: (user: object | null) => void) { - this.authListeners.push(callback); - const user = await this.getCurrentUser(); - callback(user); - } + /** + * Add a callback that will be called when user login status changes + * @param callback + */ + async onAuthChange(callback: (user: object | null) => void) { + this.authListeners.push(callback); + const user = await this.getCurrentUser(); + callback(user); + } - async removeAuthListener(callback: (user: object | null) => void) { - this.authListeners = this.authListeners.filter( cb => cb !== callback ); - } + async removeAuthListener(callback: (user: object | null) => void) { + this.authListeners = this.authListeners.filter((cb) => cb !== callback); + } - notifyAuthListeners() { - this.authListeners.forEach(cb => cb(this.currentUser)); - } + notifyAuthListeners() { + this.authListeners.forEach((cb) => cb(this.currentUser)); + } - /** - * Login with username and password - * @param credentials - * @returns - */ - async loginWithPassword({user, password, code}: { - user: string; - password: string; - code?: string | number; - }) { - const response = await loginWithPassword({ - api: this.api - }, { - user, - password, - code, - }) - this.setUser(response.data) - return this.currentUser; - } + /** + * Login with username and password + * @param credentials + * @returns + */ + async loginWithPassword({ + user, + password, + code, + }: { + user: string; + password: string; + code?: string | number; + }) { + const response = await loginWithPassword( + { + api: this.api, + }, + { + user, + password, + code, + } + ); + this.setUser(response.data); + return this.currentUser; + } - /** - * TODO - * @param oauthService - */ - async loginWithOAuthService(oauthService: any) { - const response = await loginWithOAuthService( - { - api: this.api, - }, - oauthService - ); - } + /** + * Login with OAuthService's accessToken. The service must be configured in RocketChat. + * @param credentials + * @returns + */ + async loginWithOAuthServiceToken(credentials: { + [key: string]: string; + service: string; + access_token: string; + }) { + const response = await loginWithOAuthServiceToken( + { + api: this.api, + }, + credentials + ); + this.setUser(response.data); + return this.currentUser; + } - /** - * Login with OAuthService's accessToken. The service must be configured in RocketChat. - * @param credentials - * @returns - */ - async loginWithOAuthServiceToken(credentials: { [key: string]: string; service: string; access_token: string; }) { - const response = await loginWithOAuthServiceToken( - { - api: this.api, - }, - credentials - ); - this.setUser(response.data) - return this.currentUser; - } + /** + * Login with RocketChat OAuth. The EmbeddedChatApp must be installed and configured in RocketChat. + * @param credentials + * @returns + */ + async loginWithRocketChatOAuth(credentials: { + [key: string]: string; + service: string; + access_token: string; + }) { + if (typeof window === "undefined") { + throw new Error("loginWithRocketChatOAuth can only be called in browser"); + } + const response = await loginWithRocketChatOAuth({ + api: this.api, + }); + this.setUser(response.data); + return this.currentUser; + } - /** - * Login with resume token - * @param resume Previous issued authToken - * @returns - */ - async loginWithResumeToken(resume: string) { - const response = await loginWithResumeToken({ - api: this.api - }, { - resume - }); - this.setUser(response.data) - return this.currentUser; - } + /** + * Login with resume token + * @param resume Previous issued authToken + * @returns + */ + async loginWithResumeToken(resume: string) { + const response = await loginWithResumeToken( + { + api: this.api, + }, + { + resume, + } + ); + this.setUser(response.data); + return this.currentUser; + } - /** - * Get current user. - * @param refresh - * @returns - */ - async getCurrentUser(refresh = false) { - if ( - (this.currentUser && this.currentUser.authToken) - ) { - if ( refresh || new Date() >= new Date(this.lastFetched.getTime() + 3540000)) { // 59 minutes - try { - await this.loginWithResumeToken(this.currentUser.authToken); - } catch (e) { - if( e instanceof ApiError && e.response?.status === 401) { - // token cannot be refreshed as the resume token is no longer valid. - await this.logout(); - } - } - } - } - return this.currentUser; - } + /** + * Get current user. + * @param refresh + * @returns + */ + async getCurrentUser(refresh = false) { + if (this.currentUser && this.currentUser.authToken) { + if ( + refresh || + new Date() >= new Date(this.lastFetched.getTime() + 3540000) + ) { + // 59 minutes + try { + await this.loginWithResumeToken(this.currentUser.authToken); + } catch (e) { + if (e instanceof ApiError && e.response?.status === 401) { + // token cannot be refreshed as the resume token is no longer valid. + await this.logout(); + } + } + } + } + return this.currentUser; + } - /** - * Set current user - * @param user - */ - async setUser(user: object) { - this.lastFetched = new Date(); - this.currentUser = user; - await this.save() - } + /** + * Set current user + * @param user + */ + async setUser(user: object) { + this.lastFetched = new Date(); + this.currentUser = user; + await this.save(); + } - async save() { - // localStorage.setItem("ec_user", JSON.stringify({ - // user: this.currentUser, - // lastFetched: this.lastFetched - // })) - await this.saveToken(this.currentUser.authToken) - this.notifyAuthListeners(); - } + async save() { + // localStorage.setItem("ec_user", JSON.stringify({ + // user: this.currentUser, + // lastFetched: this.lastFetched + // })) + await this.saveToken(this.currentUser.authToken); + this.notifyAuthListeners(); + } - /** - * Load current user from localStorage - */ - async load() { - // const {user, lastFetched} = JSON.parse(localStorage.getItem("ec_user") || "{}"); - try { - const token = await this.getToken(); - if (token) { - const user = await this.loginWithResumeToken(token); // will notifyAuthListeners on successful login - if (user) { - this.lastFetched = new Date(); - await this.getCurrentUser(); // refresh the token if needed - } - } - } catch (e) { - console.log('Failed to login user on initial load. Sign in.') - this.notifyAuthListeners(); - } - } + /** + * Load current user from localStorage + */ + async load() { + // const {user, lastFetched} = JSON.parse(localStorage.getItem("ec_user") || "{}"); + try { + const token = await this.getToken(); + if (token) { + const user = await this.loginWithResumeToken(token); // will notifyAuthListeners on successful login + if (user) { + this.lastFetched = new Date(); + await this.getCurrentUser(); // refresh the token if needed + } + } + } catch (e) { + console.log("Failed to login user on initial load. Sign in."); + this.notifyAuthListeners(); + } + } - /** - * Logout current user - */ - async logout() { - try { + /** + * Logout current user + */ + async logout() { + try { await this.api.post(`/api/v1/logout`, undefined, { headers: { - 'X-Auth-Token': this.currentUser.authToken, - 'X-User-Id': this.currentUser.userId, + "X-Auth-Token": this.currentUser.authToken, + "X-User-Id": this.currentUser.userId, }, }); } catch (err) { console.error(err); } finally { - await this.deleteToken(); - } - // localStorage.removeItem("ec_user"); - this.lastFetched = new Date(0); - this.currentUser = null; - this.notifyAuthListeners(); - } + await this.deleteToken(); + } + // localStorage.removeItem("ec_user"); + this.lastFetched = new Date(0); + this.currentUser = null; + this.notifyAuthListeners(); + } } export default RocketChatAuth; diff --git a/packages/auth/src/loginWithOAuthService.ts b/packages/auth/src/loginWithOAuthService.ts deleted file mode 100644 index 6ceedb612..000000000 --- a/packages/auth/src/loginWithOAuthService.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Api } from "./Api"; -import getAuthorizationUrl from "./getAuthorizationUrl"; - -const loginWithOAuthService = async ( - config: { - api: Api - }, - oauthService: any -) => { - const authorizeUrl = getAuthorizationUrl(oauthService) + `?client_id=${oauthService.clientId}&state=${1234}`; - alert(authorizeUrl) - let params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no, -width=0,height=0,left=-1000,top=-1000,rel=opener`; - const popup = window.open(authorizeUrl, "Login", params); - let tempPackage: any = null; - if ((window as any).Package?.oauth?.OAuth?._handleCredentialSecret) { - tempPackage = (window as any).Package; - } - (window as any).Package = { - oauth: { - OAuth: { - _handleCredentialSecret: (credentialToken: string, credentialSecret: string) => { - console.log(credentialSecret, credentialToken); - if (tempPackage) { - (window as any).Package = tempPackage; - } - } - } - } - } - if (popup) { - popup.window.onclose = async (e) => { - console.log('Popup close'); - e.preventDefault(); - (globalThis as any).popup = popup; - const keys = Object.keys(popup.window.localStorage); - let credentialSecret, credentialToken - keys.forEach(key => { - if(key.startsWith('Meteor.oauth.credentialSecret')) { - const [,token] = key.split('-'); - credentialToken = token; - credentialSecret = window.localStorage.getItem(key); - } - }); - if (!(credentialSecret && credentialToken)) { - return null - } - const response = await config.api.post('/api/v1/login', { - oauth: { - credentialToken, - credentialSecret, - } - }) - return response.data; - } - } -} - -export default loginWithOAuthService; diff --git a/packages/auth/src/loginWithRocketChatOAuth.ts b/packages/auth/src/loginWithRocketChatOAuth.ts new file mode 100644 index 000000000..7eea59823 --- /dev/null +++ b/packages/auth/src/loginWithRocketChatOAuth.ts @@ -0,0 +1,72 @@ +import { Api } from "./Api"; +import { getRCAppInfo } from "./utils/getRCAppInfo"; +import { getRCAuthorizeURL } from "./utils/getRCAuthorizeURL"; + +const loginWithRocketChatOAuth = async (config: { api: Api }) => { + const appInfo = await getRCAppInfo(config.api.baseUrl); + if (!appInfo) { + throw new Error("EmbeddedChatApp not found on server"); + } + const { client_id, serviceName, allowedOrigins, redirect_uri } = + appInfo.config; + + if (!client_id) { + throw new Error( + "client_id not found. Make sure you have configured the EmbeddedChatApp on Rocket.Chat server" + ); + } + if (!serviceName) { + throw new Error( + "custom_oauth_name not found. Make sure you have configured the EmbeddedChatApp on Rocket.Chat server" + ); + } + if (!redirect_uri) { + throw new Error( + "redirect_uri not found. Make sure you have configured the EmbeddedChatApp on Rocket.Chat server" + ); + } + if ( + allowedOrigins.length && + allowedOrigins.includes(window.location.origin) + ) { + throw new Error( + "Origin not allowed. Make sure you have configured the EmbeddedChatApp on Rocket.Chat server" + ); + } + const authorizeUrl = await getRCAuthorizeURL( + config.api.baseUrl, + redirect_uri, + client_id + ); + let params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no, +width=800,height=600,left=-1000,top=-1000,rel=opener`; + const popup = window.open(authorizeUrl, "Login", params); + + return new Promise((resolve) => { + if (popup) { + const onMessage = async (e: MessageEvent) => { + if (e.data.type === "rc-oauth-callback") { + const { accessToken, expiresIn, serviceName } = e.data.credentials; + const response = await config.api.post("/api/v1/login", { + accessToken, + expiresIn, + serviceName, + }); + popup.close(); + resolve(response.data); + } + }; + window.addEventListener("message", onMessage); + const checkInterval = setInterval(() => { + if (popup.closed) { + clearInterval(checkInterval); + window.removeEventListener("message", onMessage); + } + }, 1000); + } else { + throw new Error("Popup blocked"); + } + }); +}; + +export default loginWithRocketChatOAuth; diff --git a/packages/auth/src/utils/constants.ts b/packages/auth/src/utils/constants.ts new file mode 100644 index 000000000..25b9dd64f --- /dev/null +++ b/packages/auth/src/utils/constants.ts @@ -0,0 +1 @@ +export const ROCKETCHAT_APP_ID = '4c977b2e-eda2-4627-8bfe-2d0358304a79'; diff --git a/packages/auth/src/utils/getRCAppBaseURL.ts b/packages/auth/src/utils/getRCAppBaseURL.ts new file mode 100644 index 000000000..4850de224 --- /dev/null +++ b/packages/auth/src/utils/getRCAppBaseURL.ts @@ -0,0 +1,6 @@ +import { ROCKETCHAT_APP_ID } from "./constants" + +export const getRCAppBaseURL = (host: string) => { + const url = new URL(`api/apps/public/${ROCKETCHAT_APP_ID}`, host); + return url.toString(); +} diff --git a/packages/auth/src/utils/getRCAppInfo.ts b/packages/auth/src/utils/getRCAppInfo.ts new file mode 100644 index 000000000..ea4e0d617 --- /dev/null +++ b/packages/auth/src/utils/getRCAppInfo.ts @@ -0,0 +1,12 @@ +import { getRCAppBaseURL } from "./getRCAppBaseURL"; + +export const getRCAppInfo = async (host: string) => { + const rcAppBaseUrl = getRCAppBaseURL(host); + const infoUrl = rcAppBaseUrl + '/info'; + const response = await fetch(infoUrl.toString()); + if (!response.ok) { + return null; + } + const info = await response.json(); + return info; +} diff --git a/packages/auth/src/utils/getRCAuthorizeURL.ts b/packages/auth/src/utils/getRCAuthorizeURL.ts new file mode 100644 index 000000000..a8c0a71c2 --- /dev/null +++ b/packages/auth/src/utils/getRCAuthorizeURL.ts @@ -0,0 +1,8 @@ +export const getRCAuthorizeURL = (host: string, redirectUri: string, clientId: string) => { + const url = new URL(`oauth/authorize`, host); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('state', encodeURIComponent(window.location.origin)); + return url.toString(); +} diff --git a/packages/rc-app/lib/getCallbackContent.ts b/packages/rc-app/lib/getCallbackContent.ts index 403d825f8..c8c1a2898 100644 --- a/packages/rc-app/lib/getCallbackContent.ts +++ b/packages/rc-app/lib/getCallbackContent.ts @@ -50,7 +50,13 @@ export const getCallbackContent = async (read: IRead, credentials: ICredentials if (config.success) { if (window.opener) { // Post message to opener with credentials - window.opener.postMessage(config.credentials, config.origin); + window.opener.postMessage( + { + type: 'rc-oauth-callback', + credentials: config.credentials + }, + config.origin + ); } } else { console.error(config.error); @@ -59,5 +65,5 @@ export const getCallbackContent = async (read: IRead, credentials: ICredentials - ` + `; }