fix(sync): implement OAuth redirect for Dropbox on mobile

- Add redirect_uri parameter to OAuth flow for mobile platforms
- Create OAuthCallbackHandlerService to handle deep link callbacks
- Register custom URI scheme (com.super-productivity.app://) in Android/iOS
- Add platform-specific UI for OAuth flow (automatic vs manual)
- Implement proper error handling for OAuth callback errors
- Add comprehensive unit tests for callback handler
- Fix memory leak by properly cleaning up event listeners
- Use IS_NATIVE_PLATFORM constant for consistent platform detection

Web/Electron continue using manual code entry (no regression).
Mobile (iOS/Android) now use automatic redirect with deep linking.

Fixes Dropbox OAuth authentication on iOS and Android platforms.
This commit is contained in:
Johannes Millan 2026-01-20 20:19:34 +01:00
parent 524e9888a5
commit 40b18c4693
12 changed files with 318 additions and 32 deletions

View file

@ -71,6 +71,16 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<!-- OAuth callback handler for Dropbox authentication -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="com.super-productivity.app"
android:host="oauth-callback" />
</intent-filter>
</activity>
<activity

View file

@ -52,5 +52,16 @@
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.super-productivity.app</string>
</array>
<key>CFBundleURLName</key>
<string>OAuth Callback</string>
</dict>
</array>
</dict>
</plist>

View file

@ -3,7 +3,13 @@
</h1>
<mat-dialog-content>
<p>{{ T.F.SYNC.D_AUTH_CODE.FOLLOW_LINK | translate }}</p>
@if (!isNativePlatform) {
<p>{{ T.F.SYNC.D_AUTH_CODE.FOLLOW_LINK | translate }}</p>
}
@if (isNativePlatform) {
<p>{{ T.F.SYNC.D_AUTH_CODE.FOLLOW_LINK_MOBILE | translate }}</p>
}
<a
mat-button
color="primary"
@ -14,16 +20,28 @@
<mat-icon>open_in_new</mat-icon>
{{ T.F.SYNC.D_AUTH_CODE.GET_AUTH_CODE | translate }}</a
>
<br />
<br />
<mat-form-field>
<mat-label>{{ T.F.SYNC.D_AUTH_CODE.L_AUTH_CODE | translate }}</mat-label>
<mat-icon matPrefix>vpn_key</mat-icon>
<input
matInput
[(ngModel)]="token"
/>
</mat-form-field>
@if (!isNativePlatform) {
<br />
<br />
<mat-form-field>
<mat-label>{{ T.F.SYNC.D_AUTH_CODE.L_AUTH_CODE | translate }}</mat-label>
<mat-icon matPrefix>vpn_key</mat-icon>
<input
matInput
[(ngModel)]="token"
/>
</mat-form-field>
}
@if (isNativePlatform) {
<br />
<br />
<div class="oauth-waiting">
<mat-spinner diameter="24"></mat-spinner>
<span>{{ T.F.SYNC.D_AUTH_CODE.WAITING_FOR_AUTH | translate }}</span>
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@ -35,14 +53,16 @@
>
{{ T.G.CANCEL | translate }}
</button>
<button
(click)="close(token)"
[disabled]="!token"
color="primary"
mat-stroked-button
>
<mat-icon>save</mat-icon>
{{ T.G.SAVE | translate }}
</button>
@if (!isNativePlatform) {
<button
(click)="close(token)"
[disabled]="!token"
color="primary"
mat-stroked-button
>
<mat-icon>save</mat-icon>
{{ T.G.SAVE | translate }}
</button>
}
</div>
</mat-dialog-actions>

View file

@ -0,0 +1,5 @@
.oauth-waiting {
display: flex;
align-items: center;
gap: 8px;
}

View file

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
@ -13,6 +13,11 @@ import { MatFormField, MatLabel, MatPrefix } from '@angular/material/form-field'
import { MatInput } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '@ngx-translate/core';
import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform';
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';
@Component({
selector: 'dialog-get-and-enter-auth-code',
@ -32,11 +37,15 @@ import { TranslatePipe } from '@ngx-translate/core';
MatDialogActions,
MatButton,
TranslatePipe,
MatProgressSpinner,
],
})
export class DialogGetAndEnterAuthCodeComponent {
export class DialogGetAndEnterAuthCodeComponent implements OnDestroy {
private _matDialogRef =
inject<MatDialogRef<DialogGetAndEnterAuthCodeComponent>>(MatDialogRef);
private _oauthCallbackHandler = inject(OAuthCallbackHandlerService);
private _snackService = inject(SnackService);
data = inject<{
providerName: string;
url: string;
@ -45,8 +54,44 @@ export class DialogGetAndEnterAuthCodeComponent {
T: typeof T = T;
token?: string;
readonly isNativePlatform = IS_NATIVE_PLATFORM;
private _authCodeSub?: Subscription;
constructor() {
this._matDialogRef.disableClose = true;
// On mobile, listen for OAuth callback
if (this.isNativePlatform) {
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();
}
}
},
);
}
}
ngOnDestroy(): void {
this._authCodeSub?.unsubscribe();
}
close(token?: string): void {

View file

@ -0,0 +1,63 @@
import { TestBed } from '@angular/core/testing';
import { OAuthCallbackHandlerService } from './oauth-callback-handler.service';
describe('OAuthCallbackHandlerService', () => {
let service: OAuthCallbackHandlerService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(OAuthCallbackHandlerService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('_parseOAuthCallback', () => {
it('should extract auth code from valid URL', () => {
const url = 'com.super-productivity.app://oauth-callback?code=ABC123';
const result = service['_parseOAuthCallback'](url);
expect(result.code).toBe('ABC123');
expect(result.provider).toBe('dropbox');
expect(result.error).toBeUndefined();
});
it('should extract error from callback URL', () => {
const url =
'com.super-productivity.app://oauth-callback?error=access_denied&error_description=User%20denied%20access';
const result = service['_parseOAuthCallback'](url);
expect(result.code).toBeUndefined();
expect(result.error).toBe('access_denied');
expect(result.error_description).toBe('User denied access');
expect(result.provider).toBe('dropbox');
});
it('should handle URL without code or error', () => {
const url = 'com.super-productivity.app://oauth-callback';
const result = service['_parseOAuthCallback'](url);
expect(result.code).toBeUndefined();
expect(result.error).toBeUndefined();
expect(result.provider).toBe('dropbox');
});
it('should handle malformed URL', () => {
const url = 'not-a-valid-url';
const result = service['_parseOAuthCallback'](url);
expect(result.error).toBe('parse_error');
expect(result.error_description).toBe('Failed to parse OAuth callback URL');
expect(result.provider).toBe('dropbox');
});
it('should decode URL-encoded parameters', () => {
const url =
'com.super-productivity.app://oauth-callback?error_description=Access%20was%20denied';
const result = service['_parseOAuthCallback'](url);
expect(result.error_description).toBe('Access was denied');
});
});
});

View file

@ -0,0 +1,84 @@
import { Injectable, OnDestroy } 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 { PFLog } from '../../core/log';
export interface OAuthCallbackData {
code?: string;
error?: string;
error_description?: string;
provider: 'dropbox';
}
@Injectable({
providedIn: 'root',
})
export class OAuthCallbackHandlerService implements OnDestroy {
private _authCodeReceived$ = new Subject<OAuthCallbackData>();
private _urlListenerHandle?: PluginListenerHandle;
readonly authCodeReceived$ = this._authCodeReceived$.asObservable();
constructor() {
if (IS_NATIVE_PLATFORM) {
this._setupAppUrlListener();
}
}
ngOnDestroy(): void {
this._urlListenerHandle?.remove();
this._authCodeReceived$.complete();
}
private async _setupAppUrlListener(): Promise<void> {
this._urlListenerHandle = await App.addListener(
'appUrlOpen',
(event: URLOpenListenerEvent) => {
PFLog.log('OAuthCallbackHandler: Received URL', event.url);
if (event.url.startsWith('com.super-productivity.app://oauth-callback')) {
const callbackData = this._parseOAuthCallback(event.url);
if (callbackData.code) {
PFLog.log('OAuthCallbackHandler: Extracted auth code');
} else if (callbackData.error) {
PFLog.warn(
'OAuthCallbackHandler: OAuth error',
callbackData.error,
callbackData.error_description,
);
} else {
PFLog.warn('OAuthCallbackHandler: No auth code or error in URL', event.url);
}
this._authCodeReceived$.next(callbackData);
}
},
);
}
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');
return {
code: code || undefined,
error: error || undefined,
error_description: errorDescription || undefined,
provider: 'dropbox',
};
} catch (e) {
PFLog.err('OAuthCallbackHandler: Failed to parse URL', url, e);
return {
error: 'parse_error',
error_description: 'Failed to parse OAuth callback URL',
provider: 'dropbox',
};
}
}
}

View file

@ -509,6 +509,7 @@ describe('DropboxApi', () => {
const result = await dropboxApi.getTokensFromAuthCode(
'test-auth-code',
'test-code-verifier',
null,
);
expect(result).toBeTruthy();
@ -539,7 +540,7 @@ describe('DropboxApi', () => {
);
await expectAsync(
dropboxApi.getTokensFromAuthCode('test-auth-code', 'test-code-verifier'),
dropboxApi.getTokensFromAuthCode('test-auth-code', 'test-code-verifier', null),
).toBeRejectedWithError('Dropbox: Invalid access token response');
});
});

