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:
Stevche Radevski
2024-09-06 12:58:57 +02:00
committed by GitHub
parent 3ba0ddcd43
commit 62e0c593c8
10 changed files with 136 additions and 50 deletions
@@ -47,7 +47,10 @@ export const createAdminUser = async (
actor_type: "user",
auth_identity_id: authIdentity.id,
},
"test"
"test",
{
expiresIn: "1d",
}
)
adminHeaders.headers["authorization"] = `Bearer ${token}`
@@ -1,5 +1,6 @@
import { generateResetPasswordTokenWorkflow } from "@medusajs/core-flows"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import jwt from "jsonwebtoken"
import {
adminHeaders,
createAdminUser,
@@ -254,5 +255,28 @@ medusaIntegrationTestRunner({
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)
})
},
})
+27 -19
View File
@@ -49,16 +49,7 @@ export class Auth {
return { location }
}
// 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 as string)
}
await this.setToken_(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.
if (this.config?.auth?.type === "session") {
await this.client.fetch("/auth/session", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
})
} else {
this.client.setToken(token)
}
await this.setToken_(token)
return 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
}
@@ -98,4 +94,16 @@ export class Auth {
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)
}
}
}
+10 -2
View File
@@ -1,12 +1,20 @@
import jwt from "jsonwebtoken"
import { MedusaError } from "../common"
export const generateJwtToken = (
tokenPayload: Record<string, unknown>,
jwtConfig: {
secret: string
expiresIn: string
secret: string | undefined
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, {
expiresIn: jwtConfig.expiresIn,
})
@@ -1,15 +1,22 @@
import { AuthenticationInput, IAuthModuleService } from "@medusajs/types"
import {
AuthenticationInput,
ConfigModule,
IAuthModuleService,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
MedusaError,
ModuleRegistrationName,
generateJwtToken,
} from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
import { generateJwtTokenForAuthIdentity } from "../../../utils/generate-jwt-token"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { actor_type, auth_provider } = req.params
const config: ConfigModule = req.scope.resolve(
ContainerRegistrationKeys.CONFIG_MODULE
)
const service: IAuthModuleService = req.scope.resolve(
ModuleRegistrationName.AUTH
)
@@ -27,30 +34,14 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
authData
)
const entityIdKey = `${actor_type}_id`
const entityId = authIdentity?.app_metadata?.[entityIdKey] as
| string
| undefined
if (success) {
const { http } = req.scope.resolve(
ContainerRegistrationKeys.CONFIG_MODULE
).projectConfig
if (success && authIdentity) {
const { http } = config.projectConfig
const { jwtSecret, jwtExpiresIn } = http
const token = generateJwtToken(
const token = generateJwtTokenForAuthIdentity(
{ authIdentity, actorType: actor_type },
{
actor_id: entityId ?? "",
actor_type,
auth_identity_id: authIdentity?.id ?? "",
app_metadata: {
[entityIdKey]: entityId,
},
},
{
// @ts-expect-error
secret: jwtSecret,
// @ts-expect-error
expiresIn: jwtExpiresIn,
secret: http.jwtSecret,
expiresIn: http.jwtExpiresIn,
}
)
@@ -34,7 +34,7 @@ export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
authData
)
if (success) {
if (success && authIdentity) {
const { http } = config.projectConfig
const token = generateJwtTokenForAuthIdentity(
@@ -38,7 +38,7 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
return res.status(200).json({ location })
}
if (success) {
if (success && authIdentity) {
const { http } = config.projectConfig
const token = generateJwtTokenForAuthIdentity(
@@ -14,6 +14,11 @@ export const authRoutesMiddlewares: MiddlewareRoute[] = [
matcher: "/auth/session",
middlewares: [authenticate("*", ["session"])],
},
{
method: ["POST"],
matcher: "/auth/token/refresh",
middlewares: [authenticate("*", "bearer", { allowUnregistered: true })],
},
{
method: ["POST"],
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"
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 entityId = authIdentity?.app_metadata?.[entityIdKey] as