Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/angry-boats-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@saleor/app-sdk": major
---

Changed publically exported paths. New exports will be documented in docs and migration guide
5 changes: 5 additions & 0 deletions .changeset/stale-flowers-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@saleor/app-sdk": major
---

Added new root exports: auth/browser and auth/node for token-related utilities
26 changes: 13 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,6 @@
"import": "./settings-manager/index.mjs",
"require": "./settings-manager/index.js"
},
"./urls": {
"types": "./urls.d.ts",
"import": "./urls.mjs",
"require": "./urls.js"
},
"./app-bridge": {
"types": "./app-bridge/index.d.ts",
"import": "./app-bridge/index.mjs",
Expand Down Expand Up @@ -188,15 +183,15 @@
"import": "./saleor-app.mjs",
"require": "./saleor-app.js"
},
"./verify-jwt": {
"types": "./verify-jwt.d.ts",
"import": "./verify-jwt.mjs",
"require": "./verify-jwt.js"
"./auth/node": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: do we support egde runtime as well or plan to do so?

I'm asking as if we don't plan to do this we can have auth/server instead 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better if we keep node for compatibility (I dont know if it works with edge) and then if we decide to support edge, we can add alias node = edge or node = server

if we do other way around and start with "server" it will require a bc

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update: I dogfood it in Adyen and realized there is no "browser" part. JWT is from browser but it's checked on the server 🤦

So I removed node and browser and left auth

"types": "./auth/node.index.d.ts",
"import": "./auth/node/index.mjs",
"require": "./auth/node/index.js"
},
"./verify-signature": {
"types": "./verify-signature.d.ts",
"import": "./verify-signature.mjs",
"require": "./verify-signature.js"
"./auth/browser": {
"types": "./auth/node.index.d.ts",
"import": "./auth/node/index.mjs",
"require": "./auth/node/index.js"
},
"./headers": {
"types": "./headers.d.ts",
Expand All @@ -208,6 +203,11 @@
"import": "./util/public/index.mjs",
"require": "./util/public/index.js"
},
"./util/browser": {
"types": "./util/public/browser/index.d.ts",
"import": "./util/public/browser/index.mjs",
"require": "./util/public/browser/index.js"
},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chaned exports - marked these for browser and node specific, removed some because they didn't make sense anymore

"./types": {
"types": "./types.d.ts"
}
Expand Down
3 changes: 2 additions & 1 deletion src/app-bridge/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";

import { SALEOR_AUTHORIZATION_BEARER_HEADER } from "../const";
import { SALEOR_AUTHORIZATION_BEARER_HEADER } from "@/headers";

import { AppBridge } from "./app-bridge";
import { AppBridgeState } from "./app-bridge-state";
import { createAuthenticatedFetch } from "./fetch";
Expand Down
3 changes: 2 additions & 1 deletion src/app-bridge/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo } from "react";

import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "../const";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@/headers";

import { AppBridge } from "./app-bridge";
import { useAppBridge } from "./app-bridge-provider";

Expand Down
6 changes: 4 additions & 2 deletions src/app-bridge/with-authorization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import * as React from "react";
import { PropsWithChildren, ReactNode } from "react";

import { isInIframe, useIsMounted } from "../util";
import { isInIframe } from "@/util/is-in-iframe";
import { useIsMounted } from "@/util/use-is-mounted";

import { useDashboardToken } from "./use-dashboard-token";

