feat(sync): add OneDrive sync provider with PKCE auth (#7523)

* feat(sync): integrate onedrive with pkce and operation log sync

* fix(sync): add oauth state validation and token refresh concurrency control

* fix(sync): refactor onedrive oauth cleanup and reduce download API calls

- Replace setInterval-based OAuth state pruning with on-demand
  _pruneExpiredOAuthStates() to avoid background timer overhead
- Optimize downloadFile to read ETag from content response headers
  first, falling back to a metadata request only when needed (reduces
  API calls from 2 to 1 in the common case)
- Narrow OneDrive class interface from SyncProviderServiceInterface to
  FileSyncProvider for stronger type guarantees
- Accept optional devPath constructor parameter for non-production
  folder namespacing
- Extract isOneDriveClientIdRequired to a helper function in
  sync-form.const.ts for readability
- Change default OneDrive sync folder name to 'Super Productivity'

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 review feedback for OneDrive sync

Critical fixes:
- Detect invalid_grant in token refresh responses and clear credentials
- Reset _tokenRefreshInFlightPromise in clearAuthCredentials to prevent
  stale refresh results from overwriting cleared state
- Re-read config before persisting refreshed tokens to avoid race with
  concurrent clearAuthCredentials calls

Important fixes:
- Extract OAuth state management to shared oauth-state.util.ts to fix
  circular dependency between OneDrive provider and callback handler
- Add 401 retry pattern (_is401Retry) matching Dropbox's approach:
  on 401, proactively refresh token and retry, only clear credentials
  if retry fails
- Remove unused auth status translation keys (AUTH_SUCCESS,
  BTN_AUTHENTICATE, BTN_REAUTHENTICATE, REAUTH_SUCCESS,
  STATUS_CONFIGURED, STATUS_NOT_CONFIGURED) and form props
- Remove unused requireAuth parameter from _cfgOrError

Minor fixes:
- Replace path-exposing log messages with structured logging
- Remove unused _formatHttpErrorDetails helper
- Return empty ETag fallback in getFileRev instead of throwing
- Add folder cache invalidation comment for path changes
- Add afterEach to restore global fetch in tests
- Add PKCE defence-in-depth comment in auth code dialog

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): use eslint-disable-next-line for @microsoft.graph.conflictBehavior

Replace spread+computed-property workaround with a plain property plus
an explicit eslint-disable-next-line comment. Clearer intent: the
naming violation is a Graph API requirement, not accidental.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): pre-save OneDrive form config in reauth() to prevent MissingCredentialsSPError

Re-auth was failing silently on Linux because getAuthHelper() reads
clientId from IndexedDB (privateCfg), but the form values were never
written before calling configuredAuthForSyncProviderIfNecessary().

- Extract shared BTN_REAUTHENTICATE and REAUTH_SUCCESS translation keys
- Add OneDrive to OAUTH_SYNC_PROVIDERS
- Pre-save form config in reauth() matching the existing save() flow

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): adapt TooManyRequestsAPIError to new constructor signature after rebase

* refactor(sync): migrate OneDrive provider to packages/sync-providers

Move OneDrive core logic into the shared sync-providers package,
matching the pattern used by Dropbox, WebDAV, and other providers.
The app-side code is now a thin adapter with a createOneDriveProvider()
factory function.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): re-throw MissingRefreshTokenAPIError from _requestOAuthToken catch block

The catch block in _requestOAuthToken was swallowing the
MissingRefreshTokenAPIError thrown after clearing credentials on
invalid_grant, causing it to fall through to a generic HttpNotOkAPIError
instead. Now re-throws MissingRefreshTokenAPIError explicitly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 second review critical and important items

