MM-65085: Support Pre Shared Password on server connect (#9082)

* feat: add shared server password to server setup

* feat: allow editing the sever

* refactor: changed password -> secret, styling and tests

* e2e: draft e2e tests

* chore: lint fix

* feat: also send preauth secret header when using native share

* fix: removed unused server database migration

credentials are being stored in the keychain

* i18n: added missing english translations

* test(e2e): simplified connection tests

* test(e2e): rework

* refactor: remove setBearerToken

* chore: restore migrations the way it was

* chore: reverted file to original state

* chore: removed unneeded test and renamed password to secret

* chore: function version

* chore: updated forms i18n keys

* chore: remove if from test

* chore: unneeded variable

* fix: add missing key on object list

* refactor: swift keychain access to retrieve all credentials in one call

* revert: edit server screen

* refactor: credentials use getGenericCredential

* fix: objc code calling old method

* fix: added scroll to login screen

* chore: variable names

* fix: avoid inline styles

* fix: Improved appVersion positioning

* Update app/screens/server/form.tsx

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* feat: show error message on 403

* Revert "feat: show error message on 403"

This reverts commit f41630c767e10211adf1885321ceefd8a0931e32.

---------

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
This commit is contained in:
Felipe Martin 2025-09-01 11:24:15 +02:00 committed by GitHub
parent 2c1d896e9b
commit f50056f57b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 602 additions and 107 deletions

View file

@ -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);
}

View file

@ -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};
}

View file

@ -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]),

View file

@ -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<LoginActionResponse> => {
export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string, preauthSecret?: string): Promise<LoginActionResponse> => {
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

View file

@ -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);

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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 */

View file

@ -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) {

View file

@ -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

View file

@ -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<ServerCredential|null> => {
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;
}

View file

@ -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: 'Cant 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<ManagedConfig>() : undefined;
const headers: Record<string, string> = {
[ClientConstants.HEADER_USER_AGENT]: userAgent,
...(preauthSecret ? {[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]: preauthSecret} : {}),
...this.DEFAULT_CONFIG.headers,
};

View file

@ -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();
});

View file

@ -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));

View file

