mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
Improve authenticator 2FA credential setup (SKY-6598) (#6783)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
parent
54752d8b64
commit
3235d4e648
25 changed files with 1660 additions and 69 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
175
skyvern-frontend/src/routes/credentials/CredentialItem.test.tsx
Normal file
175
skyvern-frontend/src/routes/credentials/CredentialItem.test.tsx
Normal 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
63
skyvern-frontend/src/routes/credentials/decodeQrCodeImage.ts
Normal file
63
skyvern-frontend/src/routes/credentials/decodeQrCodeImage.ts
Normal 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 };
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
267
tests/unit/test_credential_totp_code.py
Normal file
267
tests/unit/test_credential_totp_code.py
Normal 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"
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue