Add EXP field validation in JWT (#233)
* Add token exp validaiton * revert lock
This commit is contained in:
parent
463fffb66c
commit
da351faae9
6 changed files with 125 additions and 1 deletions
5
.changeset/cyan-rats-deny.md
Normal file
5
.changeset/cyan-rats-deny.md
Normal file
|
@ -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
|
|
@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { verifyJWT } from "./verify-jwt";
|
import { verifyJWT } from "./verify-jwt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* exp field points to Nov 24, 2022
|
||||||
|
*/
|
||||||
const validToken =
|
const validToken =
|
||||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q";
|
"eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q";
|
||||||
|
|
||||||
|
@ -9,8 +12,14 @@ const validApiUrl = "https://demo.eu.saleor.cloud/graphql/";
|
||||||
|
|
||||||
const validAppId = "QXBwOjI3NQ==";
|
const validAppId = "QXBwOjI3NQ==";
|
||||||
|
|
||||||
|
const mockedTodayDateBeforeTokenExp = new Date(2022, 10, 20);
|
||||||
|
const mockedTodayDateAfterTokenExp = new Date(2022, 10, 26);
|
||||||
|
|
||||||
describe("verifyJWT", () => {
|
describe("verifyJWT", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(mockedTodayDateBeforeTokenExp);
|
||||||
|
|
||||||
vi.mock("jose", async () => {
|
vi.mock("jose", async () => {
|
||||||
const original = await vi.importActual("jose");
|
const original = await vi.importActual("jose");
|
||||||
return {
|
return {
|
||||||
|
@ -24,6 +33,7 @@ describe("verifyJWT", () => {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Process valid request", async () => {
|
it("Process valid request", async () => {
|
||||||
|
@ -52,4 +62,16 @@ describe("verifyJWT", () => {
|
||||||
})
|
})
|
||||||
).rejects.toThrow("JWT verification failed: Token's permissions are not sufficient.");
|
).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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { createDebug } from "./debug";
|
||||||
import { hasPermissionsInJwtToken } from "./has-permissions-in-jwt-token";
|
import { hasPermissionsInJwtToken } from "./has-permissions-in-jwt-token";
|
||||||
import { Permission } from "./types";
|
import { Permission } from "./types";
|
||||||
import { getJwksUrlFromSaleorApiUrl } from "./urls";
|
import { getJwksUrlFromSaleorApiUrl } from "./urls";
|
||||||
|
import { verifyTokenExpiration } from "./verify-token-expiration";
|
||||||
|
|
||||||
const debug = createDebug("verify-jwt");
|
const debug = createDebug("verify-jwt");
|
||||||
|
|
||||||
|
@ -36,6 +37,12 @@ export const verifyJWT = async ({
|
||||||
throw new Error(`${ERROR_MESSAGE} Could not decode authorization token.`);
|
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) {
|
if (tokenClaims.app !== appId) {
|
||||||
debug(
|
debug(
|
||||||
"Resolved App ID value from token to be different than in request, will respond with Bad Request"
|
"Resolved App ID value from token to be different than in request, will respond with Bad Request"
|
||||||
|
|
61
src/verify-token-expiration.test.ts
Normal file
61
src/verify-token-expiration.test.ts
Normal file
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
29
src/verify-token-expiration.ts
Normal file
29
src/verify-token-expiration.ts
Normal file
|
@ -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");
|
||||||
|
}
|
||||||
|
};
|
|
@ -5,8 +5,8 @@ import { defineConfig } from "vitest/config";
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
test: {
|
test: {
|
||||||
|
setupFiles: ["./src/setup-tests.ts"],
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
setupFiles: "./src/setup-tests.ts",
|
|
||||||
css: false,
|
css: false,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "c8",
|
provider: "c8",
|
||||||
|
|
Loading…
Reference in a new issue