Skip to content

Commit

Permalink
Add generate tenant token feature (#1162)
Browse files Browse the repository at this point in the history
* Add generate tenant token feature

* Change searchClient class to just Client

* Change searchClient class to just Client

* Add generateToken in parent Client as fallback if it does not exists

* Add tests on token generation

* Fix linting and types errors

* Change generate token to sync method instead of async

* Remove unecessary crypto inport in rollup build

* Add crypto dependency in external packages in rollup

* Update esm env with node target

* Update comments

* Update comments

* Remove unecessary comments

* Add token payload validation

* Fix node env test

* Improve variable naming of encoding function
  • Loading branch information
bidoubiwa committed Mar 8, 2022
1 parent fef032a commit 4fdfba2
Show file tree
Hide file tree
Showing 16 changed files with 453 additions and 14 deletions.
6 changes: 6 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-throw-literal': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
// argsIgnorePattern: arguments whose names match a regexp pattern
// varsIgnorePattern: variables whose names match a regexp pattern
{ args: 'all', argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/member-delimiter-style': [
'error',
{
Expand Down
8 changes: 6 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ const config = {
projects: [
{
preset: 'ts-jest',
displayName: 'dom',
displayName: 'browser',
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/tests/**/*.ts?(x)'],
testPathIgnorePatterns: ['meilisearch-test-utils', 'env/'],
testPathIgnorePatterns: [
'meilisearch-test-utils',
'env/',
'token_tests.ts',
],
},
{
preset: 'ts-jest',
Expand Down
2 changes: 1 addition & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ module.exports = [
...PLUGINS,
],
},
// Common JS build.
// Common JS build (Node).
// Compatible only in a nodeJS environment.
{
input: 'src/index.ts',
Expand Down
21 changes: 21 additions & 0 deletions src/lib/clients/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
ErrorStatusCode,
Task,
Result,
TokenSearchRules,
TokenOptions,
} from '../../types'
import { HttpRequests } from '../http-requests'
import { addProtocolIfNotPresent } from '../utils'
Expand Down Expand Up @@ -393,6 +395,25 @@ class Client {
const url = `dumps/${dumpUid}/status`
return await this.httpRequest.get<EnqueuedDump>(url)
}

/**
* Generate a tenant token
*
* @memberof MeiliSearch
* @method generateTenantToken
* @param {SearchRules} searchRules Search rules that are applied to every search.
* @param {TokenOptions} options Token options to customize some aspect of the token.
* @returns {String} The token in JWT format.
*/
generateTenantToken(
_searchRules: TokenSearchRules,
_options?: TokenOptions
): string {
const error = new Error()
throw new Error(
`Meilisearch: failed to generate a tenant token. Generation of a token only works in a node environment \n ${error.stack}.`
)
}
}

export { Client }
25 changes: 24 additions & 1 deletion src/lib/clients/node-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
import { Client } from './client'
import { Config } from '../../types'
import { Config, TokenSearchRules, TokenOptions } from '../../types'
import { Token } from '../token'

class MeiliSearch extends Client {
tokens: Token

constructor(config: Config) {
super(config)
this.tokens = new Token(config)
}

/**
* Generate a tenant token
*
* @memberof MeiliSearch
* @method generateTenantToken
* @param {SearchRules} searchRules Search rules that are applied to every search.
* @param {TokenOptions} options Token options to customize some aspect of the token.
* @returns {String} The token in JWT format.
*/
generateTenantToken(
searchRules: TokenSearchRules,
options?: TokenOptions
): string {
if (typeof window === 'undefined') {
return this.tokens.generateTenantToken(searchRules, options)
}
return super.generateTenantToken(searchRules, options)
}
}
export { MeiliSearch }
132 changes: 132 additions & 0 deletions src/lib/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Config, TokenSearchRules, TokenOptions } from '../types'
import crypto from 'crypto'

function encode64(data: any) {
return Buffer.from(JSON.stringify(data)).toString('base64')
}

/**
* Create the header of the token.
*
* @param {String} apiKey API key used to sign the token.
* @param {String} encodedHeader Header of the token in base64.
* @param {String} encodedPayload Payload of the token in base64.
* @returns {String} The signature of the token in base64.
*/
function sign(apiKey: string, encodedHeader: string, encodedPayload: string) {
return crypto
.createHmac('sha256', apiKey)
.update(`${encodedHeader}.${encodedPayload}`)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}

/**
* Create the header of the token.
*
* @returns {String} The header encoded in base64.
*/
function createHeader() {
const header = {
alg: 'HS256',
typ: 'JWT',
}

return encode64(header).replace(/=/g, '')
}

/**
* Validate the parameter used for the payload of the token.
*
* @param {SearchRules} searchRules Search rules that are applied to every search.
* @param {String} apiKey Api key used as issuer of the token.
* @param {Date | undefined} expiresAt Date at which the token expires.
*/
function validatePayload(payloadParams: {
searchRules: TokenSearchRules
apiKey: string
expiresAt?: Date
}) {
const { searchRules, apiKey, expiresAt } = payloadParams
const error = new Error()

if (expiresAt) {
if (!(expiresAt instanceof Date) || expiresAt.getTime() < Date.now()) {
throw new Error(
`Meilisearch: When the expiresAt field in the token generation has a value, it must be a date set in the future and not in the past. \n ${error.stack}.`
)
}
}

if (searchRules) {
if (!(typeof searchRules === 'object' || Array.isArray(searchRules))) {
throw new Error(
`Meilisearch: The search rules added in the token generation must be of type array or object. \n ${error.stack}.`
)
}
}

if (!apiKey || typeof apiKey !== 'string') {
throw new Error(
`Meilisearch: The API key used for the token generation must exist and be of type string. \n ${error.stack}.`
)
}
}

/**
* Create the payload of the token.
*
* @param {SearchRules} searchRules Search rules that are applied to every search.
* @param {String} apiKey Api key used as issuer of the token.
* @param {Date | undefined} expiresAt Date at which the token expires.
* @returns {String} The payload encoded in base64.
*/
function createPayload(payloadParams: {
searchRules: TokenSearchRules
apiKey: string
expiresAt?: Date
}): string {
const { searchRules, apiKey, expiresAt } = payloadParams
validatePayload(payloadParams)
const payload = {
searchRules,
apiKeyPrefix: apiKey.substring(0, 8),
exp: expiresAt?.getTime(),
}

return encode64(payload).replace(/=/g, '')
}

class Token {
config: Config

constructor(config: Config) {
this.config = config
}

/**
* Generate a tenant token
*
* @memberof MeiliSearch
* @method generateTenantToken
* @param {SearchRules} searchRules Search rules that are applied to every search.
* @param {TokenOptions} options Token options to customize some aspect of the token.
* @returns {String} The token in JWT format.
*/
generateTenantToken(
searchRules: TokenSearchRules,
options?: TokenOptions
): string {
const apiKey = options?.apiKey || this.config.apiKey || ''
const expiresAt = options?.expiresAt

const encodedHeader = createHeader()
const encodedPayload = createPayload({ searchRules, apiKey, expiresAt })
const signature = sign(apiKey, encodedHeader, encodedPayload)

return `${encodedHeader}.${encodedPayload}.${signature}`
}
}
export { Token }
11 changes: 11 additions & 0 deletions src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,14 @@ export const enum ErrorStatusCode {
/** @see https://docs.meilisearch.com/errors/#dump_not_found */
DUMP_NOT_FOUND = 'dump_not_found',
}

export type TokenIndexRules = {
[field: string]: any
filter?: Filter
}
export type TokenSearchRules = Record<string, TokenIndexRules | null> | string[]

export type TokenOptions = {
apiKey?: string
expiresAt?: Date
}
1 change: 0 additions & 1 deletion tests/env/esm/meilisearch.esm.js

This file was deleted.

7 changes: 4 additions & 3 deletions tests/env/esm/src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MeiliSearch } from '../meilisearch.esm'
import * as DefaultMeiliSearch from '../meilisearch.esm'
import { MeiliSearch } from '../../../../'
import * as DefaultMeiliSearch from '../../../../'

const client = new MeiliSearch({ host:'http://localhost:7700', apiKey: 'masterKey'})
const defaultClient = new DefaultMeiliSearch.MeiliSearch({ host:'http://localhost:7700', apiKey: 'masterKey'})
console.log({ client, defaultClient })
const token = client.generateTenantToken([])
console.log({ client, token, defaultClient })
1 change: 1 addition & 0 deletions tests/env/esm/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = {
path: path.resolve(__dirname, 'dist'),
filename: 'esm-meilisearch-js-test.js',
},
target: 'node',
resolve: {
extensions: ['.js'], // resolve all the modules other than index.ts
},
Expand Down
13 changes: 8 additions & 5 deletions tests/env/node/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
const { MeiliSearch } = require('../../../dist/bundles/meilisearch.cjs.js')
const DefaultMeiliSearch = require('../../../dist/bundles/meilisearch.cjs.js')
const { MeiliSearch } = require('../../../')
const DefaultMeiliSearch = require('../../../')

const CJStest = new MeiliSearch({ host:'http://localhost:7700', masterKey: 'masterKey'})
const DefaultCJSTest = new DefaultMeiliSearch.MeiliSearch({ host:'http://localhost:7700', masterKey: 'masterKey'})
console.log({ CJStest, DefaultCJSTest })
const CJStest = new MeiliSearch({ host:'http://localhost:7700', apiKey: 'masterKey'})
const DefaultCJSTest = new DefaultMeiliSearch.MeiliSearch({ host:'http://localhost:7700', apiKey: 'masterKey'})

DefaultCJSTest.generateTenantToken([]) // Resolved using the `main` field
CJStest.generateTenantToken([]) // Resolved using the `main` field

console.log({ CJStest, DefaultCJSTest })
2 changes: 2 additions & 0 deletions tests/env/typescript-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ function greeter(person: string) {
document.body.innerHTML = `${greeter(
user
)} this is the list of all your indexes: \n ${uids.join(', ')}`

console.log(await client.generateTenantToken([])) // Resolved using the `browser` field
})()
2 changes: 1 addition & 1 deletion tests/env/typescript-browser/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ let config = {
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
mainFields: ['module', 'browser'], // the `module` field has priority on the browser field

},
}