@ -69,7 +69,10 @@ const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => {
};
componentArray.push(
<View style={styleButtonWrapper}>
<View
style={styleButtonWrapper}
key={ssoType}
>
<Button
key={ssoType}
onPress={handlePress}

View file

@ -1,11 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type RefObject, useCallback, useRef} from 'react';
import React, {type RefObject, useCallback, useRef, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Keyboard, View} from 'react-native';
import {Keyboard, Pressable, View} from 'react-native';
import Button from '@components/button';
import CompassIcon from '@components/compass_icon';
import FloatingTextInput, {type FloatingTextInputRef} from '@components/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import {useAvoidKeyboard} from '@hooks/device';
@ -23,8 +24,10 @@ type Props = {
disableServerUrl: boolean;
handleConnect: () => void;
handleDisplayNameTextChanged: (text: string) => void;
handlePreauthSecretTextChanged: (text: string) => void;
handleUrlTextChanged: (text: string) => void;
keyboardAwareRef: RefObject<KeyboardAwareScrollView>;
preauthSecret?: string;
theme: Theme;
url?: string;
urlError?: string;
@ -49,6 +52,24 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
marginTop: 8,
...typography('Body', 75, 'Regular'),
},
advancedOptionsContainer: {
width: '100%',
marginTop: 16,
},
advancedOptionsHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
paddingVertical: 12,
paddingHorizontal: 4,
},
advancedOptionsTitle: {
color: theme.linkColor,
...typography('Body', 75, 'SemiBold'),
},
advancedOptionsContent: {
width: '100%',
},
connectButtonContainer: {
width: '100%',
marginTop: 32,
@ -66,6 +87,18 @@ const messages = defineMessages({
id: 'mobile.components.select_server_view.connecting',
defaultMessage: 'Connecting',
},
advancedOptions: {
id: 'mobile.components.select_server_view.advancedOptions',
defaultMessage: 'Advanced Options',
},
preauthSecret: {
id: 'mobile.components.select_server_view.sharedSecret',
defaultMessage: 'Pre-authentication secret',
},
preauthSecretHelp: {
id: 'mobile.components.select_server_view.sharedSecretHelp',
defaultMessage: 'The pre-authentication secret shared by the administrator',
},
});
const ServerForm = ({
@ -77,17 +110,22 @@ const ServerForm = ({
disableServerUrl,
handleConnect,
handleDisplayNameTextChanged,
handlePreauthSecretTextChanged,
handleUrlTextChanged,
keyboardAwareRef,
preauthSecret = '',
theme,
url = '',
urlError,
}: Props) => {
const {formatMessage} = useIntl();
const displayNameRef = useRef<FloatingTextInputRef>(null);
const preauthSecretRef = useRef<FloatingTextInputRef>(null);
const urlRef = useRef<FloatingTextInputRef>(null);
const styles = getStyleSheet(theme);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
useAvoidKeyboard(keyboardAwareRef);
const onConnect = useCallback(() => {
@ -99,6 +137,18 @@ const ServerForm = ({
displayNameRef.current?.focus();
}, []);
const onDisplayNameSubmit = useCallback(() => {
if (showAdvancedOptions) {
preauthSecretRef.current?.focus();
} else {
onConnect();
}
}, [showAdvancedOptions, onConnect]);
const toggleAdvancedOptions = useCallback(() => {
setShowAdvancedOptions(!showAdvancedOptions);
}, [showAdvancedOptions]);
const connectButtonTestId = buttonDisabled ? 'server_form.connect.button.disabled' : 'server_form.connect.button';
return (
@ -108,7 +158,6 @@ const ServerForm = ({
autoCorrect={false}
autoCapitalize={'none'}
autoFocus={autoFocus}
blurOnSubmit={false}
containerStyle={styles.enterServer}
enablesReturnKeyAutomatically={true}
editable={!disableServerUrl}
@ -139,15 +188,16 @@ const ServerForm = ({
defaultMessage: 'Display Name',
})}
onChangeText={handleDisplayNameTextChanged}
onSubmitEditing={onConnect}
onSubmitEditing={onDisplayNameSubmit}
ref={displayNameRef}
returnKeyType='done'
returnKeyType={showAdvancedOptions ? 'next' : 'done'}
spellCheck={false}
testID='server_form.server_display_name.input'
theme={theme}
value={displayName}
/>
</View>
{!displayNameError &&
<FormattedText
defaultMessage={'Choose a display name for your server'}
@ -156,6 +206,51 @@ const ServerForm = ({
testID={'server_form.display_help'}
/>
}
<View style={styles.advancedOptionsContainer}>
<Pressable
onPress={toggleAdvancedOptions}
style={styles.advancedOptionsHeader}
testID='server_form.advanced_options.toggle'
>
<CompassIcon
name={showAdvancedOptions ? 'chevron-up' : 'chevron-down'}
size={20}
style={styles.advancedOptionsTitle}
/>
<FormattedText
defaultMessage='Advanced Options'
id='mobile.components.select_server_view.advancedOptions'
style={styles.advancedOptionsTitle}
/>
</Pressable>
{showAdvancedOptions && (
<View style={styles.advancedOptionsContent}>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
enablesReturnKeyAutomatically={true}
label={formatMessage(messages.preauthSecret)}
onChangeText={handlePreauthSecretTextChanged}
onSubmitEditing={onConnect}
ref={preauthSecretRef}
returnKeyType='done'
secureTextEntry={true}
spellCheck={false}
testID='server_form.preauth_secret.input'
theme={theme}
value={preauthSecret}
/>
<FormattedText
{...messages.preauthSecretHelp}
style={styles.chooseText}
testID='server_form.preauth_secret_help'
/>
</View>
)}
</View>
<View style={styles.connectButtonContainer}>
<Button
disabled={buttonDisabled}

View file

@ -55,12 +55,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
appInfo: {
color: changeOpacity(theme.centerChannelColor, 0.56),
},
appVersionContainer: {
alignItems: 'center',
paddingHorizontal: 20,
marginTop: 24,
},
flex: {
flex: 1,
},
scrollContainer: {
alignItems: 'center',
height: '90%',
flexGrow: 1,
justifyContent: 'center',
},
}));
@ -85,6 +90,7 @@ const Server = ({
const [connecting, setConnecting] = useState(false);
const [displayName, setDisplayName] = useState<string>('');
const [buttonDisabled, setButtonDisabled] = useState(true);
const [preauthSecret, setPreauthSecret] = useState<string>('');
const [url, setUrl] = useState<string>('');
const [displayNameError, setDisplayNameError] = useState<string | undefined>();
const [urlError, setUrlError] = useState<string | undefined>();
@ -191,6 +197,7 @@ const Server = ({
launchType,
license,
serverDisplayName: displayName,
serverPreauthSecret: preauthSecret.trim() || undefined,
serverUrl,
ssoOptions,
theme,
@ -268,6 +275,10 @@ const Server = ({
setUrl(text);
}, []);
const handlePreauthSecretTextChanged = useCallback((text: string) => {
setPreauthSecret(text);
}, []);
const isServerUrlValid = (serverUrl?: string) => {
const testUrl = sanitizeUrl(serverUrl ?? url);
if (!isValidUrl(testUrl)) {
@ -289,7 +300,7 @@ const Server = ({
cancelPing = undefined;
};
const ping = await getServerUrlAfterRedirect(pingUrl, !retryWithHttp);
const ping = await getServerUrlAfterRedirect(pingUrl, !retryWithHttp, preauthSecret.trim() || undefined);
if (!ping.url) {
cancelPing();
if (retryWithHttp) {
@ -302,7 +313,7 @@ const Server = ({
}
return;
}
const result = await doPing(ping.url, true, managedConfig?.timeout ? parseInt(managedConfig?.timeout, 10) : undefined);
const result = await doPing(ping.url, true, managedConfig?.timeout ? parseInt(managedConfig?.timeout, 10) : undefined, preauthSecret.trim() || undefined);
if (canceled) {
return;
@ -402,14 +413,21 @@ const Server = ({
disableServerUrl={disableServerUrl}
handleConnect={handleConnect}
handleDisplayNameTextChanged={handleDisplayNameTextChanged}
handlePreauthSecretTextChanged={handlePreauthSecretTextChanged}
handleUrlTextChanged={handleUrlTextChanged}
keyboardAwareRef={keyboardAwareRef}
preauthSecret={preauthSecret}
theme={theme}
url={url}
urlError={urlError}
/>
<View style={styles.appVersionContainer}>
<AppVersion
textStyle={styles.appInfo}
isWrapped={false}
/>
</View>
</KeyboardAwareScrollView>
<AppVersion textStyle={styles.appInfo}/>
</AnimatedSafeArea>
</View>
);

View file

@ -31,6 +31,7 @@ interface SSOProps extends LaunchProps {
license: Partial<ClientLicense>;
ssoType: string;
serverDisplayName: string;
serverPreauthSecret?: string;
theme: Theme;
}
@ -45,7 +46,7 @@ const styles = StyleSheet.create({
const SSO = ({
closeButtonId, componentId, config, extra,
launchError, launchType, serverDisplayName,
serverUrl, ssoType, theme,
serverPreauthSecret, serverUrl, ssoType, theme,
}: SSOProps) => {
const [loginError, setLoginError] = useState<string>('');
let loginUrl = '';
@ -89,7 +90,7 @@ const SSO = ({
};
const doSSOLogin = async (bearerToken: string, csrfToken: string) => {
const result: LoginActionResponse = await ssoLogin(serverUrl!, serverDisplayName, config.DiagnosticId!, bearerToken, csrfToken);
const result: LoginActionResponse = await ssoLogin(serverUrl!, serverDisplayName, config.DiagnosticId!, bearerToken, csrfToken, serverPreauthSecret);
if (result?.error && result.failed) {
onLoadEndError(result.error);
return;

View file

@ -5,6 +5,7 @@ import GenericClient from '@mattermost/react-native-network-client';
import {Linking} from 'react-native';
import urlParse from 'url-parse';
import * as ClientConstants from '@client/rest/constants';
import {Files} from '@constants';
import {emptyFunction} from '@utils/general';
import {logDebug} from '@utils/log';
@ -58,11 +59,17 @@ export async function getUrlAfterRedirect(url: string, useHttp = false) {
}
}
export async function getServerUrlAfterRedirect(serverUrl: string, useHttp = false) {
export async function getServerUrlAfterRedirect(serverUrl: string, useHttp = false, preauthSecret?: string) {
let url = sanitizeUrl(serverUrl, useHttp);
const headers = {
...(preauthSecret) ? {[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]: preauthSecret} : {},
};
try {
const resp = await GenericClient.head(url);
const resp = await GenericClient.head(url, {
headers,
});
if (resp.redirectUrls?.length) {
url = resp.redirectUrls[resp.redirectUrls.length - 1];
}

View file

@ -622,6 +622,7 @@
"mobile.channel_list.recent": "Recent",
"mobile.channel_list.unreads": "Unreads",
"mobile.commands.error_title": "Error Executing Command",
"mobile.components.select_server_view.advancedOptions": "Advanced Options",
"mobile.components.select_server_view.connect": "Connect",
"mobile.components.select_server_view.connecting": "Connecting",
"mobile.components.select_server_view.displayHelp": "Choose a display name for your server",
@ -631,6 +632,8 @@
"mobile.components.select_server_view.msg_description": "A server is your team's communication hub accessed using a unique URL",
"mobile.components.select_server_view.msg_welcome": "Welcome",
"mobile.components.select_server_view.proceed": "Proceed",
"mobile.components.select_server_view.sharedSecret": "Pre-authentication secret",
"mobile.components.select_server_view.sharedSecretHelp": "The pre-authentication secret shared by the administrator",
"mobile.create_channel": "Create",
"mobile.create_channel.title": "New channel",
"mobile.create_direct_message.max_limit_reached": "Group messages are limited to {maxCount} members",

View file

@ -20,6 +20,9 @@ class ServerScreen {
displayHelp: 'server_form.display_help',
connectButton: 'server_form.connect.button',
connectButtonDisabled: 'server_form.connect.button.disabled',
advancedOptionsToggle: 'server_form.advanced_options.toggle',
preauthSecretInput: 'server_form.preauth_secret.input',
preauthSecretHelp: 'server_form.preauth_secret_help',
usernameInput: 'login_form.username.input',
usernameInputError: 'login_form.username.input.error',
};
@ -37,6 +40,9 @@ class ServerScreen {
displayHelp = element(by.id(this.testID.displayHelp));
connectButton = element(by.id(this.testID.connectButton));
connectButtonDisabled = element(by.id(this.testID.connectButtonDisabled));
advancedOptionsToggle = element(by.id(this.testID.advancedOptionsToggle));
preauthSecretInput = element(by.id(this.testID.preauthSecretInput));
preauthSecretHelp = element(by.id(this.testID.preauthSecretHelp));
usernameInput = element(by.id(this.testID.usernameInput));
toBeVisible = async () => {
@ -78,6 +84,47 @@ class ServerScreen {
await this.connectButton.tap();
await wait(timeouts.ONE_SEC);
};
toggleAdvancedOptions = async () => {
await this.advancedOptionsToggle.tap();
await wait(timeouts.ONE_SEC);
};
enterPreauthSecret = async (secret: string) => {
await waitFor(this.preauthSecretInput).toBeVisible().withTimeout(timeouts.TEN_SEC);
await this.preauthSecretInput.replaceText(secret);
};
connectToServerWithPreauthSecret = async (serverUrl: string, serverDisplayName: string, preauthSecret: string) => {
await this.toBeVisible();
await this.serverUrlInput.replaceText(serverUrl);
await this.serverDisplayNameInput.replaceText(serverDisplayName);
// Toggle advanced options to show preauth secret field
await this.toggleAdvancedOptions();
// Enter preauth secret
await this.enterPreauthSecret(preauthSecret);
// Connect
if (isAndroid()) {
await this.tapConnectButton();
}
if (isIos()) {
await this.tapConnectButton();
if (serverUrl.includes('127.0.0.1') || !process.env.CI) {
try {
// # Tap alert okay button
await waitFor(Alert.okayButton).toExist().withTimeout(timeouts.TEN_SEC);
await Alert.okayButton.tap();
} catch (error) {
/* eslint-disable no-console */
console.log('Alert button did not appear!');
}
}
}
await waitFor(this.usernameInput).toExist().withTimeout(isAndroid()? timeouts.ONE_MIN : timeouts.HALF_MIN);
};
}
const serverScreen = new ServerScreen();

View file

@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// *******************************************************************
// - [#] indicates a test step (e.g. # Go to a screen)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element testID when selecting an element. Create one if none.
// *******************************************************************
import {serverOneUrl} from '@support/test_config';
import {
LoginScreen,
ServerScreen,
} from '@support/ui/screen';
import {expect} from 'detox';
describe('Server Login - Preauth Secret Connection', () => {
const {
serverUrlInput,
serverDisplayNameInput,
advancedOptionsToggle,
preauthSecretInput,
preauthSecretHelp,
} = ServerScreen;
beforeEach(async () => {
// * Verify on server screen
await ServerScreen.toBeVisible();
// # FORCE advanced options to be CLOSED (clean state)
try {
await waitFor(preauthSecretInput).toBeVisible().withTimeout(500);
await ServerScreen.toggleAdvancedOptions(); // Close them
} catch (error) {
// Preauth field not visible, advanced options already closed - good!
}
// # Clear all fields
await expect(serverUrlInput).toBeVisible();
await expect(serverDisplayNameInput).toBeVisible();
await serverUrlInput.clearText();
await serverDisplayNameInput.clearText();
});
afterEach(async () => {
// # Navigate back to server screen if we ended up on login screen
try {
await LoginScreen.toBeVisible();
await LoginScreen.back();
await ServerScreen.toBeVisible();
} catch (error) {
// Not on login screen, no need to navigate back
}
});
it('MM-T5000_1 - should toggle advanced options and verify preauth secret field styling', async () => {
// * Verify advanced options toggle is visible with correct text
await expect(advancedOptionsToggle).toBeVisible();
await expect(element(by.text('Advanced Options'))).toBeVisible();
// * Verify preauth secret field is initially hidden
await expect(preauthSecretInput).not.toBeVisible();
await expect(preauthSecretHelp).not.toBeVisible();
// # Toggle advanced options to show fields
await ServerScreen.toggleAdvancedOptions();
// * Verify preauth secret field is now visible with correct text and styling
await expect(preauthSecretInput).toBeVisible();
await expect(preauthSecretHelp).toBeVisible();
await expect(element(by.text('Pre-authentication secret'))).toBeVisible();
await expect(preauthSecretHelp).toHaveText('The pre-authentication secret shared by the administrator');
// # Toggle advanced options again to hide
await ServerScreen.toggleAdvancedOptions();
// * Verify preauth secret field is hidden again
await expect(preauthSecretInput).not.toBeVisible();
await expect(preauthSecretHelp).not.toBeVisible();
});
it('MM-T5000_2 - should connect to server with preauth secret and verify storage', async () => {
const testPreauthSecret = 'test-secret-123';
const serverDisplayName = 'Test Server with Preauth';
// # Connect to server using the dedicated method with preauth secret
await ServerScreen.connectToServerWithPreauthSecret(serverOneUrl, serverDisplayName, testPreauthSecret);
// * Verify successful connection to login screen
await LoginScreen.toBeVisible();
// TODO: Add verification that preauth secret is stored in keychain
// This would require keychain testing utilities or verification through subsequent network requests
});
it('MM-T5000_3 - should connect to server without preauth secret', async () => {
const serverDisplayName = 'Server without Preauth';
// # Connect to server using the standard method (no preauth secret)
await ServerScreen.connectToServer(serverOneUrl, serverDisplayName);
// * Verify successful connection to login screen
await LoginScreen.toBeVisible();
});
});

View file

@ -0,0 +1,12 @@
//
// Constants.swift
// Gekidou
//
// Created by Claude on 21/08/25.
//
import Foundation
public struct GekidouConstants {
public static let HEADER_X_MATTERMOST_PREAUTH_SECRET = "X-Mattermost-Preauth-Secret"
}

View file

@ -7,6 +7,11 @@
import Foundation
public struct ServerCredentials {
public let token: String?
public let preauthSecret: String?
}
enum KeychainError: Error {
case CertificateForIdentityNotFound
case IdentityNotFound
@ -27,7 +32,7 @@ extension KeychainError: LocalizedError {
case .FailedSecItemCopyMatching(status: let status): return status
}
}
var errorDescription: String? {
switch self {
case .CertificateForIdentityNotFound:
@ -48,7 +53,7 @@ extension KeychainError: LocalizedError {
public class Keychain: NSObject {
@objc public static let `default` = Keychain()
public func getClientIdentityAndCertificate(for host: String) throws -> (SecIdentity, SecCertificate)? {
let query = try buildIdentityQuery(for: host)
@ -61,7 +66,7 @@ public class Keychain: NSObject {
throw KeychainError.FailedSecItemCopyMatching(identityStatus)
}
let identity = result as! SecIdentity
var certificate: SecCertificate?
let certificateStatus = SecIdentityCopyCertificate(identity, &certificate)
@ -71,19 +76,31 @@ public class Keychain: NSObject {
guard certificate != nil else {
throw KeychainError.CertificateForIdentityNotFound
}
return (identity, certificate!)
}
@objc public func getTokenObjc(for serverUrl: String) -> String? {
return try? getToken(for: serverUrl)
@objc public func getCredentialsObjc(for serverUrl: String) -> NSDictionary? {
guard let credentials = try? getCredentials(for: serverUrl) else { return nil }
return [
"token": credentials.token as Any,
"preauthSecret": credentials.preauthSecret as Any
]
}
public func getToken(for serverUrl: String) throws -> String? {
public func getCredentials(for serverUrl: String) throws -> ServerCredentials? {
// Get main token from serverUrl key
let token = try getMainToken(for: serverUrl)
let preauthSecret = try? getPreauthSecret(for: serverUrl)
return ServerCredentials(token: token, preauthSecret: preauthSecret)
}
private func getMainToken(for serverUrl: String) throws -> String? {
var attributes = try buildTokenAttributes(for: serverUrl)
attributes[kSecMatchLimit] = kSecMatchLimitOne
attributes[kSecReturnData] = kCFBooleanTrue
var result: AnyObject?
let status = SecItemCopyMatching(attributes as CFDictionary, &result)
let data = result as? Data
@ -91,15 +108,31 @@ public class Keychain: NSObject {
let token = String(data: data!, encoding: .utf8)
return token
}
return nil
}
private func getPreauthSecret(for serverUrl: String) throws -> String? {
var attributes = try buildGenericPasswordAttributes(for: serverUrl, account: "preshared_secret")
attributes[kSecMatchLimit] = kSecMatchLimitOne
attributes[kSecReturnData] = kCFBooleanTrue
var result: AnyObject?
let status = SecItemCopyMatching(attributes as CFDictionary, &result)
let data = result as? Data
if status == errSecSuccess && data != nil {
let preauthSecret = String(data: data!, encoding: .utf8)
return preauthSecret
}
return nil
}
private func buildIdentityQuery(for host: String) throws -> [CFString: Any] {
guard let hostData = host.data(using: .utf8) else {
throw KeychainError.InvalidHost(host)
}
let query: [CFString:Any] = [
kSecClass: kSecClassIdentity,
kSecAttrLabel: hostData,
@ -108,12 +141,12 @@ public class Keychain: NSObject {
return query
}
private func buildTokenAttributes(for serverUrl: String) throws -> [CFString: Any] {
guard let serverUrlData = serverUrl.data(using: .utf8) else {
throw KeychainError.InvalidServerUrl(serverUrl)
}
var attributes: [CFString: Any] = [
kSecClass: kSecClassInternetPassword,
kSecAttrServer: serverUrlData
@ -122,7 +155,29 @@ public class Keychain: NSObject {
if let accessGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as! String? {
attributes[kSecAttrAccessGroup] = accessGroup
}
return attributes
}
private func buildGenericPasswordAttributes(for service: String, account: String) throws -> [CFString: Any] {
guard let serviceData = service.data(using: .utf8) else {
throw KeychainError.InvalidServerUrl(service)
}
guard let accountData = account.data(using: .utf8) else {
throw KeychainError.InvalidServerUrl(account)
}
var attributes: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: serviceData,
kSecAttrAccount: accountData
]
if let accessGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as! String? {
attributes[kSecAttrAccessGroup] = accessGroup
}
return attributes
}
}

View file

@ -94,8 +94,13 @@ public class Network: NSObject {
}
}
if let token = try? Keychain.default.getToken(for: serverUrl) {
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let credentials = try? Keychain.default.getCredentials(for: serverUrl) {
if let token = credentials.token {
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let preauthSecret = credentials.preauthSecret {
request.addValue(preauthSecret, forHTTPHeaderField: GekidouConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET)
}
}
return request as URLRequest

View file

@ -1,6 +1,6 @@
//
// File.swift
//
//
//
// Created by Elias Nahum on 26-06-22.
//
@ -12,14 +12,15 @@ extension ShareExtension {
public func uploadFiles(serverUrl: String, channelId: String, message: String,
files: [String], completionHandler: @escaping () -> Void) -> String? {
let id = "mattermost-share-upload-\(UUID().uuidString)"
createUploadSessionData(
id: id, serverUrl: serverUrl,
channelId: channelId, message: message,
files: files
)
guard let token = try? Keychain.default.getToken(for: serverUrl) else {return "Could not retrieve the session token from the KeyChain"}
guard let credentials = try? Keychain.default.getCredentials(for: serverUrl),
let token = credentials.token else {return "Could not retrieve the session token from the KeyChain"}
if !files.isEmpty {
createBackroundSession(id: id)
@ -31,15 +32,19 @@ extension ShareExtension {
)
for file in files {
if let fileUrl = URL(string: file),
fileUrl.isFileURL {
fileUrl.isFileURL {
let filename = fileUrl.lastPathComponent
let safeFilename = filename.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
if let safeFilename = safeFilename,
let url = URL(string: "\(serverUrl)/api/v4/files?channel_id=\(channelId)&filename=\(safeFilename)") {
let url = URL(string: "\(serverUrl)/api/v4/files?channel_id=\(channelId)&filename=\(safeFilename)") {
var uploadRequest = URLRequest(url: url)
uploadRequest.httpMethod = "POST"
uploadRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let preauthSecret = credentials.preauthSecret {
uploadRequest.addValue(preauthSecret, forHTTPHeaderField: GekidouConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET)
}
if let task = backgroundSession?.uploadTask(with: uploadRequest, fromFile: fileUrl) {
os_log(
OSLogType.default,
@ -85,10 +90,10 @@ extension ShareExtension {
)
self.postMessageForSession(withId: id, completionHandler: completionHandler)
}
return nil
}
func postMessageForSession(withId id: String, completionHandler: (() -> Void)? = nil) {
guard let data = getUploadSessionData(id: id)
else {
@ -99,12 +104,12 @@ extension ShareExtension {
)
return
}
self.removeUploadSessionData(id: id)
self.deleteUploadedFiles(files: data.files)
if let serverUrl = data.serverUrl,
let channelId = data.channelId {
let channelId = data.channelId {
Network.default.createPost(
serverUrl: serverUrl,
channelId: channelId,
@ -120,7 +125,7 @@ extension ShareExtension {
err.localizedDescription
)
}
if let handler = completionHandler {
os_log(
OSLogType.default,

View file

@ -12,7 +12,7 @@ import react_native_emm
@objc class GekidouWrapper: NSObject {
@objc public static let `default` = GekidouWrapper()
override init() {
ScreenCaptureManager.startTrackingScreens()
}
@ -20,18 +20,18 @@ import react_native_emm
@objc func postNotificationReceipt(_ userInfo: [AnyHashable:Any]) {
PushNotification.default.postNotificationReceipt(userInfo)
}
@objc func fetchDataForPushNotification(_ notification: [AnyHashable:Any], withContentHandler contentHander: @escaping ((_ data: Data?) -> Void)) {
PushNotification.default.fetchDataForPushNotification(notification, withContentHandler: { data in
let jsonData = try? JSONEncoder().encode(data)
contentHander(jsonData)
})
}
@objc func verifySignature(_ notification: [AnyHashable:Any]) -> Bool {
return PushNotification.default.verifySignature(notification)
}
@objc func attachSession(_ id: String, completionHandler: @escaping () -> Void) {
let shareExtension = ShareExtension()
shareExtension.attachSession(
@ -39,16 +39,16 @@ import react_native_emm
completionHandler: completionHandler
)
}
@objc func setPreference(_ value: Any?, forKey name: String) {
Preferences.default.set(value, forKey: name)
}
@objc func getToken(for url: String) -> String? {
if let token = try? Keychain.default.getToken(for: url) {
return token
if let credentials = try? Keychain.default.getCredentials(for: url) {
return credentials?.token
}
return nil
}
}

View file

@ -62,7 +62,9 @@ static SendReplyCompletionHandlerIMP originalSendReplyCompletionHandlerImplement
return;
}
NSString *sessionToken = [[Keychain default] getTokenObjcFor:serverUrl];
NSDictionary *credentials = [[Keychain default] getCredentialsObjcFor:serverUrl];
NSString *sessionToken = [credentials objectForKey:@"token"];
NSString *preauthSecret = [credentials objectForKey:@"preauthSecret"];
if (sessionToken == nil) {
[self handleReplyFailure:@"" completionHandler:notificationCompletionHandler];
return;
@ -95,6 +97,12 @@ static SendReplyCompletionHandlerIMP originalSendReplyCompletionHandlerImplement
[request setHTTPMethod:@"POST"];
[request setValue:[NSString stringWithFormat:@"Bearer %@", sessionToken] forHTTPHeaderField:@"Authorization"];
[request setValue:@"application/json; charset=utf-8" forHTTPHeaderField:@"Content-Type"];
// Add preauth secret header if available
if (preauthSecret != nil && ![preauthSecret isEqualToString:@""]) {
[request setValue:preauthSecret forHTTPHeaderField:@"X-Mattermost-Preauth-Secret"];
}
[request setHTTPBody:postData];
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration];

View file

@ -33,6 +33,9 @@ import java.security.cert.X509Certificate
import java.util.Objects
class ShareWorker(private val context: Context, workerParameters: WorkerParameters) : Worker(context, workerParameters) {
companion object {
private const val HEADER_X_MATTERMOST_PREAUTH_SECRET = "X-Mattermost-Preauth-Secret"
}
private val jsonType: MediaType? = "application/json; charset=utf-8".toMediaTypeOrNull()
private val okHttpClient: OkHttpClient
get() {
@ -90,13 +93,18 @@ class ShareWorker(private val context: Context, workerParameters: WorkerParamete
val files = if (jsonObject.has("files")) jsonObject.getJSONArray("files") else null
val serverUrl = jsonObject.getString("serverUrl")
val token = jsonObject.getString("token")
val preauthSecret = if (jsonObject.has("preauthSecret") && !jsonObject.isNull("preauthSecret")) {
val secret = jsonObject.getString("preauthSecret")
if (secret.isNotEmpty()) secret else null
} else null
val postData = buildPostObject(jsonObject)
if (files != null && files.length() > 0) {
setForegroundAsync(createForegroundInfo())
return uploadFiles(serverUrl, token, files, postData)
return uploadFiles(serverUrl, token, preauthSecret, files, postData)
} else {
try {
return post(serverUrl, token, postData)
return post(serverUrl, token, preauthSecret, postData)
} catch (e: IOException) {
Log.e(MattermostShareImpl.NAME, "Error sending the post", e)
return Result.failure()
@ -123,13 +131,18 @@ class ShareWorker(private val context: Context, workerParameters: WorkerParamete
}
@Throws(IOException::class)
private fun post(serverUrl: String, token: String, postData: JSONObject): Result {
private fun post(serverUrl: String, token: String, preauthSecret: String?, postData: JSONObject): Result {
val body = postData.toString().toRequestBody(jsonType)
val request = Request.Builder()
val requestBuilder = Request.Builder()
.header("Authorization", "BEARER $token")
.url("$serverUrl/api/v4/posts")
.post(body)
.build()
if (preauthSecret != null) {
requestBuilder.header(HEADER_X_MATTERMOST_PREAUTH_SECRET, preauthSecret)
}
val request = requestBuilder.build()
val response = okHttpClient.newCall(request).execute()
response.body?.close()
@ -137,7 +150,7 @@ class ShareWorker(private val context: Context, workerParameters: WorkerParamete
return Result.success()
}
private fun uploadFiles(serverUrl: String, token: String, files: JSONArray, postData: JSONObject): Result {
private fun uploadFiles(serverUrl: String, token: String, preauthSecret: String?, files: JSONArray, postData: JSONObject): Result {
try {
val builder = MultipartBody.Builder()
.setType(MultipartBody.FORM)
@ -156,11 +169,16 @@ class ShareWorker(private val context: Context, workerParameters: WorkerParamete
builder.addFormDataPart("channel_id", postData.getString("channel_id"))
val body: RequestBody = builder.build()
val request = Request.Builder()
val requestBuilder = Request.Builder()
.header("Authorization", "BEARER $token")
.url("$serverUrl/api/v4/files")
.post(body)
.build()
if (preauthSecret != null) {
requestBuilder.header(HEADER_X_MATTERMOST_PREAUTH_SECRET, preauthSecret)
}
val request = requestBuilder.build()
try {
okHttpClient.newCall(request).execute().use { response ->
@ -178,7 +196,7 @@ class ShareWorker(private val context: Context, workerParameters: WorkerParamete
fileIds.put(fileInfo.getString("id"))
}
postData.put("file_ids", fileIds)
return post(serverUrl, token, postData)
return post(serverUrl, token, preauthSecret, postData)
}
return Result.failure()
}

View file

@ -24,6 +24,7 @@ export type ShareExtensionDataToSend = {
serverUrl: string;
token: string;
userId: string;
preauthSecret?: string;
}
export interface Spec extends TurboModule {

View file

@ -53,6 +53,7 @@ const PostButton = ({theme}: Props) => {
files,
message: text,
userId,
preauthSecret: credentials.preauthSecret,
});
}
}, [serverUrl, channelId, message, files, linkPreviewUrl, userId]);

View file

@ -5,4 +5,5 @@ type ServerCredential = {
serverUrl: string;
userId: string;
token: string;
preauthSecret?: string;
};