Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(js-sdk): save and access JWT token in Browser Cookies for Authentication #8681

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default async ({
let inviteToken: string | undefined = undefined

// add environment variables
let env = `MEDUSA_ADMIN_ONBOARDING_TYPE=${onboardingType}${EOL}STORE_CORS=${STORE_CORS}${EOL}ADMIN_CORS=${ADMIN_CORS}${EOL}AUTH_CORS=${AUTH_CORS}${EOL}REDIS_URL=${DEFAULT_REDIS_URL}${EOL}JWT_SECRET=supersecret${EOL}COOKIE_SECRET=supersecret`
let env = `MEDUSA_ADMIN_ONBOARDING_TYPE=${onboardingType}${EOL}STORE_CORS=${STORE_CORS}${EOL}ADMIN_CORS=${ADMIN_CORS}${EOL}AUTH_CORS=${AUTH_CORS}${EOL}REDIS_URL=${DEFAULT_REDIS_URL}${EOL}JWT_SECRET=supersecret${EOL}JWT_TOKEN_STORAGE_KEY=medusa_auth_token${EOL}COOKIE_SECRET=supersecret`

if (!skipDb) {
env += `${EOL}DATABASE_URL=${dbConnectionString}`
Expand Down
28 changes: 18 additions & 10 deletions packages/core/js-sdk/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Client } from "../client"
import { Config } from "../types"
import { AuthActor, AuthMethod, AuthPayload, AuthResponse, Config } from "../types"

export class Auth {
private client: Client
Expand All @@ -11,18 +11,26 @@ export class Auth {
}

login = async (
actor: "customer" | "user",
method: "emailpass",
payload: { email: string; password: string }
actor: AuthActor,
method: AuthMethod,
payload: AuthPayload
) => {
const { token } = await this.client.fetch<{ token: string }>(
`/auth/${actor}/${method}`,
const response = await this.client.fetch<AuthResponse>(
`/auth/${actor}/${method}?redirect=false`, // set redirect to false, to disable automatic redirect -> otherwise cors error
{
method: "POST",
body: payload,
}
)

// Redirect to 3rd Party Login
if ("location" in response) {
window.location.href = response.location
return response.location
}

const { token } = response as { token: string }

// 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", {
Expand All @@ -47,11 +55,11 @@ export class Auth {
}

create = async (
actor: "customer" | "user",
method: "emailpass",
payload: { email: string; password: string }
actor: AuthActor,
method: AuthMethod,
payload: AuthPayload
): Promise<{ token: string }> => {
return await this.client.fetch(`/auth/${actor}/${method}`, {
return await this.client.fetch(`/auth/${actor}/${method}?redirect=false`, {
method: "POST",
body: payload,
})
Expand Down
51 changes: 50 additions & 1 deletion packages/core/js-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ const hasStorage = (storage: "localStorage" | "sessionStorage") => {
return false
}

const hasCookies_ = (): boolean => {
if (hasStorage("localStorage") && typeof document !== "undefined") {
return "cookie" in document
}

return false
};

const toBase64 = (str: string) => {
if (typeof window !== "undefined") {
return window.btoa(str)
Expand Down Expand Up @@ -46,6 +54,11 @@ const normalizeRequest = (
}

const normalizeResponse = async (resp: Response, reqHeaders: Headers) => {
if (resp.status === 301 || resp.status === 302 || resp.status === 303) {
// Return the location URL for redirect handling
return { location: resp.headers.get('location')! }
}

if (resp.status >= 300) {
const jsonError = (await resp.json().catch(() => ({}))) as {
message?: string
Expand Down Expand Up @@ -134,6 +147,15 @@ export class Client {
window.sessionStorage.removeItem(storageKey)
break
}
case "cookie": {
this.fetch(
`/auth/cookie`,
{
method: "DELETE"
}
)
break
}
case "memory": {
this.token = ""
break
Expand Down Expand Up @@ -192,7 +214,10 @@ export class Client {
// Any non-request errors (eg. invalid JSON in the response) will be thrown as-is.
return await fetch(
normalizedInput,
normalizeRequest(init, headers, this.config)
{
...normalizeRequest(init, headers, this.config),
credentials: "include"
}
).then((resp) => {
this.logger.debug(`Received response with status ${resp.status}\n`)
return normalizeResponse(resp, headers)
Expand Down Expand Up @@ -235,6 +260,18 @@ export class Client {
window.sessionStorage.setItem(storageKey, token)
break
}
case "cookie": {
this.fetch(
`/auth/cookie`,
{
method: "POST",
body: {
authToken: token
}
}
)
break
}
case "memory": {
this.token = token
break
Expand All @@ -251,6 +288,14 @@ export class Client {
case "session": {
return window.sessionStorage.getItem(storageKey)
}
case "cookie": {
const cookieValue = document.cookie
.split("; ")
.find((row) => row.startsWith(`${storageKey}=`))
?.split("=")[1]

return cookieValue ? cookieValue : null;
}
case "memory": {
return this.token
}
Expand All @@ -262,6 +307,7 @@ export class Client {
protected getTokenStorageInfo_ = () => {
const hasLocal = hasStorage("localStorage")
const hasSession = hasStorage("sessionStorage")
const hasCookies = hasCookies_()

const storageMethod =
this.config.auth?.jwtTokenStorageMethod || (hasLocal ? "local" : "memory")
Expand All @@ -274,6 +320,9 @@ export class Client {
if (!hasSession && storageMethod === "session") {
throw new Error("Session JWT storage is only available in the browser")
}
if (!hasCookies && storageMethod === "cookie") {
throw new Error("Cookie JWT storage is only available in the browser")
}

return {
storageMethod,
Expand Down
10 changes: 8 additions & 2 deletions packages/core/js-sdk/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export type AuthResponse = { token: string } | { location: string }

export type AuthActor = "customer" | "user" | (string & {})
export type AuthMethod = "emailpass" | (string & {})
export type AuthPayload = { email: string; password: string } | (Object & {})

export type Logger = {
error: (...messages: string[]) => void
warn: (...messages: string[]) => void
Expand All @@ -13,7 +19,7 @@ export type Config = {
auth?: {
type?: "jwt" | "session"
jwtTokenStorageKey?: string
jwtTokenStorageMethod?: "local" | "session" | "memory"
jwtTokenStorageMethod?: "local" | "session" | "memory" | "cookie"
}
logger?: Logger
debug?: boolean
Expand All @@ -36,4 +42,4 @@ export type FetchArgs = Omit<RequestInit, "headers" | "body"> & {
export type ClientFetch = (
input: FetchInput,
init?: FetchArgs
) => Promise<Response>
) => Promise<Response>
17 changes: 17 additions & 0 deletions packages/core/types/src/common/config-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,23 @@ export type ProjectConfigOptions = {
* ```
*/
jwtExpiresIn?: string
/**
* The storage Key for the JWT token. If not provided, the default value is `medusa_auth_token`.
*
* @example
* ```js title="medusa-config.js"
* module.exports = defineConfig({
* projectConfig: {
* http: {
* jwtTokenStorageKey: "auth_token"
* }
* // ...
* },
* // ...
* })
* ```
*/
jwtTokenStorageKey?: string
/**
* A random string used to create cookie tokens in the http layer. Although this configuration option is not required, it’s highly recommended to set it for better security.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/core/utils/src/common/define-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ConfigModule } from "@medusajs/types"
import { Modules } from "../modules-sdk/definition"

const DEFAULT_SECRET = "supersecret"
const DEFAULT_STORAGE_KEY = "medusa_auth_token"
const DEFAULT_ADMIN_URL = "http://localhost:9000"
const DEFAULT_STORE_CORS = "http://localhost:8000"
const DEFAULT_DATABASE_URL = "postgres://localhost/medusa-starter-default"
Expand Down Expand Up @@ -30,6 +31,7 @@ export function defineConfig(config: Partial<ConfigModule> = {}): ConfigModule {
adminCors: process.env.ADMIN_CORS || DEFAULT_ADMIN_CORS,
authCors: process.env.AUTH_CORS || DEFAULT_ADMIN_CORS,
jwtSecret: process.env.JWT_SECRET || DEFAULT_SECRET,
jwtTokenStorageKey: process.env.JWT_TOKEN_STORAGE_KEY || DEFAULT_STORAGE_KEY,
cookieSecret: process.env.COOKIE_SECRET || DEFAULT_SECRET,
...http,
},
Expand Down
1 change: 1 addition & 0 deletions packages/framework/framework/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class ConfigManager {
{}) as ConfigModule["projectConfig"]["http"]

http.jwtExpiresIn = http?.jwtExpiresIn ?? "1d"
http.jwtTokenStorageKey = http.jwtTokenStorageKey ?? "medusa_auth_token"
http.authCors = http.authCors ?? ""
http.storeCors = http.storeCors ?? ""
http.adminCors = http.adminCors ?? ""
Expand Down
17 changes: 17 additions & 0 deletions packages/framework/framework/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,23 @@ export type ProjectConfigOptions = {
* ```
*/
jwtExpiresIn?: string
/**
* The storage Key for the JWT token. If not provided, the default value is `medusa_auth_token`.
*
* @example
* ```js title="medusa-config.js"
* module.exports = defineConfig({
* projectConfig: {
* http: {
* jwtTokenStorageKey: "auth_token"
* }
* // ...
* },
* // ...
* })
* ```
*/
jwtTokenStorageKey?: string
/**
* A random string used to create cookie tokens in the http layer. Although this configuration option is not required, it’s highly recommended to set it for better security.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ export const authenticate = (
ContainerRegistrationKeys.CONFIG_MODULE
)

const authToken = req.cookies[http.jwtTokenStorageKey!]

if (authToken) {
req.headers['authorization'] = `Bearer ${authToken}`
}

authContext = getAuthContextFromJwtToken(
req.headers.authorization,
http.jwtSecret!,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { MedusaRequest, MedusaResponse } from "../../../../types/routing"

export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { actor_type, auth_provider } = req.params
const shouldRedirect = req.query.redirect === 'true' || req.query.redirect === undefined
const config: ConfigModule = req.scope.resolve(
ContainerRegistrationKeys.CONFIG_MODULE
)
Expand Down Expand Up @@ -47,7 +48,11 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
)

if (location) {
res.redirect(location)
if (shouldRedirect) {
res.redirect(location)
} else {
res.status(200).json({ location })
}
return
}

Expand Down Expand Up @@ -90,4 +95,4 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {

export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
await GET(req, res)
}
}
42 changes: 42 additions & 0 deletions packages/medusa/src/api/auth/cookie/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
MedusaRequest,
MedusaResponse,
} from "../../../types/routing"
import { MedusaError } from "@medusajs/utils"

export const POST = async (
req: MedusaRequest,
res: MedusaResponse
) => {
const { jwtTokenStorageKey } = req.scope.resolve("configModule").projectConfig.http
const { authToken } = req.body as { authToken: string }

if (!authToken) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Missing authToken from Body."
)
}

res.cookie(jwtTokenStorageKey as string, authToken, {
httpOnly: true,
secure: true,
})

res.status(200).json({ message: 'Saved Token to Browser Cookies.' })
}

export const DELETE = async (
req: MedusaRequest,
res: MedusaResponse
) => {
const { jwtTokenStorageKey } = req.scope.resolve("configModule").projectConfig.http

res.cookie(jwtTokenStorageKey as string, "", {
httpOnly: true,
secure: true,
expires: new Date(0)
})

res.status(200).json({ message: 'Logged out successfully' });
}