Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve token.ts #1661

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;

Check warning on line 132 in src/token.ts

View check run for this annotation

Codecov / codecov/patch

src/token.ts#L132

Added line #L132 was not covered by tests

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

Check warning on line 142 in src/token.ts

View check run for this annotation

Codecov / codecov/patch

src/token.ts#L134-L142

Added lines #L134 - L142 were not covered by tests

// 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 @@ -1108,15 +1108,58 @@ export const ErrorStatusCode = {
export type ErrorStatusCode =
(typeof ErrorStatusCode)[keyof typeof ErrorStatusCode];

export type TokenIndexRules = {
[field: string]: any;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not valid, looking at the rust source code I couldn't find such a thing.

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
Loading