feat: Add support for refreshing JWT tokens (#9013)
* feat: Add support for refreshing JWT tokens * feat: Add refresh method to the auth SDK
This commit is contained in:
1 parent
3ba0ddcd43
commit
62e0c593c8
10 files changed
+136
-50
No files matched your search
@@ -47,7 +47,10 @@ export const createAdminUser = async (
|
|||||||
actor_type: "user",
|
actor_type: "user",
|
||||||
auth_identity_id: authIdentity.id,
|
auth_identity_id: authIdentity.id,
|
||||||
},
|
},
|
||||||
"test"
|
"test",
|
||||||
|
{
|
||||||
|
expiresIn: "1d",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
adminHeaders.headers["authorization"] = `Bearer ${token}`
|
adminHeaders.headers["authorization"] = `Bearer ${token}`
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { generateResetPasswordTokenWorkflow } from "@medusajs/core-flows"
|
import { generateResetPasswordTokenWorkflow } from "@medusajs/core-flows"
|
||||||
import { medusaIntegrationTestRunner } from "medusa-test-utils"
|
import { medusaIntegrationTestRunner } from "medusa-test-utils"
|
||||||
|
import jwt from "jsonwebtoken"
|
||||||
import {
|
import {
|
||||||
adminHeaders,
|
adminHeaders,
|
||||||
createAdminUser,
|
createAdminUser,
|
||||||
@@ -254,5 +255,28 @@ medusaIntegrationTestRunner({
|
|||||||
expect(response.response.data.message).toEqual("Invalid token")
|
expect(response.response.data.message).toEqual("Invalid token")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should refresh the token successfully", async () => {
|
||||||
|
// Make sure issue date is later than the admin one
|
||||||
|
jest.useFakeTimers()
|
||||||
|
jest.advanceTimersByTime(2000)
|
||||||
|
|
||||||
|
const resp = await api.post("/auth/token/refresh", {}, adminHeaders)
|
||||||
|
const decodedOriginalToken = jwt.decode(
|
||||||
|
adminHeaders.headers["authorization"].split(" ")[1]
|
||||||
|
) as any
|
||||||
|
const decodedRefreshedToken = jwt.decode(resp.data.token) as any
|
||||||
|
|
||||||
|
expect(decodedOriginalToken).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
actor_id: decodedRefreshedToken.actor_id,
|
||||||
|
actor_type: decodedRefreshedToken.actor_type,
|
||||||
|
auth_identity_id: decodedRefreshedToken.auth_identity_id,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(decodedOriginalToken.iat).toBeLessThan(decodedRefreshedToken.iat)
|
||||||
|
expect(decodedOriginalToken.exp).toBeLessThan(decodedRefreshedToken.exp)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -49,16 +49,7 @@ export class Auth {
|
|||||||
return { location }
|
return { location }
|
||||||
}
|
}
|
||||||
|
|
||||||
// By default we just set the token in memory, if configured to use sessions we convert it into session storage instead.
|
await this.setToken_(token as string)
|
||||||
if (this.config?.auth?.type === "session") {
|
|
||||||
await this.client.fetch("/auth/session", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.client.setToken(token as string)
|
|
||||||
}
|
|
||||||
|
|
||||||
return token as string
|
return token as string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,16 +67,21 @@ export class Auth {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// By default we just set the token in memory, if configured to use sessions we convert it into session storage instead.
|
await this.setToken_(token)
|
||||||
if (this.config?.auth?.type === "session") {
|
return token
|
||||||
await this.client.fetch("/auth/session", {
|
}
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.client.setToken(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
refresh = async () => {
|
||||||
|
const { token } = await this.client.fetch<{ token: string }>(
|
||||||
|
"/auth/token/refresh",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Putting the token in session after refreshing is only useful when the new token has updated info (eg. actor_id).
|
||||||
|
// Ideally we don't use the full JWT in session as key, but just store a pseudorandom key that keeps the rest of the auth context as value.
|
||||||
|
await this.setToken_(token)
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,4 +94,16 @@ export class Auth {
|
|||||||
|
|
||||||
this.client.clearToken()
|
this.client.clearToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setToken_ = async (token: string) => {
|
||||||
|
// By default we just set the token in the configured storage, 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,12 +1,20 @@
|
|||||||
import jwt from "jsonwebtoken"
|
import jwt from "jsonwebtoken"
|
||||||
|
import { MedusaError } from "../common"
|
||||||
|
|
||||||
export const generateJwtToken = (
|
export const generateJwtToken = (
|
||||||
tokenPayload: Record<string, unknown>,
|
tokenPayload: Record<string, unknown>,
|
||||||
jwtConfig: {
|
jwtConfig: {
|
||||||
secret: string
|
secret: string | undefined
|
||||||
expiresIn: string
|
expiresIn: string | undefined
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
|
if (!jwtConfig.secret || !jwtConfig.expiresIn) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_ARGUMENT,
|
||||||
|
"JWT secret and expiresIn must be provided when generating a token"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return jwt.sign(tokenPayload, jwtConfig.secret, {
|
return jwt.sign(tokenPayload, jwtConfig.secret, {
|
||||||
expiresIn: jwtConfig.expiresIn,
|
expiresIn: jwtConfig.expiresIn,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { AuthenticationInput, IAuthModuleService } from "@medusajs/types"
|
import {
|
||||||
|
AuthenticationInput,
|
||||||
|
ConfigModule,
|
||||||
|
IAuthModuleService,
|
||||||
|
} from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
ContainerRegistrationKeys,
|
ContainerRegistrationKeys,
|
||||||
MedusaError,
|
MedusaError,
|
||||||
ModuleRegistrationName,
|
ModuleRegistrationName,
|
||||||
generateJwtToken,
|
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
|
import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
|
||||||
|
import { generateJwtTokenForAuthIdentity } from "../../../utils/generate-jwt-token"
|
||||||
|
|
||||||
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
||||||
const { actor_type, auth_provider } = req.params
|
const { actor_type, auth_provider } = req.params
|
||||||
|
|
||||||
|
const config: ConfigModule = req.scope.resolve(
|
||||||
|
ContainerRegistrationKeys.CONFIG_MODULE
|
||||||
|
)
|
||||||
const service: IAuthModuleService = req.scope.resolve(
|
const service: IAuthModuleService = req.scope.resolve(
|
||||||
ModuleRegistrationName.AUTH
|
ModuleRegistrationName.AUTH
|
||||||
)
|
)
|
||||||
@@ -27,30 +34,14 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
|||||||
authData
|
authData
|
||||||
)
|
)
|
||||||
|
|
||||||
const entityIdKey = `${actor_type}_id`
|
if (success && authIdentity) {
|
||||||
const entityId = authIdentity?.app_metadata?.[entityIdKey] as
|
const { http } = config.projectConfig
|
||||||
| string
|
|
||||||
| undefined
|
|
||||||
if (success) {
|
|
||||||
const { http } = req.scope.resolve(
|
|
||||||
ContainerRegistrationKeys.CONFIG_MODULE
|
|
||||||
).projectConfig
|
|
||||||
|
|
||||||
const { jwtSecret, jwtExpiresIn } = http
|
const token = generateJwtTokenForAuthIdentity(
|
||||||
const token = generateJwtToken(
|
{ authIdentity, actorType: actor_type },
|
||||||
{
|
{
|
||||||
actor_id: entityId ?? "",
|
secret: http.jwtSecret,
|
||||||
actor_type,
|
expiresIn: http.jwtExpiresIn,
|
||||||
auth_identity_id: authIdentity?.id ?? "",
|
|
||||||
app_metadata: {
|
|
||||||
[entityIdKey]: entityId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// @ts-expect-error
|
|
||||||
secret: jwtSecret,
|
|
||||||
// @ts-expect-error
|
|
||||||
expiresIn: jwtExpiresIn,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
|
|||||||
authData
|
authData
|
||||||
)
|
)
|
||||||
|
|
||||||
if (success) {
|
if (success && authIdentity) {
|
||||||
const { http } = config.projectConfig
|
const { http } = config.projectConfig
|
||||||
|
|
||||||
const token = generateJwtTokenForAuthIdentity(
|
const token = generateJwtTokenForAuthIdentity(
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
|||||||
return res.status(200).json({ location })
|
return res.status(200).json({ location })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success && authIdentity) {
|
||||||
const { http } = config.projectConfig
|
const { http } = config.projectConfig
|
||||||
|
|
||||||
const token = generateJwtTokenForAuthIdentity(
|
const token = generateJwtTokenForAuthIdentity(
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export const authRoutesMiddlewares: MiddlewareRoute[] = [
|
|||||||
matcher: "/auth/session",
|
matcher: "/auth/session",
|
||||||
middlewares: [authenticate("*", ["session"])],
|
middlewares: [authenticate("*", ["session"])],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: ["POST"],
|
||||||
|
matcher: "/auth/token/refresh",
|
||||||
|
middlewares: [authenticate("*", "bearer", { allowUnregistered: true })],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: ["POST"],
|
method: ["POST"],
|
||||||
matcher: "/auth/:actor_type/:auth_provider/callback",
|
matcher: "/auth/:actor_type/:auth_provider/callback",
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { IAuthModuleService } from "@medusajs/types"
|
||||||
|
import {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "../../../../types/routing"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
ModuleRegistrationName,
|
||||||
|
} from "@medusajs/utils"
|
||||||
|
import { generateJwtTokenForAuthIdentity } from "../../utils/generate-jwt-token"
|
||||||
|
|
||||||
|
// Retrieve a newly generated JWT token. All checks that the existing token is valid already happen in the auth middleware.
|
||||||
|
// The token will include the actor ID, even if the token used to refresh didn't have one.
|
||||||
|
// Note: We probably want to disallow refreshes if the password changes, and require reauth.
|
||||||
|
export const POST = async (
|
||||||
|
req: AuthenticatedMedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) => {
|
||||||
|
const service: IAuthModuleService = req.scope.resolve(
|
||||||
|
ModuleRegistrationName.AUTH
|
||||||
|
)
|
||||||
|
|
||||||
|
const authIdentity = await service.retrieveAuthIdentity(
|
||||||
|
req.auth_context.auth_identity_id
|
||||||
|
)
|
||||||
|
|
||||||
|
const { http } = req.scope.resolve(
|
||||||
|
ContainerRegistrationKeys.CONFIG_MODULE
|
||||||
|
).projectConfig
|
||||||
|
|
||||||
|
const token = generateJwtTokenForAuthIdentity(
|
||||||
|
{ authIdentity, actorType: req.auth_context.actor_type },
|
||||||
|
{
|
||||||
|
secret: http.jwtSecret,
|
||||||
|
expiresIn: http.jwtExpiresIn,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ token })
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import { AuthIdentityDTO } from "@medusajs/types"
|
||||||
import { generateJwtToken } from "@medusajs/utils"
|
import { generateJwtToken } from "@medusajs/utils"
|
||||||
|
|
||||||
export function generateJwtTokenForAuthIdentity(
|
export function generateJwtTokenForAuthIdentity(
|
||||||
{ authIdentity, actorType },
|
{
|
||||||
{ secret, expiresIn }
|
authIdentity,
|
||||||
|
actorType,
|
||||||
|
}: { authIdentity: AuthIdentityDTO; actorType: string },
|
||||||
|
{
|
||||||
|
secret,
|
||||||
|
expiresIn,
|
||||||
|
}: { secret: string | undefined; expiresIn: string | undefined }
|
||||||
) {
|
) {
|
||||||
const entityIdKey = `${actorType}_id`
|
const entityIdKey = `${actorType}_id`
|
||||||
const entityId = authIdentity?.app_metadata?.[entityIdKey] as
|
const entityId = authIdentity?.app_metadata?.[entityIdKey] as
|
||||||
|
|||||||
Reference in new issue
Block a user