diff --git a/libs/recnet-api-model/package.json b/libs/recnet-api-model/package.json new file mode 100644 index 00000000..6d598ca6 --- /dev/null +++ b/libs/recnet-api-model/package.json @@ -0,0 +1,4 @@ +{ + "name": "@recnet/recnet-api-model", + "version": "0.0.0" +} diff --git a/libs/recnet-api-model/src/lib/model.ts b/libs/recnet-api-model/src/lib/model.ts index eed2cc9b..ba13d879 100644 --- a/libs/recnet-api-model/src/lib/model.ts +++ b/libs/recnet-api-model/src/lib/model.ts @@ -2,6 +2,9 @@ import { z } from "zod"; export const dateSchema = z.coerce.date(); +export const userRoleSchema = z.enum(["ADMIN", "USER"]); +export type UserRole = z.infer; + export const userPreviewSchema = z.object({ id: z.string(), handle: z.string(), @@ -15,7 +18,7 @@ export type UserPreview = z.infer; export const userSchema = userPreviewSchema.extend({ email: z.string().email(), - role: z.enum(["ADMIN", "USER"]), + role: userRoleSchema, following: z.array(userPreviewSchema), }); export type User = z.infer; diff --git a/libs/recnet-date-fns/package.json b/libs/recnet-date-fns/package.json new file mode 100644 index 00000000..be5067a5 --- /dev/null +++ b/libs/recnet-date-fns/package.json @@ -0,0 +1,8 @@ +{ + "name": "@recnet/recnet-date-fns", + "version": "0.0.0", + "dependencies": { + "firebase": "^10.7.2", + "tslib": "^2.3.0" + } +} diff --git a/libs/recnet-jwt/README.md b/libs/recnet-jwt/README.md index f59746f5..c368d60e 100644 --- a/libs/recnet-jwt/README.md +++ b/libs/recnet-jwt/README.md @@ -8,4 +8,13 @@ Run `nx build recnet-jwt` to build the library. ## Running unit tests +Before running the tests, make sure to set the environment variable. + +Run shell script `./scripts/genRS256KeyPair.sh` to generate a new RSA key pair. + Run `nx test recnet-jwt` to execute the unit tests via [Vitest](https://vitest.dev/). + +## Useful links for debugging + +- [JWT.io](https://jwt.io/#debugger) +- [Epoch Converter for timestamp](https://www.epochconverter.com/) diff --git a/libs/recnet-jwt/package.json b/libs/recnet-jwt/package.json new file mode 100644 index 00000000..9974bb7c --- /dev/null +++ b/libs/recnet-jwt/package.json @@ -0,0 +1,10 @@ +{ + "name": "@recnet/recnet-jwt", + "version": "0.0.0", + "dependencies": { + "@recnet/recnet-api-model": "0.0.0", + "jsonwebtoken": "^9.0.2", + "tslib": "^2.3.0", + "zod": "^3.22.4" + } +} diff --git a/libs/recnet-jwt/scripts/genRS256KeyPair.sh b/libs/recnet-jwt/scripts/genRS256KeyPair.sh new file mode 100755 index 00000000..bf9c8c7f --- /dev/null +++ b/libs/recnet-jwt/scripts/genRS256KeyPair.sh @@ -0,0 +1,14 @@ +ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key +# Don't add passphrase +openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub +# cat jwtRS256.key +# cat jwtRS256.key.pub + +# put them in .env file +echo "PRIVATE_KEY='$(cat jwtRS256.key)'" > ../.env.local +echo "PUBLIC_KEY='$(cat jwtRS256.key.pub)'" >> ../.env.local + +echo "Generated jwtRS256.key and jwtRS256.key.pub and put them in .env.local file." +# clean up +rm jwtRS256.key +rm jwtRS256.key.pub diff --git a/libs/recnet-jwt/src/lib/recnet-jwt.spec.ts b/libs/recnet-jwt/src/lib/recnet-jwt.spec.ts index 756625ab..7c52bc6f 100644 --- a/libs/recnet-jwt/src/lib/recnet-jwt.spec.ts +++ b/libs/recnet-jwt/src/lib/recnet-jwt.spec.ts @@ -1,7 +1,86 @@ -import { recnetJwt } from "./recnet-jwt"; +import { generateJwt, verifyJwt } from "./recnet-jwt"; +import { expect, test, describe } from "vitest"; +import { z } from "zod"; -describe("recnetJwt", () => { - it("should work", () => { - expect(recnetJwt()).toEqual("recnet-jwt"); +const envSchema = z.object({ + PRIVATE_KEY: z.string().transform((s) => s.replace(/\\n/gm, "\n")), + PUBLIC_KEY: z.string().transform((s) => s.replace(/\\n/gm, "\n")), +}); + +test("Keys pair is provided", () => { + const envRes = envSchema.safeParse(process.env); + expect(envRes.success).toBe(true); + if (envRes.success) { + expect(envRes.data).toBeDefined(); + expectTypeOf(envRes.data.PRIVATE_KEY).toBeString(); + expectTypeOf(envRes.data.PUBLIC_KEY).toBeString(); + } +}); + +describe("Sign", () => { + const env = envSchema.parse(process.env); + + test("should throw error if payload has wrong type", () => { + const sub = "randomUserId"; + const role = "INVALID_ROLE"; + // @ts-expect-error: role is invalid here + expect(() => generateJwt({ sub, role }, env.PRIVATE_KEY)).toThrowError( + "Invalid payload" + ); + }); + + test("should return jwt token", () => { + const sub = "randomUserId"; + const adminToken = generateJwt({ sub, role: "ADMIN" }, env.PRIVATE_KEY); + const userToken = generateJwt({ sub, role: "USER" }, env.PRIVATE_KEY); + expectTypeOf(adminToken).toBeString(); + expectTypeOf(userToken).toBeString(); + }); +}); + +describe("Verify", () => { + const env = envSchema.parse(process.env); + + test("Should able to decode if using correct PUBLIC KEY", () => { + const token = generateJwt({ sub: "123", role: "ADMIN" }, env.PRIVATE_KEY); + const payload = verifyJwt(token, env.PUBLIC_KEY); + expect(payload).toBeDefined(); + expect(payload.sub).toBe("123"); + expect(payload.role).toBe("ADMIN"); + }); + + test("Should throw error if using incorrect PUBLIC KEY", () => { + const token = generateJwt({ sub: "123", role: "ADMIN" }, env.PRIVATE_KEY); + expect(() => verifyJwt(token, env.PUBLIC_KEY + "INVALID")).toThrowError(); + }); + + test("Should throw error if token is expired", () => { + const dateNow = Date.now(); + const token = generateJwt( + { + sub: "123", + role: "ADMIN", + iat: Math.floor(dateNow / 1000) - 60 * 60 * 24 * 8, + }, + env.PRIVATE_KEY + ); + expect(() => verifyJwt(token, env.PUBLIC_KEY)).toThrowError("jwt expired"); + }); + + test("Should not throw error if not expired", () => { + const dateNow = Date.now(); + const token = generateJwt( + { + sub: "123", + role: "ADMIN", + iat: Math.floor(dateNow / 1000) - 60 * 60 * 24 * 5, + }, + env.PRIVATE_KEY + ); + console.log(token); + const payload = verifyJwt(token, env.PUBLIC_KEY); + expect(payload).toBeDefined(); + expect(payload.role).toEqual("ADMIN"); + expect(payload.sub).toEqual("123"); }); }); diff --git a/libs/recnet-jwt/src/lib/recnet-jwt.ts b/libs/recnet-jwt/src/lib/recnet-jwt.ts index 24f7c7ec..de3abc6e 100644 --- a/libs/recnet-jwt/src/lib/recnet-jwt.ts +++ b/libs/recnet-jwt/src/lib/recnet-jwt.ts @@ -1,3 +1,68 @@ +import { sign, verify, SignOptions, VerifyOptions } from "jsonwebtoken"; +import { z } from "zod"; +import { userRoleSchema } from "@recnet/recnet-api-model"; + export function recnetJwt(): string { return "recnet-jwt"; } + +export const recnetJwtPayloadSchema = z + .object({ + role: userRoleSchema, + sub: z.string(), + }) + .passthrough(); +export type RecNetJwtPayload = z.infer; + +/** + Generate jwt token and sign it by private key. + Use RS256 algorithm. + Throw error if payload is invalid. + + Note: timestamp's unit is second. +*/ +export function generateJwt( + payload: RecNetJwtPayload, + sk: string, + signOptions?: SignOptions +): string { + const payloadRes = recnetJwtPayloadSchema.safeParse(payload); + if (!payloadRes.success) { + throw new Error("Invalid payload"); + } + const parsedPayload = payloadRes.data; + const options = signOptions || {}; + const token = sign(parsedPayload, sk, { + algorithm: "RS256", + expiresIn: "7 days", + audience: "recnet-api", + issuer: "recnet", + ...options, + }); + return token; +} + +/** + Verify jwt token and return payload if it's valid and not expired. + Throw error if token is invalid. + + Note: timestamp's unit is second. +*/ +export function verifyJwt( + token: string, + pk: string, + verifyOptions?: VerifyOptions +): RecNetJwtPayload { + const options = verifyOptions || {}; + const payload = verify(token, pk, { + algorithms: ["RS256"], + audience: "recnet-api", + issuer: "recnet", + ...options, + }); + const payloadRes = recnetJwtPayloadSchema.safeParse(payload); + if (!payloadRes.success) { + throw new Error("Invalid payload"); + } + return payloadRes.data; +} diff --git a/lint-staged.config.js b/lint-staged.config.js index 9418f61a..c72f9586 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -3,8 +3,12 @@ module.exports = { "{apps,libs,tools}/**/*.{ts,tsx}": (files) => { return `nx affected -t typecheck --files=${files.join(",")}`; }, - "{apps,libs,tools}/**/*.{js,ts,jsx,tsx,json}": [ + "{apps,tools}/**/*.{js,ts,jsx,tsx,json}": [ (files) => `nx affected:lint --files=${files.join(",")}`, (files) => `nx format:write --files=${files.join(",")}`, ], + "{libs,tools}/**/*.{js,ts,jsx,tsx,json}": [ + (files) => `nx affected:lint --fix --files=${files.join(",")}`, + (files) => `nx format:write --files=${files.join(",")}`, + ], }; diff --git a/package.json b/package.json index 5245bc2f..6d16934f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "firebase": "^10.7.2", "firebase-admin": "^12.0.0", "framer-motion": "^11.0.3", + "jsonwebtoken": "^9.0.2", "lodash.groupby": "^4.6.0", "lucide-react": "^0.316.0", "next": "14.1.0", @@ -59,6 +60,7 @@ "@swc/helpers": "~0.5.2", "@types/chance": "^1.1.6", "@types/jest": "^29.4.0", + "@types/jsonwebtoken": "^9.0.6", "@types/lodash.groupby": "^4.6.9", "@types/node": "^20", "@types/react": "^18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72aec635..9ebd1588 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,9 @@ dependencies: framer-motion: specifier: ^11.0.3 version: 11.0.3(react-dom@18.2.0)(react@18.2.0) + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 lodash.groupby: specifier: ^4.6.0 version: 4.6.0 @@ -159,6 +162,9 @@ devDependencies: '@types/jest': specifier: ^29.4.0 version: 29.4.0 + '@types/jsonwebtoken': + specifier: ^9.0.6 + version: 9.0.6 '@types/lodash.groupby': specifier: ^4.6.9 version: 4.6.9 @@ -2720,14 +2726,14 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.0.0 + '@types/node': 20.11.17 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.0.0)(ts-node@10.9.1) + jest-config: 29.7.0(@types/node@20.11.17)(ts-node@10.9.1) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -2755,7 +2761,7 @@ packages: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.0.0 + '@types/node': 20.11.17 jest-mock: 29.7.0 dev: true @@ -2782,7 +2788,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.0.0 + '@types/node': 20.11.17 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -2903,7 +2909,7 @@ packages: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.0.0 + '@types/node': 20.11.17 '@types/yargs': 17.0.32 chalk: 4.1.2 dev: true @@ -6449,7 +6455,7 @@ packages: /@types/jsdom@20.0.1: resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} dependencies: - '@types/node': 20.0.0 + '@types/node': 20.11.17 '@types/tough-cookie': 4.0.5 parse5: 7.1.2 dev: true @@ -6461,11 +6467,10 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true - /@types/jsonwebtoken@9.0.5: - resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} + /@types/jsonwebtoken@9.0.6: + resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} dependencies: '@types/node': 20.11.17 - dev: false /@types/lodash.groupby@4.6.9: resolution: {integrity: sha512-z2xtCX2ko7GrqORnnYea4+ksT7jZNAvaOcLd6mP9M7J09RHvJs06W8BGdQQAX8ARef09VQLdeRilSOcfHlDQJQ==} @@ -11420,6 +11425,47 @@ packages: - supports-color dev: true + /jest-config@29.7.0(@types/node@20.11.17)(ts-node@10.9.1): + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + dependencies: + '@babel/core': 7.23.9 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.11.17 + babel-jest: 29.7.0(@babel/core@7.23.9) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + ts-node: 10.9.1(@swc/core@1.3.85)(@types/node@20.0.0)(typescript@5.3.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + dev: true + /jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -11545,7 +11591,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.0.0 + '@types/node': 20.11.17 jest-util: 29.7.0 dev: true @@ -11963,7 +12009,7 @@ packages: engines: {node: '>=14'} dependencies: '@types/express': 4.17.21 - '@types/jsonwebtoken': 9.0.5 + '@types/jsonwebtoken': 9.0.6 debug: 4.3.4 jose: 4.15.4 limiter: 1.1.5