-
Notifications
You must be signed in to change notification settings - Fork 89
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
flevi29
wants to merge
19
commits into
meilisearch:main
Choose a base branch
from
flevi29:token-web-crypto
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+345
−162
Open
Improve token.ts
#1661
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
0b5bdf3
Switch from Node.js crypto to Web Crypto, remove error on browser ver…
flevi29 e8e535d
Attempt fix for missing global crypto in Node.js 18
flevi29 4c5868d
Attempt fix for fix for missing global crypto in Node.js 18
flevi29 bb27a99
Remove type fix, expect import type error
flevi29 2b95dfb
Revert tsconfig changes
flevi29 4efb670
Improve tokens a slight bit
flevi29 7b8c769
Fix top level await issue
flevi29 cebab9a
Add environment detection, add/improve docs and types, simplify
flevi29 1bb6eb2
Improve, extend, fix types, test and code
flevi29 a800c29
Add explanation for date conversion
flevi29 93cd960
Fix test
flevi29 748c30b
Fix esm test
flevi29 5b087b4
Fix node test
flevi29 2c58550
Fix node-ts test
flevi29 b1e6e0e
Fix last test, code sample
flevi29 303371d
Misc
flevi29 f88a6ed
Fix potential uncaught promise rejection
flevi29 d9ac1f3
Add more tests
flevi29 3511588
Fix tests, misc
flevi29 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.