Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/api/functions/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
7 changes: 5 additions & 2 deletions src/api/functions/entraId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
} from "../../common/config.js";
import {
BaseError,
DecryptionError,

Check warning on line 12 in src/api/functions/entraId.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'DecryptionError' is defined but never used. Allowed unused vars must match /^_/u
EntraFetchError,
EntraGroupError,
EntraGroupsFromEmailError,
Expand Down Expand Up @@ -109,7 +109,9 @@
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,
});
}
Expand All @@ -118,8 +120,9 @@
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.",
});
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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" });
}
Expand Down
2 changes: 1 addition & 1 deletion src/api/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 4 additions & 2 deletions src/api/plugins/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(setA: Set<T>, setB: Set<T>): Set<T> {
const _intersection = new Set<T>();
for (const elem of setB) {
Expand Down Expand Up @@ -270,7 +272,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
verifiedTokenData.sub;
const expectedRoles = new Set(validRoles);
const cachedRoles = await getKey<string[]>({
key: `authCache:${request.username}:roles`,
key: `${AUTH_CACHE_PREFIX}${request.username}:roles`,
redisClient,
logger: request.log,
});
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions src/api/routes/clearSession.ts
Original file line number Diff line number Diff line change
@@ -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;
26 changes: 24 additions & 2 deletions src/api/routes/iam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -162,6 +162,14 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
const groupId = (request.params as Record<string, string>).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({
Expand All @@ -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) {
Expand All @@ -198,7 +213,6 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
message: "Could not create group role mapping.",
});
}
reply.send({ message: "OK" });
},
);
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
Expand Down Expand Up @@ -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);
},
Expand Down
2 changes: 1 addition & 1 deletion src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type ValueOrArray<T> = T | ArrayOfValueOrArray<T>;

type AzureRoleMapping = Record<string, readonly AppRoles[]>;

export const GENERIC_CACHE_SECONDS = 300;
export const GENERIC_CACHE_SECONDS = 600;

export type ConfigType = {
UserFacingUrl: string;
Expand Down
3 changes: 3 additions & 0 deletions src/ui/components/ProfileDropdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +32,7 @@ const AuthenticatedProfileDropdown: React.FC<ProfileDropdownProps> = ({
const theme = useMantineTheme();
const navigate = useNavigate();
const { logout } = useAuth();
const api = useApi("core");
if (!userData) {
return null;
}
Expand Down Expand Up @@ -129,6 +131,7 @@ const AuthenticatedProfileDropdown: React.FC<ProfileDropdownProps> = ({
variant="outline"
fullWidth
onClick={async () => {
await api.post("/api/v1/clearSession");
await logout();
}}
>
Expand Down
1 change: 0 additions & 1 deletion tests/unit/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 0 additions & 2 deletions tests/unit/entraGroupManagement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Expand Down
1 change: 1 addition & 0 deletions tests/unit/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ vi.mock(

return mockGroupRoles[groupId as any] || [];
}),
clearAuthCache: vi.fn(),
};
},
);
Expand Down
Loading