Expand Down
2 changes: 2 additions & 0 deletions tests/env/typescript-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,7 @@ const indexUid = "movies"
console.log(hit?._formatted?.title)
})

console.log(await client.generateTenantToken([]))

await index.delete()
})()
21 changes: 21 additions & 0 deletions tests/meilisearch-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,38 @@ async function waitForDumpProcessing(
)
}

function decode64(buff: string) {
return Buffer.from(buff, 'base64').toString()
}

const dataset = [
{ id: 123, title: 'Pride and Prejudice', comment: 'A great book' },
{ id: 456, title: 'Le Petit Prince', comment: 'A french book' },
{ id: 2, title: 'Le Rouge et le Noir', comment: 'Another french book' },
{ id: 1, title: 'Alice In Wonderland', comment: 'A weird book' },
{ id: 1344, title: 'The Hobbit', comment: 'An awesome book' },
{
id: 4,
title: 'Harry Potter and the Half-Blood Prince',
comment: 'The best book',
},
{ id: 42, title: "The Hitchhiker's Guide to the Galaxy" },
]

export {
clearAllIndexes,
config,
masterClient,
badHostClient,
anonymousClient,
BAD_HOST,
HOST,
MASTER_KEY,
MeiliSearch,
Index,
waitForDumpProcessing,
getClient,
getKey,
decode64,
dataset,
}
Loading

0 comments on commit 4fdfba2

Please sign in to comment.