Skip to content

Commit

Permalink
Improve tokens a slight bit
Browse files Browse the repository at this point in the history
  • Loading branch information
flevi29 committed Dec 7, 2024
1 parent 2b95dfb commit 4efb670
Show file tree
Hide file tree
Showing 3 changed files with 36 additions and 83 deletions.
104 changes: 34 additions & 70 deletions src/token.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { TokenSearchRules, TokenOptions } from "./types";
import { MeiliSearchError } from "./errors";
import { validateUuid4 } from "./utils";
import type { TokenSearchRules, TokenOptions } from "./types";

function encode64(data: unknown): string {
return btoa(JSON.stringify(data));
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
// TODO: Improve error handling?
const compatCrypto =
typeof crypto === "undefined"
? (await import("node:crypto")).webcrypto
: crypto;

const textEncoder = new TextEncoder();

/**
* Create the header of the token.
*
Expand All @@ -19,29 +33,21 @@ async function sign(
encodedHeader: string,
encodedPayload: string,
): Promise<string> {
// missing crypto global for Node.js 18
const localCrypto =
typeof crypto === "undefined"
? // @ts-expect-error: Need to add @types/node for this and remove dom lib
(await import("node:crypto")).webcrypto
: crypto;

const textEncoder = new TextEncoder();

const cryptoKey = await localCrypto.subtle.importKey(
const cryptoKey = await compatCrypto.subtle.importKey(
"raw",
textEncoder.encode(apiKey),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);

const signature = await localCrypto.subtle.sign(
const signature = await compatCrypto.subtle.sign(
"HMAC",
cryptoKey,
textEncoder.encode(`${encodedHeader}.${encodedPayload}`),
);

// TODO: Same problem as in `encodeToBase64` above
const digest = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
Expand All @@ -55,63 +61,13 @@ async function sign(
*
* @returns The header encoded in base64.
*/
function createHeader() {
function createHeader(): string {
const header = {
alg: "HS256",
typ: "JWT",
};

return encode64(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.
*/
function validateTokenParameters({
searchRules,
apiKeyUid,
expiresAt,
}: {
searchRules: TokenSearchRules;
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.`,
);
}
}

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.`,
);
}

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(header).replace(/=/g, "");
}

/**
Expand All @@ -137,7 +93,7 @@ function createPayload({
exp: expiresAt ? Math.floor(expiresAt.getTime() / 1000) : undefined,
};

return encode64(payload).replace(/=/g, "");
return encodeToBase64(payload).replace(/=/g, "");
}

/**
Expand All @@ -153,7 +109,15 @@ export async function generateTenantToken(
searchRules: TokenSearchRules,
{ apiKey, expiresAt }: TokenOptions,
): Promise<string> {
validateTokenParameters({ apiKeyUid, expiresAt, searchRules });
if (expiresAt !== undefined && expiresAt.getTime() < Date.now()) {
throw new Error("the `expiresAt` field must be a date in the future");
}

if (!isValidUUIDv4(apiKeyUid)) {
throw new Error(
"the uid of your key is not a valid UUIDv4; to find out the uid of your key use `getKey()`",
);
}

const encodedHeader = createHeader();
const encodedPayload = createPayload({
Expand Down
7 changes: 0 additions & 7 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,9 @@ function addTrailingSlash(url: string): string {
return url;
}

function validateUuid4(uuid: string): boolean {
const regexExp =
/^[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;
return regexExp.test(uuid);
}

export {
sleep,
removeUndefinedFromObject,
addProtocolIfNotPresent,
addTrailingSlash,
validateUuid4,
};
8 changes: 2 additions & 6 deletions tests/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,7 @@ describe.each([{ permission: "Admin" }])(

await expect(
generateTenantToken(uid, ["*"], { apiKey, expiresAt: date }),
).rejects.toThrow(
`Meilisearch: The expiresAt field must be a date in the future.`,
);
).rejects.toThrow("the `expiresAt` field must be a date in the future");
});

test(`${permission} key: Search in tenant token with specific index set to null`, async () => {
Expand Down Expand Up @@ -257,9 +255,7 @@ describe.each([{ permission: "Admin" }])(

await expect(
generateTenantToken(uid, {}, { apiKey, expiresAt: date }),
).rejects.toThrow(
`Meilisearch: The expiresAt field must be a date in the future.`,
);
).rejects.toThrow("the `expiresAt` field must be a date in the future");
});
},
);

0 comments on commit 4efb670

Please sign in to comment.