View file

@ -323,23 +323,31 @@ export class DropboxApi {
async getTokensFromAuthCode(
authCode: string,
codeVerifier: string,
redirectUri: string | null,
): Promise<{
accessToken: string;
refreshToken: string;
expiresAt: number;
} | null> {
try {
const bodyParams: Record<string, string> = {
code: authCode,
grant_type: 'authorization_code',
client_id: this._appKey,
code_verifier: codeVerifier,
};
// Only include redirect_uri for mobile platforms
if (redirectUri) {
bodyParams.redirect_uri = redirectUri;
}
const response = await fetch('https://api.dropboxapi.com/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: stringify({
code: authCode,
grant_type: 'authorization_code',
client_id: this._appKey,
code_verifier: codeVerifier,
}),
body: stringify(bodyParams),
});
if (!response.ok) {

View file

@ -14,6 +14,7 @@ import { DropboxApi } from './dropbox-api';
import { generatePKCECodes } from './generate-pkce-codes';
import { SyncCredentialStore } from '../../credential-store.service';
import { SyncProviderPrivateCfgBase } from '../../../core/types/sync.types';
import { IS_NATIVE_PLATFORM } from '../../../../util/is-native-platform';
const DROPBOX_AUTH_URL = 'https://www.dropbox.com/oauth2/authorize' as const;
const PATH_NOT_FOUND_ERROR = 'path/not_found' as const;
@ -267,18 +268,30 @@ export class Dropbox implements SyncProviderServiceInterface<SyncProviderId.Drop
async getAuthHelper(): Promise<SyncProviderAuthHelper> {
const { codeVerifier, codeChallenge } = await generatePKCECodes(128);
const authCodeUrl =
// Determine redirect URI based on platform (only for mobile)
const redirectUri = this._getRedirectUri();
let authCodeUrl =
`${DROPBOX_AUTH_URL}` +
`?response_type=code&client_id=${this._appKey}` +
'&code_challenge_method=S256' +
'&token_access_type=offline' +
`&code_challenge=${codeChallenge}`;
// Only add redirect_uri for mobile platforms
if (redirectUri) {
authCodeUrl += `&redirect_uri=${encodeURIComponent(redirectUri)}`;
}
return {
authUrl: authCodeUrl,
codeVerifier,
verifyCodeChallenge: async <T>(authCode: string) => {
return (await this._api.getTokensFromAuthCode(authCode, codeVerifier)) as T;
return (await this._api.getTokensFromAuthCode(
authCode,
codeVerifier,
redirectUri,
)) as T;
},
};
}
@ -292,6 +305,28 @@ export class Dropbox implements SyncProviderServiceInterface<SyncProviderId.Drop
return this._basePath + path;
}
/**
* Gets the appropriate OAuth redirect URI based on the current platform.
*
* Mobile platforms (iOS/Android/Android WebView):
* Returns custom URI scheme to enable automatic redirect back to app.
* Dropbox will redirect to: com.super-productivity.app://oauth-callback?code=xxx
*
* Web/Electron platforms:
* Returns null to use Dropbox's manual code entry flow.
* User must copy the code from Dropbox's page and paste it manually.
*
* @returns The redirect URI for mobile platforms, null for web/Electron
*/
private _getRedirectUri(): string | null {
if (IS_NATIVE_PLATFORM) {
return 'com.super-productivity.app://oauth-callback';
} else {
// Web/Electron: Use manual code entry (no redirect_uri)
return null;
}
}
/**
* Checks if an error is a path not found error
* @param e The error to check

View file

@ -922,9 +922,11 @@ const T = {
},
D_AUTH_CODE: {
FOLLOW_LINK: 'F.SYNC.D_AUTH_CODE.FOLLOW_LINK',
FOLLOW_LINK_MOBILE: 'F.SYNC.D_AUTH_CODE.FOLLOW_LINK_MOBILE',
GET_AUTH_CODE: 'F.SYNC.D_AUTH_CODE.GET_AUTH_CODE',
L_AUTH_CODE: 'F.SYNC.D_AUTH_CODE.L_AUTH_CODE',
TITLE: 'F.SYNC.D_AUTH_CODE.TITLE',
WAITING_FOR_AUTH: 'F.SYNC.D_AUTH_CODE.WAITING_FOR_AUTH',
},
D_CONFLICT: {
ADDITIONAL_INFO: 'F.SYNC.D_CONFLICT.ADDITIONAL_INFO',

View file

@ -898,9 +898,11 @@
},
"D_AUTH_CODE": {
"FOLLOW_LINK": "Please open the following link and copy the auth code provided there into the input field below.",
"FOLLOW_LINK_MOBILE": "Please tap the button below to authorize. You will be automatically redirected back to the app.",
"GET_AUTH_CODE": "Get Authorization Code",
"L_AUTH_CODE": "Enter Auth Code",
"TITLE": "Login: {{provider}}"
"TITLE": "Login: {{provider}}",
"WAITING_FOR_AUTH": "Waiting for authentication..."
},
"D_CONFLICT": {
"ADDITIONAL_INFO": "Additional Info",