1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[PM-11941] Migrate TOTP Generator to use SDK (#12987)

* Refactored totp service to use sdk

Fixed strict typescript issues

* Fixed dependency issues

* Returned object that contains code and period, removed get interval function

* removed dependencies

* Updated to use refactored totp service

* removed sdk service undefined check

* removed undefined as an input from the getCode function

* Made getcode$ an observable

* refactored to use getcodee$

* Filter out emmissions

* updated sdk version

* Fixed readability nit

* log error on overlay if totp response does not return a code

* fix(totpGeneration): [PM-11941] Totp countdown not working on clients

* Used optional chaining if totpresponse returns null or undefined
This commit is contained in:
SmithThe4th
2025-03-06 14:01:07 -05:00
committed by GitHub
parent 1415041fd7
commit e327816bc4
24 changed files with 345 additions and 443 deletions

View File

@@ -190,7 +190,9 @@ describe("OverlayBackground", () => {
inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
themeStateService = mock<ThemeStateService>();
themeStateService.selectedTheme$ = selectedThemeMock$;
totpService = mock<TotpService>();
totpService = mock<TotpService>({
getCode$: jest.fn().mockReturnValue(of(undefined)),
});
overlayBackground = new OverlayBackground(
logService,
cipherService,

View File

@@ -707,13 +707,15 @@ export class OverlayBackground implements OverlayBackgroundInterface {
};
if (cipher.type === CipherType.Login) {
const totpCode = await this.totpService.getCode(cipher.login?.totp);
const totpCodeTimeInterval = this.totpService.getTimeInterval(cipher.login?.totp);
const totpResponse = cipher.login?.totp
? await firstValueFrom(this.totpService.getCode$(cipher.login.totp))
: undefined;
inlineMenuData.login = {
username: cipher.login.username,
totp: totpCode,
totp: totpResponse?.code,
totpField: this.isTotpFieldForCurrentField(),
totpCodeTimeInterval: totpCodeTimeInterval,
totpCodeTimeInterval: totpResponse?.period,
passkey: hasPasskey
? {
rpName: cipher.login.fido2Credentials[0].rpName,
@@ -1131,9 +1133,13 @@ export class OverlayBackground implements OverlayBackgroundInterface {
this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher);
if (cipher.login?.totp) {
this.platformUtilsService.copyToClipboard(
await this.totpService.getCode(cipher.login.totp),
);
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
if (totpResponse?.code) {
this.platformUtilsService.copyToClipboard(totpResponse.code);
} else {
this.logService.error("Failed to get TOTP code for inline menu cipher");
}
}
return;
}

View File

@@ -1,4 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -159,19 +160,25 @@ describe("ContextMenuClickedHandler", () => {
it("copies totp code to clipboard", async () => {
cipherService.getAllDecrypted.mockResolvedValue([createCipher({ totp: "TEST_TOTP_SEED" })]);
totpService.getCode.mockImplementation((seed) => {
jest.spyOn(totpService, "getCode$").mockImplementation((seed: string) => {
if (seed === "TEST_TOTP_SEED") {
return Promise.resolve("123456");
return of({
code: "123456",
period: 30,
});
}
return Promise.resolve("654321");
return of({
code: "654321",
period: 30,
});
});
await sut.run(createData(`${COPY_VERIFICATION_CODE_ID}_1`, COPY_VERIFICATION_CODE_ID), {
url: "https://test.com",
} as any);
expect(totpService.getCode).toHaveBeenCalledTimes(1);
expect(totpService.getCode$).toHaveBeenCalledTimes(1);
expect(copyToClipboard).toHaveBeenCalledWith({
text: "123456",

View File

@@ -205,8 +205,9 @@ export class ContextMenuClickedHandler {
action: COPY_VERIFICATION_CODE_ID,
});
} else {
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
this.copyToClipboard({
text: await this.totpService.getCode(cipher.login.totp),
text: totpResponse.code,
tab: tab,
});
}

View File

@@ -921,12 +921,12 @@ describe("AutofillService", () => {
.spyOn(billingAccountProfileStateService, "hasPremiumFromAnySource$")
.mockImplementation(() => of(true));
jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(true);
jest.spyOn(totpService, "getCode").mockResolvedValue(totpCode);
totpService.getCode$.mockReturnValue(of({ code: totpCode, period: 30 }));
const autofillResult = await autofillService.doAutoFill(autofillOptions);
expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled();
expect(totpService.getCode).toHaveBeenCalledWith(autofillOptions.cipher.login.totp);
expect(totpService.getCode$).toHaveBeenCalledWith(autofillOptions.cipher.login.totp);
expect(autofillResult).toBe(totpCode);
});
@@ -940,7 +940,7 @@ describe("AutofillService", () => {
const autofillResult = await autofillService.doAutoFill(autofillOptions);
expect(autofillService.getShouldAutoCopyTotp).not.toHaveBeenCalled();
expect(totpService.getCode).not.toHaveBeenCalled();
expect(totpService.getCode$).not.toHaveBeenCalled();
expect(autofillResult).toBeNull();
});
@@ -956,12 +956,12 @@ describe("AutofillService", () => {
it("returns a null value if the login does not contain a TOTP value", async () => {
autofillOptions.cipher.login.totp = undefined;
jest.spyOn(autofillService, "getShouldAutoCopyTotp");
jest.spyOn(totpService, "getCode");
jest.spyOn(totpService, "getCode$");
const autofillResult = await autofillService.doAutoFill(autofillOptions);
expect(autofillService.getShouldAutoCopyTotp).not.toHaveBeenCalled();
expect(totpService.getCode).not.toHaveBeenCalled();
expect(totpService.getCode$).not.toHaveBeenCalled();
expect(autofillResult).toBeNull();
});
@@ -984,12 +984,12 @@ describe("AutofillService", () => {
.spyOn(billingAccountProfileStateService, "hasPremiumFromAnySource$")
.mockImplementation(() => of(true));
jest.spyOn(autofillService, "getShouldAutoCopyTotp").mockResolvedValue(false);
jest.spyOn(totpService, "getCode");
jest.spyOn(totpService, "getCode$");
const autofillResult = await autofillService.doAutoFill(autofillOptions);
expect(autofillService.getShouldAutoCopyTotp).toHaveBeenCalled();
expect(totpService.getCode).not.toHaveBeenCalled();
expect(totpService.getCode$).not.toHaveBeenCalled();
expect(autofillResult).toBeNull();
});
});

View File

@@ -494,7 +494,7 @@ export default class AutofillService implements AutofillServiceInterface {
const shouldAutoCopyTotp = await this.getShouldAutoCopyTotp();
totp = shouldAutoCopyTotp
? await this.totpService.getCode(options.cipher.login.totp)
? (await firstValueFrom(this.totpService.getCode$(options.cipher.login.totp))).code
: null;
}),
);
@@ -992,7 +992,10 @@ export default class AutofillService implements AutofillServiceInterface {
}
filledFields[t.opid] = t;
let totpValue = await this.totpService.getCode(login.totp);
const totpResponse = await firstValueFrom(
this.totpService.getCode$(options.cipher.login.totp),
);
let totpValue = totpResponse.code;
if (totpValue.length == totps.length) {
totpValue = totpValue.charAt(i);
}

View File

@@ -978,7 +978,7 @@ export default class MainBackground {
this.authService,
this.accountService,
);
this.totpService = new TotpService(this.cryptoFunctionService, this.logService);
this.totpService = new TotpService(this.sdkService);
this.scriptInjectorService = new BrowserScriptInjectorService(
this.domainSettingsService,

View File

@@ -84,6 +84,7 @@ import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/comm
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import {
AbstractStorageService,
@@ -285,7 +286,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: TotpServiceAbstraction,
useClass: TotpService,
deps: [CryptoFunctionService, LogService],
deps: [SdkService],
}),
safeProvider({
provide: OffscreenDocumentService,

View File

@@ -254,8 +254,8 @@ export class GetCommand extends DownloadCommand {
return Response.error("No TOTP available for this login.");
}
const totp = await this.totpService.getCode(cipher.login.totp);
if (totp == null) {
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
if (!totpResponse.code) {
return Response.error("Couldn't generate TOTP code.");
}
@@ -276,7 +276,7 @@ export class GetCommand extends DownloadCommand {
}
}
const res = new StringResponse(totp);
const res = new StringResponse(totpResponse.code);
return Response.success(res);
}

View File

@@ -766,7 +766,7 @@ export class ServiceContainer {
this.stateProvider,
);
this.totpService = new TotpService(this.cryptoFunctionService, this.logService);
this.totpService = new TotpService(this.sdkService);
this.importApiService = new ImportApiService(this.apiService);

View File

@@ -208,8 +208,8 @@ export class VaultComponent implements OnInit, OnDestroy {
tCipher.login.hasTotp &&
this.userHasPremiumAccess
) {
const value = await this.totpService.getCode(tCipher.login.totp);
this.copyValue(tCipher, value, "verificationCodeTotp", "TOTP");
const value = await firstValueFrom(this.totpService.getCode$(tCipher.login.totp));
this.copyValue(tCipher, value.code, "verificationCodeTotp", "TOTP");
}
break;
}
@@ -382,8 +382,8 @@ export class VaultComponent implements OnInit, OnDestroy {
menu.push({
label: this.i18nService.t("copyVerificationCodeTotp"),
click: async () => {
const value = await this.totpService.getCode(cipher.login.totp);
this.copyValue(cipher, value, "verificationCodeTotp", "TOTP");
const value = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP");
},
});
}

View File

@@ -128,54 +128,57 @@
<span class="row-label">{{ "typePasskey" | i18n }}</span>
{{ fido2CredentialCreationDateValue }}
</div>
<div
class="box-content-row box-content-row-flex totp"
[ngClass]="{ low: totpLow }"
*ngIf="cipher.login.totp && totpCode"
>
<div class="row-main">
<span
class="row-label draggable"
draggable="true"
(dragstart)="setTextDataOnDrag($event, totpCode)"
>{{ "verificationCodeTotp" | i18n }}</span
>
<span class="totp-code">{{ totpCodeFormatted }}</span>
</div>
<span class="totp-countdown" aria-hidden="true">
<span class="totp-sec">{{ totpSec }}</span>
<svg>
<g>
<circle
class="totp-circle inner"
r="12.6"
cy="16"
cx="16"
[ngStyle]="{ 'stroke-dashoffset.px': totpDash }"
></circle>
<circle class="totp-circle outer" r="14" cy="16" cx="16"></circle>
</g>
</svg>
</span>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
title="{{ 'copyValue' | i18n }}"
(click)="copy(totpCode, 'verificationCodeTotp', 'TOTP')"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
<span class="sr-only">{{ "copyValue" | i18n }}</span>
<ng-container *ngIf="cipher.login.totp && totpInfo$ | async as totpInfo">
<div
class="box-content-row box-content-row-flex totp"
[ngClass]="{ low: totpInfo.totpLow }"
>
<div class="row-main">
<span
class="sr-only exists-only-on-parent-focus"
aria-live="polite"
aria-atomic="true"
>{{ totpSec }}</span
class="row-label draggable"
draggable="true"
(dragstart)="setTextDataOnDrag($event, totpInfo.totpCode)"
>{{ "verificationCodeTotp" | i18n }}</span
>
</button>
<span class="totp-code">{{ totpInfo.totpCodeFormatted }}</span>
</div>
<span class="totp-countdown" aria-hidden="true">
<span class="totp-sec">{{ totpInfo.totpSec }}</span>
<svg>
<g>
<circle
class="totp-circle inner"
r="12.6"
cy="16"
cx="16"
[ngStyle]="{ 'stroke-dashoffset.px': totpInfo.totpDash }"
></circle>
<circle class="totp-circle outer" r="14" cy="16" cx="16"></circle>
</g>
</svg>
</span>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
title="{{ 'copyValue' | i18n }}"
(click)="copy(totpInfo.totpCode, 'verificationCodeTotp', 'TOTP')"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
<span class="sr-only">{{ "copyValue" | i18n }}</span>
<span
class="sr-only exists-only-on-parent-focus"
aria-live="polite"
aria-atomic="true"
>{{ totpInfo.totpSec }}</span
>
</button>
</div>
</div>
</div>
</ng-container>
<div class="box-content-row box-content-row-flex totp" *ngIf="showPremiumRequiredTotp">
<div class="row-main">
<span class="row-label">{{ "verificationCodeTotp" | i18n }}</span>

View File

@@ -1177,7 +1177,8 @@ export class VaultComponent implements OnInit, OnDestroy {
typeI18nKey = "password";
} else if (field === "totp") {
aType = "TOTP";
value = await this.totpService.getCode(cipher.login.totp);
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
value = totpResponse?.code;
typeI18nKey = "verificationCodeTotp";
} else {
this.toastService.showToast({

View File

@@ -135,12 +135,15 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
if (this.showTotp()) {
await this.totpUpdateCode();
const interval = this.totpService.getTimeInterval(this.cipher.login.totp);
await this.totpTick(interval);
this.totpInterval = window.setInterval(async () => {
const totpResponse = await firstValueFrom(this.totpService.getCode$(this.cipher.login.totp));
if (totpResponse) {
const interval = totpResponse.period;
await this.totpTick(interval);
}, 1000);
this.totpInterval = window.setInterval(async () => {
await this.totpTick(interval);
}, 1000);
}
}
this.cardIsExpired = isCardExpired(this.cipher.card);
@@ -273,7 +276,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
return;
}
this.totpCode = await this.totpService.getCode(this.cipher.login.totp);
const totpResponse = await firstValueFrom(this.totpService.getCode$(this.cipher.login.totp));
this.totpCode = totpResponse?.code;
if (this.totpCode != null) {
if (this.totpCode.length > 4) {
const half = Math.floor(this.totpCode.length / 2);

View File

@@ -1146,7 +1146,8 @@ export class VaultComponent implements OnInit, OnDestroy {
typeI18nKey = "password";
} else if (field === "totp") {
aType = "TOTP";
value = await this.totpService.getCode(cipher.login.totp);
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
value = totpResponse.code;
typeI18nKey = "verificationCodeTotp";
} else {
this.toastService.showToast({