diff --git a/src/api/functions/authorization.ts b/src/api/functions/authorization.ts index 0d05daa8..21c1ea9d 100644 --- a/src/api/functions/authorization.ts +++ b/src/api/functions/authorization.ts @@ -3,6 +3,10 @@ import { unmarshall } from "@aws-sdk/util-dynamodb"; import { genericConfig } from "../../common/config.js"; import { DatabaseFetchError } from "../../common/errors/index.js"; import { allAppRoles, AppRoles } from "../../common/roles.js"; +import type Redis from "ioredis"; +import { AUTH_CACHE_PREFIX } from "api/plugins/auth.js"; +import type pino from "pino"; +import { type FastifyBaseLogger } from "fastify"; export async function getUserRoles( dynamoClient: DynamoDBClient, @@ -63,3 +67,27 @@ export async function getGroupRoles( } return items.roles as AppRoles[]; } + +type ClearAuthCacheInput = { + redisClient: Redis.default; + username: string[]; + logger: pino.Logger | FastifyBaseLogger; +}; +export async function clearAuthCache({ + redisClient, + username, + logger, +}: ClearAuthCacheInput) { + logger.debug(`Clearing auth cache for: ${JSON.stringify(username)}.`); + const keys = ( + await Promise.all( + username.map((x) => redisClient.keys(`${AUTH_CACHE_PREFIX}${x}*`)), + ) + ).flat(); + if (keys.length === 0) { + return 0; + } + const result = await redisClient.del(keys); + logger.debug(`Cleared ${result} auth cache keys.`); + return result; +} diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 7fc41774..2384ada0 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -109,7 +109,9 @@ export async function getEntraIdToken({ key: cacheKey, data: JSON.stringify({ token: result.accessToken }), expiresIn: - (result.expiresOn.getTime() - new Date().getTime()) / 1000 - 120, // get new token 2 min before expiry + Math.floor( + (result.expiresOn.getTime() - new Date().getTime()) / 1000, + ) - 120, // get new token 2 min before expiry encryptionSecret, }); } @@ -118,8 +120,9 @@ export async function getEntraIdToken({ if (error instanceof BaseError) { throw error; } + logger.error(error); throw new InternalServerError({ - message: `Failed to acquire token: ${error}`, + message: "Failed to acquire Entra ID token.", }); } } diff --git a/src/api/index.ts b/src/api/index.ts index 86b324e7..d82be73f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -48,6 +48,7 @@ import { import { ZodOpenApiVersion } from "zod-openapi"; import { withTags } from "./components/index.js"; import apiKeyRoute from "./routes/apiKey.js"; +import clearSessionRoute from "./routes/clearSession.js"; import RedisModule from "ioredis"; dotenv.config(); @@ -311,6 +312,7 @@ async function init(prettyPrint: boolean = false) { api.register(roomRequestRoutes, { prefix: "/roomRequests" }); api.register(logsPlugin, { prefix: "/logs" }); api.register(apiKeyRoute, { prefix: "/apiKey" }); + api.register(clearSessionRoute, { prefix: "/clearSession" }); if (app.runEnvironment === "dev") { api.register(vendingPlugin, { prefix: "/vending" }); } diff --git a/src/api/lambda.ts b/src/api/lambda.ts index 390dc186..835870aa 100644 --- a/src/api/lambda.ts +++ b/src/api/lambda.ts @@ -17,7 +17,7 @@ const handler = async (event: APIGatewayEvent, context: Context) => { return "warmed"; } // else proceed with handler logic - return realHandler(event, context).catch((e) => { + return await realHandler(event, context).catch((e) => { console.error(e); const newError = new InternalServerError({ message: "Failed to initialize application.", diff --git a/src/api/plugins/auth.ts b/src/api/plugins/auth.ts index f045484f..850884ec 100644 --- a/src/api/plugins/auth.ts +++ b/src/api/plugins/auth.ts @@ -22,6 +22,8 @@ import { getGroupRoles, getUserRoles } from "../functions/authorization.js"; import { getApiKeyData, getApiKeyParts } from "api/functions/apiKey.js"; import { getKey, setKey } from "api/functions/redisCache.js"; +export const AUTH_CACHE_PREFIX = `authCache:`; + export function intersection(setA: Set, setB: Set): Set { const _intersection = new Set(); for (const elem of setB) { @@ -270,7 +272,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { verifiedTokenData.sub; const expectedRoles = new Set(validRoles); const cachedRoles = await getKey({ - key: `authCache:${request.username}:roles`, + key: `${AUTH_CACHE_PREFIX}${request.username}:roles`, redisClient, logger: request.log, }); @@ -326,7 +328,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { } request.userRoles = userRoles; setKey({ - key: `authCache:${request.username}:roles`, + key: `${AUTH_CACHE_PREFIX}${request.username}:roles`, data: JSON.stringify([...userRoles]), redisClient, expiresIn: GENERIC_CACHE_SECONDS, diff --git a/src/api/routes/clearSession.ts b/src/api/routes/clearSession.ts new file mode 100644 index 00000000..55faa7a6 --- /dev/null +++ b/src/api/routes/clearSession.ts @@ -0,0 +1,34 @@ +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"; + +const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => { + fastify.register(rateLimiter, { + limit: 10, + duration: 30, + rateLimitIdentifier: "clearSession", + }); + fastify.post( + "", + { + schema: withRoles( + [], + withTags(["Generic"], { + summary: "Clear user's session (usually on logout).", + hide: true, + }), + ), + onRequest: fastify.authorizeFromSchema, + }, + async (request, reply) => { + reply.status(201).send(); + const username = [request.username!]; + const { redisClient } = fastify; + const { log: logger } = fastify; + await clearAuthCache({ redisClient, username, logger }); + }, + ); +}; + +export default clearSessionPlugin; diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 75734974..66b0e609 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -32,7 +32,7 @@ import { EntraGroupActions, entraProfilePatchRequest, } from "../../common/types/iam.js"; -import { getGroupRoles } from "../functions/authorization.js"; +import { clearAuthCache, getGroupRoles } from "../functions/authorization.js"; import { getRoleCredentials } from "api/functions/sts.js"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { createAuditLogEntry } from "api/functions/auditLog.js"; @@ -162,6 +162,14 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { const groupId = (request.params as Record).groupId; try { const timestamp = new Date().toISOString(); + const entraIdToken = await getEntraIdToken({ + clients: await getAuthorizedClients(), + clientId: fastify.environmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + encryptionSecret: fastify.secretConfig.encryption_key, + logger: request.log, + }); + const groupMembers = listGroupMembers(entraIdToken, groupId); const command = new PutItemCommand({ TableName: `${genericConfig.IAMTablePrefix}-grouproles`, Item: marshall({ @@ -187,6 +195,13 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { request.body.roles, GENERIC_CACHE_SECONDS, ); + const groupMemberEmails = (await groupMembers).map((x) => x.email); + await clearAuthCache({ + redisClient: fastify.redisClient, + username: groupMemberEmails, + logger: request.log, + }); + reply.send({ message: "OK" }); } catch (e: unknown) { fastify.nodeCache.del(`grouproles-${groupId}`); if (e instanceof BaseError) { @@ -198,7 +213,6 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { message: "Could not create group role mapping.", }); } - reply.send({ message: "OK" }); }, ); fastify.withTypeProvider().post( @@ -525,6 +539,14 @@ No action is required from you at this time. ); } } + const allEmailsModified = response.success.map((x) => x.email); + const { redisClient } = fastify; + const { log: logger } = request; + await clearAuthCache({ + redisClient, + username: allEmailsModified, + logger, + }); await Promise.allSettled(logPromises); reply.status(202).send(response); }, diff --git a/src/common/config.ts b/src/common/config.ts index 5eec05d5..38c7e959 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -8,7 +8,7 @@ type ValueOrArray = T | ArrayOfValueOrArray; type AzureRoleMapping = Record; -export const GENERIC_CACHE_SECONDS = 300; +export const GENERIC_CACHE_SECONDS = 600; export type ConfigType = { UserFacingUrl: string; diff --git a/src/ui/components/ProfileDropdown/index.tsx b/src/ui/components/ProfileDropdown/index.tsx index 01c9fa9a..29a3b299 100644 --- a/src/ui/components/ProfileDropdown/index.tsx +++ b/src/ui/components/ProfileDropdown/index.tsx @@ -19,6 +19,7 @@ import { useState } from "react"; import { AuthContextData, useAuth } from "../AuthContext/index.js"; import classes from "../Navbar/index.module.css"; import { useNavigate } from "react-router-dom"; +import { useApi } from "@ui/util/api.js"; interface ProfileDropdownProps { userData?: AuthContextData; @@ -31,6 +32,7 @@ const AuthenticatedProfileDropdown: React.FC = ({ const theme = useMantineTheme(); const navigate = useNavigate(); const { logout } = useAuth(); + const api = useApi("core"); if (!userData) { return null; } @@ -129,6 +131,7 @@ const AuthenticatedProfileDropdown: React.FC = ({ variant="outline" fullWidth onClick={async () => { + await api.post("/api/v1/clearSession"); await logout(); }} > diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index 47e0bf5e..1cb4a27c 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -1,5 +1,4 @@ import { expect, test, vi } from "vitest"; -import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; import { secretObject, diff --git a/tests/unit/entraGroupManagement.test.ts b/tests/unit/entraGroupManagement.test.ts index 6eb55af0..1fa35a5b 100644 --- a/tests/unit/entraGroupManagement.test.ts +++ b/tests/unit/entraGroupManagement.test.ts @@ -43,7 +43,6 @@ import { import { EntraGroupActions } from "../../src/common/types/iam.js"; import { randomUUID } from "crypto"; import { mockClient } from "aws-sdk-client-mock"; -import { V } from "vitest/dist/chunks/reporters.d.79o4mouw.js"; const app = await init(); describe("Test Modify Group and List Group Routes", () => { @@ -128,7 +127,6 @@ describe("Test Modify Group and List Group Routes", () => { const response = await supertest(app.server) .get("/api/v1/iam/groups/test-group-id") .set("authorization", `Bearer ${testJwt}`); - expect(response.statusCode).toBe(200); expect(listGroupMembers).toHaveBeenCalledWith( "ey.test.token", diff --git a/tests/unit/vitest.config.ts b/tests/unit/vitest.config.ts index 4c6acd22..5179d932 100644 --- a/tests/unit/vitest.config.ts +++ b/tests/unit/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ exclude: ["src/api/lambda.ts", "src/api/sqs/handlers/templates/*.ts"], thresholds: { statements: 50, - functions: 66, + functions: 65, lines: 50, }, }, diff --git a/tests/unit/vitest.setup.ts b/tests/unit/vitest.setup.ts index 8ca37dd3..76f07fa4 100644 --- a/tests/unit/vitest.setup.ts +++ b/tests/unit/vitest.setup.ts @@ -56,6 +56,7 @@ vi.mock( return mockGroupRoles[groupId as any] || []; }), + clearAuthCache: vi.fn(), }; }, );