mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-22 18:30:09 +00:00
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:
parent
524e9888a5
commit
40b18c4693
12 changed files with 318 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
.oauth-waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
63
src/app/imex/sync/oauth-callback-handler.service.spec.ts
Normal file
63
src/app/imex/sync/oauth-callback-handler.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
84
src/app/imex/sync/oauth-callback-handler.service.ts
Normal file
84
src/app/imex/sync/oauth-callback-handler.service.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue