fix(sync): add copy-URL fallback for Dropbox auth dialog (#7139)

The "Get Authorization Code" button silently fails on some Linux
packagings (Flatpak, AUR) because shell.openExternal() can reject
without visible feedback — the wasOpened boolean check in
openUrlInBrowser has been dead code since the call was migrated from
shell.openPath to shell.openExternal (which returns Promise<void>).

- Add a "Copy URL" button to the auth dialog as a reliable fallback
  users can paste into a manually-opened browser.
- Replace the dead wasOpened check with a .catch() that logs the
  rejection reason, so future portal failures surface in electron logs.
- Add regression tests documenting the PKCE verifier/challenge
  invariants that make stale auth codes fail with "invalid code
  verifier" when the dialog is reopened.
This commit is contained in:
Johannes Millan 2026-04-16 19:12:20 +02:00
parent 05cd875dd6
commit 8c3b08e016
6 changed files with 76 additions and 4 deletions

View file

@ -332,10 +332,11 @@ function initWinEventListeners(app: Electron.App): void {
const urlObj = new URL(url);
urlObj.pathname = urlObj.pathname.replace('//', '/');
const wellFormedUrl = urlObj.toString();
const wasOpened = shell.openExternal(wellFormedUrl);
if (!wasOpened) {
shell.openExternal(wellFormedUrl);
}
// shell.openExternal returns Promise<void>; surface the failure reason instead
// of silently swallowing it (e.g. when xdg-open / Flatpak portal rejects the URI).
shell.openExternal(wellFormedUrl).catch((err) => {
error('Failed to open external URL via shell.openExternal:', err);
});
};
// open new window links in browser

View file

@ -20,6 +20,14 @@
<mat-icon>open_in_new</mat-icon>
{{ T.F.SYNC.D_AUTH_CODE.GET_AUTH_CODE | translate }}</a
>
<button
mat-button
type="button"
(click)="copyUrl()"
>
<mat-icon>content_copy</mat-icon>
{{ T.F.SYNC.D_AUTH_CODE.COPY_URL | translate }}
</button>
@if (!isNativePlatform) {
<br />

View file

@ -97,4 +97,16 @@ export class DialogGetAndEnterAuthCodeComponent implements OnDestroy {
close(token?: string): void {
this._matDialogRef.close(token?.trim());
}
async copyUrl(): Promise<void> {
try {
await navigator.clipboard.writeText(this.data.url);
this._snackService.open(T.GLOBAL_SNACK.COPY_TO_CLIPPBOARD);
} catch {
this._snackService.open({
type: 'ERROR',
msg: T.PS.FAILED_TO_COPY_TO_CLIPBOARD,
});
}
}
}

View file

@ -0,0 +1,49 @@
import { Dropbox } from './dropbox';
import { generateCodeChallenge } from '../../../../util/pkce.util';
/**
* Regression/demonstration tests for issue #7139 the secondary PKCE-mismatch bug.
*
* Scenario: If the auth dialog is opened twice (e.g. user closes it after the
* "Get Authorization Code" button fails to open the system browser), each call
* to `getAuthHelper()` generates a fresh `codeVerifier` + matching `code_challenge`.
* An auth code obtained from the FIRST authorization URL cannot be exchanged
* using the SECOND helper's verifier Dropbox rejects with `invalid_grant:
* invalid code verifier`, which is exactly what the user reports.
*/
describe('Dropbox.getAuthHelper — PKCE lifecycle (issue #7139)', () => {
it('returned authUrl must carry a code_challenge derived from the returned codeVerifier', async () => {
const dropbox = new Dropbox({ appKey: 'test-key', basePath: '/' });
const helper = await dropbox.getAuthHelper();
const authUrl = helper.authUrl as string;
const codeVerifier = helper.codeVerifier as string;
const challengeInUrl = new URL(authUrl).searchParams.get('code_challenge');
const expectedChallenge = await generateCodeChallenge(codeVerifier);
expect(challengeInUrl).toBe(expectedChallenge);
});
it('regenerates codeVerifier on every call — stale auth codes will fail token exchange', async () => {
const dropbox = new Dropbox({ appKey: 'test-key', basePath: '/' });
const first = await dropbox.getAuthHelper();
const second = await dropbox.getAuthHelper();
expect(first.codeVerifier).not.toBe(second.codeVerifier);
const c1 = new URL(first.authUrl as string).searchParams.get('code_challenge');
const c2 = new URL(second.authUrl as string).searchParams.get('code_challenge');
expect(c1).not.toBe(c2);
// This is the crux of the user-visible bug: if the user ever opens the
// dialog twice (common when the "Get Authorization Code" button silently
// fails in Flatpak), the verifier paired with the first URL's challenge
// is GONE. Entering an auth code obtained from the first URL produces
// `invalid_grant: invalid code verifier`.
const firstChallengeMatchesSecondVerifier =
(await generateCodeChallenge(second.codeVerifier as string)) === c1;
expect(firstChallengeMatchesSecondVerifier).toBe(false);
});
});

View file

@ -1035,6 +1035,7 @@ const T = {
FORCE_UPLOAD: 'F.SYNC.C.FORCE_UPLOAD',
},
D_AUTH_CODE: {
COPY_URL: 'F.SYNC.D_AUTH_CODE.COPY_URL',
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',

View file

@ -1018,6 +1018,7 @@
"FORCE_UPLOAD": "Forcing an upload of your data could lead to data loss. Are you sure you want to continue?"
},
"D_AUTH_CODE": {
"COPY_URL": "Copy URL",
"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",