Mobile-side for MM-65084 (#9115)

* Mobile fix for MM-65084

* Changing test/setup.ts to use a deterministic fill

This avoids the ci issue about parenthesis and is more clear that this is just a fixed sequence for testing, similar to randomUUID above.

* Add setBearerToken and setCSRFToken to Client definition

* Use setClientCredentials and memoize createPkceBundle

* Restoring the preauthSecret back to the Client constructors

This came out of a response to MM-65085: Support Pre Shared Password on server connect where preauthSecret was added in the buildConfig. Claude (correctly imo) identified this as now redundant and so removed it but it is valid to keep it as well. In any case, putting it back to be consistent with ClientTracking and ClientBase.

* Rename PKCE to SAML based terminology, similar to server

* Fix lint issue with too many blank lines at eof

* Removing plain on mobile side

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
JG Heithcock 2025-09-29 14:29:37 -07:00 committed by GitHub
parent cf04ce34cf
commit 2d6a5d5097
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 212 additions and 14 deletions

View file

@ -19,6 +19,7 @@ import EphemeralStore from '@store/ephemeral_store';
import {getFullErrorMessage, isErrorWithStatusCode, isErrorWithUrl} from '@utils/errors';
import {logWarning, logError, logDebug} from '@utils/log';
import {scheduleExpiredNotification} from '@utils/notification';
import {type SAMLChallenge} from '@utils/saml_challenge';
import {getCSRFFromCookie} from '@utils/security';
import {loginEntry} from './entry';
@ -345,6 +346,51 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
}
};
export const ssoLoginWithCodeExchange = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, loginCode: string, samlChallenge: Pick<SAMLChallenge, 'codeVerifier' | 'state'>, preauthSecret?: string): Promise<LoginActionResponse> => {
const database = DatabaseManager.appDatabase?.database;
if (!database) {
return {error: 'App database not found', failed: true};
}
try {
const client = NetworkManager.getClient(serverUrl);
const {token, csrf} = await client.exchangeSsoLoginCode(loginCode, samlChallenge.codeVerifier, samlChallenge.state);
client.setClientCredentials(token, preauthSecret);
client.setCSRFToken(csrf);
const server = await DatabaseManager.createServerDatabase({
config: {
dbName: serverUrl,
serverUrl,
identifier: serverIdentifier,
displayName: serverDisplayName,
},
});
const user = await client.getMe();
await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false});
await server?.operator.handleSystem({
systems: [{
id: Database.SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
value: user.id,
}],
prepareRecordsOnly: false,
});
} catch (error) {
logDebug('error on ssoLoginWithCodeExchange', getFullErrorMessage(error));
return {error, failed: true};
}
try {
await addPushProxyVerificationStateFromLogin(serverUrl);
const {error} = await loginEntry({serverUrl});
await DatabaseManager.setActiveServerDatabase(serverUrl);
return {error, failed: false};
} catch (error) {
return {error, failed: false};
}
};
export async function findSession(serverUrl: string, sessions: Session[]) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);

View file

@ -51,7 +51,10 @@ interface Client extends ClientBase,
ClientNPSMix,
ClientCustomAttributesMix,
ClientPlaybooksMix
{}
{
setClientCredentials: (token: string, preauthSecret?: string) => void;
setCSRFToken: (csrfToken: string) => void;
}
class Client extends mix(ClientBase).with(
ClientApps,

View file

@ -46,6 +46,7 @@ export interface ClientUsersMix {
updateCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>;
unsetCustomStatus: () => Promise<{status: string}>;
removeRecentCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>;
exchangeSsoLoginCode: (loginCode: string, codeVerifier: string, state: string) => Promise<{token: string; csrf: string}>;
}
const ClientUsers = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
@ -395,6 +396,24 @@ const ClientUsers = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
{method: 'post', body: customStatus},
);
};
exchangeSsoLoginCode = async (loginCode: string, codeVerifier: string, state: string) => {
const body = {
login_code: loginCode,
code_verifier: codeVerifier,
state,
};
// Intentionally no-cache
const resp = await this.doFetch(
`${this.getUsersRoute()}/login/sso/code-exchange`,
{method: 'post', body, headers: {'Cache-Control': 'no-store'}},
false,
);
// Expected shape: { token: string, csrf: string }
return resp?.data || resp;
};
};
export default ClientUsers;

