diff --git a/apps/remote-storage-server/.env.example b/apps/remote-storage-server/.env.example index 6dc7460..d3e4970 100644 --- a/apps/remote-storage-server/.env.example +++ b/apps/remote-storage-server/.env.example @@ -5,3 +5,5 @@ DATA_STORE=sqlite NODE_ENV=development REMOTE_STORAGE_SERVER_PORT=4000 REMOTE_STORAGE_SERVER_USE_HTTPS=false +# Set this value to your JWT secret if you wish to use JWTs for authentication +JWT_SECRET= diff --git a/apps/remote-storage-server/README.md b/apps/remote-storage-server/README.md index 4dff82f..72033a7 100644 --- a/apps/remote-storage-server/README.md +++ b/apps/remote-storage-server/README.md @@ -60,6 +60,26 @@ HTTP/1.1 200 OK {"foo":"bar"} ``` +## Enabling authentication with JSON Web Tokens +We highly recommend using JSON Web Tokens (JWT) to authenticate requests to the server. This can be done by setting the `JWT_SECRET` environment variable in `.env` to your JWT secret. + +To learn more about JWTs and you can generate them in your application, see [this guide](https://jwt.io/introduction/). + +After setting up JWT authentication make sure to pass your JWTs to the remoteStorage client: + +```js +const remoteStorage = new RemoteStorage({ + serverAddress: 'http://localhost:4000', + userId: '123e4567-e89b-12d3-a456-426614174000' +}) + +remoteStorage.getItem('my-item', { + headers: { + Authorization: `Bearer ${jwt}`, + }, +}) +``` + ## Developing the server The server is built using [NestJS](https://nestjs.com/). You can find the documentation for NestJS [here](https://docs.nestjs.com/). diff --git a/apps/remote-storage-server/package.json b/apps/remote-storage-server/package.json index d3c1029..d509424 100644 --- a/apps/remote-storage-server/package.json +++ b/apps/remote-storage-server/package.json @@ -30,6 +30,7 @@ "@nestjs/platform-fastify": "^9.3.8", "@nestjs/schedule": "^3.0.2", "@nestjs/swagger": "^6.1.4", + "jsonwebtoken": "^9.0.2", "redis": "^4.6.12", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", diff --git a/apps/remote-storage-server/src/app.module.ts b/apps/remote-storage-server/src/app.module.ts index 2834368..2b40262 100644 --- a/apps/remote-storage-server/src/app.module.ts +++ b/apps/remote-storage-server/src/app.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common' import { AppController } from './app.controller' import { AppService } from './app.service' import { EntitiesModule } from './entities/entities.module' +import { ConfigModule } from '@nestjs/config' @Module({ - imports: [EntitiesModule], + imports: [ConfigModule.forRoot(), EntitiesModule], controllers: [AppController], providers: [AppService], }) diff --git a/apps/remote-storage-server/src/entities/entities.controller.ts b/apps/remote-storage-server/src/entities/entities.controller.ts index 350897a..9b13f9b 100644 --- a/apps/remote-storage-server/src/entities/entities.controller.ts +++ b/apps/remote-storage-server/src/entities/entities.controller.ts @@ -16,6 +16,8 @@ const MAX_KEY_LENGTH = 255 @ApiTags('entities') @Controller() export class EntitiesController { + private jwt = require('jsonwebtoken') + constructor(private readonly entitiesService: EntitiesService) {} @ApiOperation({ summary: 'Get a entity by key' }) @@ -78,6 +80,28 @@ export class EntitiesController { throw new BadRequestException(`userId cannot be longer than ${MAX_KEY_LENGTH} characters`) } + // Check if JWT is enabled in feature flags. JWT automatically gets turned on when a secret is set. + const jwtSecret = process.env.JWT_SECRET + if (jwtSecret) { + // Check if authorization header is present + if (!headers['authorization']) { + throw new BadRequestException( + 'No authorization header provided. When JWT is enabled, you must provide an authorization header.' + ) + } + const token = headers['authorization'].split(' ')[1] + if (!token) { + throw new BadRequestException( + 'No authorization token provided. When JWT is enabled, you must provide an authorization token.' + ) + } + try { + this.jwt.verify(token, jwtSecret) + } catch (e) { + throw new BadRequestException('Invalid authorization token') + } + } + return { instanceId, userId, diff --git a/packages/js-client/README.md b/packages/js-client/README.md index 8bb0915..cc6a2e0 100644 --- a/packages/js-client/README.md +++ b/packages/js-client/README.md @@ -29,6 +29,7 @@ That's where remoteStorage comes in. Using the same API as localStorage, remoteS ## Features - ✨ Simple API (same as localStorage) +- 🔐 Secure (built-in JWT support) - 👌 Works with all Javascript frameworks - 📦 Lightweight (~1 kB minified) - 🔓 Open source server and client (MIT license) @@ -114,12 +115,10 @@ remoteStorage should only be used for non-sensitive data. We recommend using it localStorage is a browser API that allows you to store data in the browser. The data is stored locally on the user's device and is not shared across devices or browsers. remoteStorage is a library that combines the localStorage API with a remote server to persist data across browsers and devices. -#### Can't anyone just guess a user ID and access someone else's data? - -You can secure your calls to remote-storage by using a secret unique UUID generated with a package such as [uuid](https://www.npmjs.com/package/uuid) as your User ID. It is not recommended to use a sequential numeric ID or a user's email address as this makes it possible to easily guess other user IDs and access their data. - -Alternatively, you can create a simple wrapper/proxy API around remoteStorage that uses your own authentication method to verify the user's identity before allowing them to access the data. Then, you can pick a secure and secret Instance ID that is not publicly available to ensure that only your application can access the data. +#### How do I authenticate requests to remoteStorage? +remoteStorage can be used without any authentication, but we highly recommend using JSON Web Tokens (JWT) to authenticate requests to the server. This can be done by setting the `JWT_SECRET` environment variable in `.env` to your JWT secret for the server. +See the [server documentation](/apps/remote-storage-server/README.md) for more information. ## Contributing diff --git a/packages/js-client/src/core/remote-storage.ts b/packages/js-client/src/core/remote-storage.ts index 4b35da1..e22a02a 100644 --- a/packages/js-client/src/core/remote-storage.ts +++ b/packages/js-client/src/core/remote-storage.ts @@ -32,8 +32,13 @@ export class RemoteStorage { this.userId = userId ?? this.getUserId() } - async getItem(key: string): Promise { - const response = await this.call('GET', `${apiPrefix}${key}`, null) + /** + * Get an item from remote storage + * @param key the key that corresponds to the item to get + * @param fetchOptions optional fetch options to pass to the underlying fetch call. Currently only headers for authorization are supported. + */ + async getItem(key: string, fetchOptions?: any): Promise { + const response = await this.call('GET', `${apiPrefix}${key}`, fetchOptions, null) // Check for 404 and return null if so if (response.status === 404) { return null @@ -56,21 +61,33 @@ export class RemoteStorage { return JSON.parse(data) as T } - async setItem(key: string, value: T): Promise { - await this.call('PUT', `${apiPrefix}${key}`, value) + /** + * Set an item in remote storage + * @param key the key that corresponds to the item to set + * @param value the value to set + * @param fetchOptions optional fetch options to pass to the underlying fetch call. Currently only headers for authorization are supported. + */ + async setItem(key: string, value: T, fetchOptions?: any): Promise { + await this.call('PUT', `${apiPrefix}${key}`, fetchOptions, value) } - async removeItem(key: string): Promise { - await this.call('DELETE', `${apiPrefix}${key}`, null) + /** + * Remove an item from remote storage + * @param key the key that corresponds to the item to remove + * @param fetchOptions optional fetch options to pass to the underlying fetch call. Currently only headers for authorization are supported. + */ + async removeItem(key: string, fetchOptions?: any): Promise { + await this.call('DELETE', `${apiPrefix}${key}`, fetchOptions, null) } - async call(method: string, path: string, data?: any) { + async call(method: string, path: string, options?: any, data?: any) { return fetch(new URL(path, this.serverAddress).toString(), { method: method, headers: { 'Content-Type': 'application/json', [HEADER_REMOTE_STORAGE_INSTANCE_ID]: this.instanceId, [HEADER_REMOTE_STORAGE_USER_ID]: this.userId, + ...options?.headers, }, body: data ? JSON.stringify(data) : undefined, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7eee1ca..d4295e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@nestjs/swagger': specifier: ^6.1.4 version: 6.1.4(@fastify/static@6.6.1)(@nestjs/common@9.2.0)(@nestjs/core@9.2.0)(reflect-metadata@0.1.13) + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 redis: specifier: ^4.6.12 version: 4.6.12 @@ -4771,6 +4774,10 @@ packages: node-int64: 0.4.0 dev: true + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5504,6 +5511,12 @@ packages: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -8178,6 +8191,22 @@ packages: graceful-fs: 4.2.11 dev: true + /jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.5.4 + dev: false + /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -8188,6 +8217,21 @@ packages: object.values: 1.1.7 dev: true + /jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + dev: false + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: @@ -8361,6 +8405,30 @@ packages: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} dev: true + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: false + + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: false + + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: false + + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: false + + /lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + dev: false + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: false + /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -8371,6 +8439,10 @@ packages: resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} dev: false + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: false + /lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} dev: true