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 authored and GitHub committed 2024-09-06 12:58:57 +02:00
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)
})
}, },
}) })
+27 -19
View File
@@ -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)
}
}
} }
+10 -2
View File
@@ -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