Skip to content

Commit 413f1b5

Browse files
authored
refactor(auth): make the token cipher async (#3444)
1 parent e338415 commit 413f1b5

4 files changed

Lines changed: 256 additions & 37 deletions

File tree

apps/code/src/main/services/auth/port-adapters.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ import {
3737

3838
@injectable()
3939
export class TokenCipherPortAdapter implements IAuthTokenCipher {
40-
encrypt(plaintext: string): string {
41-
return encrypt(plaintext);
40+
encrypt(plaintext: string): Promise<string> {
41+
return Promise.resolve(encrypt(plaintext));
4242
}
4343

44-
decrypt(encrypted: string): string | null {
45-
return decrypt(encrypted);
44+
decrypt(encrypted: string): Promise<string | null> {
45+
return Promise.resolve(decrypt(encrypted));
4646
}
4747
}
4848

packages/core/src/auth/auth.test.ts

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ function createPreferencePort(): IAuthPreferenceStore {
6464
}
6565

6666
const identityCipher: IAuthTokenCipher = {
67-
encrypt: (plaintext) => plaintext,
68-
decrypt: (encrypted) => encrypted,
67+
encrypt: (plaintext) => Promise.resolve(plaintext),
68+
decrypt: (encrypted) => Promise.resolve(encrypted),
6969
};
7070

7171
const mockLogger: RootLogger = {
@@ -413,6 +413,52 @@ describe("AuthService", () => {
413413
}
414414
});
415415

416+
it("dedupes concurrent refreshes into a single token request", async () => {
417+
oauthFlow.startFlow.mockResolvedValue(
418+
mockTokenResponse({
419+
accessToken: "initial-access-token",
420+
refreshToken: "initial-refresh-token",
421+
}),
422+
);
423+
stubAuthFetch();
424+
425+
// Keep the refresh in flight so both callers overlap; if the refresh
426+
// isn't deduped, the rotating refresh token is spent twice.
427+
let resolveRefresh!: (value: unknown) => void;
428+
oauthFlow.refreshToken.mockReturnValue(
429+
new Promise((resolve) => {
430+
resolveRefresh = resolve;
431+
}),
432+
);
433+
434+
await service.initialize();
435+
await service.login("us");
436+
437+
// Two forced refreshes fired in the same tick. The dedup guard must close
438+
// synchronously — resolving the stored session now awaits decryption, so
439+
// if that await sat between the guard and the refreshPromise assignment,
440+
// both callers would slip through and fire their own request.
441+
oauthFlow.refreshToken.mockClear();
442+
const both = Promise.all([
443+
service.refreshAccessToken(),
444+
service.refreshAccessToken(),
445+
]);
446+
447+
await new Promise((r) => setTimeout(r, 0));
448+
expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1);
449+
450+
resolveRefresh(
451+
mockTokenResponse({
452+
accessToken: "refreshed-access-token",
453+
refreshToken: "refreshed-refresh-token",
454+
}),
455+
);
456+
const [a, b] = await both;
457+
expect(a.accessToken).toBe("refreshed-access-token");
458+
expect(b.accessToken).toBe("refreshed-access-token");
459+
expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1);
460+
});
461+
416462
it("forces a token refresh when explicitly requested", async () => {
417463
oauthFlow.startFlow.mockResolvedValue(
418464
mockTokenResponse({
@@ -500,6 +546,141 @@ describe("AuthService", () => {
500546
});
501547
});
502548

549+
it("keeps the prior selection committed when persisting a new selection fails", async () => {
550+
const orgs = {
551+
"org-1": {
552+
name: "Org 1",
553+
projects: [
554+
{ id: 42, name: "Project 42" },
555+
{ id: 84, name: "Project 84" },
556+
],
557+
},
558+
};
559+
oauthFlow.startFlow.mockResolvedValue(
560+
mockTokenResponse({
561+
accessToken: "initial-access-token",
562+
refreshToken: "initial-refresh-token",
563+
}),
564+
);
565+
stubAuthFetch({ orgs });
566+
567+
// Encrypts fine during login, then rejects so the selectProject persist
568+
// fails mid-flow (mirrors the browser cipher's Web Crypto rejecting).
569+
let failEncrypt = false;
570+
const flakyCipher: IAuthTokenCipher = {
571+
encrypt: (plaintext) =>
572+
failEncrypt
573+
? Promise.reject(new Error("encryption unavailable"))
574+
: Promise.resolve(plaintext),
575+
decrypt: (encrypted) => Promise.resolve(encrypted),
576+
};
577+
service = new AuthService(
578+
preferencePort,
579+
sessionPort,
580+
oauthFlow as unknown as IAuthOAuthFlowService,
581+
connectivity,
582+
flakyCipher,
583+
mockPowerManager as unknown as IPowerManager,
584+
mockLogger,
585+
null,
586+
);
587+
588+
// Initialize once while the session store is empty so login/selectProject
589+
// don't later trigger the stored-session restore path.
590+
service.init();
591+
await service.initialize();
592+
593+
await service.login("us");
594+
const priorProjectId = service.getState().currentProjectId;
595+
expect(priorProjectId).toBe(42);
596+
597+
failEncrypt = true;
598+
await expect(service.selectProject(84)).rejects.toThrow(
599+
"encryption unavailable",
600+
);
601+
602+
// A failed persist must not commit the new project anywhere: published
603+
// state, the stored session, and the saved preference all stay on the
604+
// prior selection (the preference is the tell — the buggy order saved it
605+
// to 84 before the encrypt rejected).
606+
expect(service.getState().currentProjectId).toBe(priorProjectId);
607+
expect(sessionPort.getCurrent()?.selectedProjectId).toBe(priorProjectId);
608+
expect(preferencePort.get("user-1", "us")?.lastSelectedProjectId).toBe(
609+
priorProjectId,
610+
);
611+
});
612+
613+
it("keeps overlapping selections consistent so the latest one wins", async () => {
614+
const orgs = {
615+
"org-1": {
616+
name: "Org 1",
617+
projects: [
618+
{ id: 42, name: "Project 42" },
619+
{ id: 84, name: "Project 84" },
620+
{ id: 99, name: "Project 99" },
621+
],
622+
},
623+
};
624+
oauthFlow.startFlow.mockResolvedValue(
625+
mockTokenResponse({
626+
accessToken: "initial-access-token",
627+
refreshToken: "initial-refresh-token",
628+
}),
629+
);
630+
stubAuthFetch({ orgs });
631+
632+
// Encryption is gated so overlapping selection commits stay in flight and
633+
// can be resolved out of order below.
634+
let deferEncrypt = false;
635+
const pending: Array<() => void> = [];
636+
const gatedCipher: IAuthTokenCipher = {
637+
encrypt: (plaintext) =>
638+
deferEncrypt
639+
? new Promise<string>((resolve) => {
640+
pending.push(() => resolve(plaintext));
641+
})
642+
: Promise.resolve(plaintext),
643+
decrypt: (encrypted) => Promise.resolve(encrypted),
644+
};
645+
service = new AuthService(
646+
preferencePort,
647+
sessionPort,
648+
oauthFlow as unknown as IAuthOAuthFlowService,
649+
connectivity,
650+
gatedCipher,
651+
mockPowerManager as unknown as IPowerManager,
652+
mockLogger,
653+
null,
654+
);
655+
service.init();
656+
await service.initialize();
657+
await service.login("us");
658+
expect(service.getState().currentProjectId).toBe(42);
659+
660+
deferEncrypt = true;
661+
// Two overlapping selections: 84 first, then 99 (the latest intent).
662+
const first = service.selectProject(84);
663+
const second = service.selectProject(99);
664+
665+
// Drain pending encryptions newest-first each round to surface any
666+
// out-of-order completion, until both commits settle. Serialized commits
667+
// only expose one pending encryption at a time; unserialized ones would
668+
// let the stale 84 commit land last.
669+
const flush = () => new Promise((r) => setTimeout(r, 0));
670+
await flush();
671+
while (pending.length > 0) {
672+
for (const resolve of pending.splice(0).reverse()) resolve();
673+
await flush();
674+
}
675+
await Promise.all([first, second]);
676+
677+
// The latest selection wins everywhere — no stale overwrite and no split
678+
// between the in-memory session (getState), stored session, and preference.
679+
expect(service.getState().currentProjectId).toBe(99);
680+
expect(sessionPort.getCurrent()?.selectedProjectId).toBe(99);
681+
expect(preferencePort.get("user-1", "us")?.lastSelectedProjectId).toBe(99);
682+
});
683+
503684
it("restores the selected project after app restart while logged out", async () => {
504685
const orgs = {
505686
"org-1": {

0 commit comments

Comments
 (0)