You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-03-03 09:27:01 +02:00
Compare commits
12 Commits
android-v3
...
oidc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c383996fa | ||
|
|
01b15d58dd | ||
|
|
fa07eb3db0 | ||
|
|
f439835281 | ||
|
|
740c87a817 | ||
|
|
8b3835eb04 | ||
|
|
a5318099c5 | ||
|
|
b39628a963 | ||
|
|
0386028803 | ||
|
|
ed242f736c | ||
|
|
8cd39e3b40 | ||
|
|
8d4632d9dd |
@@ -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
|
||||
|
||||
@@ -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
8
.gitignore
vendored
@@ -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
23
docker-compose-oidc.yml
Normal 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
12
ocis-clients.yaml
Normal 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
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') },
|
||||
|
||||
33
packages/app-desktop/gui/WebDavOidcLoginScreen.scss
Normal file
33
packages/app-desktop/gui/WebDavOidcLoginScreen.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
111
packages/app-desktop/gui/WebDavOidcLoginScreen.tsx
Normal file
111
packages/app-desktop/gui/WebDavOidcLoginScreen.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
158
packages/app-mobile/components/screens/webdav-oidc-login.tsx
Normal file
158
packages/app-mobile/components/screens/webdav-oidc-login.tsx
Normal 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;
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
242
packages/lib/OidcApi.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
202
packages/lib/SyncTargetWebDAV.ts
Normal file
202
packages/lib/SyncTargetWebDAV.ts
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
96
packages/lib/base-oauth-node-utils.ts
Normal file
96
packages/lib/base-oauth-node-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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`];
|
||||
};
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
94
packages/lib/oidc-api-node-utils.ts
Normal file
94
packages/lib/oidc-api-node-utils.ts
Normal 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`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
84
packages/lib/onedrive-api-node-utils.ts
Normal file
84
packages/lib/onedrive-api-node-utils.ts
Normal 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 };
|
||||
@@ -32,6 +32,7 @@ interface FetchOptions {
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
agent?: unknown;
|
||||
ignoreTlsErrors?: boolean;
|
||||
}
|
||||
|
||||
interface AttachFileToNoteOptions {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
22
readme/dev/spec/oidc.md
Normal 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
|
||||
Reference in New Issue
Block a user