Skip to content

Commit 896b5c0

Browse files
Merge #1661
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>
2 parents 47fcb09 + 4d305ed commit 896b5c0

File tree

9 files changed

+345
-162
lines changed

9 files changed

+345
-162
lines changed

.code-samples.meilisearch.yaml

+1-4
Original file line numberDiff line numberDiff line change
@@ -711,10 +711,7 @@ tenant_token_guide_generate_sdk_1: |-
711711
const apiKeyUid = '85c3c2f9-bdd6-41f1-abd8-11fcf80e0f76'
712712
const expiresAt = new Date('2025-12-20') // optional
713713
714-
const token = await generateTenantToken(apiKeyUid, searchRules, {
715-
apiKey: apiKey,
716-
expiresAt: expiresAt,
717-
})
714+
const token = await generateTenantToken({ apiKey, apiKeyUid, searchRules, expiresAt })
718715
tenant_token_guide_search_sdk_1: |-
719716
const frontEndClient = new MeiliSearch({ host: 'http://localhost:7700', apiKey: token })
720717
frontEndClient.index('patient_medical_records').search('blood test')

src/token.ts

+150-109
Original file line numberDiff line numberDiff line change
@@ -1,146 +1,187 @@
1-
import { TokenSearchRules, TokenOptions } from "./types";
2-
import { MeiliSearchError } from "./errors";
3-
import { validateUuid4 } from "./utils";
1+
import type { webcrypto } from "node:crypto";
2+
import type { TenantTokenGeneratorOptions, TokenSearchRules } from "./types";
43

