|
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