|
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"; |
4 | 3 |
|
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 }; |
7 | 12 | } |
8 | 13 |
|
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. */ |
17 | 45 | async function sign( |
18 | | - apiKey: string, |
19 | | - encodedHeader: string, |
| 46 | + { apiKey, algorithm }: TenantTokenGeneratorOptionsWithDefaults, |
20 | 47 | 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 | + ); |
23 | 69 |
|
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))) |
27 | 72 | .replace(/\+/g, "-") |
28 | 73 | .replace(/\//g, "_") |
29 | 74 | .replace(/=/g, ""); |
30 | | -} |
31 | 75 |
|
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 | +} |
42 | 78 |
|
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, ""); |
44 | 85 | } |
45 | 86 |
|
46 | 87 | /** |
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} |
53 | 90 | */ |
54 | | -function validateTokenParameters({ |
55 | | - searchRules, |
56 | | - apiKeyUid, |
57 | | - expiresAt, |
58 | | -}: { |
| 91 | +type TokenClaims = { |
59 | 92 | searchRules: TokenSearchRules; |
| 93 | + exp?: number; |
60 | 94 | 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 | +}; |
74 | 96 |
|
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"); |
81 | 105 | } |
82 | 106 |
|
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); |
87 | 114 | } |
88 | 115 |
|
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, ""); |
94 | 117 | } |
95 | 118 |
|
96 | 119 | /** |
97 | | - * Create the payload of the token. |
| 120 | + * Try to detect if the script is running in a server-side runtime. |
98 | 121 | * |
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. |
103 | 129 | */ |
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 | + ); |
120 | 155 | } |
121 | 156 |
|
122 | 157 | /** |
123 | | - * Generate a tenant token |
| 158 | + * Generate a tenant token. |
124 | 159 | * |
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} |
129 | 168 | */ |
130 | 169 | export async function generateTenantToken( |
131 | | - apiKeyUid: string, |
132 | | - searchRules: TokenSearchRules, |
133 | | - { apiKey, expiresAt }: TokenOptions, |
| 170 | + options: TenantTokenGeneratorOptions, |
134 | 171 | ): 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 | + ); |
144 | 185 |
|
145 | 186 | return `${encodedHeader}.${encodedPayload}.${signature}`; |
146 | 187 | } |
0 commit comments