5-
function encode64(data: any) {
6-
return Buffer.from(JSON.stringify(data)).toString("base64");
4+
function getOptionsWithDefaults(options: TenantTokenGeneratorOptions) {
5+
const {
6+
searchRules = ["*"],
7+
algorithm = "HS256",
8+
force = false,
9+
...restOfOptions
10+
} = options;
11+
return { searchRules, algorithm, force, ...restOfOptions };
712
}
813

9-
/**
10-
* Create the header of the token.
11-
*
12-
* @param apiKey - API key used to sign the token.
13-
* @param encodedHeader - Header of the token in base64.
14-
* @param encodedPayload - Payload of the token in base64.
15-
* @returns The signature of the token in base64.
16-
*/
14+
type TenantTokenGeneratorOptionsWithDefaults = ReturnType<
15+
typeof getOptionsWithDefaults
16+
>;
17+
18+
const UUID_V4_REGEXP = /^[0-9a-f]{8}\b(?:-[0-9a-f]{4}\b){3}-[0-9a-f]{12}$/i;
19+
function isValidUUIDv4(uuid: string): boolean {
20+
return UUID_V4_REGEXP.test(uuid);
21+
}
22+
23+
function encodeToBase64(data: unknown): string {
24+
// TODO: instead of btoa use Uint8Array.prototype.toBase64() when it becomes available in supported runtime versions
25+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64
26+
return btoa(typeof data === "string" ? data : JSON.stringify(data));
27+
}
28+
29+
// missing crypto global for Node.js 18 https://nodejs.org/api/globals.html#crypto_1
30+
let cryptoPonyfill: Promise<Crypto | typeof webcrypto> | undefined;
31+
function getCrypto(): NonNullable<typeof cryptoPonyfill> {
32+
if (cryptoPonyfill === undefined) {
33+
cryptoPonyfill =
34+
typeof crypto === "undefined"
35+
? import("node:crypto").then((v) => v.webcrypto)
36+
: Promise.resolve(crypto);
37+
}
38+
39+
return cryptoPonyfill;
40+
}
41+
42+
const textEncoder = new TextEncoder();
43+
44+
/** Create the signature of the token. */
1745
async function sign(
18-
apiKey: string,
19-
encodedHeader: string,
46+
{ apiKey, algorithm }: TenantTokenGeneratorOptionsWithDefaults,
2047
encodedPayload: string,
21-
) {
22-
const { createHmac } = await import("node:crypto");
48+
encodedHeader: string,
49+
): Promise<string> {
50+
const crypto = await getCrypto();
51+
52+
const cryptoKey = await crypto.subtle.importKey(
53+
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#raw
54+
"raw",
55+
textEncoder.encode(apiKey),
56+
// https://developer.mozilla.org/en-US/docs/Web/API/HmacImportParams#instance_properties
57+
{ name: "HMAC", hash: `SHA-${algorithm.slice(2)}` },
58+
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#extractable
59+
false,
60+
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#keyusages
61+
["sign"],
62+
);
63+
64+
const signature = await crypto.subtle.sign(
65+
"HMAC",
66+
cryptoKey,
67+
textEncoder.encode(`${encodedHeader}.${encodedPayload}`),
68+
);
2369

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

32-
/**
33-
* Create the header of the token.
34-
*
35-
* @returns The header encoded in base64.
36-
*/
37-
function createHeader() {
38-
const header = {
39-
alg: "HS256",
40-
typ: "JWT",
41-
};
76+
return digest;
77+
}
4278

43-
return encode64(header).replace(/=/g, "");
79+
/** Create the header of the token. */
80+
function getHeader({
81+
algorithm: alg,
82+
}: TenantTokenGeneratorOptionsWithDefaults): string {
83+
const header = { alg, typ: "JWT" };
84+
return encodeToBase64(header).replace(/=/g, "");
4485
}
4586

4687
/**
47-
* Validate the parameter used for the payload of the token.
48-
*
49-
* @param searchRules - Search rules that are applied to every search.
50-
* @param apiKey - Api key used as issuer of the token.
51-
* @param uid - The uid of the api key used as issuer of the token.
52-
* @param expiresAt - Date at which the token expires.
88+
* @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference | Tenant token payload reference}
89+
* @see {@link https://github.com/meilisearch/meilisearch/blob/b21d7aedf9096539041362d438e973a18170f3fc/crates/meilisearch/src/extractors/authentication/mod.rs#L334-L340 | GitHub source code}
5390
*/
54-
function validateTokenParameters({
55-
searchRules,
56-
apiKeyUid,
57-
expiresAt,
58-
}: {
91+
type TokenClaims = {
5992
searchRules: TokenSearchRules;
93+
exp?: number;
6094
apiKeyUid: string;
61-
expiresAt?: Date;
62-
}) {
63-
if (expiresAt) {
64-
if (!(expiresAt instanceof Date)) {
65-
throw new MeiliSearchError(
66-
`Meilisearch: The expiredAt field must be an instance of Date.`,
67-
);
68-
} else if (expiresAt.getTime() < Date.now()) {
69-
throw new MeiliSearchError(
70-
`Meilisearch: The expiresAt field must be a date in the future.`,
71-
);
72-
}
73-
}
95+
};
7496

75-
if (searchRules) {
76-
if (!(typeof searchRules === "object" || Array.isArray(searchRules))) {
77-
throw new MeiliSearchError(
78-
`Meilisearch: The search rules added in the token generation must be of type array or object.`,
79-
);
80-
}
97+
/** Create the payload of the token. */
98+
function getPayload({
99+
searchRules,
100+
apiKeyUid,
101+
expiresAt,
102+
}: TenantTokenGeneratorOptionsWithDefaults): string {
103+
if (!isValidUUIDv4(apiKeyUid)) {
104+
throw new Error("the uid of your key is not a valid UUIDv4");
81105
}
82106

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

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

96119
/**
97-
* Create the payload of the token.
120+
* Try to detect if the script is running in a server-side runtime.
98121
*
99-
* @param searchRules - Search rules that are applied to every search.
100-
* @param uid - The uid of the api key used as issuer of the token.
101-
* @param expiresAt - Date at which the token expires.
102-
* @returns The payload encoded in base64.
122+
* @remarks
123+
* There is no silver bullet way for determining the environment. Even so, this
124+
* is the recommended way according to
125+
* {@link https://min-common-api.proposal.wintercg.org/#navigator-useragent-requirements | WinterCG specs}.
126+
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgent | User agent }
127+
* can be spoofed, `process` can be patched. It should prevent misuse for the
128+
* overwhelming majority of cases.
103129
*/
104-
function createPayload({
105-
searchRules,
106-
apiKeyUid,
107-
expiresAt,
108-
}: {
109-
searchRules: TokenSearchRules;
110-
apiKeyUid: string;
111-
expiresAt?: Date;
112-
}): string {
113-
const payload = {
114-
searchRules,
115-
apiKeyUid,
116-
exp: expiresAt ? Math.floor(expiresAt.getTime() / 1000) : undefined,
117-
};
118-
119-
return encode64(payload).replace(/=/g, "");
130+
function tryDetectEnvironment(): void {
131+
if (typeof navigator !== "undefined" && "userAgent" in navigator) {
132+
const { userAgent } = navigator;
133+
134+
if (
135+
userAgent.startsWith("Node") ||
136+
userAgent.startsWith("Deno") ||
137+
userAgent.startsWith("Bun") ||
138+
userAgent.startsWith("Cloudflare-Workers")
139+
) {
140+
return;
141+
}
142+
}
143+
144+
// Node.js prior to v21.1.0 doesn't have the above global
145+
// https://nodejs.org/api/globals.html#navigatoruseragent
146+
const versions = globalThis.process?.versions;
147+
if (versions !== undefined && Object.hasOwn(versions, "node")) {
148+
return;
149+
}
150+
151+
throw new Error(
152+
"failed to detect a server-side environment; do not generate tokens on the frontend in production!\n" +
153+
"use the `force` option to disable environment detection, consult the documentation (Use at your own risk!)",
154+
);
120155
}
121156

122157
/**
123-
* Generate a tenant token
158+
* Generate a tenant token.
124159
*
125-
* @param apiKeyUid - The uid of the api key used as issuer of the token.
126-
* @param searchRules - Search rules that are applied to every search.
127-
* @param options - Token options to customize some aspect of the token.
128-
* @returns The token in JWT format.
160+
* @remarks
161+
* Warning: while this can be used in browsers with
162+
* {@link TenantTokenGeneratorOptions.force}, it is only intended for server
163+
* side. Don't use this in production on the frontend, unless you really know
164+
* what you're doing!
165+
* @param options - Options object for tenant token generation
166+
* @returns The token in JWT (JSON Web Token) format
167+
* @see {@link https://www.meilisearch.com/docs/learn/security/basic_security | Securing your project}
129168
*/
130169
export async function generateTenantToken(
131-
apiKeyUid: string,
132-
searchRules: TokenSearchRules,
133-
{ apiKey, expiresAt }: TokenOptions,
170+
options: TenantTokenGeneratorOptions,
134171
): Promise<string> {
135-
validateTokenParameters({ apiKeyUid, expiresAt, searchRules });
136-
137-
const encodedHeader = createHeader();
138-
const encodedPayload = createPayload({
139-
searchRules,
140-
apiKeyUid,
141-
expiresAt,
142-
});
143-
const signature = await sign(apiKey, encodedHeader, encodedPayload);
172+
const optionsWithDefaults = getOptionsWithDefaults(options);
173+
174+
if (!optionsWithDefaults.force) {
175+
tryDetectEnvironment();
176+
}
177+
178+
const encodedPayload = getPayload(optionsWithDefaults);
179+
const encodedHeader = getHeader(optionsWithDefaults);
180+
const signature = await sign(
181+
optionsWithDefaults,
182+
encodedPayload,
183+
encodedHeader,
184+
);
144185

145186
return `${encodedHeader}.${encodedPayload}.${signature}`;
146187
}

src/types/types.ts

+49-6
Original file line numberDiff line numberDiff line change
@@ -1221,15 +1221,58 @@ export const ErrorStatusCode = {
12211221
export type ErrorStatusCode =
12221222
(typeof ErrorStatusCode)[keyof typeof ErrorStatusCode];
12231223

1224-
export type TokenIndexRules = {
1225-
[field: string]: any;
1226-
filter?: Filter;
1227-
};
1224+
/** @see {@link TokenSearchRules} */
1225+
export type TokenIndexRules = { filter?: Filter };
1226+
1227+
/**
1228+
* {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#search-rules}
1229+
*
1230+
* @remarks
1231+
* Not well documented.
1232+
* @see {@link https://github.com/meilisearch/meilisearch/blob/b21d7aedf9096539041362d438e973a18170f3fc/crates/meilisearch-auth/src/lib.rs#L271-L277 | GitHub source code}
1233+
*/
12281234
export type TokenSearchRules =
12291235
| Record<string, TokenIndexRules | null>
12301236
| string[];
12311237

1232-
export type TokenOptions = {
1238+
/** Options object for tenant token generation. */
1239+
export type TenantTokenGeneratorOptions = {
1240+
/** API key used to sign the token. */
12331241
apiKey: string;
1234-
expiresAt?: Date;
1242+
/**
1243+
* The uid of the api key used as issuer of the token.
1244+
*
1245+
* @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#api-key-uid}
1246+
*/
1247+
apiKeyUid: string;
1248+
/**
1249+
* Search rules that are applied to every search.
1250+
*
1251+
* @defaultValue `["*"]`
1252+
*/
1253+
searchRules?: TokenSearchRules;
1254+
/**
1255+
* {@link https://en.wikipedia.org/wiki/Unix_time | UNIX timestamp} or
1256+
* {@link Date} object at which the token expires.
1257+
*
1258+
* @see {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#expiry-date}
1259+
*/
1260+
expiresAt?: number | Date;
1261+
/**
1262+
* Encryption algorithm used to sign the JWT. Supported values by Meilisearch
1263+
* are HS256, HS384, HS512. (HS[number] means HMAC using SHA-[number])
1264+
*
1265+
* @defaultValue `"HS256"`
1266+
* @see {@link https://www.meilisearch.com/docs/learn/security/generate_tenant_token_scratch#prepare-token-header}
1267+
*/
1268+
algorithm?: `HS${256 | 384 | 512}`;
1269+
/**
1270+
* By default if a non-safe environment is detected, an error is thrown.
1271+
* Setting this to `true` skips environment detection. This is intended for
1272+
* server-side environments where detection fails or usage in a browser is
1273+
* intentional (Use at your own risk).
1274+
*
1275+
* @defaultValue `false`
1276+
*/
1277+
force?: boolean;
12351278
};

src/utils.ts

-7
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,9 @@ function addTrailingSlash(url: string): string {
2828
return url;
2929
}
3030

31-
function validateUuid4(uuid: string): boolean {
32-
const regexExp =
33-
/^[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;
34-
return regexExp.test(uuid);
35-
}
36-
3731
export {
3832
sleep,
3933
removeUndefinedFromObject,
4034
addProtocolIfNotPresent,
4135
addTrailingSlash,
42-
validateUuid4,
4336
};

0 commit comments

Comments
 (0)