You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-16 00:14:34 +02:00
Desktop, Cli, Mobile, Server: Add Joplin Server SAML support (#11865)
This commit is contained in:
@ -420,6 +420,7 @@ packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
|
|||||||
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
|
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
|
||||||
packages/app-desktop/gui/Sidebar/styles/index.js
|
packages/app-desktop/gui/Sidebar/styles/index.js
|
||||||
packages/app-desktop/gui/Sidebar/types.js
|
packages/app-desktop/gui/Sidebar/types.js
|
||||||
|
packages/app-desktop/gui/SsoLoginScreen/SsoLoginScreen.js
|
||||||
packages/app-desktop/gui/StatusScreen/StatusScreen.js
|
packages/app-desktop/gui/StatusScreen/StatusScreen.js
|
||||||
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
|
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
|
||||||
packages/app-desktop/gui/SyncWizard/Dialog.js
|
packages/app-desktop/gui/SyncWizard/Dialog.js
|
||||||
@ -818,6 +819,7 @@ packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
|
|||||||
packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js
|
packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js
|
||||||
packages/app-mobile/components/screens/ShareManager/index.test.js
|
packages/app-mobile/components/screens/ShareManager/index.test.js
|
||||||
packages/app-mobile/components/screens/ShareManager/index.js
|
packages/app-mobile/components/screens/ShareManager/index.js
|
||||||
|
packages/app-mobile/components/screens/SsoLoginScreen.js
|
||||||
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
|
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
|
||||||
packages/app-mobile/components/screens/dropbox-login.js
|
packages/app-mobile/components/screens/dropbox-login.js
|
||||||
packages/app-mobile/components/screens/encryption-config.test.js
|
packages/app-mobile/components/screens/encryption-config.test.js
|
||||||
@ -1051,6 +1053,7 @@ packages/lib/RotatingLogs.js
|
|||||||
packages/lib/SyncTargetFilesystem.js
|
packages/lib/SyncTargetFilesystem.js
|
||||||
packages/lib/SyncTargetJoplinCloud.js
|
packages/lib/SyncTargetJoplinCloud.js
|
||||||
packages/lib/SyncTargetJoplinServer.js
|
packages/lib/SyncTargetJoplinServer.js
|
||||||
|
packages/lib/SyncTargetJoplinServerSAML.js
|
||||||
packages/lib/SyncTargetNone.js
|
packages/lib/SyncTargetNone.js
|
||||||
packages/lib/SyncTargetOneDrive.js
|
packages/lib/SyncTargetOneDrive.js
|
||||||
packages/lib/SyncTargetRegistry.js
|
packages/lib/SyncTargetRegistry.js
|
||||||
@ -1078,6 +1081,8 @@ packages/lib/components/EncryptionConfigScreen/utils.test.js
|
|||||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||||
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
||||||
|
packages/lib/components/shared/SamlShared.js
|
||||||
|
packages/lib/components/shared/SsoScreenShared.js
|
||||||
packages/lib/components/shared/config/config-shared.js
|
packages/lib/components/shared/config/config-shared.js
|
||||||
packages/lib/components/shared/config/plugins/types.js
|
packages/lib/components/shared/config/plugins/types.js
|
||||||
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
|
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
|
||||||
@ -1519,6 +1524,7 @@ packages/lib/utils/ipc/utils/separateCallbacksFromSerializableArray.js
|
|||||||
packages/lib/utils/joplinCloud/index.js
|
packages/lib/utils/joplinCloud/index.js
|
||||||
packages/lib/utils/joplinCloud/types.js
|
packages/lib/utils/joplinCloud/types.js
|
||||||
packages/lib/utils/markupLanguageUtils.js
|
packages/lib/utils/markupLanguageUtils.js
|
||||||
|
packages/lib/utils/prefixWithHttps.js
|
||||||
packages/lib/utils/processStartFlags.js
|
packages/lib/utils/processStartFlags.js
|
||||||
packages/lib/utils/replaceUnsupportedCharacters.test.js
|
packages/lib/utils/replaceUnsupportedCharacters.test.js
|
||||||
packages/lib/utils/replaceUnsupportedCharacters.js
|
packages/lib/utils/replaceUnsupportedCharacters.js
|
||||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -394,6 +394,7 @@ packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
|
|||||||
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
|
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
|
||||||
packages/app-desktop/gui/Sidebar/styles/index.js
|
packages/app-desktop/gui/Sidebar/styles/index.js
|
||||||
packages/app-desktop/gui/Sidebar/types.js
|
packages/app-desktop/gui/Sidebar/types.js
|
||||||
|
packages/app-desktop/gui/SsoLoginScreen/SsoLoginScreen.js
|
||||||
packages/app-desktop/gui/StatusScreen/StatusScreen.js
|
packages/app-desktop/gui/StatusScreen/StatusScreen.js
|
||||||
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
|
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
|
||||||
packages/app-desktop/gui/SyncWizard/Dialog.js
|
packages/app-desktop/gui/SyncWizard/Dialog.js
|
||||||
@ -792,6 +793,7 @@ packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
|
|||||||
packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js
|
packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js
|
||||||
packages/app-mobile/components/screens/ShareManager/index.test.js
|
packages/app-mobile/components/screens/ShareManager/index.test.js
|
||||||
packages/app-mobile/components/screens/ShareManager/index.js
|
packages/app-mobile/components/screens/ShareManager/index.js
|
||||||
|
packages/app-mobile/components/screens/SsoLoginScreen.js
|
||||||
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
|
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
|
||||||
packages/app-mobile/components/screens/dropbox-login.js
|
packages/app-mobile/components/screens/dropbox-login.js
|
||||||
packages/app-mobile/components/screens/encryption-config.test.js
|
packages/app-mobile/components/screens/encryption-config.test.js
|
||||||
@ -1025,6 +1027,7 @@ packages/lib/RotatingLogs.js
|
|||||||
packages/lib/SyncTargetFilesystem.js
|
packages/lib/SyncTargetFilesystem.js
|
||||||
packages/lib/SyncTargetJoplinCloud.js
|
packages/lib/SyncTargetJoplinCloud.js
|
||||||
packages/lib/SyncTargetJoplinServer.js
|
packages/lib/SyncTargetJoplinServer.js
|
||||||
|
packages/lib/SyncTargetJoplinServerSAML.js
|
||||||
packages/lib/SyncTargetNone.js
|
packages/lib/SyncTargetNone.js
|
||||||
packages/lib/SyncTargetOneDrive.js
|
packages/lib/SyncTargetOneDrive.js
|
||||||
packages/lib/SyncTargetRegistry.js
|
packages/lib/SyncTargetRegistry.js
|
||||||
@ -1052,6 +1055,8 @@ packages/lib/components/EncryptionConfigScreen/utils.test.js
|
|||||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||||
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
||||||
|
packages/lib/components/shared/SamlShared.js
|
||||||
|
packages/lib/components/shared/SsoScreenShared.js
|
||||||
packages/lib/components/shared/config/config-shared.js
|
packages/lib/components/shared/config/config-shared.js
|
||||||
packages/lib/components/shared/config/plugins/types.js
|
packages/lib/components/shared/config/plugins/types.js
|
||||||
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
|
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
|
||||||
@ -1493,6 +1498,7 @@ packages/lib/utils/ipc/utils/separateCallbacksFromSerializableArray.js
|
|||||||
packages/lib/utils/joplinCloud/index.js
|
packages/lib/utils/joplinCloud/index.js
|
||||||
packages/lib/utils/joplinCloud/types.js
|
packages/lib/utils/joplinCloud/types.js
|
||||||
packages/lib/utils/markupLanguageUtils.js
|
packages/lib/utils/markupLanguageUtils.js
|
||||||
|
packages/lib/utils/prefixWithHttps.js
|
||||||
packages/lib/utils/processStartFlags.js
|
packages/lib/utils/processStartFlags.js
|
||||||
packages/lib/utils/replaceUnsupportedCharacters.test.js
|
packages/lib/utils/replaceUnsupportedCharacters.test.js
|
||||||
packages/lib/utils/replaceUnsupportedCharacters.js
|
packages/lib/utils/replaceUnsupportedCharacters.js
|
||||||
|
@ -4,7 +4,7 @@ import Note from '@joplin/lib/models/Note';
|
|||||||
import uuid from '@joplin/lib/uuid';
|
import uuid from '@joplin/lib/uuid';
|
||||||
import populateDatabase from '@joplin/lib/services/debug/populateDatabase';
|
import populateDatabase from '@joplin/lib/services/debug/populateDatabase';
|
||||||
import { readCredentialFile } from '@joplin/lib/utils/credentialFiles';
|
import { readCredentialFile } from '@joplin/lib/utils/credentialFiles';
|
||||||
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
import JoplinServerApi, { Session } from '@joplin/lib/JoplinServerApi';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
function randomElement(array: any[]): any {
|
function randomElement(array: any[]): any {
|
||||||
@ -107,6 +107,7 @@ class Command extends BaseCommand {
|
|||||||
userContentBaseUrl: () => joplinServerAuth.userContentBaseUrl,
|
userContentBaseUrl: () => joplinServerAuth.userContentBaseUrl,
|
||||||
username: () => joplinServerAuth.email,
|
username: () => joplinServerAuth.email,
|
||||||
password: () => joplinServerAuth.password,
|
password: () => joplinServerAuth.password,
|
||||||
|
session: (): Session => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiPut = async () => {
|
const apiPut = async () => {
|
||||||
|
@ -258,6 +258,28 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
|
||||||
|
const server = settings['sync.11.path'] as string;
|
||||||
|
|
||||||
|
const goToSamlLogin = () => {
|
||||||
|
this.props.dispatch({
|
||||||
|
type: 'NAV_GO',
|
||||||
|
routeName: 'JoplinServerSamlLogin',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
settingComps.push(
|
||||||
|
<div key="connect_to_joplin_server_saml_button" style={this.rowStyle_}>
|
||||||
|
<Button
|
||||||
|
title={_('Connect using your organisation account')}
|
||||||
|
level={ButtonLevel.Primary}
|
||||||
|
onClick={goToSamlLogin}
|
||||||
|
disabled={!server || server?.trim().length === 0}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
settingComps.push(
|
settingComps.push(
|
||||||
<div key="check_sync_config_button" style={this.rowStyle_}>
|
<div key="check_sync_config_button" style={this.rowStyle_}>
|
||||||
<Button
|
<Button
|
||||||
|
@ -30,6 +30,8 @@ import WindowCommandsAndDialogs from './WindowCommandsAndDialogs/WindowCommandsA
|
|||||||
import { defaultWindowId, stateUtils, WindowState } from '@joplin/lib/reducer';
|
import { defaultWindowId, stateUtils, WindowState } from '@joplin/lib/reducer';
|
||||||
import bridge from '../services/bridge';
|
import bridge from '../services/bridge';
|
||||||
import EditorWindow from './NoteEditor/EditorWindow';
|
import EditorWindow from './NoteEditor/EditorWindow';
|
||||||
|
import SsoLoginScreen from './SsoLoginScreen/SsoLoginScreen';
|
||||||
|
import SamlShared from '@joplin/lib/components/shared/SamlShared';
|
||||||
import PopupNotificationProvider from './PopupNotification/PopupNotificationProvider';
|
import PopupNotificationProvider from './PopupNotification/PopupNotificationProvider';
|
||||||
const { ThemeProvider, StyleSheetManager, createGlobalStyle } = require('styled-components');
|
const { ThemeProvider, StyleSheetManager, createGlobalStyle } = require('styled-components');
|
||||||
|
|
||||||
@ -190,6 +192,7 @@ class RootComponent extends React.Component<Props, any> {
|
|||||||
OneDriveLogin: { screen: OneDriveLoginScreen, title: () => _('OneDrive Login') },
|
OneDriveLogin: { screen: OneDriveLoginScreen, title: () => _('OneDrive Login') },
|
||||||
DropboxLogin: { screen: DropboxLoginScreen, title: () => _('Dropbox Login') },
|
DropboxLogin: { screen: DropboxLoginScreen, title: () => _('Dropbox Login') },
|
||||||
JoplinCloudLogin: { screen: JoplinCloudLoginScreen, title: () => _('Joplin Cloud Login') },
|
JoplinCloudLogin: { screen: JoplinCloudLoginScreen, title: () => _('Joplin Cloud Login') },
|
||||||
|
JoplinServerSamlLogin: { screen: SsoLoginScreen(new SamlShared()), title: () => _('Joplin Server Login') },
|
||||||
Import: { screen: ImportScreen, title: () => _('Import') },
|
Import: { screen: ImportScreen, title: () => _('Import') },
|
||||||
Config: { screen: ConfigScreen, title: () => _('Options') },
|
Config: { screen: ConfigScreen, title: () => _('Options') },
|
||||||
Resources: { screen: ResourceScreen, title: () => _('Note attachments') },
|
Resources: { screen: ResourceScreen, title: () => _('Note attachments') },
|
||||||
|
19
packages/app-desktop/gui/SsoLoginScreen/SsoLoginScreen.scss
Normal file
19
packages/app-desktop/gui/SsoLoginScreen/SsoLoginScreen.scss
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
.sso-login-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: var(--joplin-font-size);
|
||||||
|
height: inherit;
|
||||||
|
|
||||||
|
ol > li {
|
||||||
|
margin-bottom: var(--joplin-margin);
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100px;
|
||||||
|
margin-left: var(--joplin-margin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
63
packages/app-desktop/gui/SsoLoginScreen/SsoLoginScreen.tsx
Normal file
63
packages/app-desktop/gui/SsoLoginScreen/SsoLoginScreen.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import ButtonBar from '../ConfigScreen/ButtonBar';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { AppState } from '../../app.reducer';
|
||||||
|
import SsoScreenShared from '@joplin/lib/components/shared/SsoScreenShared';
|
||||||
|
import shim from '@joplin/lib/shim';
|
||||||
|
import { Dispatch } from 'redux';
|
||||||
|
import Button from '../Button/Button';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
themeId: number;
|
||||||
|
dispatch: Dispatch;
|
||||||
|
shared: SsoScreenShared;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SsoLoginScreen = (props: Props) => {
|
||||||
|
const [code, setCode] = React.useState('');
|
||||||
|
|
||||||
|
const back = () => props.dispatch({ type: 'NAV_BACK' });
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (await props.shared.processLoginCode(code)) {
|
||||||
|
await shim.showMessageBox(_('You are now logged into your account.'), {
|
||||||
|
buttons: [_('OK')],
|
||||||
|
});
|
||||||
|
back();
|
||||||
|
} else {
|
||||||
|
await shim.showErrorDialog(_('Failed to connect to your account. Please try again.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='sso-login-screen'>
|
||||||
|
<div className='container'>
|
||||||
|
<p>{_('To allow Joplin to synchronise with your account, please follow these steps:')}</p>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
<Button onClick={props.shared.openLoginPage} title={_('Log in with your web browser')}/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='sso-code'>{_('Enter the code:')}</label>
|
||||||
|
<input id='sso-code' type='text' value={code} onChange={e => setCode(e.target.value)} placeholder='###-###-###' />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button type='submit' onClick={submit} disabled={!props.shared.isLoginCodeValid(code)} title={_('Continue')}/>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonBar onCancelClick={back} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state: AppState) => ({
|
||||||
|
themeId: state.settings.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allows reuse of this screen for other code-based login flow
|
||||||
|
export default (shared: SsoScreenShared) => connect(mapStateToProps)((props: Props) => <SsoLoginScreen {...props} shared={shared}/>);
|
@ -130,6 +130,7 @@ const syncTargetNames: string[] = [
|
|||||||
'webdav',
|
'webdav',
|
||||||
'amazon_s3',
|
'amazon_s3',
|
||||||
'joplinServer',
|
'joplinServer',
|
||||||
|
'joplinServerSaml',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
@use 'gui/Dropdown/style.scss' as dropdown-control;
|
@use 'gui/Dropdown/style.scss' as dropdown-control;
|
||||||
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
|
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
|
||||||
@use 'gui/NoteList/style.scss' as note-list;
|
@use 'gui/NoteList/style.scss' as note-list;
|
||||||
|
@use 'gui/SsoLoginScreen/SsoLoginScreen.scss' as sso-login-screen;
|
||||||
@use 'gui/JoplinCloudLoginScreen.scss' as joplin-cloud-login-screen;
|
@use 'gui/JoplinCloudLoginScreen.scss' as joplin-cloud-login-screen;
|
||||||
@use 'gui/NoteListHeader/style.scss' as note-list-header;
|
@use 'gui/NoteListHeader/style.scss' as note-list-header;
|
||||||
@use 'gui/UpdateNotification/style.scss' as update-notification;
|
@use 'gui/UpdateNotification/style.scss' as update-notification;
|
||||||
|
@ -93,6 +93,18 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
|||||||
await NavService.go('JoplinCloudLogin');
|
await NavService.go('JoplinCloudLogin');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private goToJoplinServerSamlLogin_ = async () => {
|
||||||
|
// Save the settings to allow for sync when the user completes authentication
|
||||||
|
await this.saveButton_press();
|
||||||
|
|
||||||
|
await NavService.go('JoplinServerSamlLogin');
|
||||||
|
};
|
||||||
|
|
||||||
|
private logoutJoplinServerSaml_ = () => {
|
||||||
|
Setting.setValue('sync.11.id', '');
|
||||||
|
Setting.setValue('sync.11.userId', '');
|
||||||
|
};
|
||||||
|
|
||||||
private checkSyncConfig_ = async () => {
|
private checkSyncConfig_ = async () => {
|
||||||
if (this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud')) {
|
if (this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud')) {
|
||||||
const isAuthenticated = await reg.syncTarget().isAuthenticated();
|
const isAuthenticated = await reg.syncTarget().isAuthenticated();
|
||||||
@ -460,6 +472,12 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
|||||||
|
|
||||||
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud')) {
|
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud')) {
|
||||||
addSettingButton('go_to_joplin_cloud_login_button', _('Connect to Joplin Cloud'), this.goToJoplinCloudLogin_);
|
addSettingButton('go_to_joplin_cloud_login_button', _('Connect to Joplin Cloud'), this.goToJoplinCloudLogin_);
|
||||||
|
} else if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
|
||||||
|
addSettingButton('login_joplin_server_saml_button', _('Connect using your organisation account'), this.goToJoplinServerSamlLogin_);
|
||||||
|
|
||||||
|
if (Setting.value('sync.11.id') !== '' || Setting.value('sync.11.userId') !== '') {
|
||||||
|
addSettingButton('logout_joplin_server_saml_button', _('Logout'), this.logoutJoplinServerSaml_);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addSettingButton('check_sync_config_button', _('Check synchronisation configuration'), this.checkSyncConfig_, { statusComp: statusComp });
|
addSettingButton('check_sync_config_button', _('Check synchronisation configuration'), this.checkSyncConfig_, { statusComp: statusComp });
|
||||||
|
90
packages/app-mobile/components/screens/SsoLoginScreen.tsx
Normal file
90
packages/app-mobile/components/screens/SsoLoginScreen.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import SsoScreenShared from '@joplin/lib/components/shared/SsoScreenShared';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { AppState } from '../../utils/types';
|
||||||
|
import { StyleSheet, View, Text } from 'react-native';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { themeStyle } from '@joplin/lib/theme';
|
||||||
|
import createRootStyle from '../../utils/createRootStyle';
|
||||||
|
import ScreenHeader from '../ScreenHeader';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import { Button, TextInput } from 'react-native-paper';
|
||||||
|
import shim from '@joplin/lib/shim';
|
||||||
|
import BackButtonService from '../../services/BackButtonService';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
themeId: number;
|
||||||
|
shared: SsoScreenShared;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SsoLoginScreenComponent = (props: Props) => {
|
||||||
|
const theme = themeStyle(props.themeId);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
...createRootStyle(props.themeId),
|
||||||
|
buttonContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.margin,
|
||||||
|
},
|
||||||
|
containerStyle: {
|
||||||
|
padding: theme.margin,
|
||||||
|
backgroundColor: theme.backgroundColor,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: theme.color,
|
||||||
|
fontSize: theme.fontSize,
|
||||||
|
},
|
||||||
|
marginBottom: {
|
||||||
|
marginBottom: theme.margin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [code, setCode] = React.useState('');
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (await props.shared.processLoginCode(code)) {
|
||||||
|
await shim.showMessageBox(_('You are now logged into your account.'), {
|
||||||
|
buttons: [_('OK')],
|
||||||
|
});
|
||||||
|
|
||||||
|
await BackButtonService.back();
|
||||||
|
} else {
|
||||||
|
await shim.showErrorDialog(_('Failed to connect to your account. Please try again.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<ScreenHeader title={_('Joplin Server Login')} />
|
||||||
|
<View style={styles.containerStyle}>
|
||||||
|
<React.Fragment>
|
||||||
|
<Text style={{ ...styles.text, ...styles.buttonContainer }}>
|
||||||
|
{_('To allow Joplin to synchronise with your account, please follow these steps:')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<Text style={styles.text}>1. </Text>
|
||||||
|
<Button onPress={props.shared.openLoginPage} mode='contained'>{_('Log in with your web browser')}</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.marginBottom}>
|
||||||
|
<Text style={styles.text}>2. {_('Enter the code')}</Text>
|
||||||
|
<TextInput placeholder='###-###-###' value={code} onChangeText={setCode}/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<Text style={styles.text}>3. </Text>
|
||||||
|
<Button onPress={submit} disabled={!props.shared.isLoginCodeValid(code)} mode='contained'>{_('Continue')}</Button>
|
||||||
|
</View>
|
||||||
|
</React.Fragment>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Allows reuse of this screen for other code-based login flow
|
||||||
|
export default (shared: SsoScreenShared) => connect((state: AppState) => ({
|
||||||
|
themeId: state.settings.theme,
|
||||||
|
}))((props: Props) => <SsoLoginScreenComponent {...props} shared={shared}/>);
|
@ -82,6 +82,7 @@ const SyncTargetNextcloud = require('@joplin/lib/SyncTargetNextcloud.js');
|
|||||||
const SyncTargetWebDAV = require('@joplin/lib/SyncTargetWebDAV.js');
|
const SyncTargetWebDAV = require('@joplin/lib/SyncTargetWebDAV.js');
|
||||||
const SyncTargetDropbox = require('@joplin/lib/SyncTargetDropbox.js');
|
const SyncTargetDropbox = require('@joplin/lib/SyncTargetDropbox.js');
|
||||||
const SyncTargetAmazonS3 = require('@joplin/lib/SyncTargetAmazonS3.js');
|
const SyncTargetAmazonS3 = require('@joplin/lib/SyncTargetAmazonS3.js');
|
||||||
|
import SyncTargetJoplinServerSAML from '@joplin/lib/SyncTargetJoplinServerSAML';
|
||||||
import BiometricPopup from './components/biometrics/BiometricPopup';
|
import BiometricPopup from './components/biometrics/BiometricPopup';
|
||||||
import initLib from '@joplin/lib/initLib';
|
import initLib from '@joplin/lib/initLib';
|
||||||
import { isCallbackUrl, parseCallbackUrl, CallbackUrlCommand } from '@joplin/lib/callbackUrlUtils';
|
import { isCallbackUrl, parseCallbackUrl, CallbackUrlCommand } from '@joplin/lib/callbackUrlUtils';
|
||||||
@ -99,6 +100,7 @@ SyncTargetRegistry.addClass(SyncTargetDropbox);
|
|||||||
SyncTargetRegistry.addClass(SyncTargetFilesystem);
|
SyncTargetRegistry.addClass(SyncTargetFilesystem);
|
||||||
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
||||||
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
||||||
|
SyncTargetRegistry.addClass(SyncTargetJoplinServerSAML);
|
||||||
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
|
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
|
||||||
|
|
||||||
import FsDriverRN from './utils/fs-driver/fs-driver-rn';
|
import FsDriverRN from './utils/fs-driver/fs-driver-rn';
|
||||||
@ -141,6 +143,8 @@ import { AppState } from './utils/types';
|
|||||||
import { getDisplayParentId } from '@joplin/lib/services/trash';
|
import { getDisplayParentId } from '@joplin/lib/services/trash';
|
||||||
import PluginNotification from './components/plugins/PluginNotification';
|
import PluginNotification from './components/plugins/PluginNotification';
|
||||||
import FocusControl from './components/accessibility/FocusControl/FocusControl';
|
import FocusControl from './components/accessibility/FocusControl/FocusControl';
|
||||||
|
import SsoLoginScreen from './components/screens/SsoLoginScreen';
|
||||||
|
import SamlShared from '@joplin/lib/components/shared/SamlShared';
|
||||||
import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
|
import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
|
||||||
|
|
||||||
const logger = Logger.create('root');
|
const logger = Logger.create('root');
|
||||||
@ -1227,7 +1231,6 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
|||||||
folderId: params.id,
|
folderId: params.id,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1302,6 +1305,7 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
|||||||
OneDriveLogin: { screen: OneDriveLoginScreen },
|
OneDriveLogin: { screen: OneDriveLoginScreen },
|
||||||
DropboxLogin: { screen: DropboxLoginScreen },
|
DropboxLogin: { screen: DropboxLoginScreen },
|
||||||
JoplinCloudLogin: { screen: JoplinCloudLoginScreen },
|
JoplinCloudLogin: { screen: JoplinCloudLoginScreen },
|
||||||
|
JoplinServerSamlLogin: { screen: SsoLoginScreen(new SamlShared()) },
|
||||||
EncryptionConfig: { screen: EncryptionConfigScreen },
|
EncryptionConfig: { screen: EncryptionConfigScreen },
|
||||||
UpgradeSyncTarget: { screen: UpgradeSyncTargetScreen },
|
UpgradeSyncTarget: { screen: UpgradeSyncTargetScreen },
|
||||||
ShareManager: { screen: ShareManager },
|
ShareManager: { screen: ShareManager },
|
||||||
|
@ -9,6 +9,7 @@ import KeychainServiceDriverElectron from './services/keychain/KeychainServiceDr
|
|||||||
import { setLocale } from './locale';
|
import { setLocale } from './locale';
|
||||||
import KvStore from './services/KvStore';
|
import KvStore from './services/KvStore';
|
||||||
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
|
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
|
||||||
|
import SyncTargetJoplinServerSAML from './SyncTargetJoplinServerSAML';
|
||||||
import SyncTargetOneDrive from './SyncTargetOneDrive';
|
import SyncTargetOneDrive from './SyncTargetOneDrive';
|
||||||
import { createStore, applyMiddleware, Store } from 'redux';
|
import { createStore, applyMiddleware, Store } from 'redux';
|
||||||
import { defaultState, stateUtils } from './reducer';
|
import { defaultState, stateUtils } from './reducer';
|
||||||
@ -715,6 +716,7 @@ export default class BaseApplication {
|
|||||||
SyncTargetRegistry.addClass(SyncTargetDropbox);
|
SyncTargetRegistry.addClass(SyncTargetDropbox);
|
||||||
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
||||||
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
||||||
|
SyncTargetRegistry.addClass(SyncTargetJoplinServerSAML);
|
||||||
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
|
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -16,6 +16,7 @@ interface Options {
|
|||||||
userContentBaseUrl(): string;
|
userContentBaseUrl(): string;
|
||||||
username(): string;
|
username(): string;
|
||||||
password(): string;
|
password(): string;
|
||||||
|
session(): Session | null;
|
||||||
env?: Env;
|
env?: Env;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,7 +37,7 @@ export interface ExecOptions {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
}
|
}
|
||||||
@ -76,6 +77,12 @@ export default class JoplinServerApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async session() {
|
private async session() {
|
||||||
|
const optionSession = this.options_.session();
|
||||||
|
|
||||||
|
if (optionSession) {
|
||||||
|
return optionSession;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.session_) return this.session_;
|
if (this.session_) return this.session_;
|
||||||
|
|
||||||
const clientInfo = await this.getClientInfo();
|
const clientInfo = await this.getClientInfo();
|
||||||
|
@ -2,14 +2,14 @@ import FileApiDriverJoplinServer from './file-api-driver-joplinServer';
|
|||||||
import Setting from './models/Setting';
|
import Setting from './models/Setting';
|
||||||
import Synchronizer from './Synchronizer';
|
import Synchronizer from './Synchronizer';
|
||||||
import { _ } from './locale.js';
|
import { _ } from './locale.js';
|
||||||
import JoplinServerApi from './JoplinServerApi';
|
import JoplinServerApi, { Session } from './JoplinServerApi';
|
||||||
import BaseSyncTarget from './BaseSyncTarget';
|
import BaseSyncTarget from './BaseSyncTarget';
|
||||||
import { FileApi } from './file-api';
|
import { FileApi } from './file-api';
|
||||||
import Logger from '@joplin/utils/Logger';
|
import Logger from '@joplin/utils/Logger';
|
||||||
|
|
||||||
const staticLogger = Logger.create('SyncTargetJoplinServer');
|
const staticLogger = Logger.create('SyncTargetJoplinServer');
|
||||||
|
|
||||||
interface FileApiOptions {
|
export interface FileApiOptions {
|
||||||
path(): string;
|
path(): string;
|
||||||
userContentPath(): string;
|
userContentPath(): string;
|
||||||
username(): string;
|
username(): string;
|
||||||
@ -22,6 +22,7 @@ export async function newFileApi(id: number, options: FileApiOptions) {
|
|||||||
userContentBaseUrl: () => options.userContentPath(),
|
userContentBaseUrl: () => options.userContentPath(),
|
||||||
username: () => options.username(),
|
username: () => options.username(),
|
||||||
password: () => options.password(),
|
password: () => options.password(),
|
||||||
|
session: (): Session => null,
|
||||||
env: Setting.value('env'),
|
env: Setting.value('env'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -83,7 +84,7 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
|||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
syncTargetId = syncTargetId === null ? SyncTargetJoplinServer.id() : syncTargetId;
|
syncTargetId = syncTargetId === null ? this.id() : syncTargetId;
|
||||||
|
|
||||||
let fileApi = null;
|
let fileApi = null;
|
||||||
try {
|
try {
|
||||||
|
83
packages/lib/SyncTargetJoplinServerSAML.ts
Normal file
83
packages/lib/SyncTargetJoplinServerSAML.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import FileApiDriverJoplinServer from './file-api-driver-joplinServer';
|
||||||
|
import Setting from './models/Setting';
|
||||||
|
import { _ } from './locale.js';
|
||||||
|
import JoplinServerApi, { Session } from './JoplinServerApi';
|
||||||
|
import { FileApi } from './file-api';
|
||||||
|
import SyncTargetJoplinServer, { FileApiOptions } from './SyncTargetJoplinServer';
|
||||||
|
import Logger from '@joplin/utils/Logger';
|
||||||
|
|
||||||
|
export async function newFileApi(id: number, options: FileApiOptions) {
|
||||||
|
const apiOptions = {
|
||||||
|
baseUrl: () => options.path(),
|
||||||
|
userContentBaseUrl: () => options.userContentPath(),
|
||||||
|
username: () => '',
|
||||||
|
password: () => '',
|
||||||
|
session: () => ({ id: Setting.value('sync.11.id'), user_id: Setting.value('sync.11.userId') }),
|
||||||
|
env: Setting.value('env'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = new JoplinServerApi(apiOptions);
|
||||||
|
const driver = new FileApiDriverJoplinServer(api);
|
||||||
|
const fileApi = new FileApi('', driver);
|
||||||
|
fileApi.setSyncTargetId(id);
|
||||||
|
await fileApi.initialize();
|
||||||
|
return fileApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initFileApi(syncTargetId: number, logger: Logger, options: FileApiOptions) {
|
||||||
|
const fileApi = await newFileApi(syncTargetId, options);
|
||||||
|
fileApi.setLogger(logger);
|
||||||
|
return fileApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authenticateWithCode = async (code: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${Setting.value('sync.11.path')}/api/login_with_code/${code}`);
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token: Session = await response.json();
|
||||||
|
Setting.setValue('sync.11.id', token.id);
|
||||||
|
Setting.setValue('sync.11.userId', token.user_id);
|
||||||
|
|
||||||
|
} catch (_e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A sync target for Joplin Server that uses SAML for authentication.
|
||||||
|
//
|
||||||
|
// Based on the regular Joplin Server sync target.
|
||||||
|
export default class SyncTargetJoplinServerSAML extends SyncTargetJoplinServer {
|
||||||
|
public static override id() {
|
||||||
|
return 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static override targetName() {
|
||||||
|
return 'joplinServerSaml';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static override label() {
|
||||||
|
return `${_('Joplin Server')} (Beta, SAML)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async isAuthenticated() {
|
||||||
|
return Setting.value('sync.11.id') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static override requiresPassword() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async initFileApi() {
|
||||||
|
return initFileApi(SyncTargetJoplinServerSAML.id(), this.logger(), {
|
||||||
|
path: () => Setting.value('sync.11.path'),
|
||||||
|
userContentPath: () => Setting.value('sync.11.userContentPath'),
|
||||||
|
username: () => '',
|
||||||
|
password: () => '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
29
packages/lib/components/shared/SamlShared.ts
Normal file
29
packages/lib/components/shared/SamlShared.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import Setting from '../../models/Setting';
|
||||||
|
import shim from '../../shim';
|
||||||
|
import { authenticateWithCode } from '../../SyncTargetJoplinServerSAML';
|
||||||
|
import prefixWithHttps from '../../utils/prefixWithHttps';
|
||||||
|
import SsoScreenShared from './SsoScreenShared';
|
||||||
|
|
||||||
|
export default class SamlShared implements SsoScreenShared {
|
||||||
|
public openLoginPage() {
|
||||||
|
shim.openUrl(`${prefixWithHttps(Setting.value('sync.11.path'))}/login/sso-saml-app`);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
public processLoginCode(code: string) {
|
||||||
|
if (this.isLoginCodeValid(code)) {
|
||||||
|
return authenticateWithCode(this.cleanCode(code));
|
||||||
|
} else {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isLoginCodeValid(code: string) {
|
||||||
|
const cleanedCode = this.cleanCode(code);
|
||||||
|
return !isNaN(+cleanedCode) && cleanedCode.length === 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanCode(code: string) {
|
||||||
|
return code.replace(/\s|-/gi, '');
|
||||||
|
}
|
||||||
|
}
|
7
packages/lib/components/shared/SsoScreenShared.ts
Normal file
7
packages/lib/components/shared/SsoScreenShared.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
interface SsoScreenShared {
|
||||||
|
openLoginPage(): Promise<void>;
|
||||||
|
processLoginCode(code: string): Promise<boolean>;
|
||||||
|
isLoginCodeValid(code: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SsoScreenShared;
|
@ -349,6 +349,37 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
|||||||
label: () => _('Joplin Server password'),
|
label: () => _('Joplin Server password'),
|
||||||
secure: true,
|
secure: true,
|
||||||
},
|
},
|
||||||
|
'sync.11.path': {
|
||||||
|
value: '',
|
||||||
|
type: SettingItemType.String,
|
||||||
|
section: 'sync',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
|
show: (settings: any) => {
|
||||||
|
return settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml');
|
||||||
|
},
|
||||||
|
public: true,
|
||||||
|
label: () => _('Joplin Server URL'),
|
||||||
|
description: () => emptyDirWarning,
|
||||||
|
storage: SettingStorage.File,
|
||||||
|
},
|
||||||
|
'sync.11.userContentPath': {
|
||||||
|
value: '',
|
||||||
|
type: SettingItemType.String,
|
||||||
|
public: false,
|
||||||
|
storage: SettingStorage.Database,
|
||||||
|
},
|
||||||
|
'sync.11.id': {
|
||||||
|
value: '',
|
||||||
|
type: SettingItemType.String,
|
||||||
|
public: false,
|
||||||
|
storage: SettingStorage.Database,
|
||||||
|
},
|
||||||
|
'sync.11.userId': {
|
||||||
|
value: '',
|
||||||
|
type: SettingItemType.String,
|
||||||
|
public: false,
|
||||||
|
storage: SettingStorage.Database,
|
||||||
|
},
|
||||||
|
|
||||||
// Although sync.10.path is essentially a constant, we still define
|
// Although sync.10.path is essentially a constant, we still define
|
||||||
// it here so that both Joplin Server and Joplin Cloud can be
|
// it here so that both Joplin Server and Joplin Cloud can be
|
||||||
@ -433,6 +464,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
|||||||
'sync.8.context': { value: '', type: SettingItemType.String, public: false },
|
'sync.8.context': { value: '', type: SettingItemType.String, public: false },
|
||||||
'sync.9.context': { value: '', type: SettingItemType.String, public: false },
|
'sync.9.context': { value: '', type: SettingItemType.String, public: false },
|
||||||
'sync.10.context': { value: '', type: SettingItemType.String, public: false },
|
'sync.10.context': { value: '', type: SettingItemType.String, public: false },
|
||||||
|
'sync.11.context': { value: '', type: SettingItemType.String, public: false },
|
||||||
|
|
||||||
'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, storage: SettingStorage.File, isGlobal: true, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 },
|
'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, storage: SettingStorage.File, isGlobal: true, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 },
|
||||||
|
|
||||||
@ -1447,6 +1479,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
|||||||
SyncTargetRegistry.nameToId('nextcloud'),
|
SyncTargetRegistry.nameToId('nextcloud'),
|
||||||
SyncTargetRegistry.nameToId('webdav'),
|
SyncTargetRegistry.nameToId('webdav'),
|
||||||
SyncTargetRegistry.nameToId('joplinServer'),
|
SyncTargetRegistry.nameToId('joplinServer'),
|
||||||
|
SyncTargetRegistry.nameToId('joplinServerSaml'),
|
||||||
// Needs to be enabled for Joplin Cloud too because
|
// Needs to be enabled for Joplin Cloud too because
|
||||||
// some companies filter all traffic and swap TLS
|
// some companies filter all traffic and swap TLS
|
||||||
// certificates, which result in error
|
// certificates, which result in error
|
||||||
|
@ -83,6 +83,16 @@ export default class ShareService {
|
|||||||
userContentBaseUrl: () => Setting.value(`sync.${syncTargetId}.userContentPath`),
|
userContentBaseUrl: () => Setting.value(`sync.${syncTargetId}.userContentPath`),
|
||||||
username: () => Setting.value(`sync.${syncTargetId}.username`),
|
username: () => Setting.value(`sync.${syncTargetId}.username`),
|
||||||
password: () => Setting.value(`sync.${syncTargetId}.password`),
|
password: () => Setting.value(`sync.${syncTargetId}.password`),
|
||||||
|
session: () => {
|
||||||
|
if (syncTargetId === 11) {
|
||||||
|
return {
|
||||||
|
id: Setting.value('sync.11.id'),
|
||||||
|
user_id: Setting.value('sync.11.userId'),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.api_;
|
return this.api_;
|
||||||
|
@ -48,7 +48,7 @@ import RevisionService from '../services/RevisionService';
|
|||||||
import ResourceFetcher from '../services/ResourceFetcher';
|
import ResourceFetcher from '../services/ResourceFetcher';
|
||||||
const WebDavApi = require('../WebDavApi');
|
const WebDavApi = require('../WebDavApi');
|
||||||
const DropboxApi = require('../DropboxApi');
|
const DropboxApi = require('../DropboxApi');
|
||||||
import JoplinServerApi from '../JoplinServerApi';
|
import JoplinServerApi, { Session } from '../JoplinServerApi';
|
||||||
import { FolderEntity, ResourceEntity } from '../services/database/types';
|
import { FolderEntity, ResourceEntity } from '../services/database/types';
|
||||||
import { credentialFile, readCredentialFile } from '../utils/credentialFiles';
|
import { credentialFile, readCredentialFile } from '../utils/credentialFiles';
|
||||||
import SyncTargetJoplinCloud from '../SyncTargetJoplinCloud';
|
import SyncTargetJoplinCloud from '../SyncTargetJoplinCloud';
|
||||||
@ -68,6 +68,7 @@ import OcrService from '../services/ocr/OcrService';
|
|||||||
import { createWorker } from 'tesseract.js';
|
import { createWorker } from 'tesseract.js';
|
||||||
import { reg } from '../registry';
|
import { reg } from '../registry';
|
||||||
import { Store } from 'redux';
|
import { Store } from 'redux';
|
||||||
|
import SyncTargetJoplinServerSAML from '../SyncTargetJoplinServerSAML';
|
||||||
|
|
||||||
// Each suite has its own separate data and temp directory so that multiple
|
// Each suite has its own separate data and temp directory so that multiple
|
||||||
// suites can be run at the same time. suiteName is what is used to
|
// suites can be run at the same time. suiteName is what is used to
|
||||||
@ -129,6 +130,7 @@ SyncTargetRegistry.addClass(SyncTargetDropbox);
|
|||||||
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
||||||
SyncTargetRegistry.addClass(SyncTargetWebDAV);
|
SyncTargetRegistry.addClass(SyncTargetWebDAV);
|
||||||
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
||||||
|
SyncTargetRegistry.addClass(SyncTargetJoplinServerSAML);
|
||||||
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
|
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
|
||||||
|
|
||||||
let syncTargetName_ = '';
|
let syncTargetName_ = '';
|
||||||
@ -146,7 +148,7 @@ function setSyncTargetName(name: string) {
|
|||||||
syncTargetName_ = name;
|
syncTargetName_ = name;
|
||||||
syncTargetId_ = SyncTargetRegistry.nameToId(syncTargetName_);
|
syncTargetId_ = SyncTargetRegistry.nameToId(syncTargetName_);
|
||||||
sleepTime = syncTargetId_ === SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;// 400;
|
sleepTime = syncTargetId_ === SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;// 400;
|
||||||
isNetworkSyncTarget_ = ['nextcloud', 'dropbox', 'onedrive', 'amazon_s3', 'joplinServer', 'joplinCloud'].includes(syncTargetName_);
|
isNetworkSyncTarget_ = ['nextcloud', 'dropbox', 'onedrive', 'amazon_s3', 'joplinServer', 'joplinServerSaml', 'joplinCloud'].includes(syncTargetName_);
|
||||||
synchronizers_ = [];
|
synchronizers_ = [];
|
||||||
return previousName;
|
return previousName;
|
||||||
}
|
}
|
||||||
@ -697,6 +699,7 @@ async function initFileApi() {
|
|||||||
userContentBaseUrl: () => joplinServerAuth.userContentBaseUrl,
|
userContentBaseUrl: () => joplinServerAuth.userContentBaseUrl,
|
||||||
username: () => joplinServerAuth.email,
|
username: () => joplinServerAuth.email,
|
||||||
password: () => joplinServerAuth.password,
|
password: () => joplinServerAuth.password,
|
||||||
|
session: (): Session => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
fileApi = new FileApi('', new FileApiDriverJoplinServer(api));
|
fileApi = new FileApi('', new FileApiDriverJoplinServer(api));
|
||||||
|
9
packages/lib/utils/prefixWithHttps.ts
Normal file
9
packages/lib/utils/prefixWithHttps.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const prefixWithHttps = (url: string) => {
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
return `https://${url}`;
|
||||||
|
} else {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default prefixWithHttps;
|
@ -22,6 +22,7 @@
|
|||||||
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json"
|
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@authenio/samlify-xmllint-wasm": "1.0.1",
|
||||||
"@aws-sdk/client-s3": "3.296.0",
|
"@aws-sdk/client-s3": "3.296.0",
|
||||||
"@fortawesome/fontawesome-free": "5.15.4",
|
"@fortawesome/fontawesome-free": "5.15.4",
|
||||||
"@joplin/lib": "~3.4",
|
"@joplin/lib": "~3.4",
|
||||||
@ -53,6 +54,7 @@
|
|||||||
"query-string": "7.1.3",
|
"query-string": "7.1.3",
|
||||||
"rate-limiter-flexible": "5.0.3",
|
"rate-limiter-flexible": "5.0.3",
|
||||||
"raw-body": "2.5.2",
|
"raw-body": "2.5.2",
|
||||||
|
"samlify": "2.8.10",
|
||||||
"sqlite3": "5.1.6",
|
"sqlite3": "5.1.6",
|
||||||
"stripe": "8.222.0",
|
"stripe": "8.222.0",
|
||||||
"uuid": "9.0.1",
|
"uuid": "9.0.1",
|
||||||
|
Binary file not shown.
@ -28,6 +28,7 @@ import { setLocale } from '@joplin/lib/locale';
|
|||||||
import initLib from '@joplin/lib/initLib';
|
import initLib from '@joplin/lib/initLib';
|
||||||
import checkAdminHandler from './middleware/checkAdminHandler';
|
import checkAdminHandler from './middleware/checkAdminHandler';
|
||||||
import ActionLogger from '@joplin/lib/utils/ActionLogger';
|
import ActionLogger from '@joplin/lib/utils/ActionLogger';
|
||||||
|
import { setupSamlAuthentication } from './utils/saml';
|
||||||
|
|
||||||
interface Argv {
|
interface Argv {
|
||||||
env?: Env;
|
env?: Env;
|
||||||
@ -260,6 +261,10 @@ async function main() {
|
|||||||
fs.writeFileSync(pidFile, `${process.pid}`);
|
fs.writeFileSync(pidFile, `${process.pid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config().saml.enabled) {
|
||||||
|
setupSamlAuthentication();
|
||||||
|
}
|
||||||
|
|
||||||
let runCommandAndExitApp = true;
|
let runCommandAndExitApp = true;
|
||||||
|
|
||||||
if (selectedCommand) {
|
if (selectedCommand) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { rtrimSlashes } from '@joplin/lib/path-utils';
|
import { rtrimSlashes } from '@joplin/lib/path-utils';
|
||||||
import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, LdapConfig, RouteType, StripeConfig } from './utils/types';
|
import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, LdapConfig, RouteType, StripeConfig, SamlConfig } from './utils/types';
|
||||||
import * as pathUtils from 'path';
|
import * as pathUtils from 'path';
|
||||||
import { loadStripeConfig, StripePublicConfig } from '@joplin/lib/utils/joplinCloud';
|
import { loadStripeConfig, StripePublicConfig } from '@joplin/lib/utils/joplinCloud';
|
||||||
import { EnvVariables } from './env';
|
import { EnvVariables } from './env';
|
||||||
@ -145,6 +145,24 @@ function ldapConfigFromEnv(env: EnvVariables): LdapConfig[] {
|
|||||||
return ldapConfig;
|
return ldapConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function samlConfigFromEnv(env: EnvVariables): SamlConfig {
|
||||||
|
if (env.SAML_ENABLED) {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
identityProviderConfigFile: env.SAML_IDP_CONFIG_FILE,
|
||||||
|
serviceProviderConfigFile: env.SAML_SP_CONFIG_FILE,
|
||||||
|
organizationDisplayName: env.SAML_ORGANIZATION_DISPLAY_NAME,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
identityProviderConfigFile: '',
|
||||||
|
serviceProviderConfigFile: '',
|
||||||
|
organizationDisplayName: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let config_: Config = null;
|
let config_: Config = null;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
@ -197,6 +215,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
|
|||||||
itemSizeHardLimit: 250000000, // Beyond this the Postgres driver will crash the app
|
itemSizeHardLimit: 250000000, // Beyond this the Postgres driver will crash the app
|
||||||
maxTimeDrift: env.MAX_TIME_DRIFT,
|
maxTimeDrift: env.MAX_TIME_DRIFT,
|
||||||
ldap: ldapConfigFromEnv(env),
|
ldap: ldapConfigFromEnv(env),
|
||||||
|
saml: samlConfigFromEnv(env),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -42,6 +42,10 @@ const defaultEnvValues: EnvVariables = {
|
|||||||
|
|
||||||
DELTA_INCLUDES_ITEMS: true,
|
DELTA_INCLUDES_ITEMS: true,
|
||||||
|
|
||||||
|
// Whether or not to allow users logging in with a username/password combo.
|
||||||
|
// If this is disabled, a SAML-based login flow must be configured.
|
||||||
|
LOCAL_AUTH_ENABLED: true,
|
||||||
|
|
||||||
// ==================================================
|
// ==================================================
|
||||||
// URL config
|
// URL config
|
||||||
// ==================================================
|
// ==================================================
|
||||||
@ -149,6 +153,14 @@ const defaultEnvValues: EnvVariables = {
|
|||||||
LDAP_2_BIND_PW: '', // used for user search - leave empty if ldap server allows anonymous bind
|
LDAP_2_BIND_PW: '', // used for user search - leave empty if ldap server allows anonymous bind
|
||||||
LDAP_2_TLS_CA_FILE: '', // used for self-signed certificate with ldaps - leave empty if using ldap or server uses CA-issued certificate
|
LDAP_2_TLS_CA_FILE: '', // used for self-signed certificate with ldaps - leave empty if using ldap or server uses CA-issued certificate
|
||||||
|
|
||||||
|
// ==================================================
|
||||||
|
// SAML configuration
|
||||||
|
// ==================================================
|
||||||
|
|
||||||
|
SAML_ENABLED: false,
|
||||||
|
SAML_IDP_CONFIG_FILE: '', // Config file for the Identity Provider. Should point to an XML file generated by the Identity Provider.
|
||||||
|
SAML_SP_CONFIG_FILE: '', // Config file for the Service Provider (Joplin, in this case). Should point to an XML file generated by the Identity Provider.
|
||||||
|
SAML_ORGANIZATION_DISPLAY_NAME: '', // The name of the organization to display on the login screen. Optional.
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface EnvVariables {
|
export interface EnvVariables {
|
||||||
@ -241,6 +253,13 @@ export interface EnvVariables {
|
|||||||
LDAP_2_BIND_DN: string;
|
LDAP_2_BIND_DN: string;
|
||||||
LDAP_2_BIND_PW: string;
|
LDAP_2_BIND_PW: string;
|
||||||
LDAP_2_TLS_CA_FILE: string;
|
LDAP_2_TLS_CA_FILE: string;
|
||||||
|
|
||||||
|
SAML_ENABLED: boolean;
|
||||||
|
SAML_IDP_CONFIG_FILE: string;
|
||||||
|
SAML_SP_CONFIG_FILE: string;
|
||||||
|
SAML_ORGANIZATION_DISPLAY_NAME: string;
|
||||||
|
|
||||||
|
LOCAL_AUTH_ENABLED: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseBoolean = (s: string): boolean => {
|
const parseBoolean = (s: string): boolean => {
|
||||||
|
@ -0,0 +1,13 @@
|
|||||||
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
|
export const up = async (db: DbConnection) => {
|
||||||
|
await db.schema.alterTable('users', (table) => {
|
||||||
|
table.integer('is_external').defaultTo(0).notNullable();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = async (db: DbConnection) => {
|
||||||
|
await db.schema.alterTable('users', (table) => {
|
||||||
|
table.dropColumn('is_external');
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,15 @@
|
|||||||
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
|
export const up = async (db: DbConnection) => {
|
||||||
|
await db.schema.alterTable('users', (table) => {
|
||||||
|
table.string('sso_auth_code').defaultTo('').notNullable();
|
||||||
|
table.integer('sso_auth_code_expire_at').defaultTo(0).notNullable();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = async (db: DbConnection) => {
|
||||||
|
await db.schema.alterTable('users', (table) => {
|
||||||
|
table.dropColumn('sso_auth_code');
|
||||||
|
table.dropColumn('sso_auth_code_expire_at');
|
||||||
|
});
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, expectThrow } from '../utils/testing/testUtils';
|
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, expectThrow, createUser } from '../utils/testing/testUtils';
|
||||||
import { EmailSender, UserFlagType } from '../services/database/types';
|
import { EmailSender, UserFlagType } from '../services/database/types';
|
||||||
import { ErrorBadRequest, ErrorUnprocessableEntity } from '../utils/errors';
|
import { ErrorBadRequest, ErrorUnprocessableEntity } from '../utils/errors';
|
||||||
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
|
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
|
||||||
@ -6,6 +6,7 @@ import { accountByType, AccountType } from './UserModel';
|
|||||||
import { failedPaymentFinalAccount, failedPaymentWarningInterval } from './SubscriptionModel';
|
import { failedPaymentFinalAccount, failedPaymentWarningInterval } from './SubscriptionModel';
|
||||||
import { stripePortalUrl } from '../utils/urlUtils';
|
import { stripePortalUrl } from '../utils/urlUtils';
|
||||||
import { Day } from '../utils/time';
|
import { Day } from '../utils/time';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
describe('UserModel', () => {
|
describe('UserModel', () => {
|
||||||
|
|
||||||
@ -455,4 +456,11 @@ describe('UserModel', () => {
|
|||||||
expect(error instanceof ErrorBadRequest).toBe(true);
|
expect(error instanceof ErrorBadRequest).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should not log in an user using a email/password combo when the local auth is disabled', async () => {
|
||||||
|
config().LOCAL_AUTH_ENABLED = false;
|
||||||
|
|
||||||
|
const user = await createUser();
|
||||||
|
|
||||||
|
expect(await models().user().login(user.email, '123456')).toBe(null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -31,6 +31,8 @@ import { Config, Env, LdapConfig } from '../utils/types';
|
|||||||
import ldapLogin from '../utils/ldapLogin';
|
import ldapLogin from '../utils/ldapLogin';
|
||||||
import { DbConnection } from '../db';
|
import { DbConnection } from '../db';
|
||||||
import { NewModelFactoryHandler } from './factory';
|
import { NewModelFactoryHandler } from './factory';
|
||||||
|
import config from '../config';
|
||||||
|
import { randomInt } from 'node:crypto';
|
||||||
|
|
||||||
const logger = Logger.create('UserModel');
|
const logger = Logger.create('UserModel');
|
||||||
|
|
||||||
@ -115,12 +117,12 @@ export function accountTypeToString(accountType: AccountType): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class UserModel extends BaseModel<User> {
|
export default class UserModel extends BaseModel<User> {
|
||||||
|
private authCodeTtl = 600000; // 10 minutes
|
||||||
|
|
||||||
private ldapConfig_: LdapConfig[];
|
private ldapConfig_: LdapConfig[];
|
||||||
|
|
||||||
public constructor(db: DbConnection, dbSlave: DbConnection, modelFactory: NewModelFactoryHandler, config: Config) {
|
public constructor(db: DbConnection, dbSlave: DbConnection, modelFactory: NewModelFactoryHandler, config: Config) {
|
||||||
super(db, dbSlave, modelFactory, config);
|
super(db, dbSlave, modelFactory, config);
|
||||||
|
|
||||||
this.ldapConfig_ = config.ldap;
|
this.ldapConfig_ = config.ldap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,7 +135,16 @@ export default class UserModel extends BaseModel<User> {
|
|||||||
return this.db<User>(this.tableName).where(user).first();
|
return this.db<User>(this.tableName).where(user).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async loadBySsoAuthCode(code: string): Promise<User> {
|
||||||
|
const user = this.formatValues({ sso_auth_code: code });
|
||||||
|
return this.db<User>(this.tableName).where(user).first();
|
||||||
|
}
|
||||||
|
|
||||||
public async login(email: string, password: string): Promise<User> {
|
public async login(email: string, password: string): Promise<User> {
|
||||||
|
if (!config().LOCAL_AUTH_ENABLED) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.loadByEmail(email);
|
const user = await this.loadByEmail(email);
|
||||||
|
|
||||||
for (const config of this.ldapConfig_) {
|
for (const config of this.ldapConfig_) {
|
||||||
@ -149,11 +160,74 @@ export default class UserModel extends BaseModel<User> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user || user.is_external) return null;
|
||||||
if (!(await checkPassword(password, user.password))) return null;
|
if (!(await checkPassword(password, user.password))) return null;
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ssoLogin(email: string, displayName: string) {
|
||||||
|
if (!email || !displayName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await this.loadByEmail(email);
|
||||||
|
|
||||||
|
if (!user) { // User does not exist
|
||||||
|
user = {
|
||||||
|
email: email,
|
||||||
|
full_name: displayName,
|
||||||
|
must_set_password: 0,
|
||||||
|
email_confirmed: 1,
|
||||||
|
is_external: 1,
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
user = await this.save(user, { skipValidation: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async generateSsoCode(user: User) {
|
||||||
|
let authCode;
|
||||||
|
|
||||||
|
// Make sure that the code is not already in use.
|
||||||
|
do {
|
||||||
|
authCode = randomInt(0, 999999999).toString().padStart(9, '0');
|
||||||
|
} while (await this.loadBySsoAuthCode(authCode) === null);
|
||||||
|
|
||||||
|
user.sso_auth_code = authCode;
|
||||||
|
user.sso_auth_code_expire_at = Date.now() + this.authCodeTtl;
|
||||||
|
|
||||||
|
await this.save(user, { skipValidation: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async authCodeLogin(code: string) {
|
||||||
|
const user = await this.loadBySsoAuthCode(code);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
} else if (user.sso_auth_code_expire_at > Date.now()) {
|
||||||
|
// Clear the saved code
|
||||||
|
user.sso_auth_code = '';
|
||||||
|
user.sso_auth_code_expire_at = 0;
|
||||||
|
|
||||||
|
return await this.save(user, { skipValidation: true });
|
||||||
|
} else { // Code is expired. Clear the code but do not return the user.
|
||||||
|
user.sso_auth_code = '';
|
||||||
|
user.sso_auth_code_expire_at = 0;
|
||||||
|
|
||||||
|
await this.save(user, { skipValidation: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteExpiredAuthCodes() {
|
||||||
|
await this.db(this.tableName)
|
||||||
|
.where('sso_auth_code_expire_at', '<', Date.now())
|
||||||
|
.update({ sso_auth_code: '', sso_auth_code_expire_at: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
public fromApiInput(object: User): User {
|
public fromApiInput(object: User): User {
|
||||||
const user: User = {};
|
const user: User = {};
|
||||||
|
|
||||||
@ -169,6 +243,9 @@ export default class UserModel extends BaseModel<User> {
|
|||||||
if ('can_upload' in object) user.can_upload = object.can_upload;
|
if ('can_upload' in object) user.can_upload = object.can_upload;
|
||||||
if ('account_type' in object) user.account_type = object.account_type;
|
if ('account_type' in object) user.account_type = object.account_type;
|
||||||
if ('must_set_password' in object) user.must_set_password = object.must_set_password;
|
if ('must_set_password' in object) user.must_set_password = object.must_set_password;
|
||||||
|
if ('is_external' in object) user.is_external = object.is_external;
|
||||||
|
if ('sso_auth_code' in object) user.sso_auth_code = object.sso_auth_code;
|
||||||
|
if ('sso_auth_code_expire_at' in object) user.sso_auth_code_expire_at = object.sso_auth_code_expire_at;
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@ -190,6 +267,10 @@ export default class UserModel extends BaseModel<User> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === AclAction.Update) {
|
if (action === AclAction.Update) {
|
||||||
|
if (user.is_external) { // Modifying users directly from Joplin may cause them to be out of sync with the data we got from the Identity Provider
|
||||||
|
throw new ErrorForbidden('users imported from an external source (such as SAML) cannot be modified');
|
||||||
|
}
|
||||||
|
|
||||||
const previousResource = await this.load(resource.id);
|
const previousResource = await this.load(resource.id);
|
||||||
|
|
||||||
if (!user.is_admin && resource.id !== user.id) throw new ErrorForbidden('non-admin user cannot modify another user');
|
if (!user.is_admin && resource.id !== user.id) throw new ErrorForbidden('non-admin user cannot modify another user');
|
||||||
@ -705,5 +786,4 @@ export default class UserModel extends BaseModel<User> {
|
|||||||
}
|
}
|
||||||
}, 'UserModel::saveMulti');
|
}, 'UserModel::saveMulti');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
92
packages/server/src/routes/api/login.ts
Normal file
92
packages/server/src/routes/api/login.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import config from '../../config';
|
||||||
|
import Router from '../../utils/Router';
|
||||||
|
import { redirect, SubPath } from '../../utils/routeUtils';
|
||||||
|
import { generateRedirectHtml, getIdentityProvider, getServiceProvider } from '../../utils/saml';
|
||||||
|
import { AppContext, RouteType, SamlPostResponse } from '../../utils/types';
|
||||||
|
import { bodyFields } from '../../utils/requestUtils';
|
||||||
|
import { ErrorBadRequest, ErrorForbidden } from '../../utils/errors';
|
||||||
|
import { cookieSet } from '../../utils/cookies';
|
||||||
|
import defaultView from '../../utils/defaultView';
|
||||||
|
|
||||||
|
export const router = new Router(RouteType.Api);
|
||||||
|
|
||||||
|
router.public = true;
|
||||||
|
|
||||||
|
// Redirect the user to the Identity Provider login page, if they somehow get to this URL directly.
|
||||||
|
router.get('api/saml', async (_path: SubPath, _ctx: AppContext) => {
|
||||||
|
if (!config().saml.enabled) throw new ErrorForbidden('SAML not enabled');
|
||||||
|
return await generateRedirectHtml();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Called when a user successfully authenticated with the Identity Provider, and was redirected to Joplin.
|
||||||
|
router.post('api/saml', async (_path: SubPath, ctx: AppContext) => {
|
||||||
|
if (!config().saml.enabled) throw new ErrorForbidden('SAML not enabled');
|
||||||
|
|
||||||
|
// Load SAML configuration
|
||||||
|
const [serviceProvider, identityProvider] = await Promise.all([
|
||||||
|
getServiceProvider(),
|
||||||
|
getIdentityProvider(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse the login response
|
||||||
|
const fields = await bodyFields<SamlPostResponse>(ctx.req);
|
||||||
|
|
||||||
|
const result = await serviceProvider.parseLoginResponse(identityProvider, 'post', { body: fields });
|
||||||
|
|
||||||
|
// Extract attributes from the SAML response
|
||||||
|
const email = result.extract.attributes['email'];
|
||||||
|
const displayName = result.extract.attributes['displayName'];
|
||||||
|
|
||||||
|
// Load the user
|
||||||
|
const user = await ctx.joplin.models.user().ssoLogin(email, displayName);
|
||||||
|
|
||||||
|
if (fields.RelayState) {
|
||||||
|
switch (fields.RelayState) {
|
||||||
|
case 'web-login': { // If the user wanted to load a page from Joplin Server, we set the cookie for this session
|
||||||
|
const session = await ctx.joplin.models.session().createUserSession(user.id);
|
||||||
|
cookieSet(ctx, 'sessionId', session.id);
|
||||||
|
|
||||||
|
return redirect(ctx, `${config().baseUrl}/home`);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'app-login': { // If the user came from a client, we display the authentication code
|
||||||
|
await ctx.joplin.models.user().generateSsoCode(user);
|
||||||
|
|
||||||
|
const view = defaultView('displaySsoCode', 'Login');
|
||||||
|
|
||||||
|
view.content = {
|
||||||
|
ssoCode: user.sso_auth_code.replace(/\B(?=(\d{3})+(?!\d))/g, '-'), // Split the code into blocks of three digits each
|
||||||
|
organizationName: config().saml.enabled && config().saml.organizationDisplayName ? config().saml.organizationDisplayName : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // Otherwise, just return the authentication code
|
||||||
|
await ctx.joplin.models.user().generateSsoCode(user);
|
||||||
|
|
||||||
|
return { code: user.sso_auth_code };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('api/login_with_code/:id', async (path: SubPath, ctx: AppContext) => {
|
||||||
|
const code = path.id;
|
||||||
|
if (!code) {
|
||||||
|
throw new ErrorBadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await ctx.joplin.models.user().authCodeLogin(code);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const session = await ctx.joplin.models.session().createUserSession(user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
user_id: session.user_id,
|
||||||
|
};
|
||||||
|
} else { // Invalid auth code
|
||||||
|
throw new ErrorBadRequest();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
@ -9,6 +9,8 @@ import { View } from '../../services/MustacheService';
|
|||||||
import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce';
|
import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce';
|
||||||
import { cookieSet } from '../../utils/cookies';
|
import { cookieSet } from '../../utils/cookies';
|
||||||
import { homeUrl } from '../../utils/urlUtils';
|
import { homeUrl } from '../../utils/urlUtils';
|
||||||
|
import { generateRedirectHtml } from '../../utils/saml';
|
||||||
|
import { ErrorForbidden } from '../../utils/errors';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
function makeView(error: any = null): View {
|
function makeView(error: any = null): View {
|
||||||
@ -16,6 +18,8 @@ function makeView(error: any = null): View {
|
|||||||
view.content = {
|
view.content = {
|
||||||
error,
|
error,
|
||||||
signupUrl: config().signupEnabled || config().isJoplinCloud ? makeUrl(UrlType.Signup) : '',
|
signupUrl: config().signupEnabled || config().isJoplinCloud ? makeUrl(UrlType.Signup) : '',
|
||||||
|
samlEnabled: config().saml.enabled,
|
||||||
|
samlOrganizationName: config().saml.enabled && config().saml.organizationDisplayName ? config().saml.organizationDisplayName : undefined,
|
||||||
};
|
};
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
@ -28,9 +32,29 @@ router.get('login', async (_path: SubPath, ctx: AppContext) => {
|
|||||||
if (ctx.joplin.owner) {
|
if (ctx.joplin.owner) {
|
||||||
return redirect(ctx, homeUrl());
|
return redirect(ctx, homeUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!config().LOCAL_AUTH_ENABLED) {
|
||||||
|
return await generateRedirectHtml('web-login');
|
||||||
|
}
|
||||||
|
|
||||||
return makeView();
|
return makeView();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log in using external authentication.
|
||||||
|
router.get('login/:id', async (path: SubPath, ctx: AppContext) => {
|
||||||
|
if (!config().saml.enabled) throw new ErrorForbidden('SAML not enabled');
|
||||||
|
|
||||||
|
if (ctx.joplin.owner) { // Already logged-in
|
||||||
|
return redirect(ctx, homeUrl());
|
||||||
|
} else if (config().saml.enabled && path.id === 'sso-saml') { // Server page, SAML
|
||||||
|
return await generateRedirectHtml('web-login');
|
||||||
|
} else if (config().saml.enabled && path.id === 'sso-saml-app') { // Client, SAML
|
||||||
|
return await generateRedirectHtml('app-login');
|
||||||
|
} else {
|
||||||
|
return makeView();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('login', async (_path: SubPath, ctx: AppContext) => {
|
router.post('login', async (_path: SubPath, ctx: AppContext) => {
|
||||||
await limiterLoginBruteForce(userIp(ctx));
|
await limiterLoginBruteForce(userIp(ctx));
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ import apiSessions from './api/sessions';
|
|||||||
import apiShares from './api/shares';
|
import apiShares from './api/shares';
|
||||||
import apiShareUsers from './api/share_users';
|
import apiShareUsers from './api/share_users';
|
||||||
import apiUsers from './api/users';
|
import apiUsers from './api/users';
|
||||||
|
import apiLogin from './api/login';
|
||||||
|
|
||||||
import adminDashboard from './admin/dashboard';
|
import adminDashboard from './admin/dashboard';
|
||||||
import adminEmails from './admin/emails';
|
import adminEmails from './admin/emails';
|
||||||
@ -45,6 +46,8 @@ const routes: Routers = {
|
|||||||
'api/items': apiItems,
|
'api/items': apiItems,
|
||||||
'api/locks': apiLocks,
|
'api/locks': apiLocks,
|
||||||
'api/ping': apiPing,
|
'api/ping': apiPing,
|
||||||
|
'api/saml': apiLogin,
|
||||||
|
'api/login_with_code': apiLogin,
|
||||||
'api/sessions': apiSessions,
|
'api/sessions': apiSessions,
|
||||||
'api/share_users': apiShareUsers,
|
'api/share_users': apiShareUsers,
|
||||||
'api/shares': apiShares,
|
'api/shares': apiShares,
|
||||||
|
1
packages/server/src/samlify-xmllint-wasm.d.ts
vendored
Normal file
1
packages/server/src/samlify-xmllint-wasm.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module '@authenio/samlify-xmllint-wasm';
|
@ -33,6 +33,7 @@ export const taskIdToLabel = (taskId: TaskId): string => {
|
|||||||
[TaskId.ProcessEmails]: 'Process emails',
|
[TaskId.ProcessEmails]: 'Process emails',
|
||||||
[TaskId.LogHeartbeatMessage]: 'Log heartbeat message',
|
[TaskId.LogHeartbeatMessage]: 'Log heartbeat message',
|
||||||
[TaskId.DeleteOldEvents]: 'Delete old events',
|
[TaskId.DeleteOldEvents]: 'Delete old events',
|
||||||
|
[TaskId.DeleteExpiredAuthCodes]: 'Delete expired authentication codes',
|
||||||
};
|
};
|
||||||
|
|
||||||
const s = strings[taskId];
|
const s = strings[taskId];
|
||||||
|
@ -137,6 +137,7 @@ export enum TaskId {
|
|||||||
ProcessEmails,
|
ProcessEmails,
|
||||||
LogHeartbeatMessage,
|
LogHeartbeatMessage,
|
||||||
DeleteOldEvents,
|
DeleteOldEvents,
|
||||||
|
DeleteExpiredAuthCodes,
|
||||||
}
|
}
|
||||||
|
|
||||||
// AUTO-GENERATED-TYPES
|
// AUTO-GENERATED-TYPES
|
||||||
@ -259,6 +260,9 @@ export interface User extends WithDates, WithUuid {
|
|||||||
enabled?: number;
|
enabled?: number;
|
||||||
disabled_time?: number;
|
disabled_time?: number;
|
||||||
can_receive_folder?: number;
|
can_receive_folder?: number;
|
||||||
|
is_external?: number;
|
||||||
|
sso_auth_code?: string;
|
||||||
|
sso_auth_code_expire_at?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserFlag extends WithDates {
|
export interface UserFlag extends WithDates {
|
||||||
@ -468,6 +472,9 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
enabled: { type: 'number', defaultValue: 1 },
|
enabled: { type: 'number', defaultValue: 1 },
|
||||||
disabled_time: { type: 'string', defaultValue: 0 },
|
disabled_time: { type: 'string', defaultValue: 0 },
|
||||||
can_receive_folder: { type: 'number', defaultValue: null },
|
can_receive_folder: { type: 'number', defaultValue: null },
|
||||||
|
is_external: { type: 'number', defaultValue: 0 },
|
||||||
|
sso_auth_code: { type: 'string', defaultValue: '' },
|
||||||
|
sso_auth_code_expire_at: { type: 'number', defaultValue: 0 },
|
||||||
},
|
},
|
||||||
user_flags: {
|
user_flags: {
|
||||||
id: { type: 'number', defaultValue: null },
|
id: { type: 'number', defaultValue: null },
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { hashPassword } from './auth';
|
import { hashPassword } from './auth';
|
||||||
|
|
||||||
describe('hashPassword', () => {
|
describe('auth', () => {
|
||||||
|
|
||||||
// cSpell:disable
|
// cSpell:disable
|
||||||
it.each(
|
it.each(
|
||||||
@ -16,5 +16,4 @@ describe('hashPassword', () => {
|
|||||||
expect((await hashPassword(plainText)).startsWith('$2a$10')).toBe(true);
|
expect((await hashPassword(plainText)).startsWith('$2a$10')).toBe(true);
|
||||||
});
|
});
|
||||||
// cSpell:enable
|
// cSpell:enable
|
||||||
|
|
||||||
});
|
});
|
||||||
|
91
packages/server/src/utils/saml.test.ts
Normal file
91
packages/server/src/utils/saml.test.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { writeFile, remove } from 'fs-extra';
|
||||||
|
import { createTempDir } from '@joplin/lib/testing/test-utils';
|
||||||
|
import config from '../config';
|
||||||
|
import { getLoginRequest } from './saml';
|
||||||
|
import { afterAllTests, beforeAllDb, beforeEachDb } from './testing/testUtils';
|
||||||
|
import { SamlRelayState } from './types';
|
||||||
|
|
||||||
|
describe('getLoginRequest', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await beforeAllDb('saml');
|
||||||
|
|
||||||
|
// cSpell:disable
|
||||||
|
const spXml = `
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
|
||||||
|
entityID="Joplin">
|
||||||
|
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||||
|
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||||
|
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||||
|
Location="http://localhost:22300/api/saml"
|
||||||
|
index="1" />
|
||||||
|
|
||||||
|
</md:SPSSODescriptor>
|
||||||
|
</md:EntityDescriptor>`;
|
||||||
|
|
||||||
|
const idpXml = `
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="saml-idp">
|
||||||
|
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||||
|
<md:KeyDescriptor use="signing">
|
||||||
|
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<ds:X509Data>
|
||||||
|
<ds:X509Certificate>MIIDIzCCAgugAwIBAgIUOGfU4onZ0So0R4L4FH2OUo7cmwcwDQYJKoZIhvcNAQELBQAwITEfMB0GA1UEAwwWVGVzdCBJZGVudGl0eSBQcm92aWRlcjAeFw0yNDEwMjUwOTI0NDBaFw00NDEwMjAwOTI0NDBaMCExHzAdBgNVBAMMFlRlc3QgSWRlbnRpdHkgUHJvdmlkZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCrgUKiNwsnlCwQViTqUfTKJXtGQdFZ5ZHHupqNX3hLa2H/MqL25k00p9dw3h9ddpnpmvBsP4jaEeXF4ibU/HQ78cWiUzPkQripkTtYvAM2I/KodqyCHPJr0yJtFUCT/rDrtrCRZ1eZ+K1nvzVFBqiQwgY8IOmhVIqvK7r+sOuDoP7fFDbiZgDyD07noA/oMlcfkm/xj5O70YGX+Iqh8FMJTA8z6DyqTQKtXPBhndkchZDehCkWmKsmpvM3X9QBBl71tJoFu9WqGgtvfMWq+/WoTJ18jbcj0p2jhhEuvDsI1jmeisXzwunO0HtmbDgd17rjOP2CIXUffAV+gg7B5PFBAgMBAAGjUzBRMB0GA1UdDgQWBBSDjyS0o+Y8Sjb885BCo+bmvbwrgTAfBgNVHSMEGDAWgBSDjyS0o+Y8Sjb885BCo+bmvbwrgTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAMxqjfHu6rjnm4PeOXywpnRca8Md95tnh0YJNAu9Vb19jpqUF96psS1lZMqmZ66tnLPCi+rBAtI66BO2wClqxe5K9MeiJIZOwDHLqJ8TDGE+8LM/uEOqobtdjp1vSEuLAC2zeXba9ISqYUrXGcTic65EERGBnG3w2D/rTm7te7C0b6yYet1l4K1RqctxDaI90YV2a1aiT1wngaOQclHAJlR7c0kJP6JZaS/R56Y88S0exZo82u4CsI3GuY42M2ET74/5pllsRsYrQz6iXqnrbcpxvFAWj5D+1uq+rdqc8M0dW5CXZ7zLjJxXH9pFneOnSyX6YbuK+b6kdKUxKlQRMs</ds:X509Certificate>
|
||||||
|
</ds:X509Data>
|
||||||
|
</ds:KeyInfo>
|
||||||
|
</md:KeyDescriptor>
|
||||||
|
<md:KeyDescriptor use="encryption">
|
||||||
|
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<ds:X509Data>
|
||||||
|
<ds:X509Certificate>MIIDIzCCAgugAwIBAgIUOGfU4onZ0So0R4L4FH2OUo7cmwcwDQYJKoZIhvcNAQELBQAwITEfMB0GA1UEAwwWVGVzdCBJZGVudGl0eSBQcm92aWRlcjAeFw0yNDEwMjUwOTI0NDBaFw00NDEwMjAwOTI0NDBaMCExHzAdBgNVBAMMFlRlc3QgSWRlbnRpdHkgUHJvdmlkZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCrgUKiNwsnlCwQViTqUfTKJXtGQdFZ5ZHHupqNX3hLa2H/MqL25k00p9dw3h9ddpnpmvBsP4jaEeXF4ibU/HQ78cWiUzPkQripkTtYvAM2I/KodqyCHPJr0yJtFUCT/rDrtrCRZ1eZ+K1nvzVFBqiQwgY8IOmhVIqvK7r+sOuDoP7fFDbiZgDyD07noA/oMlcfkm/xj5O70YGX+Iqh8FMJTA8z6DyqTQKtXPBhndkchZDehCkWmKsmpvM3X9QBBl71tJoFu9WqGgtvfMWq+/WoTJ18jbcj0p2jhhEuvDsI1jmeisXzwunO0HtmbDgd17rjOP2CIXUffAV+gg7B5PFBAgMBAAGjUzBRMB0GA1UdDgQWBBSDjyS0o+Y8Sjb885BCo+bmvbwrgTAfBgNVHSMEGDAWgBSDjyS0o+Y8Sjb885BCo+bmvbwrgTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAMxqjfHu6rjnm4PeOXywpnRca8Md95tnh0YJNAu9Vb19jpqUF96psS1lZMqmZ66tnLPCi+rBAtI66BO2wClqxe5K9MeiJIZOwDHLqJ8TDGE+8LM/uEOqobtdjp1vSEuLAC2zeXba9ISqYUrXGcTic65EERGBnG3w2D/rTm7te7C0b6yYet1l4K1RqctxDaI90YV2a1aiT1wngaOQclHAJlR7c0kJP6JZaS/R56Y88S0exZo82u4CsI3GuY42M2ET74/5pllsRsYrQz6iXqnrbcpxvFAWj5D+1uq+rdqc8M0dW5CXZ7zLjJxXH9pFneOnSyX6YbuK+b6kdKUxKlQRMs</ds:X509Certificate>
|
||||||
|
</ds:X509Data>
|
||||||
|
</ds:KeyInfo>
|
||||||
|
</md:KeyDescriptor>
|
||||||
|
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||||
|
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
|
||||||
|
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
|
||||||
|
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:7000/saml/sso"/>
|
||||||
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:7000/saml/sso"/>
|
||||||
|
<Attribute Name="firstName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="First Name" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||||
|
<Attribute Name="lastName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="Last Name" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||||
|
<Attribute Name="displayName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="Display Name" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||||
|
<Attribute Name="email" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="E-Mail Address" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||||
|
<Attribute Name="mobilePhone" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="Mobile Phone" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||||
|
<Attribute Name="groups" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="Groups" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||||
|
<Attribute Name="userType" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="User Type" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||||
|
</md:IDPSSODescriptor>
|
||||||
|
</md:EntityDescriptor>`;
|
||||||
|
// cSpell:enable
|
||||||
|
|
||||||
|
dir = await createTempDir();
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(`${dir}/idp.xml`, idpXml),
|
||||||
|
writeFile(`${dir}/sp.xml`, spXml),
|
||||||
|
]);
|
||||||
|
|
||||||
|
config().saml = {
|
||||||
|
enabled: true,
|
||||||
|
identityProviderConfigFile: `${dir}/idp.xml`,
|
||||||
|
serviceProviderConfigFile: `${dir}/sp.xml`,
|
||||||
|
organizationDisplayName: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await afterAllTests();
|
||||||
|
await remove(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await beforeEachDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, 'web-login', 'app-login'] as SamlRelayState[])('should create a login request with the relay state: %', async (relayState) => {
|
||||||
|
const loginRequest = await getLoginRequest(relayState);
|
||||||
|
|
||||||
|
expect(loginRequest.entityEndpoint).toBe('http://localhost:7000/saml/sso');
|
||||||
|
expect(loginRequest.relayState).toBe(relayState);
|
||||||
|
});
|
||||||
|
});
|
72
packages/server/src/utils/saml.ts
Normal file
72
packages/server/src/utils/saml.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { ServiceProvider, IdentityProvider, setSchemaValidator } from 'samlify';
|
||||||
|
import * as validator from '@authenio/samlify-xmllint-wasm';
|
||||||
|
import { readFile } from 'fs-extra';
|
||||||
|
import config from '../config';
|
||||||
|
import { PostBindingContext } from 'samlify/types/src/entity';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import { SamlRelayState } from './types';
|
||||||
|
|
||||||
|
const checkIfSamlIsEnabled = () => {
|
||||||
|
if (!config().saml.enabled) {
|
||||||
|
throw new Error('SAML support is disabled for this server.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getServiceProvider = async (relayState: SamlRelayState = null) => {
|
||||||
|
checkIfSamlIsEnabled();
|
||||||
|
|
||||||
|
return ServiceProvider({
|
||||||
|
metadata: await readFile(config().saml.serviceProviderConfigFile),
|
||||||
|
relayState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIdentityProvider = async () => {
|
||||||
|
checkIfSamlIsEnabled();
|
||||||
|
|
||||||
|
return IdentityProvider({
|
||||||
|
metadata: await readFile(config().saml.identityProviderConfigFile),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupSamlAuthentication = () => {
|
||||||
|
setSchemaValidator(validator);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoginRequest = async (relayState: SamlRelayState = null) => {
|
||||||
|
const [sp, idp] = await Promise.all([
|
||||||
|
getServiceProvider(relayState),
|
||||||
|
getIdentityProvider(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return sp.createLoginRequest(idp, 'post') as PostBindingContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
// This does not rely on the usual templates since the redirect should be fast, and shouldn't contain too much HTML code.
|
||||||
|
export const generateRedirectHtml = async (relayState: SamlRelayState = null) => {
|
||||||
|
const loginRequest = await getLoginRequest(relayState);
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${_('Joplin SSO Authentication')}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>${_('Please wait while we load your organisation sign-in page...')}</p>
|
||||||
|
|
||||||
|
<form id="saml-form" method="post" action="${loginRequest.entityEndpoint}" autocomplete="off">
|
||||||
|
<input type="hidden" name="${loginRequest.type}" value="${loginRequest.context}"/>
|
||||||
|
|
||||||
|
${loginRequest.relayState ? `<input type="hidden" name="RelayState" value="${loginRequest.relayState}"/>` : ''}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
(() => {
|
||||||
|
document.querySelector('#saml-form').submit();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
@ -83,6 +83,13 @@ export default async function(env: Env, models: Models, config: Config, services
|
|||||||
schedule: config.HEARTBEAT_MESSAGE_SCHEDULE,
|
schedule: config.HEARTBEAT_MESSAGE_SCHEDULE,
|
||||||
run: (_models: Models, _services: Services) => logHeartbeatMessage(),
|
run: (_models: Models, _services: Services) => logHeartbeatMessage(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: TaskId.DeleteExpiredAuthCodes,
|
||||||
|
description: taskIdToLabel(TaskId.DeleteExpiredAuthCodes),
|
||||||
|
schedule: '*/15 * * * *',
|
||||||
|
run: (models: Models) => models.user().deleteExpiredAuthCodes(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (config.USER_DATA_AUTO_DELETE_ENABLED) {
|
if (config.USER_DATA_AUTO_DELETE_ENABLED) {
|
||||||
|
@ -145,6 +145,13 @@ export interface LdapConfig {
|
|||||||
tlsCaFile: string;
|
tlsCaFile: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SamlConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
identityProviderConfigFile: string;
|
||||||
|
serviceProviderConfigFile: string;
|
||||||
|
organizationDisplayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Config extends EnvVariables {
|
export interface Config extends EnvVariables {
|
||||||
appVersion: string;
|
appVersion: string;
|
||||||
joplinServerVersion: string; // May be different from appVersion, if this is a fork of JS
|
joplinServerVersion: string; // May be different from appVersion, if this is a fork of JS
|
||||||
@ -181,6 +188,7 @@ export interface Config extends EnvVariables {
|
|||||||
itemSizeHardLimit: number;
|
itemSizeHardLimit: number;
|
||||||
maxTimeDrift: number;
|
maxTimeDrift: number;
|
||||||
ldap: LdapConfig[];
|
ldap: LdapConfig[];
|
||||||
|
saml: SamlConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum HttpMethod {
|
export enum HttpMethod {
|
||||||
@ -202,3 +210,9 @@ export type KoaNext = ()=> Promise<void>;
|
|||||||
export interface CommandContext {
|
export interface CommandContext {
|
||||||
models: Models;
|
models: Models;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SamlRelayState = 'web-login' | 'app-login' | null;
|
||||||
|
export interface SamlPostResponse {
|
||||||
|
SAMLResponse: string;
|
||||||
|
RelayState?: SamlRelayState;
|
||||||
|
}
|
||||||
|
19
packages/server/src/views/index/displaySsoCode.mustache
Normal file
19
packages/server/src/views/index/displaySsoCode.mustache
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<section class="section">
|
||||||
|
<div class="container block">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
{{#organizationName}}
|
||||||
|
<h1 class="title">Successfully logged into your {{organizationName}} account!</h1>
|
||||||
|
{{/organizationName}}
|
||||||
|
{{^organizationName}}
|
||||||
|
<h1 class="title">Successfully logged into your account!</h1>
|
||||||
|
{{/organizationName}}
|
||||||
|
|
||||||
|
<p>Please enter the following code into your Joplin application to start syncing your notes:</p>
|
||||||
|
<p class="is-family-monospace is-size-1">{{ssoCode}}</p>
|
||||||
|
|
||||||
|
<button class="button is-primary" onclick="navigator.clipboard.writeText('{{ssoCode}}')">Copy to clipboard</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
@ -1,28 +1,49 @@
|
|||||||
<section class="section login-box">
|
<section class="section login-box">
|
||||||
<h1 class="title">Login to {{global.appName}}</h1>
|
|
||||||
<p class="subtitle">Please input your details to login to {{global.appName}}</p>
|
|
||||||
|
|
||||||
<div class="container block">
|
<div class="container block">
|
||||||
{{> errorBanner}}
|
{{> errorBanner}}
|
||||||
<form action="{{{global.baseUrl}}}/login" method="POST">
|
|
||||||
<div class="field">
|
<div class="columns">
|
||||||
<label class="label">Email</label>
|
<form action="{{{global.baseUrl}}}/login" method="POST" class="column">
|
||||||
<div class="control">
|
<h2 class="title">Login to {{global.appName}}</h1>
|
||||||
<input class="input" type="email" name="email"/>
|
<p class="subtitle">Please input your details to login to {{global.appName}}</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Email</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="email" name="email"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="field">
|
||||||
<div class="field">
|
<label class="label">Password</label>
|
||||||
<label class="label">Password</label>
|
<div class="control">
|
||||||
<div class="control">
|
<input class="input" type="password" name="password"/>
|
||||||
<input class="input" type="password" name="password"/>
|
</div>
|
||||||
|
<p class="help"><a href="{{{global.baseUrl}}}/password/forgot">I forgot my password</a></p>
|
||||||
</div>
|
</div>
|
||||||
<p class="help"><a href="{{{global.baseUrl}}}/password/forgot">I forgot my password</a></p>
|
<div class="control">
|
||||||
|
<button class="button is-primary">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{#samlEnabled}}
|
||||||
|
<div class="column is-one-fifth is-flex is-justify-content-center">
|
||||||
|
<div style="border-left: 2px solid #f5f5f5; height: 100%; width: 2px;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
|
||||||
<button class="button is-primary">Login</button>
|
<div class="column is-flex is-flex-direction-column is-justify-content-center">
|
||||||
|
<h2 class="title">Use your organisation account</h2>
|
||||||
|
{{#samlOrganizationName}}
|
||||||
|
<p class="subtitle">{{samlOrganizationName}}</p>
|
||||||
|
{{/samlOrganizationName}}
|
||||||
|
|
||||||
|
<a href="/login/sso-saml">
|
||||||
|
<button class="button is-primary is-fullwidth">Login using your organisation account</button>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{{/samlEnabled}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#signupUrl}}
|
{{#signupUrl}}
|
||||||
<div class="container block">
|
<div class="container block">
|
||||||
Or <a href="{{signupUrl}}">sign up</a> to create a new account.
|
Or <a href="{{signupUrl}}">sign up</a> to create a new account.
|
||||||
|
@ -168,6 +168,8 @@ pmmmwh
|
|||||||
webm
|
webm
|
||||||
millis
|
millis
|
||||||
sideloading
|
sideloading
|
||||||
|
samlify
|
||||||
|
authenio
|
||||||
ggml
|
ggml
|
||||||
Minidump
|
Minidump
|
||||||
collapseall
|
collapseall
|
||||||
|
79
yarn.lock
79
yarn.lock
@ -270,6 +270,26 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@authenio/samlify-xmllint-wasm@npm:1.0.1":
|
||||||
|
version: 1.0.1
|
||||||
|
resolution: "@authenio/samlify-xmllint-wasm@npm:1.0.1"
|
||||||
|
dependencies:
|
||||||
|
xmllint-wasm: ^4.0.0
|
||||||
|
checksum: 02ba8ad28ccdacc7f41cab9183315eca18ee8cf55a817edfd09ef8cff6ed87b440f07874084e33b901edb5ad8bc4d76e44de7d086ea48ea1ef259b4fb02dc026
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@authenio/xml-encryption@npm:^2.0.2":
|
||||||
|
version: 2.0.2
|
||||||
|
resolution: "@authenio/xml-encryption@npm:2.0.2"
|
||||||
|
dependencies:
|
||||||
|
"@xmldom/xmldom": ^0.8.6
|
||||||
|
escape-html: ^1.0.3
|
||||||
|
xpath: 0.0.32
|
||||||
|
checksum: 210b5c32a84d0c944e0e4a9dd8592b7246f23c89deee6fdc979a25625b4fae1ef3f4e802265288fa54c5514d7ef8ce8440f39ec0be8c9123b0a76121786611a8
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@aws-crypto/crc32@npm:3.0.0":
|
"@aws-crypto/crc32@npm:3.0.0":
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
resolution: "@aws-crypto/crc32@npm:3.0.0"
|
resolution: "@aws-crypto/crc32@npm:3.0.0"
|
||||||
@ -9256,6 +9276,7 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@joplin/server@workspace:packages/server"
|
resolution: "@joplin/server@workspace:packages/server"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@authenio/samlify-xmllint-wasm": 1.0.1
|
||||||
"@aws-sdk/client-s3": 3.296.0
|
"@aws-sdk/client-s3": 3.296.0
|
||||||
"@fortawesome/fontawesome-free": 5.15.4
|
"@fortawesome/fontawesome-free": 5.15.4
|
||||||
"@joplin/lib": ~3.4
|
"@joplin/lib": ~3.4
|
||||||
@ -9307,6 +9328,7 @@ __metadata:
|
|||||||
query-string: 7.1.3
|
query-string: 7.1.3
|
||||||
rate-limiter-flexible: 5.0.3
|
rate-limiter-flexible: 5.0.3
|
||||||
raw-body: 2.5.2
|
raw-body: 2.5.2
|
||||||
|
samlify: 2.8.10
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
sqlite3: 5.1.6
|
sqlite3: 5.1.6
|
||||||
stripe: 8.222.0
|
stripe: 8.222.0
|
||||||
@ -15340,6 +15362,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@xmldom/xmldom@npm:^0.8.6":
|
||||||
|
version: 0.8.10
|
||||||
|
resolution: "@xmldom/xmldom@npm:0.8.10"
|
||||||
|
checksum: 4c136aec31fb3b49aaa53b6fcbfe524d02a1dc0d8e17ee35bd3bf35e9ce1344560481cd1efd086ad1a4821541482528672306d5e37cdbd187f33d7fadd3e2cf0
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@xmldom/xmldom@npm:^0.8.8":
|
"@xmldom/xmldom@npm:^0.8.8":
|
||||||
version: 0.8.8
|
version: 0.8.8
|
||||||
resolution: "@xmldom/xmldom@npm:0.8.8"
|
resolution: "@xmldom/xmldom@npm:0.8.8"
|
||||||
@ -35778,7 +35807,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"node-forge@npm:^1, node-forge@npm:^1.2.1, node-forge@npm:^1.3.1":
|
"node-forge@npm:^1, node-forge@npm:^1.2.1, node-forge@npm:^1.3.0, node-forge@npm:^1.3.1":
|
||||||
version: 1.3.1
|
version: 1.3.1
|
||||||
resolution: "node-forge@npm:1.3.1"
|
resolution: "node-forge@npm:1.3.1"
|
||||||
checksum: 08fb072d3d670599c89a1704b3e9c649ff1b998256737f0e06fbd1a5bf41cae4457ccaee32d95052d80bbafd9ffe01284e078c8071f0267dc9744e51c5ed42a9
|
checksum: 08fb072d3d670599c89a1704b3e9c649ff1b998256737f0e06fbd1a5bf41cae4457ccaee32d95052d80bbafd9ffe01284e078c8071f0267dc9744e51c5ed42a9
|
||||||
@ -35953,7 +35982,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"node-rsa@npm:1.1.1":
|
"node-rsa@npm:1.1.1, node-rsa@npm:^1.1.1":
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
resolution: "node-rsa@npm:1.1.1"
|
resolution: "node-rsa@npm:1.1.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -37411,7 +37440,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"pako@npm:~1.0.5":
|
"pako@npm:^1.0.10, pako@npm:~1.0.5":
|
||||||
version: 1.0.11
|
version: 1.0.11
|
||||||
resolution: "pako@npm:1.0.11"
|
resolution: "pako@npm:1.0.11"
|
||||||
checksum: 1be2bfa1f807608c7538afa15d6f25baa523c30ec870a3228a89579e474a4d992f4293859524e46d5d87fd30fa17c5edf34dbef0671251d9749820b488660b16
|
checksum: 1be2bfa1f807608c7538afa15d6f25baa523c30ec870a3228a89579e474a4d992f4293859524e46d5d87fd30fa17c5edf34dbef0671251d9749820b488660b16
|
||||||
@ -42516,6 +42545,24 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"samlify@npm:2.8.10":
|
||||||
|
version: 2.8.10
|
||||||
|
resolution: "samlify@npm:2.8.10"
|
||||||
|
dependencies:
|
||||||
|
"@authenio/xml-encryption": ^2.0.2
|
||||||
|
"@xmldom/xmldom": ^0.8.6
|
||||||
|
camelcase: ^6.2.0
|
||||||
|
node-forge: ^1.3.0
|
||||||
|
node-rsa: ^1.1.1
|
||||||
|
pako: ^1.0.10
|
||||||
|
uuid: ^8.3.2
|
||||||
|
xml: ^1.0.1
|
||||||
|
xml-crypto: ^3.0.1
|
||||||
|
xpath: ^0.0.32
|
||||||
|
checksum: fdfb4bd36d1bac531fe26f7c4c41ca215df2a7eebab9c6c7f980bd2026e5e3ac6340560c55169b639d90af3ef5d783b565d31fab31641b3562a8f1e357908ef1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"sanitize-filename@npm:^1.6.3":
|
"sanitize-filename@npm:^1.6.3":
|
||||||
version: 1.6.3
|
version: 1.6.3
|
||||||
resolution: "sanitize-filename@npm:1.6.3"
|
resolution: "sanitize-filename@npm:1.6.3"
|
||||||
@ -49694,6 +49741,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"xml-crypto@npm:^3.0.1":
|
||||||
|
version: 3.2.0
|
||||||
|
resolution: "xml-crypto@npm:3.2.0"
|
||||||
|
dependencies:
|
||||||
|
"@xmldom/xmldom": ^0.8.8
|
||||||
|
xpath: 0.0.32
|
||||||
|
checksum: 6c4974a7518307ea006dcfc1405f61c6738b45574b4d9d1e62f53b602bfcf894d34017f99d618f26f67c40a5e6d78e6228116ded2768b2ca5b2df5c8bf7774b7
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"xml-js@npm:^1.6.11":
|
"xml-js@npm:^1.6.11":
|
||||||
version: 1.6.11
|
version: 1.6.11
|
||||||
resolution: "xml-js@npm:1.6.11"
|
resolution: "xml-js@npm:1.6.11"
|
||||||
@ -49756,7 +49813,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"xml@npm:1.0.1":
|
"xml@npm:1.0.1, xml@npm:^1.0.1":
|
||||||
version: 1.0.1
|
version: 1.0.1
|
||||||
resolution: "xml@npm:1.0.1"
|
resolution: "xml@npm:1.0.1"
|
||||||
checksum: 11b5545ef3f8fec3fa29ce251f50ad7b6c97c103ed4d851306ec23366f5fa4699dd6a942262df52313a0cd1840ab26256da253c023bad3309d8ce46fe6020ca0
|
checksum: 11b5545ef3f8fec3fa29ce251f50ad7b6c97c103ed4d851306ec23366f5fa4699dd6a942262df52313a0cd1840ab26256da253c023bad3309d8ce46fe6020ca0
|
||||||
@ -49798,6 +49855,20 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"xmllint-wasm@npm:^4.0.0":
|
||||||
|
version: 4.0.2
|
||||||
|
resolution: "xmllint-wasm@npm:4.0.2"
|
||||||
|
checksum: f802920b2a9d6be5ac5923b1608eaff40dde0dbe8a8c2d47381ae370e26a04059d6517306a3728bd8a33b973acadd3fbf4b21b0e4fe6409ea6a2efb2c6a64b08
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"xpath@npm:0.0.32, xpath@npm:^0.0.32":
|
||||||
|
version: 0.0.32
|
||||||
|
resolution: "xpath@npm:0.0.32"
|
||||||
|
checksum: 887e9747b960ea45fb47a9464744424512de0a49205e82c2ad6be662d7a2f1a75145662a143304340864c6da68fd8d767cce4065cc198ee07a3d4897e0a3d4bb
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"xtend@npm:^4.0.0, xtend@npm:^4.0.1, xtend@npm:^4.0.2, xtend@npm:~4.0.0, xtend@npm:~4.0.1":
|
"xtend@npm:^4.0.0, xtend@npm:^4.0.1, xtend@npm:^4.0.2, xtend@npm:~4.0.0, xtend@npm:~4.0.1":
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
resolution: "xtend@npm:4.0.2"
|
resolution: "xtend@npm:4.0.2"
|
||||||
|
Reference in New Issue
Block a user