Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f22d597b3 | |||
| 559d676cd6 |
@@ -287,16 +287,6 @@ EMBEDDING_DOC_PREFIX="passage: "
|
||||
# Default 120000 (2 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=120000
|
||||
|
||||
# Kill-switch for the agent API-key feature (#501). Default ON when unset — a
|
||||
# deploy that never sets it must NOT silently kill every agent. STRICT parse:
|
||||
# only the literals `true` / `false` are accepted; a typo like `=0`/`=off`/`=False`
|
||||
# FAILS AT BOOT by design (never silently read as "enabled"), so the switch is
|
||||
# guaranteed to actually flip when an operator flips it during an incident. When
|
||||
# set to `false`: all api-key auth is DENIED (every api-key token is rejected) and
|
||||
# the api-key management endpoints return 404. The resolved state is logged at boot
|
||||
# (`API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`) so it is verifiable per deploy.
|
||||
# API_KEYS_ENABLED=true
|
||||
|
||||
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
||||
# small for a long AI-chat research turn: the client resends the FULL message
|
||||
# history (every tool call + search result) on each turn, so a deep conversation's
|
||||
|
||||
@@ -13,8 +13,16 @@
|
||||
"Add space members": "Add space members",
|
||||
"Add to favorites": "Add to favorites",
|
||||
"Admin": "Admin",
|
||||
"API key copied to clipboard": "API key copied to clipboard",
|
||||
"API key created": "API key created",
|
||||
"API key revoked": "API key revoked",
|
||||
"Confirm your password": "Confirm your password",
|
||||
"Copy API key": "Copy API key",
|
||||
"Copy to clipboard": "Copy to clipboard",
|
||||
"Copy {{name}}": "Copy {{name}}",
|
||||
"Enter your password to copy the API key \"{{name}}\" to your clipboard.": "Enter your password to copy the API key \"{{name}}\" to your clipboard.",
|
||||
"Failed to copy API key": "Failed to copy API key",
|
||||
"Incorrect password": "Incorrect password",
|
||||
"API keys across the workspace.": "API keys across the workspace.",
|
||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
|
||||
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
|
||||
|
||||
@@ -13,8 +13,16 @@
|
||||
"Add space members": "Добавить участников пространства",
|
||||
"Add to favorites": "Добавить в избранное",
|
||||
"Admin": "Администратор",
|
||||
"API key copied to clipboard": "API ключ скопирован в буфер обмена",
|
||||
"API key created": "API ключ создан",
|
||||
"API key revoked": "API ключ отозван",
|
||||
"Confirm your password": "Подтвердите пароль",
|
||||
"Copy API key": "Скопировать API ключ",
|
||||
"Copy to clipboard": "Скопировать в буфер обмена",
|
||||
"Copy {{name}}": "Скопировать {{name}}",
|
||||
"Enter your password to copy the API key \"{{name}}\" to your clipboard.": "Введите пароль, чтобы скопировать API ключ «{{name}}» в буфер обмена.",
|
||||
"Failed to copy API key": "Не удалось скопировать API ключ",
|
||||
"Incorrect password": "Неверный пароль",
|
||||
"API keys across the workspace.": "API ключи всего рабочего пространства.",
|
||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
|
||||
"Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?",
|
||||
|
||||
@@ -20,12 +20,14 @@ vi.mock("@/features/api-key/services/api-key-service", () => ({
|
||||
getApiKeys: vi.fn(),
|
||||
createApiKey: vi.fn(),
|
||||
revokeApiKey: vi.fn(),
|
||||
revealApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
revokeApiKey,
|
||||
revealApiKey,
|
||||
} from "@/features/api-key/services/api-key-service";
|
||||
import ApiKeysManager from "./api-keys-manager";
|
||||
|
||||
@@ -181,9 +183,9 @@ describe("ApiKeysManager — revoke (acceptance #4)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiKeysManager — show-once token (acceptance #1 & #2)", () => {
|
||||
it("shows the token once, then discards it from the UI, localStorage and query cache", async () => {
|
||||
const SECRET = "gm_secret-token-value-xyz";
|
||||
describe("ApiKeysManager — create no longer shows the token (copy replaces show-once)", () => {
|
||||
it("create closes the modal + toasts without ever rendering the token", async () => {
|
||||
const SECRET = "gm_secret-created-value";
|
||||
vi.mocked(getApiKeys).mockResolvedValue([]);
|
||||
vi.mocked(createApiKey).mockResolvedValue({
|
||||
token: SECRET,
|
||||
@@ -198,7 +200,6 @@ describe("ApiKeysManager — show-once token (acceptance #1 & #2)", () => {
|
||||
const { queryClient } = renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("No API keys yet");
|
||||
|
||||
// Open create modal (use the header CTA), fill the name, submit.
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Create API key" })[0],
|
||||
);
|
||||
@@ -206,22 +207,13 @@ describe("ApiKeysManager — show-once token (acceptance #1 & #2)", () => {
|
||||
fireEvent.change(nameInput, { target: { value: "My key" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
// Token is shown exactly once in the show-once modal.
|
||||
const tokenEl = await screen.findByTestId("api-key-token");
|
||||
expect(tokenEl.textContent).toBe(SECRET);
|
||||
await waitFor(() => expect(createApiKey).toHaveBeenCalled());
|
||||
// The token is never rendered (no more show-once modal).
|
||||
expect(screen.queryByTestId("api-key-token")).toBeNull();
|
||||
expect(screen.queryByText(SECRET)).toBeNull();
|
||||
|
||||
// Close the modal → token discarded from the DOM.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Done" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("api-key-token")).toBeNull(),
|
||||
);
|
||||
|
||||
// Acceptance #2: the secret is nowhere in localStorage or the react-query
|
||||
// caches (query cache never carried it; the mutation copy was reset()).
|
||||
// Non-vacuous: currentUser IS in storage, so the dump is exercised.
|
||||
const dump = storageDump();
|
||||
expect(dump).toContain("currentUser");
|
||||
expect(dump).not.toContain(SECRET);
|
||||
// The created token never lands in localStorage or the react-query caches.
|
||||
expect(storageDump()).not.toContain(SECRET);
|
||||
const cacheDump = JSON.stringify(
|
||||
queryClient.getQueryCache().getAll().map((q) => q.state.data),
|
||||
);
|
||||
@@ -232,3 +224,73 @@ describe("ApiKeysManager — show-once token (acceptance #1 & #2)", () => {
|
||||
expect(mutationDump).not.toContain(SECRET);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiKeysManager — copy under step-up (acceptance #9)", () => {
|
||||
it("copies a re-minted token to the clipboard and leaks it nowhere else", async () => {
|
||||
const SECRET = "gm_revealed-token-value-xyz";
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
// jsdom has no clipboard; install a stub the copy helper will use.
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
vi.mocked(getApiKeys).mockResolvedValue([makeKey({ id: "k1", name: "CI token" })]);
|
||||
vi.mocked(revealApiKey).mockResolvedValue(SECRET);
|
||||
|
||||
const { queryClient } = renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("CI token");
|
||||
|
||||
// Click the per-row Copy action → password step-up modal.
|
||||
fireEvent.click(screen.getByLabelText("Copy CI token"));
|
||||
const pwInput = await screen.findByLabelText("Password");
|
||||
fireEvent.change(pwInput, { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy to clipboard" }));
|
||||
|
||||
// The reveal request carried the id + password; the token was written to the
|
||||
// clipboard and NOWHERE else.
|
||||
await waitFor(() =>
|
||||
expect(revealApiKey).toHaveBeenCalledWith({
|
||||
id: "k1",
|
||||
password: "hunter2",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith(SECRET));
|
||||
|
||||
// The revealed secret is never in the DOM, localStorage, or either cache.
|
||||
expect(screen.queryByText(SECRET)).toBeNull();
|
||||
expect(storageDump()).not.toContain(SECRET);
|
||||
const cacheDump = JSON.stringify(
|
||||
queryClient.getQueryCache().getAll().map((q) => q.state.data),
|
||||
);
|
||||
expect(cacheDump).not.toContain(SECRET);
|
||||
const mutationDump = JSON.stringify(
|
||||
queryClient.getMutationCache().getAll().map((m) => m.state.data),
|
||||
);
|
||||
expect(mutationDump).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it("a wrong password keeps the modal open and does not copy", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
vi.mocked(getApiKeys).mockResolvedValue([makeKey({ id: "k1", name: "CI token" })]);
|
||||
// Server returns a 401 for a wrong step-up password.
|
||||
vi.mocked(revealApiKey).mockRejectedValue({ response: { status: 401 } });
|
||||
|
||||
renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("CI token");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Copy CI token"));
|
||||
const pwInput = await screen.findByLabelText("Password");
|
||||
fireEvent.change(pwInput, { target: { value: "wrong" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy to clipboard" }));
|
||||
|
||||
await waitFor(() => expect(revealApiKey).toHaveBeenCalled());
|
||||
// Nothing copied; the password field is still on screen for a retry.
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(await screen.findByLabelText("Password")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,27 +13,26 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconAlertTriangle, IconKey, IconTrash } from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, IconCopy, IconKey, IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import {
|
||||
useApiKeysQuery,
|
||||
useCreateApiKeyMutation,
|
||||
useRevealApiKeyMutation,
|
||||
useRevokeApiKeyMutation,
|
||||
} from "@/features/api-key/queries/api-key-query";
|
||||
import {
|
||||
IApiKey,
|
||||
ICreateApiKeyResponse,
|
||||
} from "@/features/api-key/types/api-key.types";
|
||||
import { IApiKey } from "@/features/api-key/types/api-key.types";
|
||||
import {
|
||||
isExpired,
|
||||
isExpiringSoon,
|
||||
lastUsedBucket,
|
||||
} from "@/features/api-key/utils";
|
||||
import { CreateApiKeyModal } from "./create-api-key-modal";
|
||||
import { ShowTokenModal } from "./show-token-modal";
|
||||
import { RevealKeyModal } from "./reveal-key-modal";
|
||||
|
||||
export default function ApiKeysManager() {
|
||||
const { t } = useTranslation();
|
||||
@@ -46,27 +45,27 @@ export default function ApiKeysManager() {
|
||||
const { data: keys, isLoading, isError } = useApiKeysQuery();
|
||||
const createMutation = useCreateApiKeyMutation();
|
||||
const revokeMutation = useRevokeApiKeyMutation();
|
||||
const revealMutation = useRevealApiKeyMutation();
|
||||
|
||||
const [createOpened, setCreateOpened] = useState(false);
|
||||
// SECURITY: the show-once token lives ONLY here, in this component's local
|
||||
// state. It is never written to localStorage, the query cache or a log. Closing
|
||||
// the modal sets it back to null (handleCloseToken) — discarded forever.
|
||||
const [createdKey, setCreatedKey] = useState<ICreateApiKeyResponse | null>(
|
||||
null,
|
||||
);
|
||||
// SECURITY: only the key METADATA is held here (for the modal title) — never a
|
||||
// token. The token is copied straight to the clipboard in handleCopy and is
|
||||
// never stored in state, the query cache or localStorage.
|
||||
const [revealTarget, setRevealTarget] = useState<IApiKey | null>(null);
|
||||
|
||||
const handleCreate = async (values: {
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
}): Promise<boolean> => {
|
||||
try {
|
||||
const res = await createMutation.mutateAsync(values);
|
||||
setCreateOpened(false);
|
||||
// Move the token into local state, then immediately purge react-query's
|
||||
// own copy of the mutation result so the secret does not linger in the
|
||||
// mutation cache.
|
||||
setCreatedKey(res);
|
||||
await createMutation.mutateAsync(values);
|
||||
// The create response carries a token, but it is now retrievable any time
|
||||
// via the per-row Copy action (reveal), so we DISCARD it here: purge
|
||||
// react-query's copy immediately and never move it into state. The user
|
||||
// copies the key from the list via a password step-up.
|
||||
createMutation.reset();
|
||||
setCreateOpened(false);
|
||||
notifications.show({ message: t("API key created") });
|
||||
return true;
|
||||
} catch {
|
||||
notifications.show({
|
||||
@@ -77,9 +76,36 @@ export default function ApiKeysManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseToken = () => {
|
||||
// Token discarded — the only copy the UI ever held is dropped here.
|
||||
setCreatedKey(null);
|
||||
// Password step-up accepted -> re-mint + copy. The token exists only as a local
|
||||
// `const` for the single clipboard write, then react-query's copy is reset().
|
||||
// It never enters component state, the query cache or localStorage.
|
||||
const handleCopy = async (password: string): Promise<boolean> => {
|
||||
const target = revealTarget;
|
||||
if (!target) return false;
|
||||
try {
|
||||
const token = await revealMutation.mutateAsync({
|
||||
id: target.id,
|
||||
password,
|
||||
});
|
||||
await copyToClipboard(token);
|
||||
revealMutation.reset();
|
||||
notifications.show({ message: t("API key copied to clipboard") });
|
||||
return true;
|
||||
} catch (err) {
|
||||
revealMutation.reset();
|
||||
// 401 = wrong password (keep the modal open to retry); anything else is a
|
||||
// uniform failure (the server does not distinguish key states).
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
notifications.show({
|
||||
message:
|
||||
status === 401
|
||||
? t("Incorrect password")
|
||||
: t("Failed to copy API key"),
|
||||
color: "red",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const openRevokeModal = (key: IApiKey) =>
|
||||
@@ -186,16 +212,28 @@ export default function ApiKeysManager() {
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td style={{ textAlign: "right" }}>
|
||||
<Tooltip label={t("Revoke")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Revoke {{name}}", { name: key.name })}
|
||||
onClick={() => openRevokeModal(key)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label={t("Copy API key")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Copy {{name}}", { name: key.name })}
|
||||
disabled={expired}
|
||||
onClick={() => setRevealTarget(key)}
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Revoke")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Revoke {{name}}", { name: key.name })}
|
||||
onClick={() => openRevokeModal(key)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
@@ -258,7 +296,13 @@ export default function ApiKeysManager() {
|
||||
loading={createMutation.isPending}
|
||||
/>
|
||||
|
||||
<ShowTokenModal created={createdKey} onClose={handleCloseToken} />
|
||||
<RevealKeyModal
|
||||
keyName={revealTarget?.name ?? null}
|
||||
opened={revealTarget !== null}
|
||||
onClose={() => setRevealTarget(null)}
|
||||
onConfirm={handleCopy}
|
||||
loading={revealMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
// The key being copied (metadata only — never its token). `null` closes.
|
||||
keyName: string | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
// Step-up: resolves true when the password was accepted, the token was copied
|
||||
// to the clipboard and the modal should close; false to keep it open (wrong
|
||||
// password) so the user can retry. The password is passed straight through and
|
||||
// never persisted here.
|
||||
onConfirm: (password: string) => Promise<boolean>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// Password step-up before a copyable key is re-minted + copied. This modal only
|
||||
// ever holds the PASSWORD (transient, cleared on close) — never the revealed
|
||||
// token, which the parent writes straight to the clipboard.
|
||||
export function RevealKeyModal({
|
||||
keyName,
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const close = () => {
|
||||
setPassword("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password.length === 0) return;
|
||||
const ok = await onConfirm(password);
|
||||
if (ok) close();
|
||||
// On failure keep the modal open; clear the field so a retry starts clean.
|
||||
else setPassword("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={t("Confirm your password")}
|
||||
centered
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'Enter your password to copy the API key "{{name}}" to your clipboard.',
|
||||
{ name: keyName ?? "" },
|
||||
)}
|
||||
</Text>
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
data-autofocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={close} type="button">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading} disabled={password.length === 0}>
|
||||
{t("Copy to clipboard")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
|
||||
import { ICreateApiKeyResponse } from "@/features/api-key/types/api-key.types";
|
||||
|
||||
interface Props {
|
||||
// The freshly-created key incl. its token. Owned by the parent; this modal
|
||||
// only renders it and never copies it into its own persistent state.
|
||||
created: ICreateApiKeyResponse | null;
|
||||
// Closing MUST discard the token in the parent (set the `created` prop back to
|
||||
// null) — the token is shown exactly once.
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ShowTokenModal({ created, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateFnsLocale();
|
||||
|
||||
const expiresAt = created?.apiKey.expiresAt ?? null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={created !== null}
|
||||
onClose={onClose}
|
||||
title={t("API key created")}
|
||||
centered
|
||||
// No dismiss-on-outside-click: the token is irretrievable, so closing is a
|
||||
// deliberate act (the user confirms they have saved it).
|
||||
closeOnClickOutside={false}
|
||||
>
|
||||
{created && (
|
||||
<Stack gap="sm">
|
||||
<Alert
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb={4}>
|
||||
{t("Token")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Code
|
||||
block
|
||||
data-testid="api-key-token"
|
||||
style={{ flex: 1, wordBreak: "break-all" }}
|
||||
>
|
||||
{created.token}
|
||||
</Code>
|
||||
<CopyButton value={created.token}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
color={copied ? "teal" : "blue"}
|
||||
leftSection={
|
||||
copied ? (
|
||||
<IconCheck size={16} />
|
||||
) : (
|
||||
<IconCopy size={16} />
|
||||
)
|
||||
}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{expiresAt
|
||||
? t("Expires {{date}}", {
|
||||
date: formatLocalized(
|
||||
new Date(expiresAt),
|
||||
"MMM dd, yyyy",
|
||||
"PP",
|
||||
locale,
|
||||
),
|
||||
})
|
||||
: t("This key never expires")}
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button onClick={onClose}>{t("Done")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,14 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createApiKey,
|
||||
getApiKeys,
|
||||
revealApiKey,
|
||||
revokeApiKey,
|
||||
} from "@/features/api-key/services/api-key-service";
|
||||
import {
|
||||
IApiKey,
|
||||
ICreateApiKey,
|
||||
ICreateApiKeyResponse,
|
||||
IRevealApiKey,
|
||||
} from "@/features/api-key/types/api-key.types";
|
||||
|
||||
export const API_KEYS_QUERY_KEY = ["api-keys"];
|
||||
@@ -29,12 +31,12 @@ export function useApiKeysQuery(): UseQueryResult<IApiKey[], Error> {
|
||||
/**
|
||||
* Create mutation.
|
||||
*
|
||||
* SECURITY: the response contains the token exactly once. This hook deliberately
|
||||
* does NOT stash it anywhere — the caller reads it from `mutateAsync`'s resolved
|
||||
* value, moves it into the show-once modal's local state, then calls
|
||||
* `mutation.reset()` to purge react-query's own copy immediately. `gcTime: 0`
|
||||
* is a second belt so nothing lingers in the mutation cache after the observer
|
||||
* unmounts. The list is invalidated here (the list carries no token).
|
||||
* SECURITY: the response contains the token. This hook deliberately does NOT
|
||||
* stash it anywhere — the caller reads it from `mutateAsync`'s resolved value
|
||||
* and immediately calls `mutation.reset()` to purge react-query's own copy (the
|
||||
* create flow discards the token; it is re-obtainable later via the reveal/copy
|
||||
* action). `gcTime: 0` is a second belt so nothing lingers in the mutation cache
|
||||
* after the observer unmounts. The list is invalidated here (it carries no token).
|
||||
*/
|
||||
export function useCreateApiKeyMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -47,6 +49,23 @@ export function useCreateApiKeyMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal (copy) mutation.
|
||||
*
|
||||
* SECURITY (mirrors useCreateApiKeyMutation): the resolved value is the raw
|
||||
* token. This hook deliberately stashes it NOWHERE — the caller reads it from
|
||||
* `mutateAsync`, writes it straight to the clipboard, then calls
|
||||
* `mutation.reset()` to purge react-query's own copy. `gcTime: 0` is the second
|
||||
* belt so nothing lingers in the mutation cache after the observer unmounts.
|
||||
* There is no `onSuccess` list invalidation: reveal does not change the list.
|
||||
*/
|
||||
export function useRevealApiKeyMutation() {
|
||||
return useMutation<string, Error, IRevealApiKey>({
|
||||
mutationFn: (data) => revealApiKey(data),
|
||||
gcTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeApiKeyMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("@/lib/api-client", () => ({
|
||||
import {
|
||||
createApiKey,
|
||||
getApiKeys,
|
||||
revealApiKey,
|
||||
} from "@/features/api-key/services/api-key-service";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -75,4 +76,23 @@ describe("api-key-service response-contract unwrap", () => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect(post).toHaveBeenCalledWith("/api-keys/list");
|
||||
});
|
||||
|
||||
it("revealApiKey unwraps the envelope to the bare token string", async () => {
|
||||
const SECRET = "gm_revealed-token";
|
||||
post.mockResolvedValue({
|
||||
data: { token: SECRET },
|
||||
success: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const result = await revealApiKey({ id: "key-1", password: "pw" });
|
||||
|
||||
// The service returns ONLY the token string (not the { token } wrapper), so
|
||||
// the caller can copy it straight to the clipboard.
|
||||
expect(result).toBe(SECRET);
|
||||
expect(post).toHaveBeenCalledWith("/api-keys/reveal", {
|
||||
id: "key-1",
|
||||
password: "pw",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,15 @@ import {
|
||||
IApiKey,
|
||||
ICreateApiKey,
|
||||
ICreateApiKeyResponse,
|
||||
IRevealApiKey,
|
||||
} from "@/features/api-key/types/api-key.types";
|
||||
|
||||
// Mint a new key. The response carries the token ONCE — the caller must move it
|
||||
// straight into the show-once modal's local state and never cache it. See
|
||||
// Mint a new key. The response carries the token, but the create flow now
|
||||
// DISCARDS it (the user copies the key later via the per-row reveal action, so
|
||||
// there is no show-once modal) — the caller must never cache or persist it. See
|
||||
// queries/api-key-query.ts (gcTime: 0 + query invalidation) and
|
||||
// components/api-keys-manager.tsx `handleCreate` (createMutation.reset() right
|
||||
// after reading the token) for the reset()-after-read pattern.
|
||||
// after the mint) for the reset()-after-read discipline the reveal path mirrors.
|
||||
export async function createApiKey(
|
||||
data: ICreateApiKey,
|
||||
): Promise<ICreateApiKeyResponse> {
|
||||
@@ -28,3 +30,13 @@ export async function getApiKeys(): Promise<IApiKey[]> {
|
||||
export async function revokeApiKey(id: string): Promise<void> {
|
||||
await api.post("/api-keys/revoke", { id });
|
||||
}
|
||||
|
||||
// Reveal (re-mint) an existing key's token under a password step-up. Returns the
|
||||
// bare token string — the SECURITY contract (mirrors create): the caller must
|
||||
// write it straight to the clipboard and never stash it in state, the query
|
||||
// cache or localStorage. See useRevealApiKeyMutation (gcTime: 0 + reset-after-
|
||||
// read) and api-keys-manager.tsx `handleCopy`.
|
||||
export async function revealApiKey(data: IRevealApiKey): Promise<string> {
|
||||
const res = await api.post<{ token: string }>("/api-keys/reveal", data);
|
||||
return (res.data as { token: string }).token;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ export interface ICreateApiKey {
|
||||
}
|
||||
|
||||
// The metadata half of the create response. The token itself is carried
|
||||
// separately (see ICreateApiKeyResponse) and is shown exactly once.
|
||||
// separately (see ICreateApiKeyResponse); it is re-obtainable later by its owner
|
||||
// via a deterministic re-mint under a step-up (POST /api-keys/reveal).
|
||||
export interface ICreatedApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -39,10 +40,20 @@ export interface ICreatedApiKey {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Response of `POST /api/api-keys/create`. `token` is the ONLY time the secret
|
||||
// is ever returned — it must live only in the show-once modal's local state and
|
||||
// must never be cached, persisted or logged.
|
||||
// Response of `POST /api/api-keys/create`. `token` is returned on create; it is
|
||||
// ALSO retrievable later via reveal (deterministic re-mint under a step-up), so
|
||||
// it is no longer "show once". It must never be cached, persisted or logged —
|
||||
// the create flow discards it (the user copies via the per-row Copy action).
|
||||
export interface ICreateApiKeyResponse {
|
||||
token: string;
|
||||
apiKey: ICreatedApiKey;
|
||||
}
|
||||
|
||||
// Payload for `POST /api/api-keys/reveal`: the key id + the caller's current
|
||||
// password (step-up). The response is `{ token }` — a re-minted, byte-identical
|
||||
// copy of the key's token, which must be written straight to the clipboard and
|
||||
// never held in state, cache or storage.
|
||||
export interface IRevealApiKey {
|
||||
id: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -115,18 +115,12 @@ function DiffLine({
|
||||
<Box
|
||||
key={i}
|
||||
component="mark"
|
||||
px={3}
|
||||
fw={600}
|
||||
style={{
|
||||
background: markBg,
|
||||
color: markFg,
|
||||
borderRadius: 2,
|
||||
// Snug intra-word highlight: a hair of horizontal padding keeps
|
||||
// the mark background slightly wider than the glyph, canceled by
|
||||
// an equal negative margin so neighbouring letters are NOT pushed
|
||||
// apart. The old px={3} padding made single-letter edits (е→ё,
|
||||
// х→е) look like they had spaces around the changed character.
|
||||
padding: "0 1px",
|
||||
margin: "0 -1px",
|
||||
borderRadius: 3,
|
||||
textDecoration: isDel ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -14,3 +14,19 @@ export function execCommandCopy(text: string): void {
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
// Stateless one-shot copy: write `text` to the clipboard without ever storing it
|
||||
// in React state (unlike useClipboard, which keeps a `copied` flag AND holds the
|
||||
// last value). Used by the api-key reveal/copy flow, where the secret must touch
|
||||
// nothing but the clipboard — no component state, no cache, no localStorage.
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
if (typeof navigator !== "undefined" && "clipboard" in navigator) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to the execCommand fallback (e.g. insecure context).
|
||||
}
|
||||
}
|
||||
execCommandCopy(text);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export const AuditEvent = {
|
||||
API_KEY_CREATED: 'api_key.created',
|
||||
API_KEY_UPDATED: 'api_key.updated',
|
||||
API_KEY_DELETED: 'api_key.deleted',
|
||||
// A copyable key was re-minted and returned to its owner under a password
|
||||
// step-up (see ApiKeyController.reveal). Durable via DatabaseAuditService.
|
||||
API_KEY_REVEALED: 'api_key.revealed',
|
||||
|
||||
// SCIM Tokens
|
||||
SCIM_TOKEN_CREATED: 'scim_token.created',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiKeyController } from './api-key.controller';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
@@ -12,7 +16,8 @@ import {
|
||||
* — GitHub-PAT semantics closing post-revocation laundering.
|
||||
* - admin (CASL Manage on API) sees/revokes all workspace keys; a member only
|
||||
* their own.
|
||||
* - kill-switch OFF -> the surface 404s (looks like the feature does not exist).
|
||||
* - reveal: password step-up (401 on wrong password), owner-only even for admin
|
||||
* (404), api_key principal 403 — the copyable-key surface (#557).
|
||||
*/
|
||||
|
||||
function makeController(over: any = {}) {
|
||||
@@ -22,6 +27,7 @@ function makeController(over: any = {}) {
|
||||
key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() },
|
||||
}),
|
||||
revoke: jest.fn().mockResolvedValue(undefined),
|
||||
reveal: jest.fn().mockResolvedValue('remint.tok'),
|
||||
...(over.apiKeyService ?? {}),
|
||||
};
|
||||
const apiKeyRepo = {
|
||||
@@ -36,16 +42,16 @@ function makeController(over: any = {}) {
|
||||
}),
|
||||
...(over.workspaceAbility ?? {}),
|
||||
};
|
||||
const environmentService = {
|
||||
isApiKeysEnabled: jest.fn().mockReturnValue(over.enabled ?? true),
|
||||
...(over.environmentService ?? {}),
|
||||
const authService = {
|
||||
verifyUserCredentials: jest.fn().mockResolvedValue({ id: 'u-1' }),
|
||||
...(over.authService ?? {}),
|
||||
};
|
||||
const auditService = { log: jest.fn() };
|
||||
const controller = new ApiKeyController(
|
||||
apiKeyService as any,
|
||||
apiKeyRepo as any,
|
||||
workspaceAbility as any,
|
||||
environmentService as any,
|
||||
authService as any,
|
||||
auditService as any,
|
||||
);
|
||||
return {
|
||||
@@ -53,12 +59,12 @@ function makeController(over: any = {}) {
|
||||
apiKeyService,
|
||||
apiKeyRepo,
|
||||
workspaceAbility,
|
||||
environmentService,
|
||||
authService,
|
||||
auditService,
|
||||
};
|
||||
}
|
||||
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
const user = { id: 'u-1', email: 'u@x.io', workspaceId: 'ws-1' } as any;
|
||||
const workspace = { id: 'ws-1' } as any;
|
||||
const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any;
|
||||
const reqApiKey = () =>
|
||||
@@ -86,20 +92,86 @@ describe('ApiKeyController — a token cannot manage tokens', () => {
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('403 on reveal for an api_key principal (a key cannot reveal its siblings)', async () => {
|
||||
const { controller, authService, apiKeyService } = makeController();
|
||||
await expect(
|
||||
controller.reveal(
|
||||
{ id: 'k1', password: 'pw' } as any,
|
||||
user,
|
||||
workspace,
|
||||
reqApiKey(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
// Rejected BEFORE the step-up and the key lookup even run.
|
||||
expect(authService.verifyUserCredentials).not.toHaveBeenCalled();
|
||||
expect(apiKeyService.reveal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — kill-switch OFF → 404', () => {
|
||||
it('create/list/revoke all 404 when disabled', async () => {
|
||||
const { controller } = makeController({ enabled: false });
|
||||
describe('ApiKeyController — reveal (copyable key under step-up)', () => {
|
||||
it('re-mints and returns the token + logs API_KEY_REVEALED on success', async () => {
|
||||
const { controller, apiKeyService, authService, auditService } =
|
||||
makeController();
|
||||
const res = await controller.reveal(
|
||||
{ id: 'k1', password: 'correct-horse' } as any,
|
||||
user,
|
||||
workspace,
|
||||
reqAccess(),
|
||||
);
|
||||
|
||||
expect(res).toEqual({ token: 'remint.tok' });
|
||||
// Step-up ran with the caller's email + supplied password.
|
||||
expect(authService.verifyUserCredentials).toHaveBeenCalledWith(
|
||||
{ email: 'u@x.io', password: 'correct-horse' },
|
||||
'ws-1',
|
||||
);
|
||||
expect(apiKeyService.reveal).toHaveBeenCalledWith({
|
||||
apiKeyId: 'k1',
|
||||
user,
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'api_key.revealed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('401 on a wrong password — step-up runs BEFORE the key lookup (no oracle)', async () => {
|
||||
const { controller, apiKeyService } = makeController({
|
||||
authService: {
|
||||
verifyUserCredentials: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new UnauthorizedException()),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
controller.create({ name: 'x' } as any, user, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
controller.reveal(
|
||||
{ id: 'k1', password: 'wrong' } as any,
|
||||
user,
|
||||
workspace,
|
||||
reqAccess(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
// The key is never looked up / re-minted when step-up fails.
|
||||
expect(apiKeyService.reveal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates the service's uniform 404 (absent/revoked/expired/non-owner)", async () => {
|
||||
const { controller, auditService } = makeController({
|
||||
apiKeyService: {
|
||||
reveal: jest.fn().mockRejectedValue(new NotFoundException()),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
controller.list(user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
|
||||
controller.reveal(
|
||||
{ id: 'k1', password: 'correct' } as any,
|
||||
user,
|
||||
workspace,
|
||||
reqAccess(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
// No audit event on a failed reveal.
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
@@ -17,6 +18,7 @@ import { ApiKeyService } from './api-key.service';
|
||||
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { RevokeApiKeyDto } from './dto/revoke-api-key.dto';
|
||||
import { RevealApiKeyDto } from './dto/reveal-api-key.dto';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
@@ -31,7 +33,7 @@ import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { AuthService } from '../auth/services/auth.service';
|
||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||
import { AUTH_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||
|
||||
@@ -44,18 +46,13 @@ export class ApiKeyController {
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
private readonly apiKeyRepo: ApiKeyRepo,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
// forwardRef breaks the AuthModule <-> ApiKeyModule cycle (AuthModule imports
|
||||
// ApiKeyModule for JwtStrategy). Only used for the reveal step-up.
|
||||
@Inject(forwardRef(() => AuthService))
|
||||
private readonly authService: AuthService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
// Kill-switch OFF: the issuance surface disappears entirely (404), not a 403 —
|
||||
// it must look like the feature does not exist.
|
||||
private assertEnabled(): void {
|
||||
if (!this.environmentService.isApiKeysEnabled()) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
// A token cannot manage tokens (GitHub-PAT semantics): an api-key principal is
|
||||
// refused on the management surface, so a leaked key cannot mint a replacement
|
||||
// or revoke the keys that would lock it out (closes post-revocation laundering).
|
||||
@@ -78,7 +75,6 @@ export class ApiKeyController {
|
||||
@AuthUser() user: User,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
// undefined -> service default (1 year); null -> unlimited; string -> Date.
|
||||
@@ -100,8 +96,10 @@ export class ApiKeyController {
|
||||
resourceType: AuditResource.API_KEY,
|
||||
resourceId: key.id,
|
||||
});
|
||||
// The durable trail lives in container logs (AUDIT_SERVICE is a Noop in this
|
||||
// build). No token material — the JWT is only ever returned in the response.
|
||||
// Durable audit is via DatabaseAuditService (#496); this structured log is a
|
||||
// second, container-log trail. No token material — the JWT is only ever
|
||||
// returned in a response body (here on create, or via /api-keys/reveal),
|
||||
// never logged and never stored.
|
||||
this.logger.log(
|
||||
`API key created: id=${key.id} name=${JSON.stringify(
|
||||
key.name,
|
||||
@@ -110,9 +108,9 @@ export class ApiKeyController {
|
||||
} ip=${this.clientIp(req)}`,
|
||||
);
|
||||
|
||||
// Return the token ONCE (never retrievable again) and the computed expiry so
|
||||
// the caller/UI can surface "expires <date>" (the year-default time-bomb
|
||||
// early-warning).
|
||||
// Return the token (also re-obtainable later by the owner via
|
||||
// /api-keys/reveal under a step-up) and the computed expiry so the caller/UI
|
||||
// can surface "expires <date>" (the year-default time-bomb early-warning).
|
||||
return {
|
||||
token,
|
||||
apiKey: {
|
||||
@@ -131,7 +129,6 @@ export class ApiKeyController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
@@ -163,7 +160,6 @@ export class ApiKeyController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
const key = await this.apiKeyRepo.findById(dto.id, workspace.id);
|
||||
@@ -198,4 +194,64 @@ export class ApiKeyController {
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal (re-mint) a copyable token for an existing key, under a password
|
||||
* step-up. The token is hashed nowhere and stored nowhere — "reveal" re-mints
|
||||
* the SAME deterministic value (see TokenService.generateApiToken /
|
||||
* ApiKeyService.reveal) and returns it in the body only.
|
||||
*
|
||||
* Defence layers (triple, against exfiltration through a hijacked session):
|
||||
* - rejectApiKeyPrincipal: an api_key Bearer caller is 403 — a leaked key must
|
||||
* NOT be able to reveal its siblings (key laundering); a token doesn't manage
|
||||
* tokens.
|
||||
* - step-up: the password is re-verified (verifyUserCredentials) BEFORE any key
|
||||
* lookup, so a wrong/absent password is a uniform 401 that leaks nothing about
|
||||
* the key. SSO/MFA-only users (no local password) are out of scope (homelab).
|
||||
* - AUTH throttler: same limiter as create.
|
||||
*
|
||||
* Owner-only, EVEN for an admin, and every key-state negative (absent / revoked /
|
||||
* expired / another user's / creator disabled) is a UNIFORM 404 (see
|
||||
* ApiKeyService.reveal) — no existence/state oracle.
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [AUTH_THROTTLER]: { limit: 10, ttl: 60_000 } })
|
||||
@Post('reveal')
|
||||
async reveal(
|
||||
@Body() dto: RevealApiKeyDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
// Step-up FIRST — before any key lookup — so the 401 for a bad password never
|
||||
// depends on (and never leaks) the key's existence/state. Reuses the exact
|
||||
// timing-safe, SSO-aware credential check used by login (throws a uniform
|
||||
// 401 on wrong/absent/SSO-only password).
|
||||
await this.authService.verifyUserCredentials(
|
||||
{ email: user.email, password: dto.password },
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const token = await this.apiKeyService.reveal({
|
||||
apiKeyId: dto.id,
|
||||
user,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
// Durable audit (DatabaseAuditService, #496) + a structured container-log
|
||||
// trail. NEVER the token material.
|
||||
this.auditService.log({
|
||||
event: AuditEvent.API_KEY_REVEALED,
|
||||
resourceType: AuditResource.API_KEY,
|
||||
resourceId: dto.id,
|
||||
});
|
||||
this.logger.log(
|
||||
`API key revealed: id=${dto.id} actor=${user.id} ip=${this.clientIp(req)}`,
|
||||
);
|
||||
|
||||
return { token };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { ApiKeyController } from './api-key.controller';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
// Core (non-EE) API-key feature: issuance REST endpoints + the shared validator
|
||||
// consumed by jwt.strategy (REST) and McpService (the /mcp Bearer router).
|
||||
@@ -11,7 +12,10 @@ import { TokenModule } from '../auth/token.module';
|
||||
// jwt.strategy) and McpModule (for the /mcp router) can inject it directly,
|
||||
// replacing the absent EE `ee/api-key` dynamic require.
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
// forwardRef(AuthModule): the reveal endpoint's password step-up uses
|
||||
// AuthService.verifyUserCredentials. AuthModule already imports ApiKeyModule
|
||||
// (for JwtStrategy), so the two form a cycle that forwardRef resolves.
|
||||
imports: [TokenModule, forwardRef(() => AuthModule)],
|
||||
controllers: [ApiKeyController],
|
||||
providers: [ApiKeyService],
|
||||
exports: [ApiKeyService],
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { JwtType } from '../auth/dto/jwt-payload';
|
||||
|
||||
@@ -16,8 +20,6 @@ import { JwtType } from '../auth/dto/jwt-payload';
|
||||
* - No validate cache (each call re-reads the row → immediate revocation).
|
||||
*/
|
||||
|
||||
const APP_SECRET = 'secret';
|
||||
|
||||
function makeDeps(over: Partial<Record<string, any>> = {}) {
|
||||
const apiKeyRepo = {
|
||||
findById: jest.fn(),
|
||||
@@ -38,20 +40,13 @@ function makeDeps(over: Partial<Record<string, any>> = {}) {
|
||||
generateApiToken: jest.fn().mockResolvedValue('minted.jwt.token'),
|
||||
...(over.tokenService ?? {}),
|
||||
};
|
||||
const environmentService = {
|
||||
isApiKeysEnabled: jest.fn().mockReturnValue(true),
|
||||
getApiKeysEnabledRaw: jest.fn().mockReturnValue(undefined),
|
||||
getAppSecret: jest.fn().mockReturnValue(APP_SECRET),
|
||||
...(over.environmentService ?? {}),
|
||||
};
|
||||
const service = new (ApiKeyService as unknown as new (...a: any[]) => ApiKeyService)(
|
||||
apiKeyRepo,
|
||||
userRepo,
|
||||
workspaceRepo,
|
||||
tokenService,
|
||||
environmentService,
|
||||
);
|
||||
return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService, environmentService };
|
||||
return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService };
|
||||
}
|
||||
|
||||
const payload = (over: Record<string, any> = {}) => ({
|
||||
@@ -148,10 +143,6 @@ describe('ApiKeyService.validate', () => {
|
||||
d.workspaceRepo.findById.mockResolvedValue(undefined);
|
||||
},
|
||||
],
|
||||
[
|
||||
'kill-switch OFF',
|
||||
(d) => d.environmentService.isApiKeysEnabled.mockReturnValue(false),
|
||||
],
|
||||
[
|
||||
'malformed payload (missing apiKeyId)',
|
||||
() => undefined,
|
||||
@@ -175,14 +166,16 @@ describe('ApiKeyService.validate', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('kill-switch OFF denies BEFORE any DB read', async () => {
|
||||
const { service, apiKeyRepo, environmentService } = makeDeps();
|
||||
environmentService.isApiKeysEnabled.mockReturnValue(false);
|
||||
// --- Kill-switch removed: keys work with no env variable ------------------
|
||||
it('validates with no API_KEYS_ENABLED env (kill-switch fully removed)', async () => {
|
||||
// The service no longer takes EnvironmentService — there is no env gate left.
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
|
||||
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(apiKeyRepo.findById).not.toHaveBeenCalled();
|
||||
await expect(service.validate(payload() as any)).resolves.toMatchObject({
|
||||
user: { id: 'u-1' },
|
||||
});
|
||||
});
|
||||
|
||||
// --- Infra error propagates (5xx), NOT masked as 401 ---------------------
|
||||
@@ -297,3 +290,90 @@ describe('ApiKeyService.create (mint-then-insert, no exp)', () => {
|
||||
expect(apiKeyRepo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ApiKeyService.reveal — the KEY-STATE gate for the copyable-key flow. Owner-only
|
||||
* (even for an admin), and EVERY negative is a uniform NotFoundException so there
|
||||
* is no existence/state oracle. Re-mint is deterministic (delegated to
|
||||
* TokenService); here we only assert the gate + normalization.
|
||||
*/
|
||||
describe('ApiKeyService.reveal', () => {
|
||||
const caller = (over: Record<string, any> = {}) =>
|
||||
({ id: 'u-1', email: 'u@x.io', workspaceId: 'ws-1', ...over }) as any;
|
||||
|
||||
it('re-mints and returns the token for a live key owned by the caller', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
tokenService.generateApiToken.mockResolvedValue('remint.tok');
|
||||
|
||||
const token = await service.reveal({
|
||||
apiKeyId: 'key-1',
|
||||
user: caller(),
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
|
||||
expect(token).toBe('remint.tok');
|
||||
// Re-mints against the SAME id + the caller (== creator) so the value matches.
|
||||
expect(tokenService.generateApiToken).toHaveBeenCalledWith({
|
||||
apiKeyId: 'key-1',
|
||||
user: expect.objectContaining({ id: 'u-1' }),
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('404 for a missing/revoked key (findById returns undefined)', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.reveal({ apiKeyId: 'key-1', user: caller(), workspaceId: 'ws-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
// No re-mint on any negative.
|
||||
expect(tokenService.generateApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404 for another user's key — owner-only, even for an admin", async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
// The caller is a workspace admin (irrelevant here — reveal ignores CASL).
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow({ creatorId: 'someone-else' }));
|
||||
|
||||
await expect(
|
||||
service.reveal({ apiKeyId: 'key-1', user: caller(), workspaceId: 'ws-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tokenService.generateApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404 for an expired key (expiry read from the row) — no dead token issued', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(
|
||||
activeRow({ expiresAt: new Date(Date.now() - 1000) }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.reveal({ apiKeyId: 'key-1', user: caller(), workspaceId: 'ws-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tokenService.generateApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes a disabled-creator ForbiddenException (403) to a uniform 404', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
// generateApiToken throws ForbiddenException when isUserDisabled(user).
|
||||
tokenService.generateApiToken.mockRejectedValue(new ForbiddenException());
|
||||
|
||||
await expect(
|
||||
service.reveal({ apiKeyId: 'key-1', user: caller(), workspaceId: 'ws-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('propagates an UNEXPECTED re-mint error (not masked as 404)', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
const boom = new Error('signer exploded');
|
||||
tokenService.generateApiToken.mockRejectedValue(boom);
|
||||
|
||||
await expect(
|
||||
service.reveal({ apiKeyId: 'key-1', user: caller(), workspaceId: 'ws-1' }),
|
||||
).rejects.toBe(boom);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
@@ -9,7 +10,6 @@ import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { JwtApiKeyPayload } from '../auth/dto/jwt-payload';
|
||||
import { ApiKey, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { isUserDisabled } from '../../common/helpers';
|
||||
@@ -31,7 +31,7 @@ const LAST_USED_THROTTLE_MS = 60 * 60 * 1000;
|
||||
* key's lifetime and revocation — never the JWT, which carries no `exp` claim.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ApiKeyService implements OnModuleInit {
|
||||
export class ApiKeyService {
|
||||
private readonly logger = new Logger(ApiKeyService.name);
|
||||
|
||||
constructor(
|
||||
@@ -39,20 +39,8 @@ export class ApiKeyService implements OnModuleInit {
|
||||
private readonly userRepo: UserRepo,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
// Boot log so the kill-switch state after each deploy is verifiable in logs.
|
||||
const enabled = this.environmentService.isApiKeysEnabled();
|
||||
const raw = this.environmentService.getApiKeysEnabledRaw();
|
||||
this.logger.log(
|
||||
`API keys: ${enabled ? 'ENABLED' : 'DISABLED'} (API_KEYS_ENABLED=${
|
||||
raw ?? 'unset'
|
||||
})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a new key for `user`. mint-then-insert ordering (R1):
|
||||
* 1. generate the id first — it must be in the JWT payload before the row.
|
||||
@@ -60,8 +48,11 @@ export class ApiKeyService implements OnModuleInit {
|
||||
* written (inert), so a half-created key cannot exist.
|
||||
* 3. insert the row last. A lost response leaves an orphaned row that is
|
||||
* visible in `list` and self-heals (the user revokes it).
|
||||
* The token is returned ONCE and never stored — the JWT is self-contained, so
|
||||
* no token material lives in the table.
|
||||
* No token material is ever stored — the JWT is self-contained (its `api_keys`
|
||||
* row holds only metadata + lifetime, never the token). The token is returned
|
||||
* here on create AND is re-obtainable any time by its owner via a deterministic
|
||||
* re-mint under a password step-up (POST /api-keys/reveal); it is deterministic
|
||||
* precisely because it carries no `iat`/`exp` (see TokenService).
|
||||
*
|
||||
* `expiresAt`: `undefined` -> default 1 year; `null` -> unlimited (explicit);
|
||||
* a Date -> that instant (a past date is rejected at the DTO layer).
|
||||
@@ -101,8 +92,8 @@ export class ApiKeyService implements OnModuleInit {
|
||||
* path returns) so the AuthUser/AuthWorkspace decorators and MCP identity work
|
||||
* unchanged.
|
||||
*
|
||||
* Failure semantics (R4, anti-enumeration): a DEFINITE negative fact — feature
|
||||
* disabled, missing/revoked/expired row, workspace mismatch, disabled user —
|
||||
* Failure semantics (R4, anti-enumeration): a DEFINITE negative fact —
|
||||
* missing/revoked/expired row, workspace mismatch, disabled user —
|
||||
* throws a bare `UnauthorizedException` (a single generic 401 for every case;
|
||||
* an agent cannot distinguish expired from revoked, and its reaction is
|
||||
* identical). An UNEXPECTED (infra) error is NOT caught here: it propagates so
|
||||
@@ -114,12 +105,6 @@ export class ApiKeyService implements OnModuleInit {
|
||||
async validate(
|
||||
payload: JwtApiKeyPayload,
|
||||
): Promise<{ user: User; workspace: Workspace }> {
|
||||
// Kill-switch OFF: deny unconditionally (same generic 401). Note the
|
||||
// endpoints additionally 404 at the controller; here we deny the token.
|
||||
if (!this.environmentService.isApiKeysEnabled()) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (!payload?.apiKeyId || !payload?.sub || !payload?.workspaceId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
@@ -174,6 +159,67 @@ export class ApiKeyService implements OnModuleInit {
|
||||
await this.apiKeyRepo.softDelete(id, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-mint (reveal) the token for an EXISTING key so its owner can copy it again.
|
||||
* The token material is never stored, so "reveal" = re-generate the same value:
|
||||
* generateApiToken is deterministic (no `iat`/`exp`, fixed payload), so the
|
||||
* re-minted token is byte-identical to the original for the same key.
|
||||
*
|
||||
* Authorization/step-up (principal rejection + password) is the caller's job
|
||||
* (the controller). This method owns the KEY-STATE gate and the re-mint. Every
|
||||
* negative is a UNIFORM `NotFoundException` — there is NO existence/state oracle:
|
||||
* a caller cannot tell "absent" from "revoked" from "expired" from "another
|
||||
* user's key" from "creator disabled". Only a live key owned by `user` re-mints.
|
||||
*
|
||||
* `user` is the authenticated caller AND (after the owner check) the key's
|
||||
* creator, so the re-minted token's `sub` matches the original mint — passing
|
||||
* `user` directly avoids re-fetching the creator row.
|
||||
*/
|
||||
async reveal(opts: {
|
||||
apiKeyId: string;
|
||||
user: User;
|
||||
workspaceId: string;
|
||||
}): Promise<string> {
|
||||
const { apiKeyId, user, workspaceId } = opts;
|
||||
|
||||
const row = await this.apiKeyRepo.findById(apiKeyId, workspaceId);
|
||||
// Absent = revoked (soft-deleted), orphaned, or never existed -> uniform 404.
|
||||
if (!row) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Owner-only, EVEN for an admin: a working revealed token is an impersonation
|
||||
// of the creator, so unlike list/revoke, admin must NOT reveal others' keys.
|
||||
// Someone else's key looks exactly like a missing one -> uniform 404.
|
||||
if (row.creatorId !== user.id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Expiry is read from the ROW (findById filters only deletedAt, so an expired
|
||||
// row is still returned). A dead key must not hand out a working token, and a
|
||||
// uniform 404 keeps the anti-enumeration property.
|
||||
if (row.expiresAt && row.expiresAt.getTime() <= Date.now()) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
try {
|
||||
// Deterministic re-mint (byte-identical to the original for this key).
|
||||
return await this.tokenService.generateApiToken({
|
||||
apiKeyId: row.id,
|
||||
user,
|
||||
workspaceId,
|
||||
});
|
||||
} catch (err) {
|
||||
// A disabled creator makes generateApiToken throw ForbiddenException (403).
|
||||
// Normalize to the SAME 404 so reveal never leaks "user is blocked" vs "key
|
||||
// does not exist". Unexpected errors propagate (5xx), never masked.
|
||||
if (err instanceof ForbiddenException) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Throttled best-effort last_used_at bump: skip if it was touched within the
|
||||
// window; otherwise fire-and-forget so a stamp write never fails or slows the
|
||||
// request (mirrors SessionActivityService.trackActivity).
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
// Step-up payload for `POST /api-keys/reveal`: the key to re-mint + the caller's
|
||||
// current password. The password is re-verified server-side (AuthService.
|
||||
// verifyUserCredentials) before the key is ever looked up, so a copyable,
|
||||
// non-expiring token cannot be exfiltrated through a merely-hijacked session.
|
||||
export class RevealApiKeyDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
@@ -10,8 +10,9 @@ import { ApiKeyModule } from '../api-key/api-key.module';
|
||||
@Module({
|
||||
// ApiKeyModule supplies ApiKeyService, injected into JwtStrategy so an
|
||||
// api_key Bearer/cookie token is validated directly (replacing the absent EE
|
||||
// `ee/api-key` dynamic require).
|
||||
imports: [TokenModule, WorkspaceModule, ApiKeyModule],
|
||||
// `ee/api-key` dynamic require). forwardRef: ApiKeyModule imports AuthModule
|
||||
// back (for the reveal step-up), so the two form a cycle.
|
||||
imports: [TokenModule, WorkspaceModule, forwardRef(() => ApiKeyModule)],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, SignupService, JwtStrategy],
|
||||
exports: [SignupService, AuthService],
|
||||
|
||||
@@ -306,6 +306,41 @@ describe('TokenService.generateApiToken (no exp claim ever)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #557: the copyable-key contract. noTimestamp suppresses `iat`, so the token
|
||||
// is a pure deterministic function of (payload, secret) — re-minting the SAME
|
||||
// key yields a BYTE-IDENTICAL value, which is what makes "reveal" a safe
|
||||
// re-mint rather than a stored secret.
|
||||
it('mints an api-key JWT with NEITHER exp NOR iat (deterministic)', async () => {
|
||||
const { service } = makeRealSignerService();
|
||||
|
||||
const token = await service.generateApiToken({
|
||||
apiKeyId: 'key-1',
|
||||
user: user as never,
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
|
||||
const decoded = jwt.decode(token) as Record<string, unknown>;
|
||||
expect(decoded.exp).toBeUndefined();
|
||||
expect(decoded.iat).toBeUndefined();
|
||||
});
|
||||
|
||||
it('two mints of the SAME key are byte-identical (re-mint = reveal)', async () => {
|
||||
const { service } = makeRealSignerService();
|
||||
const opts = {
|
||||
apiKeyId: 'key-1',
|
||||
user: user as never,
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
const first = await service.generateApiToken(opts);
|
||||
// A different time (and a fresh signer instance) must not change the bytes.
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
const { service: service2 } = makeRealSignerService();
|
||||
const second = await service2.generateApiToken(opts);
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('demonstrates the live bug it guards: the SHARED signer WOULD add exp', () => {
|
||||
const { JwtService } = require('@nestjs/jwt');
|
||||
const sharedJwt = new JwtService({
|
||||
|
||||
@@ -155,19 +155,31 @@ export class TokenService {
|
||||
// signer would silently get exp=now+90d and die in 90 days regardless of its
|
||||
// row. We mint through a dedicated no-expiry signer, re-stamping only
|
||||
// `issuer: 'Docmost'` for claim parity with the shared signer.
|
||||
//
|
||||
// The dedicated signer ALSO sets `noTimestamp: true`, so the token carries
|
||||
// neither `exp` nor `iat`. The payload is a fixed literal { sub, apiKeyId,
|
||||
// workspaceId, type } in a stable order, so the HS256 signature is a pure
|
||||
// deterministic function of (sub, apiKeyId, workspaceId, APP_SECRET): minting
|
||||
// the SAME key twice yields a BYTE-IDENTICAL token. This is what makes the key
|
||||
// "copyable" — the reveal endpoint (#557) re-mints the same value under a
|
||||
// step-up without ever persisting the token material.
|
||||
return this.apiKeyJwtService().sign(payload);
|
||||
}
|
||||
|
||||
// Lazily-built JWT signer for API-key tokens: same APP_SECRET, same 'Docmost'
|
||||
// issuer, but WITHOUT the global `expiresIn` — so minted API-key tokens have no
|
||||
// `exp` claim. Built once and cached. Verification still goes through the
|
||||
// shared verifier (same secret); `verifyAsync` does not require an `exp`.
|
||||
// `exp` claim. `noTimestamp: true` additionally suppresses the default `iat`
|
||||
// claim jsonwebtoken would otherwise add, making the minted token a fully
|
||||
// deterministic function of its payload + secret (byte-identical re-mints, the
|
||||
// basis of the copyable/reveal flow). Built once and cached. Verification still
|
||||
// goes through the shared verifier (same secret); `verifyAsync` does not
|
||||
// require an `exp` or `iat`.
|
||||
private _apiKeyJwtService?: JwtService;
|
||||
private apiKeyJwtService(): JwtService {
|
||||
if (!this._apiKeyJwtService) {
|
||||
this._apiKeyJwtService = new JwtService({
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
signOptions: { issuer: 'Docmost' },
|
||||
signOptions: { issuer: 'Docmost', noTimestamp: true },
|
||||
});
|
||||
}
|
||||
return this._apiKeyJwtService;
|
||||
|
||||
@@ -70,23 +70,6 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
|
||||
}
|
||||
|
||||
// Kill-switch for the agent API-key feature. Default ON (unset -> enabled): a
|
||||
// deploy that never sets the variable must NOT silently kill every agent. The
|
||||
// parse is STRICT — the value is validated by environment.validation to be
|
||||
// exactly 'true' or 'false' (or absent), so a typo like `=0`/`=off`/`=False`
|
||||
// fails at boot rather than being read as "enabled" (which would leave an
|
||||
// operator who yanked the switch during an incident with it still on). When
|
||||
// OFF: validate() denies every api-key token and the issuance endpoints 404.
|
||||
isApiKeysEnabled(): boolean {
|
||||
return this.configService.get<string>('API_KEYS_ENABLED', 'true') !== 'false';
|
||||
}
|
||||
|
||||
// Raw value (or undefined) for the boot log, so the state after each deploy is
|
||||
// verifiable in container logs: `API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`.
|
||||
getApiKeysEnabledRaw(): string | undefined {
|
||||
return this.configService.get<string>('API_KEYS_ENABLED');
|
||||
}
|
||||
|
||||
getCookieExpiresIn(): Date {
|
||||
const expiresInStr = this.getJwtTokenExpiresIn();
|
||||
let msUntilExpiry: number;
|
||||
|
||||
@@ -103,15 +103,6 @@ export class EnvironmentVariables {
|
||||
@IsString()
|
||||
TYPESENSE_LOCALE: string;
|
||||
|
||||
// Agent API-key kill-switch. Optional (absent -> default ON). STRICT: only the
|
||||
// literals 'true'/'false' are accepted, so `=0`/`=off`/`=False` fail at boot
|
||||
// instead of being silently read as "enabled" — the switch must actually flip
|
||||
// when an operator flips it. See EnvironmentService.isApiKeysEnabled.
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
@IsString()
|
||||
API_KEYS_ENABLED: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((obj) => obj.AI_DRIVER)
|
||||
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
|
||||
|
||||
Reference in New Issue
Block a user