AACsearch
Безопасность и соответствие

Токены поиска с ограниченной областью

Короткоживущие HMAC-подписанные токены поверх API-ключа с фильтром арендатора — безопасно класть в браузер.

Scoped-токены поиска

Scoped-токен поиска — это то, что вы отдаёте в браузер, когда каждый пользователь должен видеть только свои данные. Сервер подписывает токен фильтром пользователя и коротким TTL, браузер шлёт его вместо исходного API-ключа, а серверная сторона на каждом запросе принудительно применяет фильтр.

Scoped-токен никогда не даёт больше, чем родительский API-ключ. Он только сужает видимость.

Когда применять

Используйте scoped-токен, если все условия выполнены:

  1. Хотите ходить в поиск прямо из браузера.
  2. Пользователь у вас аутентифицирован, и вы знаете его tenant ID.
  3. Нужно ограничить каждый запрос данными именно этого арендатора — по userId, accountId, companyId и т. п.

Если нужен только публичный поиск по каталогу без ограничения по пользователю — достаточно search-ключа с allow-list по Origin. См. API-ключи и Allow-list по Origin.

Анатомия токена

Scoped-токен выглядит так:

ss_scoped_<base64url payload>.<base64url HMAC-SHA-256 signature>

Декодированный payload — JSON:

{
  "keyId": "ck_…",
  "parentRawKey": "ss_search_…",
  "filterBy": "tenantId:=org_abc",
  "exp": 1730000000
}
  • keyId — строка родительского SearchApiKey, для корреляции в журнале аудита.
  • parentRawKey — API-ключ, который оборачивает токен. Ведёт себя как этот ключ, но…
  • filterBy — …этот фильтр AND-комбинируется с тем, что шлёт приложение.
  • exp — Unix epoch seconds. После истечения верификация падает с invalid_or_expired_scoped_token (HTTP 401).

Подпись — HMAC-SHA-256(BETTER_AUTH_SECRET, payload). Подделка требует либо серверный секрет, либо взлом SHA-256.

В токене зашит исходный родительский API-ключ как base64url-поле. Ключ виден только тем клиентам, которым вы выпустили токен, но и сам токен — секретный материал: не логируйте его и не пишите в query-параметры URL, где он попадёт в access-логи. Передавайте в заголовке Authorization: Bearer.

Сужение фильтра — правило AND

Самое важное правило страницы. filterBy из токена всегда AND-комбинируется с filterBy приложения через серверный хелпер combineFilters():

final_filter = "(token.filterBy) && (application.filterBy)"

Из этого следует:

  • Scoped-токен никогда не расширяет видимость пользователя.
  • Если из браузера прислать filterBy: "tenantId:!=org_abc", токен с tenantId:=org_abc это не «отменит» — AND даст пустой результат, а не расширение.
  • Даже когда родителем выступает admin-ключ, обёрнутый в scoped-токен, на время жизни токена он ведёт себя как суженный.

Это enforced server-side. Флага, чтобы отключить, нет. См. Инвариант 4 в agents.md.

Выпуск токена

Выпускайте scoped-токены на сервере, никогда не в браузере. Для подписи нужен BETTER_AUTH_SECRET, который не должен покидать сервер.

Из oRPC-процедуры

// apps/saas (серверная сторона)
import { issueScopedSearchToken } from "@repo/api/search/scoped-token";

const { token, expiresAt } = issueScopedSearchToken({
	keyId: parentKey.id,
	parentRawKey: parentKey.rawKey,
	filterBy: `tenantId:=${session.user.organizationId}`,
	expiresInSeconds: 60 * 15, // 15 минут
});

Из Hono-роута

// packages/api/.../route.ts
app.post("/api/scoped-token", async (c) => {
	const session = await getSession(c);
	if (!session) return c.json({ error: "unauthorized" }, 401);

	const { token, expiresAt } = issueScopedSearchToken({
		keyId: parentKey.id,
		parentRawKey: parentKey.rawKey,
		filterBy: `userId:=${session.user.id}`,
		expiresInSeconds: 60 * 5,
	});

	return c.json({ token, expiresAt });
});

Использование из браузера

// 1. Получаем свежий токен у своего бэкенда при загрузке страницы или когда до exp осталось < 60 сек.
const { token } = await fetch("/api/scoped-token", { credentials: "include" }).then((r) =>
	r.json(),
);

// 2. Передаём как обычный bearer-токен.
const res = await fetch("https://app.aacsearch.com/api/v1/indexes/products/search", {
	method: "POST",
	headers: {
		Authorization: `Bearer ${token}`,
		"Content-Type": "application/json",
	},
	body: JSON.stringify({ q: "кроссовки", filter_by: "in_stock:=true" }),
});

Либо используйте @repo/search-client, который умеет рефрешить токен сам, если передать tokenProvider.

Какой TTL выбрать

Берите минимальный TTL, какой выдерживает ваш UI. Ориентиры:

ПоверхностьРекомендуемый TTL
Личный кабинет залогиненного клиента5–15 минут
Публичный сайт за CDN1–5 минут
SPA-форма оформления заказа60 секунд

Токены stateless — централизованного revocation-list нет. Способов «отозвать» scoped-токен два: (а) дождаться истечения, (б) отозвать родительский API-ключ — это инвалидирует все выпущенные из него токены. Планируйте соответственно.

Пример: поиск по организации

В каталоге документы вида { id, name, tenantId, price, stock }. Каждый пользователь должен видеть только документы своей организации.

Сервер (Next.js Route Handler):

import { issueScopedSearchToken } from "@repo/api/search/scoped-token";
import { getSession } from "@auth/lib/server";

export async function POST() {
	const session = await getSession();
	if (!session) return Response.json({ error: "unauthorized" }, { status: 401 });

	const parentKey = await getParentApiKeyForOrganization(session.user.organizationId);
	const { token, expiresAt } = issueScopedSearchToken({
		keyId: parentKey.id,
		parentRawKey: parentKey.rawKey,
		filterBy: `tenantId:=${session.user.organizationId}`,
		expiresInSeconds: 60 * 10,
	});

	return Response.json({ token, expiresAt });
}

Клиент:

// Пользователь шлёт `filter_by: "stock:>0"`. С токеном итоговый фильтр:
//   (tenantId:=org_abc) && (stock:>0)
// Доступа к чужим документам не получит ни при каких комбинациях фильтров.

Пример: поиск по пользователю

Та же идея, но более узко:

filterBy: `userId:=${session.user.id}`;

Браузер может строить любые фильтры — результат всегда пересечение с userId:=… из токена.

Частые ошибки

  • Выпуск на клиенте. HMAC-секрет должен оставаться на сервере. Подпись на клиенте = опубликовать секрет.
  • Хранение токена в localStorage. Он короткоживущий, но секретный. Лучше держать в памяти и обновлять через callback; если нужно — sessionStorage.
  • Длинный TTL «чтобы не возиться с рефрешем». Длинный TTL увеличивает blast radius украденного токена. Рефреш дешёвый.
  • Заворачивание admin-ключа. Scoped-токен сужает фильтры, но операции остаются те же. Заворачивайте search-ключ.

См. также

On this page