function SimpleError({ children }: PropsWithChildren<{}>) {
Expand Down Expand Up @@ -41,7 +43,7 @@
export const withAuthorization =
(props: Props = defaultProps) =>
<BaseProps extends React.ComponentProps<NextPage>>(
BaseComponent: React.FunctionComponent<BaseProps>
BaseComponent: React.FunctionComponent<BaseProps>,

Check warning on line 46 in src/app-bridge/with-authorization.tsx

View check run for this annotation

Codecov / codecov/patch

src/app-bridge/with-authorization.tsx#L46

Added line #L46 was not covered by tests
): WithAuthorizationHOC<BaseProps> => {
const { dashboardTokenInvalid, noDashboardToken, notIframe, unmounted } = {
...defaultProps,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { createDebug } from "./debug";
import { Permission } from "./types";
import { createDebug } from "../../debug";
import { Permission } from "../../types";
import { DashboardTokenPayload } from "./verify-jwt";

const debug = createDebug("checkJwtPermissions");

/**
* Takes decoded JWT token that Dashboard provides via AppBridge.
* Compare permissions against required in parameter
*/
export const hasPermissionsInJwtToken = (
tokenData?: Pick<DashboardTokenPayload, "user_permissions">,
permissionsToCheckAgainst?: Permission[]
permissionsToCheckAgainst?: Permission[],
) => {
debug(`Permissions required ${permissionsToCheckAgainst}`);

Expand All @@ -23,7 +27,7 @@ export const hasPermissionsInJwtToken = (
}

const arePermissionsSatisfied = permissionsToCheckAgainst.every((permission) =>
userPermissions.includes(permission)
userPermissions.includes(permission),
);

if (!arePermissionsSatisfied) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe("hasPermissionsInJwtToken", () => {
user_permissions: ["MANAGE_ORDERS", "MANAGE_CHECKOUTS", "HANDLE_TAXES"],
};
await expect(
hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"])
hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"]),
).toBeTruthy();
});

Expand All @@ -32,7 +32,7 @@ describe("hasPermissionsInJwtToken", () => {
user_permissions: ["MANAGE_ORDERS", "HANDLE_TAXES"],
};
await expect(
hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"])
hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"]),
).toBeFalsy();
});

Expand All @@ -41,7 +41,7 @@ describe("hasPermissionsInJwtToken", () => {
user_permissions: [],
};
await expect(
hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"])
hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"]),
).toBeFalsy();
});
});
1 change: 1 addition & 0 deletions src/auth/browser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { verifyJWT } from "./verify-jwt";
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ describe("verifyJWT", () => {

it("Throw error on decode issue", async () => {
await expect(
verifyJWT({ appId: validAppId, saleorApiUrl: validApiUrl, token: "wrong_token" })
verifyJWT({ appId: validAppId, saleorApiUrl: validApiUrl, token: "wrong_token" }),
).rejects.toThrow("JWT verification failed: Could not decode authorization token.");
});

it("Throw error on app ID missmatch", async () => {
await expect(
verifyJWT({ appId: "wrong_id", saleorApiUrl: validApiUrl, token: validToken })
verifyJWT({ appId: "wrong_id", saleorApiUrl: validApiUrl, token: validToken }),
).rejects.toThrow("JWT verification failed: Token's app property is different than app ID.");
});

Expand All @@ -59,7 +59,7 @@ describe("verifyJWT", () => {
saleorApiUrl: validApiUrl,
token: validToken,
requiredPermissions: ["HANDLE_TAXES"],
})
}),
).rejects.toThrow("JWT verification failed: Token's permissions are not sufficient.");
});

Expand All @@ -71,7 +71,7 @@ describe("verifyJWT", () => {
appId: validAppId,
saleorApiUrl: validApiUrl,
token: validToken,
})
}),
).rejects.toThrow("JWT verification failed: Token is expired");
});
});
9 changes: 5 additions & 4 deletions src/verify-jwt.ts → src/auth/browser/verify-jwt.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as jose from "jose";

import { createDebug } from "./debug";
import { getJwksUrlFromSaleorApiUrl } from "@/auth/node";

import { createDebug } from "../../debug";
import { Permission } from "../../types";
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");
Expand Down Expand Up @@ -45,7 +46,7 @@ export const verifyJWT = async ({

if (tokenClaims.app !== appId) {
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",
);

throw new Error(`${ERROR_MESSAGE} Token's app property is different than app ID.`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe("verifyTokenExpiration", () => {
* Must be seconds
*/
exp: tokenDate.valueOf() / 1000,
} as DashboardTokenPayload)
} as DashboardTokenPayload),
).not.toThrow();
});

Expand All @@ -44,7 +44,7 @@ describe("verifyTokenExpiration", () => {
* Must be seconds
*/
exp: tokenDate.valueOf() / 1000,
} as DashboardTokenPayload)
} as DashboardTokenPayload),
).toThrow();
});

Expand All @@ -55,7 +55,7 @@ describe("verifyTokenExpiration", () => {
* Must be seconds
*/
exp: mockTodayDate.valueOf() / 1000,
} as DashboardTokenPayload)
} as DashboardTokenPayload),
).toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { createDebug } from "./debug";
import { createDebug } from "../../debug";
import { DashboardTokenPayload } from "./verify-jwt";

const debug = createDebug("verify-token-expiration");

