diff --git a/.changeset/cyan-rats-deny.md b/.changeset/cyan-rats-deny.md new file mode 100644 index 0000000..a091c90 --- /dev/null +++ b/.changeset/cyan-rats-deny.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": minor +--- + +Added JWT token expire date validation (claims.exp field). If token is expired, error will be thrown and verification will be aborted diff --git a/src/verify-jwt.test.ts b/src/verify-jwt.test.ts index 94ba6b0..aebe94c 100644 --- a/src/verify-jwt.test.ts +++ b/src/verify-jwt.test.ts @@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { verifyJWT } from "./verify-jwt"; +/** + * exp field points to Nov 24, 2022 + */ const validToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; @@ -9,8 +12,14 @@ const validApiUrl = "https://demo.eu.saleor.cloud/graphql/"; const validAppId = "QXBwOjI3NQ=="; +const mockedTodayDateBeforeTokenExp = new Date(2022, 10, 20); +const mockedTodayDateAfterTokenExp = new Date(2022, 10, 26); + describe("verifyJWT", () => { beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(mockedTodayDateBeforeTokenExp); + vi.mock("jose", async () => { const original = await vi.importActual("jose"); return { @@ -24,6 +33,7 @@ describe("verifyJWT", () => { afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); it("Process valid request", async () => { @@ -52,4 +62,16 @@ describe("verifyJWT", () => { }) ).rejects.toThrow("JWT verification failed: Token's permissions are not sufficient."); }); + + it("Throws if today date is newer than token expiration", async () => { + vi.setSystemTime(mockedTodayDateAfterTokenExp); + + await expect( + verifyJWT({ + appId: validAppId, + saleorApiUrl: validApiUrl, + token: validToken, + }) + ).rejects.toThrow("JWT verification failed: Token is expired"); + }); }); diff --git a/src/verify-jwt.ts b/src/verify-jwt.ts index 08bf3a6..e9f5a4d 100644 --- a/src/verify-jwt.ts +++ b/src/verify-jwt.ts @@ -4,6 +4,7 @@ import { createDebug } from "./debug"; import { hasPermissionsInJwtToken } from "./has-permissions-in-jwt-token"; import { Permission } from "./types"; import { getJwksUrlFromSaleorApiUrl } from "./urls"; +import { verifyTokenExpiration } from "./verify-token-expiration"; const debug = createDebug("verify-jwt"); @@ -36,6 +37,12 @@ export const verifyJWT = async ({ throw new Error(`${ERROR_MESSAGE} Could not decode authorization token.`); } + try { + verifyTokenExpiration(tokenClaims); + } catch (e) { + throw new Error(`${ERROR_MESSAGE} ${(e as Error).message}`); + } + if (tokenClaims.app !== appId) { debug( "Resolved App ID value from token to be different than in request, will respond with Bad Request" diff --git a/src/verify-token-expiration.test.ts b/src/verify-token-expiration.test.ts new file mode 100644 index 0000000..207644a --- /dev/null +++ b/src/verify-token-expiration.test.ts @@ -0,0 +1,61 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DashboardTokenPayload } from "./verify-jwt"; +import { verifyTokenExpiration } from "./verify-token-expiration"; + +describe("verifyTokenExpiration", () => { + const mockTodayDate = new Date(2020, 2, 1, 9, 0, 0); + + beforeAll(() => { + vi.useFakeTimers(); + }); + + beforeEach(() => { + vi.setSystemTime(mockTodayDate); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it("Passes if exp field in token is in the future from \"now\"", () => { + const tokenDate = new Date(2020, 2, 1, 12, 0, 0); + + expect(() => + verifyTokenExpiration({ + /** + * Must be seconds + */ + exp: tokenDate.valueOf() / 1000, + } as DashboardTokenPayload) + ).not.toThrow(); + }); + + it("Throws if exp field is missing", () => { + expect(() => verifyTokenExpiration({} as DashboardTokenPayload)).toThrow(); + }); + + it("Throws if exp field is older than today", () => { + const tokenDate = new Date(2020, 2, 1, 4, 0, 0); + + expect(() => + verifyTokenExpiration({ + /** + * Must be seconds + */ + exp: tokenDate.valueOf() / 1000, + } as DashboardTokenPayload) + ).toThrow(); + }); + + it("Throws if exp field is the same as today", () => { + expect(() => + verifyTokenExpiration({ + /** + * Must be seconds + */ + exp: mockTodayDate.valueOf() / 1000, + } as DashboardTokenPayload) + ).toThrow(); + }); +}); diff --git a/src/verify-token-expiration.ts b/src/verify-token-expiration.ts new file mode 100644 index 0000000..7e670d6 --- /dev/null +++ b/src/verify-token-expiration.ts @@ -0,0 +1,29 @@ +import { createDebug } from "./debug"; +import { DashboardTokenPayload } from "./verify-jwt"; + +const debug = createDebug("verify-token-expiration"); + +export const verifyTokenExpiration = (token: DashboardTokenPayload) => { + const tokenExpiration = token.exp; + const now = new Date(); + const nowTimestamp = now.valueOf(); + + if (!tokenExpiration) { + throw new Error("Missing \"exp\" field in token"); + } + + /** + * Timestamp in token are in seconds, but timestamp from Date is in miliseconds + */ + const tokenMsTimestamp = tokenExpiration * 1000; + + debug( + "Comparing todays date: %s and token expiration date: %s", + now.toLocaleString(), + new Date(tokenMsTimestamp).toLocaleString() + ); + + if (tokenMsTimestamp <= nowTimestamp) { + throw new Error("Token is expired"); + } +}; diff --git a/vitest.config.ts b/vitest.config.ts index 014a1cd..6b9f320 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,8 +5,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], test: { + setupFiles: ["./src/setup-tests.ts"], environment: "jsdom", - setupFiles: "./src/setup-tests.ts", css: false, coverage: { provider: "c8",