Improve authenticator 2FA credential setup (SKY-6598) (#6783)

Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
Suchintan 2026-06-24 00:01:02 -04:00 committed by GitHub
parent 54752d8b64
commit 3235d4e648
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1660 additions and 69 deletions

View file

@ -39,8 +39,8 @@ The preferred method. Skyvern generates valid 6-digit codes on demand during log
<Step title="Select Authenticator App">
Choose **Authenticator App** from the three options.
</Step>
<Step title="Paste your TOTP secret key">
Enter the secret key into the **Authenticator Key** field and click **Save**.
<Step title="Add your TOTP setup key">
Enter the secret key into the **Authenticator Key** field, or click **Scan QR** to upload a QR code image from the site's 2FA setup screen. Then click **Save**.
<img src="/images/cloud/totp-setup-2.png" alt="Add Credential dialog with Authenticator App selected and the Authenticator Key field" />
</Step>
</Steps>
@ -53,7 +53,7 @@ The secret key is the base32-encoded string behind the QR code you'd normally sc
- **LastPass**: Edit the login → Advanced Settings → copy the TOTP secret
- **Site settings**: Many sites show a "Can't scan?" link during 2FA setup that reveals the text key
If you only have a QR code, decode it to extract the `secret=` parameter from the `otpauth://totp/...?secret=BASE32KEY` URI.
If you only have a QR code, use **Scan QR** in the credential form or paste the full `otpauth://totp/...?secret=BASE32KEY` URI.
</Accordion>
---
@ -252,7 +252,7 @@ curl -X GET "https://api.skyvern.com/v1/credentials/totp?totp_identifier=user@ex
| `otp_type` | Filter by `totp` or `magic_link` |
| `limit` | Number of records (default 50, max 200) |
Returns a list ordered newest first. Only codes created within the last 10 minutes (configurable via `TOTP_LIFESPAN_MINUTES`) are returned.
Returns historical records ordered newest first, capped by `limit`. Runtime polling still only uses unexpired codes within `TOTP_LIFESPAN_MINUTES`.
<CardGroup cols={2}>
<Card

View file

@ -40,6 +40,8 @@ Select the method that matches how you receive verification codes:
When you enable 2FA on a website, you're typically shown a QR code or a secret key. Store this secret with your credential, and Skyvern generates valid codes automatically.
In the Skyvern UI, paste the manual key or full `otpauth://` URI into **Authenticator Key**. You can also click **Scan QR** and upload a QR code image from the site's 2FA setup screen.
<CodeGroup>
```python Python
import os
@ -520,7 +522,7 @@ curl -X GET "https://api.skyvern.com/v1/credentials/totp?totp_identifier=user@ex
| `limit` | Number of records (default 50, max 200) |
<Note>
Skyvern only returns codes from the last 10 minutes (configurable via `TOTP_LIFESPAN_MINUTES`).
This endpoint returns historical records ordered newest first, capped by `limit`. Runtime polling still only uses unexpired codes within `TOTP_LIFESPAN_MINUTES`.
</Note>
---

View file

@ -15,7 +15,7 @@ Skyvern supports logging into websites that require a 2FA/MFA/Verification code.
Step 1: Save your username and password in [Skyvern Credential](https://app.skyvern.com/credentials). See [Password Management](/credentials/passwords#manage-passwords-in-skyvern-cloud) for more details.
Step 2: Add your account by manually entering the secret key (extracted from the QR code). Not sure how to get it? [Follow this guide](https://bitwarden.com/help/integrated-authenticator/).
Step 2: Add your account by entering the secret key, pasting the full `otpauth://` URI, or clicking **Scan QR** to upload a QR code image from the site's 2FA setup screen. Not sure how to get the key? [Follow this guide](https://bitwarden.com/help/integrated-authenticator/).
> 💡 Don't have the key? Contact [Skyvern Support](mailto:support@skyvern.com) and we can help you get it.
@ -228,4 +228,4 @@ curl -X GET "https://api.skyvern.com/v1/credentials/totp?totp_identifier=user@ex
- `otp_type` *(optional)* filter on `totp` or `magic_link`.
- `limit` *(optional)* number of records to return (default `50`, maximum `200`).
The response is a list of TOTP objects, ordered newest first. Skyvern only returns codes created within the last `TOTP_LIFESPAN_MINUTES` (10 minutes by default).
The response is a list of TOTP objects, ordered newest first and capped by `limit`. Runtime polling still only uses unexpired codes within `TOTP_LIFESPAN_MINUTES`.

View file

@ -800,6 +800,11 @@ export type PasswordCredentialApiResponse = {
totp_identifier?: string | null;
};
export type CredentialTotpCodeResponse = {
code: string;
seconds_remaining: number;
};
export type CreditCardCredentialApiResponse = {
last_four: string;
brand: string;

View file

@ -0,0 +1,175 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getClient } from "@/api/AxiosClient";
import type { CredentialApiResponse } from "@/api/types";
import { copyText } from "@/util/copyText";
import { CredentialItem } from "./CredentialItem";
vi.mock("@/api/AxiosClient", () => ({ getClient: vi.fn() }));
vi.mock("@/hooks/useCredentialGetter", () => ({
useCredentialGetter: () => null,
}));
vi.mock("@/store/useCredentialTestStore", () => ({
useCredentialTestStore: (
selector: (state: { activeTest: null }) => unknown,
) => selector({ activeTest: null }),
}));
vi.mock("@/util/copyText", () => ({ copyText: vi.fn() }));
vi.mock("@/components/ui/use-toast", () => ({
toast: vi.fn(),
}));
vi.mock("./CredentialFolderSelector", () => ({
CredentialFolderSelector: () => <button>Folder</button>,
}));
vi.mock("./DeleteCredentialButton", () => ({
DeleteCredentialButton: () => <button>Delete</button>,
}));
vi.mock("./CredentialsModal", () => ({
CredentialsModal: () => null,
}));
const mockedGetClient = vi.mocked(getClient);
const mockedCopyText = vi.mocked(copyText);
function makePasswordCredential(
overrides: Partial<CredentialApiResponse> = {},
): CredentialApiResponse {
return {
credential_id: "cred_1",
credential_type: "password",
name: "Example Login",
credential: {
username: "user@example.com",
totp_type: "authenticator",
totp_identifier: null,
},
...overrides,
};
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("CredentialItem TOTP code preview", () => {
it("loads and copies the current authenticator code on demand", async () => {
mockedGetClient.mockResolvedValue({
get: vi.fn().mockResolvedValue({
data: { code: "123456", seconds_remaining: 24 },
}),
} as never);
mockedCopyText.mockResolvedValue(true);
render(<CredentialItem credential={makePasswordCredential()} />);
expect(screen.getAllByText("••••••••").length).toBeGreaterThan(1);
fireEvent.click(screen.getByRole("button", { name: "Show 2FA code" }));
await waitFor(() => {
expect(screen.getByText("123 456")).toBeTruthy();
expect(screen.getByText("24s")).toBeTruthy();
});
expect(mockedGetClient).toHaveBeenCalledWith(null, "sans-api-v1");
fireEvent.click(screen.getByRole("button", { name: "Copy 2FA code" }));
await waitFor(() => {
expect(mockedCopyText).toHaveBeenCalledWith("123456");
});
});
it("shows an API error when the saved authenticator code cannot be loaded", async () => {
mockedGetClient.mockResolvedValue({
get: vi.fn().mockRejectedValue({
isAxiosError: true,
response: { data: { detail: "Saved authenticator key is invalid." } },
}),
} as never);
render(<CredentialItem credential={makePasswordCredential()} />);
fireEvent.click(screen.getByRole("button", { name: "Show 2FA code" }));
await waitFor(() => {
expect(
screen.getByText("Saved authenticator key is invalid."),
).toBeTruthy();
});
});
it("refreshes a hidden authenticator code without revealing it", async () => {
const getMock = vi
.fn()
.mockResolvedValueOnce({
data: { code: "123456", seconds_remaining: 24 },
})
.mockResolvedValueOnce({
data: { code: "654321", seconds_remaining: 20 },
});
mockedGetClient.mockResolvedValue({ get: getMock } as never);
render(<CredentialItem credential={makePasswordCredential()} />);
fireEvent.click(screen.getByRole("button", { name: "Show 2FA code" }));
await waitFor(() => {
expect(screen.getByText("123 456")).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Hide 2FA code" }));
expect(screen.queryByText("123 456")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Refresh 2FA code" }));
await waitFor(() => {
expect(getMock).toHaveBeenCalledTimes(2);
});
expect(screen.queryByText("654 321")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Show 2FA code" }));
expect(screen.getByText("654 321")).toBeTruthy();
expect(getMock).toHaveBeenCalledTimes(2);
});
it("clears the displayed code after it expires", async () => {
mockedGetClient.mockResolvedValue({
get: vi.fn().mockResolvedValue({
data: { code: "123456", seconds_remaining: 1 },
}),
} as never);
render(<CredentialItem credential={makePasswordCredential()} />);
fireEvent.click(screen.getByRole("button", { name: "Show 2FA code" }));
await waitFor(() => {
expect(screen.getByText("123 456")).toBeTruthy();
expect(
screen.getByRole("button", { name: "Copy 2FA code" }),
).toBeTruthy();
});
await waitFor(
() => {
expect(screen.queryByText("123 456")).toBeNull();
const copyButton = screen.getByRole("button", {
name: "Copy 2FA code",
}) as HTMLButtonElement;
expect(copyButton.disabled).toBe(true);
expect(
screen.getByText("2FA code expired. Refresh to load a new code."),
).toBeTruthy();
},
{ timeout: 3000 },
);
});
});

View file

@ -1,12 +1,17 @@
import {
CredentialApiResponse,
isCreditCardCredential,
isPasswordCredential,
isSecretCredential,
type CredentialApiResponse,
type CredentialTotpCodeResponse,
} from "@/api/types";
import { getClient } from "@/api/AxiosClient";
import { SelectionCheckbox } from "@/components/SelectionCheckbox";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
CopyIcon,
EyeNoneIcon,
EyeOpenIcon,
ExclamationTriangleIcon,
ExternalLinkIcon,
Pencil1Icon,
@ -26,6 +31,11 @@ import { CredentialsModal } from "./CredentialsModal";
import { credentialTypeToModalType } from "./useCredentialModalState";
import { SaveIcon } from "@/components/icons/SaveIcon";
import { useCredentialTestStore } from "@/store/useCredentialTestStore";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { toast } from "@/components/ui/use-toast";
import { copyText } from "@/util/copyText";
import { isAxiosError } from "axios";
import { cn } from "@/util/utils";
type Props = {
credential: CredentialApiResponse;
@ -40,6 +50,208 @@ type Props = {
onSelect?: (index: number, shiftKey: boolean) => void;
};
function formatTotpCode(code: string): string {
const splitAt = Math.ceil(code.length / 2);
return `${code.slice(0, splitAt)} ${code.slice(splitAt)}`;
}
function CredentialTotpCodePreview({ credentialId }: { credentialId: string }) {
const credentialGetter = useCredentialGetter();
const isMountedRef = useRef(true);
const [totpCode, setTotpCode] = useState<CredentialTotpCodeResponse | null>(
null,
);
const [secondsRemaining, setSecondsRemaining] = useState<number | null>(null);
const [expiresAt, setExpiresAt] = useState<number | null>(null);
const [isCodeVisible, setIsCodeVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => {
if (expiresAt === null) {
return;
}
const updateRemaining = () => {
const nextSecondsRemaining = Math.max(
0,
Math.ceil((expiresAt - Date.now()) / 1000),
);
if (nextSecondsRemaining <= 0) {
setTotpCode(null);
setSecondsRemaining(null);
setExpiresAt(null);
setIsCodeVisible(false);
setError("2FA code expired. Refresh to load a new code.");
return;
}
setSecondsRemaining(nextSecondsRemaining);
};
updateRemaining();
const interval = window.setInterval(updateRemaining, 1000);
return () => window.clearInterval(interval);
}, [expiresAt]);
const fetchTotpCode = async ({
reveal = true,
}: { reveal?: boolean } = {}) => {
setIsLoading(true);
setError(null);
try {
const client = await getClient(credentialGetter, "sans-api-v1");
const response = await client.get<CredentialTotpCodeResponse>(
`/credentials/${credentialId}/totp-code`,
);
if (!isMountedRef.current) {
return;
}
setTotpCode(response.data);
setSecondsRemaining(response.data.seconds_remaining);
setExpiresAt(Date.now() + response.data.seconds_remaining * 1000);
setIsCodeVisible(reveal);
} catch (caught) {
if (!isMountedRef.current) {
return;
}
const detail = isAxiosError(caught)
? (caught.response?.data as { detail?: string } | undefined)?.detail
: undefined;
setTotpCode(null);
setSecondsRemaining(null);
setExpiresAt(null);
setIsCodeVisible(false);
setError(detail ?? "Unable to load code");
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
};
const hasValidTotpCode =
totpCode !== null && secondsRemaining !== null && secondsRemaining > 0;
const visibleTotpCode =
isCodeVisible && hasValidTotpCode ? totpCode.code : null;
const canCopyCode = Boolean(visibleTotpCode);
const handleToggleCodeVisibility = () => {
if (isCodeVisible) {
setIsCodeVisible(false);
return;
}
if (hasValidTotpCode) {
setIsCodeVisible(true);
return;
}
void fetchTotpCode();
};
const handleCopyCode = async () => {
if (!visibleTotpCode) {
return;
}
// Display formatting adds spacing; copy the raw code for paste targets.
const copied = await copyText(visibleTotpCode);
toast({
title: copied ? "Copied code" : "Copy failed",
description: copied
? "The current 2FA code was copied to your clipboard."
: "Could not copy the current 2FA code.",
variant: copied ? "success" : "destructive",
});
};
return (
<div className="space-y-1">
<div className="flex min-h-6 items-center gap-2">
<span
className={cn(
"inline-block min-w-[5.75rem] font-mono text-sm tabular-nums text-foreground",
!isCodeVisible && "text-muted-foreground",
)}
>
{isCodeVisible && totpCode
? formatTotpCode(totpCode.code)
: "••••••••"}
</span>
<span
className={cn(
"inline-block w-7 text-right text-xs text-slate-400",
(!isCodeVisible || !hasValidTotpCode) && "invisible",
)}
>
{secondsRemaining ?? 0}s
</span>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="tertiary"
className="h-6 w-6"
onClick={handleToggleCodeVisibility}
disabled={isLoading}
aria-label={isCodeVisible ? "Hide 2FA code" : "Show 2FA code"}
>
{isCodeVisible ? (
<EyeNoneIcon className="size-3.5" />
) : (
<EyeOpenIcon className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isCodeVisible ? "Hide 2FA code" : "Show 2FA code"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="tertiary"
className="h-6 w-6"
onClick={() => void fetchTotpCode({ reveal: isCodeVisible })}
disabled={isLoading}
aria-label="Refresh 2FA code"
>
<ReloadIcon
className={cn("size-3.5", isLoading && "animate-spin")}
/>
</Button>
</TooltipTrigger>
<TooltipContent>Refresh 2FA code</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="tertiary"
className="h-6 w-6"
onClick={handleCopyCode}
disabled={!canCopyCode}
aria-label="Copy 2FA code"
>
<CopyIcon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy 2FA code</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
function CredentialItem({
credential,
onStartBackgroundTest,
@ -105,15 +317,25 @@ function CredentialItem({
2FA Type
</p>
)}
{credentialData.totp_type === "authenticator" && (
<p className="text-sm text-neutral-600 dark:text-slate-400">
2FA Code
</p>
)}
</div>
<div className="space-y-2">
<p className="text-sm">{credentialData.username}</p>
<p className="text-sm">{"********"}</p>
<p className="text-sm">{"••••••••"}</p>
{credentialData.totp_type !== "none" && (
<p className="text-sm text-blue-400">
<p className="text-sm">
{getTotpTypeDisplay(credentialData.totp_type)}
</p>
)}
{credentialData.totp_type === "authenticator" && (
<CredentialTotpCodePreview
credentialId={credential.credential_id}
/>
)}
</div>
</div>
</div>

View file

@ -0,0 +1,48 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { getAuthenticatorKeyError } from "./credentialTotpValidation";
describe("getAuthenticatorKeyError", () => {
it("requires an authenticator key when authenticator 2FA is selected", () => {
expect(
getAuthenticatorKeyError({ totp: " ", totp_type: "authenticator" }),
).toBe("Authenticator key is required.");
});
it("rejects Base32-shaped authenticator keys that are too short to be useful", () => {
expect(
getAuthenticatorKeyError({ totp: "A", totp_type: "authenticator" }),
).toBe(
"Authenticator key should be a raw Base32 setup key or full otpauth:// URI.",
);
});
it("accepts a raw Base32 key or a full otpauth URI", () => {
expect(
getAuthenticatorKeyError({
totp: "JBSW-Y3DP EHPK3PXP",
totp_type: "authenticator",
}),
).toBeNull();
expect(
getAuthenticatorKeyError({
totp: "otpauth://totp/user@example.com?secret=JBSWY3DPEHPK3PXP",
totp_type: "authenticator",
}),
).toBeNull();
});
it("does not validate the key for email, text, or disabled 2FA methods", () => {
expect(
getAuthenticatorKeyError({ totp: "", totp_type: "email" }),
).toBeNull();
expect(
getAuthenticatorKeyError({ totp: "", totp_type: "text" }),
).toBeNull();
expect(
getAuthenticatorKeyError({ totp: "", totp_type: "none" }),
).toBeNull();
});
});

View file

@ -49,6 +49,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { getHostname } from "@/util/getHostname";
import { useCustomCredentialServiceConfig } from "@/hooks/useCustomCredentialServiceConfig";
import { getAuthenticatorKeyError } from "./credentialTotpValidation";
const PASSWORD_CREDENTIAL_INITIAL_VALUES = {
name: "",
@ -891,6 +892,9 @@ function CredentialsModal({
});
return;
}
if (authenticatorKeyError) {
return;
}
// If test passed, rename the temp credential instead of creating a new one
if (testAndSave && testStatus === "completed" && testCredentialId) {
@ -1086,6 +1090,13 @@ function CredentialsModal({
</div>
) : undefined;
const shouldValidateAuthenticatorKey =
type === CredentialModalTypes.PASSWORD &&
(!isEditMode || editingGroups.values);
const authenticatorKeyError = shouldValidateAuthenticatorKey
? getAuthenticatorKeyError(passwordCredentialValues)
: null;
const credentialContent = (() => {
if (type === CredentialModalTypes.PASSWORD) {
return (
@ -1100,6 +1111,7 @@ function CredentialsModal({
editingGroups={editingGroups}
onEnableEditName={handleEnableEditName}
onEnableEditValues={handleEnableEditValues}
totpError={authenticatorKeyError}
beforeCredentialFields={customVaultCheckbox}
afterUrl={
<div className="space-y-3">
@ -1262,6 +1274,9 @@ function CredentialsModal({
});
return;
}
if (authenticatorKeyError) {
return;
}
// Set testing state immediately to avoid button flash
pollCancelledRef.current = false;
@ -1316,6 +1331,7 @@ function CredentialsModal({
testUrl.trim() !== "" &&
passwordCredentialValues.username.trim() !== "" &&
passwordCredentialValues.password.trim() !== "" &&
!authenticatorKeyError &&
!isTestInProgress;
return (

View file

@ -6,6 +6,7 @@ import {
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { useEffect, useState } from "react";
import { MemoryRouter } from "react-router-dom";
@ -72,7 +73,10 @@ function ModalLikeHarness({
}
describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression)", () => {
afterEach(() => cleanup());
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("preserves saved totp_identifier when modal hydrates an email-TOTP credential into a form that started at PASSWORD_CREDENTIAL_INITIAL_VALUES", async () => {
// Reproduces the bug:
@ -149,6 +153,263 @@ describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression
expect(autoFillCall).toBeTruthy();
});
it("marks Authenticator App as the selected 2FA method when a new credential opens the 2FA section", async () => {
const onChangeSpy = vi.fn();
render(
<MemoryRouter>
<PasswordCredentialContent
values={{
name: "New cred",
username: "new-user@example.com",
password: "password",
totp: "",
totp_type: "none",
totp_identifier: "",
}}
onChange={onChangeSpy}
/>
</MemoryRouter>,
);
await act(async () => {
fireEvent.click(screen.getByText("Two-Factor Authentication"));
});
const authenticatorCall = onChangeSpy.mock.calls.find((call) => {
const next = call[0] as Values;
return next.totp_type === "authenticator";
});
expect(authenticatorCall).toBeTruthy();
});
it("keeps Authenticator App selected when the user types a key without clicking the method tile", async () => {
const onChangeSpy = vi.fn();
render(
<MemoryRouter>
<PasswordCredentialContent
values={{
name: "New cred",
username: "new-user@example.com",
password: "password",
totp: "",
totp_type: "none",
totp_identifier: "",
}}
onChange={onChangeSpy}
/>
</MemoryRouter>,
);
await act(async () => {
fireEvent.click(screen.getByText("Two-Factor Authentication"));
});
fireEvent.change(screen.getByPlaceholderText("e.g. JBSWY3DPEHPK3PXP"), {
target: { value: "JBSWY3DPEHPK3PXP" },
});
const typedCall = onChangeSpy.mock.calls.find((call) => {
const next = call[0] as Values;
return (
next.totp_type === "authenticator" && next.totp === "JBSWY3DPEHPK3PXP"
);
});
expect(typedCall).toBeTruthy();
});
it("imports an otpauth URI from an uploaded QR code image", async () => {
const onChangeSpy = vi.fn();
const imageBitmap = { close: vi.fn() } as unknown as ImageBitmap;
class MockBarcodeDetector {
detect = vi.fn().mockResolvedValue([
{
rawValue:
"otpauth://totp/user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
},
]);
}
vi.stubGlobal("createImageBitmap", vi.fn().mockResolvedValue(imageBitmap));
vi.stubGlobal("BarcodeDetector", MockBarcodeDetector);
render(
<MemoryRouter>
<PasswordCredentialContent
values={{
name: "New cred",
username: "new-user@example.com",
password: "password",
totp: "",
totp_type: "none",
totp_identifier: "",
}}
onChange={onChangeSpy}
/>
</MemoryRouter>,
);
await act(async () => {
fireEvent.click(screen.getByText("Two-Factor Authentication"));
});
fireEvent.change(screen.getByLabelText("Upload QR code image"), {
target: {
files: [new File(["qr"], "totp.png", { type: "image/png" })],
},
});
await waitFor(() => {
const qrCall = onChangeSpy.mock.calls.find((call) => {
const next = call[0] as Values;
return (
next.totp_type === "authenticator" &&
next.totp.startsWith("otpauth://totp/")
);
});
expect(qrCall).toBeTruthy();
});
expect(imageBitmap.close).toHaveBeenCalled();
});
it("rejects QR codes that are not 2FA setup values", async () => {
const onChangeSpy = vi.fn();
const imageBitmap = { close: vi.fn() } as unknown as ImageBitmap;
class MockBarcodeDetector {
detect = vi.fn().mockResolvedValue([
{
rawValue: "https://example.com/account/settings",
},
]);
}
vi.stubGlobal("createImageBitmap", vi.fn().mockResolvedValue(imageBitmap));
vi.stubGlobal("BarcodeDetector", MockBarcodeDetector);
render(
<MemoryRouter>
<PasswordCredentialContent
values={{
name: "New cred",
username: "new-user@example.com",
password: "password",
totp: "",
totp_type: "none",
totp_identifier: "",
}}
onChange={onChangeSpy}
/>
</MemoryRouter>,
);
await act(async () => {
fireEvent.click(screen.getByText("Two-Factor Authentication"));
});
fireEvent.change(screen.getByLabelText("Upload QR code image"), {
target: {
files: [new File(["qr"], "totp.png", { type: "image/png" })],
},
});
await waitFor(() => {
expect(
screen.getByText(
"This QR code doesn't look like a 2FA setup code. Make sure you're scanning the setup QR from the site's 2FA settings.",
),
).toBeTruthy();
});
expect(imageBitmap.close).toHaveBeenCalled();
expect(
onChangeSpy.mock.calls.some((call) => {
const next = call[0] as Values;
return next.totp === "https://example.com/account/settings";
}),
).toBe(false);
});
it("shows an inline fallback when the browser cannot scan QR codes", async () => {
const onChangeSpy = vi.fn();
vi.stubGlobal("BarcodeDetector", undefined);
render(
<MemoryRouter>
<PasswordCredentialContent
values={{
name: "New cred",
username: "new-user@example.com",
password: "password",
totp: "",
totp_type: "none",
totp_identifier: "",
}}
onChange={onChangeSpy}
/>
</MemoryRouter>,
);
await act(async () => {
fireEvent.click(screen.getByText("Two-Factor Authentication"));
});
fireEvent.change(screen.getByLabelText("Upload QR code image"), {
target: {
files: [new File(["qr"], "totp.png", { type: "image/png" })],
},
});
await waitFor(() => {
expect(
screen.getByText(
"QR scanning is not supported by this browser. Paste the setup key or otpauth:// URI instead.",
),
).toBeTruthy();
});
});
it("shows the QR fallback when the browser rejects QR detection", async () => {
const onChangeSpy = vi.fn();
const createImageBitmapMock = vi.fn();
class MockBarcodeDetector {
constructor() {
throw new TypeError("Unsupported barcode format");
}
detect = vi.fn();
}
vi.stubGlobal("createImageBitmap", createImageBitmapMock);
vi.stubGlobal("BarcodeDetector", MockBarcodeDetector);
render(
<MemoryRouter>
<PasswordCredentialContent
values={{
name: "New cred",
username: "new-user@example.com",
password: "password",
totp: "",
totp_type: "none",
totp_identifier: "",
}}
onChange={onChangeSpy}
/>
</MemoryRouter>,
);
await act(async () => {
fireEvent.click(screen.getByText("Two-Factor Authentication"));
});
fireEvent.change(screen.getByLabelText("Upload QR code image"), {
target: {
files: [new File(["qr"], "totp.png", { type: "image/png" })],
},
});
await waitFor(() => {
expect(
screen.getByText(
"QR scanning is not supported by this browser. Paste the setup key or otpauth:// URI instead.",
),
).toBeTruthy();
});
expect(createImageBitmapMock).not.toHaveBeenCalled();
});
it("preserves an intentionally-empty totp_identifier when hydrating a saved email-TOTP credential", async () => {
// Regression for the empty-string false-positive in the username-rename
// follow-on path: at mount, prevUsername="" and identifier="". After

View file

@ -5,19 +5,24 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/util/utils";
import {
CheckIcon,
EnvelopeClosedIcon,
EyeNoneIcon,
EyeOpenIcon,
MobileIcon,
Pencil1Icon,
ReloadIcon,
UploadIcon,
} from "@radix-ui/react-icons";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { decodeQrCodeImage } from "./decodeQrCodeImage";
type Props = {
values: {
@ -51,6 +56,7 @@ type Props = {
editingGroups?: { name: boolean; values: boolean };
onEnableEditName?: () => void;
onEnableEditValues?: () => void;
totpError?: string | null;
};
function PasswordCredentialContent({
@ -66,6 +72,7 @@ function PasswordCredentialContent({
editingGroups,
onEnableEditName,
onEnableEditValues,
totpError,
}: Props) {
const { name, username, password, totp, totp_type, totp_identifier } = values;
const nameReadOnly = editMode && !editingGroups?.name;
@ -78,6 +85,9 @@ function PasswordCredentialContent({
);
const [totpAccordionValue, setTotpAccordionValue] = useState<string>("");
const [showPassword, setShowPassword] = useState(false);
const [qrCodeScanError, setQrCodeScanError] = useState<string | null>(null);
const [isScanningQrCode, setIsScanningQrCode] = useState(false);
const qrCodeInputRef = useRef<HTMLInputElement>(null);
// Sync totpMethod and auto-expand accordion when totp_type prop changes
// (e.g. edit data arriving after mount)
@ -147,6 +157,7 @@ function PasswordCredentialContent({
onEnableEditValues?.();
const prevMethod = totpMethod;
setTotpMethod(method);
setQrCodeScanError(null);
const updates: Partial<Props["values"]> = {
totp: method === "authenticator" ? totp : "",
@ -170,6 +181,51 @@ function PasswordCredentialContent({
updateValues(updates);
};
const handleTotpAccordionValueChange = (value: string) => {
setTotpAccordionValue(value);
if (
value === "two-factor-authentication" &&
totp_type === "none" &&
!valuesReadOnly
) {
handleTotpMethodChange(totpMethod);
}
};
const handleAuthenticatorTotpChange = (value: string) => {
onEnableEditValues?.();
setQrCodeScanError(null);
setTotpMethod("authenticator");
updateValues({ totp: value, totp_type: "authenticator" });
};
const handleQrCodeFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
onEnableEditValues?.();
setIsScanningQrCode(true);
setQrCodeScanError(null);
try {
const qrCodeValue = await decodeQrCodeImage(file);
setTotpMethod("authenticator");
updateValues({ totp: qrCodeValue, totp_type: "authenticator" });
} catch (caught) {
setQrCodeScanError(
caught instanceof Error
? caught.message
: "Unable to scan that QR code. Paste the setup key instead.",
);
} finally {
setIsScanningQrCode(false);
}
};
return (
<div className="space-y-5">
<div className="flex items-center gap-12">
@ -287,7 +343,7 @@ function PasswordCredentialContent({
type="single"
collapsible
value={totpAccordionValue}
onValueChange={setTotpAccordionValue}
onValueChange={handleTotpAccordionValueChange}
>
<AccordionItem value="two-factor-authentication" className="border-b-0">
<AccordionTrigger className="py-2">
@ -302,39 +358,61 @@ function PasswordCredentialContent({
<div className="grid h-36 grid-cols-3 gap-4">
<div
className={cn(
"flex cursor-pointer items-center justify-center gap-2 rounded-lg bg-slate-elevation1 hover:bg-slate-elevation3",
"relative flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-transparent bg-slate-elevation1 hover:bg-slate-elevation3",
{
"bg-slate-elevation3": totpMethod === "authenticator",
"border-blue-400 bg-slate-elevation3 ring-1 ring-blue-400/60":
totpMethod === "authenticator",
},
)}
onClick={() => handleTotpMethodChange("authenticator")}
>
{totpMethod === "authenticator" && (
<span className="absolute right-3 top-3 flex size-5 items-center justify-center rounded-full bg-blue-500 text-white">
<CheckIcon className="size-3" />
</span>
)}
<QRCodeIcon className="h-6 w-6" />
<Label>Authenticator App</Label>
<Label className="cursor-pointer text-center">
Authenticator App
</Label>
</div>
<div
className={cn(
"flex cursor-pointer items-center justify-center gap-2 rounded-lg bg-slate-elevation1 hover:bg-slate-elevation3",
"relative flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-transparent bg-slate-elevation1 hover:bg-slate-elevation3",
{
"bg-slate-elevation3": totpMethod === "email",
"border-blue-400 bg-slate-elevation3 ring-1 ring-blue-400/60":
totpMethod === "email",
},
)}
onClick={() => handleTotpMethodChange("email")}
>
{totpMethod === "email" && (
<span className="absolute right-3 top-3 flex size-5 items-center justify-center rounded-full bg-blue-500 text-white">
<CheckIcon className="size-3" />
</span>
)}
<EnvelopeClosedIcon className="h-6 w-6" />
<Label>Email</Label>
<Label className="cursor-pointer text-center">Email</Label>
</div>
<div
className={cn(
"flex cursor-pointer items-center justify-center gap-2 rounded-lg bg-slate-elevation1 hover:bg-slate-elevation3",
"relative flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-transparent bg-slate-elevation1 hover:bg-slate-elevation3",
{
"bg-slate-elevation3": totpMethod === "text",
"border-blue-400 bg-slate-elevation3 ring-1 ring-blue-400/60":
totpMethod === "text",
},
)}
onClick={() => handleTotpMethodChange("text")}
>
{totpMethod === "text" && (
<span className="absolute right-3 top-3 flex size-5 items-center justify-center rounded-full bg-blue-500 text-white">
<CheckIcon className="size-3" />
</span>
)}
<MobileIcon className="h-6 w-6" />
<Label>Text Message</Label>
<Label className="cursor-pointer text-center">
Text Message
</Label>
</div>
</div>
{(totpMethod === "text" || totpMethod === "email") && (
@ -399,6 +477,7 @@ function PasswordCredentialContent({
<div className="w-40 shrink-0">
<Label className="whitespace-nowrap">
Authenticator Key
<span className="text-destructive"> *</span>
</Label>
</div>
{valuesReadOnly ? (
@ -418,17 +497,56 @@ function PasswordCredentialContent({
</button>
</div>
) : (
<Input
value={totp}
onChange={(e) => updateValues({ totp: e.target.value })}
placeholder={editMode ? "••••••••" : undefined}
/>
<div className="flex w-full gap-2">
<Input
value={totp}
onChange={(e) =>
handleAuthenticatorTotpChange(e.target.value)
}
placeholder="e.g. JBSWY3DPEHPK3PXP"
aria-invalid={Boolean(totpError)}
className={cn(
totpError &&
"border-destructive bg-destructive/10 focus-visible:ring-destructive/30",
)}
/>
<input
ref={qrCodeInputRef}
type="file"
accept="image/*"
className="sr-only"
aria-label="Upload QR code image"
onChange={(event) =>
void handleQrCodeFileChange(event)
}
/>
<Button
type="button"
variant="secondary"
className="shrink-0"
disabled={isScanningQrCode}
onClick={() => qrCodeInputRef.current?.click()}
>
{isScanningQrCode ? (
<ReloadIcon className="mr-2 size-4 animate-spin" />
) : (
<UploadIcon className="mr-2 size-4" />
)}
Scan QR
</Button>
</div>
)}
</div>
{(totpError || qrCodeScanError) && (
<div className="space-y-1 text-xs text-destructive">
{totpError && <p>{totpError}</p>}
{qrCodeScanError && <p>{qrCodeScanError}</p>}
</div>
)}
<p className="text-sm text-slate-400">
You need to find the authenticator secret from the website
You need to find the authenticator key from the website
where you are using the credential. Here are some guides
from popular authenticator apps:{" "}
from popular password managers:{" "}
<Link
to="https://bitwarden.com/help/integrated-authenticator/#manually-enter-a-secret"
target="_blank"

View file

@ -0,0 +1,39 @@
const AUTHENTICATOR_KEY_REQUIRED_MESSAGE = "Authenticator key is required.";
const AUTHENTICATOR_KEY_FORMAT_MESSAGE =
"Authenticator key should be a raw Base32 setup key or full otpauth:// URI.";
const MIN_AUTHENTICATOR_KEY_LENGTH = 16;
type AuthenticatorKeyValues = {
totp: string;
totp_type: "authenticator" | "email" | "text" | "none";
};
function getAuthenticatorKeyError(
values: AuthenticatorKeyValues,
): string | null {
if (values.totp_type !== "authenticator") {
return null;
}
const raw = values.totp.trim();
if (raw === "") {
return AUTHENTICATOR_KEY_REQUIRED_MESSAGE;
}
if (
raw.toLowerCase().startsWith("otpauth://") ||
/(?:^|[?&])secret=/i.test(raw)
) {
return null;
}
const normalized = raw.replace(/[\s-]/g, "").toUpperCase();
if (
normalized.length < MIN_AUTHENTICATOR_KEY_LENGTH ||
!/^[A-Z2-7]+=*$/.test(normalized)
) {
return AUTHENTICATOR_KEY_FORMAT_MESSAGE;
}
return null;
}
export { getAuthenticatorKeyError };

View file

@ -0,0 +1,63 @@
type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => {
detect: (image: ImageBitmap) => Promise<Array<{ rawValue?: string }>>;
};
type BrowserBarcodeDetectorScope = typeof globalThis & {
BarcodeDetector?: BarcodeDetectorConstructor;
};
const QR_SCAN_UNSUPPORTED_MESSAGE =
"QR scanning is not supported by this browser. Paste the setup key or otpauth:// URI instead.";
const QR_CODE_NOT_FOUND_MESSAGE =
"No QR code was found in that image. Try a clearer screenshot or paste the setup key.";
const QR_CODE_INVALID_TOTP_MESSAGE =
"This QR code doesn't look like a 2FA setup code. Make sure you're scanning the setup QR from the site's 2FA settings.";
function isLikelyTotpSetupValue(value: string): boolean {
const normalizedValue = value.trim();
if (normalizedValue.toLowerCase().startsWith("otpauth://")) {
return true;
}
const compactValue = normalizedValue.replace(/[\s-]/g, "");
return compactValue.length >= 16 && /^[A-Z2-7]+=*$/i.test(compactValue);
}
async function decodeQrCodeImage(file: File): Promise<string> {
const scope = globalThis as BrowserBarcodeDetectorScope;
const BarcodeDetector = scope.BarcodeDetector;
if (!BarcodeDetector) {
throw new Error(QR_SCAN_UNSUPPORTED_MESSAGE);
}
let detector: InstanceType<BarcodeDetectorConstructor>;
try {
detector = new BarcodeDetector({ formats: ["qr_code"] });
} catch {
throw new Error(QR_SCAN_UNSUPPORTED_MESSAGE);
}
let image: ImageBitmap;
try {
image = await globalThis.createImageBitmap(file);
} catch {
throw new Error(QR_SCAN_UNSUPPORTED_MESSAGE);
}
try {
const codes = await detector.detect(image);
const rawValue = codes.find((code) => code.rawValue)?.rawValue?.trim();
if (!rawValue) {
throw new Error(QR_CODE_NOT_FOUND_MESSAGE);
}
if (!isLikelyTotpSetupValue(rawValue)) {
throw new Error(QR_CODE_INVALID_TOTP_MESSAGE);
}
return rawValue;
} finally {
image.close();
}
}
export { decodeQrCodeImage };

View file

@ -8,7 +8,6 @@ from pathlib import Path
from typing import Any, Callable
import pydantic
import pyotp
import structlog
from cachetools import TTLCache
from playwright.async_api import Page
@ -30,6 +29,7 @@ from skyvern.forge.sdk.api.files import (
from skyvern.forge.sdk.artifact.models import ArtifactType
from skyvern.forge.sdk.core import skyvern_context
from skyvern.forge.sdk.db.utils import ACTION_TYPE_TO_CLASS
from skyvern.forge.sdk.services.credentials import generate_totp_code
from skyvern.schemas.steps import AgentStepOutput
from skyvern.services.otp_service import poll_otp_value
from skyvern.utils.url_validators import prepend_scheme_and_validate_url
@ -913,7 +913,7 @@ class ScriptSkyvernPage(SkyvernPage):
totp_secret = workflow_run_context.get_original_secret_value_or_none(totp_secret_key)
if totp_secret:
try:
totp_code = pyotp.TOTP(totp_secret).now()
totp_code = generate_totp_code(totp_secret)
# Cache the code for subsequent digit requests in this sequence
self._totp_sequence_cache[cache_key] = totp_code
LOG.info(

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any
import pyotp
import structlog
from skyvern.cli.core.session_manager import get_page
@ -17,7 +16,7 @@ from skyvern.forge.sdk.copilot.secret_scrub import (
scrub_secrets_from_text,
)
from skyvern.forge.sdk.schemas.credentials import CredentialVaultType, PasswordCredential, TotpType
from skyvern.forge.sdk.services.credentials import parse_totp_secret
from skyvern.forge.sdk.services.credentials import generate_totp_code
from .banned_blocks import _copilot_block_authoring_policy
from .blockers import _tool_loop_error
@ -135,7 +134,7 @@ async def _resolve_credential_fill_value(
return None, "", _runtime_otp_steering_error(credential_id)
return None, "", f"Credential `{credential_id}` has no TOTP secret configured."
try:
value = pyotp.TOTP(parse_totp_secret(credential.totp)).now()
value = generate_totp_code(credential.totp)
except Exception:
LOG.warning(
"fill_credential_field could not generate a TOTP code",

View file

@ -12,6 +12,10 @@ metadata:
- Credit card credentials: ``last_four``, ``brand``
- Secret credentials: ``secret_label``
The one narrow exception is ``GET /credentials/{credential_id}/totp-code``,
which may return a transient current authenticator code derived from a stored
TOTP seed. It must never return the seed itself.
This is enforced by the ``*CredentialResponse`` Pydantic models and the
``_convert_to_response()`` helper. When adding new credential types or
modifying existing ones, ensure that:
@ -28,10 +32,13 @@ exact threat the vault architecture is designed to prevent.
import asyncio
import json
import time
from dataclasses import dataclass
from typing import Annotated
import pyotp
import structlog
from fastapi import BackgroundTasks, Body, Depends, Header, HTTPException, Path, Query
from fastapi import BackgroundTasks, Body, Depends, Header, HTTPException, Path, Query, Response
from onepassword.client import Client as OnePasswordClient
from skyvern.config import settings
@ -67,12 +74,14 @@ from skyvern.forge.sdk.schemas.credentials import (
CreateCredentialRequest,
Credential,
CredentialResponse,
CredentialTotpCodeResponse,
CredentialType,
CredentialVaultType,
CreditCardCredentialResponse,
NonEmptyPasswordCredential,
OnePasswordItemOverview,
OnePasswordItemsResponse,
PasswordCredential,
PasswordCredentialResponse,
SecretCredentialResponse,
TestCredentialRequest,
@ -80,6 +89,7 @@ from skyvern.forge.sdk.schemas.credentials import (
TestCredentialStatusResponse,
TestLoginRequest,
TestLoginResponse,
TotpType,
UpdateCredentialRequest,
)
from skyvern.forge.sdk.schemas.organizations import (
@ -102,6 +112,7 @@ from skyvern.forge.sdk.schemas.totp_codes import OTPType, TOTPCode, TOTPCodeCrea
from skyvern.forge.sdk.services import org_auth_service
from skyvern.forge.sdk.services.bitwarden import BitwardenService
from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService
from skyvern.forge.sdk.services.credentials import parse_totp_config
from skyvern.forge.sdk.workflow.models.parameter import WorkflowParameterType
from skyvern.forge.sdk.workflow.models.workflow import WorkflowRequestBody, WorkflowRunStatus
from skyvern.schemas.credential_folders import (
@ -145,6 +156,132 @@ _ORG_AUTH_CREDENTIAL_TOKEN_TYPES = {
_SESSION_PERSIST_MAX_WAIT_SECONDS = (_SESSION_PERSIST_MAX_RETRIES - 1) * _SESSION_PERSIST_RETRY_INTERVAL_SECONDS
# Buffer over the max wait so the status endpoint doesn't misreport while the task still retries.
_PROFILE_GRACE_PERIOD_HEADROOM_SECONDS = 15
_AUTHENTICATOR_SECRET_REQUIRED_DETAIL = (
"Authenticator key is required. Paste the raw setup key or full otpauth:// URI from the website's 2FA setup screen."
)
_AUTHENTICATOR_SECRET_INVALID_DETAIL = (
"Invalid authenticator key. Paste the raw Base32 setup key or full otpauth:// URI "
"from the website's 2FA setup screen."
)
_SAVED_AUTHENTICATOR_SECRET_INVALID_DETAIL = (
"Saved authenticator key is invalid. Edit the credential and paste the raw setup key "
"or full otpauth:// URI from the website's 2FA setup screen."
)
_TOTP_CODE_PREVIEW_CACHE_MAX_ENTRIES = 1024
@dataclass(frozen=True)
class _TotpCodePreviewCacheEntry:
code: str
expires_at: int
_TOTP_CODE_PREVIEW_CACHE: dict[tuple[str, str], _TotpCodePreviewCacheEntry] = {}
# Best-effort, per-process UX cache. Correctness never depends on sharing this
# across workers; entries are bounded and expire at the active TOTP window.
def _parse_authenticator_totp_config_or_raise(
totp_secret: str | None,
*,
missing_detail: str = _AUTHENTICATOR_SECRET_REQUIRED_DETAIL,
invalid_detail: str = _AUTHENTICATOR_SECRET_INVALID_DETAIL,
) -> tuple[pyotp.TOTP, str]:
raw_totp_secret = (totp_secret or "").strip()
if raw_totp_secret == "":
raise HTTPException(status_code=400, detail=missing_detail)
normalized_input = "".join(raw_totp_secret.split())
totp = parse_totp_config(normalized_input)
if not totp:
raise HTTPException(status_code=400, detail=invalid_detail)
normalized_totp_secret = normalized_input if normalized_input.lower().startswith("otpauth://") else totp.secret
return totp, normalized_totp_secret
def _build_authenticator_totp_or_raise(
totp_secret: str | None,
*,
missing_detail: str = _AUTHENTICATOR_SECRET_REQUIRED_DETAIL,
invalid_detail: str = _AUTHENTICATOR_SECRET_INVALID_DETAIL,
) -> pyotp.TOTP:
totp, _ = _parse_authenticator_totp_config_or_raise(
totp_secret,
missing_detail=missing_detail,
invalid_detail=invalid_detail,
)
return totp
def _parse_authenticator_totp_or_raise(
totp_secret: str | None,
*,
missing_detail: str = _AUTHENTICATOR_SECRET_REQUIRED_DETAIL,
invalid_detail: str = _AUTHENTICATOR_SECRET_INVALID_DETAIL,
) -> str:
_, normalized_totp_secret = _parse_authenticator_totp_config_or_raise(
totp_secret,
missing_detail=missing_detail,
invalid_detail=invalid_detail,
)
return normalized_totp_secret
def _normalize_authenticator_totp_or_raise(credential: PasswordCredential | TestLoginRequest) -> None:
if credential.totp_type != TotpType.AUTHENTICATOR:
return
credential.totp = _parse_authenticator_totp_or_raise(credential.totp)
def _get_cached_totp_code_preview(
*,
organization_id: str,
credential_id: str,
now: int,
) -> CredentialTotpCodeResponse | None:
cache_key = (organization_id, credential_id)
cached = _TOTP_CODE_PREVIEW_CACHE.get(cache_key)
if cached is None:
return None
if cached.expires_at <= now:
_TOTP_CODE_PREVIEW_CACHE.pop(cache_key, None)
return None
return CredentialTotpCodeResponse(code=cached.code, seconds_remaining=cached.expires_at - now)
def _clear_cached_totp_code_preview(*, organization_id: str, credential_id: str) -> None:
"""Clear this worker's best-effort preview cache entry after a mutation.
The cache is intentionally per-process UX protection for repeated preview
reads. Other workers can keep serving the previous within-window code or
error until the active TOTP window expires.
"""
_TOTP_CODE_PREVIEW_CACHE.pop((organization_id, credential_id), None)
def _prune_totp_code_preview_cache(*, now: int) -> None:
for cache_key, cached in list(_TOTP_CODE_PREVIEW_CACHE.items()):
if cached.expires_at <= now:
_TOTP_CODE_PREVIEW_CACHE.pop(cache_key, None)
def _cache_totp_code_preview(
*,
organization_id: str,
credential_id: str,
code: str,
now: int,
expires_at: int,
) -> None:
_prune_totp_code_preview_cache(now=now)
while len(_TOTP_CODE_PREVIEW_CACHE) >= _TOTP_CODE_PREVIEW_CACHE_MAX_ENTRIES:
_TOTP_CODE_PREVIEW_CACHE.pop(next(iter(_TOTP_CODE_PREVIEW_CACHE)))
_TOTP_CODE_PREVIEW_CACHE[(organization_id, credential_id)] = _TotpCodePreviewCacheEntry(
code=code,
expires_at=expires_at,
)
async def fetch_credential_item_background(item_id: str) -> None:
@ -360,6 +497,9 @@ async def create_credential(
),
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> CredentialResponse:
if isinstance(data.credential, NonEmptyPasswordCredential):
_normalize_authenticator_totp_or_raise(data.credential)
credential_service = await _get_credential_vault_service(vault_type_override=data.vault_type)
try:
@ -580,6 +720,7 @@ async def test_login(
) -> TestLoginResponse:
"""Test a login with inline credentials without requiring a saved credential."""
organization_id = current_org.organization_id
_normalize_authenticator_totp_or_raise(data)
# Create a temporary credential
create_request = CreateCredentialRequest(
@ -1380,6 +1521,9 @@ async def update_credential(
if not existing_credential:
raise HTTPException(status_code=404, detail=f"Credential not found, credential_id={credential_id}")
if isinstance(data.credential, NonEmptyPasswordCredential):
_normalize_authenticator_totp_or_raise(data.credential)
vault_type = existing_credential.vault_type or CredentialVaultType.BITWARDEN
credential_service = app.CREDENTIAL_VAULT_SERVICES.get(vault_type)
if not credential_service:
@ -1411,6 +1555,8 @@ async def update_credential(
if updated_credential.vault_type == CredentialVaultType.BITWARDEN:
background_tasks.add_task(fetch_credential_item_background, updated_credential.item_id)
_clear_cached_totp_code_preview(organization_id=current_org.organization_id, credential_id=credential_id)
return _convert_to_response(updated_credential)
@ -1478,9 +1624,102 @@ async def delete_credential(
credential.organization_id,
)
_clear_cached_totp_code_preview(organization_id=current_org.organization_id, credential_id=credential_id)
return None
@base_router.get(
"/credentials/{credential_id}/totp-code",
response_model=CredentialTotpCodeResponse,
summary="Get current credential TOTP code",
description="Returns the current generated authenticator code for a password credential.",
tags=["Credentials"],
include_in_schema=False,
)
@base_router.get(
"/credentials/{credential_id}/totp-code/",
response_model=CredentialTotpCodeResponse,
include_in_schema=False,
)
async def get_credential_totp_code(
response: Response,
credential_id: str = Path(
...,
description="The unique identifier of the credential",
examples=["cred_1234567890"],
),
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> CredentialTotpCodeResponse:
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
credential = await app.DATABASE.credentials.get_credential(
credential_id=credential_id, organization_id=current_org.organization_id
)
if not credential:
raise HTTPException(status_code=404, detail="Credential not found")
if credential.credential_type != CredentialType.PASSWORD or credential.totp_type != TotpType.AUTHENTICATOR:
raise HTTPException(status_code=400, detail="This credential does not have an authenticator app configured.")
now = int(time.time())
cached_response = _get_cached_totp_code_preview(
organization_id=current_org.organization_id,
credential_id=credential_id,
now=now,
)
if cached_response is not None:
return cached_response
credential_service = await _get_credential_vault_service(vault_type_override=credential.vault_type)
try:
credential_item = await credential_service.get_credential_item(credential)
except SkyvernHttpException as e:
detail = (
f"Custom credential service returned {e.error_message}"
if e.error_message
else f"Custom credential service returned HTTP {e.status_code}"
)
raise HTTPException(status_code=502, detail=detail)
except Exception as e:
LOG.exception(
"Failed to retrieve credential item for TOTP code preview",
credential_id=credential_id,
organization_id=current_org.organization_id,
error=str(e),
)
raise HTTPException(status_code=500, detail="Unable to retrieve credential from vault") from e
if not isinstance(credential_item.credential, PasswordCredential):
raise HTTPException(status_code=400, detail="This credential does not have an authenticator app configured.")
try:
totp = _build_authenticator_totp_or_raise(
credential_item.credential.totp,
missing_detail=_SAVED_AUTHENTICATOR_SECRET_INVALID_DETAIL,
invalid_detail=_SAVED_AUTHENTICATOR_SECRET_INVALID_DETAIL,
)
except HTTPException:
LOG.warning(
"Saved authenticator key is invalid for TOTP code preview",
credential_id=credential_id,
organization_id=current_org.organization_id,
vault_type=credential.vault_type,
)
raise
expires_at = ((now // totp.interval) + 1) * totp.interval
code = totp.at(now)
_cache_totp_code_preview(
organization_id=current_org.organization_id,
credential_id=credential_id,
code=code,
now=now,
expires_at=expires_at,
)
return CredentialTotpCodeResponse(code=code, seconds_remaining=expires_at - now)
@legacy_base_router.get("/credentials/{credential_id}")
@legacy_base_router.get("/credentials/{credential_id}/", include_in_schema=False)
@base_router.get(

View file

@ -52,6 +52,21 @@ class PasswordCredentialResponse(BaseModel):
)
class CredentialTotpCodeResponse(BaseModel):
"""Current authenticator code for a password credential.
SECURITY: This response must never include the TOTP seed/secret.
"""
code: str = Field(..., description="Current generated authenticator code", examples=["123456"])
seconds_remaining: int = Field(
...,
ge=0,
description="Seconds until this code rolls over",
examples=[24],
)
class CreditCardCredentialResponse(BaseModel):
"""Response model for credit card credentials — non-sensitive fields only.

View file

@ -35,7 +35,7 @@ from skyvern.forge.sdk.schemas.credentials import (
PasswordCredential,
SecretCredential,
)
from skyvern.forge.sdk.services.credentials import parse_totp_secret
from skyvern.forge.sdk.services.credentials import normalize_totp_config as normalize_credential_totp_config
from skyvern.utils.strings import is_uuid
LOG = structlog.get_logger()
@ -144,7 +144,7 @@ def get_bitwarden_item_type_code(item_type: BitwardenItemType) -> int:
def get_list_response_item_from_bitwarden_item(item: dict) -> CredentialItem:
if item["type"] == BitwardenItemType.LOGIN:
login = item["login"]
totp = BitwardenService.extract_totp_secret(login.get("totp", ""))
totp = BitwardenService.normalize_totp_config(login.get("totp", ""))
return CredentialItem(
item_id=item["id"],
credential=PasswordCredential(
@ -520,23 +520,28 @@ class BitwardenService:
)
@staticmethod
def extract_totp_secret(totp_value: str) -> str:
def normalize_totp_config(totp_value: str) -> str:
"""
Extract the TOTP secret from either a raw secret or a TOTP URI.
Validate the TOTP config from either a raw secret or a TOTP URI.
Args:
totp_value: Raw TOTP secret or URI (otpauth://totp/...)
Returns:
The extracted TOTP secret
The raw secret or full TOTP URI config
Example:
>>> BitwardenService.extract_totp_secret("AAAAAABBBBBBB")
"AAAAAABBBBBBB"
>>> BitwardenService.extract_totp_secret("otpauth://totp/user@domain.com?secret=AAAAAABBBBBBB")
>>> BitwardenService.normalize_totp_config("AAAAAABBBBBBB")
"AAAAAABBBBBBB"
>>> BitwardenService.normalize_totp_config("otpauth://totp/user@domain.com?secret=AAAAAABBBBBBB")
"otpauth://totp/user@domain.com?secret=AAAAAABBBBBBB"
"""
return parse_totp_secret(totp_value)
return normalize_credential_totp_config(totp_value)
@staticmethod
def extract_totp_secret(totp_value: str) -> str:
"""Compatibility shim for callers using the old method name."""
return BitwardenService.normalize_totp_config(totp_value)
@staticmethod
async def _get_secret_value_from_url(
@ -572,7 +577,7 @@ class BitwardenService:
raise BitwardenGetItemError(f"Failed to parse item JSON for item ID: {item_id}")
login = item["login"]
totp = BitwardenService.extract_totp_secret(login.get("totp") or "")
totp = BitwardenService.normalize_totp_config(login.get("totp") or "")
return {
BitwardenConstants.USERNAME: login.get("username") or "",
@ -634,7 +639,7 @@ class BitwardenService:
continue
login = item["login"]
totp = BitwardenService.extract_totp_secret(login.get("totp") or "")
totp = BitwardenService.normalize_totp_config(login.get("totp") or "")
bitwarden_result.append(
BitwardenQueryResult(
@ -1052,7 +1057,7 @@ class BitwardenService:
raise BitwardenGetItemError(f"Failed to get login item by ID: {item_id}")
login = response["data"]["login"]
totp = BitwardenService.extract_totp_secret(login.get("totp", ""))
totp = BitwardenService.normalize_totp_config(login.get("totp", ""))
if not login:
raise BitwardenGetItemError(f"Item with ID: {item_id} is not a login item")

View file

@ -20,6 +20,14 @@ class AzureVaultConstants(StrEnum):
TOTP = "AZ_TOTP" # Special value to indicate a TOTP code
def _strip_totp_input_whitespace(totp_secret: str) -> str:
return "".join(totp_secret.split())
def _is_otpauth_uri(totp_secret: str) -> bool:
return totp_secret.lower().startswith("otpauth://")
def parse_totp_secret(totp_secret: str) -> str:
if not totp_secret:
return ""
@ -54,3 +62,48 @@ def parse_totp_secret(totp_secret: str) -> str:
exc_info=True,
)
return ""
def parse_totp_config(totp_secret: str) -> pyotp.TOTP | None:
"""Parse a raw Base32 TOTP secret or full otpauth URI into a TOTP config."""
if not totp_secret:
return None
totp_secret_no_whitespace = _strip_totp_input_whitespace(totp_secret)
if _is_otpauth_uri(totp_secret_no_whitespace):
try:
parsed_otp = pyotp.parse_uri(totp_secret_no_whitespace)
except Exception:
LOG.warning("Failed to parse TOTP config from URI", exc_info=True)
return None
if not isinstance(parsed_otp, pyotp.TOTP):
LOG.warning("Parsed OTP URI is not a TOTP config")
return None
return parsed_otp
parsed_totp_secret = parse_totp_secret(totp_secret)
if not parsed_totp_secret:
return None
return pyotp.TOTP(parsed_totp_secret)
def normalize_totp_config(totp_secret: str) -> str:
"""Return the validated TOTP config string while preserving otpauth URI parameters."""
if not totp_secret:
return ""
totp_secret_no_whitespace = _strip_totp_input_whitespace(totp_secret)
if _is_otpauth_uri(totp_secret_no_whitespace):
return totp_secret_no_whitespace if parse_totp_config(totp_secret_no_whitespace) else ""
return parse_totp_secret(totp_secret)
def generate_totp_code(totp_secret: str, for_time: int | None = None) -> str:
"""Generate the current code for a raw TOTP secret or full otpauth URI."""
totp = parse_totp_config(totp_secret)
if not totp:
raise ValueError("Invalid TOTP secret or otpauth URI")
if for_time is None:
return totp.now()
return totp.at(for_time)

View file

@ -32,7 +32,7 @@ from skyvern.forge.sdk.schemas.credentials import CredentialVaultType, PasswordC
from skyvern.forge.sdk.schemas.organizations import Organization
from skyvern.forge.sdk.schemas.tasks import TaskStatus
from skyvern.forge.sdk.services.bitwarden import BitwardenConstants, BitwardenService
from skyvern.forge.sdk.services.credentials import AzureVaultConstants, OnePasswordConstants, parse_totp_secret
from skyvern.forge.sdk.services.credentials import AzureVaultConstants, OnePasswordConstants, normalize_totp_config
from skyvern.forge.sdk.workflow.exceptions import MissingJinjaVariables, OutputParameterKeyCollisionError
from skyvern.forge.sdk.workflow.models.parameter import (
PARAMETER_TYPE,
@ -660,7 +660,7 @@ class WorkflowRunContext:
totp_secret_id = f"{random_secret_id}_totp"
self.secrets[totp_secret_id] = BitwardenConstants.TOTP
totp_secret_value = self.totp_secret_value_key(totp_secret_id)
self.secrets[totp_secret_value] = parse_totp_secret(credential.totp)
self.secrets[totp_secret_value] = normalize_totp_config(credential.totp)
self.values[parameter.key]["totp"] = totp_secret_id
def get_credential_totp_identifier(self, parameter_key: str) -> str | None:
@ -808,7 +808,7 @@ class WorkflowRunContext:
totp_secret_id = f"{random_secret_id}_totp"
self.secrets[totp_secret_id] = OnePasswordConstants.TOTP
totp_secret_value = self.totp_secret_value_key(totp_secret_id)
self.secrets[totp_secret_value] = parse_totp_secret(field.value)
self.secrets[totp_secret_value] = normalize_totp_config(field.value)
self.values[parameter.key]["totp"] = totp_secret_id
elif field.title and field.title.lower() in ["expire date", "expiry date", "expiration date"]:
parts = [part.strip() for part in field.value.strip().split("/")]
@ -1040,7 +1040,7 @@ class WorkflowRunContext:
totp_secret_id = f"{random_secret_id}_totp"
self.secrets[totp_secret_id] = BitwardenConstants.TOTP
totp_secret_value = self.totp_secret_value_key(totp_secret_id)
self.secrets[totp_secret_value] = secret_credentials[BitwardenConstants.TOTP]
self.secrets[totp_secret_value] = normalize_totp_config(secret_credentials[BitwardenConstants.TOTP])
self.values[parameter.key]["totp"] = totp_secret_id
except BitwardenBaseError as e:
@ -1094,7 +1094,7 @@ class WorkflowRunContext:
totp_secret_id = f"{random_secret_id}_totp"
self.secrets[totp_secret_id] = AzureVaultConstants.TOTP
totp_secret_value = self.totp_secret_value_key(totp_secret_id)
self.secrets[totp_secret_value] = parse_totp_secret(totp_secret)
self.secrets[totp_secret_value] = normalize_totp_config(totp_secret)
self.values[parameter.key]["totp"] = totp_secret_id
async def register_bitwarden_sensitive_information_parameter_value(

View file

@ -28,7 +28,6 @@ import aiohttp
import docx
import filetype
import pandas as pd
import pyotp
import structlog
from charset_normalizer import from_bytes
from email_validator import EmailNotValidError, validate_email
@ -94,7 +93,7 @@ from skyvern.forge.sdk.schemas.files import FileInfo
from skyvern.forge.sdk.schemas.task_v2 import TaskV2Status
from skyvern.forge.sdk.schemas.tasks import Task, TaskOutput, TaskStatus
from skyvern.forge.sdk.services.bitwarden import BitwardenConstants
from skyvern.forge.sdk.services.credentials import AzureVaultConstants, OnePasswordConstants
from skyvern.forge.sdk.services.credentials import AzureVaultConstants, OnePasswordConstants, generate_totp_code
from skyvern.forge.sdk.settings_manager import SettingsManager
from skyvern.forge.sdk.trace import traced
from skyvern.forge.sdk.utils.pdf_parser import extract_pdf_file, render_pdf_pages_as_images, validate_pdf_file
@ -4045,7 +4044,7 @@ async def wrapper({default_args}):
totp_secret_key = workflow_run_context.totp_secret_value_key(credential_place_holder)
totp_secret = workflow_run_context.get_original_secret_value_or_none(totp_secret_key)
if totp_secret:
secret_value = pyotp.TOTP(totp_secret).now()
secret_value = generate_totp_code(totp_secret)
# The pre-minted .totp string is exposed to user code (legacy path),
# so register it for masking like any other resolved secret.
_register_code_block_secret(workflow_run_context, secret_value)

View file

@ -4,7 +4,6 @@ import re
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
import pyotp
import structlog
from pydantic import BaseModel, Field
@ -22,6 +21,7 @@ from skyvern.forge.sdk.core.security import generate_skyvern_webhook_signature
from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType
from skyvern.forge.sdk.schemas.organizations import OrganizationAuthToken
from skyvern.forge.sdk.schemas.totp_codes import OTPType
from skyvern.forge.sdk.services.credentials import generate_totp_code
from skyvern.services.otp_gmail import GmailOTPVerificationContext
LOG = structlog.get_logger()
@ -112,7 +112,7 @@ def _is_mfa_like_parameter_key(key: object) -> bool:
def extract_totp_from_navigation_inputs(navigation_payload: MFANavigationPayload) -> OTPValue | None:
"""Extract TOTP from runtime navigation inputs.
"""Extract inline OTP or magic-link content from runtime navigation inputs.
Runtime inline OTP extraction is intentionally payload-only.
"""
@ -126,7 +126,10 @@ def extract_totp_from_navigation_inputs(navigation_payload: MFANavigationPayload
current_item = traversal_stack.pop()
if isinstance(current_item, str):
return OTPValue(value=current_item, type=OTPType.TOTP)
otp_type = (
OTPType.MAGIC_LINK if current_item.strip().lower().startswith(("https://", "http://")) else OTPType.TOTP
)
return OTPValue(value=current_item, type=otp_type)
current_id = id(current_item)
if current_id in visited_container_ids:
@ -285,7 +288,7 @@ def try_generate_totp_for_credential(
if not totp_secret:
return None
try:
code = pyotp.TOTP(totp_secret).now()
code = generate_totp_code(totp_secret)
LOG.info(
"Generated TOTP from credential secret",
workflow_run_id=workflow_run_id,

View file

@ -12,7 +12,6 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Awaitable, Callable, List
import pyotp
import structlog
from fuzzysearch import find_near_matches
from opentelemetry import trace as otel_trace
@ -84,7 +83,12 @@ from skyvern.forge.sdk.experimentation.slim_llm_output import get_slim_output_te
from skyvern.forge.sdk.models import Step
from skyvern.forge.sdk.schemas.tasks import Task
from skyvern.forge.sdk.services.bitwarden import BitwardenConstants
from skyvern.forge.sdk.services.credentials import AzureVaultConstants, OnePasswordConstants
from skyvern.forge.sdk.services.credentials import (
AzureVaultConstants,
OnePasswordConstants,
generate_totp_code,
parse_totp_config,
)
from skyvern.forge.sdk.settings_manager import SettingsManager
from skyvern.forge.sdk.trace import apply_context_attrs, traced
from skyvern.services import service_utils
@ -1696,7 +1700,6 @@ async def handle_click_to_download_file_action(
# TOTP timing constants
TOTP_TIME_STEP_SECONDS = 30
TOTP_EXPIRY_THRESHOLD_SECONDS = 20
@ -1717,11 +1720,13 @@ async def _handle_multi_field_totp_sequence(
if action_index == 0:
# First digit: generate TOTP and cache it
totp_secret = timing_info["totp_secret"]
totp = pyotp.TOTP(totp_secret)
totp = parse_totp_config(totp_secret)
if not totp:
raise ValueError("Invalid TOTP secret or otpauth URI")
# Check current TOTP expiry time
current_time = int(time.time())
current_totp_valid_until = ((current_time // TOTP_TIME_STEP_SECONDS) + 1) * TOTP_TIME_STEP_SECONDS
current_totp_valid_until = ((current_time // totp.interval) + 1) * totp.interval
seconds_until_expiry = current_totp_valid_until - current_time
# If less than threshold seconds until expiry, use the next TOTP
@ -1758,11 +1763,13 @@ async def _handle_multi_field_totp_sequence(
# Check if cached TOTP has expired
totp_secret = timing_info["totp_secret"]
totp = pyotp.TOTP(totp_secret)
totp = parse_totp_config(totp_secret)
if not totp:
raise ValueError("Invalid TOTP secret or otpauth URI")
# Get current time and calculate TOTP expiry
current_time = int(time.time())
totp_valid_until = ((current_time // TOTP_TIME_STEP_SECONDS) + 1) * TOTP_TIME_STEP_SECONDS
totp_valid_until = ((current_time // totp.interval) + 1) * totp.interval
if current_time >= totp_valid_until:
LOG.error(
@ -1786,7 +1793,7 @@ async def _handle_multi_field_totp_sequence(
if action_index == 5:
# Calculate when this TOTP becomes valid (valid_from time)
# If we used the next TOTP window, valid_from is the start of that window
totp_valid_from = totp_valid_until - TOTP_TIME_STEP_SECONDS
totp_valid_from = totp_valid_until - totp.interval
if current_time < totp_valid_from:
# TOTP is not yet valid, wait until it becomes valid
@ -3459,7 +3466,7 @@ def generate_totp_value(workflow_run_id: str, parameter: str) -> str:
if not totp_secret:
LOG.warning("No TOTP secret found, returning the parameter value as is", parameter=parameter)
return parameter
return pyotp.TOTP(totp_secret).now()
return generate_totp_code(totp_secret)
def generate_totp_value_with_task(task: Task, parameter: str) -> str:

View file

@ -0,0 +1,267 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pyotp
import pytest
from fastapi import HTTPException, Response
from skyvern.forge.sdk.routes import credentials
from skyvern.forge.sdk.schemas.credentials import (
CredentialItem,
CredentialType,
NonEmptyPasswordCredential,
PasswordCredential,
TotpType,
)
@pytest.fixture(autouse=True)
def clear_totp_code_preview_cache() -> None:
credentials._TOTP_CODE_PREVIEW_CACHE.clear()
def _mock_totp_preview_dependencies(
monkeypatch: pytest.MonkeyPatch,
*,
secret: str | None,
credential_id: str = "cred_test",
organization_id: str = "org_test",
) -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace]:
db_credential = SimpleNamespace(
credential_id=credential_id,
organization_id=organization_id,
name="Example",
vault_type=None,
item_id="item_test",
credential_type=CredentialType.PASSWORD,
totp_type=TotpType.AUTHENTICATOR,
)
vault_service = SimpleNamespace(
get_credential_item=AsyncMock(
return_value=CredentialItem(
item_id="item_test",
name="Example",
credential_type=CredentialType.PASSWORD,
credential=PasswordCredential(
username="user@example.com",
password="pw",
totp=secret,
totp_type=TotpType.AUTHENTICATOR,
),
)
)
)
mock_credentials = SimpleNamespace(get_credential=AsyncMock(return_value=db_credential))
monkeypatch.setattr(credentials.app, "DATABASE", SimpleNamespace(credentials=mock_credentials))
monkeypatch.setattr(credentials, "_get_credential_vault_service", AsyncMock(return_value=vault_service))
return db_credential, vault_service, mock_credentials
def test_clear_cached_totp_code_preview_removes_entry() -> None:
credentials._cache_totp_code_preview(
organization_id="org_test",
credential_id="cred_test",
code="123456",
now=0,
expires_at=30,
)
credentials._clear_cached_totp_code_preview(organization_id="org_test", credential_id="cred_test")
assert (
credentials._get_cached_totp_code_preview(
organization_id="org_test",
credential_id="cred_test",
now=1,
)
is None
)
def test_totp_code_preview_cache_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(credentials, "_TOTP_CODE_PREVIEW_CACHE_MAX_ENTRIES", 2)
for index in range(3):
credentials._cache_totp_code_preview(
organization_id="org_test",
credential_id=f"cred_{index}",
code=f"12345{index}",
now=0,
expires_at=30,
)
assert len(credentials._TOTP_CODE_PREVIEW_CACHE) == 2
assert ("org_test", "cred_0") not in credentials._TOTP_CODE_PREVIEW_CACHE
def test_authenticator_totp_validation_preserves_uri_configuration() -> None:
totp_uri = (
"otpauth://totp/Example:user@example.com"
"?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA256&digits=8&period=60"
)
credential = NonEmptyPasswordCredential(
username="user@example.com",
password="pw",
totp=totp_uri,
totp_type=TotpType.AUTHENTICATOR,
)
credentials._normalize_authenticator_totp_or_raise(credential)
assert credential.totp == totp_uri
def test_authenticator_totp_validation_normalizes_raw_secret() -> None:
credential = NonEmptyPasswordCredential(
username="user@example.com",
password="pw",
totp="JBSW Y3DP-EHPK 3PXP",
totp_type=TotpType.AUTHENTICATOR,
)
credentials._normalize_authenticator_totp_or_raise(credential)
assert credential.totp == "JBSWY3DPEHPK3PXP"
def test_authenticator_totp_validation_rejects_invalid_secret() -> None:
credential = NonEmptyPasswordCredential(
username="user@example.com",
password="pw",
totp="not a valid secret!",
totp_type=TotpType.AUTHENTICATOR,
)
with pytest.raises(HTTPException) as exc_info:
credentials._normalize_authenticator_totp_or_raise(credential)
assert exc_info.value.status_code == 400
assert "Invalid authenticator key" in exc_info.value.detail
def test_authenticator_totp_validation_ignores_non_authenticator_methods() -> None:
credential = NonEmptyPasswordCredential(
username="user@example.com",
password="pw",
totp=None,
totp_type=TotpType.EMAIL,
)
credentials._normalize_authenticator_totp_or_raise(credential)
assert credential.totp is None
@pytest.mark.asyncio
async def test_get_credential_totp_code_returns_current_generated_code(monkeypatch: pytest.MonkeyPatch) -> None:
secret = "JBSWY3DPEHPK3PXP"
db_credential, vault_service, mock_credentials = _mock_totp_preview_dependencies(monkeypatch, secret=secret)
monkeypatch.setattr(credentials.time, "time", lambda: 0)
response = await credentials.get_credential_totp_code(
response=Response(),
credential_id="cred_test",
current_org=SimpleNamespace(organization_id="org_test"),
)
assert response.code == pyotp.TOTP(secret).at(0)
assert response.seconds_remaining == 30
mock_credentials.get_credential.assert_awaited_once_with(
credential_id="cred_test",
organization_id="org_test",
)
vault_service.get_credential_item.assert_awaited_once_with(db_credential)
@pytest.mark.asyncio
async def test_get_credential_totp_code_uses_otpauth_uri_parameters(monkeypatch: pytest.MonkeyPatch) -> None:
secret = "JBSWY3DPEHPK3PXP"
totp_uri = (
f"otpauth://totp/Example:user@example.com?secret={secret}&issuer=Example&algorithm=SHA256&digits=8&period=60"
)
expected_totp = pyotp.parse_uri(totp_uri)
_mock_totp_preview_dependencies(monkeypatch, secret=totp_uri)
monkeypatch.setattr(credentials.time, "time", lambda: 0)
response = await credentials.get_credential_totp_code(
response=Response(),
credential_id="cred_test",
current_org=SimpleNamespace(organization_id="org_test"),
)
assert response.code == expected_totp.at(0)
assert response.seconds_remaining == 60
@pytest.mark.asyncio
async def test_get_credential_totp_code_uses_cache_within_current_window(monkeypatch: pytest.MonkeyPatch) -> None:
secret = "JBSWY3DPEHPK3PXP"
db_credential, vault_service, mock_credentials = _mock_totp_preview_dependencies(monkeypatch, secret=secret)
current_time = 0
monkeypatch.setattr(credentials, "_get_credential_vault_service", AsyncMock(return_value=vault_service))
monkeypatch.setattr(credentials.time, "time", lambda: current_time)
first_response = await credentials.get_credential_totp_code(
response=Response(),
credential_id="cred_test",
current_org=SimpleNamespace(organization_id="org_test"),
)
current_time = 1
second_response = await credentials.get_credential_totp_code(
response=Response(),
credential_id="cred_test",
current_org=SimpleNamespace(organization_id="org_test"),
)
assert first_response.code == pyotp.TOTP(secret).at(0)
assert second_response.code == first_response.code
assert second_response.seconds_remaining == 29
mock_credentials.get_credential.assert_awaited_with(
credential_id="cred_test",
organization_id="org_test",
)
assert mock_credentials.get_credential.await_count == 2
vault_service.get_credential_item.assert_awaited_once_with(db_credential)
@pytest.mark.asyncio
async def test_get_credential_totp_code_logs_invalid_saved_secret(monkeypatch: pytest.MonkeyPatch) -> None:
_mock_totp_preview_dependencies(monkeypatch, secret="not a valid secret!")
monkeypatch.setattr(credentials.time, "time", lambda: 0)
warning_mock = Mock()
monkeypatch.setattr(credentials.LOG, "warning", warning_mock)
with pytest.raises(HTTPException) as exc_info:
await credentials.get_credential_totp_code(
response=Response(),
credential_id="cred_test",
current_org=SimpleNamespace(organization_id="org_test"),
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == credentials._SAVED_AUTHENTICATOR_SECRET_INVALID_DETAIL
warning_mock.assert_called_once_with(
"Saved authenticator key is invalid for TOTP code preview",
credential_id="cred_test",
organization_id="org_test",
vault_type=None,
)
@pytest.mark.asyncio
async def test_get_credential_totp_code_sets_no_store_headers(monkeypatch: pytest.MonkeyPatch) -> None:
secret = "JBSWY3DPEHPK3PXP"
_mock_totp_preview_dependencies(monkeypatch, secret=secret)
monkeypatch.setattr(credentials.time, "time", lambda: 0)
response = Response()
await credentials.get_credential_totp_code(
response=response,
credential_id="cred_test",
current_org=SimpleNamespace(organization_id="org_test"),
)
assert response.headers["Cache-Control"] == "no-store"
assert response.headers["Pragma"] == "no-cache"

View file

@ -70,6 +70,9 @@ class TestIsMfaLikeParameterKey:
class TestExtractTotpFromNavigationInputs:
"""extract_totp_from_navigation_inputs should only return actual OTP codes."""
def test_untyped_short_numeric_value_defaults_to_totp(self) -> None:
assert OTPValue(value="123456").get_otp_type() == OTPType.TOTP
def test_returns_none_for_none_payload(self) -> None:
assert extract_totp_from_navigation_inputs(None) is None
@ -81,6 +84,14 @@ class TestExtractTotpFromNavigationInputs:
result = extract_totp_from_navigation_inputs(payload)
assert result is not None
assert result.value == "123456"
assert result.get_otp_type() == OTPType.TOTP
def test_extracts_magic_link_from_payload(self) -> None:
payload = {"verification_code": "https://example.com/login/magic?token=abc123"}
result = extract_totp_from_navigation_inputs(payload)
assert result is not None
assert result.value == "https://example.com/login/magic?token=abc123"
assert result.get_otp_type() == OTPType.MAGIC_LINK
def test_ignores_totp_identifier_key(self) -> None:
"""The core bug: totp_identifier value must NOT be returned as OTP code."""
@ -915,6 +926,29 @@ class TestTryGenerateTotpFromCredential:
assert result is not None
assert result.value == "424242"
def test_active_credential_with_otpauth_uri_uses_uri_config(self, monkeypatch: pytest.MonkeyPatch) -> None:
from skyvern.services import otp_service
totp_uri = (
"otpauth://totp/Example:user@example.com"
"?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA256&digits=8&period=60"
)
fake = _FakeWorkflowRunContext(
values={"credentials": {"username": "u_b", "password": "p_b", "totp": "tot_b"}},
secrets={"tot_b_value": totp_uri},
)
self._patch_workflow_context(monkeypatch, fake)
generate_totp_code_mock = MagicMock(return_value="12345678")
monkeypatch.setattr(otp_service, "generate_totp_code", generate_totp_code_mock)
with skyvern_context.scoped(_scoped_context(active="credentials")):
result = try_generate_totp_from_credential("wr_test")
assert result is not None
assert result.value == "12345678"
assert len(result.value) == 8
generate_totp_code_mock.assert_called_once_with(totp_uri)
def test_no_active_credential_with_multiple_totps_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Avoid the original bug: walking all credentials when which is active is unknown."""
fake = _FakeWorkflowRunContext(

View file

@ -1,4 +1,6 @@
from skyvern.forge.sdk.services.credentials import parse_totp_secret
import pyotp
from skyvern.forge.sdk.services.credentials import generate_totp_code, normalize_totp_config, parse_totp_secret
def test_empty_string_returns_empty() -> None:
@ -24,6 +26,25 @@ def test_valid_otpauth_uri() -> None:
assert result == "JBSWY3DPEHPK3PXP"
def test_normalize_totp_config_preserves_otpauth_uri_params() -> None:
uri = (
"otpauth://totp/Example:user@example.com"
"?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA256&digits=8&period=60"
)
assert normalize_totp_config(uri) == uri
def test_generate_totp_code_uses_otpauth_uri_params() -> None:
uri = (
"otpauth://totp/Example:user@example.com"
"?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA256&digits=8&period=60"
)
assert generate_totp_code(uri, for_time=0) == pyotp.parse_uri(uri).at(0)
assert len(generate_totp_code(uri, for_time=0)) == 8
def test_regex_extraction_valid_secret() -> None:
value = "https://example.com?secret=JBSWY3DPEHPK3PXP&other=stuff"
result = parse_totp_secret(value)