/**
* Takes user token that Dashboard provides via AppBridge (decoded).
* Checks token expiration and throws if expired
*/
export const verifyTokenExpiration = (token: DashboardTokenPayload) => {
const tokenExpiration = token.exp;
const now = new Date();
Expand All @@ -18,9 +22,9 @@ export const verifyTokenExpiration = (token: DashboardTokenPayload) => {
const tokenMsTimestamp = tokenExpiration * 1000;

debug(
"Comparing todays date: %s and token expiration date: %s",
"Comparing to days date: %s and token expiration date: %s",
now.toLocaleString(),
new Date(tokenMsTimestamp).toLocaleString()
new Date(tokenMsTimestamp).toLocaleString(),
);

if (tokenMsTimestamp <= nowTimestamp) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
import { SemanticAttributes } from "@opentelemetry/semantic-conventions";

import { getOtelTracer, OTEL_CORE_SERVICE_NAME } from "./open-telemetry";
import { getJwksUrlFromSaleorApiUrl } from "./urls";
import { getJwksUrlFromSaleorApiUrl } from "@/auth/node/index";

import { getOtelTracer, OTEL_CORE_SERVICE_NAME } from "../../open-telemetry";

export const fetchRemoteJwks = async (saleorApiUrl: string) => {
const tracer = getOtelTracer();
Expand Down Expand Up @@ -31,6 +32,6 @@
} finally {
span.end();
}
}
},

Check warning on line 35 in src/auth/node/fetch-remote-jwks.ts

View check run for this annotation

Codecov / codecov/patch

src/auth/node/fetch-remote-jwks.ts#L35

Added line #L35 was not covered by tests
);
};
1 change: 1 addition & 0 deletions src/auth/node/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { getJwksUrlFromSaleorApiUrl, verifySignatureWithJwks } from "./verify-signature";
41 changes: 4 additions & 37 deletions src/verify-signature.ts → src/auth/node/verify-signature.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,9 @@
import * as jose from "jose";

import { createDebug } from "./debug";
import { getJwksUrlFromSaleorApiUrl } from "./urls";
import { createDebug } from "../../debug";

const debug = createDebug("verify-signature");

/**
* Verify Webhook payload signature with public key of given `domain`
* https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#payload-signature
*
* Use Saleor URL to fetch JWKS
*
* TODO: Add test
*/
export const verifySignatureFromApiUrl = async (
saleorApiUrl: string,
signature: string,
rawBody: string
) => {
const [header, , jwsSignature] = signature.split(".");
const jws: jose.FlattenedJWSInput = {
protected: header,
payload: rawBody,
signature: jwsSignature,
};

const remoteJwks = jose.createRemoteJWKSet(
new URL(getJwksUrlFromSaleorApiUrl(saleorApiUrl))
) as jose.FlattenedVerifyGetKey;

debug("Created remote JWKS");

try {
await jose.flattenedVerify(jws, remoteJwks);
debug("JWKS verified");
} catch {
debug("JWKS verification failed");
throw new Error("JWKS verification failed");
}
};

/**
* Verify the Webhook payload signature from provided JWKS string.
* JWKS can be cached to avoid unnecessary calls.
Expand Down Expand Up @@ -74,3 +38,6 @@ export const verifySignatureWithJwks = async (jwks: string, signature: string, r
throw new Error("JWKS verification failed");
}
};

export const getJwksUrlFromSaleorApiUrl = (saleorApiUrl: string): string =>
`${new URL(saleorApiUrl).origin}/.well-known/jwks.json`;
7 changes: 0 additions & 7 deletions src/const.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/handlers/actions/manifest-action-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { SALEOR_SCHEMA_VERSION } from "@/const";
import { SALEOR_SCHEMA_VERSION } from "@/headers";
import { MockAdapter } from "@/test-utils/mock-adapter";
import { AppManifest } from "@/types";

Expand Down
2 changes: 1 addition & 1 deletion src/handlers/actions/manifest-action-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createDebug } from "@/debug";
import { AppManifest, SaleorSchemaVersion } from "@/types";
import { parseSchemaVersion } from "@/util";
import { parseSchemaVersion } from "@/util/schema-version";

import {
ActionHandlerInterface,
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/actions/register-action-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { SALEOR_API_URL_HEADER } from "@/const";
import * as fetchRemoteJwksModule from "@/fetch-remote-jwks";
import * as fetchRemoteJwksModule from "@/auth/node/fetch-remote-jwks";
import * as getAppIdModule from "@/get-app-id";
import { SALEOR_API_URL_HEADER } from "@/headers";
import { MockAdapter } from "@/test-utils/mock-adapter";
import { MockAPL } from "@/test-utils/mock-apl";

Expand Down
4 changes: 2 additions & 2 deletions src/handlers/actions/register-action-handler.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/* eslint-disable max-classes-per-file */
import { APL, AuthData } from "@/APL";
import { SALEOR_API_URL_HEADER } from "@/const";
import { fetchRemoteJwks } from "@/auth/node/fetch-remote-jwks";
import { createDebug } from "@/debug";
import { fetchRemoteJwks } from "@/fetch-remote-jwks";
import { getAppId } from "@/get-app-id";
import { SALEOR_API_URL_HEADER } from "@/headers";

import { GenericCreateAppRegisterHandlerOptions } from "../shared";
import {
Expand Down
Loading