Compare commits

...

2 Commits

Author SHA1 Message Date
agent_coder 4f22d597b3 docs(api-key): correct stale "show-once" comments for the reveal/copy flow (#557)
The reveal/copy flow makes several pre-existing docstrings false (they claimed the
token is shown/returned only ONCE and is "never retrievable"). Comment-only fixes,
no logic change:

- api-key.service.ts create(): the token is not "returned ONCE"; no token material
  is ever stored (self-contained JWT) and it is re-obtainable by the owner via a
  deterministic re-mint under a step-up (POST /api-keys/reveal).
- api-key.controller.ts: drop "never retrievable again"; the JWT is returned in a
  body on create OR via /api-keys/reveal, never logged, never stored.
- client api-key-service.ts / api-key-query.ts / types.ts: drop "show-once modal"
  and "shown exactly once"; the create flow discards the token and it is copied
  later via the per-row reveal action.

Tests unchanged: 62 server / 28 client green; tsc clean on touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:23:38 +03:00
agent_coder 559d676cd6 feat(api-key): фаза 3A — снять kill-switch API_KEYS_ENABLED + копируемый ключ (reveal под step-up)
Removes the API_KEYS_ENABLED kill-switch entirely and makes api-keys copyable via
a deterministic re-mint revealed under a password step-up (#557, epic #556).

Part 1 — remove the kill-switch (keys are now always on):
- environment.service.ts: delete isApiKeysEnabled()/getApiKeysEnabledRaw().
- environment.validation.ts: delete the API_KEYS_ENABLED field + decorators
  (validation has no forbidNonWhitelisted, so a stray env value is ignored).
- api-key.service.ts: drop OnModuleInit boot-log, the validate kill-switch branch,
  and the now-unused EnvironmentService injection.
- api-key.controller.ts: drop assertEnabled() + its call sites and the
  EnvironmentService injection.
- .env.example: delete the API_KEYS_ENABLED block.

Part 2 — deterministic mint: apiKeyJwtService gets noTimestamp:true, so the
api-key token carries neither exp nor iat and re-minting the same key is
byte-identical (basis of copyable reveal).

Part 3 — POST /api-keys/reveal { id, password }: JwtAuthGuard +
rejectApiKeyPrincipal (403) + AUTH throttler; step-up via
AuthService.verifyUserCredentials BEFORE any key lookup (401, no oracle);
owner-only even for admin; uniform 404 for absent/revoked/expired/other-owner/
disabled-creator (ForbiddenException from generateApiToken normalized to 404);
re-mint via the existing deterministic path; token never persisted; durable
AuditEvent.API_KEY_REVEALED + structured log without token material.

Part 4 — UI: replace the show-once modal with a per-row Copy action -> password
prompt -> reveal -> clipboard write + toast. The token never enters React state,
localStorage or the query cache (gcTime:0 + reset-after-read, mirroring #506).
i18n en+ru added; immediate-revoke behavior kept.

Tests: server api-key + token.service specs (reveal, deterministic no-exp/no-iat,
byte-identical, kill-switch-removal) and client api-key vitest (copy flow, no-leak,
wrong-password) all green. Mutation-verified: skipping the owner check reds the
non-owner-404 test, skipping step-up reds the wrong-password test, dropping
noTimestamp reds the deterministic/no-iat tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:23:38 +03:00
24 changed files with 762 additions and 297 deletions
-10
View File
@@ -287,16 +287,6 @@ EMBEDDING_DOC_PREFIX="passage: "
# Default 120000 (2 min). # Default 120000 (2 min).
# AI_MCP_CALL_TIMEOUT_MS=120000 # 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 # 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 # 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 # 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 space members": "Add space members",
"Add to favorites": "Add to favorites", "Add to favorites": "Add to favorites",
"Admin": "Admin", "Admin": "Admin",
"API key copied to clipboard": "API key copied to clipboard",
"API key created": "API key created", "API key created": "API key created",
"API key revoked": "API key revoked", "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.", "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 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?", "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 space members": "Добавить участников пространства",
"Add to favorites": "Добавить в избранное", "Add to favorites": "Добавить в избранное",
"Admin": "Администратор", "Admin": "Администратор",
"API key copied to clipboard": "API ключ скопирован в буфер обмена",
"API key created": "API ключ создан", "API key created": "API ключ создан",
"API key revoked": "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 ключи всего рабочего пространства.", "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 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?": "Вы уверены, что хотите удалить эту страницу?",
@@ -20,12 +20,14 @@ vi.mock("@/features/api-key/services/api-key-service", () => ({
getApiKeys: vi.fn(), getApiKeys: vi.fn(),
createApiKey: vi.fn(), createApiKey: vi.fn(),
revokeApiKey: vi.fn(), revokeApiKey: vi.fn(),
revealApiKey: vi.fn(),
})); }));
import { import {
getApiKeys, getApiKeys,
createApiKey, createApiKey,
revokeApiKey, revokeApiKey,
revealApiKey,
} from "@/features/api-key/services/api-key-service"; } from "@/features/api-key/services/api-key-service";
import ApiKeysManager from "./api-keys-manager"; import ApiKeysManager from "./api-keys-manager";
@@ -181,9 +183,9 @@ describe("ApiKeysManager — revoke (acceptance #4)", () => {
}); });
}); });
describe("ApiKeysManager — show-once token (acceptance #1 & #2)", () => { describe("ApiKeysManager — create no longer shows the token (copy replaces show-once)", () => {
it("shows the token once, then discards it from the UI, localStorage and query cache", async () => { it("create closes the modal + toasts without ever rendering the token", async () => {
const SECRET = "gm_secret-token-value-xyz"; const SECRET = "gm_secret-created-value";
vi.mocked(getApiKeys).mockResolvedValue([]); vi.mocked(getApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({ vi.mocked(createApiKey).mockResolvedValue({
token: SECRET, token: SECRET,
@@ -198,7 +200,6 @@ describe("ApiKeysManager — show-once token (acceptance #1 & #2)", () => {
const { queryClient } = renderManager(UserRole.MEMBER); const { queryClient } = renderManager(UserRole.MEMBER);
await screen.findByText("No API keys yet"); await screen.findByText("No API keys yet");
// Open create modal (use the header CTA), fill the name, submit.
fireEvent.click( fireEvent.click(
screen.getAllByRole("button", { name: "Create API key" })[0], 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.change(nameInput, { target: { value: "My key" } });
fireEvent.click(screen.getByRole("button", { name: "Create" })); fireEvent.click(screen.getByRole("button", { name: "Create" }));
// Token is shown exactly once in the show-once modal. await waitFor(() => expect(createApiKey).toHaveBeenCalled());
const tokenEl = await screen.findByTestId("api-key-token"); // The token is never rendered (no more show-once modal).
expect(tokenEl.textContent).toBe(SECRET); expect(screen.queryByTestId("api-key-token")).toBeNull();
expect(screen.queryByText(SECRET)).toBeNull();
// Close the modal → token discarded from the DOM. // The created token never lands in localStorage or the react-query caches.
fireEvent.click(screen.getByRole("button", { name: "Done" })); expect(storageDump()).not.toContain(SECRET);
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);
const cacheDump = JSON.stringify( const cacheDump = JSON.stringify(
queryClient.getQueryCache().getAll().map((q) => q.state.data), 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); 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"; } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications"; 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 { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale"; import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { import {
useApiKeysQuery, useApiKeysQuery,
useCreateApiKeyMutation, useCreateApiKeyMutation,
useRevealApiKeyMutation,
useRevokeApiKeyMutation, useRevokeApiKeyMutation,
} from "@/features/api-key/queries/api-key-query"; } from "@/features/api-key/queries/api-key-query";
import { import { IApiKey } from "@/features/api-key/types/api-key.types";
IApiKey,
ICreateApiKeyResponse,
} from "@/features/api-key/types/api-key.types";
import { import {
isExpired, isExpired,
isExpiringSoon, isExpiringSoon,
lastUsedBucket, lastUsedBucket,
} from "@/features/api-key/utils"; } from "@/features/api-key/utils";
import { CreateApiKeyModal } from "./create-api-key-modal"; import { CreateApiKeyModal } from "./create-api-key-modal";
import { ShowTokenModal } from "./show-token-modal"; import { RevealKeyModal } from "./reveal-key-modal";
export default function ApiKeysManager() { export default function ApiKeysManager() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -46,27 +45,27 @@ export default function ApiKeysManager() {
const { data: keys, isLoading, isError } = useApiKeysQuery(); const { data: keys, isLoading, isError } = useApiKeysQuery();
const createMutation = useCreateApiKeyMutation(); const createMutation = useCreateApiKeyMutation();
const revokeMutation = useRevokeApiKeyMutation(); const revokeMutation = useRevokeApiKeyMutation();
const revealMutation = useRevealApiKeyMutation();
const [createOpened, setCreateOpened] = useState(false); const [createOpened, setCreateOpened] = useState(false);
// SECURITY: the show-once token lives ONLY here, in this component's local // SECURITY: only the key METADATA is held here (for the modal title) — never a
// state. It is never written to localStorage, the query cache or a log. Closing // token. The token is copied straight to the clipboard in handleCopy and is
// the modal sets it back to null (handleCloseToken) — discarded forever. // never stored in state, the query cache or localStorage.
const [createdKey, setCreatedKey] = useState<ICreateApiKeyResponse | null>( const [revealTarget, setRevealTarget] = useState<IApiKey | null>(null);
null,
);
const handleCreate = async (values: { const handleCreate = async (values: {
name: string; name: string;
expiresAt: string | null; expiresAt: string | null;
}): Promise<boolean> => { }): Promise<boolean> => {
try { try {
const res = await createMutation.mutateAsync(values); await createMutation.mutateAsync(values);
setCreateOpened(false); // The create response carries a token, but it is now retrievable any time
// Move the token into local state, then immediately purge react-query's // via the per-row Copy action (reveal), so we DISCARD it here: purge
// own copy of the mutation result so the secret does not linger in the // react-query's copy immediately and never move it into state. The user
// mutation cache. // copies the key from the list via a password step-up.
setCreatedKey(res);
createMutation.reset(); createMutation.reset();
setCreateOpened(false);
notifications.show({ message: t("API key created") });
return true; return true;
} catch { } catch {
notifications.show({ notifications.show({
@@ -77,9 +76,36 @@ export default function ApiKeysManager() {
} }
}; };
const handleCloseToken = () => { // Password step-up accepted -> re-mint + copy. The token exists only as a local
// Token discarded — the only copy the UI ever held is dropped here. // `const` for the single clipboard write, then react-query's copy is reset().
setCreatedKey(null); // 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) => const openRevokeModal = (key: IApiKey) =>
@@ -186,16 +212,28 @@ export default function ApiKeysManager() {
</Table.Td> </Table.Td>
)} )}
<Table.Td style={{ textAlign: "right" }}> <Table.Td style={{ textAlign: "right" }}>
<Tooltip label={t("Revoke")} withArrow> <Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon <Tooltip label={t("Copy API key")} withArrow>
variant="subtle" <ActionIcon
color="red" variant="subtle"
aria-label={t("Revoke {{name}}", { name: key.name })} aria-label={t("Copy {{name}}", { name: key.name })}
onClick={() => openRevokeModal(key)} disabled={expired}
> onClick={() => setRevealTarget(key)}
<IconTrash size={16} /> >
</ActionIcon> <IconCopy size={16} />
</Tooltip> </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.Td>
</Table.Tr> </Table.Tr>
); );
@@ -258,7 +296,13 @@ export default function ApiKeysManager() {
loading={createMutation.isPending} 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 { import {
createApiKey, createApiKey,
getApiKeys, getApiKeys,
revealApiKey,
revokeApiKey, revokeApiKey,
} from "@/features/api-key/services/api-key-service"; } from "@/features/api-key/services/api-key-service";
import { import {
IApiKey, IApiKey,
ICreateApiKey, ICreateApiKey,
ICreateApiKeyResponse, ICreateApiKeyResponse,
IRevealApiKey,
} from "@/features/api-key/types/api-key.types"; } from "@/features/api-key/types/api-key.types";
export const API_KEYS_QUERY_KEY = ["api-keys"]; export const API_KEYS_QUERY_KEY = ["api-keys"];
@@ -29,12 +31,12 @@ export function useApiKeysQuery(): UseQueryResult<IApiKey[], Error> {
/** /**
* Create mutation. * Create mutation.
* *
* SECURITY: the response contains the token exactly once. This hook deliberately * SECURITY: the response contains the token. This hook deliberately does NOT
* does NOT stash it anywhere — the caller reads it from `mutateAsync`'s resolved * stash it anywhere — the caller reads it from `mutateAsync`'s resolved value
* value, moves it into the show-once modal's local state, then calls * and immediately calls `mutation.reset()` to purge react-query's own copy (the
* `mutation.reset()` to purge react-query's own copy immediately. `gcTime: 0` * create flow discards the token; it is re-obtainable later via the reveal/copy
* is a second belt so nothing lingers in the mutation cache after the observer * action). `gcTime: 0` is a second belt so nothing lingers in the mutation cache
* unmounts. The list is invalidated here (the list carries no token). * after the observer unmounts. The list is invalidated here (it carries no token).
*/ */
export function useCreateApiKeyMutation() { export function useCreateApiKeyMutation() {
const queryClient = useQueryClient(); 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() { export function useRevokeApiKeyMutation() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -15,6 +15,7 @@ vi.mock("@/lib/api-client", () => ({
import { import {
createApiKey, createApiKey,
getApiKeys, getApiKeys,
revealApiKey,
} from "@/features/api-key/services/api-key-service"; } from "@/features/api-key/services/api-key-service";
beforeEach(() => { beforeEach(() => {
@@ -75,4 +76,23 @@ describe("api-key-service response-contract unwrap", () => {
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(post).toHaveBeenCalledWith("/api-keys/list"); 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, IApiKey,
ICreateApiKey, ICreateApiKey,
ICreateApiKeyResponse, ICreateApiKeyResponse,
IRevealApiKey,
} from "@/features/api-key/types/api-key.types"; } from "@/features/api-key/types/api-key.types";
// Mint a new key. The response carries the token ONCE — the caller must move it // Mint a new key. The response carries the token, but the create flow now
// straight into the show-once modal's local state and never cache it. See // 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 // queries/api-key-query.ts (gcTime: 0 + query invalidation) and
// components/api-keys-manager.tsx `handleCreate` (createMutation.reset() right // 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( export async function createApiKey(
data: ICreateApiKey, data: ICreateApiKey,
): Promise<ICreateApiKeyResponse> { ): Promise<ICreateApiKeyResponse> {
@@ -28,3 +30,13 @@ export async function getApiKeys(): Promise<IApiKey[]> {
export async function revokeApiKey(id: string): Promise<void> { export async function revokeApiKey(id: string): Promise<void> {
await api.post("/api-keys/revoke", { id }); 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 // 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 { export interface ICreatedApiKey {
id: string; id: string;
name: string; name: string;
@@ -39,10 +40,20 @@ export interface ICreatedApiKey {
createdAt: string; createdAt: string;
} }
// Response of `POST /api/api-keys/create`. `token` is the ONLY time the secret // Response of `POST /api/api-keys/create`. `token` is returned on create; it is
// is ever returned — it must live only in the show-once modal's local state and // ALSO retrievable later via reveal (deterministic re-mint under a step-up), so
// must never be cached, persisted or logged. // 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 { export interface ICreateApiKeyResponse {
token: string; token: string;
apiKey: ICreatedApiKey; 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;
}
+16
View File
@@ -14,3 +14,19 @@ export function execCommandCopy(text: string): void {
document.execCommand("copy"); document.execCommand("copy");
document.body.removeChild(textarea); 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_CREATED: 'api_key.created',
API_KEY_UPDATED: 'api_key.updated', API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted', 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 Tokens
SCIM_TOKEN_CREATED: 'scim_token.created', 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 { ApiKeyController } from './api-key.controller';
import { import {
WorkspaceCaslAction, WorkspaceCaslAction,
@@ -12,7 +16,8 @@ import {
* — GitHub-PAT semantics closing post-revocation laundering. * — GitHub-PAT semantics closing post-revocation laundering.
* - admin (CASL Manage on API) sees/revokes all workspace keys; a member only * - admin (CASL Manage on API) sees/revokes all workspace keys; a member only
* their own. * 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 = {}) { function makeController(over: any = {}) {
@@ -22,6 +27,7 @@ function makeController(over: any = {}) {
key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() }, key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() },
}), }),
revoke: jest.fn().mockResolvedValue(undefined), revoke: jest.fn().mockResolvedValue(undefined),
reveal: jest.fn().mockResolvedValue('remint.tok'),
...(over.apiKeyService ?? {}), ...(over.apiKeyService ?? {}),
}; };
const apiKeyRepo = { const apiKeyRepo = {
@@ -36,16 +42,16 @@ function makeController(over: any = {}) {
}), }),
...(over.workspaceAbility ?? {}), ...(over.workspaceAbility ?? {}),
}; };
const environmentService = { const authService = {
isApiKeysEnabled: jest.fn().mockReturnValue(over.enabled ?? true), verifyUserCredentials: jest.fn().mockResolvedValue({ id: 'u-1' }),
...(over.environmentService ?? {}), ...(over.authService ?? {}),
}; };
const auditService = { log: jest.fn() }; const auditService = { log: jest.fn() };
const controller = new ApiKeyController( const controller = new ApiKeyController(
apiKeyService as any, apiKeyService as any,
apiKeyRepo as any, apiKeyRepo as any,
workspaceAbility as any, workspaceAbility as any,
environmentService as any, authService as any,
auditService as any, auditService as any,
); );
return { return {
@@ -53,12 +59,12 @@ function makeController(over: any = {}) {
apiKeyService, apiKeyService,
apiKeyRepo, apiKeyRepo,
workspaceAbility, workspaceAbility,
environmentService, authService,
auditService, 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 workspace = { id: 'ws-1' } as any;
const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any; const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any;
const reqApiKey = () => const reqApiKey = () =>
@@ -86,20 +92,86 @@ describe('ApiKeyController — a token cannot manage tokens', () => {
controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()), controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException); ).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', () => { describe('ApiKeyController — reveal (copyable key under step-up)', () => {
it('create/list/revoke all 404 when disabled', async () => { it('re-mints and returns the token + logs API_KEY_REVEALED on success', async () => {
const { controller } = makeController({ enabled: false }); 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( await expect(
controller.create({ name: 'x' } as any, user, reqAccess()), controller.reveal(
).rejects.toBeInstanceOf(NotFoundException); { 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( await expect(
controller.list(user, workspace, reqAccess()), controller.reveal(
).rejects.toBeInstanceOf(NotFoundException); { id: 'k1', password: 'correct' } as any,
await expect( user,
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()), workspace,
reqAccess(),
),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
// No audit event on a failed reveal.
expect(auditService.log).not.toHaveBeenCalled();
}); });
}); });
@@ -2,6 +2,7 @@ import {
Body, Body,
Controller, Controller,
ForbiddenException, ForbiddenException,
forwardRef,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Inject, Inject,
@@ -17,6 +18,7 @@ import { ApiKeyService } from './api-key.service';
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo'; import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { RevokeApiKeyDto } from './dto/revoke-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 { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -31,7 +33,7 @@ import {
AUDIT_SERVICE, AUDIT_SERVICE,
IAuditService, IAuditService,
} from '../../integrations/audit/audit.service'; } 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 { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
import { AUTH_THROTTLER } from '../../integrations/throttle/throttler-names'; import { AUTH_THROTTLER } from '../../integrations/throttle/throttler-names';
@@ -44,18 +46,13 @@ export class ApiKeyController {
private readonly apiKeyService: ApiKeyService, private readonly apiKeyService: ApiKeyService,
private readonly apiKeyRepo: ApiKeyRepo, private readonly apiKeyRepo: ApiKeyRepo,
private readonly workspaceAbility: WorkspaceAbilityFactory, 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, @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 // 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 // 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). // or revoke the keys that would lock it out (closes post-revocation laundering).
@@ -78,7 +75,6 @@ export class ApiKeyController {
@AuthUser() user: User, @AuthUser() user: User,
@Req() req: FastifyRequest, @Req() req: FastifyRequest,
) { ) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req); this.rejectApiKeyPrincipal(req);
// undefined -> service default (1 year); null -> unlimited; string -> Date. // undefined -> service default (1 year); null -> unlimited; string -> Date.
@@ -100,8 +96,10 @@ export class ApiKeyController {
resourceType: AuditResource.API_KEY, resourceType: AuditResource.API_KEY,
resourceId: key.id, resourceId: key.id,
}); });
// The durable trail lives in container logs (AUDIT_SERVICE is a Noop in this // Durable audit is via DatabaseAuditService (#496); this structured log is a
// build). No token material — the JWT is only ever returned in the response. // 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( this.logger.log(
`API key created: id=${key.id} name=${JSON.stringify( `API key created: id=${key.id} name=${JSON.stringify(
key.name, key.name,
@@ -110,9 +108,9 @@ export class ApiKeyController {
} ip=${this.clientIp(req)}`, } ip=${this.clientIp(req)}`,
); );
// Return the token ONCE (never retrievable again) and the computed expiry so // Return the token (also re-obtainable later by the owner via
// the caller/UI can surface "expires <date>" (the year-default time-bomb // /api-keys/reveal under a step-up) and the computed expiry so the caller/UI
// early-warning). // can surface "expires <date>" (the year-default time-bomb early-warning).
return { return {
token, token,
apiKey: { apiKey: {
@@ -131,7 +129,6 @@ export class ApiKeyController {
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest, @Req() req: FastifyRequest,
) { ) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req); this.rejectApiKeyPrincipal(req);
const ability = this.workspaceAbility.createForUser(user, workspace); const ability = this.workspaceAbility.createForUser(user, workspace);
@@ -163,7 +160,6 @@ export class ApiKeyController {
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest, @Req() req: FastifyRequest,
) { ) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req); this.rejectApiKeyPrincipal(req);
const key = await this.apiKeyRepo.findById(dto.id, workspace.id); const key = await this.apiKeyRepo.findById(dto.id, workspace.id);
@@ -198,4 +194,64 @@ export class ApiKeyController {
return { success: true }; 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 { ApiKeyService } from './api-key.service';
import { ApiKeyController } from './api-key.controller'; import { ApiKeyController } from './api-key.controller';
import { TokenModule } from '../auth/token.module'; import { TokenModule } from '../auth/token.module';
import { AuthModule } from '../auth/auth.module';
// Core (non-EE) API-key feature: issuance REST endpoints + the shared validator // Core (non-EE) API-key feature: issuance REST endpoints + the shared validator
// consumed by jwt.strategy (REST) and McpService (the /mcp Bearer router). // 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, // jwt.strategy) and McpModule (for the /mcp router) can inject it directly,
// replacing the absent EE `ee/api-key` dynamic require. // replacing the absent EE `ee/api-key` dynamic require.
@Module({ @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], controllers: [ApiKeyController],
providers: [ApiKeyService], providers: [ApiKeyService],
exports: [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 { ApiKeyService } from './api-key.service';
import { JwtType } from '../auth/dto/jwt-payload'; 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). * - No validate cache (each call re-reads the row → immediate revocation).
*/ */
const APP_SECRET = 'secret';
function makeDeps(over: Partial<Record<string, any>> = {}) { function makeDeps(over: Partial<Record<string, any>> = {}) {
const apiKeyRepo = { const apiKeyRepo = {
findById: jest.fn(), findById: jest.fn(),
@@ -38,20 +40,13 @@ function makeDeps(over: Partial<Record<string, any>> = {}) {
generateApiToken: jest.fn().mockResolvedValue('minted.jwt.token'), generateApiToken: jest.fn().mockResolvedValue('minted.jwt.token'),
...(over.tokenService ?? {}), ...(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)( const service = new (ApiKeyService as unknown as new (...a: any[]) => ApiKeyService)(
apiKeyRepo, apiKeyRepo,
userRepo, userRepo,
workspaceRepo, workspaceRepo,
tokenService, tokenService,
environmentService,
); );
return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService, environmentService }; return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService };
} }
const payload = (over: Record<string, any> = {}) => ({ const payload = (over: Record<string, any> = {}) => ({
@@ -148,10 +143,6 @@ describe('ApiKeyService.validate', () => {
d.workspaceRepo.findById.mockResolvedValue(undefined); d.workspaceRepo.findById.mockResolvedValue(undefined);
}, },
], ],
[
'kill-switch OFF',
(d) => d.environmentService.isApiKeysEnabled.mockReturnValue(false),
],
[ [
'malformed payload (missing apiKeyId)', 'malformed payload (missing apiKeyId)',
() => undefined, () => undefined,
@@ -175,14 +166,16 @@ describe('ApiKeyService.validate', () => {
}, },
); );
it('kill-switch OFF denies BEFORE any DB read', async () => { // --- Kill-switch removed: keys work with no env variable ------------------
const { service, apiKeyRepo, environmentService } = makeDeps(); it('validates with no API_KEYS_ENABLED env (kill-switch fully removed)', async () => {
environmentService.isApiKeysEnabled.mockReturnValue(false); // 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( await expect(service.validate(payload() as any)).resolves.toMatchObject({
UnauthorizedException, user: { id: 'u-1' },
); });
expect(apiKeyRepo.findById).not.toHaveBeenCalled();
}); });
// --- Infra error propagates (5xx), NOT masked as 401 --------------------- // --- 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(); 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);
});
});
+71 -25
View File
@@ -1,7 +1,8 @@
import { import {
ForbiddenException,
Injectable, Injectable,
Logger, Logger,
OnModuleInit, NotFoundException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { v7 as uuid7 } from 'uuid'; 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 { UserRepo } from '@docmost/db/repos/user/user.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { TokenService } from '../auth/services/token.service'; import { TokenService } from '../auth/services/token.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { JwtApiKeyPayload } from '../auth/dto/jwt-payload'; import { JwtApiKeyPayload } from '../auth/dto/jwt-payload';
import { ApiKey, User, Workspace } from '@docmost/db/types/entity.types'; import { ApiKey, User, Workspace } from '@docmost/db/types/entity.types';
import { isUserDisabled } from '../../common/helpers'; 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. * key's lifetime and revocation — never the JWT, which carries no `exp` claim.
*/ */
@Injectable() @Injectable()
export class ApiKeyService implements OnModuleInit { export class ApiKeyService {
private readonly logger = new Logger(ApiKeyService.name); private readonly logger = new Logger(ApiKeyService.name);
constructor( constructor(
@@ -39,20 +39,8 @@ export class ApiKeyService implements OnModuleInit {
private readonly userRepo: UserRepo, private readonly userRepo: UserRepo,
private readonly workspaceRepo: WorkspaceRepo, private readonly workspaceRepo: WorkspaceRepo,
private readonly tokenService: TokenService, 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): * 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. * 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. * written (inert), so a half-created key cannot exist.
* 3. insert the row last. A lost response leaves an orphaned row that is * 3. insert the row last. A lost response leaves an orphaned row that is
* visible in `list` and self-heals (the user revokes it). * 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 is ever stored — the JWT is self-contained (its `api_keys`
* no token material lives in the table. * 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); * `expiresAt`: `undefined` -> default 1 year; `null` -> unlimited (explicit);
* a Date -> that instant (a past date is rejected at the DTO layer). * 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 * path returns) so the AuthUser/AuthWorkspace decorators and MCP identity work
* unchanged. * unchanged.
* *
* Failure semantics (R4, anti-enumeration): a DEFINITE negative fact — feature * Failure semantics (R4, anti-enumeration): a DEFINITE negative fact —
* disabled, missing/revoked/expired row, workspace mismatch, disabled user — * missing/revoked/expired row, workspace mismatch, disabled user —
* throws a bare `UnauthorizedException` (a single generic 401 for every case; * throws a bare `UnauthorizedException` (a single generic 401 for every case;
* an agent cannot distinguish expired from revoked, and its reaction is * an agent cannot distinguish expired from revoked, and its reaction is
* identical). An UNEXPECTED (infra) error is NOT caught here: it propagates so * identical). An UNEXPECTED (infra) error is NOT caught here: it propagates so
@@ -114,12 +105,6 @@ export class ApiKeyService implements OnModuleInit {
async validate( async validate(
payload: JwtApiKeyPayload, payload: JwtApiKeyPayload,
): Promise<{ user: User; workspace: Workspace }> { ): 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) { if (!payload?.apiKeyId || !payload?.sub || !payload?.workspaceId) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
@@ -174,6 +159,67 @@ export class ApiKeyService implements OnModuleInit {
await this.apiKeyRepo.softDelete(id, workspaceId); 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 // 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 // window; otherwise fire-and-forget so a stamp write never fails or slows the
// request (mirrors SessionActivityService.trackActivity). // 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;
}
+4 -3
View File
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtStrategy } from './strategies/jwt.strategy';
@@ -10,8 +10,9 @@ import { ApiKeyModule } from '../api-key/api-key.module';
@Module({ @Module({
// ApiKeyModule supplies ApiKeyService, injected into JwtStrategy so an // ApiKeyModule supplies ApiKeyService, injected into JwtStrategy so an
// api_key Bearer/cookie token is validated directly (replacing the absent EE // api_key Bearer/cookie token is validated directly (replacing the absent EE
// `ee/api-key` dynamic require). // `ee/api-key` dynamic require). forwardRef: ApiKeyModule imports AuthModule
imports: [TokenModule, WorkspaceModule, ApiKeyModule], // back (for the reveal step-up), so the two form a cycle.
imports: [TokenModule, WorkspaceModule, forwardRef(() => ApiKeyModule)],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, SignupService, JwtStrategy], providers: [AuthService, SignupService, JwtStrategy],
exports: [SignupService, AuthService], 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', () => { it('demonstrates the live bug it guards: the SHARED signer WOULD add exp', () => {
const { JwtService } = require('@nestjs/jwt'); const { JwtService } = require('@nestjs/jwt');
const sharedJwt = new JwtService({ 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 // 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 // row. We mint through a dedicated no-expiry signer, re-stamping only
// `issuer: 'Docmost'` for claim parity with the shared signer. // `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); return this.apiKeyJwtService().sign(payload);
} }
// Lazily-built JWT signer for API-key tokens: same APP_SECRET, same 'Docmost' // 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 // issuer, but WITHOUT the global `expiresIn` — so minted API-key tokens have no
// `exp` claim. Built once and cached. Verification still goes through the // `exp` claim. `noTimestamp: true` additionally suppresses the default `iat`
// shared verifier (same secret); `verifyAsync` does not require an `exp`. // 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;
private apiKeyJwtService(): JwtService { private apiKeyJwtService(): JwtService {
if (!this._apiKeyJwtService) { if (!this._apiKeyJwtService) {
this._apiKeyJwtService = new JwtService({ this._apiKeyJwtService = new JwtService({
secret: this.environmentService.getAppSecret(), secret: this.environmentService.getAppSecret(),
signOptions: { issuer: 'Docmost' }, signOptions: { issuer: 'Docmost', noTimestamp: true },
}); });
} }
return this._apiKeyJwtService; return this._apiKeyJwtService;
@@ -70,23 +70,6 @@ export class EnvironmentService {
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d'); 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 { getCookieExpiresIn(): Date {
const expiresInStr = this.getJwtTokenExpiresIn(); const expiresInStr = this.getJwtTokenExpiresIn();
let msUntilExpiry: number; let msUntilExpiry: number;
@@ -103,15 +103,6 @@ export class EnvironmentVariables {
@IsString() @IsString()
TYPESENSE_LOCALE: string; 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() @IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER) @ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama']) @IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])