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
This commit is contained in:
Stevche Radevski
2024-05-17 14:37:38 +02:00
committed by GitHub
parent ff337498a0
commit 00a37cede1
13 changed files with 207 additions and 133 deletions
-1
View File
@@ -19,7 +19,6 @@
"@medusajs/ui-preset": patch "@medusajs/ui-preset": patch
"@medusajs/medusa": patch "@medusajs/medusa": patch
"medusa-core-utils": patch "medusa-core-utils": patch
"medusa-interfaces": patch
"medusa-telemetry": patch "medusa-telemetry": patch
"@medusajs/api-key": patch "@medusajs/api-key": patch
"@medusajs/auth": patch "@medusajs/auth": patch
@@ -33,6 +33,7 @@
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@hookform/resolvers": "3.3.2", "@hookform/resolvers": "3.3.2",
"@medusajs/icons": "1.2.1", "@medusajs/icons": "1.2.1",
"@medusajs/js-sdk": "0.0.1",
"@medusajs/ui": "3.0.0", "@medusajs/ui": "3.0.0",
"@radix-ui/react-collapsible": "1.0.3", "@radix-ui/react-collapsible": "1.0.3",
"@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-hover-card": "^1.0.7",
@@ -1,20 +1,14 @@
import { UseMutationOptions, useMutation } from "@tanstack/react-query" import { UseMutationOptions, useMutation } from "@tanstack/react-query"
import { client } from "../../lib/client" import { sdk } from "../../lib/client"
import { EmailPassReq } from "../../types/api-payloads" import { EmailPassReq } from "../../types/api-payloads"
import { EmailPassRes } from "../../types/api-responses"
export const useEmailPassLogin = ( export const useEmailPassLogin = (
options?: UseMutationOptions<EmailPassRes, Error, EmailPassReq> options?: UseMutationOptions<void, Error, EmailPassReq>
) => { ) => {
return useMutation({ return useMutation({
mutationFn: (payload) => client.auth.authenticate.emailPass(payload), mutationFn: (payload) => sdk.auth.login(payload),
onSuccess: async (data: { token: string }, variables, context) => { onSuccess: async (data, variables, context) => {
const { token } = data
// Create a new session with the token
await client.auth.login(token)
options?.onSuccess?.(data, variables, context) options?.onSuccess?.(data, variables, context)
}, },
...options, ...options,
@@ -1,48 +1,7 @@
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { adminAuthKeys, useAdminCustomQuery } from "medusa-react"
import { medusa } from "../medusa" import { medusa } from "../medusa"
import { AcceptInviteInput, CreateAuthUserInput } from "./types/auth" 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") => { export const useV2CreateAuthUser = (provider = "emailpass") => {
// TODO: Migrate type to work for other providers, e.g. Google // TODO: Migrate type to work for other providers, e.g. Google
return useMutation((args: CreateAuthUserInput) => return useMutation((args: CreateAuthUserInput) =>
@@ -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<EmailPassRes>("/auth/admin/emailpass", payload)
}
async function login(token: string) {
return postRequest<void>("/auth/session", undefined, {
headers: {
Authorization: `Bearer ${token}`,
},
})
}
export const auth = {
authenticate: {
emailPass,
},
login,
}
@@ -1,5 +1,5 @@
import Medusa from "@medusajs/js-sdk"
import { apiKeys } from "./api-keys" import { apiKeys } from "./api-keys"
import { auth } from "./auth"
import { campaigns } from "./campaigns" import { campaigns } from "./campaigns"
import { categories } from "./categories" import { categories } from "./categories"
import { collections } from "./collections" import { collections } from "./collections"
@@ -28,7 +28,6 @@ import { workflowExecutions } from "./workflow-executions"
import { shippingProfiles } from "./shipping-profiles" import { shippingProfiles } from "./shipping-profiles"
export const client = { export const client = {
auth: auth,
apiKeys: apiKeys, apiKeys: apiKeys,
campaigns: campaigns, campaigns: campaigns,
categories: categories, categories: categories,
@@ -57,3 +56,10 @@ export const client = {
stockLocations: stockLocations, stockLocations: stockLocations,
workflowExecutions: workflowExecutions, workflowExecutions: workflowExecutions,
} }
export const sdk = new Medusa({
baseUrl: __BACKEND_URL__ || "http://localhost:9000",
auth: {
type: "session",
},
})
@@ -49,6 +49,13 @@ const server = setupServer(
http.delete(`${baseUrl}/delete/123`, async ({ request, params, cookies }) => { http.delete(`${baseUrl}/delete/123`, async ({ request, params, cookies }) => {
return HttpResponse.json({ test: "test" }) 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 }) => { http.all("*", ({ request, params, cookies }) => {
return new HttpResponse(null, { return new HttpResponse(null, {
status: 404, status: 404,
@@ -142,4 +149,34 @@ describe("Client", () => {
expect(resp).toEqual({ test: "test" }) 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<any>("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<any>("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
})
})
}) })
+33
View File
@@ -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)
}
}
}
+110 -48
View File
@@ -1,7 +1,13 @@
import qs from "qs" import qs from "qs"
import { ClientFetch, Config, FetchArgs, FetchInput, Logger } from "./types" 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) => { const toBase64 = (str: string) => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -20,7 +26,8 @@ const sanitizeHeaders = (headers: Headers) => {
const normalizeRequest = ( const normalizeRequest = (
init: FetchArgs | undefined, init: FetchArgs | undefined,
headers: Headers headers: Headers,
config: Config
): RequestInit | undefined => { ): RequestInit | undefined => {
let body = init?.body let body = init?.body
if (body && headers.get("content-type")?.includes("application/json")) { if (body && headers.get("content-type")?.includes("application/json")) {
@@ -30,6 +37,8 @@ const normalizeRequest = (
return { return {
...init, ...init,
headers, 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"] } : {}), ...(body ? { body: body as RequestInit["body"] } : {}),
} as RequestInit } as RequestInit
} }
@@ -40,7 +49,7 @@ const normalizeResponse = async (resp: Response, reqHeaders: Headers) => {
throw error 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") const isJsonRequest = reqHeaders.get("accept")?.includes("application/json")
return isJsonRequest ? await resp.json() : resp return isJsonRequest ? await resp.json() : resp
} }
@@ -56,12 +65,14 @@ export class FetchError extends Error {
export class Client { export class Client {
public fetch_: ClientFetch public fetch_: ClientFetch
private config: Config
private logger: Logger private logger: Logger
private DEFAULT_JWT_STORAGE_KEY = "medusa_auth_token" private DEFAULT_JWT_STORAGE_KEY = "medusa_auth_token"
private token = "" private token = ""
constructor(config: Config) { constructor(config: Config) {
this.config = config
const logger = config.logger || { const logger = config.logger || {
error: console.error, error: console.error,
warn: console.warn, warn: console.warn,
@@ -74,20 +85,36 @@ export class Client {
debug: config.debug ? logger.debug : () => {}, 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<T>
*/
fetch<T extends any>(input: FetchInput, init?: FetchArgs): Promise<T> { fetch<T extends any>(input: FetchInput, init?: FetchArgs): Promise<T> {
return this.fetch_(input, init) as unknown as Promise<T> return this.fetch_(input, init) as unknown as Promise<T>
} }
protected initClient(config: Config): ClientFetch { setToken(token: string) {
this.setToken_(token)
}
protected initClient(): ClientFetch {
const defaultHeaders = new Headers({ const defaultHeaders = new Headers({
"content-type": "application/json", "content-type": "application/json",
accept: "application/json", accept: "application/json",
...this.getApiKeyHeader(config), ...this.getApiKeyHeader_(),
...this.getPublishableKeyHeader(config), ...this.getPublishableKeyHeader_(),
}) })
this.logger.debug( this.logger.debug(
@@ -95,12 +122,12 @@ export class Client {
`${JSON.stringify(sanitizeHeaders(defaultHeaders), null, 2)}\n` `${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. // We always want to fetch the up-to-date JWT token before firing off a request.
const headers = new Headers(defaultHeaders) const headers = new Headers(defaultHeaders)
const customHeaders = { const customHeaders = {
...config.globalHeaders, ...this.config.globalHeaders,
...this.getJwtTokenHeader(config), ...this.getJwtHeader_(),
...init?.headers, ...init?.headers,
} }
// We use `headers.set` in order to ensure headers are overwritten in a case-insensitive manner. // 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 let normalizedInput: RequestInfo | URL = input
if (input instanceof URL || typeof input === "string") { if (input instanceof URL || typeof input === "string") {
normalizedInput = new URL(input, config.baseUrl) normalizedInput = new URL(input, this.config.baseUrl)
if (init?.query) { if (init?.query) {
const existing = qs.parse(normalizedInput.search) const existing = qs.parse(normalizedInput.search)
const stringifiedQuery = qs.stringify({ existing, ...init.query }) 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. // Any non-request errors (eg. invalid JSON in the response) will be thrown as-is.
return fetch(normalizedInput, normalizeRequest(init, headers)).then( return await fetch(
(resp) => { normalizedInput,
this.logger.debug(`Received response with status ${resp.status}\n`) normalizeRequest(init, headers, this.config)
return normalizeResponse(resp, headers) ).then((resp) => {
} this.logger.debug(`Received response with status ${resp.status}\n`)
) return normalizeResponse(resp, headers)
})
} }
} }
protected getApiKeyHeader = ( protected getApiKeyHeader_ = (): { Authorization: string } | {} => {
config: Config return this.config.apiKey
): { Authorization: string } | {} => { ? { Authorization: "Basic " + toBase64(this.config.apiKey + ":") }
return config.apiKey
? { Authorization: "Basic " + toBase64(config.apiKey + ":") }
: {} : {}
} }
protected getPublishableKeyHeader = ( protected getPublishableKeyHeader_ = ():
config: Config | { "x-medusa-pub-key": string }
): { "x-medusa-pub-key": string } | {} => { | {} => {
return config.publishableKey return this.config.publishableKey
? { "x-medusa-pub-key": config.publishableKey } ? { "x-medusa-pub-key": this.config.publishableKey }
: {} : {}
} }
protected getJwtTokenHeader = ( protected getJwtHeader_ = (): { Authorization: string } | {} => {
config: Config // If the user has requested for session storage, we don't want to send the JWT token in the header.
): { Authorization: string } | {} => { if (this.config.auth?.type === "session") {
const storageMethod = return {}
config.jwtToken?.storageMethod || (isBrowser() ? "local" : "memory") }
const storageKey =
config.jwtToken?.storageKey || this.DEFAULT_JWT_STORAGE_KEY
const token = this.getToken_()
return token ? { Authorization: `Bearer ${token}` } : {}
}
protected setToken_ = (token: string) => {
const { storageMethod, storageKey } = this.getTokenStorageInfo_()
switch (storageMethod) { switch (storageMethod) {
case "local": { case "local": {
if (!isBrowser()) { window.localStorage.setItem(storageKey, token)
throw new Error("Local JWT storage is only available in the browser") break
}
const token = window.localStorage.getItem(storageKey)
return token ? { Authorization: `Bearer ${token}` } : {}
} }
case "session": { case "session": {
if (!isBrowser()) { window.sessionStorage.setItem(storageKey, token)
throw new Error( break
"Session JWT storage is only available in the browser"
)
}
const token = window.sessionStorage.getItem(storageKey)
return token ? { Authorization: `Bearer ${token}` } : {}
} }
case "memory": { 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,
}
}
} }
+5
View File
@@ -1,17 +1,22 @@
import { Admin } from "./admin" import { Admin } from "./admin"
import { Auth } from "./auth"
import { Client } from "./client" import { Client } from "./client"
import { Store } from "./store" import { Store } from "./store"
import { Config } from "./types" import { Config } from "./types"
class Medusa { class Medusa {
public client: Client public client: Client
public admin: Admin public admin: Admin
public store: Store public store: Store
public auth: Auth
constructor(config: Config) { constructor(config: Config) {
this.client = new Client(config) this.client = new Client(config)
this.admin = new Admin(this.client) this.admin = new Admin(this.client)
this.store = new Store(this.client) this.store = new Store(this.client)
this.auth = new Auth(this.client, config)
} }
} }
+4 -4
View File
@@ -10,10 +10,10 @@ export type Config = {
globalHeaders?: ClientHeaders globalHeaders?: ClientHeaders
publishableKey?: string publishableKey?: string
apiKey?: string apiKey?: string
jwtToken?: { auth?: {
storageKey?: string type?: "jwt" | "session"
// TODO: Add support for cookie storage jwtTokenStorageKey?: string
storageMethod?: "local" | "session" | "memory" jwtTokenStorageMethod?: "local" | "session" | "memory"
} }
logger?: Logger logger?: Logger
debug?: boolean debug?: boolean
+3 -4
View File
@@ -5,8 +5,8 @@
"outDir": "./dist", "outDir": "./dist",
"esModuleInterop": true, "esModuleInterop": true,
"declaration": true, "declaration": true,
"module": "commonjs", "module": "ES2020",
"moduleResolution": "node", "moduleResolution": "Node",
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"sourceMap": true, "sourceMap": true,
@@ -15,8 +15,7 @@
"strictFunctionTypes": true, "strictFunctionTypes": true,
"noImplicitThis": true, "noImplicitThis": true,
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true
"downlevelIteration": true // to use ES5 specific tooling
}, },
"include": ["./src/**/*"], "include": ["./src/**/*"],
"exclude": [ "exclude": [
+2 -1
View File
@@ -5261,6 +5261,7 @@ __metadata:
"@hookform/resolvers": 3.3.2 "@hookform/resolvers": 3.3.2
"@medusajs/admin-vite-plugin": 0.0.1 "@medusajs/admin-vite-plugin": 0.0.1
"@medusajs/icons": 1.2.1 "@medusajs/icons": 1.2.1
"@medusajs/js-sdk": 0.0.1
"@medusajs/types": 1.11.16 "@medusajs/types": 1.11.16
"@medusajs/ui": 3.0.0 "@medusajs/ui": 3.0.0
"@medusajs/ui-preset": 1.1.3 "@medusajs/ui-preset": 1.1.3
@@ -5507,7 +5508,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 0.0.0-use.local
resolution: "@medusajs/js-sdk@workspace:packages/core/js-sdk" resolution: "@medusajs/js-sdk@workspace:packages/core/js-sdk"
dependencies: dependencies: