1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-03-03 09:27:01 +02:00

Compare commits

...

12 Commits

Author SHA1 Message Date
Laurent Cozic
6c383996fa update 2026-02-08 19:57:45 +00:00
Laurent Cozic
01b15d58dd update 2026-02-08 18:15:04 +00:00
Laurent Cozic
fa07eb3db0 update 2026-02-08 16:06:40 +00:00
Laurent Cozic
f439835281 update 2026-02-08 15:46:55 +00:00
Laurent Cozic
740c87a817 update 2026-02-08 15:43:42 +00:00
Laurent Cozic
8b3835eb04 update 2026-02-08 15:32:37 +00:00
Laurent Cozic
a5318099c5 update 2026-02-08 15:22:06 +00:00
Laurent Cozic
b39628a963 update 2026-02-08 15:14:14 +00:00
Laurent Cozic
0386028803 update 2026-02-08 15:04:42 +00:00
Laurent Cozic
ed242f736c update 2026-02-07 13:41:07 +00:00
Laurent Cozic
8cd39e3b40 update 2026-02-07 12:49:16 +00:00
Laurent Cozic
8d4632d9dd update 2026-02-07 12:40:56 +00:00
34 changed files with 1317 additions and 261 deletions

View File

@@ -459,6 +459,7 @@ packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.js
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
packages/app-desktop/gui/WebDavOidcLoginScreen.js
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
@@ -877,6 +878,7 @@ packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/folder.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/screens/webdav-oidc-login.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/testing/TestProviderStack.js
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
@@ -1242,6 +1244,7 @@ packages/lib/JoplinError.js
packages/lib/JoplinServerApi.js
packages/lib/ObjectUtils.test.js
packages/lib/ObjectUtils.js
packages/lib/OidcApi.js
packages/lib/PerformanceLogger.test.js
packages/lib/PerformanceLogger.js
packages/lib/PoorManIntervals.js
@@ -1251,14 +1254,17 @@ packages/lib/SyncTargetFilesystem.js
packages/lib/SyncTargetJoplinCloud.js
packages/lib/SyncTargetJoplinServer.js
packages/lib/SyncTargetJoplinServerSAML.js
packages/lib/SyncTargetNextcloud.js
packages/lib/SyncTargetNone.js
packages/lib/SyncTargetOneDrive.js
packages/lib/SyncTargetRegistry.js
packages/lib/SyncTargetWebDAV.js
packages/lib/Synchronizer.js
packages/lib/TaskQueue.js
packages/lib/WebDavApi.js
packages/lib/WelcomeUtils.js
packages/lib/array.js
packages/lib/base-oauth-node-utils.js
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
@@ -1408,6 +1414,8 @@ packages/lib/models/utils/userData.test.js
packages/lib/models/utils/userData.js
packages/lib/net-utils.js
packages/lib/ntp.js
packages/lib/oidc-api-node-utils.js
packages/lib/onedrive-api-node-utils.js
packages/lib/onedrive-api.test.js
packages/lib/onedrive-api.js
packages/lib/path-utils.js

View File

@@ -314,7 +314,7 @@ module.exports = {
selector: 'interface',
format: null,
'filter': {
'regex': '^(RSA|RSAKeyPair|iOS.*)$',
'regex': '^(RSA|RSAKeyPair|iOS.*|OAuth.*)$',
'match': true,
},
},

8
.gitignore vendored
View File

@@ -432,6 +432,7 @@ packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.js
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
packages/app-desktop/gui/WebDavOidcLoginScreen.js
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
@@ -850,6 +851,7 @@ packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/folder.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/screens/webdav-oidc-login.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/testing/TestProviderStack.js
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
@@ -1215,6 +1217,7 @@ packages/lib/JoplinError.js
packages/lib/JoplinServerApi.js
packages/lib/ObjectUtils.test.js
packages/lib/ObjectUtils.js
packages/lib/OidcApi.js
packages/lib/PerformanceLogger.test.js
packages/lib/PerformanceLogger.js
packages/lib/PoorManIntervals.js
@@ -1224,14 +1227,17 @@ packages/lib/SyncTargetFilesystem.js
packages/lib/SyncTargetJoplinCloud.js
packages/lib/SyncTargetJoplinServer.js
packages/lib/SyncTargetJoplinServerSAML.js
packages/lib/SyncTargetNextcloud.js
packages/lib/SyncTargetNone.js
packages/lib/SyncTargetOneDrive.js
packages/lib/SyncTargetRegistry.js
packages/lib/SyncTargetWebDAV.js
packages/lib/Synchronizer.js
packages/lib/TaskQueue.js
packages/lib/WebDavApi.js
packages/lib/WelcomeUtils.js
packages/lib/array.js
packages/lib/base-oauth-node-utils.js
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
@@ -1381,6 +1387,8 @@ packages/lib/models/utils/userData.test.js
packages/lib/models/utils/userData.js
packages/lib/net-utils.js
packages/lib/ntp.js
packages/lib/oidc-api-node-utils.js
packages/lib/onedrive-api-node-utils.js
packages/lib/onedrive-api.test.js
packages/lib/onedrive-api.js
packages/lib/path-utils.js

23
docker-compose-oidc.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
ocis:
image: owncloud/ocis:latest
container_name: ocis
entrypoint: /bin/sh
command: ["-c", "ocis init --insecure true || true; ocis server"]
environment:
OCIS_URL: https://localhost:9200
OCIS_INSECURE: "true"
PROXY_ENABLE_BASIC_AUTH: "false"
IDM_ADMIN_PASSWORD: admin
OCIS_LOG_LEVEL: warn
# Allow Joplin's redirect URIs
IDP_INSECURE: "true"
IDP_IDENTIFIER_REGISTRATION_CONF: /etc/ocis/clients.yaml
ports:
- "9200:9200"
volumes:
- ocis_data:/var/lib/ocis
- ./ocis-clients.yaml:/etc/ocis/clients.yaml:ro
volumes:
ocis_data:

12
ocis-clients.yaml Normal file
View File

@@ -0,0 +1,12 @@
clients:
- id: joplin
name: Joplin
application_type: native
redirect_uris:
- http://localhost:9968
- http://localhost:8968
- http://localhost:8868
- http://127.0.0.1:9968
- http://127.0.0.1:8968
- http://127.0.0.1:8868
- joplin://oidc-callback

View File

@@ -8,7 +8,7 @@ import { masterKeysWithoutPassword } from '@joplin/lib/services/e2ee/utils';
import { appTypeToLockType } from '@joplin/lib/services/synchronizer/LockHandler';
const BaseCommand = require('./base-command').default;
import app from './app';
const { OneDriveApiNodeUtils } = require('@joplin/lib/onedrive-api-node-utils.js');
import OneDriveApiNodeUtils from '@joplin/lib/onedrive-api-node-utils';
import { reg } from '@joplin/lib/registry';
const { cliUtils } = require('./cli-utils.js');
const md5 = require('md5');

View File

@@ -76,6 +76,19 @@ class ConfigScreenComponent extends React.Component<any, any> {
});
}
}
// Check if WebDAV with OIDC authentication needs login
if (this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('webdav') &&
this.state.settings['sync.6.authType'] === 'oidc') {
const isAuthenticated = await reg.syncTarget().isAuthenticated();
if (!isAuthenticated) {
return this.props.dispatch({
type: 'NAV_GO',
routeName: 'WebDavOidcLogin',
});
}
}
await shared.checkSyncConfig(this, this.state.settings);
}
@@ -115,6 +128,13 @@ class ConfigScreenComponent extends React.Component<any, any> {
type: 'DIALOG_OPEN',
name: 'syncWizard',
});
} else if (key === 'sync.6.oidcLogin') {
// Save current settings before navigating to login
await shared.saveSettings(this);
this.props.dispatch({
type: 'NAV_GO',
routeName: 'WebDavOidcLogin',
});
} else {
throw new Error(`Unhandled key: ${key}`);
}

