Skip to content

Commit

Permalink
Merge #1661
Browse files Browse the repository at this point in the history
1661: Improve `token.ts` r=flevi29 a=flevi29

# Pull Request

## What does this PR do?

> [!IMPORTANT]
> Because this code can now be run in a browser as well, an additional, enabled-by-default, environment check is added to assert that the code runs server-side.

- fixes #1746
- switches from [Node.js Crypto](https://nodejs.org/docs/latest-v18.x/api/crypto.html) to [Web Crypto](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API), which makes this packages compatible with [WinterCG](https://wintercg.org/) specs
- removes some unnecessary checks
  - remove parameter type validations that are already validated by TypeScript
  - `expiredAt` is no longer checked for being in the past, because maybe the system time is wrong or cannot be determined accurately locally, let `meilisearch` determine it after using the token
> [!CAUTION]
> BREAKING
> - reworks `generateTenantToken` function, which now only accepts one object as a parameter, `TenantTokenGeneratorOptions`, all other parameters are now properties of this object
> - new parameters/extended functionality
>   - `force` - use to disable safety environment check for when it's necessary to do so
>   - `algorithm` - use to select encryption algorithm
>   - `searchRules` - can now be omitted, default value is `["*"]`
>   - `expiresAt` - can now be a `number`, a UNIX timestamp number
> - removes type `TokenOptions`
- adds detailed documentation for types and functions, especially exported ones

TODO: Add option of MeiliSearch instead of apiKeyUid? Or rather raise issue about it for now.

Co-authored-by: F. Levi <55688616+flevi29@users.noreply.github.com>
  • Loading branch information
meili-bors[bot] and flevi29 authored Dec 27, 2024
2 parents 47fcb09 + 4d305ed commit 896b5c0
Show file tree
Hide file tree
Showing 9 changed files with 345 additions and 162 deletions.
5 changes: 1 addition & 4 deletions .code-samples.meilisearch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -711,10 +711,7 @@ tenant_token_guide_generate_sdk_1: |-
const apiKeyUid = '85c3c2f9-bdd6-41f1-abd8-11fcf80e0f76'
const expiresAt = new Date('2025-12-20') // optional
const token = await generateTenantToken(apiKeyUid, searchRules, {
apiKey: apiKey,
expiresAt: expiresAt,
})
const token = await generateTenantToken({ apiKey, apiKeyUid, searchRules, expiresAt })
tenant_token_guide_search_sdk_1: |-
const frontEndClient = new MeiliSearch({ host: 'http://localhost:7700', apiKey: token })
frontEndClient.index('patient_medical_records').search('blood test')
Expand Down
259 changes: 150 additions & 109 deletions src/token.ts
Original file line number Diff line number Diff line change
@@ -1,146 +1,187 @@
import { TokenSearchRules, TokenOptions } from "./types";
import { MeiliSearchError } from "./errors";
import { validateUuid4 } from "./utils";
import type { webcrypto } from "node:crypto";
import type { TenantTokenGeneratorOptions, TokenSearchRules } from "./types";

function encode64(data: any) {
return Buffer.from(JSON.stringify(data)).toString("base64");
function getOptionsWithDefaults(options: TenantTokenGeneratorOptions) {
const {
searchRules = ["*"],
algorithm = "HS256",
force = false,
...restOfOptions
} = options;
return { searchRules, algorithm, force, ...restOfOptions };
}

/**
* Create the header of the token.
*
* @param apiKey - API key used to sign the token.
* @param encodedHeader - Header of the token in base64.
* @param encodedPayload - Payload of the token in base64.
* @returns The signature of the token in base64.
*/
type TenantTokenGeneratorOptionsWithDefaults = ReturnType<
typeof getOptionsWithDefaults
>;

const UUID_V4_REGEXP = /^[0-9a-f]{8}\b(?:-[0-9a-f]{4}\b){3}-[0-9a-f]{12}$/i;
function isValidUUIDv4(uuid: string): boolean {
return UUID_V4_REGEXP.test(uuid);
}

function encodeToBase64(data: unknown): string {
// TODO: instead of btoa use Uint8Array.prototype.toBase64() when it becomes available in supported runtime versions
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64
return btoa(typeof data === "string" ? data : JSON.stringify(data));
}

// missing crypto global for Node.js 18 https://nodejs.org/api/globals.html#crypto_1
let cryptoPonyfill: Promise<Crypto | typeof webcrypto> | undefined;
function getCrypto(): NonNullable<typeof cryptoPonyfill> {
if (cryptoPonyfill === undefined) {
cryptoPonyfill =
typeof crypto === "undefined"
? import("node:crypto").then((v) => v.webcrypto)
: Promise.resolve(crypto);
}

return cryptoPonyfill;
}

const textEncoder = new TextEncoder();

/** Create the signature of the token. */
async function sign(
apiKey: string,
encodedHeader: string,
{ apiKey, algorithm }: TenantTokenGeneratorOptionsWithDefaults,
encodedPayload: string,
) {
const { createHmac } = await import("node:crypto");
encodedHeader: string,
): Promise<string> {
const crypto = await getCrypto();

const cryptoKey = await crypto.subtle.importKey(
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#raw
"raw",
textEncoder.encode(apiKey),
// https://developer.mozilla.org/en-US/docs/Web/API/HmacImportParams#instance_properties
{ name: "HMAC", hash: `SHA-${algorithm.slice(2)}` },
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#extractable
false,
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#keyusages
["sign"],
);

const signature = await crypto.subtle.sign(
"HMAC",
cryptoKey,
textEncoder.encode(`${encodedHeader}.${encodedPayload}`),
);

return createHmac("sha256", apiKey)
.update(`${encodedHeader}.${encodedPayload}`)
.digest("base64")
// TODO: Same problem as in `encodeToBase64` above
const digest = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}

/**
* Create the header of the token.
*
* @returns The header encoded in base64.
*/
function createHeader() {
const header = {
alg: "HS256",
typ: "JWT",
};
return digest;
}

return encode64(header).replace(/=/g, "");
/** Create the header of the token. */
function getHeader({
algorithm: alg,
}: TenantTokenGeneratorOptionsWithDefaults): string {
const header = { alg, typ: "JWT" };
return encodeToBase64(header).replace(/=/g, "");
}

/**
* Validate the parameter used for the payload of the token.
*
* @param searchRules - Search rules that are applied to every search.
* @param apiKey - Api key used as issuer of the token.
* @param uid - The uid of the api key used as issuer of the token.
* @param expiresAt - Date at which the token expires.
* @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference | Tenant token payload reference}
* @see {@link https://github.com/meilisearch/meilisearch/blob/b21d7aedf9096539041362d438e973a18170f3fc/crates/meilisearch/src/extractors/authentication/mod.rs#L334-L340 | GitHub source code}
*/
function validateTokenParameters({
searchRules,
apiKeyUid,
expiresAt,
}: {
type TokenClaims = {
searchRules: TokenSearchRules;
exp?: number;
apiKeyUid: string;
expiresAt?: Date;
}) {
if (expiresAt) {
if (!(expiresAt instanceof Date)) {
throw new MeiliSearchError(
`Meilisearch: The expiredAt field must be an instance of Date.`,
);
} else if (expiresAt.getTime() < Date.now()) {
throw new MeiliSearchError(
`Meilisearch: The expiresAt field must be a date in the future.`,
);
}
}
};

if (searchRules) {
if (!(typeof searchRules === "object" || Array.isArray(searchRules))) {
throw new MeiliSearchError(
`Meilisearch: The search rules added in the token generation must be of type array or object.`,
);
}
/** Create the payload of the token. */
function getPayload({
searchRules,
apiKeyUid,
expiresAt,
}: TenantTokenGeneratorOptionsWithDefaults): string {
if (!isValidUUIDv4(apiKeyUid)) {
throw new Error("the uid of your key is not a valid UUIDv4");
}

if (!apiKeyUid || typeof apiKeyUid !== "string") {
throw new MeiliSearchError(
`Meilisearch: The uid of the api key used for the token generation must exist, be of type string and comply to the uuid4 format.`,
);
const payload: TokenClaims = { searchRules, apiKeyUid };
if (expiresAt !== undefined) {
payload.exp =
typeof expiresAt === "number"
? expiresAt
: // To get from a Date object the number of seconds since Unix epoch, i.e. Unix timestamp:
Math.floor(expiresAt.getTime() / 1000);
}

if (!validateUuid4(apiKeyUid)) {
throw new MeiliSearchError(
`Meilisearch: The uid of your key is not a valid uuid4. To find out the uid of your key use getKey().`,
);
}
return encodeToBase64(payload).replace(/=/g, "");
}

/**
* Create the payload of the token.
* Try to detect if the script is running in a server-side runtime.
*
* @param searchRules - Search rules that are applied to every search.
* @param uid - The uid of the api key used as issuer of the token.
* @param expiresAt - Date at which the token expires.
* @returns The payload encoded in base64.
* @remarks
* There is no silver bullet way for determining the environment. Even so, this
* is the recommended way according to
* {@link https://min-common-api.proposal.wintercg.org/#navigator-useragent-requirements | WinterCG specs}.
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgent | User agent }
* can be spoofed, `process` can be patched. It should prevent misuse for the
* overwhelming majority of cases.
*/
function createPayload({
searchRules,
apiKeyUid,
expiresAt,
}: {
searchRules: TokenSearchRules;
apiKeyUid: string;
expiresAt?: Date;
}): string {
const payload = {
searchRules,
apiKeyUid,
exp: expiresAt ? Math.floor(expiresAt.getTime() / 1000) : undefined,
};

return encode64(payload).replace(/=/g, "");
function tryDetectEnvironment(): void {
if (typeof navigator !== "undefined" && "userAgent" in navigator) {
const { userAgent } = navigator;

if (
userAgent.startsWith("Node") ||
userAgent.startsWith("Deno") ||
userAgent.startsWith("Bun") ||
userAgent.startsWith("Cloudflare-Workers")
) {
return;
}
}

// Node.js prior to v21.1.0 doesn't have the above global
// https://nodejs.org/api/globals.html#navigatoruseragent
const versions = globalThis.process?.versions;
if (versions !== undefined && Object.hasOwn(versions, "node")) {
return;
}

throw new Error(
"failed to detect a server-side environment; do not generate tokens on the frontend in production!\n" +
"use the `force` option to disable environment detection, consult the documentation (Use at your own risk!)",
);
}

/**
* Generate a tenant token
* Generate a tenant token.
*
* @param apiKeyUid - The uid of the api key used as issuer of the token.
* @param searchRules - Search rules that are applied to every search.
* @param options - Token options to customize some aspect of the token.
* @returns The token in JWT format.
* @remarks
* Warning: while this can be used in browsers with
* {@link TenantTokenGeneratorOptions.force}, it is only intended for server
* side. Don't use this in production on the frontend, unless you really know
* what you're doing!
* @param options - Options object for tenant token generation
* @returns The token in JWT (JSON Web Token) format
* @see {@link https://www.meilisearch.com/docs/learn/security/basic_security | Securing your project}
*/
export async function generateTenantToken(
apiKeyUid: string,
searchRules: TokenSearchRules,
{ apiKey, expiresAt }: TokenOptions,
options: TenantTokenGeneratorOptions,
): Promise<string> {
validateTokenParameters({ apiKeyUid, expiresAt, searchRules });

const encodedHeader = createHeader();
const encodedPayload = createPayload({
searchRules,
apiKeyUid,
expiresAt,
});
const signature = await sign(apiKey, encodedHeader, encodedPayload);
const optionsWithDefaults = getOptionsWithDefaults(options);

if (!optionsWithDefaults.force) {
tryDetectEnvironment();
}

const encodedPayload = getPayload(optionsWithDefaults);
const encodedHeader = getHeader(optionsWithDefaults);
const signature = await sign(
optionsWithDefaults,
encodedPayload,
encodedHeader,
);

return `${encodedHeader}.${encodedPayload}.${signature}`;
}
55 changes: 49 additions & 6 deletions src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1221,15 +1221,58 @@ export const ErrorStatusCode = {
export type ErrorStatusCode =
(typeof ErrorStatusCode)[keyof typeof ErrorStatusCode];

export type TokenIndexRules = {
[field: string]: any;
filter?: Filter;
};
/** @see {@link TokenSearchRules} */
export type TokenIndexRules = { filter?: Filter };

/**
* {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#search-rules}
*
* @remarks
* Not well documented.
* @see {@link https://github.com/meilisearch/meilisearch/blob/b21d7aedf9096539041362d438e973a18170f3fc/crates/meilisearch-auth/src/lib.rs#L271-L277 | GitHub source code}
*/
export type TokenSearchRules =
| Record<string, TokenIndexRules | null>
| string[];

export type TokenOptions = {
/** Options object for tenant token generation. */
export type TenantTokenGeneratorOptions = {
/** API key used to sign the token. */
apiKey: string;
expiresAt?: Date;
/**
* The uid of the api key used as issuer of the token.
*
* @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#api-key-uid}
*/
apiKeyUid: string;
/**
* Search rules that are applied to every search.
*
* @defaultValue `["*"]`
*/
searchRules?: TokenSearchRules;
/**
* {@link https://en.wikipedia.org/wiki/Unix_time | UNIX timestamp} or
* {@link Date} object at which the token expires.
*
* @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#expiry-date}
*/
expiresAt?: number | Date;
/**
* Encryption algorithm used to sign the JWT. Supported values by Meilisearch
* are HS256, HS384, HS512. (HS[number] means HMAC using SHA-[number])
*
* @defaultValue `"HS256"`
* @see {@link https://www.meilisearch.com/docs/learn/security/generate_tenant_token_scratch#prepare-token-header}
*/
algorithm?: `HS${256 | 384 | 512}`;
/**
* By default if a non-safe environment is detected, an error is thrown.
* Setting this to `true` skips environment detection. This is intended for
* server-side environments where detection fails or usage in a browser is
* intentional (Use at your own risk).
*
* @defaultValue `false`
*/
force?: boolean;
};
7 changes: 0 additions & 7 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,9 @@ function addTrailingSlash(url: string): string {
return url;
}

function validateUuid4(uuid: string): boolean {
const regexExp =
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi;
return regexExp.test(uuid);
}

export {
sleep,
removeUndefinedFromObject,
addProtocolIfNotPresent,
addTrailingSlash,
validateUuid4,
};
Loading

0 comments on commit 896b5c0

Please sign in to comment.