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,
+};