diff --git a/app/actions/remote/entry/login.ts b/app/actions/remote/entry/login.ts index f70a30500..a605b92ce 100644 --- a/app/actions/remote/entry/login.ts +++ b/app/actions/remote/entry/login.ts @@ -35,7 +35,7 @@ export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?: const credentials = await getServerCredentials(serverUrl); if (credentials?.token) { SecurityManager.addServer(serverUrl, clData.config, true); - WebsocketManager.createClient(serverUrl, credentials.token); + WebsocketManager.createClient(serverUrl, credentials.token, credentials.preauthSecret); await WebsocketManager.initializeClient(serverUrl, 'Login'); SecurityManager.setActiveServer(serverUrl); } diff --git a/app/actions/remote/general.ts b/app/actions/remote/general.ts index 92ee79ebe..853f5d0a2 100644 --- a/app/actions/remote/general.ts +++ b/app/actions/remote/general.ts @@ -34,10 +34,10 @@ async function getDeviceIdForPing(serverUrl: string, checkDeviceId: boolean) { } // Default timeout interval for ping is 5 seconds -export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeoutInterval = 5000) => { +export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeoutInterval = 5000, preauthSecret?: string) => { let client: Client; try { - client = await NetworkManager.createClient(serverUrl); + client = await NetworkManager.createClient(serverUrl, undefined, preauthSecret); } catch (error) { return {error}; } diff --git a/app/actions/remote/session.test.ts b/app/actions/remote/session.test.ts index f22013c1a..b36a35511 100644 --- a/app/actions/remote/session.test.ts +++ b/app/actions/remote/session.test.ts @@ -46,7 +46,7 @@ const throwFunc = () => { const mockClient = { login: jest.fn(() => user1), setCSRFToken: jest.fn(), - setBearerToken: jest.fn(), + setClientCredentials: jest.fn(), getClientConfigOld: jest.fn(() => ({})), getClientLicenseOld: jest.fn(() => ({})), getSessions: jest.fn(() => [session1]), diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index 8327bdfa5..40d426e13 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -300,7 +300,7 @@ export const sendPasswordResetEmail = async (serverUrl: string, email: string) = } }; -export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string): Promise => { +export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string, preauthSecret?: string): Promise => { const database = DatabaseManager.appDatabase?.database; if (!database) { return {error: 'App database not found', failed: true}; @@ -309,7 +309,7 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser try { const client = NetworkManager.getClient(serverUrl); - client.setBearerToken(bearerToken); + client.setClientCredentials(bearerToken, preauthSecret); client.setCSRFToken(csrfToken); // Setting up active database for this SSO login flow diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index b3a7ad030..9978e751e 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -9,11 +9,11 @@ import ClientTracking from './tracking'; import type {APIClientInterface} from '@mattermost/react-native-network-client'; export default class ClientBase extends ClientTracking { - constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) { + constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string, preauthSecret?: string) { super(apiClient); - if (bearerToken) { - this.setBearerToken(bearerToken); + if (bearerToken || preauthSecret) { + this.setClientCredentials(bearerToken || '', preauthSecret || ''); } if (csrfToken) { this.setCSRFToken(csrfToken); diff --git a/app/client/rest/constants.ts b/app/client/rest/constants.ts index c02ba7c0c..5aeb125f0 100644 --- a/app/client/rest/constants.ts +++ b/app/client/rest/constants.ts @@ -10,6 +10,7 @@ export const HEADER_REQUESTED_WITH = 'X-Requested-With'; export const HEADER_TOKEN = 'Token'; export const HEADER_USER_AGENT = 'User-Agent'; export const HEADER_X_CSRF_TOKEN = 'X-CSRF-Token'; +export const HEADER_X_MATTERMOST_PREAUTH_SECRET = 'X-Mattermost-Preauth-Secret'; export const HEADER_X_VERSION_ID = 'X-Version-Id'; export const DEFAULT_LIMIT_BEFORE = 30; export const DEFAULT_LIMIT_AFTER = 30; diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts index f6929880f..ce3a68252 100644 --- a/app/client/rest/index.ts +++ b/app/client/rest/index.ts @@ -78,8 +78,8 @@ class Client extends mix(ClientBase).with( ClientPlaybooks, ) { // eslint-disable-next-line no-useless-constructor - constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) { - super(apiClient, serverUrl, bearerToken, csrfToken); + constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string, preauthSecret?: string) { + super(apiClient, serverUrl, bearerToken, csrfToken, preauthSecret); } } diff --git a/app/client/rest/tracking.test.ts b/app/client/rest/tracking.test.ts index 05997da08..280842307 100644 --- a/app/client/rest/tracking.test.ts +++ b/app/client/rest/tracking.test.ts @@ -92,10 +92,10 @@ describe('ClientTracking', () => { it('should set bearer token', () => { const token = 'testToken'; - client.setBearerToken(token); + client.setClientCredentials(token); expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} ${token}`); - expect(require('@init/credentials').setServerCredentials).toHaveBeenCalledWith(apiClientMock.baseUrl, token); + expect(require('@init/credentials').setServerCredentials).toHaveBeenCalledWith(apiClientMock.baseUrl, token, undefined); }); it('should set CSRF token', () => { @@ -107,7 +107,7 @@ describe('ClientTracking', () => { it('should get request headers', () => { client.setCSRFToken('csrfToken'); - client.setBearerToken('testToken'); + client.setClientCredentials('testToken'); const headers = client.getRequestHeaders('POST'); expect(headers[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} testToken`); @@ -823,5 +823,59 @@ describe('ClientTracking', () => { expect(result).toBe(100); }); }); + + describe('setClientCredentials', () => { + it('should set shared password header when provided', () => { + client.setClientCredentials('bearer-token', 'shared-password'); + + expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer-token`); + expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password'); + }); + + it('should remove shared password header when undefined', () => { + // First set a shared password + client.setClientCredentials('bearer-token', 'shared-password'); + expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password'); + + // Then remove it by setting undefined + client.setClientCredentials('bearer-token', undefined); + expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined(); + }); + + it('should remove shared password header when empty string', () => { + // First set a shared password + client.setClientCredentials('bearer-token', 'shared-password'); + expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password'); + + // Then remove it by setting empty string + client.setClientCredentials('bearer-token', ''); + expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined(); + }); + + it('should always set bearer token correctly', () => { + client.setClientCredentials('test-bearer', 'shared-password'); + expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} test-bearer`); + + client.setClientCredentials('new-bearer', undefined); + expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} new-bearer`); + }); + + it('should handle multiple header updates correctly', () => { + // Set initial credentials + client.setClientCredentials('bearer1', 'password1'); + expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer1`); + expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('password1'); + + // Update to new password + client.setClientCredentials('bearer2', 'password2'); + expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer2`); + expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('password2'); + + // Remove password + client.setClientCredentials('bearer3', undefined); + expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer3`); + expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined(); + }); + }); }); /* eslint-enable max-lines */ diff --git a/app/client/rest/tracking.ts b/app/client/rest/tracking.ts index b0407d0c7..d86486539 100644 --- a/app/client/rest/tracking.ts +++ b/app/client/rest/tracking.ts @@ -72,9 +72,17 @@ export default class ClientTracking { this.apiClient = apiClient; } - setBearerToken(bearerToken: string) { + setClientCredentials(bearerToken: string, preauthSecret?: string) { this.requestHeaders[ClientConstants.HEADER_AUTH] = `${ClientConstants.HEADER_BEARER} ${bearerToken}`; - setServerCredentials(this.apiClient.baseUrl, bearerToken); + + if (preauthSecret) { + this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET] = preauthSecret; + } else { + // Remove shared password header when undefined + delete this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]; + } + + setServerCredentials(this.apiClient.baseUrl, bearerToken, preauthSecret); } setCSRFToken(csrfToken: string) { @@ -412,7 +420,8 @@ export default class ClientTracking { const bearerToken = headers[ClientConstants.HEADER_TOKEN] || headers[ClientConstants.HEADER_TOKEN.toLowerCase()]; if (bearerToken) { - this.setBearerToken(bearerToken); + const existingSharedPassword = this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]; + this.setClientCredentials(bearerToken, existingSharedPassword); } if (response.ok) { diff --git a/app/client/websocket/index.ts b/app/client/websocket/index.ts index 214fd55e4..7ed730d6b 100644 --- a/app/client/websocket/index.ts +++ b/app/client/websocket/index.ts @@ -4,6 +4,7 @@ import {type ClientHeaders, getOrCreateWebSocketClient, type WebSocketClientInterface, WebSocketReadyState} from '@mattermost/react-native-network-client'; import {Platform} from 'react-native'; +import * as ClientConstants from '@client/rest/constants'; import {WebsocketEvents} from '@constants'; import DatabaseManager from '@database/manager'; import {getConfigValue} from '@queries/servers/system'; @@ -26,6 +27,7 @@ export default class WebSocketClient { private connectionTimeout: NodeJS.Timeout | undefined; private connectionId = ''; private token: string; + private preauthSecret?: string; private stop = false; private url = ''; private serverUrl: string; @@ -58,9 +60,10 @@ export default class WebSocketClient { private closeCallback?: (connectFailCount: number) => void; private connectingCallback?: () => void; - constructor(serverUrl: string, token: string) { + constructor(serverUrl: string, token: string, preauthSecret?: string) { this.token = token; this.serverUrl = serverUrl; + this.preauthSecret = preauthSecret; } public async initialize(opts = {}, shouldSkipSync = false) { @@ -134,6 +137,13 @@ export default class WebSocketClient { // iOS is using he underlying cookieJar headers.Authorization = `Bearer ${this.token}`; } + + // Add shared password header if available + if (this.preauthSecret) { + headers[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET] = this.preauthSecret; + logDebug('WebSocket: Added shared password header for', this.serverUrl); + } + const {client} = await getOrCreateWebSocketClient(this.url, {headers, timeoutInterval: WEBSOCKET_TIMEOUT}); // Check again if the client is the same, to avoid race conditions diff --git a/app/init/credentials.ts b/app/init/credentials.ts index ed8ed1176..74bb0ce59 100644 --- a/app/init/credentials.ts +++ b/app/init/credentials.ts @@ -44,7 +44,7 @@ export const getActiveServerUrl = async () => { return serverUrl || undefined; }; -export const setServerCredentials = (serverUrl: string, token: string) => { +export const setServerCredentials = (serverUrl: string, token: string, preauthSecret?: string) => { if (!(serverUrl && token)) { return; } @@ -60,14 +60,29 @@ export const setServerCredentials = (serverUrl: string, token: string) => { accessGroup, securityLevel: KeyChain.SECURITY_LEVEL.SECURE_SOFTWARE, }; + + // Store main token credentials (clean format) KeyChain.setInternetCredentials(serverUrl, token, token, options); + + // Store preauth secret separately if provided + if (preauthSecret) { + KeyChain.setGenericPassword('preshared_secret', preauthSecret, { + server: serverUrl, + ...options, + }); + } } catch (e) { logWarning('could not set credentials', e); } }; export const removeServerCredentials = async (serverUrl: string) => { - return KeyChain.resetInternetCredentials({server: serverUrl}); + await KeyChain.resetInternetCredentials({server: serverUrl}); + try { + await KeyChain.resetGenericPassword({server: serverUrl}); + } catch (e) { + // Preauth secret might not exist, ignore errors + } }; export const removeActiveServerCredentials = async () => { @@ -79,23 +94,42 @@ export const removeActiveServerCredentials = async () => { export const getServerCredentials = async (serverUrl: string): Promise => { try { + // Get main credentials const credentials = await KeyChain.getInternetCredentials(serverUrl); - if (credentials) { - // TODO: Pre-Gekidou we were concatenating the deviceToken and the userId in - // credentials.username so we need to check the length of credentials.username.split(','). - // This check should be removed at some point. https://mattermost.atlassian.net/browse/MM-43483 - const parts = credentials.username.split(','); - const userId = parts[parts.length - 1]; - - const token = credentials.password; - - if (token && token !== 'undefined') { - return {serverUrl, userId, token}; - } + if (!credentials) { + return null; } - return null; + // TODO: Pre-Gekidou we were concatenating the deviceToken and the userId in + // credentials.username so we need to check the length of credentials.username.split(','). + // This check should be removed at some point. https://mattermost.atlassian.net/browse/MM-43483 + const parts = credentials.username.split(','); + const userId = parts[parts.length - 1]; + const token = credentials.password; + + if (!token || token === 'undefined') { + return null; + } + + // Get preauth secret separately + let preauthSecret: string | undefined; + try { + const preauthCredentials = await KeyChain.getGenericPassword({ + server: serverUrl, + }); + preauthSecret = preauthCredentials ? preauthCredentials.password : undefined; + } catch (e) { + // Preauth secret is optional, so ignore errors + preauthSecret = undefined; + } + + return { + serverUrl, + userId, + token, + preauthSecret, + }; } catch (e) { return null; } diff --git a/app/managers/network_manager.ts b/app/managers/network_manager.ts index 64b45624f..61dde731d 100644 --- a/app/managers/network_manager.ts +++ b/app/managers/network_manager.ts @@ -76,9 +76,9 @@ class NetworkManagerSingleton { }; public init = async (serverCredentials: ServerCredential[]) => { - for await (const {serverUrl, token} of serverCredentials) { + for await (const {serverUrl, token, preauthSecret} of serverCredentials) { try { - await this.createClient(serverUrl, token); + await this.createClient(serverUrl, token, preauthSecret); } catch (error) { logError('NetworkManager init error', error); } @@ -99,12 +99,13 @@ class NetworkManagerSingleton { return client; }; - public createClient = async (serverUrl: string, bearerToken?: string) => { - const config = await this.buildConfig(); + public createClient = async (serverUrl: string, bearerToken?: string, preauthSecret?: string) => { + const config = await this.buildConfig(preauthSecret); + try { const {client} = await getOrCreateAPIClient(serverUrl, config, this.clientErrorEventHandler); const csrfToken = await getCSRFFromCookie(serverUrl); - this.clients[serverUrl] = new Client(client, serverUrl, bearerToken, csrfToken); + this.clients[serverUrl] = new Client(client, serverUrl, bearerToken, csrfToken, preauthSecret); } catch (error) { throw new ClientError(serverUrl, { message: 'Can’t find this server. Check spelling and URL format.', @@ -120,11 +121,12 @@ class NetworkManagerSingleton { return this.clients[serverUrl]; }; - private buildConfig = async () => { + private buildConfig = async (preauthSecret?: string) => { const userAgent = `Mattermost Mobile/${nativeApplicationVersion}+${nativeBuildVersion} (${osName}; ${osVersion}; ${modelName})`; const managedConfig = ManagedApp.enabled ? Emm.getManagedConfig() : undefined; const headers: Record = { [ClientConstants.HEADER_USER_AGENT]: userAgent, + ...(preauthSecret ? {[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]: preauthSecret} : {}), ...this.DEFAULT_CONFIG.headers, }; diff --git a/app/managers/websocket_manager.test.ts b/app/managers/websocket_manager.test.ts index 22e8005f3..3c5938e86 100644 --- a/app/managers/websocket_manager.test.ts +++ b/app/managers/websocket_manager.test.ts @@ -90,7 +90,7 @@ describe('WebsocketManager', () => { await manager.init(mockCredentials); expect(NetInfo.fetch).toHaveBeenCalled(); - expect(WebSocketClient).toHaveBeenCalledWith(mockServerUrl, mockToken); + expect(WebSocketClient).toHaveBeenCalledWith(mockServerUrl, mockToken, undefined); expect(NetInfo.addEventListener).toHaveBeenCalled(); }); diff --git a/app/managers/websocket_manager.ts b/app/managers/websocket_manager.ts index 7f8738fde..d0736fdf4 100644 --- a/app/managers/websocket_manager.ts +++ b/app/managers/websocket_manager.ts @@ -48,10 +48,10 @@ class WebsocketManagerSingleton { this.netConnected = Boolean(netInfo.isConnected); this.netType = netInfo.type; serverCredentials.forEach( - ({serverUrl, token}) => { + ({serverUrl, token, preauthSecret}) => { try { DatabaseManager.getServerDatabaseAndOperator(serverUrl); - this.createClient(serverUrl, token); + this.createClient(serverUrl, token, preauthSecret); } catch (error) { logError('WebsocketManager init error', error); } @@ -80,12 +80,12 @@ class WebsocketManagerSingleton { this.getConnectedSubject(serverUrl).next('not_connected'); }; - public createClient = (serverUrl: string, bearerToken: string) => { + public createClient = (serverUrl: string, bearerToken: string, preauthSecret?: string) => { if (this.clients[serverUrl]) { this.invalidateClient(serverUrl); } - const client = new WebSocketClient(serverUrl, bearerToken); + const client = new WebSocketClient(serverUrl, bearerToken, preauthSecret); client.setFirstConnectCallback(() => this.onFirstConnect(serverUrl)); client.setEventCallback((evt: WebSocketMessage) => handleWebSocketEvent(serverUrl, evt)); diff --git a/app/screens/login/sso_options.tsx b/app/screens/login/sso_options.tsx index 37f3fd4b2..6039b66c7 100644 --- a/app/screens/login/sso_options.tsx +++ b/app/screens/login/sso_options.tsx @@ -69,7 +69,10 @@ const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => { }; componentArray.push( - +