-
Notifications
You must be signed in to change notification settings - Fork 89
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
9 changed files
with
345 additions
and
162 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.