From 8a1b749667b2d2a25d297df53fb0e17d76c96ebf Mon Sep 17 00:00:00 2001 From: jamesjulich <51384945+jamesjulich@users.noreply.github.com> Date: Sun, 10 Oct 2021 16:36:44 -0500 Subject: [PATCH] Add support for SSO login. --- src/app/molecules/sso-buttons/SSOButtons.jsx | 98 +++++++++++++++++++ src/app/molecules/sso-buttons/SSOButtons.scss | 31 ++++++ src/app/templates/auth/Auth.jsx | 31 +++++- src/client/action/auth.js | 45 ++++++++- 4 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 src/app/molecules/sso-buttons/SSOButtons.jsx create mode 100644 src/app/molecules/sso-buttons/SSOButtons.scss diff --git a/src/app/molecules/sso-buttons/SSOButtons.jsx b/src/app/molecules/sso-buttons/SSOButtons.jsx new file mode 100644 index 00000000..82e8cc4c --- /dev/null +++ b/src/app/molecules/sso-buttons/SSOButtons.jsx @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import './SSOButtons.scss'; + +import { createTemporaryClient, getLoginFlows, startSsoLogin } from '../../../client/action/auth'; + +function SSOButtons({ homeserver }) { + const [identityProviders, setIdentityProviders] = useState([]); + + useEffect(() => { + // If the homeserver passed in is not a fully-qualified domain name, do not update. + if (!homeserver.match('(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(? { + const providers = []; + getLoginFlows(client).then((flows) => { + if (flows.flows !== undefined) { + const ssoFlows = flows.flows.filter((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas'); + ssoFlows.forEach((flow) => { + if (flow.identity_providers !== undefined) { + const type = flow.type.substring(8); + flow.identity_providers.forEach((idp) => { + const imageSrc = client.mxcUrlToHttp(idp.icon); + providers.push({ + homeserver, id: idp.id, name: idp.name, type, imageSrc, + }); + }); + } + }); + } + setIdentityProviders(providers); + }).catch(() => {}); + }).catch(() => { + setIdentityProviders([]); + }); + }, [homeserver]); + + // TODO Render all non-icon providers at the end so that they are never inbetween icons. + return ( +
+ {identityProviders.map((idp) => { + if (idp.imageSrc == null || idp.imageSrc === undefined || idp.imageSrc === '') { + return ( + + ); + } + return ( + + ); + })} +
+ ); +} + +function SSOButton({ + homeserver, id, name, type, imageSrc, +}) { + function handleClick() { + startSsoLogin(homeserver, type, id); + } + return ( + + ); +} + +SSOButtons.propTypes = { + homeserver: PropTypes.string.isRequired, +}; + +SSOButton.propTypes = { + homeserver: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + imageSrc: PropTypes.string.isRequired, +}; + +export default SSOButtons; diff --git a/src/app/molecules/sso-buttons/SSOButtons.scss b/src/app/molecules/sso-buttons/SSOButtons.scss new file mode 100644 index 00000000..61d48bc5 --- /dev/null +++ b/src/app/molecules/sso-buttons/SSOButtons.scss @@ -0,0 +1,31 @@ +.sso-buttons { + margin-top: var(--sp-extra-loose); + + display: flex; + justify-content: center; + flex-wrap: wrap; + + &__fallback-text { + margin: var(--sp-tight) 0px; + + flex-basis: 100%; + text-align: center; + + color: var(--bg-primary); + cursor: pointer; + } +} + +.sso-button { + margin-bottom: var(--sp-normal); + + display: flex; + justify-content: center; + flex-basis: 20%; + + cursor: pointer; + + &__img { + height: var(--av-normal); + } +} \ No newline at end of file diff --git a/src/app/templates/auth/Auth.jsx b/src/app/templates/auth/Auth.jsx index 3d97ca51..3eaf16a8 100644 --- a/src/app/templates/auth/Auth.jsx +++ b/src/app/templates/auth/Auth.jsx @@ -3,8 +3,9 @@ import PropTypes from 'prop-types'; import './Auth.scss'; import ReCAPTCHA from 'react-google-recaptcha'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import * as auth from '../../../client/action/auth'; +import cons from '../../../client/state/cons'; import Text from '../../atoms/text/Text'; import Button from '../../atoms/button/Button'; @@ -15,6 +16,7 @@ import ScrollView from '../../atoms/scroll/ScrollView'; import EyeIC from '../../../../public/res/ic/outlined/eye.svg'; import CinnySvg from '../../../../public/res/svg/cinny.svg'; +import SSOButtons from '../../molecules/sso-buttons/SSOButtons'; // This regex validates historical usernames, which don't satisfy today's username requirements. // See https://matrix.org/docs/spec/appendices#id13 for more info. @@ -75,12 +77,35 @@ function normalizeUsername(rawUsername) { function Auth({ type }) { const [process, changeProcess] = useState(null); + const [homeserver, changeHomeserver] = useState('matrix.org'); + const usernameRef = useRef(null); const homeserverRef = useRef(null); const passwordRef = useRef(null); const confirmPasswordRef = useRef(null); const emailRef = useRef(null); + const { search } = useLocation(); + const searchParams = new URLSearchParams(search); + if (searchParams.has('loginToken')) { + const loginToken = searchParams.get('loginToken'); + if (loginToken !== undefined) { + if (localStorage.getItem(cons.secretKey.BASE_URL) !== undefined) { + const baseUrl = localStorage.getItem(cons.secretKey.BASE_URL); + auth.loginWithToken(baseUrl, loginToken) + .then(() => { + window.location.replace('/'); + }) + .catch((error) => { + changeProcess(null); + if (!error.contains('CORS request rejected')) { + renderErrorMessage(error); + } + }); + } + } + } + function register(recaptchaValue, terms, verified) { auth.register( usernameRef.current.value, @@ -205,6 +230,7 @@ function Auth({ type }) { /> changeHomeserver(e.target.value)} id="auth_homeserver" placeholder="Homeserver" value="matrix.org" @@ -281,6 +307,9 @@ function Auth({ type }) { {type === 'login' ? 'Login' : 'Register' } + {type === 'login' && ( + + )} diff --git a/src/client/action/auth.js b/src/client/action/auth.js index 6c77aa81..47fe2ba2 100644 --- a/src/client/action/auth.js +++ b/src/client/action/auth.js @@ -2,7 +2,8 @@ import * as sdk from 'matrix-js-sdk'; import cons from '../state/cons'; import { getBaseUrl } from '../../util/matrixUtil'; -async function login(username, homeserver, password) { +// This method inspired by a similar one in matrix-react-sdk +async function createTemporaryClient(homeserver) { let baseUrl = null; try { baseUrl = await getBaseUrl(homeserver); @@ -12,7 +13,25 @@ async function login(username, homeserver, password) { if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found'); - const client = sdk.createClient({ baseUrl }); + return sdk.createClient({ baseUrl }); +} + +async function getLoginFlows(client) { + const flows = await client.loginFlows(); + if (flows !== undefined) { + return flows; + } + return null; +} + +async function startSsoLogin(homeserver, type, idpId) { + const client = await createTemporaryClient(homeserver); + localStorage.setItem(cons.secretKey.BASE_URL, client.baseUrl); + window.location.href = client.getSsoLoginUrl(window.location.href, type, idpId); +} + +async function login(username, homeserver, password) { + const client = await createTemporaryClient(homeserver); const response = await client.login('m.login.password', { identifier: { @@ -26,7 +45,21 @@ async function login(username, homeserver, password) { localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token); localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id); localStorage.setItem(cons.secretKey.USER_ID, response.user_id); - localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || baseUrl); + localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || client.baseUrl); +} + +async function loginWithToken(baseUrl, token) { + const client = sdk.createClient(baseUrl); + + const response = await client.login('m.login.token', { + token, + initial_device_display_name: cons.DEVICE_DISPLAY_NAME, + }); + + localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token); + localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id); + localStorage.setItem(cons.secretKey.USER_ID, response.user_id); + localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || client.baseUrl); } async function getAdditionalInfo(baseUrl, content) { @@ -45,6 +78,7 @@ async function getAdditionalInfo(baseUrl, content) { throw new Error(e); } } + async function verifyEmail(baseUrl, content) { try { const res = await fetch(`${baseUrl}/_matrix/client/r0/register/email/requestToken`, { @@ -149,4 +183,7 @@ async function register(username, homeserver, password, email, recaptchaValue, t return {}; } -export { login, register }; +export { + createTemporaryClient, getLoginFlows, login, + loginWithToken, register, startSsoLogin, +};