diff --git a/src/api/plugins/auth.ts b/src/api/plugins/auth.ts index 089184ae..6c641830 100644 --- a/src/api/plugins/auth.ts +++ b/src/api/plugins/auth.ts @@ -265,11 +265,27 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { request.log.debug( `Start to verifying JWT took ${new Date().getTime() - startTime} ms.`, ); - request.tokenPayload = verifiedTokenData; - request.username = + // check revocation list for token + const proposedUsername = verifiedTokenData.email || verifiedTokenData.upn?.replace("acm.illinois.edu", "illinois.edu") || verifiedTokenData.sub; + const { redisClient, log: logger } = fastify; + const revokedResult = await getKey<{ isInvalid: boolean }>({ + redisClient, + key: `tokenRevocationList:${verifiedTokenData.uti}`, + logger, + }); + if (revokedResult) { + fastify.log.info( + `Revoked token ${verifiedTokenData.uti} for ${proposedUsername} was attempted.`, + ); + throw new UnauthenticatedError({ + message: "Invalid token.", + }); + } + request.tokenPayload = verifiedTokenData; + request.username = proposedUsername; const expectedRoles = new Set(validRoles); const cachedRoles = await getKey({ key: `${AUTH_CACHE_PREFIX}${request.username}:roles`, diff --git a/src/api/routes/clearSession.ts b/src/api/routes/clearSession.ts index 55faa7a6..e3908231 100644 --- a/src/api/routes/clearSession.ts +++ b/src/api/routes/clearSession.ts @@ -2,6 +2,7 @@ import { FastifyPluginAsync } from "fastify"; import rateLimiter from "api/plugins/rateLimiter.js"; import { withRoles, withTags } from "api/components/index.js"; import { clearAuthCache } from "api/functions/authorization.js"; +import { setKey } from "api/functions/redisCache.js"; const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.register(rateLimiter, { @@ -26,7 +27,25 @@ const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => { const username = [request.username!]; const { redisClient } = fastify; const { log: logger } = fastify; + await clearAuthCache({ redisClient, username, logger }); + if (!request.tokenPayload) { + return; + } + const now = Date.now() / 1000; + const tokenExpiry = request.tokenPayload.exp; + const expiresIn = Math.ceil(tokenExpiry - now); + const tokenId = request.tokenPayload.uti; + // if the token expires more than 10 seconds after now, add to a revoke list + if (expiresIn > 10) { + await setKey({ + redisClient, + key: `tokenRevocationList:${tokenId}`, + data: JSON.stringify({ isInvalid: true }), + logger, + expiresIn, + }); + } }, ); }; diff --git a/tests/live/clearSession.test.ts b/tests/live/clearSession.test.ts new file mode 100644 index 00000000..77a49a2f --- /dev/null +++ b/tests/live/clearSession.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; +import { createJwt, getBaseEndpoint } from "./utils.js"; +import { allAppRoles } from "../../src/common/roles.js"; + +const baseEndpoint = getBaseEndpoint(); + +describe("Session clearing tests", async () => { + test("Token is revoked on logout", async () => { + const token = await createJwt(); + // token works + const response = await fetch(`${baseEndpoint}/api/v1/protected`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + expect(response.status).toBe(200); + const responseBody = await response.json(); + expect(responseBody).toStrictEqual({ + username: "infra@acm.illinois.edu", + roles: allAppRoles, + }); + // user logs out + const clearResponse = await fetch(`${baseEndpoint}/api/v1/clearSession`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + expect(clearResponse.status).toBe(201); + // token should be revoked + const responseFail = await fetch(`${baseEndpoint}/api/v1/protected`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + expect(responseFail.status).toBe(403); + }); +}); diff --git a/tests/live/utils.ts b/tests/live/utils.ts index 2af976cd..4125bc94 100644 --- a/tests/live/utils.ts +++ b/tests/live/utils.ts @@ -3,6 +3,7 @@ import { SecretsManagerClient, GetSecretValueCommand, } from "@aws-sdk/client-secrets-manager"; +import { randomUUID } from "node:crypto"; export const getSecretValue = async ( secretId: string, @@ -47,7 +48,7 @@ export async function createJwt( iss: "custom_jwt", iat: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600 * 24, // Token expires after 24 hour + exp: Math.floor(Date.now() / 1000) + 3600 * 1, // Token expires after 1 hour acr: "1", aio: "AXQAi/8TAAAA", amr: ["pwd"], @@ -64,7 +65,7 @@ export async function createJwt( sub: "subject", tid: "tenant-id", unique_name: username, - uti: "uti-value", + uti: randomUUID().toString(), ver: "1.0", }; const token = jwt.sign(payload, secretData.JWTKEY, { algorithm: "HS256" });