-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add protected handlers for next (#121)
* Add protected handlers for next * Remove unneeded awaits
- Loading branch information
1 parent
d8b22bf
commit a0a1955
Showing
10 changed files
with
439 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import { AuthData } from "./APL"; | ||
import { createDebug } from "./debug"; | ||
|
||
const debug = createDebug("getAppId"); | ||
|
||
type GetIdResponseType = { | ||
data?: { | ||
app?: { | ||
id: string; | ||
}; | ||
}; | ||
}; | ||
|
||
export const getAppId = async (authData: AuthData): Promise<string | undefined> => { | ||
try { | ||
const response = await fetch(`https://${authData.domain}/graphql/`, { | ||
method: "POST", | ||
headers: { | ||
"Content-Type": "application/json", | ||
Authorization: `Bearer ${authData.token}`, | ||
}, | ||
body: JSON.stringify({ | ||
query: ` | ||
{ | ||
app{ | ||
id | ||
} | ||
} | ||
`, | ||
}), | ||
}); | ||
if (response.status !== 200) { | ||
debug(`Could not get the app ID: Saleor API has response code ${response.status}`); | ||
return undefined; | ||
} | ||
const body = (await response.json()) as GetIdResponseType; | ||
const appId = body.data?.app?.id; | ||
return appId; | ||
} catch (e) { | ||
debug("Could not get the app ID: %O", e); | ||
return undefined; | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; | ||
|
||
import { APL } from "../../APL"; | ||
import { createDebug } from "../../debug"; | ||
import { ProtectedHandlerContext } from "./process-async-saleor-webhook"; | ||
import { | ||
processSaleorProtectedHandler, | ||
ProtectedHandlerError, | ||
SaleorProtectedHandlerError, | ||
} from "./process-protected-handler"; | ||
|
||
const debug = createDebug("ProtectedHandler"); | ||
|
||
export const ProtectedHandlerErrorCodeMap: Record<SaleorProtectedHandlerError, number> = { | ||
OTHER: 500, | ||
MISSING_HOST_HEADER: 400, | ||
MISSING_DOMAIN_HEADER: 400, | ||
NOT_REGISTERED: 401, | ||
JWT_VERIFICATION_FAILED: 401, | ||
NO_APP_ID: 401, | ||
MISSING_AUTHORIZATION_BEARER_HEADER: 400, | ||
}; | ||
|
||
export type NextProtectedApiHandler<TResp = unknown> = ( | ||
req: NextApiRequest, | ||
res: NextApiResponse<TResp>, | ||
ctx: ProtectedHandlerContext | ||
) => unknown | Promise<unknown>; | ||
|
||
/** | ||
* Wraps provided function, to ensure incoming request comes from Saleor Dashboard. | ||
* Also provides additional `context` object containing request properties. | ||
*/ | ||
export const createProtectedHandler = | ||
(handlerFn: NextProtectedApiHandler, apl: APL): NextApiHandler => | ||
(req, res) => { | ||
debug("Protected handler called"); | ||
processSaleorProtectedHandler({ | ||
req, | ||
apl, | ||
}) | ||
.then(async (context) => { | ||
debug("Incoming request validated. Call handlerFn"); | ||
return handlerFn(req, res, context); | ||
}) | ||
.catch((e) => { | ||
debug("Unexpected error during processing the request"); | ||
|
||
if (e instanceof ProtectedHandlerError) { | ||
debug(`Validation error: ${e.message}`); | ||
res.status(ProtectedHandlerErrorCodeMap[e.errorType] || 400).end(); | ||
return; | ||
} | ||
debug("Unexpected error: %O", e); | ||
res.status(500).end(); | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,5 @@ | ||
export * from "./create-app-register-handler"; | ||
export * from "./create-manifest-handler"; | ||
export * from "./create-protected-handler"; | ||
export * from "./process-protected-handler"; | ||
export * from "./saleor-async-webhook"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
import { NextApiRequest } from "next/types"; | ||
import { createMocks } from "node-mocks-http"; | ||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
||
import { APL } from "../../APL"; | ||
import { getAppId } from "../../get-app-id"; | ||
import { verifyJWT } from "../../verify-jwt"; | ||
import { processSaleorProtectedHandler } from "./process-protected-handler"; | ||
|
||
const validToken = | ||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; | ||
|
||
const validAppId = "QXBwOjI3NQ=="; | ||
|
||
vi.mock("./../../get-app-id", () => ({ | ||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
getAppId: vi.fn(), | ||
})); | ||
|
||
vi.mock("./../../verify-jwt", () => ({ | ||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
verifyJWT: vi.fn(), | ||
})); | ||
|
||
describe("processSaleorProtectedHandler", () => { | ||
let mockRequest: NextApiRequest; | ||
|
||
const mockAPL: APL = { | ||
get: async (domain: string) => | ||
domain === "example.com" | ||
? { | ||
domain: "example.com", | ||
token: "mock-token", | ||
} | ||
: undefined, | ||
set: vi.fn(), | ||
delete: vi.fn(), | ||
getAll: vi.fn(), | ||
isReady: vi.fn(), | ||
isConfigured: vi.fn(), | ||
}; | ||
|
||
beforeEach(() => { | ||
// Create request method which passes all the tests | ||
const { req } = createMocks({ | ||
headers: { | ||
host: "some-saleor-host.cloud", | ||
"x-forwarded-proto": "https", | ||
"saleor-domain": "example.com", | ||
"saleor-event": "product_updated", | ||
"saleor-signature": "mocked_signature", | ||
"authorization-bearer": validToken, | ||
}, | ||
method: "POST", | ||
}); | ||
mockRequest = req; | ||
}); | ||
|
||
afterEach(() => { | ||
vi.resetAllMocks(); | ||
}); | ||
|
||
it("Process valid request", async () => { | ||
vi.mocked(getAppId).mockResolvedValue(validAppId); | ||
vi.mocked(verifyJWT).mockResolvedValue(); | ||
|
||
expect(await processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).toStrictEqual({ | ||
authData: { | ||
domain: "example.com", | ||
token: "mock-token", | ||
}, | ||
baseUrl: "https://some-saleor-host.cloud", | ||
}); | ||
}); | ||
|
||
it("Throw error when app ID can't be fetched", async () => { | ||
vi.mocked(getAppId).mockResolvedValue(""); | ||
|
||
await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( | ||
"Could not get the app ID from the domain example.com" | ||
); | ||
}); | ||
|
||
it("Throw error when domain header is missing", async () => { | ||
vi.mocked(getAppId).mockResolvedValue(validAppId); | ||
vi.mocked(verifyJWT).mockResolvedValue(); | ||
|
||
delete mockRequest.headers["saleor-domain"]; | ||
|
||
await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( | ||
"Missing saleor-domain header" | ||
); | ||
}); | ||
|
||
it("Throw error when token header is missing", async () => { | ||
vi.mocked(getAppId).mockResolvedValue(validAppId); | ||
vi.mocked(verifyJWT).mockResolvedValue(); | ||
|
||
delete mockRequest.headers["authorization-bearer"]; | ||
|
||
await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( | ||
"Missing authorization-bearer header" | ||
); | ||
}); | ||
|
||
it("Throw error when APL has no auth data for the given domain", async () => { | ||
vi.mocked(getAppId).mockResolvedValue(validAppId); | ||
vi.mocked(verifyJWT).mockResolvedValue(); | ||
|
||
mockRequest.headers["saleor-domain"] = "wrong.example.com"; | ||
|
||
await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( | ||
"Can't find auth data for domain wrong.example.com. Please register the application" | ||
); | ||
}); | ||
|
||
it("Throw error when token verification fails", async () => { | ||
vi.mocked(getAppId).mockResolvedValue(validAppId); | ||
vi.mocked(verifyJWT).mockRejectedValue("Verification error"); | ||
|
||
await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( | ||
"JWT verification failed: " | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import { NextApiRequest } from "next"; | ||
|
||
import { APL } from "../../APL"; | ||
import { AuthData } from "../../APL/apl"; | ||
import { createDebug } from "../../debug"; | ||
import { getAppId } from "../../get-app-id"; | ||
import { getBaseUrl, getSaleorHeaders } from "../../headers"; | ||
import { verifyJWT } from "../../verify-jwt"; | ||
|
||
const debug = createDebug("processProtectedHandler"); | ||
|
||
export type SaleorProtectedHandlerError = | ||
| "OTHER" | ||
| "MISSING_HOST_HEADER" | ||
| "MISSING_DOMAIN_HEADER" | ||
| "MISSING_AUTHORIZATION_BEARER_HEADER" | ||
| "NOT_REGISTERED" | ||
| "JWT_VERIFICATION_FAILED" | ||
| "NO_APP_ID"; | ||
|
||
export class ProtectedHandlerError extends Error { | ||
errorType: SaleorProtectedHandlerError = "OTHER"; | ||
|
||
constructor(message: string, errorType: SaleorProtectedHandlerError) { | ||
super(message); | ||
if (errorType) { | ||
this.errorType = errorType; | ||
} | ||
Object.setPrototypeOf(this, ProtectedHandlerError.prototype); | ||
} | ||
} | ||
|
||
export type ProtectedHandlerContext = { | ||
baseUrl: string; | ||
authData: AuthData; | ||
}; | ||
|
||
interface ProcessSaleorProtectedHandlerArgs { | ||
req: NextApiRequest; | ||
apl: APL; | ||
} | ||
|
||
type ProcessAsyncSaleorProtectedHandler = ( | ||
props: ProcessSaleorProtectedHandlerArgs | ||
) => Promise<ProtectedHandlerContext>; | ||
|
||
/** | ||
* Perform security checks on given request and return ProtectedHandlerContext object. | ||
* In case of validation issues, instance of the ProtectedHandlerError will be thrown. | ||
*/ | ||
export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler = async ({ | ||
req, | ||
apl, | ||
}: ProcessSaleorProtectedHandlerArgs): Promise<ProtectedHandlerContext> => { | ||
debug("Request processing started"); | ||
const { domain, authorizationBearer: token } = getSaleorHeaders(req.headers); | ||
|
||
const baseUrl = getBaseUrl(req.headers); | ||
if (!baseUrl) { | ||
debug("Missing host header"); | ||
throw new ProtectedHandlerError("Missing host header", "MISSING_HOST_HEADER"); | ||
} | ||
|
||
if (!domain) { | ||
debug("Missing saleor-domain header"); | ||
throw new ProtectedHandlerError("Missing saleor-domain header", "MISSING_DOMAIN_HEADER"); | ||
} | ||
|
||
if (!token) { | ||
debug("Missing authorization-bearer header"); | ||
throw new ProtectedHandlerError( | ||
"Missing authorization-bearer header", | ||
"MISSING_AUTHORIZATION_BEARER_HEADER" | ||
); | ||
} | ||
|
||
// Check if domain has been registered in the APL | ||
const authData = await apl.get(domain); | ||
if (!authData) { | ||
debug("APL didn't found auth data for domain %s", domain); | ||
throw new ProtectedHandlerError( | ||
`Can't find auth data for domain ${domain}. Please register the application`, | ||
"NOT_REGISTERED" | ||
); | ||
} | ||
|
||
const appId = await getAppId(authData); | ||
if (!appId) { | ||
debug("Could not get the app ID."); | ||
throw new ProtectedHandlerError( | ||
`Could not get the app ID from the domain ${domain}`, | ||
"NO_APP_ID" | ||
); | ||
} | ||
|
||
try { | ||
await verifyJWT({ appId, token, domain }); | ||
} catch (e) { | ||
throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED"); | ||
} | ||
|
||
return { | ||
baseUrl, | ||
authData, | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.