From 62e0c593c8c8c0a04c12742b0022a2a7f80b45a9 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Fri, 6 Sep 2024 12:58:57 +0200 Subject: [PATCH] feat: Add support for refreshing JWT tokens (#9013) * feat: Add support for refreshing JWT tokens * feat: Add refresh method to the auth SDK --- .../helpers/create-admin-user.ts | 5 +- .../http/__tests__/auth/admin/auth.spec.ts | 24 ++++++++++ packages/core/js-sdk/src/auth/index.ts | 46 +++++++++++-------- packages/core/utils/src/auth/token.ts | 12 ++++- .../[auth_provider]/callback/route.ts | 39 ++++++---------- .../[auth_provider]/register/route.ts | 2 +- .../[actor_type]/[auth_provider]/route.ts | 2 +- packages/medusa/src/api/auth/middlewares.ts | 5 ++ .../src/api/auth/token/refresh/route.ts | 40 ++++++++++++++++ .../src/api/auth/utils/generate-jwt-token.ts | 11 ++++- 10 files changed, 136 insertions(+), 50 deletions(-) create mode 100644 packages/medusa/src/api/auth/token/refresh/route.ts diff --git a/integration-tests/helpers/create-admin-user.ts b/integration-tests/helpers/create-admin-user.ts index 90fb5854e1..dbd9608b7e 100644 --- a/integration-tests/helpers/create-admin-user.ts +++ b/integration-tests/helpers/create-admin-user.ts @@ -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}` diff --git a/integration-tests/http/__tests__/auth/admin/auth.spec.ts b/integration-tests/http/__tests__/auth/admin/auth.spec.ts index 49cc8d8223..1774b88a19 100644 --- a/integration-tests/http/__tests__/auth/admin/auth.spec.ts +++ b/integration-tests/http/__tests__/auth/admin/auth.spec.ts @@ -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) + }) }, }) diff --git a/packages/core/js-sdk/src/auth/index.ts b/packages/core/js-sdk/src/auth/index.ts index 35a2842388..f6d18435e8 100644 --- a/packages/core/js-sdk/src/auth/index.ts +++ b/packages/core/js-sdk/src/auth/index.ts @@ -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) + } + } } diff --git a/packages/core/utils/src/auth/token.ts b/packages/core/utils/src/auth/token.ts index 88683b1e94..5014619b59 100644 --- a/packages/core/utils/src/auth/token.ts +++ b/packages/core/utils/src/auth/token.ts @@ -1,12 +1,20 @@ import jwt from "jsonwebtoken" +import { MedusaError } from "../common" export const generateJwtToken = ( tokenPayload: Record, 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, }) diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts index 60e40722a4..09453bb726 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts @@ -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, } ) diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts index b29cc5baa0..b4db076dfa 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts @@ -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( diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts index db84f16e46..efe17b28f8 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts @@ -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( diff --git a/packages/medusa/src/api/auth/middlewares.ts b/packages/medusa/src/api/auth/middlewares.ts index 8d28376eed..cbaca72e37 100644 --- a/packages/medusa/src/api/auth/middlewares.ts +++ b/packages/medusa/src/api/auth/middlewares.ts @@ -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", diff --git a/packages/medusa/src/api/auth/token/refresh/route.ts b/packages/medusa/src/api/auth/token/refresh/route.ts new file mode 100644 index 0000000000..d39d249fbb --- /dev/null +++ b/packages/medusa/src/api/auth/token/refresh/route.ts @@ -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 }) +} diff --git a/packages/medusa/src/api/auth/utils/generate-jwt-token.ts b/packages/medusa/src/api/auth/utils/generate-jwt-token.ts index 6a2927ac45..91c488faea 100644 --- a/packages/medusa/src/api/auth/utils/generate-jwt-token.ts +++ b/packages/medusa/src/api/auth/utils/generate-jwt-token.ts @@ -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