- Replace _is401Retry instance field with per-call parameter to prevent
  concurrent 401 races (Critical #1)
- Fix _requestOAuthToken catch block to re-throw MissingRefreshTokenAPIError
  instead of swallowing it (Critical #2)
- Fix _parseOAuthCallback defaulting unknown provider to 'unknown' instead
  of 'dropbox' to prevent misrouting (Critical #3 + Important #6)
- Change conflictBehavior from 'replace' to 'fail' to prevent data loss
  if a file exists with the same name as the folder (Critical #4)
- Await in-flight token refresh promise in clearAuthCredentials before
  clearing, to close the refresh-during-clear race (Important #5)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 review important and minor items

- Extract duplicate OneDrive pre-auth save block into
  _persistOneDriveFormCfgBeforeAuth() method (Important #7)
- Add GET probe in _ensureSyncFolderExists to skip per-segment
  creation when folder already exists (Important #9)
- Redact sensitive params (code, code_verifier, refresh_token,
  access_token) from token endpoint and Graph error bodies
  (Important #11)
- Remove targetPath from _mapAndThrow error messages to prevent
  logging user content (Important #12)
- Add OAuth state validation in manual paste flow for OneDrive
  (Important #13)
- Fix unknown provider routing in _parseOAuthCallback (Important #14)
- Guard Electron IPC listener against post-destroy emissions
  (Important #15)
- Remove dead authStatus Formly field from sync-form.const.ts (Minor)
- Simplify Headers builder in _ensureSyncFolderExists (Minor)
- Fix spec afterEach to restore original fetch instead of undefined
  (Minor)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): fix clearAuthCredentials race and add unit tests for OneDrive

Fix a race condition in clearAuthCredentials where _tokenRefreshInFlightPromise
was nulled AFTER awaiting, causing the invalid_grant handler's clearAuthCredentials
to wait on itself. Now captures the promise and nulls the field before awaiting.

Add 5 new unit tests covering critical code paths:
- 401 token refresh and retry
- 401 retry failure with credential clearing
- 400 invalid_grant credential clearing
- 412 precondition failed mapping
- Concurrent token refresh deduplication

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 third review items

- Simplify clearAuthCredentials: null _tokenRefreshInFlightPromise without
  awaiting to avoid deadlock when called from inside the refresh IIFE
- Add superproductivity:// scheme validation in Electron OAuth listener
- Fix invalid_grant test to assert MissingRefreshTokenAPIError
- Fix folder cache test to match GET-probe optimization
- Set setComplete default in beforeEach

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(sync): fix OAuthCallbackHandler spec to expect 'unknown' provider

The _parseOAuthCallback now returns 'unknown' instead of 'dropbox' for
URLs without an explicit provider path segment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 fourth review items

- Handle generic 401 from token endpoint (clear creds + MissingRefreshTokenAPIError)
- Remove user-supplied paths from error constructors (rule 9 compliance)
- Soften clearAuthCredentials comment to reflect actual TOCTOU guarantee
- Tighten Electron scheme gate to match native path prefix
- Add scheme info to Electron rejection log
- Fix folder-cache test to assert GET probe count
- Remove redundant per-test setComplete stub

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 fifth review items

- Scope invalid_grant/401 credential clearing to refresh_token grant only
  (bad auth code exchange no longer wipes existing credentials)
- Redact OAuth code/state from Electron protocol handler logs
- Add @sp/sync-providers/onedrive TS path alias to tsconfig files
- Default undefined sync config fields instead of overwriting with undefined
- Handle OneDrive in provider-switch branch (preserve encryptKey)
- Update sync-config test expectations to match default values

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 sixth review items

- Use If-None-Match: * for null-rev uploads to prevent concurrent overwrite
- Guard in-flight refresh against stale credentials (match refreshToken/clientId/tenantId)
- Gate OneDrive provider option behind Electron/Native (web CORS unsupported)
- Don't clear credentials on transient token endpoint errors (429/5xx)
- Preserve null syncProvider instead of defaulting to WebDAV
- Hydrate full OneDrive privateCfg on provider switch
- Remove duplicate syncInterval/isManualSyncOnly Formly controls
- Revert non-English locale changes (zh.json, zh-tw.json)
- Redact URL fragments (#) in Electron protocol handler logs
- listFiles rethrows non-404 errors instead of swallowing all
- Update 401 test expectations for new rethrow behavior

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): guard credential clearing against stale concurrent refresh

Prevent a stale in-flight refresh from wiping newer credentials after
a concurrent re-auth. The refresh IIFE now throws a plain Error (not
MissingRefreshTokenAPIError) when it detects config drift, and all
clearAuthCredentials() call sites now verify the stored config still
matches before clearing. Also fix listFiles() to handle 404 from
_requestJson (which throws HttpNotOkAPIError, not RemoteFileNotFoundAPIError).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 eighth review items

- Replace If-None-Match:* with @microsoft.graph.conflictBehavior=fail query param for upload create-only semantics
- Add identity change detection in _persistOneDriveFormCfgBeforeAuth to clear tokens when useCustomApp/clientId/tenantId change
- Extract _clearIfConfigMatches helper to guard credential clearing against stale concurrent refresh
- Restrict folder probe fallthrough to 404 only, propagate other errors
- Restore locale files from upstream master
- Centralize IS_ONEDRIVE_SUPPORTED in onedrive-auth-mode.const.ts, use in factory and form config

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 ninth review items

- Paginate listFiles via @odata.nextLink to avoid silent data loss
- Fix logger misuse (log -> normal with proper string message)
- Replace as any with OneDrivePrivateCfg in dialog-sync-cfg
- Throw NoRevAPIError when uploadFile response lacks eTag
- Omit undefined optional booleans from global config to prevent overwrite
- Move OneDrive dynamic import inside IS_ONEDRIVE_SUPPORTED gate
- Require state param for OneDrive full-URL paste (CSRF)
- Add id_token to sensitive keys for body redaction

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): add OneDrive type alias to electron tsconfig paths

The @sp/sync-providers/onedrive path alias was missing from
electron/tsconfig.electron.json, causing the Electron build to fail
with "Cannot find module '@sp/sync-providers/onedrive'". The alias was
already present in tsconfig.base.json but electron uses its own paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Harbinger 2026-05-26 21:31:06 +08:00 committed by GitHub
parent f1146613b3
commit 9910d30fca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1873 additions and 59 deletions

View file

@ -11,7 +11,9 @@ export const PROTOCOL_PREFIX = `${PROTOCOL_NAME}://`;
let pendingUrls: string[] = [];
export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null): void => {
log('Processing protocol URL:', url);
// Redact query params before logging — OAuth code/state are credentials
const redactedUrl = url.split('?')[0].split('#')[0];
log('Processing protocol URL:', redactedUrl);
// Only process after window is ready
if (!mainWin || !mainWin.webContents) {
@ -34,6 +36,13 @@ export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null):
log('Protocol path parts:', pathParts);
switch (action) {
case 'oauth-callback':
log('Received OAuth callback URL via app protocol');
if (mainWin && mainWin.webContents) {
mainWin.webContents.send(IPC.OAUTH_CALLBACK, { url });
showOrFocus(mainWin);
}
break;
case 'create-task':
if (pathParts.length > 0) {
const taskTitle = decodeURIComponent(pathParts[0]);
@ -76,8 +85,16 @@ export const initializeProtocolHandling = (
// Register protocol handler
if (IS_DEV && process.defaultApp) {
if (process.argv.length >= 2) {
const launchArgsForProtocol = [path.resolve(process.argv[1])];
const userDataDirArg = process.argv.find((arg) =>
arg.startsWith('--user-data-dir='),
);
if (userDataDirArg) {
launchArgsForProtocol.push(userDataDirArg);
}
appInstance.setAsDefaultProtocolClient(PROTOCOL_NAME, process.execPath, [
path.resolve(process.argv[1]),
...launchArgsForProtocol,
]);
}
} else {
@ -111,7 +128,7 @@ export const initializeProtocolHandling = (
// Handle protocol URL passed as command line argument for testing
process.argv.forEach((val) => {
if (val && val.startsWith(PROTOCOL_PREFIX)) {
log('Protocol URL from command line:', val);
log('Protocol URL from command line:', val.split('?')[0].split('#')[0]);
// Process after app is ready
appInstance.whenReady().then(() => {
processProtocolUrl(val, getMainWindow());

View file

@ -89,6 +89,9 @@ export enum IPC {
PLUGIN_OAUTH_START = 'PLUGIN_OAUTH_START',
PLUGIN_OAUTH_CB = 'PLUGIN_OAUTH_CB',
// Generic OAuth callback for app protocol redirects (e.g. OneDrive)
OAUTH_CALLBACK = 'OAUTH_CALLBACK',
LOCAL_REST_API_REQUEST = 'LOCAL_REST_API_REQUEST',
LOCAL_REST_API_RESPONSE = 'LOCAL_REST_API_RESPONSE',

View file

@ -44,7 +44,8 @@
"@sp/sync-providers/provider-types": [
"packages/sync-providers/dist/provider-types.d.ts"
],
"@sp/sync-providers/log": ["packages/sync-providers/dist/log.d.ts"]
"@sp/sync-providers/log": ["packages/sync-providers/dist/log.d.ts"],
"@sp/sync-providers/onedrive": ["packages/sync-providers/dist/onedrive.d.ts"]
}
},
"include": ["main.ts", "**/*.ts", "../src/app/core/window-ea.d.ts"],

View file

@ -44,6 +44,16 @@
"default": "./dist/super-sync.js"
}
},
"./onedrive": {
"import": {
"types": "./dist/onedrive.d.mts",
"default": "./dist/onedrive.mjs"
},
"require": {
"types": "./dist/onedrive.d.ts",
"default": "./dist/onedrive.js"
}
},
"./http": {
"import": {
"types": "./dist/http.d.mts",

View file

@ -0,0 +1,30 @@
export interface OneDrivePrivateCfg {
encryptKey?: string;
useCustomApp?: boolean;
clientId: string;
tenantId: string;
syncFolderPath?: string;
accessToken?: string;
refreshToken?: string;
tokenExpiresAt?: number;
}
export interface OneDriveItem {
id: string;
name: string;
eTag?: string;
folder?: Record<string, unknown>;
file?: Record<string, unknown>;
}
export interface OneDriveListResponse {
value?: OneDriveItem[];
// eslint-disable-next-line @typescript-eslint/naming-convention
'@odata.nextLink'?: string;
}
export interface OneDriveTokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
}

View file

@ -0,0 +1,770 @@
import type { SyncLogger } from '@sp/sync-core';
import type { ProviderPlatformInfo } from '../../platform/provider-platform-info';
import type { WebFetchFactory } from '../../platform/web-fetch-factory';
import type { SyncCredentialStorePort } from '../../credential-store-port';
import type { FileSyncProvider, SyncProviderAuthHelper } from '../../provider-types';
import {
AuthFailSPError,
HttpNotOkAPIError,
MissingRefreshTokenAPIError,
NoRevAPIError,
RemoteFileNotFoundAPIError,
TooManyRequestsAPIError,
UploadRevToMatchMismatchAPIError,
} from '../../errors';
import { generateCodeVerifier, generateCodeChallenge } from '../../pkce';
import type {
OneDrivePrivateCfg,
OneDriveListResponse,
OneDriveTokenResponse,
} from './onedrive.model';
export const PROVIDER_ID_ONEDRIVE = 'OneDrive' as const;
export interface OneDriveDeps {
logger: SyncLogger;
platformInfo: ProviderPlatformInfo;
webFetch: WebFetchFactory;
credentialStore: SyncCredentialStorePort<
typeof PROVIDER_ID_ONEDRIVE,
OneDrivePrivateCfg
>;
officialClientId: string | null;
hasOfficialClientId: boolean;
addOAuthState: (provider: string, state: string) => void;
isElectron: boolean;
}
const ONEDRIVE_PROTOCOL = {
graphApiBaseUrl: 'https://graph.microsoft.com/v1.0',
scope: 'offline_access Files.ReadWrite.AppFolder',
redirectUri: 'https://login.microsoftonline.com/common/oauth2/nativeclient',
electronRedirectUri: 'superproductivity://oauth-callback/onedrive',
tokenRefreshSkewMs: 60_000,
} as const;
const ONEDRIVE_DEFAULTS = {
tenantId: 'common',
syncFolderPath: 'Super Productivity',
} as const;
export class OneDrive implements FileSyncProvider<
typeof PROVIDER_ID_ONEDRIVE,
OneDrivePrivateCfg
> {
readonly id = PROVIDER_ID_ONEDRIVE;
readonly isUploadForcePossible = true;
readonly maxConcurrentRequests = 4;
readonly privateCfg: SyncCredentialStorePort<
typeof PROVIDER_ID_ONEDRIVE,
OneDrivePrivateCfg
>;
private _ensuredFolderPath: string | null = null;
private _folderEnsureInFlightPath: string | null = null;
private _folderEnsureInFlightPromise: Promise<void> | null = null;
private _tokenRefreshInFlightPromise: Promise<string> | null = null;
private readonly _devPath?: string;
private readonly _deps: OneDriveDeps;
constructor(cfg: { devPath?: string }, deps: OneDriveDeps) {
this._devPath = cfg.devPath;
this._deps = deps;
this.privateCfg = deps.credentialStore;
}
async isReady(): Promise<boolean> {
const cfg = await this.privateCfg.load();
const resolvedClientId = this._resolveClientId(cfg || {});
return !!(resolvedClientId && cfg?.accessToken && cfg?.refreshToken);
}
async setPrivateCfg(privateCfg: OneDrivePrivateCfg): Promise<void> {
await this.privateCfg.setComplete(privateCfg);
}
async clearAuthCredentials(): Promise<void> {
const cfg = await this.privateCfg.load();
if (!cfg) {
return;
}
// Null without awaiting: clearAuthCredentials can be called from inside
// the refresh IIFE (invalid_grant path), so awaiting would deadlock.
// NOTE: an external concurrent call (e.g. user disconnect) has a narrow
// TOCTOU window — the in-flight IIFE may still overwrite cleared creds
// if its refresh succeeds. Risk is low in practice and would self-correct
// on the next sync cycle.
this._tokenRefreshInFlightPromise = null;
await this.privateCfg.setComplete({
...cfg,
accessToken: '',
refreshToken: '',
tokenExpiresAt: 0,
});
}
private async _clearIfConfigMatches(cfg: Partial<OneDrivePrivateCfg>): Promise<void> {
const currentCfg = await this.privateCfg.load();
if (
currentCfg?.refreshToken === cfg.refreshToken &&
currentCfg?.clientId === cfg.clientId &&
currentCfg?.tenantId === cfg.tenantId
) {
await this.clearAuthCredentials();
}
}
async getAuthHelper(): Promise<SyncProviderAuthHelper> {
const cfg = await this._cfgOrError();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const tenant = cfg.tenantId || ONEDRIVE_DEFAULTS.tenantId;
const redirectUri = this._getRedirectUri();
const state = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
this._deps.addOAuthState('onedrive', state);
const authUrl =
`https://login.microsoftonline.com/${encodeURIComponent(tenant)}/oauth2/v2.0/authorize` +
`?client_id=${encodeURIComponent(cfg.clientId)}` +
'&response_type=code' +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent(ONEDRIVE_PROTOCOL.scope)}` +
`&code_challenge=${encodeURIComponent(codeChallenge)}` +
'&code_challenge_method=S256' +
`&state=${encodeURIComponent(state)}`;
return {
authUrl,
codeVerifier,
verifyCodeChallenge: async <T>(authCode: string) => {
return (await this._exchangeAuthCode(authCode, codeVerifier, cfg)) as T;
},
};
}
async getFileRev(targetPath: string, _localRev: string): Promise<{ rev: string }> {
const cfg = await this._cfgOrError();
try {
const item = await this._requestJson<{ eTag?: string }>(
this._getDriveItemPath(targetPath, cfg),
);
return { rev: item.eTag || '' };
} catch (e) {
this._mapAndThrow(e);
}
throw new RemoteFileNotFoundAPIError();
}
async downloadFile(targetPath: string): Promise<{ rev: string; dataStr: string }> {
const cfg = await this._cfgOrError();
try {
const driveItemPath = this._getDriveItemPath(targetPath, cfg);
const response = await this._request({
method: 'GET',
path: `${driveItemPath}/content`,
});
const dataStr = await response.text();
const revFromContentHeaders = this._getResponseETag(response);
if (revFromContentHeaders) {
return {
rev: revFromContentHeaders,
dataStr,
};
}
const metadata = await this._requestJson<{ eTag?: string }>(driveItemPath);
return {
rev: metadata.eTag || '',
dataStr,
};
} catch (e) {
this._mapAndThrow(e);
}
throw new RemoteFileNotFoundAPIError();
}
async uploadFile(
targetPath: string,
dataStr: string,
revToMatch: string | null,
isForceOverwrite = false,
): Promise<{ rev: string }> {
const cfg = await this._cfgOrError();
try {
await this._ensureSyncFolderExistsCached(cfg);
const headers = new Headers();
headers.set('Content-Type', 'text/plain');
let uploadPath = `${this._getDriveItemPath(targetPath, cfg)}/content`;
if (!isForceOverwrite && revToMatch) {
headers.set('If-Match', revToMatch);
} else if (!isForceOverwrite && !revToMatch) {
// Microsoft documents conflictBehavior=fail as the way to prevent
// overwriting existing items on the small-upload endpoint.
// https://learn.microsoft.com/en-gb/onedrive/developer/rest-api/api/driveitem_put_content
uploadPath += '?@microsoft.graph.conflictBehavior=fail';
}
const response = await this._request({
method: 'PUT',
path: uploadPath,
headers,
body: dataStr,
});
const result = (await response.json()) as { eTag?: string };
if (!result.eTag) {
throw new NoRevAPIError('OneDrive upload missing eTag');
}
return { rev: result.eTag };
} catch (e) {
this._mapAndThrow(e);
}
throw new UploadRevToMatchMismatchAPIError();
}
async removeFile(targetPath: string): Promise<void> {
const cfg = await this._cfgOrError();
try {
await this._request({
method: 'DELETE',
path: this._getDriveItemPath(targetPath, cfg),
});
} catch (e) {
this._mapAndThrow(e);
}
}
async listFiles(dirPath: string): Promise<string[]> {
const cfg = await this._cfgOrError();
const names: string[] = [];
// Graph /children paginates by ~200 items; follow @odata.nextLink
// to collect the full listing. Missing entries would cause replay drift
// when the op-log folder grows beyond a single page.
let nextUrl: string | undefined = `${this._getDriveItemPath(dirPath, cfg)}/children`;
try {
while (nextUrl) {
const result: OneDriveListResponse =
await this._requestJson<OneDriveListResponse>(nextUrl);
for (const item of result.value || []) {
if (item.file && item.name) {
names.push(item.name);
}
}
// @odata.nextLink is a full URL — strip the Graph base prefix
// so _request can re-add it without doubling.
const raw: string | undefined = result['@odata.nextLink'];
nextUrl = raw?.startsWith(ONEDRIVE_PROTOCOL.graphApiBaseUrl)
? raw.slice(ONEDRIVE_PROTOCOL.graphApiBaseUrl.length)
: raw;
}
return names;
} catch (e) {
if (e instanceof RemoteFileNotFoundAPIError) {
return [];
}
if (e instanceof HttpNotOkAPIError && e.response.status === 404) {
return [];
}
throw e;
}
}
// ---- private helpers ----
private _getDriveItemPath(targetPath: string, cfg: OneDrivePrivateCfg): string {
const relativeTargetPath = this._normalizeRelativePath(targetPath);
const cfgPath = this._getSyncFolderPath(cfg);
const fullPath = this._joinPathSegments(cfgPath, relativeTargetPath);
const encodedPath = this._encodePath(fullPath);
return `/me/drive/special/approot:/${encodedPath}:`;
}
private _getSyncFolderPath(cfg: OneDrivePrivateCfg): string {
return this._joinPathSegments(this._devPath || '', cfg?.syncFolderPath || '');
}
private _normalizeRelativePath(path: string): string {
return path
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.join('/');
}
private _joinPathSegments(...parts: string[]): string {
return parts
.map((part) => this._normalizeRelativePath(part))
.filter(Boolean)
.join('/');
}
private _encodePath(path: string): string {
return this._normalizeRelativePath(path)
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
}
private _getResponseETag(response: Response): string {
return response.headers.get('etag') || response.headers.get('ETag') || '';
}
private async _ensureSyncFolderExistsCached(cfg: OneDrivePrivateCfg): Promise<void> {
const folderPath = this._getSyncFolderPath(cfg);
if (!folderPath) {
return;
}
if (this._ensuredFolderPath === folderPath) {
return;
}
if (
this._folderEnsureInFlightPromise &&
this._folderEnsureInFlightPath === folderPath
) {
await this._folderEnsureInFlightPromise;
return;
}
this._folderEnsureInFlightPath = folderPath;
this._folderEnsureInFlightPromise = this._ensureSyncFolderExists(cfg)
.then(() => {
this._ensuredFolderPath = folderPath;
})
.finally(() => {
this._folderEnsureInFlightPath = null;
this._folderEnsureInFlightPromise = null;
});
await this._folderEnsureInFlightPromise;
}
private async _ensureSyncFolderExists(cfg: OneDrivePrivateCfg): Promise<void> {
const folderPath = this._getSyncFolderPath(cfg);
if (!folderPath) {
return;
}
// Probe the full path first — if it exists, skip per-segment creation entirely.
const fullPath = `/me/drive/special/approot:/${this._encodePath(folderPath)}`;
try {
await this._request({ method: 'GET', path: fullPath });
return;
} catch (e) {
if (e instanceof HttpNotOkAPIError && e.response.status === 404) {
// Folder not found — fall through to create the folder chain.
} else {
throw e;
}
}
const segments = folderPath.split('/').filter(Boolean);
let currentPath = '';
for (const segment of segments) {
const parentPath = currentPath;
currentPath = parentPath ? `${parentPath}/${segment}` : segment;
const createPath = parentPath
? `/me/drive/special/approot:/${this._encodePath(parentPath)}:/children`
: '/me/drive/special/approot/children';
try {
await this._request({
method: 'POST',
path: createPath,
headers: new Headers({ 'Content-Type': 'application/json' }), // eslint-disable-line @typescript-eslint/naming-convention
body: JSON.stringify({
name: segment,
folder: {},
// eslint-disable-next-line @typescript-eslint/naming-convention
'@microsoft.graph.conflictBehavior': 'fail',
}),
});
} catch (e) {
const err = e as HttpNotOkAPIError;
if (err?.response?.status === 409) {
const parsed = this._parseGraphError(err.body);
if (parsed.code === 'nameAlreadyExists') {
continue;
}
}
throw e;
}
}
}
private async _cfgOrError(): Promise<OneDrivePrivateCfg> {
const cfg = (await this.privateCfg.load()) || ({} as Partial<OneDrivePrivateCfg>);
const resolvedClientId = this._resolveClientId(cfg);
if (!resolvedClientId) {
throw new Error('OneDrive clientId is required');
}
return {
...cfg,
useCustomApp:
cfg.useCustomApp !== undefined
? cfg.useCustomApp
: !this._deps.hasOfficialClientId,
clientId: resolvedClientId,
tenantId: cfg.tenantId || ONEDRIVE_DEFAULTS.tenantId,
syncFolderPath: cfg.syncFolderPath || ONEDRIVE_DEFAULTS.syncFolderPath,
};
}
private _resolveClientId(cfg: Partial<OneDrivePrivateCfg>): string | null {
if (cfg.useCustomApp === true) {
return cfg.clientId || null;
}
if (cfg.useCustomApp === false) {
return this._deps.officialClientId || cfg.clientId || null;
}
return cfg.clientId || this._deps.officialClientId || null;
}
private async _exchangeAuthCode(
authCode: string,
codeVerifier: string,
cfg: OneDrivePrivateCfg,
): Promise<OneDrivePrivateCfg> {
const tokenData = await this._requestOAuthToken(cfg, {
grantType: 'authorization_code',
authCode,
codeVerifier,
redirectUri: this._getRedirectUri(),
});
const expiresInMs = tokenData.expires_in * 1000;
return {
...cfg,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || cfg.refreshToken,
tokenExpiresAt: Date.now() + expiresInMs,
};
}
private async _refreshAccessTokenIfNeeded(cfg: OneDrivePrivateCfg): Promise<string> {
const expiresAt = cfg.tokenExpiresAt || 0;
if (
cfg.accessToken &&
Date.now() < expiresAt - ONEDRIVE_PROTOCOL.tokenRefreshSkewMs
) {
return cfg.accessToken;
}
if (!cfg.refreshToken) {
throw new MissingRefreshTokenAPIError();
}
if (this._tokenRefreshInFlightPromise) {
return this._tokenRefreshInFlightPromise;
}
this._tokenRefreshInFlightPromise = (async () => {
try {
const tokenData = await this._requestOAuthToken(cfg, {
grantType: 'refresh_token',
refreshToken: cfg.refreshToken,
});
const expiresInMs = tokenData.expires_in * 1000;
const currentCfg = await this.privateCfg.load();
if (
!currentCfg?.refreshToken ||
currentCfg.refreshToken !== cfg.refreshToken ||
currentCfg.clientId !== cfg.clientId ||
currentCfg.tenantId !== cfg.tenantId
) {
this._deps.logger.warn(
'[OneDrive] Credentials changed during token refresh, discarding refresh result',
);
// Throw a plain Error — MissingRefreshTokenAPIError would trigger
// clearAuthCredentials() in the 401-retry path, wiping the *newer* creds.
throw new Error('OneDrive: stale refresh discarded');
}
const updatedCfg: OneDrivePrivateCfg = {
...currentCfg,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || currentCfg.refreshToken,
tokenExpiresAt: Date.now() + expiresInMs,
};
await this.privateCfg.setComplete(updatedCfg);
return updatedCfg.accessToken || '';
} finally {
this._tokenRefreshInFlightPromise = null;
}
})();
return this._tokenRefreshInFlightPromise;
}
private _buildOAuthTokenUrl(tenantId: string): string {
return `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`;
}
private _buildOAuthTokenRequestBody(
cfg: OneDrivePrivateCfg,
req: OAuthTokenRequest,
): URLSearchParams {
const bodyBase = {
client_id: cfg.clientId,
scope: ONEDRIVE_PROTOCOL.scope,
};
if (req.grantType === 'authorization_code') {
return new URLSearchParams({
...bodyBase,
grant_type: 'authorization_code',
code: req.authCode || '',
redirect_uri: req.redirectUri || ONEDRIVE_PROTOCOL.redirectUri,
code_verifier: req.codeVerifier || '',
});
}
return new URLSearchParams({
...bodyBase,
grant_type: 'refresh_token',
refresh_token: req.refreshToken || '',
});
}
private async _requestOAuthToken(
cfg: OneDrivePrivateCfg,
req: OAuthTokenRequest,
): Promise<OneDriveTokenResponse> {
const tenant = cfg.tenantId || ONEDRIVE_DEFAULTS.tenantId;
const response = await this._deps.webFetch()(this._buildOAuthTokenUrl(tenant), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // eslint-disable-line @typescript-eslint/naming-convention
body: this._buildOAuthTokenRequestBody(cfg, req),
});
if (!response.ok) {
const body = await response.text();
if (response.status === 400) {
let parsed: { error?: string } | null = null;
try {
parsed = JSON.parse(body) as { error?: string };
} catch {
/* not JSON */
}
if (parsed?.error === 'invalid_grant') {
if (req.grantType === 'refresh_token') {
this._deps.logger.warn(
'[OneDrive] Refresh token revoked (invalid_grant), clearing credentials',
);
await this._clearIfConfigMatches({
refreshToken: req.refreshToken,
clientId: cfg.clientId,
tenantId: cfg.tenantId,
});
throw new MissingRefreshTokenAPIError();
}
}
}
if (response.status === 401 && req.grantType === 'refresh_token') {
this._deps.logger.warn(
'[OneDrive] Token endpoint returned 401, clearing credentials',
);
await this._clearIfConfigMatches({
refreshToken: req.refreshToken,
clientId: cfg.clientId,
tenantId: cfg.tenantId,
});
throw new MissingRefreshTokenAPIError();
}
const redactedBody = this._redactTokenBody(body);
throw new HttpNotOkAPIError(response, redactedBody);
}
return (await response.json()) as OneDriveTokenResponse;
}
private _getRedirectUri(): string {
if (!this._deps.isElectron) {
return ONEDRIVE_PROTOCOL.redirectUri;
}
return ONEDRIVE_PROTOCOL.electronRedirectUri;
}
private async _request(options: ApiRequestOptions, isRetry = false): Promise<Response> {
const cfg = await this._cfgOrError();
const accessToken = await this._refreshAccessTokenIfNeeded(cfg);
const isUploadRequest = options.method === 'PUT' && options.path.endsWith('/content');
this._deps.logger.normal('OneDrive.request', {
method: options.method,
isUpload: isUploadRequest,
});
const requestHeaders = new Headers(options.headers);
requestHeaders.set('Authorization', `Bearer ${accessToken}`);
const response = await this._deps.webFetch()(
`${ONEDRIVE_PROTOCOL.graphApiBaseUrl}${options.path}`,
{
method: options.method,
headers: requestHeaders,
body: options.body,
},
);
const responseBody = response.ok ? '' : await response.text();
if (!response.ok) {
const parsed = this._parseGraphError(responseBody);
this._deps.logger.normal('OneDrive request error', {
status: response.status,
code: parsed.code || undefined,
});
}
if (response.status === 401) {
if (!isRetry) {
try {
await this._refreshAccessTokenIfNeeded({
...cfg,
tokenExpiresAt: 0,
});
return this._request(options, true);
} catch (refreshErr) {
// Only clear credentials for permanent auth failures, not transient ones.
// Stale-refresh (plain Error) is ignored — newer creds may exist.
if (
refreshErr instanceof MissingRefreshTokenAPIError ||
refreshErr instanceof AuthFailSPError
) {
await this._clearIfConfigMatches(cfg);
}
throw refreshErr;
}
}
await this._clearIfConfigMatches(cfg);
throw new AuthFailSPError('OneDrive 401');
}
if (response.status === 403) {
const parsed = this._parseGraphError(responseBody);
if (parsed.code === 'InvalidAuthenticationToken') {
await this._clearIfConfigMatches(cfg);
}
}
if (!response.ok) {
throw new HttpNotOkAPIError(response, this._redactResponseBody(responseBody));
}
return response;
}
private async _requestJson<T>(path: string): Promise<T> {
const response = await this._request({ method: 'GET', path });
return (await response.json()) as T;
}
private _parseGraphError(body?: string): ParsedGraphError {
if (!body) {
return {};
}
try {
const parsed = JSON.parse(body) as {
error?: { code?: string; message?: string };
};
return {
code: parsed.error?.code,
message: parsed.error?.message,
};
} catch {
return {};
}
}
private _mapAndThrow(error: unknown): never {
if (error instanceof RemoteFileNotFoundAPIError) {
throw error;
}
if (error instanceof AuthFailSPError) {
throw error;
}
if (error instanceof HttpNotOkAPIError) {
const status = error.response.status;
if (status === 404) {
this._ensuredFolderPath = null;
throw new RemoteFileNotFoundAPIError();
}
if (status === 429) {
throw new TooManyRequestsAPIError({
status,
});
}
if (status === 409 || status === 412) {
throw new UploadRevToMatchMismatchAPIError();
}
if (status === 401 || status === 403) {
throw new AuthFailSPError(`OneDrive auth failed (status=${status})`);
}
}
throw error;
}
private _redactTokenBody(body: string): string {
return this._redactSensitiveFields(body);
}
private _redactResponseBody(body: string): string {
return this._redactSensitiveFields(body);
}
private _redactSensitiveFields(body: string): string {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
const sensitiveKeys = [
'code',
'code_verifier',
'refresh_token',
'access_token',
'id_token',
];
let changed = false;
for (const key of sensitiveKeys) {
if (key in parsed) {
parsed[key] = '[REDACTED]';
changed = true;
}
}
return changed ? JSON.stringify(parsed) : body;
} catch {
return body;
}
}
}
interface ApiRequestOptions {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
headers?: HeadersInit;
body?: string;
}
interface ParsedGraphError {
code?: string;
message?: string;
}
interface OAuthTokenRequest {
grantType: 'authorization_code' | 'refresh_token';
authCode?: string;
codeVerifier?: string;
refreshToken?: string;
redirectUri?: string;
}

View file

@ -0,0 +1,11 @@
export {
OneDrive,
PROVIDER_ID_ONEDRIVE,
type OneDriveDeps,
} from './file-based/onedrive/onedrive';
export type {
OneDrivePrivateCfg,
OneDriveItem,
OneDriveListResponse,
OneDriveTokenResponse,
} from './file-based/onedrive/onedrive.model';

View file

@ -6,6 +6,7 @@ export default defineConfig({
'src/webdav.ts',
'src/local-file.ts',
'src/super-sync.ts',
'src/onedrive.ts',
'src/http.ts',
'src/errors.ts',
'src/credential-store.ts',

View file

@ -51,7 +51,7 @@
<button
class="sync-btn"
matTooltip="{{ T.MH.TRIGGER_SYNC | translate }}"
(click)="syncIsEnabledAndReady() ? sync() : setupSync()"
(click)="onSyncButtonClick()"
(longPress)="setupSync()"
(contextmenu)="$event.preventDefault(); setupSync()"
mat-icon-button

View file

@ -297,6 +297,15 @@ export class MainHeaderComponent implements OnDestroy {
});
}
onSyncButtonClick(): void {
const ready = !!this.syncIsEnabledAndReady();
if (ready) {
this.sync();
} else {
this.setupSync();
}
}
private dialogSyncCfgRef: MatDialogRef<unknown> | null = null;
async setupSync(): Promise<void> {

View file

@ -1,4 +1,8 @@
import { environment } from '../../../environments/environment';
import {
HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
OFFICIAL_ONEDRIVE_CLIENT_ID,
} from '../../imex/sync/onedrive-auth-mode.const';
import { TaskReminderOptionId } from '../tasks/task.model';
import { GlobalConfigState } from './global-config.model';
@ -246,5 +250,12 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
password: null,
syncFolderPath: 'super-productivity',
},
oneDrive: {
useCustomApp: !HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
clientId: OFFICIAL_ONEDRIVE_CLIENT_ID,
tenantId: 'common',
syncFolderPath: 'Super Productivity',
},
},
} as const;

View file

@ -28,6 +28,10 @@ import {
openEncryptionPasswordChangeDialog,
openEncryptionPasswordChangeDialogForFileBased,
} from '../../../imex/sync/encryption-password-dialog-opener.service';
import {
HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
IS_ONEDRIVE_SUPPORTED,
} from '../../../imex/sync/onedrive-auth-mode.const';
/**
* Creates form fields for WebDAV-based sync providers.
@ -119,6 +123,17 @@ const createWebdavFormFields = (options: {
];
};
const isOneDriveClientIdRequired = (field: FormlyFieldConfig): boolean => {
if (field?.parent?.parent?.model?.syncProvider !== SyncProviderId.OneDrive) {
return false;
}
// If no official app client ID is available, users must provide their own.
// If an official app client ID is available, clientId is only required when
// users explicitly switch to custom app mode.
return !HAS_OFFICIAL_ONEDRIVE_CLIENT_ID || !!field?.parent?.model?.useCustomApp;
};
export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
title: T.F.SYNC.FORM.TITLE,
key: 'sync',
@ -141,6 +156,9 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
options: [
{ label: 'SuperSync (Beta)', value: SyncProviderId.SuperSync },
{ label: SyncProviderId.Dropbox, value: SyncProviderId.Dropbox },
...(IS_ONEDRIVE_SUPPORTED
? [{ label: 'Microsoft 365 (OneDrive)', value: SyncProviderId.OneDrive }]
: []),
{ label: 'Nextcloud', value: SyncProviderId.Nextcloud },
{
label: 'WebDAV (not recommended / no support)',
@ -339,6 +357,82 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
},
],
},
// OneDrive provider form fields
{
hideExpression: (m, v, field) =>
field?.parent?.model.syncProvider !== SyncProviderId.OneDrive,
resetOnHide: false,
key: 'oneDrive',
fieldGroup: [
...(HAS_OFFICIAL_ONEDRIVE_CLIENT_ID
? [
{
type: 'tpl',
hideExpression: (m: any) => !!m?.useCustomApp,
templateOptions: {
tag: 'p',
text: T.F.SYNC.FORM.ONEDRIVE.OFFICIAL_MODE_INFO,
},
},
{
key: 'useCustomApp',
type: 'checkbox',
defaultValue: false,
templateOptions: {
label: T.F.SYNC.FORM.ONEDRIVE.L_USE_CUSTOM_APP,
description: T.F.SYNC.FORM.ONEDRIVE.USE_CUSTOM_APP_DESCRIPTION,
},
},
]
: []),
{
key: 'clientId',
type: 'input',
hideExpression: (m: any) => HAS_OFFICIAL_ONEDRIVE_CLIENT_ID && !m?.useCustomApp,
templateOptions: {
label: T.F.SYNC.FORM.ONEDRIVE.L_CLIENT_ID,
description: T.F.SYNC.FORM.ONEDRIVE.CLIENT_ID_DESCRIPTION,
},
expressions: {
'props.required': (field: FormlyFieldConfig) =>
isOneDriveClientIdRequired(field),
},
},
{
key: 'tenantId',
type: 'input',
templateOptions: {
label: T.F.SYNC.FORM.ONEDRIVE.L_TENANT_ID,
description: T.F.SYNC.FORM.ONEDRIVE.TENANT_ID_DESCRIPTION,
},
},
{
key: 'syncFolderPath',
type: 'input',
templateOptions: {
label: T.F.SYNC.FORM.ONEDRIVE.L_SYNC_FOLDER_PATH,
description: T.F.SYNC.FORM.ONEDRIVE.SYNC_FOLDER_PATH_DESCRIPTION,
},
},
],
},
// OneDrive provider authentication panel
{
hideExpression: (m, v, field) =>
field?.parent?.model.syncProvider !== SyncProviderId.OneDrive,
resetOnHide: false,
props: {},
fieldGroup: [
{
type: 'tpl',
templateOptions: {
tag: 'p',
text: T.F.SYNC.FORM.ONEDRIVE.INFO_TEXT,
},
},
],
},
// Encryption status box - shown when encryption is enabled (for any provider)
{

View file

@ -160,6 +160,14 @@ export interface NextcloudConfig {
syncFolderPath?: string | null;
}
export interface OneDriveConfig {
/** View-model only: false = use built-in official app (if available), true = use custom app */
useCustomApp?: boolean | null;
clientId?: string | null;
tenantId?: string | null;
syncFolderPath?: string | null;
}
export interface LocalFileSyncConfig {
// TODO remove and migrate
syncFilePath?: string | null;
@ -209,6 +217,8 @@ export type SyncConfig = Readonly<{
localFileSync?: LocalFileSyncConfig;
/* NOTE: view model for form only*/
nextcloud?: NextcloudConfig;
/* NOTE: view model for form only*/
oneDrive?: OneDriveConfig;
}>;
export type ScheduleConfig = Readonly<{

View file

@ -23,6 +23,8 @@ import { OAuthCallbackHandlerService } from '../oauth-callback-handler.service';
import { Subscription } from 'rxjs';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { SnackService } from '../../../core/snack/snack.service';
import { IS_ELECTRON } from '../../../app.constants';
import { validateOAuthState } from '../oauth-state.util';
@Component({
selector: 'dialog-get-and-enter-auth-code',
@ -68,35 +70,37 @@ export class DialogGetAndEnterAuthCodeComponent implements OnDestroy {
// field is rendered at the top of the dialog — keeps the field above the
// iOS on-screen keyboard (discussion #7340).
readonly codeRequested = signal(false);
readonly isElectron = IS_ELECTRON;
private _authCodeSub?: Subscription;
constructor() {
this._matDialogRef.disableClose = true;
// On mobile, listen for OAuth callback
if (this.isNativePlatform) {
// Listen for OAuth callback on native and Electron for automatic auth completion.
if (this.isNativePlatform || this.isElectron) {
const expectedProvider = this.data.providerName.toLowerCase();
this._authCodeSub = this._oauthCallbackHandler.authCodeReceived$.subscribe(
(data) => {
if (data.provider === 'dropbox') {
if (data.error) {
// Handle error from OAuth provider
const errorMsg = data.error_description || data.error;
this._snackService.open({
type: 'ERROR',
msg: `Authentication failed: ${errorMsg}`,
});
this.close();
} else if (data.code) {
this.token = data.code;
this.close(this.token);
} else {
// Unexpected case - no code and no error
this._snackService.open({
type: 'ERROR',
msg: 'Authentication failed: No authorization code received',
});
this.close();
}
if (data.provider !== expectedProvider) {
return;
}
if (data.error) {
const errorMsg = data.error_description || data.error;
this._snackService.open({
type: 'ERROR',
msg: `Authentication failed: ${errorMsg}`,
});
this.close();
} else if (data.code) {
this.token = data.code;
this.close(this.token);
} else {
this._snackService.open({
type: 'ERROR',
msg: 'Authentication failed: No authorization code received',
});
this.close();
}
},
);
@ -108,7 +112,63 @@ export class DialogGetAndEnterAuthCodeComponent implements OnDestroy {
}
close(token?: string): void {
this._matDialogRef.close(token?.trim());
this._matDialogRef.close(this._normalizeAuthCodeInput(token));
}
private _normalizeAuthCodeInput(token?: string): string | undefined {
const trimmed = token?.trim();
if (!trimmed) {
return undefined;
}
let codeFromInput: string | undefined;
// Allow pasting the full callback URL and extract `code` automatically.
try {
const parsedUrl = new URL(trimmed);
codeFromInput = parsedUrl.searchParams.get('code') ?? undefined;
// Validate state parameter when present (CSRF protection for manual paste).
// For OneDrive full-URL paste, state is required — missing state means the
// callback URL is malformed or attacker-crafted. Raw code-only paste (no URL)
// is fine: it won't match the PKCE verifier on token exchange.
const stateFromUrl = parsedUrl.searchParams.get('state');
if (this.data.providerName.toLowerCase() === 'onedrive') {
if (!stateFromUrl) {
this._snackService.open({
type: 'ERROR',
msg: 'OAuth state missing from callback URL. Please try again.',
});
return undefined;
}
if (!validateOAuthState('onedrive', stateFromUrl)) {
this._snackService.open({
type: 'ERROR',
msg: 'OAuth state validation failed. Please try again.',
});
return undefined;
}
}
} catch {
// Not a URL, continue with other extraction attempts.
}
if (codeFromInput) {
return codeFromInput;
}
// An attacker-supplied code is harmless here — it won't match the user's
// PKCE verifier, so the token exchange will fail with invalid_grant.
const codeMatch = trimmed.match(/(?:^|[?&#])code=([^&#]+)/i);
if (codeMatch?.[1]) {
try {
return decodeURIComponent(codeMatch[1]);
} catch {
return codeMatch[1];
}
}
return trimmed;
}
// iOS Safari only opens the keyboard when .focus() runs synchronously in the

View file

@ -47,6 +47,7 @@ import {
type WebdavPrivateCfg,
} from '@sp/sync-providers/webdav';
import { testWebdavConnection } from '../../../op-log/sync-providers/file-based/webdav/test-webdav-connection';
import type { OneDrivePrivateCfg } from '../../../op-log/sync-providers/file-based/onedrive/onedrive.model';
@Component({
selector: 'dialog-sync-cfg',
@ -207,7 +208,7 @@ export class DialogSyncCfgComponent implements AfterViewInit {
// in lockstep with the provider-side definition without an async probe.
private _reauthBtn(): FormlyFieldConfig {
return this._actionBtn({
text: T.F.SYNC.FORM.DROPBOX.BTN_REAUTHENTICATE,
text: T.F.SYNC.FORM.BTN_REAUTHENTICATE,
onClick: () => this.reauth(),
hideExpression: (m, v, field) => {
const id = field?.parent?.parent?.model?.syncProvider as
@ -381,6 +382,11 @@ export class DialogSyncCfgComponent implements AfterViewInit {
providerSpecificUpdate = {
encryptKey: privateCfg.encryptKey || '',
};
} else if (newProvider === SyncProviderId.OneDrive && privateCfg) {
providerSpecificUpdate = {
oneDrive: privateCfg as any,
encryptKey: privateCfg.encryptKey || '',
};
}
// Update the model, preserving non-provider-specific fields
@ -412,6 +418,45 @@ export class DialogSyncCfgComponent implements AfterViewInit {
this._matDialogRef.close();
}
private async _persistOneDriveFormCfgBeforeAuth(
providerId: SyncProviderId,
): Promise<void> {
if (providerId !== SyncProviderId.OneDrive) {
return;
}
const oneDriveProvider = await this._providerManager.getProviderById(providerId);
if (oneDriveProvider) {
const existingCfg = (await oneDriveProvider.privateCfg.load()) as
| OneDrivePrivateCfg
| null
| undefined;
const formOneDriveCfg = this._tmpUpdatedCfg.oneDrive || {};
// If useCustomApp / clientId / tenantId changed, existing tokens are
// bound to the old identity — clear them to force a fresh OAuth flow.
let identityChanged = !existingCfg;
if (existingCfg && !identityChanged) {
const formClientId = formOneDriveCfg.clientId ?? existingCfg.clientId;
const formTenantId = formOneDriveCfg.tenantId ?? existingCfg.tenantId;
const formUseCustomApp = formOneDriveCfg.useCustomApp ?? existingCfg.useCustomApp;
identityChanged =
formUseCustomApp !== existingCfg.useCustomApp ||
formClientId !== existingCfg.clientId ||
formTenantId !== existingCfg.tenantId;
}
const mergedCfg: OneDrivePrivateCfg = {
...(existingCfg || {}),
...formOneDriveCfg,
...(identityChanged
? { accessToken: '', refreshToken: '', tokenExpiresAt: 0 }
: {}),
} as OneDrivePrivateCfg;
await oneDriveProvider.privateCfg.setComplete(mergedCfg);
}
}
async save(): Promise<void> {
// Check if form is valid
if (!this.form.valid) {
@ -438,6 +483,10 @@ export class DialogSyncCfgComponent implements AfterViewInit {
const providerId = toSyncProviderId(this._tmpUpdatedCfg.syncProvider);
if (providerId && this._tmpUpdatedCfg.isEnabled) {
if (providerId === SyncProviderId.OneDrive) {
await this._persistOneDriveFormCfgBeforeAuth(providerId);
}
await this.syncWrapperService.configuredAuthForSyncProviderIfNecessary(providerId);
// If the provider requires auth (e.g. Dropbox) and is still not ready,
@ -470,6 +519,10 @@ export class DialogSyncCfgComponent implements AfterViewInit {
return;
}
try {
if (providerId === SyncProviderId.OneDrive) {
await this._persistOneDriveFormCfgBeforeAuth(providerId);
}
const result =
await this.syncWrapperService.configuredAuthForSyncProviderIfNecessary(
providerId,
@ -478,7 +531,7 @@ export class DialogSyncCfgComponent implements AfterViewInit {
if (result.wasConfigured) {
this._snackService.open({
type: 'SUCCESS',
msg: T.F.SYNC.FORM.DROPBOX.REAUTH_SUCCESS,
msg: T.F.SYNC.FORM.REAUTH_SUCCESS,
});
}
} catch (e) {

View file

@ -15,7 +15,7 @@ describe('OAuthCallbackHandlerService', () => {
const result = service['_parseOAuthCallback'](url);
expect(result.code).toBe('ABC123');
expect(result.provider).toBe('dropbox');
expect(result.provider).toBe('unknown');
expect(result.error).toBeUndefined();
});
@ -27,7 +27,7 @@ describe('OAuthCallbackHandlerService', () => {
expect(result.code).toBeUndefined();
expect(result.error).toBe('access_denied');
expect(result.error_description).toBe('User denied access');
expect(result.provider).toBe('dropbox');
expect(result.provider).toBe('unknown');
});
it('should handle URL without code or error', () => {
@ -36,7 +36,7 @@ describe('OAuthCallbackHandlerService', () => {
expect(result.code).toBeUndefined();
expect(result.error).toBeUndefined();
expect(result.provider).toBe('dropbox');
expect(result.provider).toBe('unknown');
});
it('should handle malformed URL', () => {
@ -45,7 +45,7 @@ describe('OAuthCallbackHandlerService', () => {
expect(result.error).toBe('parse_error');
expect(result.error_description).toBe('Failed to parse OAuth callback URL');
expect(result.provider).toBe('dropbox');
expect(result.provider).toBe('unknown');
});
it('should decode URL-encoded parameters', () => {

View file

@ -3,14 +3,19 @@ import { Subject } from 'rxjs';
import { App, URLOpenListenerEvent } from '@capacitor/app';
import { PluginListenerHandle } from '@capacitor/core';
import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform';
import { IS_ELECTRON } from '../../app.constants';
import { SyncLog } from '../../core/log';
import { PluginOAuthService } from '../../plugins/oauth/plugin-oauth.service';
import { IPC } from '../../../../electron/shared-with-frontend/ipc-events.const';
import { validateOAuthState } from './oauth-state.util';
type OAuthProvider = 'dropbox' | 'onedrive' | 'plugin' | 'unknown';
export interface OAuthCallbackData {
code?: string;
error?: string;
error_description?: string;
provider: 'dropbox' | 'plugin';
provider: OAuthProvider;
}
@Injectable({
@ -20,6 +25,7 @@ export class OAuthCallbackHandlerService implements OnDestroy {
private _pluginOAuthService = inject(PluginOAuthService);
private _authCodeReceived$ = new Subject<OAuthCallbackData>();
private _urlListenerHandle?: PluginListenerHandle;
private _isDestroyed = false;
readonly authCodeReceived$ = this._authCodeReceived$.asObservable();
@ -27,9 +33,13 @@ export class OAuthCallbackHandlerService implements OnDestroy {
if (IS_NATIVE_PLATFORM) {
this._setupAppUrlListener();
}
if (IS_ELECTRON && typeof window !== 'undefined' && !!window.ea?.on) {
this._setupElectronOAuthListener();
}
}
ngOnDestroy(): void {
this._isDestroyed = true;
this._urlListenerHandle?.remove();
this._authCodeReceived$.complete();
}
@ -38,11 +48,14 @@ export class OAuthCallbackHandlerService implements OnDestroy {
this._urlListenerHandle = await App.addListener(
'appUrlOpen',
(event: URLOpenListenerEvent) => {
SyncLog.log('OAuthCallbackHandler: Received URL', event.url);
SyncLog.log('OAuthCallbackHandler: Received URL');
if (event.url.includes('plugin-oauth-callback')) {
this._handlePluginOAuthCallback(event.url);
} else if (event.url.startsWith('com.super-productivity.app://oauth-callback')) {
} else if (
event.url.startsWith('com.super-productivity.app://oauth-callback') ||
event.url.startsWith('superproductivity://oauth-callback')
) {
const callbackData = this._parseOAuthCallback(event.url);
if (callbackData.code) {
@ -54,7 +67,7 @@ export class OAuthCallbackHandlerService implements OnDestroy {
callbackData.error_description,
);
} else {
SyncLog.warn('OAuthCallbackHandler: No auth code or error in URL', event.url);
SyncLog.warn('OAuthCallbackHandler: No auth code or error in URL');
}
this._authCodeReceived$.next(callbackData);
@ -63,25 +76,82 @@ export class OAuthCallbackHandlerService implements OnDestroy {
);
}
private _setupElectronOAuthListener(): void {
window.ea.on(IPC.OAUTH_CALLBACK, (_event, payload) => {
if (this._isDestroyed) {
return;
}
const callbackUrl =
typeof payload === 'string'
? payload
: (payload as { url?: string } | undefined)?.url;
if (!callbackUrl) {
SyncLog.warn('OAuthCallbackHandler: Missing callback URL payload from Electron');
return;
}
if (!callbackUrl.startsWith('superproductivity://oauth-callback')) {
SyncLog.warn(
'OAuthCallbackHandler: Rejected callback URL with unexpected scheme',
callbackUrl.split(':')[0],
);
return;
}
SyncLog.log('OAuthCallbackHandler: Received Electron OAuth callback URL');
this._authCodeReceived$.next(this._parseOAuthCallback(callbackUrl));
});
}
private _parseOAuthCallback(url: string): OAuthCallbackData {
try {
const urlObj = new URL(url);
const code = urlObj.searchParams.get('code');
const error = urlObj.searchParams.get('error');
const errorDescription = urlObj.searchParams.get('error_description');
const state = urlObj.searchParams.get('state');
const pathParts = urlObj.pathname.split('/').filter(Boolean);
const providerFromPath = pathParts[0]?.toLowerCase();
const providerFromQuery = urlObj.searchParams.get('provider')?.toLowerCase();
const providerRaw = providerFromPath || providerFromQuery;
// Validate state for OneDrive CSRF protection.
let provider: OAuthProvider;
if (providerRaw === 'onedrive') {
const stateValid = validateOAuthState('onedrive', state);
if (!stateValid) {
SyncLog.warn(
'OAuthCallbackHandler: Invalid or missing state for OneDrive callback',
);
return {
error: 'invalid_state',
error_description: 'OAuth state validation failed',
provider: 'onedrive',
};
}
provider = 'onedrive';
} else if (providerRaw === 'dropbox') {
provider = 'dropbox';
} else if (providerRaw === 'plugin') {
provider = 'plugin';
} else {
SyncLog.warn('OAuthCallbackHandler: Unknown provider in callback', providerRaw);
provider = 'unknown';
}
return {
code: code || undefined,
error: error || undefined,
error_description: errorDescription || undefined,
provider: 'dropbox',
provider,
};
} catch (e) {
SyncLog.err('OAuthCallbackHandler: Failed to parse URL', url, e);
SyncLog.err('OAuthCallbackHandler: Failed to parse URL');
return {
error: 'parse_error',
error_description: 'Failed to parse OAuth callback URL',
provider: 'dropbox',
provider: 'unknown',
};
}
}
@ -100,7 +170,7 @@ export class OAuthCallbackHandlerService implements OnDestroy {
SyncLog.warn('OAuthCallbackHandler: Plugin OAuth error', error);
this._pluginOAuthService.handleRedirectError(error, state);
} else {
SyncLog.warn('OAuthCallbackHandler: No code or error in plugin OAuth URL', url);
SyncLog.warn('OAuthCallbackHandler: No code or error in plugin OAuth URL');
this._pluginOAuthService.handleRedirectError('no_code_or_error', state);
}
} catch (e) {

View file

@ -0,0 +1,38 @@
const TOKEN_STATE_VALIDITY_MS = 10 * 60 * 1000; // 10 minutes
// OAuth state storage for CSRF protection: state -> { provider, expiresAt }
const OAUTH_STATES_MAP = new Map<string, { provider: string; expiresAt: number }>();
const _pruneExpiredOAuthStates = (): void => {
const now = Date.now();
for (const [state, data] of OAUTH_STATES_MAP.entries()) {
if (now > data.expiresAt) {
OAUTH_STATES_MAP.delete(state);
}
}
};
/**
* Store an OAuth state for a provider to protect against CSRF.
*/
export const addOAuthState = (provider: string, state: string): void => {
_pruneExpiredOAuthStates();
OAUTH_STATES_MAP.set(state, {
provider,
expiresAt: Date.now() + TOKEN_STATE_VALIDITY_MS,
});
};
/**
* Validate an OAuth state parameter against stored states for a given provider.
* Returns true if state is valid, false otherwise.
* Consuming the state removes it (one-time use).
*/
export const validateOAuthState = (provider: string, state: string | null): boolean => {
_pruneExpiredOAuthStates();
if (!state) return false;
const stored = OAUTH_STATES_MAP.get(state);
if (!stored || stored.provider !== provider) return false;
OAUTH_STATES_MAP.delete(state);
return true;
};

View file

@ -0,0 +1,10 @@
import { getEnvOptional } from '../../util/env';
import { IS_ELECTRON } from '../../app.constants';
import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform';
const _rawOfficialClientId = getEnvOptional('ONEDRIVE_CLIENT_ID') || '';
export const OFFICIAL_ONEDRIVE_CLIENT_ID = _rawOfficialClientId || null;
export const HAS_OFFICIAL_ONEDRIVE_CLIENT_ID = !!_rawOfficialClientId;
export const IS_ONEDRIVE_SUPPORTED = IS_ELECTRON || IS_NATIVE_PLATFORM;

View file

@ -98,12 +98,14 @@ describe('SyncConfigService', () => {
await service.updateSettingsFromForm(settings);
// Should only pass non-private data to global config
// Should only pass non-private data to global config.
// Optional booleans that were not set in the form are omitted entirely,
// so partial form updates don't silently overwrite prior true values.
expect(globalConfigService.updateSection).toHaveBeenCalledWith('sync', {
isEnabled: true,
isEncryptionEnabled: true,
syncProvider: SyncProviderId.WebDAV,
syncInterval: 300000,
isEncryptionEnabled: true,
});
});

View file

@ -14,6 +14,7 @@ import { SyncLog } from '../../core/log';
import { clearSessionKeyCache } from '@sp/sync-core';
import type { SuperSyncPrivateCfg } from '@sp/sync-providers/super-sync';
import { SyncWrapperService } from './sync-wrapper.service';
import { HAS_OFFICIAL_ONEDRIVE_CLIENT_ID } from './onedrive-auth-mode.const';
// Maps sync providers to their corresponding form field in SyncConfig
// Dropbox is null because it doesn't store settings in the form (uses OAuth)
@ -22,6 +23,7 @@ const PROP_MAP_TO_FORM: Record<SyncProviderId, keyof SyncConfig | null> = {
[SyncProviderId.WebDAV]: 'webDav',
[SyncProviderId.SuperSync]: 'superSync',
[SyncProviderId.Nextcloud]: 'nextcloud',
[SyncProviderId.OneDrive]: 'oneDrive',
[SyncProviderId.Dropbox]: null,
};
@ -65,7 +67,7 @@ const redactSensitiveFields = (obj: unknown): unknown => {
const PROVIDER_FIELD_DEFAULTS: Record<
SyncProviderId,
Record<string, string | boolean>
Record<string, string | boolean | number>
> = {
[SyncProviderId.WebDAV]: {
baseUrl: '',
@ -98,6 +100,16 @@ const PROVIDER_FIELD_DEFAULTS: Record<
[SyncProviderId.Dropbox]: {
encryptKey: '',
},
[SyncProviderId.OneDrive]: {
useCustomApp: !HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
clientId: '',
tenantId: 'common',
syncFolderPath: 'Super Productivity',
accessToken: '',
refreshToken: '',
tokenExpiresAt: 0,
encryptKey: '',
},
};
@Injectable({
@ -174,6 +186,10 @@ export class SyncConfigService {
...DEFAULT_GLOBAL_CONFIG.sync.nextcloud,
...syncCfg?.nextcloud,
},
oneDrive: {
...DEFAULT_GLOBAL_CONFIG.sync.oneDrive,
...syncCfg?.oneDrive,
},
};
// If no provider is active, return base config with empty encryption key
@ -227,6 +243,7 @@ export class SyncConfigService {
webDav: DEFAULT_GLOBAL_CONFIG.sync.webDav,
superSync: DEFAULT_GLOBAL_CONFIG.sync.superSync,
nextcloud: DEFAULT_GLOBAL_CONFIG.sync.nextcloud,
oneDrive: DEFAULT_GLOBAL_CONFIG.sync.oneDrive,
};
// Add current provider config if applicable
@ -295,12 +312,30 @@ export class SyncConfigService {
this._lastSettings = newSettings;
const providerId = newSettings.syncProvider as SyncProviderId | null;
type SyncPublicConfig = Omit<
SyncConfig,
'encryptKey' | 'webDav' | 'localFileSync' | 'superSync' | 'nextcloud' | 'oneDrive'
>;
// Split settings into public (global config) and private (credentials/secrets)
// to maintain security boundaries - credentials never go to global config
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { encryptKey, webDav, localFileSync, superSync, nextcloud, ...globalConfig } =
newSettings;
const superSync = newSettings.superSync;
// Only include optional booleans when explicitly set, so partial form
// updates don't silently overwrite prior true values with undefined.
let globalConfig: SyncPublicConfig = {
isEnabled: newSettings.isEnabled ?? false,
syncProvider: newSettings.syncProvider ?? null,
syncInterval: newSettings.syncInterval ?? 300000,
...(newSettings.isEncryptionEnabled !== undefined
? { isEncryptionEnabled: newSettings.isEncryptionEnabled }
: {}),
...(newSettings.isCompressionEnabled !== undefined
? { isCompressionEnabled: newSettings.isCompressionEnabled }
: {}),
...(newSettings.isManualSyncOnly !== undefined
? { isManualSyncOnly: newSettings.isManualSyncOnly }
: {}),
};
// Provider-specific settings (URLs, credentials) must be stored securely
if (providerId) {
await this._updatePrivateConfig(providerId, newSettings);
@ -319,10 +354,13 @@ export class SyncConfigService {
(savedPrivateCfg as { isEncryptionEnabled?: boolean } | null)
?.isEncryptionEnabled ??
false;
globalConfig.isEncryptionEnabled = isEncryptionEnabled;
globalConfig = {
...globalConfig,
isEncryptionEnabled,
};
}
this._globalConfigService.updateSection('sync', globalConfig);
this._globalConfigService.updateSection('sync', globalConfig as SyncConfig);
}
private async _updatePrivateConfig(

View file

@ -64,6 +64,7 @@ export interface LocalSyncMetaForProvider {
export interface LocalSyncMetaModel {
[SyncProviderId.WebDAV]: LocalSyncMetaForProvider;
[SyncProviderId.Dropbox]: LocalSyncMetaForProvider;
[SyncProviderId.OneDrive]: LocalSyncMetaForProvider;
[SyncProviderId.LocalFile]: LocalSyncMetaForProvider;
}

View file

@ -81,11 +81,13 @@ import type { DropboxPrivateCfg } from '@sp/sync-providers/dropbox';
import type { LocalFileSyncPrivateCfg as PackageLocalFileSyncPrivateCfg } from '@sp/sync-providers/local-file';
import type { NextcloudPrivateCfg, WebdavPrivateCfg } from '@sp/sync-providers/webdav';
import type { SuperSyncPrivateCfg } from '@sp/sync-providers/super-sync';
import type { OneDrivePrivateCfg } from '@sp/sync-providers/onedrive';
export type LocalFileSyncPrivateCfg = PackageLocalFileSyncPrivateCfg;
export type SyncProviderPrivateCfg =
| DropboxPrivateCfg
| OneDrivePrivateCfg
| WebdavPrivateCfg
| SuperSyncPrivateCfg
| LocalFileSyncPrivateCfg
@ -98,11 +100,13 @@ export type PrivateCfgByProviderId<T extends SyncProviderId> =
? WebdavPrivateCfg
: T extends SyncProviderId.Dropbox
? DropboxPrivateCfg
: T extends SyncProviderId.SuperSync
? SuperSyncPrivateCfg
: T extends SyncProviderId.Nextcloud
? NextcloudPrivateCfg
: never;
: T extends SyncProviderId.OneDrive
? OneDrivePrivateCfg
: T extends SyncProviderId.SuperSync
? SuperSyncPrivateCfg
: T extends SyncProviderId.Nextcloud
? NextcloudPrivateCfg
: never;
// ============================================================================
// Current Provider Config (for observable emissions)

View file

@ -61,6 +61,7 @@ export {
// Provider types
export type { DropboxPrivateCfg } from '@sp/sync-providers/dropbox';
export type { OneDrivePrivateCfg } from '@sp/sync-providers/onedrive';
// VectorClock from core
export { VectorClock } from '../core/util/vector-clock';

View file

@ -0,0 +1,6 @@
export type {
OneDrivePrivateCfg,
OneDriveItem,
OneDriveListResponse,
OneDriveTokenResponse,
} from '@sp/sync-providers/onedrive';

View file

@ -0,0 +1,476 @@
import {
OneDrive as PackageOneDrive,
PROVIDER_ID_ONEDRIVE,
type OneDriveDeps,
} from '@sp/sync-providers/onedrive';
import { OneDrivePrivateCfg } from './onedrive.model';
import type { SyncCredentialStorePort } from '@sp/sync-providers/credential-store';
describe('OneDrive', () => {
let provider: PackageOneDrive;
let fetchSpy: jasmine.Spy;
let cfgStoreSpy: jasmine.SpyObj<
SyncCredentialStorePort<typeof PROVIDER_ID_ONEDRIVE, OneDrivePrivateCfg>
>;
const tokenExpiryMs = 5 * 60 * 1000;
const baseCfg: OneDrivePrivateCfg = {
clientId: 'client-id',
tenantId: 'common',
syncFolderPath: 'Super Productivity',
accessToken: 'access-token',
refreshToken: 'refresh-token',
tokenExpiresAt: Date.now() + tokenExpiryMs,
encryptKey: 'enc',
};
const noop = (): void => undefined;
const mockDeps: OneDriveDeps = {
logger: {
log: noop,
error: noop,
err: noop,
normal: noop,
verbose: noop,
info: noop,
warn: noop,
critical: noop,
debug: noop,
},
platformInfo: {
isNativePlatform: false,
isAndroidWebView: false,
isIosNative: false,
},
webFetch: () => fetch as typeof fetch,
credentialStore: null as unknown as OneDriveDeps['credentialStore'],
officialClientId: null,
hasOfficialClientId: false,
addOAuthState: noop,
isElectron: false,
};
let originalFetch: typeof fetch | undefined;
beforeEach(() => {
cfgStoreSpy = jasmine.createSpyObj('SyncCredentialStore', ['load', 'setComplete']);
cfgStoreSpy.setComplete.and.resolveTo();
const deps: OneDriveDeps = {
...mockDeps,
credentialStore: cfgStoreSpy as unknown as OneDriveDeps['credentialStore'],
};
provider = new PackageOneDrive({}, deps);
originalFetch = (globalThis as any).fetch;
fetchSpy = jasmine.createSpy('fetch');
(globalThis as any).fetch = fetchSpy;
});
it('should report ready when credentials are present', async () => {
cfgStoreSpy.load.and.resolveTo(baseCfg);
await expectAsync(provider.isReady()).toBeResolvedTo(true);
});
it('should report not ready when refresh token is missing', async () => {
cfgStoreSpy.load.and.resolveTo({ ...baseCfg, refreshToken: '' });
await expectAsync(provider.isReady()).toBeResolvedTo(false);
});
it('should clear only auth credentials', async () => {
cfgStoreSpy.load.and.resolveTo(baseCfg);
cfgStoreSpy.setComplete.and.resolveTo();
await provider.clearAuthCredentials();
expect(cfgStoreSpy.setComplete).toHaveBeenCalledWith({
...baseCfg,
accessToken: '',
refreshToken: '',
tokenExpiresAt: 0,
});
});
it('should clear credentials and throw MissingRefreshTokenAPIError on 401', async () => {
cfgStoreSpy.load.and.resolveTo(baseCfg);
fetchSpy.and.resolveTo({
ok: false,
status: 401,
text: async () => '',
} as Response);
try {
await provider.removeFile('test.json');
fail('should have thrown');
} catch (e) {
expect((e as Error).name).toBe('MissingRefreshTokenAPIError');
}
expect(cfgStoreSpy.setComplete).toHaveBeenCalled();
});
it('should throw MissingRefreshTokenAPIError when token is expired and refresh token is missing', async () => {
cfgStoreSpy.load.and.resolveTo({
...baseCfg,
accessToken: 'stale',
refreshToken: '',
tokenExpiresAt: Date.now() - 1000,
});
try {
await provider.removeFile('test.json');
fail('should have thrown');
} catch (e) {
expect((e as Error).name).toBe('MissingRefreshTokenAPIError');
}
});
it('should clear credentials on 403 InvalidAuthenticationToken', async () => {
cfgStoreSpy.load.and.resolveTo(baseCfg);
cfgStoreSpy.setComplete.and.resolveTo();
fetchSpy.and.resolveTo({
ok: false,
status: 403,
text: async () =>
JSON.stringify({
error: {
code: 'InvalidAuthenticationToken',
message: 'Access token has expired or is invalid',
},
}),
} as Response);
try {
await provider.removeFile('test.json');
fail('should have thrown');
} catch (e) {
expect((e as Error).name).toBe('AuthFailSPError');
}
expect(cfgStoreSpy.setComplete).toHaveBeenCalled();
});
it('should map 429 responses to TooManyRequestsAPIError', async () => {
cfgStoreSpy.load.and.resolveTo(baseCfg);
fetchSpy.and.resolveTo({
ok: false,
status: 429,
text: async () =>
JSON.stringify({
error: {
code: 'tooManyRequests',
message: 'Rate limit exceeded',
},
}),
} as Response);
try {
await provider.removeFile('test.json');
fail('should have thrown');
} catch (e) {
expect((e as Error).name).toBe('TooManyRequestsAPIError');
}
});
it('should refresh expired token and persist new credentials', async () => {
cfgStoreSpy.load.and.resolveTo({
...baseCfg,
accessToken: 'old-token',
tokenExpiresAt: Date.now() - 1000,
});
cfgStoreSpy.setComplete.and.resolveTo();
fetchSpy.and.callFake(async (url: string, init?: RequestInit) => {
if (url.includes('/oauth2/v2.0/token')) {
return {
ok: true,
status: 200,
json: async () => ({
access_token: 'new-token',
refresh_token: 'new-refresh',
expires_in: 3600,
}),
text: async () => '',
} as Response;
}
if (init?.method === 'DELETE') {
return {
ok: true,
status: 204,
text: async () => '',
} as Response;
}
return {
ok: true,
status: 200,
text: async () => '',
} as Response;
});
await expectAsync(provider.removeFile('test.json')).toBeResolved();
expect(cfgStoreSpy.setComplete).toHaveBeenCalledWith(
jasmine.objectContaining({
accessToken: 'new-token',
refreshToken: 'new-refresh',
}),
);
});
it('should avoid repeated folder existence checks after first successful upload', async () => {
cfgStoreSpy.load.and.resolveTo(baseCfg);
let getCount = 0;
let postCount = 0;
fetchSpy.and.callFake(async (_url: string, init?: RequestInit) => {
if (init?.method === 'GET') {
getCount++;
return {
ok: true,
status: 200,
text: async () => '',
} as Response;
}
if (init?.method === 'POST') {
postCount++;
return {
ok: true,
status: 200,
json: async () => ({}),
text: async () => '',
} as Response;
}
if (init?.method === 'PUT') {
return {
ok: true,
status: 200,
json: async () => ({ eTag: 'etag-1' }),
text: async () => '',
} as Response;
}
return {
ok: true,
status: 200,
text: async () => '',
} as Response;
});
await expectAsync(
provider.uploadFile('file-1.json', '{"a":1}', null, true),
).toBeResolved();
await expectAsync(
provider.uploadFile('file-2.json', '{"a":2}', null, true),
).toBeResolved();
// First upload probes the folder; second upload hits the cache
expect(getCount).toBe(1);
expect(postCount).toBe(0);
});
it('should refresh token and retry on 401', async () => {
let firstRequest = true;
cfgStoreSpy.load.and.resolveTo(baseCfg);
cfgStoreSpy.setComplete.and.resolveTo();
fetchSpy.and.callFake(async (url: string, init?: RequestInit) => {
// Token refresh endpoint
if (url.includes('/oauth2/v2.0/token')) {
return {
ok: true,
status: 200,
json: async () => ({
access_token: 'refreshed-token',
refresh_token: 'refreshed-refresh',
expires_in: 3600,
}),
text: async () => '',
} as Response;
}
// First API request returns 401, second succeeds
if (firstRequest && init?.method === 'DELETE') {
firstRequest = false;
return {
ok: false,
status: 401,
text: async () => '',
} as Response;
}
return {
ok: true,
status: 204,
text: async () => '',
} as Response;
});
await expectAsync(provider.removeFile('test.json')).toBeResolved();
// Token refresh was called
expect(
fetchSpy.calls.all().some((c) => String(c.args[0]).includes('/oauth2/v2.0/token')),
).toBeTrue();
});
it('should throw HttpNotOkAPIError when 401 retry also fails with transient error', async () => {
cfgStoreSpy.load.and.resolveTo(baseCfg);
fetchSpy.and.callFake(async (url: string, init?: RequestInit) => {
if (url.includes('/oauth2/v2.0/token')) {
return {
ok: false,
status: 500,
text: async () => 'Internal Server Error',
} as Response;
}
return {
ok: false,
status: 401,
text: async () => '',
} as Response;
});
try {
await provider.removeFile('test.json');
fail('should have thrown');
} catch (e) {
// Transient 500 from token endpoint should not clear credentials
expect((e as Error).name).toBe('HttpNotOkAPIError');
}
// Credentials should NOT be cleared for transient failures
const clearCalls = cfgStoreSpy.setComplete.calls
.all()
.filter(
(call) => call.args[0]?.accessToken === '' && call.args[0]?.refreshToken === '',
);
expect(clearCalls.length).toBe(0);
});
it('should clear credentials on 400 invalid_grant from token endpoint', async () => {
const expiredCfg = {
...baseCfg,
accessToken: 'stale',
tokenExpiresAt: Date.now() - 1000,
};
// load() returns expired cfg; clearAuthCredentials also calls load() before setComplete
cfgStoreSpy.load.and.resolveTo(expiredCfg);
fetchSpy.and.callFake(async (url: string) => {
if (url.includes('/oauth2/v2.0/token')) {
return {
ok: false,
status: 400,
text: async () =>
JSON.stringify({
error: 'invalid_grant',
error_description: 'Token has been revoked',
}),
} as Response;
}
return {
ok: true,
status: 200,
text: async () => '',
} as Response;
});
try {
await provider.removeFile('test.json');
fail('should have thrown');
} catch (e) {
// invalid_grant → clearAuthCredentials → throw MissingRefreshTokenAPIError.
// Propagates: refresh IIFE → _request → removeFile catch → _mapAndThrow
// which re-throws as-is (not an HttpNotOkAPIError).
expect((e as Error).name).toBe('MissingRefreshTokenAPIError');
}
// Credentials were cleared by clearAuthCredentials()
const clearCalls = cfgStoreSpy.setComplete.calls
.all()
.filter(
(call) => call.args[0]?.accessToken === '' && call.args[0]?.refreshToken === '',
);
expect(clearCalls.length).toBeGreaterThanOrEqual(1);
});
it('should map 412 responses to UploadRevToMatchMismatchAPIError', async () => {
cfgStoreSpy.load.and.resolveTo(baseCfg);
fetchSpy.and.resolveTo({
ok: false,
status: 412,
text: async () =>
JSON.stringify({
error: {
code: 'preconditionFailed',
message: 'ETag does not match',
},
}),
} as Response);
try {
await provider.uploadFile('test.json', '{"a":1}', 'rev-old');
fail('should have thrown');
} catch (e) {
expect((e as Error).name).toBe('UploadRevToMatchMismatchAPIError');
}
});
it('should deduplicate concurrent token refresh requests', async () => {
let refreshCallCount = 0;
cfgStoreSpy.load.and.resolveTo({
...baseCfg,
accessToken: 'old-token',
tokenExpiresAt: Date.now() - 1000,
});
cfgStoreSpy.setComplete.and.resolveTo();
fetchSpy.and.callFake(async (url: string, init?: RequestInit) => {
if (url.includes('/oauth2/v2.0/token')) {
refreshCallCount++;
return {
ok: true,
status: 200,
json: async () => ({
access_token: 'new-token',
refresh_token: 'new-refresh',
expires_in: 3600,
}),
text: async () => '',
} as Response;
}
// API requests succeed
return {
ok: true,
status: 200,
text: async () => '',
json: async () => ({ eTag: 'etag-1' }),
} as Response;
});
// Fire two concurrent requests that both need a token refresh
await Promise.all([
provider.removeFile('file-1.json'),
provider.removeFile('file-2.json'),
]);
// Only one token refresh should have been made
expect(refreshCallCount).toBe(1);
});
afterEach(() => {
(globalThis as any).fetch = originalFetch;
});
});

View file

@ -0,0 +1,42 @@
import {
OneDrive as PackageOneDrive,
PROVIDER_ID_ONEDRIVE,
type OneDriveDeps,
} from '@sp/sync-providers/onedrive';
import { SyncProviderId } from '../../provider.const';
import { SyncCredentialStore } from '../../credential-store.service';
import { OP_LOG_SYNC_LOGGER } from '../../../core/sync-logger.adapter';
import { APP_PROVIDER_PLATFORM_INFO } from '../../platform/app-provider-platform-info';
import { APP_WEB_FETCH } from '../../platform/app-web-fetch';
import {
OFFICIAL_ONEDRIVE_CLIENT_ID,
HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
} from '../../../../imex/sync/onedrive-auth-mode.const';
import { addOAuthState } from '../../../../imex/sync/oauth-state.util';
import { IS_ELECTRON } from '../../../../app.constants';
type AssertOneDriveId = SyncProviderId.OneDrive extends typeof PROVIDER_ID_ONEDRIVE
? true
: never;
const _idCheck: AssertOneDriveId = true;
void _idCheck;
export type { OneDrivePrivateCfg } from '@sp/sync-providers/onedrive';
export const createOneDriveProvider = (
cfg: { devPath?: string } = {},
): PackageOneDrive => {
const deps: OneDriveDeps = {
logger: OP_LOG_SYNC_LOGGER,
platformInfo: APP_PROVIDER_PLATFORM_INFO,
webFetch: APP_WEB_FETCH,
credentialStore: new SyncCredentialStore(
SyncProviderId.OneDrive,
) as OneDriveDeps['credentialStore'],
officialClientId: OFFICIAL_ONEDRIVE_CLIENT_ID,
hasOfficialClientId: HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
addOAuthState,
isElectron: IS_ELECTRON,
};
return new PackageOneDrive(cfg, deps);
};

View file

@ -1,6 +1,7 @@
// NOTE: do not change!!
export enum SyncProviderId {
'Dropbox' = 'Dropbox',
'OneDrive' = 'OneDrive',
'WebDAV' = 'WebDAV',
'LocalFile' = 'LocalFile',
'SuperSync' = 'SuperSync',
@ -14,6 +15,7 @@ export enum SyncProviderId {
*/
export const OAUTH_SYNC_PROVIDERS: ReadonlySet<SyncProviderId> = new Set([
SyncProviderId.Dropbox,
SyncProviderId.OneDrive,
]);
/**

View file

@ -3,6 +3,7 @@ import { SyncProviderBase } from './provider.interface';
import { DROPBOX_APP_KEY } from '../../imex/sync/dropbox/dropbox.const';
import { IS_ELECTRON } from '../../app.constants';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { IS_ONEDRIVE_SUPPORTED } from '../../imex/sync/onedrive-auth-mode.const';
import { environment } from '../../../environments/environment';
let _providersPromise: Promise<SyncProviderBase<SyncProviderId>[]> | null = null;
@ -56,6 +57,15 @@ const _createProviders = async (): Promise<SyncProviderBase<SyncProviderId>[]> =
createNextcloudProvider(extraPath) as SyncProviderBase<SyncProviderId>,
];
if (IS_ONEDRIVE_SUPPORTED) {
const { createOneDriveProvider } = await import('./file-based/onedrive/onedrive');
providers.push(
createOneDriveProvider({
devPath: extraPath,
}) as SyncProviderBase<SyncProviderId>,
);
}
if (IS_ELECTRON) {
const { createLocalFileSyncElectron } =
await import('./file-based/local-file/local-file-sync-electron');

View file

@ -56,6 +56,7 @@ describe('operation-sync utility', () => {
const KNOWN_FILE_BASED: ReadonlySet<SyncProviderId> = new Set([
SyncProviderId.Dropbox,
SyncProviderId.WebDAV,
SyncProviderId.OneDrive,
SyncProviderId.LocalFile,
SyncProviderId.Nextcloud,
]);

View file

@ -7,10 +7,11 @@ import {
} from '../sync-providers/provider.interface';
import { SyncProviderId } from '../sync-providers/provider.const';
/** Provider IDs that use file-based operation sync (WebDAV, Dropbox, LocalFile, Nextcloud) */
/** Provider IDs that use file-based operation sync (WebDAV, Dropbox, OneDrive, LocalFile, Nextcloud) */
const FILE_BASED_PROVIDER_IDS: Set<SyncProviderId> = new Set([
SyncProviderId.WebDAV,
SyncProviderId.Dropbox,
SyncProviderId.OneDrive,
SyncProviderId.LocalFile,
SyncProviderId.Nextcloud,
]);
@ -39,7 +40,7 @@ export const isOperationSyncCapable = (
/**
* Type guard to check if a provider uses file-based operation sync.
* File-based providers (WebDAV, Dropbox, LocalFile, Nextcloud) use file storage for sync.
* File-based providers (WebDAV, Dropbox, OneDrive, LocalFile, Nextcloud) use file storage for sync.
*/
export const isFileBasedProvider = (
provider: SyncProviderBase<SyncProviderId>,

View file

@ -1281,6 +1281,8 @@ const T = {
FORM: {
BTN_CHANGE_PASSWORD: 'F.SYNC.FORM.BTN_CHANGE_PASSWORD',
BTN_DISABLE_ENCRYPTION: 'F.SYNC.FORM.BTN_DISABLE_ENCRYPTION',
BTN_REAUTHENTICATE: 'F.SYNC.FORM.BTN_REAUTHENTICATE',
REAUTH_SUCCESS: 'F.SYNC.FORM.REAUTH_SUCCESS',
DROPBOX: {
AUTH_SUCCESS: 'F.SYNC.FORM.DROPBOX.AUTH_SUCCESS',
BTN_AUTHENTICATE: 'F.SYNC.FORM.DROPBOX.BTN_AUTHENTICATE',
@ -1443,6 +1445,19 @@ const T = {
L_APP_PASSWORD: 'F.SYNC.FORM.NEXTCLOUD.L_APP_PASSWORD',
APP_PASSWORD_DESCRIPTION: 'F.SYNC.FORM.NEXTCLOUD.APP_PASSWORD_DESCRIPTION',
},
ONEDRIVE: {
CLIENT_ID_DESCRIPTION: 'F.SYNC.FORM.ONEDRIVE.CLIENT_ID_DESCRIPTION',
INFO_TEXT: 'F.SYNC.FORM.ONEDRIVE.INFO_TEXT',
L_CLIENT_ID: 'F.SYNC.FORM.ONEDRIVE.L_CLIENT_ID',
L_SYNC_FOLDER_PATH: 'F.SYNC.FORM.ONEDRIVE.L_SYNC_FOLDER_PATH',
L_TENANT_ID: 'F.SYNC.FORM.ONEDRIVE.L_TENANT_ID',
L_USE_CUSTOM_APP: 'F.SYNC.FORM.ONEDRIVE.L_USE_CUSTOM_APP',
OFFICIAL_MODE_INFO: 'F.SYNC.FORM.ONEDRIVE.OFFICIAL_MODE_INFO',
SYNC_FOLDER_PATH_DESCRIPTION:
'F.SYNC.FORM.ONEDRIVE.SYNC_FOLDER_PATH_DESCRIPTION',
TENANT_ID_DESCRIPTION: 'F.SYNC.FORM.ONEDRIVE.TENANT_ID_DESCRIPTION',
USE_CUSTOM_APP_DESCRIPTION: 'F.SYNC.FORM.ONEDRIVE.USE_CUSTOM_APP_DESCRIPTION',
},
TITLE: 'F.SYNC.FORM.TITLE',
WEB_DAV: {
CORS_INFO: 'F.SYNC.FORM.WEB_DAV.CORS_INFO',

View file

@ -1263,6 +1263,8 @@
"FORM": {
"BTN_CHANGE_PASSWORD": "Change Password",
"BTN_DISABLE_ENCRYPTION": "Disable Encryption",
"BTN_REAUTHENTICATE": "Re-authenticate",
"REAUTH_SUCCESS": "Re-authentication successful! Credentials updated.",
"DROPBOX": {
"AUTH_SUCCESS": "Dropbox authentication successful! Credentials saved.",
"BTN_AUTHENTICATE": "Authenticate with Dropbox",
@ -1406,6 +1408,18 @@
"L_APP_PASSWORD": "App Password",
"APP_PASSWORD_DESCRIPTION": "Go to Nextcloud → Settings → Security → App passwords to generate one"
},
"ONEDRIVE": {
"CLIENT_ID_DESCRIPTION": "Application (client) ID from your Microsoft Entra app registration",
"INFO_TEXT": "Microsoft 365 authentication uses OAuth. Fill client and tenant first, then authenticate.",
"L_CLIENT_ID": "Application (Client) ID",
"L_SYNC_FOLDER_PATH": "Sync Folder Path",
"L_TENANT_ID": "Tenant ID",
"L_USE_CUSTOM_APP": "Use my own Microsoft Entra app registration",
"OFFICIAL_MODE_INFO": "Using built-in Microsoft 365 sign-in for this app. Switch to custom mode only if you need your own Entra app registration.",
"SYNC_FOLDER_PATH_DESCRIPTION": "Stored under your OneDrive App Folder (e.g. super-productivity)",
"TENANT_ID_DESCRIPTION": "Use 'common' for multi-tenant sign-in",
"USE_CUSTOM_APP_DESCRIPTION": "Enable this if you want to provide your own Application (Client) ID"
},
"TITLE": "Sync",
"WEB_DAV": {
"CORS_INFO": "<strong>Making it work in a web browser:</strong> You need to allow Super Productivity to make CORS requests to your server. For Nextcloud, one approach is allowing \"https://app.super-productivity.com\" via the <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword</a> app. Refer to <a href='https://github.com/nextcloud/server/issues/3131'>this GitHub thread</a> for more information.",

View file

@ -23,7 +23,8 @@
"@sp/sync-providers/provider-types": [
"packages/sync-providers/src/provider-types.ts"
],
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"]
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"],
"@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"]
}
},
"files": ["test.ts", "polyfills.ts"],

View file

@ -35,6 +35,7 @@ const REQUIRED_ENV_KEYS = [
const OPTIONAL_ENV_KEYS = [
'UNSPLASH_KEY',
'UNSPLASH_CLIENT_ID',
'ONEDRIVE_CLIENT_ID',
'GOOGLE_DRIVE_TOKEN',
'DROPBOX_API_KEY',
'WEBDAV_URL',

View file

@ -42,7 +42,8 @@
"@sp/sync-providers/provider-types": [
"packages/sync-providers/src/provider-types.ts"
],
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"]
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"],
"@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"]
},
"plugins": [
{