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:
@@ -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<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
packages/core/js-sdk/src/auth/index.ts
Normal file
33
packages/core/js-sdk/src/auth/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T>
|
||||
*/
|
||||
|
||||
fetch<T extends any>(input: FetchInput, init?: FetchArgs): 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({
|
||||
"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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user