View File

@@ -7,7 +7,7 @@ import { reg } from '@joplin/lib/registry';
import Setting from '@joplin/lib/models/Setting';
import bridge from '../services/bridge';
const { themeStyle } = require('@joplin/lib/theme');
const { OneDriveApiNodeUtils } = require('@joplin/lib/onedrive-api-node-utils.js');
import OneDriveApiNodeUtils from '@joplin/lib/onedrive-api-node-utils';
interface Props {
themeId: string;

View File

@@ -6,6 +6,7 @@ import ConfigScreen from './ConfigScreen/ConfigScreen';
import StatusScreen from './StatusScreen/StatusScreen';
import OneDriveLoginScreen from './OneDriveLoginScreen';
import DropboxLoginScreen from './DropboxLoginScreen';
import WebDavOidcLoginScreen from './WebDavOidcLoginScreen';
import ErrorBoundary from './ErrorBoundary';
import { themeStyle } from '@joplin/lib/theme';
import MenuBar from './MenuBar';
@@ -163,6 +164,7 @@ class RootComponent extends React.Component<Props, any> {
DropboxLogin: { screen: DropboxLoginScreen, title: () => _('Dropbox Login') },
JoplinCloudLogin: { screen: JoplinCloudLoginScreen, title: () => _('Joplin Cloud Login') },
JoplinServerSamlLogin: { screen: SsoLoginScreen(new SamlShared()), title: () => _('Joplin Server Login') },
WebDavOidcLogin: { screen: WebDavOidcLoginScreen, title: () => _('WebDAV OIDC Login') },
Import: { screen: ImportScreen, title: () => _('Import') },
Config: { screen: ConfigScreen, title: () => _('Options') },
Resources: { screen: ResourceScreen, title: () => _('Note attachments') },

View File

@@ -0,0 +1,33 @@
.webdav-oidc-login-screen {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--joplin-background-color);
> .content {
padding: var(--joplin-config-screen-padding);
flex: 1;
color: var(--joplin-color);
> .title {
font-size: var(--joplin-h1-font-size);
font-weight: bold;
margin-bottom: 1em;
}
> .logentry {
font-size: var(--joplin-font-size);
margin: 0;
}
> .loglink {
color: var(--joplin-url-color);
font-size: var(--joplin-font-size);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}

View File

@@ -0,0 +1,111 @@
import * as React from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import ButtonBar from './ConfigScreen/ButtonBar';
import { _ } from '@joplin/lib/locale';
import { reg } from '@joplin/lib/registry';
import Setting from '@joplin/lib/models/Setting';
import bridge from '../services/bridge';
import { OidcApiNodeUtils } from '@joplin/lib/oidc-api-node-utils';
import OidcApi from '@joplin/lib/OidcApi';
interface LogEntry {
key: string;
text: string;
}
const WebDavOidcLoginScreen: React.FC = () => {
const [authLog, setAuthLog] = useState<LogEntry[]>([]);
const oidcApiUtilsRef = useRef<OidcApiNodeUtils | null>(null);
const dispatch = useDispatch();
const log = useCallback((s: string) => {
setAuthLog(prevLog => [
...prevLog,
{ key: `${Date.now()}-${Math.random()}`, text: s },
]);
}, []);
useEffect(() => {
const performAuth = async () => {
const syncTargetId = Setting.value('sync.target');
const oidcApi = new OidcApi({
issuerUrl: Setting.value('sync.6.oidcIssuerUrl'),
clientId: Setting.value('sync.6.oidcClientId'),
clientSecret: Setting.value('sync.6.oidcClientSecret'),
ignoreTlsErrors: Setting.value('net.ignoreTlsErrors'),
});
oidcApiUtilsRef.current = new OidcApiNodeUtils(oidcApi);
try {
const auth = await oidcApiUtilsRef.current.oauthDance({
log: (s: string) => log(s),
});
Setting.setValue(`sync.${syncTargetId}.oidcAuth`, auth ? JSON.stringify(auth) : '');
const syncTarget = reg.syncTarget(syncTargetId);
if (syncTarget.api && syncTarget.api()) {
syncTarget.api().setAuth(auth);
}
if (!auth) {
log(_('Authentication was not completed (did not receive an authentication token).'));
} else {
log(_('Authentication successful! You can now close this screen.'));
void reg.scheduleSync(0);
}
} catch (error) {
log(_('Authentication failed: %s', (error as Error).message));
}
};
void performAuth();
return () => {
if (oidcApiUtilsRef.current) {
oidcApiUtilsRef.current.cancelOAuthDance();
}
};
}, [log]);
const handleCancelClick = useCallback(() => {
dispatch({ type: 'NAV_BACK' });
}, [dispatch]);
const handleLinkClick = useCallback((url: string) => {
void bridge().openExternal(url);
}, []);
const renderLogEntries = () => {
return authLog.map(entry => {
if (entry.text.indexOf('http:') === 0 || entry.text.indexOf('https://') === 0) {
return (
<a
key={entry.key}
className="loglink"
href="#"
onClick={() => handleLinkClick(entry.text)}
>
{entry.text}
</a>
);
}
return <p key={entry.key} className="logentry">{entry.text}</p>;
});
};
return (
<div className="webdav-oidc-login-screen">
<div className="content">
<h1 className="title">{_('WebDAV OIDC Authentication')}</h1>
{renderLogEntries()}
</div>
<ButtonBar onCancelClick={handleCancelClick} />
</div>
);
};
export default WebDavOidcLoginScreen;

View File

@@ -8,6 +8,7 @@
@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/WebDavOidcLoginScreen.scss' as webdav-oidc-login-screen;
@use 'gui/NoteListHeader/style.scss' as note-list-header;
@use 'gui/UpdateNotification/style.scss' as update-notification;
@use 'gui/Sidebar/style.scss' as sidebar-styles;

View File

@@ -739,6 +739,14 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
return false;
};
private handleSettingButtonPress = async (key: string) => {
if (key === 'sync.6.oidcLogin') {
// Save current settings before navigating to login
await shared.saveSettings(this);
await NavService.go('WebDavOidcLogin');
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public settingToComponent(key: string, value: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -755,6 +763,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
themeId={this.props.themeId}
updateSettingValue={updateSettingValue}
styles={this.styles()}
onSettingButtonPress={this.handleSettingButtonPress}
/>
);
}

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { UpdateSettingValueCallback } from './types';
import { View, Text } from 'react-native';
import { View, Text, Button } from 'react-native';
import Setting, { AppType } from '@joplin/lib/models/Setting';
import Dropdown from '../../Dropdown';
import { ConfigScreenStyles } from './configScreenStyles';
@@ -23,6 +23,7 @@ interface Props {
themeId: number;
updateSettingValue: UpdateSettingValueCallback;
onSettingButtonPress?: (key: string)=> void;
}
@@ -127,7 +128,21 @@ const SettingComponent: React.FunctionComponent<Props> = props => {
/>
);
} else if (md.type === Setting.TYPE_BUTTON) {
// TODO: Not yet supported
return (
<View key={props.settingId} style={containerStyles.outerContainer}>
<View style={containerStyles.innerContainer}>
<Button
title={md.label()}
onPress={() => {
if (props.onSettingButtonPress) {
props.onSettingButtonPress(props.settingId);
}
}}
/>
</View>
{descriptionComp}
</View>
);
} else if (Setting.value('env') === 'dev') {
throw new Error(`Unsupported setting type: ${md.type}`);
}

View File

@@ -0,0 +1,158 @@
import * as React from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { View, Button } from 'react-native';
import { WebView, WebViewNavigation } from 'react-native-webview';
import { useDispatch, useSelector } from 'react-redux';
import { ScreenHeader } from '../ScreenHeader';
import { reg } from '@joplin/lib/registry';
import { _ } from '@joplin/lib/locale';
import { themeStyle } from '../global-style';
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import OidcApi from '@joplin/lib/OidcApi';
const parseUri = require('@joplin/lib/parseUri');
const WebDavOidcLoginScreen: React.FC = () => {
const dispatch = useDispatch();
const themeId = useSelector((state: { settings: { theme: number } }) => state.settings.theme);
const [webviewUrl, setWebviewUrl] = useState('');
const [oidcApi, setOidcApi] = useState<OidcApi | null>(null);
const [redirectUri, setRedirectUri] = useState('');
const [oauthState, setOauthState] = useState('');
const authCodeRef = useRef<string | null>(null);
const styles = useMemo(() => {
const theme = themeStyle(themeId);
return {
screen: {
flex: 1,
backgroundColor: theme.backgroundColor,
},
};
}, [themeId]);
useEffect(() => {
const initOidc = async () => {
const api = new OidcApi({
issuerUrl: Setting.value('sync.6.oidcIssuerUrl'),
clientId: Setting.value('sync.6.oidcClientId'),
clientSecret: Setting.value('sync.6.oidcClientSecret'),
ignoreTlsErrors: Setting.value('net.ignoreTlsErrors'),
});
// Use a custom redirect URI that the WebView can intercept
// This is a common pattern for mobile OAuth - using a non-http URI
const redirect = 'joplin://oidc-callback';
const state = Math.random().toString(36).substring(7);
const authCodeUrl = await api.authCodeUrl(redirect, state);
setOidcApi(api);
setRedirectUri(redirect);
setOauthState(state);
setWebviewUrl(authCodeUrl);
};
void initOidc();
}, []);
const handleWebviewLoad = useCallback(async (event: WebViewNavigation) => {
const url = event.url;
// Check if this is our callback URL
if (url.startsWith('joplin://oidc-callback')) {
const parsedUrl = parseUri(url);
const query = parsedUrl.queryKey;
if (query.error) {
const errorDesc = query.error_description || query.error;
alert(`${_('Authentication failed')}: ${errorDesc}`);
dispatch({ type: 'NAV_BACK' });
return;
}
if (!authCodeRef.current && query.code) {
// Verify state to prevent CSRF
if (query.state !== oauthState) {
alert(_('Authentication failed: Invalid state parameter'));
dispatch({ type: 'NAV_BACK' });
return;
}
authCodeRef.current = query.code;
try {
await oidcApi.execTokenRequest(authCodeRef.current, redirectUri);
const auth = oidcApi.auth();
const syncTargetId = Setting.value('sync.target');
Setting.setValue(`sync.${syncTargetId}.oidcAuth`, auth ? JSON.stringify(auth) : '');
// Update the sync target's API with the new auth
const syncTarget = reg.syncTarget(syncTargetId);
if (syncTarget.api && syncTarget.api()) {
syncTarget.api().setAuth(auth);
}
dispatch({ type: 'NAV_BACK' });
void reg.scheduleSync(0);
} catch (error) {
alert(`${_('Could not authenticate with OIDC provider. Please try again')}\n\n${(error as Error).message}`);
}
authCodeRef.current = null;
}
}
}, [dispatch, oidcApi, oauthState, redirectUri]);
const handleWebviewError = useCallback(() => {
alert(_('Could not load page. Please check your connection and try again.'));
}, []);
const handleRetryPress = useCallback(() => {
// Reload the page by setting a temporary URL then back to the auth URL
const authUrl = webviewUrl;
setWebviewUrl('about:blank');
shim.setTimeout(() => {
setWebviewUrl(authUrl);
}, 500);
}, [webviewUrl]);
const handleShouldStartLoadWithRequest = useCallback((request: { url: string }) => {
// Intercept the callback URL
if (request.url.startsWith('joplin://oidc-callback')) {
void handleWebviewLoad({ url: request.url } as WebViewNavigation);
return false;
}
return true;
}, [handleWebviewLoad]);
const source = useMemo(() => ({ uri: webviewUrl }), [webviewUrl]);
return (
<View style={styles.screen}>
<ScreenHeader title={_('WebDAV OIDC Login')} />
<WebView
source={source}
onNavigationStateChange={(event: WebViewNavigation) => {
void handleWebviewLoad(event);
}}
onError={handleWebviewError}
onHttpError={handleWebviewError}
// Allow the custom joplin:// scheme to be intercepted
originWhitelist={['*']}
onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
/>
<Button
title={_('Refresh')}
onPress={handleRetryPress}
/>
</View>
);
};
export default WebDavOidcLoginScreen;

View File

@@ -42,6 +42,7 @@ import SearchScreen from './components/screens/SearchScreen';
const { OneDriveLoginScreen } = require('./components/screens/onedrive-login.js');
import EncryptionConfigScreen from './components/screens/encryption-config';
import DropboxLoginScreen from './components/screens/dropbox-login.js';
import WebDavOidcLoginScreen from './components/screens/webdav-oidc-login';
import { MenuProvider } from 'react-native-popup-menu';
import SideMenu, { SideMenuPosition } from './components/SideMenu';
import SideMenuContent from './components/side-menu-content';
@@ -53,8 +54,8 @@ import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { themeStyle } from './components/global-style';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import SyncTargetFilesystem from '@joplin/lib/SyncTargetFilesystem';
const SyncTargetNextcloud = require('@joplin/lib/SyncTargetNextcloud.js');
const SyncTargetWebDAV = require('@joplin/lib/SyncTargetWebDAV.js');
import SyncTargetNextcloud from '@joplin/lib/SyncTargetNextcloud';
import SyncTargetWebDAV from '@joplin/lib/SyncTargetWebDAV';
const SyncTargetDropbox = require('@joplin/lib/SyncTargetDropbox.js');
const SyncTargetAmazonS3 = require('@joplin/lib/SyncTargetAmazonS3.js');
import SyncTargetJoplinServerSAML from '@joplin/lib/SyncTargetJoplinServerSAML';
@@ -712,6 +713,7 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
OneDriveLogin: { screen: OneDriveLoginScreen },
DropboxLogin: { screen: DropboxLoginScreen },
JoplinCloudLogin: { screen: JoplinCloudLoginScreen },
WebDavOidcLogin: { screen: WebDavOidcLoginScreen },
JoplinServerSamlLogin: { screen: SsoLoginScreen(new SamlShared()) },
EncryptionConfig: { screen: EncryptionConfigScreen },
UpgradeSyncTarget: { screen: UpgradeSyncTargetScreen },

View File

@@ -21,6 +21,7 @@ function historyCanGoBackTo(route: any) {
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
if (route.routeName === 'OneDriveLogin') return false;
if (route.routeName === 'DropboxLogin') return false;
if (route.routeName === 'WebDavOidcLogin') return false;
return true;
}

View File

@@ -39,8 +39,8 @@ import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import WelcomeUtils from '@joplin/lib/WelcomeUtils';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import SyncTargetFilesystem from '@joplin/lib/SyncTargetFilesystem';
const SyncTargetNextcloud = require('@joplin/lib/SyncTargetNextcloud.js');
const SyncTargetWebDAV = require('@joplin/lib/SyncTargetWebDAV.js');
import SyncTargetNextcloud from '@joplin/lib/SyncTargetNextcloud';
import SyncTargetWebDAV from '@joplin/lib/SyncTargetWebDAV';
const SyncTargetDropbox = require('@joplin/lib/SyncTargetDropbox.js');
const SyncTargetAmazonS3 = require('@joplin/lib/SyncTargetAmazonS3.js');
import SyncTargetJoplinServerSAML from '@joplin/lib/SyncTargetJoplinServerSAML';

View File

@@ -33,8 +33,8 @@ const EventEmitter = require('events');
const syswidecas = require('./vendor/syswide-cas');
import SyncTargetRegistry from './SyncTargetRegistry';
import SyncTargetFilesystem from './SyncTargetFilesystem';
const SyncTargetNextcloud = require('./SyncTargetNextcloud.js');
const SyncTargetWebDAV = require('./SyncTargetWebDAV.js');
import SyncTargetNextcloud from './SyncTargetNextcloud';
import SyncTargetWebDAV from './SyncTargetWebDAV';
const SyncTargetDropbox = require('./SyncTargetDropbox.js');
const SyncTargetAmazonS3 = require('./SyncTargetAmazonS3.js');
import EncryptionService from './services/e2ee/EncryptionService';

242
packages/lib/OidcApi.ts Normal file
View File

@@ -0,0 +1,242 @@
import shim from './shim';
import Logger from '@joplin/utils/Logger';
import { _ } from './locale';
import { EventEmitter } from 'events';
const { stringify } = require('query-string');
const urlUtils = require('./urlUtils.js');
const logger = Logger.create('OidcApi');
export enum OidcEventName {
AuthRefreshed = 'authRefreshed',
}
type OidcAuthCallback = (auth: OidcAuth | null)=> void;
export interface OidcAuth {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
expires_at?: number;
id_token?: string;
scope?: string;
}
export interface OidcDiscoveryDocument {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint?: string;
end_session_endpoint?: string;
jwks_uri?: string;
scopes_supported?: string[];
response_types_supported?: string[];
grant_types_supported?: string[];
}
export interface OidcApiOptions {
issuerUrl: string;
clientId: string;
clientSecret?: string;
scope?: string;
ignoreTlsErrors?: boolean;
}
export default class OidcApi {
private options_: OidcApiOptions;
private auth_: OidcAuth | null = null;
private discoveryDocument_: OidcDiscoveryDocument | null = null;
private emitter_ = new EventEmitter();
public constructor(options: OidcApiOptions) {
this.options_ = options;
}
public on(eventName: OidcEventName, callback: OidcAuthCallback) {
this.emitter_.on(eventName, callback);
}
public off(eventName: OidcEventName, callback: OidcAuthCallback) {
this.emitter_.off(eventName, callback);
}
public auth(): OidcAuth | null {
return this.auth_;
}
public setAuth(auth: OidcAuth | null) {
if (auth && auth.expires_in && !auth.expires_at) {
// Calculate absolute expiration time
auth.expires_at = Date.now() + (auth.expires_in * 1000);
}
this.auth_ = auth;
this.emitter_.emit(OidcEventName.AuthRefreshed, this.auth());
}
public token(): string | null {
return this.auth_ ? this.auth_.access_token : null;
}
public issuerUrl(): string {
return this.options_.issuerUrl.replace(/\/$/, '');
}
public clientId(): string {
return this.options_.clientId;
}
public clientSecret(): string | undefined {
return this.options_.clientSecret;
}
public scope(): string {
return this.options_.scope || 'openid profile';
}
public ignoreTlsErrors(): boolean {
return this.options_.ignoreTlsErrors || false;
}
private async fetchDiscoveryDocument(): Promise<OidcDiscoveryDocument> {
if (this.discoveryDocument_) {
return this.discoveryDocument_;
}
const discoveryUrl = `${this.issuerUrl()}/.well-known/openid-configuration`;
const response = await shim.fetch(discoveryUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
ignoreTlsErrors: this.ignoreTlsErrors(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to fetch OIDC discovery document from ${discoveryUrl}: ${response.status}: ${text}`);
}
this.discoveryDocument_ = await response.json();
return this.discoveryDocument_;
}
public async authorizationEndpoint(): Promise<string> {
const doc = await this.fetchDiscoveryDocument();
return doc.authorization_endpoint;
}
public async tokenEndpoint(): Promise<string> {
const doc = await this.fetchDiscoveryDocument();
return doc.token_endpoint;
}
public async authCodeUrl(redirectUri: string, state?: string): Promise<string> {
const authEndpoint = await this.authorizationEndpoint();
const query: Record<string, string> = {
client_id: this.clientId(),
scope: this.scope(),
response_type: 'code',
redirect_uri: redirectUri,
};
if (state) {
query.state = state;
}
return `${authEndpoint}?${stringify(query)}`;
}
private async postToTokenEndpoint(body: Record<string, string>, errorContext: string): Promise<OidcAuth> {
const tokenEndpoint = await this.tokenEndpoint();
if (this.clientSecret()) {
body.client_secret = this.clientSecret();
}
const response = await shim.fetch(tokenEndpoint, {
method: 'POST',
body: urlUtils.objectToQueryString(body),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
ignoreTlsErrors: this.ignoreTlsErrors(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${errorContext}: ${response.status}: ${text}`);
}
return response.json();
}
public async execTokenRequest(code: string, redirectUri: string): Promise<void> {
const body: Record<string, string> = {
client_id: this.clientId(),
code: code,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
};
try {
const auth = await this.postToTokenEndpoint(body, 'Could not exchange authorization code for token');
this.setAuth(auth);
} catch (error) {
this.setAuth(null);
throw error;
}
}
public isTokenExpired(): boolean {
if (!this.auth_) return true;
if (!this.auth_.expires_at) return false;
// Consider token expired 60 seconds before actual expiration
const bufferMs = 60 * 1000;
return Date.now() >= (this.auth_.expires_at - bufferMs);
}
public async refreshAccessToken(): Promise<void> {
if (!this.auth_ || !this.auth_.refresh_token) {
this.setAuth(null);
throw new Error(_('Cannot refresh token: authentication data is missing. Starting the synchronisation again may fix the problem.'));
}
const body: Record<string, string> = {
client_id: this.clientId(),
refresh_token: this.auth_.refresh_token,
grant_type: 'refresh_token',
};
logger.info('Refreshing OIDC access token...');
try {
const auth = await this.postToTokenEndpoint(body, 'Failed to refresh token');
// Preserve refresh token if new one not provided
if (!auth.refresh_token && this.auth_.refresh_token) {
auth.refresh_token = this.auth_.refresh_token;
}
this.setAuth(auth);
logger.info('OIDC access token refreshed successfully');
} catch (error) {
this.setAuth(null);
throw error;
}
}
public async ensureValidToken(): Promise<string> {
if (!this.auth_) {
throw new Error('Not authenticated');
}
if (this.isTokenExpired()) {
await this.refreshAccessToken();
}
return this.token();
}
}

View File

@@ -1,47 +1,54 @@
// The Nextcloud sync target is essentially a wrapper over the WebDAV sync target,
// thus all the calls to SyncTargetWebDAV to avoid duplicate code.
const BaseSyncTarget = require('./BaseSyncTarget').default;
const { _ } = require('./locale');
const Setting = require('./models/Setting').default;
const Synchronizer = require('./Synchronizer').default;
const SyncTargetWebDAV = require('./SyncTargetWebDAV');
import BaseSyncTarget, { CheckConfigResult } from './BaseSyncTarget';
import { _ } from './locale';
import Setting from './models/Setting';
import Synchronizer from './Synchronizer';
import SyncTargetWebDAV from './SyncTargetWebDAV';
class SyncTargetNextcloud extends BaseSyncTarget {
interface NextcloudOptions {
path(): string;
username(): string;
password(): string;
ignoreTlsErrors(): boolean;
}
static id() {
export default class SyncTargetNextcloud extends BaseSyncTarget {
public static id() {
return 5;
}
static supportsConfigCheck() {
public static supportsConfigCheck() {
return true;
}
static targetName() {
public static targetName() {
return 'nextcloud';
}
static label() {
public static label() {
return _('Nextcloud');
}
static description() {
public static description() {
return 'A suite of client-server software for creating and using file hosting services.';
}
async isAuthenticated() {
public async isAuthenticated() {
return true;
}
static requiresPassword() {
public static requiresPassword() {
return true;
}
static async checkConfig(options) {
public static async checkConfig(options: NextcloudOptions): Promise<CheckConfigResult> {
return SyncTargetWebDAV.checkConfig(options);
}
async initFileApi() {
protected async initFileApi() {
const fileApi = await SyncTargetWebDAV.newFileApi_(SyncTargetNextcloud.id(), {
path: () => Setting.value('sync.5.path'),
username: () => Setting.value('sync.5.username'),
@@ -54,10 +61,7 @@ class SyncTargetNextcloud extends BaseSyncTarget {
return fileApi;
}
async initSynchronizer() {
protected async initSynchronizer() {
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}
module.exports = SyncTargetNextcloud;

View File

@@ -1,94 +0,0 @@
const BaseSyncTarget = require('./BaseSyncTarget').default;
const { _ } = require('./locale');
const Setting = require('./models/Setting').default;
const { FileApi } = require('./file-api.js');
const Synchronizer = require('./Synchronizer').default;
const WebDavApi = require('./WebDavApi').default;
const { FileApiDriverWebDav } = require('./file-api-driver-webdav');
const checkProviderIsSupported = require('./utils/webDAVUtils').default;
class SyncTargetWebDAV extends BaseSyncTarget {
static id() {
return 6;
}
static supportsConfigCheck() {
return true;
}
static targetName() {
return 'webdav';
}
static label() {
return _('WebDAV');
}
static description() {
return 'The WebDAV protocol allows users to create, change and move documents on a server. There are many WebDAV compatible servers, including SeaFile, Nginx or Apache.';
}
async isAuthenticated() {
return true;
}
static requiresPassword() {
return true;
}
static async newFileApi_(syncTargetId, options) {
const apiOptions = {
baseUrl: () => options.path(),
username: () => options.username(),
password: () => options.password(),
ignoreTlsErrors: () => options.ignoreTlsErrors(),
};
const api = new WebDavApi(apiOptions);
const driver = new FileApiDriverWebDav(api);
const fileApi = new FileApi('', driver);
fileApi.setSyncTargetId(syncTargetId);
return fileApi;
}
static async checkConfig(options) {
const fileApi = await SyncTargetWebDAV.newFileApi_(SyncTargetWebDAV.id(), options);
fileApi.requestRepeatCount_ = 0;
const output = {
ok: false,
errorMessage: '',
};
try {
checkProviderIsSupported(options.path());
const result = await fileApi.stat('');
if (!result) throw new Error(`WebDAV directory not found: ${options.path()}`);
output.ok = true;
} catch (error) {
output.errorMessage = error.message;
if (error.code) output.errorMessage += ` (Code ${error.code})`;
}
return output;
}
async initFileApi() {
const fileApi = await SyncTargetWebDAV.newFileApi_(SyncTargetWebDAV.id(), {
path: () => Setting.value('sync.6.path'),
username: () => Setting.value('sync.6.username'),
password: () => Setting.value('sync.6.password'),
ignoreTlsErrors: () => Setting.value('net.ignoreTlsErrors'),
});
fileApi.setLogger(this.logger());
return fileApi;
}
async initSynchronizer() {
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}
module.exports = SyncTargetWebDAV;

View File

@@ -0,0 +1,202 @@
import BaseSyncTarget, { CheckConfigResult } from './BaseSyncTarget';
import { _ } from './locale';
import Setting from './models/Setting';
import { FileApi } from './file-api';
import Synchronizer from './Synchronizer';
import WebDavApi, { WebDavAuthType } from './WebDavApi';
import checkProviderIsSupported from './utils/webDAVUtils';
import OidcApi, { OidcAuth, OidcEventName } from './OidcApi';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const { FileApiDriverWebDav } = require('./file-api-driver-webdav');
interface WebDavOptions {
path(): string;
username(): string;
password(): string;
ignoreTlsErrors(): boolean;
authType?(): string;
oidcIssuerUrl?(): string;
oidcClientId?(): string;
oidcClientSecret?(): string;
oidcAuth?(): string;
}
export default class SyncTargetWebDAV extends BaseSyncTarget {
private oidcApi_: OidcApi | null = null;
public static id() {
return 6;
}
public static supportsConfigCheck() {
return true;
}
public static targetName() {
return 'webdav';
}
public static label() {
return _('WebDAV');
}
public static description() {
return 'The WebDAV protocol allows users to create, change and move documents on a server. There are many WebDAV compatible servers, including SeaFile, Nginx or Apache.';
}
public async isAuthenticated() {
const authType = Setting.value('sync.6.authType');
if (authType === 'oidc') {
const oidcAuth = Setting.value('sync.6.oidcAuth');
return !!oidcAuth;
}
return true;
}
public static requiresPassword() {
return true;
}
public authRouteName(): string | null {
const authType = Setting.value('sync.6.authType');
if (authType === 'oidc') {
return 'WebDavOidcLogin';
}
return null;
}
private oidcApi(): OidcApi {
if (!this.oidcApi_) {
this.oidcApi_ = new OidcApi({
issuerUrl: Setting.value('sync.6.oidcIssuerUrl'),
clientId: Setting.value('sync.6.oidcClientId'),
clientSecret: Setting.value('sync.6.oidcClientSecret'),
ignoreTlsErrors: Setting.value('net.ignoreTlsErrors'),
});
// Load existing auth if available
const authJson = Setting.value('sync.6.oidcAuth');
if (authJson) {
try {
const auth = JSON.parse(authJson) as OidcAuth;
this.oidcApi_.setAuth(auth);
} catch (e) {
// Invalid auth JSON, ignore
}
}
// Save auth when refreshed
this.oidcApi_.on(OidcEventName.AuthRefreshed, (auth: OidcAuth | null) => {
Setting.setValue('sync.6.oidcAuth', auth ? JSON.stringify(auth) : '');
});
}
return this.oidcApi_;
}
public api(): OidcApi | null {
const authType = Setting.value('sync.6.authType');
if (authType === 'oidc') {
return this.oidcApi();
}
return null;
}
public static async newFileApi_(syncTargetId: number, options: WebDavOptions): Promise<FileApi> {
const authType = options.authType ? options.authType() : 'basic';
let oidcApi: OidcApi | null = null;
if (authType === 'oidc') {
oidcApi = new OidcApi({
issuerUrl: options.oidcIssuerUrl ? options.oidcIssuerUrl() : '',
clientId: options.oidcClientId ? options.oidcClientId() : '',
clientSecret: options.oidcClientSecret ? options.oidcClientSecret() : '',
ignoreTlsErrors: options.ignoreTlsErrors ? options.ignoreTlsErrors() : false,
});
// Load existing auth if available
const authJson = options.oidcAuth ? options.oidcAuth() : '';
if (authJson) {
try {
const auth = JSON.parse(authJson) as OidcAuth;
oidcApi.setAuth(auth);
} catch (e) {
// Invalid auth JSON, ignore
}
}
}
const apiOptions = {
baseUrl: () => options.path(),
username: () => options.username(),
password: () => options.password(),
ignoreTlsErrors: () => options.ignoreTlsErrors(),
authType: () => authType === 'oidc' ? WebDavAuthType.Bearer : WebDavAuthType.Basic,
bearerToken: async () => {
if (oidcApi) {
return oidcApi.ensureValidToken();
}
return null;
},
};
const api = new WebDavApi(apiOptions);
const driver = new FileApiDriverWebDav(api);
const fileApi = new FileApi('', driver);
fileApi.setSyncTargetId(syncTargetId);
return fileApi;
}
public static async checkConfig(options: WebDavOptions): Promise<CheckConfigResult> {
const fileApi = await SyncTargetWebDAV.newFileApi_(SyncTargetWebDAV.id(), options);
fileApi.requestRepeatCount_ = 0;
const output: CheckConfigResult = {
ok: false,
errorMessage: '',
};
try {
checkProviderIsSupported(options.path());
const result = await fileApi.stat('');
if (!result) throw new Error(`WebDAV directory not found: ${options.path()}`);
output.ok = true;
} catch (error) {
output.errorMessage = (error as Error).message;
if ((error as { code?: string }).code) output.errorMessage += ` (Code ${(error as { code: string }).code})`;
}
return output;
}
protected async initFileApi(): Promise<FileApi> {
const authType = Setting.value('sync.6.authType');
const oidcApi = authType === 'oidc' ? this.oidcApi() : null;
const apiOptions = {
baseUrl: () => Setting.value('sync.6.path'),
username: () => Setting.value('sync.6.username'),
password: () => Setting.value('sync.6.password'),
ignoreTlsErrors: () => Setting.value('net.ignoreTlsErrors'),
authType: () => authType === 'oidc' ? WebDavAuthType.Bearer : WebDavAuthType.Basic,
bearerToken: async () => {
if (oidcApi) {
return oidcApi.ensureValidToken();
}
return null;
},
};
const api = new WebDavApi(apiOptions);
const driver = new FileApiDriverWebDav(api);
const fileApi = new FileApi('', driver);
fileApi.setSyncTargetId(SyncTargetWebDAV.id());
fileApi.setLogger(this.logger());
return fileApi;
}
protected async initSynchronizer(): Promise<Synchronizer> {
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}

View File

@@ -15,11 +15,18 @@ const base64 = require('base-64');
// example to convert a custom namespace to "d:" so that it can be used by the rest of the code.
// In general, we should only deal with things in "d:", which is the standard DAV namespace.
export enum WebDavAuthType {
Basic = 'basic',
Bearer = 'bearer',
}
interface WebDavApiOptions {
baseUrl(): string;
username(): string;
password(): string;
ignoreTlsErrors?(): boolean;
authType?(): WebDavAuthType;
bearerToken?(): Promise<string>;
}
interface LoggedRequest {
@@ -98,6 +105,13 @@ class WebDavApi {
return this.logger_;
}
private authType(): WebDavAuthType {
if (this.options_.authType) {
return this.options_.authType();
}
return WebDavAuthType.Basic;
}
private authToken(): string | null {
if (!this.options_.username() || !this.options_.password()) return null;
try {
@@ -111,6 +125,13 @@ class WebDavApi {
}
}
private async bearerToken(): Promise<string | null> {
if (this.options_.bearerToken) {
return this.options_.bearerToken();
}
return null;
}
public baseUrl(): string {
return rtrimSlashes(this.options_.baseUrl());
}
@@ -383,9 +404,18 @@ class WebDavApi {
if (!options.responseFormat) options.responseFormat = 'json';
if (!options.target) options.target = 'string';
const authToken = this.authToken();
if (authToken) headers['Authorization'] = `Basic ${authToken}`;
// Set authorization header based on auth type
if (this.authType() === WebDavAuthType.Bearer) {
const token = await this.bearerToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
} else {
const authToken = this.authToken();
if (authToken) {
headers['Authorization'] = `Basic ${authToken}`;
}
}
// That should not be needed, but it is required for React Native 0.63+
// https://github.com/facebook/react-native/issues/30176
@@ -496,8 +526,9 @@ class WebDavApi {
let message = 'Unknown error 2';
if (response.status === 401 || response.status === 403) {
// No auth token means an empty username or password
if (!authToken) {
// Check if we have valid auth credentials
const hasAuth = headers['Authorization'] !== undefined;
if (!hasAuth) {
message = _('Access denied: Please re-enter your password and/or username');
} else {
message = _('Access denied: Please check your username and password');

View File

@@ -0,0 +1,96 @@
import { _ } from './locale';
import { findAvailablePort } from './net-utils';
import shim from './shim';
const http = require('http');
const urlParser = require('url');
const enableServerDestroy = require('server-destroy');
export interface OAuthDanceOptions {
log?: (message: string)=> void;
}
export interface OAuthApi {
setAuth(auth: unknown): void;
auth(): unknown;
execTokenRequest(code: string, redirectUri: string): Promise<void>;
}
export interface OAuthDanceConfig {
authCodeUrl: string;
redirectUri: string;
state?: string;
successMessage: string;
}
export type OAuthServerType = ReturnType<typeof http.createServer>;
export abstract class BaseOAuthNodeUtils<T extends OAuthApi> {
protected api_: T;
protected oauthServer_: OAuthServerType | null = null;
public constructor(api: T) {
this.api_ = api;
}
public api(): T {
return this.api_;
}
public abstract possibleOAuthDancePorts(): number[];
protected makePage(message: string): string {
const header = `
<!doctype html>
<html><head><meta charset="utf-8"><title>Joplin Authentication</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 600px; margin: 50px auto; padding: 20px; text-align: center; }
.success { color: #28a745; }
.error { color: #dc3545; }
</style>
</head><body>`;
const footer = `
</body></html>
`;
return header + message + footer;
}
public cancelOAuthDance(): void {
if (!this.oauthServer_) return;
this.oauthServer_.destroy();
}
protected async findPort(): Promise<number> {
const port = await findAvailablePort(require('tcp-port-used'), this.possibleOAuthDancePorts(), 0);
if (!port) throw new Error(_('All potential ports are in use - please report the issue at %s', 'https://github.com/laurent22/joplin'));
return port;
}
protected createOAuthServer(): OAuthServerType {
this.oauthServer_ = http.createServer();
enableServerDestroy(this.oauthServer_);
return this.oauthServer_;
}
protected waitAndDestroy(): void {
shim.setTimeout(() => {
if (this.oauthServer_) {
this.oauthServer_.destroy();
this.oauthServer_ = null;
}
}, 1000);
}
protected writeResponse(response: typeof http.ServerResponse, code: number, message: string): void {
response.writeHead(code, { 'Content-Type': 'text/html' });
response.write(this.makePage(message));
response.end();
}
protected parseUrl(url: string): { pathname: string; query: Record<string, string> } {
return urlParser.parse(url, true);
}
}

View File

@@ -4,6 +4,12 @@ import SyncTargetRegistry from '../../../SyncTargetRegistry';
const shouldShowMissingPasswordWarning = (syncTargetId: number, settings: any) => {
const syncTargetClass = SyncTargetRegistry.classById(syncTargetId);
// For sync targets that support OIDC auth, password is not required when using OIDC
const authType = settings[`sync.${syncTargetId}.authType`];
if (authType === 'oidc') {
return false;
}
return syncTargetClass.requiresPassword() && !settings[`sync.${syncTargetId}.password`];
};

View File

@@ -223,13 +223,34 @@ const builtInMetadata = (Setting: typeof SettingType) => {
description: () => emptyDirWarning,
storage: SettingStorage.File,
},
'sync.6.authType': {
value: 'basic',
type: SettingItemType.String,
section: 'sync',
isEnum: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No type is available for settings in this context
show: (settings: any) => {
return settings['sync.target'] === SyncTargetRegistry.nameToId('webdav');
},
public: true,
label: () => _('WebDAV authentication type'),
options: () => {
return {
'basic': _('Basic (Username/Password)'),
'oidc': `${_('OIDC (OpenID Connect)')} (${_('Alpha')})`,
};
},
description: () => 'Please note that the OIDC implementation has not been extensively tested and should not be considered production-ready.',
storage: SettingStorage.File,
},
'sync.6.username': {
value: '',
type: SettingItemType.String,
section: 'sync',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No type is available for settings in this context
show: (settings: any) => {
return settings['sync.target'] === SyncTargetRegistry.nameToId('webdav');
return settings['sync.target'] === SyncTargetRegistry.nameToId('webdav') &&
settings['sync.6.authType'] !== 'oidc';
},
public: true,
label: () => _('WebDAV username'),
@@ -239,14 +260,76 @@ const builtInMetadata = (Setting: typeof SettingType) => {
value: '',
type: SettingItemType.String,
section: 'sync',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No type is available for settings in this context
show: (settings: any) => {
return settings['sync.target'] === SyncTargetRegistry.nameToId('webdav');
return settings['sync.target'] === SyncTargetRegistry.nameToId('webdav') &&
settings['sync.6.authType'] !== 'oidc';
},
public: true,
label: () => _('WebDAV password'),
secure: true,
},
'sync.6.oidcIssuerUrl': {
value: '',
type: SettingItemType.String,
section: 'sync',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No type is available for settings in this context
show: (settings: any) => {
return settings['sync.target'] === SyncTargetRegistry.nameToId('webdav') &&
settings['sync.6.authType'] === 'oidc';
},
public: true,
label: () => _('OIDC Issuer URL'),
description: () => _('The URL of the OpenID Connect provider (e.g., https://auth.example.com/realms/myrealm)'),
storage: SettingStorage.File,
},
'sync.6.oidcClientId': {
value: '',
type: SettingItemType.String,
section: 'sync',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No type is available for settings in this context
show: (settings: any) => {
return settings['sync.target'] === SyncTargetRegistry.nameToId('webdav') &&
settings['sync.6.authType'] === 'oidc';
},
public: true,
label: () => _('OIDC Client ID'),
storage: SettingStorage.File,
},
'sync.6.oidcClientSecret': {
value: '',
type: SettingItemType.String,
section: 'sync',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No type is available for settings in this context
show: (settings: any) => {
return settings['sync.target'] === SyncTargetRegistry.nameToId('webdav') &&
settings['sync.6.authType'] === 'oidc';
},
public: true,
label: () => _('OIDC Client Secret (optional)'),
description: () => _('Optional for public clients'),
secure: true,
},
'sync.6.oidcAuth': {
value: '',
type: SettingItemType.String,
public: false,
secure: true,
},
'sync.6.oidcLogin': {
value: null as null,
type: SettingItemType.Button,
public: true,
appTypes: [AppType.Desktop, AppType.Mobile],
section: 'sync',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No type is available for settings in this context
show: (settings: any) => {
return settings['sync.target'] === SyncTargetRegistry.nameToId('webdav') &&
settings['sync.6.authType'] === 'oidc';
},
label: () => _('Login with OIDC'),
description: () => _('Authenticate with your OpenID Connect provider'),
},
'sync.8.path': {
value: '',

View File

@@ -0,0 +1,94 @@
import { _ } from './locale';
import OidcApi, { OidcAuth } from './OidcApi';
import { BaseOAuthNodeUtils, OAuthDanceOptions } from './base-oauth-node-utils';
const http = require('http');
export { OAuthDanceOptions } from './base-oauth-node-utils';
export class OidcApiNodeUtils extends BaseOAuthNodeUtils<OidcApi> {
public possibleOAuthDancePorts(): number[] {
return [9968, 8968, 8868];
}
public async oauthDance(options: OAuthDanceOptions = {}): Promise<OidcAuth | null> {
const targetConsole = options.log ? { log: options.log } : console;
this.api().setAuth(null);
const port = await this.findPort();
const redirectUri = `http://localhost:${port}`;
const state = Math.random().toString(36).substring(7);
const authCodeUrl = await this.api().authCodeUrl(redirectUri, state);
return new Promise((resolve, reject) => {
const server = this.createOAuthServer();
let errorMessage: string | null = null;
server.on('request', async (request: typeof http.IncomingMessage, response: typeof http.ServerResponse) => {
const url = this.parseUrl(request.url);
if (url.pathname === '/auth') {
response.writeHead(302, { Location: authCodeUrl });
response.end();
return;
}
const query = url.query;
if (query.error) {
const errorDesc = query.error_description || query.error;
errorMessage = `Authentication failed: ${errorDesc}`;
this.writeResponse(response, 400, `<p class="error">${errorMessage}</p>`);
this.waitAndDestroy();
return;
}
if (!query.code) {
this.writeResponse(response, 400, '<p class="error">"code" query parameter is missing</p>');
return;
}
// Verify state to prevent CSRF
if (query.state !== state) {
this.writeResponse(response, 400, '<p class="error">Invalid state parameter</p>');
return;
}
try {
await this.api().execTokenRequest(query.code, redirectUri);
this.writeResponse(response, 200, `<p class="success">${_('The application has been authorised - you may now close this browser tab.')}</p>`);
targetConsole.log('');
targetConsole.log(_('The application has been successfully authorised.'));
this.waitAndDestroy();
} catch (error) {
const errorMsg = (error as Error).message;
this.writeResponse(response, 400, `<p class="error">${errorMsg}</p>`);
targetConsole.log('');
targetConsole.log(errorMsg);
errorMessage = errorMsg;
this.waitAndDestroy();
}
});
server.on('close', () => {
if (errorMessage) {
reject(new Error(errorMessage));
} else {
resolve(this.api().auth());
}
});
server.listen(port);
// Rather than displaying authCodeUrl directly, we go through the local
// server. This is just so that the URL being displayed is shorter and
// doesn't get cut in terminals (especially those that don't handle multi
// lines URLs).
targetConsole.log(_('Please open the following URL in your browser to authenticate with your OIDC provider.'));
targetConsole.log('');
targetConsole.log(`http://127.0.0.1:${port}/auth`);
});
}
}

View File

@@ -1,125 +0,0 @@
const { _ } = require('./locale');
const { findAvailablePort } = require('./net-utils');
const shim = require('./shim').default;
const http = require('http');
const urlParser = require('url');
const enableServerDestroy = require('server-destroy');
class OneDriveApiNodeUtils {
constructor(api) {
this.api_ = api;
this.oauthServer_ = null;
}
api() {
return this.api_;
}
possibleOAuthDancePorts() {
return [9967, 8967, 8867];
}
makePage(message) {
const header = `
<!doctype html>
<html><head><meta charset="utf-8"></head><body>`;
const footer = `
</body></html>
`;
return header + message + footer;
}
cancelOAuthDance() {
if (!this.oauthServer_) return;
this.oauthServer_.destroy();
}
async oauthDance(targetConsole = null) {
if (targetConsole === null) targetConsole = console;
this.api().setAuth(null);
const port = await findAvailablePort(require('tcp-port-used'), this.possibleOAuthDancePorts(), 0);
if (!port) throw new Error(_('All potential ports are in use - please report the issue at %s', 'https://github.com/laurent22/joplin'));
const authCodeUrl = this.api().authCodeUrl(`http://localhost:${port}`);
return new Promise((resolve, reject) => {
this.oauthServer_ = http.createServer();
const errorMessage = null;
this.oauthServer_.on('request', (request, response) => {
const url = urlParser.parse(request.url, true);
if (url.pathname === '/auth') {
response.writeHead(302, { Location: authCodeUrl });
response.end();
return;
}
const query = url.query;
const writeResponse = (code, message) => {
response.writeHead(code, { 'Content-Type': 'text/html' });
response.write(this.makePage(message));
response.end();
};
// After the response has been received, don't destroy the server right
// away or the browser might display a connection reset error (even
// though it worked).
const waitAndDestroy = () => {
shim.setTimeout(() => {
this.oauthServer_.destroy();
this.oauthServer_ = null;
}, 1000);
};
if (!query.code) return writeResponse(400, '"code" query parameter is missing');
this.api()
.execTokenRequest(query.code, `http://localhost:${port.toString()}`)
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
.then(() => {
writeResponse(200, _('The application has been authorised - you may now close this browser tab.'));
targetConsole.log('');
targetConsole.log(_('The application has been successfully authorised.'));
waitAndDestroy();
})
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
.catch(error => {
writeResponse(400, error.message);
targetConsole.log('');
targetConsole.log(error.message);
waitAndDestroy();
});
});
this.oauthServer_.on('close', () => {
if (errorMessage) {
reject(new Error(errorMessage));
} else {
resolve(this.api().auth());
}
});
this.oauthServer_.listen(port);
enableServerDestroy(this.oauthServer_);
// Rather than displaying authCodeUrl directly, we go through the local
// server. This is just so that the URL being displayed is shorter and
// doesn't get cut in terminals (especially those that don't handle multi
// lines URLs).
targetConsole.log(_('Please open the following URL in your browser to authenticate the application. The application will create a directory in "Apps/Joplin" and will only read and write files in this directory. It will have no access to any files outside this directory nor to any other personal data. No data will be shared with any third party.'));
targetConsole.log('');
targetConsole.log(`http://127.0.0.1:${port}/auth`);
});
}
}
module.exports = { OneDriveApiNodeUtils };

View File

@@ -0,0 +1,84 @@
import { _ } from './locale';
import { BaseOAuthNodeUtils, OAuthDanceOptions } from './base-oauth-node-utils';
const http = require('http');
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- OneDriveApi is not typed
type OneDriveApi = any;
export default class OneDriveApiNodeUtils extends BaseOAuthNodeUtils<OneDriveApi> {
public possibleOAuthDancePorts(): number[] {
return [9967, 8967, 8867];
}
public async oauthDance(options: OAuthDanceOptions = {}): Promise<unknown> {
// eslint-disable-next-line no-console -- Fallback to console when no log function provided
const log = options.log || ((message: string) => console.log(message));
this.api().setAuth(null);
const port = await this.findPort();
const authCodeUrl = this.api().authCodeUrl(`http://localhost:${port}`);
return new Promise((resolve, reject) => {
const server = this.createOAuthServer();
const errorMessage: string | null = null;
server.on('request', (request: typeof http.IncomingMessage, response: typeof http.ServerResponse) => {
const url = this.parseUrl(request.url);
if (url.pathname === '/auth') {
response.writeHead(302, { Location: authCodeUrl });
response.end();
return;
}
const query = url.query;
if (!query.code) {
this.writeResponse(response, 400, '"code" query parameter is missing');
return;
}
this.api()
.execTokenRequest(query.code, `http://localhost:${port.toString()}`)
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
.then(() => {
this.writeResponse(response, 200, _('The application has been authorised - you may now close this browser tab.'));
log('');
log(_('The application has been successfully authorised.'));
this.waitAndDestroy();
})
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
.catch((error: Error) => {
this.writeResponse(response, 400, error.message);
log('');
log(error.message);
this.waitAndDestroy();
});
});
server.on('close', () => {
if (errorMessage) {
reject(new Error(errorMessage));
} else {
resolve(this.api().auth());
}
});
server.listen(port);
// Rather than displaying authCodeUrl directly, we go through the local
// server. This is just so that the URL being displayed is shorter and
// doesn't get cut in terminals (especially those that don't handle multi
// lines URLs).
log(_('Please open the following URL in your browser to authenticate the application. The application will create a directory in "Apps/Joplin" and will only read and write files in this directory. It will have no access to any files outside this directory nor to any other personal data. No data will be shared with any third party.'));
log('');
log(`http://127.0.0.1:${port}/auth`);
});
}
}
// Keep backwards compatibility with the old export
module.exports = { OneDriveApiNodeUtils: OneDriveApiNodeUtils };

View File

@@ -32,6 +32,7 @@ interface FetchOptions {
headers?: Record<string, string>;
body?: string;
agent?: unknown;
ignoreTlsErrors?: boolean;
}
interface AttachFileToNoteOptions {

View File

@@ -37,10 +37,10 @@ const { FileApiDriverOneDrive } = require('../file-api-driver-onedrive.js');
import SyncTargetRegistry from '../SyncTargetRegistry';
const SyncTargetMemory = require('../SyncTargetMemory.js');
import SyncTargetFilesystem from '../SyncTargetFilesystem';
const SyncTargetNextcloud = require('../SyncTargetNextcloud.js');
import SyncTargetNextcloud from '../SyncTargetNextcloud';
const SyncTargetDropbox = require('../SyncTargetDropbox.js');
const SyncTargetAmazonS3 = require('../SyncTargetAmazonS3.js');
const SyncTargetWebDAV = require('../SyncTargetWebDAV.js');
import SyncTargetWebDAV from '../SyncTargetWebDAV';
import SyncTargetJoplinServer from '../SyncTargetJoplinServer';
import EncryptionService from '../services/e2ee/EncryptionService';
import DecryptionWorker from '../services/DecryptionWorker';

View File

@@ -250,4 +250,11 @@ mrjo
codegen
analyzed
Perfetto
appmodules
appmodules
OIDC
userinfo
jwks
Segoe
loglink
logentry
ocis

22
readme/dev/spec/oidc.md Normal file
View File

@@ -0,0 +1,22 @@
# Open ID feature specification
## Setting up synchronisation for local testing
Run:
```shell
docker compose --file docker-compose-oidc.yml up
```
In a separate terminal run:
```shell
docker exec ocis cat /var/lib/ocis/idp/tmp/identifier-registration.yaml
```
This will print the list of default OIDC clients. The "ownCloud desktop app" client for example can be used to test synchronisation.
- WebDAV URL: https://localhost:9200/remote.php/dav/files/admin/
- OIDC Issuer URL: https://localhost:9200
- OIDC Client ID: `id` property from the "ownCloud desktop app" client
- OIDC Client Secret: `secret` property from the "ownCloud desktop app" client