From 00a37cede13b474719e9fb72623d62b164cc2fcc Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Fri, 17 May 2024 14:37:38 +0200 Subject: [PATCH] feat: Add support for authentication to the sdk, and plug it in the admin (#7349) * feat: Add support for authentication to the sdk, and plug it in the admin * fix: await fetch before returning in sdk --- .changeset/little-books-reply.md | 1 - packages/admin-next/dashboard/package.json | 1 + .../dashboard/src/hooks/api/auth.tsx | 14 +- .../dashboard/src/lib/api-v2/auth.ts | 41 ----- .../dashboard/src/lib/client/auth.ts | 22 --- .../dashboard/src/lib/client/client.ts | 10 +- .../core/js-sdk/src/__tests__/client.spec.ts | 37 ++++ packages/core/js-sdk/src/auth/index.ts | 33 ++++ packages/core/js-sdk/src/client.ts | 158 ++++++++++++------ packages/core/js-sdk/src/index.ts | 5 + packages/core/js-sdk/src/types.ts | 8 +- packages/core/js-sdk/tsconfig.json | 7 +- yarn.lock | 3 +- 13 files changed, 207 insertions(+), 133 deletions(-) delete mode 100644 packages/admin-next/dashboard/src/lib/client/auth.ts create mode 100644 packages/core/js-sdk/src/auth/index.ts diff --git a/.changeset/little-books-reply.md b/.changeset/little-books-reply.md index 9a63169911..79b4066f9f 100644 --- a/.changeset/little-books-reply.md +++ b/.changeset/little-books-reply.md @@ -19,7 +19,6 @@ "@medusajs/ui-preset": patch "@medusajs/medusa": patch "medusa-core-utils": patch -"medusa-interfaces": patch "medusa-telemetry": patch "@medusajs/api-key": patch "@medusajs/auth": patch diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index bac0cb4f24..53b8cfa274 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -33,6 +33,7 @@ "@dnd-kit/sortable": "^8.0.0", "@hookform/resolvers": "3.3.2", "@medusajs/icons": "1.2.1", + "@medusajs/js-sdk": "0.0.1", "@medusajs/ui": "3.0.0", "@radix-ui/react-collapsible": "1.0.3", "@radix-ui/react-hover-card": "^1.0.7", diff --git a/packages/admin-next/dashboard/src/hooks/api/auth.tsx b/packages/admin-next/dashboard/src/hooks/api/auth.tsx index 5c838f3425..6be8ca81fb 100644 --- a/packages/admin-next/dashboard/src/hooks/api/auth.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/auth.tsx @@ -1,20 +1,14 @@ import { UseMutationOptions, useMutation } from "@tanstack/react-query" -import { client } from "../../lib/client" +import { sdk } from "../../lib/client" import { EmailPassReq } from "../../types/api-payloads" -import { EmailPassRes } from "../../types/api-responses" export const useEmailPassLogin = ( - options?: UseMutationOptions + options?: UseMutationOptions ) => { return useMutation({ - mutationFn: (payload) => client.auth.authenticate.emailPass(payload), - onSuccess: async (data: { token: string }, variables, context) => { - const { token } = data - - // Create a new session with the token - await client.auth.login(token) - + mutationFn: (payload) => sdk.auth.login(payload), + onSuccess: async (data, variables, context) => { options?.onSuccess?.(data, variables, context) }, ...options, diff --git a/packages/admin-next/dashboard/src/lib/api-v2/auth.ts b/packages/admin-next/dashboard/src/lib/api-v2/auth.ts index 172bafabef..99de422251 100644 --- a/packages/admin-next/dashboard/src/lib/api-v2/auth.ts +++ b/packages/admin-next/dashboard/src/lib/api-v2/auth.ts @@ -1,48 +1,7 @@ import { useMutation } from "@tanstack/react-query" -import { adminAuthKeys, useAdminCustomQuery } from "medusa-react" import { medusa } from "../medusa" import { AcceptInviteInput, CreateAuthUserInput } from "./types/auth" -export const useV2Session = (options: any = {}) => { - const { data, isLoading, isError, error } = useAdminCustomQuery( - "/admin/users/me", - adminAuthKeys.details(), - {}, - options - ) - - const user = data?.user - - return { user, isLoading, isError, error } -} - -export const useV2LoginAndSetSession = () => { - return useMutation( - (payload: { email: string; password: string }) => - medusa.client.request("POST", "/auth/admin/emailpass", { - email: payload.email, - password: payload.password, - }), - { - onSuccess: async (args: { token: string }) => { - const { token } = args - - // Convert the JWT to a session cookie - // TODO: Consider if the JWT is a good choice for session token - await medusa.client.request( - "POST", - "/auth/session", - {}, - {}, - { - Authorization: `Bearer ${token}`, - } - ) - }, - } - ) -} - export const useV2CreateAuthUser = (provider = "emailpass") => { // TODO: Migrate type to work for other providers, e.g. Google return useMutation((args: CreateAuthUserInput) => diff --git a/packages/admin-next/dashboard/src/lib/client/auth.ts b/packages/admin-next/dashboard/src/lib/client/auth.ts deleted file mode 100644 index 9387f9672d..0000000000 --- a/packages/admin-next/dashboard/src/lib/client/auth.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { EmailPassReq } from "../../types/api-payloads" -import { EmailPassRes } from "../../types/api-responses" -import { postRequest } from "./common" - -async function emailPass(payload: EmailPassReq) { - return postRequest("/auth/admin/emailpass", payload) -} - -async function login(token: string) { - return postRequest("/auth/session", undefined, { - headers: { - Authorization: `Bearer ${token}`, - }, - }) -} - -export const auth = { - authenticate: { - emailPass, - }, - login, -} diff --git a/packages/admin-next/dashboard/src/lib/client/client.ts b/packages/admin-next/dashboard/src/lib/client/client.ts index 67d0aa2ce8..30d49718f3 100644 --- a/packages/admin-next/dashboard/src/lib/client/client.ts +++ b/packages/admin-next/dashboard/src/lib/client/client.ts @@ -1,5 +1,5 @@ +import Medusa from "@medusajs/js-sdk" import { apiKeys } from "./api-keys" -import { auth } from "./auth" import { campaigns } from "./campaigns" import { categories } from "./categories" import { collections } from "./collections" @@ -28,7 +28,6 @@ import { workflowExecutions } from "./workflow-executions" import { shippingProfiles } from "./shipping-profiles" export const client = { - auth: auth, apiKeys: apiKeys, campaigns: campaigns, categories: categories, @@ -57,3 +56,10 @@ export const client = { stockLocations: stockLocations, workflowExecutions: workflowExecutions, } + +export const sdk = new Medusa({ + baseUrl: __BACKEND_URL__ || "http://localhost:9000", + auth: { + type: "session", + }, +}) diff --git a/packages/core/js-sdk/src/__tests__/client.spec.ts b/packages/core/js-sdk/src/__tests__/client.spec.ts index 6714c548c5..7f5e27588e 100644 --- a/packages/core/js-sdk/src/__tests__/client.spec.ts +++ b/packages/core/js-sdk/src/__tests__/client.spec.ts @@ -49,6 +49,13 @@ const server = setupServer( http.delete(`${baseUrl}/delete/123`, async ({ request, params, cookies }) => { return HttpResponse.json({ test: "test" }) }), + http.get(`${baseUrl}/jwt`, ({ request, params, cookies }) => { + if (request.headers.get("authorization") === "Bearer token-123") { + return HttpResponse.json({ + test: "test", + }) + } + }), http.all("*", ({ request, params, cookies }) => { return new HttpResponse(null, { status: 404, @@ -142,4 +149,34 @@ describe("Client", () => { expect(resp).toEqual({ test: "test" }) }) }) + + describe("Authrized requests", () => { + it("should set the token in memory by default", async () => { + const token = "token-123" // Eg. from a response after a successful authentication + client.setToken(token) + + const resp = await client.fetch("jwt") + expect(resp).toEqual({ test: "test" }) + }) + + it("should set the token in local storage if in browser", async () => { + // We are mimicking a browser environment here + global.window = { + localStorage: { setItem: jest.fn(), getItem: () => token } as any, + } as any + + const token = "token-123" // Eg. from a response after a successful authentication + client.setToken(token) + + const resp = await client.fetch("jwt") + expect(resp).toEqual({ test: "test" }) + expect(global.window.localStorage.setItem).toHaveBeenCalledWith( + "medusa_auth_token", + token + ) + + // Cleaning up after this specific test + global.window = undefined as any + }) + }) }) diff --git a/packages/core/js-sdk/src/auth/index.ts b/packages/core/js-sdk/src/auth/index.ts new file mode 100644 index 0000000000..516f1900ab --- /dev/null +++ b/packages/core/js-sdk/src/auth/index.ts @@ -0,0 +1,33 @@ +import { Client } from "../client" +import { Config } from "../types" + +export class Auth { + private client: Client + private config: Config + + constructor(client: Client, config: Config) { + this.client = client + this.config = config + } + + login = async (payload: { email: string; password: string }) => { + // TODO: It is a bit strange to eg. require to pass `scope` in `login`, it might be better for us to have auth methods in both `admin` and `store` classes instead? + const { token } = await this.client.fetch<{ token: string }>( + "/auth/admin/emailpass", + { + method: "POST", + body: payload, + } + ) + + // By default we just set the token in memory, if configured to use sessions we convert it into session storage instead. + if (this.config?.auth?.type === "session") { + await this.client.fetch("/auth/session", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }) + } else { + this.client.setToken(token) + } + } +} diff --git a/packages/core/js-sdk/src/client.ts b/packages/core/js-sdk/src/client.ts index 51e70290b1..63f0ee4a21 100644 --- a/packages/core/js-sdk/src/client.ts +++ b/packages/core/js-sdk/src/client.ts @@ -1,7 +1,13 @@ import qs from "qs" import { ClientFetch, Config, FetchArgs, FetchInput, Logger } from "./types" -const isBrowser = () => typeof window !== "undefined" +const hasStorage = (storage: "localStorage" | "sessionStorage") => { + if (typeof window !== "undefined") { + return storage in window + } + + return false +} const toBase64 = (str: string) => { if (typeof window !== "undefined") { @@ -20,7 +26,8 @@ const sanitizeHeaders = (headers: Headers) => { const normalizeRequest = ( init: FetchArgs | undefined, - headers: Headers + headers: Headers, + config: Config ): RequestInit | undefined => { let body = init?.body if (body && headers.get("content-type")?.includes("application/json")) { @@ -30,6 +37,8 @@ const normalizeRequest = ( return { ...init, headers, + // TODO: Setting this to "include" poses some security risks, as it will send cookies to any domain. We should consider making this configurable. + credentials: config.auth?.type === "session" ? "include" : "omit", ...(body ? { body: body as RequestInit["body"] } : {}), } as RequestInit } @@ -40,7 +49,7 @@ const normalizeResponse = async (resp: Response, reqHeaders: Headers) => { throw error } - // If we both requested JSON, we try to parse. Otherwise, we return the raw response. + // If we requested JSON, we try to parse the response. Otherwise, we return the raw response. const isJsonRequest = reqHeaders.get("accept")?.includes("application/json") return isJsonRequest ? await resp.json() : resp } @@ -56,12 +65,14 @@ export class FetchError extends Error { export class Client { public fetch_: ClientFetch + private config: Config private logger: Logger private DEFAULT_JWT_STORAGE_KEY = "medusa_auth_token" private token = "" constructor(config: Config) { + this.config = config const logger = config.logger || { error: console.error, warn: console.warn, @@ -74,20 +85,36 @@ export class Client { debug: config.debug ? logger.debug : () => {}, } - this.fetch_ = this.initClient(config) + this.fetch_ = this.initClient() } - // Since the response is dynamically determined, we cannot know if it is JSON or not. Therefore, it is important to pass `Response` as the return type + /** + * `fetch` closely follows (and uses under the hood) the native `fetch` API. There are, however, few key differences: + * - Non 2xx statuses throw a `FetchError` with the status code as the `status` property, rather than resolving the promise + * - You can pass `body` and `query` as objects, and they will be encoded and stringified. + * - The response gets parsed as JSON if the `accept` header is set to `application/json`, otherwise the raw Response object is returned + * + * Since the response is dynamically determined, we cannot know if it is JSON or not. Therefore, it is important to pass `Response` as the return type + * + * @param input: FetchInput + * @param init: FetchArgs + * @returns Promise + */ + fetch(input: FetchInput, init?: FetchArgs): Promise { return this.fetch_(input, init) as unknown as Promise } - protected initClient(config: Config): ClientFetch { + setToken(token: string) { + this.setToken_(token) + } + + protected initClient(): ClientFetch { const defaultHeaders = new Headers({ "content-type": "application/json", accept: "application/json", - ...this.getApiKeyHeader(config), - ...this.getPublishableKeyHeader(config), + ...this.getApiKeyHeader_(), + ...this.getPublishableKeyHeader_(), }) this.logger.debug( @@ -95,12 +122,12 @@ export class Client { `${JSON.stringify(sanitizeHeaders(defaultHeaders), null, 2)}\n` ) - return (input: FetchInput, init?: FetchArgs) => { + return async (input: FetchInput, init?: FetchArgs) => { // We always want to fetch the up-to-date JWT token before firing off a request. const headers = new Headers(defaultHeaders) const customHeaders = { - ...config.globalHeaders, - ...this.getJwtTokenHeader(config), + ...this.config.globalHeaders, + ...this.getJwtHeader_(), ...init?.headers, } // We use `headers.set` in order to ensure headers are overwritten in a case-insensitive manner. @@ -110,7 +137,7 @@ export class Client { let normalizedInput: RequestInfo | URL = input if (input instanceof URL || typeof input === "string") { - normalizedInput = new URL(input, config.baseUrl) + normalizedInput = new URL(input, this.config.baseUrl) if (init?.query) { const existing = qs.parse(normalizedInput.search) const stringifiedQuery = qs.stringify({ existing, ...init.query }) @@ -125,59 +152,94 @@ export class Client { ) // Any non-request errors (eg. invalid JSON in the response) will be thrown as-is. - return fetch(normalizedInput, normalizeRequest(init, headers)).then( - (resp) => { - this.logger.debug(`Received response with status ${resp.status}\n`) - return normalizeResponse(resp, headers) - } - ) + return await fetch( + normalizedInput, + normalizeRequest(init, headers, this.config) + ).then((resp) => { + this.logger.debug(`Received response with status ${resp.status}\n`) + return normalizeResponse(resp, headers) + }) } } - protected getApiKeyHeader = ( - config: Config - ): { Authorization: string } | {} => { - return config.apiKey - ? { Authorization: "Basic " + toBase64(config.apiKey + ":") } + protected getApiKeyHeader_ = (): { Authorization: string } | {} => { + return this.config.apiKey + ? { Authorization: "Basic " + toBase64(this.config.apiKey + ":") } : {} } - protected getPublishableKeyHeader = ( - config: Config - ): { "x-medusa-pub-key": string } | {} => { - return config.publishableKey - ? { "x-medusa-pub-key": config.publishableKey } + protected getPublishableKeyHeader_ = (): + | { "x-medusa-pub-key": string } + | {} => { + return this.config.publishableKey + ? { "x-medusa-pub-key": this.config.publishableKey } : {} } - protected getJwtTokenHeader = ( - config: Config - ): { Authorization: string } | {} => { - const storageMethod = - config.jwtToken?.storageMethod || (isBrowser() ? "local" : "memory") - const storageKey = - config.jwtToken?.storageKey || this.DEFAULT_JWT_STORAGE_KEY + protected getJwtHeader_ = (): { Authorization: string } | {} => { + // If the user has requested for session storage, we don't want to send the JWT token in the header. + if (this.config.auth?.type === "session") { + return {} + } + const token = this.getToken_() + return token ? { Authorization: `Bearer ${token}` } : {} + } + + protected setToken_ = (token: string) => { + const { storageMethod, storageKey } = this.getTokenStorageInfo_() switch (storageMethod) { case "local": { - if (!isBrowser()) { - throw new Error("Local JWT storage is only available in the browser") - } - const token = window.localStorage.getItem(storageKey) - return token ? { Authorization: `Bearer ${token}` } : {} + window.localStorage.setItem(storageKey, token) + break } case "session": { - if (!isBrowser()) { - throw new Error( - "Session JWT storage is only available in the browser" - ) - } - const token = window.sessionStorage.getItem(storageKey) - return token ? { Authorization: `Bearer ${token}` } : {} + window.sessionStorage.setItem(storageKey, token) + break } case "memory": { - return this.token ? { Authorization: `Bearer ${this.token}` } : {} + this.token = token + break } } } + + protected getToken_ = () => { + const { storageMethod, storageKey } = this.getTokenStorageInfo_() + switch (storageMethod) { + case "local": { + return window.localStorage.getItem(storageKey) + } + case "session": { + return window.sessionStorage.getItem(storageKey) + } + case "memory": { + return this.token + } + } + + return + } + + protected getTokenStorageInfo_ = () => { + const hasLocal = hasStorage("localStorage") + const hasSession = hasStorage("sessionStorage") + + const storageMethod = + this.config.auth?.jwtTokenStorageMethod || (hasLocal ? "local" : "memory") + const storageKey = + this.config.auth?.jwtTokenStorageKey || this.DEFAULT_JWT_STORAGE_KEY + + if (!hasLocal && storageMethod === "local") { + throw new Error("Local JWT storage is only available in the browser") + } + if (!hasSession && storageMethod === "session") { + throw new Error("Session JWT storage is only available in the browser") + } + + return { + storageMethod, + storageKey, + } + } } diff --git a/packages/core/js-sdk/src/index.ts b/packages/core/js-sdk/src/index.ts index 6d2fdeb99f..03592cdf6e 100644 --- a/packages/core/js-sdk/src/index.ts +++ b/packages/core/js-sdk/src/index.ts @@ -1,17 +1,22 @@ import { Admin } from "./admin" +import { Auth } from "./auth" import { Client } from "./client" import { Store } from "./store" import { Config } from "./types" class Medusa { public client: Client + public admin: Admin public store: Store + public auth: Auth constructor(config: Config) { this.client = new Client(config) + this.admin = new Admin(this.client) this.store = new Store(this.client) + this.auth = new Auth(this.client, config) } } diff --git a/packages/core/js-sdk/src/types.ts b/packages/core/js-sdk/src/types.ts index c3fa4b1097..6428ed118c 100644 --- a/packages/core/js-sdk/src/types.ts +++ b/packages/core/js-sdk/src/types.ts @@ -10,10 +10,10 @@ export type Config = { globalHeaders?: ClientHeaders publishableKey?: string apiKey?: string - jwtToken?: { - storageKey?: string - // TODO: Add support for cookie storage - storageMethod?: "local" | "session" | "memory" + auth?: { + type?: "jwt" | "session" + jwtTokenStorageKey?: string + jwtTokenStorageMethod?: "local" | "session" | "memory" } logger?: Logger debug?: boolean diff --git a/packages/core/js-sdk/tsconfig.json b/packages/core/js-sdk/tsconfig.json index d41dc00d5c..d6fb0c0b94 100644 --- a/packages/core/js-sdk/tsconfig.json +++ b/packages/core/js-sdk/tsconfig.json @@ -5,8 +5,8 @@ "outDir": "./dist", "esModuleInterop": true, "declaration": true, - "module": "commonjs", - "moduleResolution": "node", + "module": "ES2020", + "moduleResolution": "Node", "emitDecoratorMetadata": true, "experimentalDecorators": true, "sourceMap": true, @@ -15,8 +15,7 @@ "strictFunctionTypes": true, "noImplicitThis": true, "allowJs": true, - "skipLibCheck": true, - "downlevelIteration": true // to use ES5 specific tooling + "skipLibCheck": true }, "include": ["./src/**/*"], "exclude": [ diff --git a/yarn.lock b/yarn.lock index 6fd0dca3d3..ced89d64d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5261,6 +5261,7 @@ __metadata: "@hookform/resolvers": 3.3.2 "@medusajs/admin-vite-plugin": 0.0.1 "@medusajs/icons": 1.2.1 + "@medusajs/js-sdk": 0.0.1 "@medusajs/types": 1.11.16 "@medusajs/ui": 3.0.0 "@medusajs/ui-preset": 1.1.3 @@ -5507,7 +5508,7 @@ __metadata: languageName: node linkType: hard -"@medusajs/js-sdk@workspace:packages/core/js-sdk": +"@medusajs/js-sdk@0.0.1, @medusajs/js-sdk@workspace:packages/core/js-sdk": version: 0.0.0-use.local resolution: "@medusajs/js-sdk@workspace:packages/core/js-sdk" dependencies: