Skip to content

Commit

Permalink
feat: finish recnet-jwt package
Browse files Browse the repository at this point in the history
  • Loading branch information
swh00tw committed Mar 11, 2024
1 parent d626136 commit 224af67
Show file tree
Hide file tree
Showing 11 changed files with 261 additions and 17 deletions.
4 changes: 4 additions & 0 deletions libs/recnet-api-model/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "@recnet/recnet-api-model",
"version": "0.0.0"
}
5 changes: 4 additions & 1 deletion libs/recnet-api-model/src/lib/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof userRoleSchema>;

export const userPreviewSchema = z.object({
id: z.string(),
handle: z.string(),
Expand All @@ -15,7 +18,7 @@ export type UserPreview = z.infer<typeof userPreviewSchema>;

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<typeof userSchema>;
Expand Down
8 changes: 8 additions & 0 deletions libs/recnet-date-fns/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@recnet/recnet-date-fns",
"version": "0.0.0",
"dependencies": {
"firebase": "^10.7.2",
"tslib": "^2.3.0"
}
}
9 changes: 9 additions & 0 deletions libs/recnet-jwt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
10 changes: 10 additions & 0 deletions libs/recnet-jwt/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
14 changes: 14 additions & 0 deletions libs/recnet-jwt/scripts/genRS256KeyPair.sh
Original file line number Diff line number Diff line change
@@ -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
87 changes: 83 additions & 4 deletions libs/recnet-jwt/src/lib/recnet-jwt.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
65 changes: 65 additions & 0 deletions libs/recnet-jwt/src/lib/recnet-jwt.ts
Original file line number Diff line number Diff line change
@@ -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<typeof recnetJwtPayloadSchema>;

/**
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;
}
6 changes: 5 additions & 1 deletion lint-staged.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(",")}`,
],
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 224af67

Please sign in to comment.