mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* docs(plugins): add microsoft 365 calendar provider plan * fix(jira): gate Electron requests behind one-shot capability Claim privileged Jira IPC before plugin startup and return responses through invoke instead of a broadcast event. Keep arbitrary HTTP(S) Jira hosts supported while rejecting redirects and bounding request resources. * fix(jira): enforce Electron request capability Bind privileged Jira IPC to a main-issued renderer-document token and strip raw Electron events from renderer callbacks. Scope image authentication by origin, base path, and resource type while preserving safe redirects and legacy configurations. * fix(electron): handle payload-only IPC lifecycle Clear Jira image authentication before replacement and when a new renderer document claims the capability. Parse before-close IDs from payload-only events so pending sync and finish-day hooks can complete. * fix(electron): address Jira IPC capability review findings - electron.effects: read ANY_FILE_DOWNLOADED payload at [0] after the payload-only IPC refactor (was [1], now undefined -> TypeError on every download); guard against a malformed payload - jira-capability: rotate the token on re-register so a renderer reload that reuses the WebFrameMain object is not permanently locked out of Jira; invalidates any stale token - document that the one-shot consumption order, not the bypassable main-frame check, is the real capability boundary - jira-electron-bridge: skip the no-op clearImgHeaders IPC round-trip when image auth was never set up (non-Jira detail-panel open/close) - jira-api: route a synchronous _toElectronRequestInit throw through _handleResponse instead of leaking a dangling request-log entry * test(electron): cover ANY_FILE_DOWNLOADED payload parsing Extract parseDownloadedFilePayload from ElectronEffects and add a regression spec pinning the payload-only shape ([file], not [event, file]) that caused a TypeError on every download. Hardened against non-array input surfaced by the new test. * fix(electron): restore Node ambient globals for frontend build
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
import { Injectable, OnDestroy, inject } from '@angular/core';
|
|
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: OAuthProvider;
|
|
}
|
|
|
|
@Injectable({
|
|
providedIn: 'root',
|
|
})
|
|
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();
|
|
|
|
constructor() {
|
|
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();
|
|
}
|
|
|
|
private async _setupAppUrlListener(): Promise<void> {
|
|
this._urlListenerHandle = await App.addListener(
|
|
'appUrlOpen',
|
|
(event: URLOpenListenerEvent) => {
|
|
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') ||
|
|
event.url.startsWith('superproductivity://oauth-callback')
|
|
) {
|
|
const callbackData = this._parseOAuthCallback(event.url);
|
|
|
|
if (callbackData.code) {
|
|
SyncLog.log('OAuthCallbackHandler: Extracted auth code');
|
|
} else if (callbackData.error) {
|
|
SyncLog.warn(
|
|
'OAuthCallbackHandler: OAuth error',
|
|
callbackData.error,
|
|
callbackData.error_description,
|
|
);
|
|
} else {
|
|
SyncLog.warn('OAuthCallbackHandler: No auth code or error in URL');
|
|
}
|
|
|
|
this._authCodeReceived$.next(callbackData);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
private _setupElectronOAuthListener(): void {
|
|
window.ea.on(IPC.OAUTH_CALLBACK, (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,
|
|
};
|
|
} catch (e) {
|
|
SyncLog.err('OAuthCallbackHandler: Failed to parse URL');
|
|
return {
|
|
error: 'parse_error',
|
|
error_description: 'Failed to parse OAuth callback URL',
|
|
provider: 'unknown',
|
|
};
|
|
}
|
|
}
|
|
|
|
private _handlePluginOAuthCallback(url: string): void {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const code = urlObj.searchParams.get('code');
|
|
const error = urlObj.searchParams.get('error');
|
|
const state = urlObj.searchParams.get('state') ?? undefined;
|
|
|
|
if (code) {
|
|
SyncLog.log('OAuthCallbackHandler: Extracted plugin OAuth code');
|
|
this._pluginOAuthService.handleRedirectCode(code, state);
|
|
} else if (error) {
|
|
SyncLog.warn('OAuthCallbackHandler: Plugin OAuth error', error);
|
|
this._pluginOAuthService.handleRedirectError(error, state);
|
|
} else {
|
|
SyncLog.warn('OAuthCallbackHandler: No code or error in plugin OAuth URL');
|
|
this._pluginOAuthService.handleRedirectError('no_code_or_error', state);
|
|
}
|
|
} catch (e) {
|
|
SyncLog.err('OAuthCallbackHandler: Failed to parse plugin OAuth URL', url, e);
|
|
this._pluginOAuthService.handleRedirectError('parse_error');
|
|
}
|
|
}
|
|
}
|