View file

@ -105,6 +105,8 @@ class NetworkManagerSingleton {
try {
const {client} = await getOrCreateAPIClient(serverUrl, config, this.clientErrorEventHandler);
const csrfToken = await getCSRFFromCookie(serverUrl);
// Pass preauthSecret explicitly to constructor to match ClientBase behavior
this.clients[serverUrl] = new Client(client, serverUrl, bearerToken, csrfToken, preauthSecret);
} catch (error) {
throw new ClientError(serverUrl, {

View file

@ -6,7 +6,7 @@ import {StyleSheet, View} from 'react-native';
import Animated from 'react-native-reanimated';
import {SafeAreaView} from 'react-native-safe-area-context';
import {ssoLogin} from '@actions/remote/session';
import {ssoLogin, ssoLoginWithCodeExchange} from '@actions/remote/session';
import {Screens, Sso} from '@constants';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
@ -98,6 +98,15 @@ const SSO = ({
goToHome(result.error);
};
const doSSOCodeExchange = async (loginCode: string, samlChallenge: {codeVerifier: string; state: string}) => {
const result: LoginActionResponse = await ssoLoginWithCodeExchange(serverUrl!, serverDisplayName, config.DiagnosticId!, loginCode, samlChallenge, serverPreauthSecret);
if (result?.error && result.failed) {
onLoadEndError(result.error);
return;
}
goToHome(result.error);
};
const goToHome = (error?: unknown) => {
const hasError = launchError || Boolean(error);
resetToHome({extra, launchError: hasError, launchType, serverUrl});
@ -126,6 +135,7 @@ const SSO = ({
const props = {
doSSOLogin,
doSSOCodeExchange,
loginError,
loginUrl,
setLoginError,

View file

@ -19,6 +19,7 @@ describe('SSO with redirect url', () => {
const baseProps = {
customUrlScheme: LocalConfig.AuthUrlSchemeDev,
doSSOLogin: jest.fn(),
doSSOCodeExchange: jest.fn(),
intl: {},
loginError: '',
loginUrl: '',

View file

@ -3,13 +3,14 @@
import {openAuthSessionAsync} from 'expo-web-browser';
import qs from 'querystringify';
import React, {useEffect, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Linking, Platform, StyleSheet, View, type EventSubscription} from 'react-native';
import urlParse from 'url-parse';
import {Sso} from '@constants';
import {isBetaApp} from '@utils/general';
import {createSamlChallenge} from '@utils/saml_challenge';
import AuthError from './components/auth_error';
import AuthRedirect from './components/auth_redirect';
@ -17,6 +18,7 @@ import AuthSuccess from './components/auth_success';
interface SSOAuthenticationProps {
doSSOLogin: (bearerToken: string, csrfToken: string) => void;
doSSOCodeExchange: (loginCode: string, samlChallenge: {codeVerifier: string; state: string}) => void;
loginError: string;
loginUrl: string;
setLoginError: (value: string) => void;
@ -30,7 +32,7 @@ const style = StyleSheet.create({
},
});
const SSOAuthentication = ({doSSOLogin, loginError, loginUrl, setLoginError, theme}: SSOAuthenticationProps) => {
const SSOAuthentication = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl, setLoginError, theme}: SSOAuthenticationProps) => {
const [error, setError] = useState<string>('');
const [loginSuccess, setLoginSuccess] = useState(false);
const intl = useIntl();
@ -40,7 +42,8 @@ const SSOAuthentication = ({doSSOLogin, loginError, loginUrl, setLoginError, the
}
const redirectUrl = customUrlScheme + 'callback';
const init = async (resetErrors = true) => {
const samlChallenge = useMemo(() => createSamlChallenge(), []);
const init = useCallback(async (resetErrors = true) => {
setLoginSuccess(false);
if (resetErrors !== false) {
setError('');
@ -50,12 +53,22 @@ const SSOAuthentication = ({doSSOLogin, loginError, loginUrl, setLoginError, the
const query: Record<string, string> = {
...parsedUrl.query,
redirect_to: redirectUrl,
state: samlChallenge.state,
code_challenge: samlChallenge.codeChallenge,
code_challenge_method: samlChallenge.method,
};
parsedUrl.set('query', qs.stringify(query));
const url = parsedUrl.toString();
const result = await openAuthSessionAsync(url, null, {preferEphemeralSession: true, createTask: false});
if ('url' in result && result.url) {
const resultUrl = urlParse(result.url, true);
const loginCode = resultUrl.query?.login_code as string | undefined;
if (loginCode) {
// Prefer code exchange when available
setLoginSuccess(true);
doSSOCodeExchange(loginCode, {codeVerifier: samlChallenge.codeVerifier, state: samlChallenge.state});
return;
}
const bearerToken = resultUrl.query?.MMAUTHTOKEN;
const csrfToken = resultUrl.query?.MMCSRF;
if (bearerToken && csrfToken) {
@ -70,7 +83,7 @@ const SSOAuthentication = ({doSSOLogin, loginError, loginUrl, setLoginError, the
}),
);
}
};
}, [doSSOCodeExchange, doSSOLogin, intl, loginUrl, samlChallenge, redirectUrl, setLoginError]);
useEffect(() => {
let listener: EventSubscription | null = null;
@ -80,6 +93,12 @@ const SSOAuthentication = ({doSSOLogin, loginError, loginUrl, setLoginError, the
setError('');
if (url && url.startsWith(redirectUrl)) {
const parsedUrl = urlParse(url, true);
const loginCode = parsedUrl.query?.login_code as string | undefined;
if (loginCode) {
setLoginSuccess(true);
doSSOCodeExchange(loginCode, {codeVerifier: samlChallenge.codeVerifier, state: samlChallenge.state});
return;
}
const bearerToken = parsedUrl.query?.MMAUTHTOKEN;
const csrfToken = parsedUrl.query?.MMCSRF;
if (bearerToken && csrfToken) {
@ -107,7 +126,7 @@ const SSOAuthentication = ({doSSOLogin, loginError, loginUrl, setLoginError, the
clearTimeout(timeout);
listener?.remove();
};
}, []);
}, [doSSOCodeExchange, doSSOLogin, init, intl, samlChallenge, redirectUrl]);
let content;
if (loginSuccess) {

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import qs from 'querystringify';
import React, {useEffect, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Linking, Platform, View} from 'react-native';
import urlParse from 'url-parse';
@ -10,6 +10,7 @@ import urlParse from 'url-parse';
import {Sso} from '@constants';
import {isErrorWithMessage} from '@utils/errors';
import {isBetaApp} from '@utils/general';
import {createSamlChallenge} from '@utils/saml_challenge';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {tryOpenURL} from '@utils/url';
@ -20,6 +21,7 @@ import AuthSuccess from './components/auth_success';
interface SSOWithRedirectURLProps {
doSSOLogin: (bearerToken: string, csrfToken: string) => void;
doSSOCodeExchange: (loginCode: string, samlChallenge: {codeVerifier: string; state: string}) => void;
loginError: string;
loginUrl: string;
setLoginError: (value: string) => void;
@ -57,7 +59,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, loginError, loginUrl, setLoginError, theme}: SSOWithRedirectURLProps) => {
const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl, setLoginError, theme}: SSOWithRedirectURLProps) => {
const [error, setError] = useState<string>('');
const [loginSuccess, setLoginSuccess] = useState(false);
const style = getStyleSheet(theme);
@ -68,7 +70,8 @@ const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, loginError, loginUrl,
}
const redirectUrl = customUrlScheme + 'callback';
const init = (resetErrors = true) => {
const samlChallenge = useMemo(() => createSamlChallenge(), []);
const init = useCallback((resetErrors = true) => {
setLoginSuccess(false);
if (resetErrors !== false) {
setError('');
@ -78,6 +81,9 @@ const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, loginError, loginUrl,
const query: Record<string, string> = {
...parsedUrl.query,
redirect_to: redirectUrl,
state: samlChallenge.state,
code_challenge: samlChallenge.codeChallenge,
code_challenge_method: samlChallenge.method,
};
parsedUrl.set('query', qs.stringify(query));
const url = parsedUrl.toString();
@ -101,12 +107,19 @@ const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, loginError, loginUrl,
};
tryOpenURL(url, onError);
};
}, [intl, loginUrl, redirectUrl, samlChallenge, setLoginError]);
useEffect(() => {
const startedRef = {current: false};
const onURLChange = ({url}: { url: string }) => {
if (url && url.startsWith(redirectUrl)) {
const parsedUrl = urlParse(url, true);
const loginCode = parsedUrl.query?.login_code as string | undefined;
if (loginCode) {
setLoginSuccess(true);
doSSOCodeExchange(loginCode, {codeVerifier: samlChallenge.codeVerifier, state: samlChallenge.state});
return;
}
const bearerToken = parsedUrl.query?.MMAUTHTOKEN;
const csrfToken = parsedUrl.query?.MMCSRF;
if (bearerToken && csrfToken) {
@ -126,13 +139,16 @@ const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, loginError, loginUrl,
const listener = Linking.addEventListener('url', onURLChange);
const timeout = setTimeout(() => {
init(false);
if (!startedRef.current) {
startedRef.current = true;
init(false);
}
}, 1000);
return () => {
listener.remove();
clearTimeout(timeout);
};
}, []);
}, [doSSOCodeExchange, doSSOLogin, init, intl, samlChallenge, redirectUrl]);
let content;
if (loginSuccess) {

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// SAML mobile code-exchange challenge helpers (modeled after RFC 7636)
import base64 from 'base-64';
import {getRandomValues, randomUUID} from 'expo-crypto';
import {sha256} from 'js-sha256';
function getRandomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length);
getRandomValues(bytes);
return bytes;
}
function bytesToBase64Url(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const b64 = base64.encode(binary);
return b64.replace(/\+/g, '-').replace(/\//g, '_').split('=').join('');
}
export function generateState(): string {
// Use UUID for state; allowed challenge chars include '-'
return randomUUID();
}
export function generateCodeVerifier(length = 64): string {
// Allowed characters are ALPHA / DIGIT / "-" / "." / "_" / "~"
// We generate random bytes and base64url encode without padding which fits the charset
const bytes = getRandomBytes(length);
return bytesToBase64Url(bytes);
}
export function computeS256CodeChallenge(verifier: string): string {
const hashArrayBuffer = sha256.arrayBuffer(verifier) as ArrayBuffer;
const bytes = new Uint8Array(hashArrayBuffer);
return bytesToBase64Url(bytes);
}
export type SAMLChallenge = {
state: string;
codeVerifier: string;
codeChallenge: string;
method: 'S256';
};
export function createSamlChallenge(): SAMLChallenge {
const state = generateState();
const codeVerifier = generateCodeVerifier(64);
const codeChallenge = computeS256CodeChallenge(codeVerifier);
return {state, codeVerifier, codeChallenge, method: 'S256'};
}

6
package-lock.json generated
View file

@ -64,6 +64,7 @@
"fflate": "0.8.2",
"fuse.js": "7.1.0",
"html-entities": "2.6.0",
"js-sha256": "0.11.1",
"mime-db": "1.54.0",
"moment-timezone": "0.5.48",
"node-html-parser": "7.0.1",
@ -16012,6 +16013,11 @@
"integrity": "sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==",
"license": "MIT"
},
"node_modules/js-sha256": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
"integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"license": "MIT"

View file

@ -65,6 +65,7 @@
"fflate": "0.8.2",
"fuse.js": "7.1.0",
"html-entities": "2.6.0",
"js-sha256": "0.11.1",
"mime-db": "1.54.0",
"moment-timezone": "0.5.48",
"node-html-parser": "7.0.1",

View file

@ -3,7 +3,7 @@
jsfiles=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.js$|\.ts$|\.tsx$')
exit_code=0
if [ -z "jsfiles" ]; then
if [ -z "$jsfiles" ]; then
exit 0
fi

View file

@ -35,6 +35,11 @@ jest.mock('expo-application', () => {
jest.mock('expo-crypto', () => ({
randomUUID: jest.fn(() => '12345678-1234-1234-1234-1234567890ab'),
getRandomValues: jest.fn((arr: Uint8Array) => {
// deterministic non-zero bytes for tests
arr.fill(0x7b);
return arr;
}),
}));
jest.mock('expo-device', () => {

15
types/vendor/js-sha256.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
declare module 'js-sha256' {
export interface Sha256Fn {
(message: string | ArrayBuffer | Uint8Array): string;
array(message: string | ArrayBuffer | Uint8Array): number[];
arrayBuffer(message: string | ArrayBuffer | Uint8Array): ArrayBuffer;
hex(message: string | ArrayBuffer | Uint8Array): string;
}
const sha256: Sha256Fn;
export default sha256;
export {sha256};
}