From 9910d30fcacd1833923c6d9973ec619fec63dc77 Mon Sep 17 00:00:00 2001 From: Harbinger Date: Tue, 26 May 2026 21:31:06 +0800 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.7 --- electron/protocol-handler.ts | 23 +- .../shared-with-frontend/ipc-events.const.ts | 3 + electron/tsconfig.electron.json | 3 +- packages/sync-providers/package.json | 10 + .../src/file-based/onedrive/onedrive.model.ts | 30 + .../src/file-based/onedrive/onedrive.ts | 770 ++++++++++++++++++ packages/sync-providers/src/onedrive.ts | 11 + packages/sync-providers/tsup.config.ts | 1 + .../main-header/main-header.component.html | 2 +- .../main-header/main-header.component.ts | 9 + .../config/default-global-config.const.ts | 11 + .../config/form-cfgs/sync-form.const.ts | 94 +++ .../features/config/global-config.model.ts | 10 + ...ialog-get-and-enter-auth-code.component.ts | 106 ++- .../dialog-sync-cfg.component.ts | 57 +- .../oauth-callback-handler.service.spec.ts | 8 +- .../sync/oauth-callback-handler.service.ts | 86 +- src/app/imex/sync/oauth-state.util.ts | 38 + src/app/imex/sync/onedrive-auth-mode.const.ts | 10 + src/app/imex/sync/sync-config.service.spec.ts | 6 +- src/app/imex/sync/sync-config.service.ts | 50 +- src/app/imex/sync/sync.model.ts | 1 + src/app/op-log/core/types/sync.types.ts | 14 +- src/app/op-log/sync-exports.ts | 1 + .../file-based/onedrive/onedrive.model.ts | 6 + .../file-based/onedrive/onedrive.spec.ts | 476 +++++++++++ .../file-based/onedrive/onedrive.ts | 42 + .../op-log/sync-providers/provider.const.ts | 2 + .../sync-providers/sync-providers.factory.ts | 10 + .../op-log/sync/operation-sync.util.spec.ts | 1 + src/app/op-log/sync/operation-sync.util.ts | 5 +- src/app/t.const.ts | 15 + src/assets/i18n/en.json | 14 + src/tsconfig.spec.json | 3 +- tools/load-env.js | 1 + tsconfig.base.json | 3 +- 36 files changed, 1873 insertions(+), 59 deletions(-) create mode 100644 packages/sync-providers/src/file-based/onedrive/onedrive.model.ts create mode 100644 packages/sync-providers/src/file-based/onedrive/onedrive.ts create mode 100644 packages/sync-providers/src/onedrive.ts create mode 100644 src/app/imex/sync/oauth-state.util.ts create mode 100644 src/app/imex/sync/onedrive-auth-mode.const.ts create mode 100644 src/app/op-log/sync-providers/file-based/onedrive/onedrive.model.ts create mode 100644 src/app/op-log/sync-providers/file-based/onedrive/onedrive.spec.ts create mode 100644 src/app/op-log/sync-providers/file-based/onedrive/onedrive.ts diff --git a/electron/protocol-handler.ts b/electron/protocol-handler.ts index f16d5c9881..963c6be4b1 100644 --- a/electron/protocol-handler.ts +++ b/electron/protocol-handler.ts @@ -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()); diff --git a/electron/shared-with-frontend/ipc-events.const.ts b/electron/shared-with-frontend/ipc-events.const.ts index 45903a6bbc..c31e70911e 100644 --- a/electron/shared-with-frontend/ipc-events.const.ts +++ b/electron/shared-with-frontend/ipc-events.const.ts @@ -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', diff --git a/electron/tsconfig.electron.json b/electron/tsconfig.electron.json index fa74e8da32..f1f103915a 100644 --- a/electron/tsconfig.electron.json +++ b/electron/tsconfig.electron.json @@ -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"], diff --git a/packages/sync-providers/package.json b/packages/sync-providers/package.json index 99ab37d34b..12b73532b5 100644 --- a/packages/sync-providers/package.json +++ b/packages/sync-providers/package.json @@ -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", diff --git a/packages/sync-providers/src/file-based/onedrive/onedrive.model.ts b/packages/sync-providers/src/file-based/onedrive/onedrive.model.ts new file mode 100644 index 0000000000..1b573e67f0 --- /dev/null +++ b/packages/sync-providers/src/file-based/onedrive/onedrive.model.ts @@ -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; + file?: Record; +} + +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; +} diff --git a/packages/sync-providers/src/file-based/onedrive/onedrive.ts b/packages/sync-providers/src/file-based/onedrive/onedrive.ts new file mode 100644 index 0000000000..94cf580c8a --- /dev/null +++ b/packages/sync-providers/src/file-based/onedrive/onedrive.ts @@ -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 | null = null; + private _tokenRefreshInFlightPromise: Promise | 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 { + const cfg = await this.privateCfg.load(); + const resolvedClientId = this._resolveClientId(cfg || {}); + return !!(resolvedClientId && cfg?.accessToken && cfg?.refreshToken); + } + + async setPrivateCfg(privateCfg: OneDrivePrivateCfg): Promise { + await this.privateCfg.setComplete(privateCfg); + } + + async clearAuthCredentials(): Promise { + 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): Promise { + 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 { + 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 (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 { + 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 { + 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(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 { + 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 { + 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 { + const cfg = (await this.privateCfg.load()) || ({} as Partial); + 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): 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 { + 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 { + 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 { + 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 { + 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(path: string): Promise { + 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; + 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; +} diff --git a/packages/sync-providers/src/onedrive.ts b/packages/sync-providers/src/onedrive.ts new file mode 100644 index 0000000000..b486e462e9 --- /dev/null +++ b/packages/sync-providers/src/onedrive.ts @@ -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'; diff --git a/packages/sync-providers/tsup.config.ts b/packages/sync-providers/tsup.config.ts index 7e14f0392a..988cebccd7 100644 --- a/packages/sync-providers/tsup.config.ts +++ b/packages/sync-providers/tsup.config.ts @@ -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', diff --git a/src/app/core-ui/main-header/main-header.component.html b/src/app/core-ui/main-header/main-header.component.html index 7988b06b31..07b1ddacc7 100644 --- a/src/app/core-ui/main-header/main-header.component.html +++ b/src/app/core-ui/main-header/main-header.component.html @@ -51,7 +51,7 @@