Skip to content

Commit 56109cf

Browse files
authored
Add JWT revocation list (#205)
1 parent b34fe8a commit 56109cf

File tree

4 files changed

+80
-4
lines changed

4 files changed

+80
-4
lines changed

src/api/plugins/auth.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,27 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
265265
request.log.debug(
266266
`Start to verifying JWT took ${new Date().getTime() - startTime} ms.`,
267267
);
268-
request.tokenPayload = verifiedTokenData;
269-
request.username =
268+
// check revocation list for token
269+
const proposedUsername =
270270
verifiedTokenData.email ||
271271
verifiedTokenData.upn?.replace("acm.illinois.edu", "illinois.edu") ||
272272
verifiedTokenData.sub;
273+
const { redisClient, log: logger } = fastify;
274+
const revokedResult = await getKey<{ isInvalid: boolean }>({
275+
redisClient,
276+
key: `tokenRevocationList:${verifiedTokenData.uti}`,
277+
logger,
278+
});
279+
if (revokedResult) {
280+
fastify.log.info(
281+
`Revoked token ${verifiedTokenData.uti} for ${proposedUsername} was attempted.`,
282+
);
283+
throw new UnauthenticatedError({
284+
message: "Invalid token.",
285+
});
286+
}
287+
request.tokenPayload = verifiedTokenData;
288+
request.username = proposedUsername;
273289
const expectedRoles = new Set(validRoles);
274290
const cachedRoles = await getKey<string[]>({
275291
key: `${AUTH_CACHE_PREFIX}${request.username}:roles`,

src/api/routes/clearSession.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { FastifyPluginAsync } from "fastify";
22
import rateLimiter from "api/plugins/rateLimiter.js";
33
import { withRoles, withTags } from "api/components/index.js";
44
import { clearAuthCache } from "api/functions/authorization.js";
5+
import { setKey } from "api/functions/redisCache.js";
56

67
const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => {
78
fastify.register(rateLimiter, {
@@ -26,7 +27,25 @@ const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => {
2627
const username = [request.username!];
2728
const { redisClient } = fastify;
2829
const { log: logger } = fastify;
30+
2931
await clearAuthCache({ redisClient, username, logger });
32+
if (!request.tokenPayload) {
33+
return;
34+
}
35+
const now = Date.now() / 1000;
36+
const tokenExpiry = request.tokenPayload.exp;
37+
const expiresIn = Math.ceil(tokenExpiry - now);
38+
const tokenId = request.tokenPayload.uti;
39+
// if the token expires more than 10 seconds after now, add to a revoke list
40+
if (expiresIn > 10) {
41+
await setKey({
42+
redisClient,
43+
key: `tokenRevocationList:${tokenId}`,
44+
data: JSON.stringify({ isInvalid: true }),
45+
logger,
46+
expiresIn,
47+
});
48+
}
3049
},
3150
);
3251
};

tests/live/clearSession.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, test } from "vitest";
2+
import { createJwt, getBaseEndpoint } from "./utils.js";
3+
import { allAppRoles } from "../../src/common/roles.js";
4+
5+
const baseEndpoint = getBaseEndpoint();
6+
7+
describe("Session clearing tests", async () => {
8+
test("Token is revoked on logout", async () => {
9+
const token = await createJwt();
10+
// token works
11+
const response = await fetch(`${baseEndpoint}/api/v1/protected`, {
12+
method: "GET",
13+
headers: {
14+
Authorization: `Bearer ${token}`,
15+
},
16+
});
17+
expect(response.status).toBe(200);
18+
const responseBody = await response.json();
19+
expect(responseBody).toStrictEqual({
20+
username: "infra@acm.illinois.edu",
21+
roles: allAppRoles,
22+
});
23+
// user logs out
24+
const clearResponse = await fetch(`${baseEndpoint}/api/v1/clearSession`, {
25+
method: "POST",
26+
headers: {
27+
Authorization: `Bearer ${token}`,
28+
},
29+
});
30+
expect(clearResponse.status).toBe(201);
31+
// token should be revoked
32+
const responseFail = await fetch(`${baseEndpoint}/api/v1/protected`, {
33+
method: "GET",
34+
headers: {
35+
Authorization: `Bearer ${token}`,
36+
},
37+
});
38+
expect(responseFail.status).toBe(403);
39+
});
40+
});

tests/live/utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
SecretsManagerClient,
44
GetSecretValueCommand,
55
} from "@aws-sdk/client-secrets-manager";
6+
import { randomUUID } from "node:crypto";
67

78
export const getSecretValue = async (
89
secretId: string,
@@ -47,7 +48,7 @@ export async function createJwt(
4748
iss: "custom_jwt",
4849
iat: Math.floor(Date.now() / 1000),
4950
nbf: Math.floor(Date.now() / 1000),
50-
exp: Math.floor(Date.now() / 1000) + 3600 * 24, // Token expires after 24 hour
51+
exp: Math.floor(Date.now() / 1000) + 3600 * 1, // Token expires after 1 hour
5152
acr: "1",
5253
aio: "AXQAi/8TAAAA",
5354
amr: ["pwd"],
@@ -64,7 +65,7 @@ export async function createJwt(
6465
sub: "subject",
6566
tid: "tenant-id",
6667
unique_name: username,
67-
uti: "uti-value",
68+
uti: randomUUID().toString(),
6869
ver: "1.0",
6970
};
7071
const token = jwt.sign(payload, secretData.JWTKEY, { algorithm: "HS256" });

0 commit comments

Comments
 (0)