Skip to content

Commit 75457bc

Browse files
authored
Add OpenAPI auth decorators (#123)
* add auth security scheme to api endpoints * add openapi auth lock * add tests to get the OpenAPI schema
1 parent ed3da44 commit 75457bc

File tree

9 files changed

+185
-112
lines changed

9 files changed

+185
-112
lines changed

src/api/components/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@ export function withRoles<T extends FastifyZodOpenApiSchema>(
3232
schema: T,
3333
): T & RoleSchema {
3434
return {
35+
security: [{ bearerAuth: [] }],
3536
"x-required-roles": roles,
36-
description: `Requires one of the following roles: ${roles.join(", ")}.${schema.description ? "\n\n" + schema.description : ""}`,
37+
description:
38+
roles.length > 0
39+
? `Requires one of the following roles: ${roles.join(", ")}.${schema.description ? "\n\n" + schema.description : ""}`
40+
: "Requires valid authentication but no specific role.",
3741
...schema,
3842
};
3943
}

src/api/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,17 @@ async function init(prettyPrint: boolean = false) {
165165
},
166166
],
167167
openapi: "3.0.3" satisfies ZodOpenApiVersion, // If this is not specified, it will default to 3.1.0
168+
components: {
169+
securitySchemes: {
170+
bearerAuth: {
171+
type: "http",
172+
scheme: "bearer",
173+
bearerFormat: "JWT",
174+
description:
175+
"Authorization: Bearer {token}\n\nThis API uses JWT tokens issued by Entra ID (Azure AD) with the Core API audience. Tokens must be included in the Authorization header as a Bearer token for all protected endpoints.",
176+
},
177+
},
178+
},
168179
},
169180
transform: fastifyZodOpenApiTransform,
170181
transformObject: fastifyZodOpenApiTransformObject,

src/api/routes/events.ts

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
serializerCompiler,
4141
validatorCompiler,
4242
} from "fastify-zod-openapi";
43-
import { ts, withTags } from "api/components/index.js";
43+
import { ts, withRoles, withTags } from "api/components/index.js";
4444

4545
const repeatOptions = ["weekly", "biweekly"] as const;
4646
export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${EVENT_CACHED_DURATION}, stale-while-revalidate=420, stale-if-error=3600`;
@@ -221,26 +221,27 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
221221
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
222222
"/:id?",
223223
{
224-
schema: withTags(["Events"], {
225-
// response: {
226-
// 201: z.object({
227-
// id: z.string(),
228-
// resource: z.string(),
229-
// }),
230-
// },
231-
body: postRequestSchema,
232-
params: z.object({
233-
id: z.string().min(1).optional().openapi({
234-
description:
235-
"Event ID to modify (leave empty to create a new event).",
236-
example: "6667e095-8b04-4877-b361-f636f459ba42",
224+
schema: withRoles(
225+
[AppRoles.EVENTS_MANAGER],
226+
withTags(["Events"], {
227+
// response: {
228+
// 201: z.object({
229+
// id: z.string(),
230+
// resource: z.string(),
231+
// }),
232+
// },
233+
body: postRequestSchema,
234+
params: z.object({
235+
id: z.string().min(1).optional().openapi({
236+
description:
237+
"Event ID to modify (leave empty to create a new event).",
238+
example: "6667e095-8b04-4877-b361-f636f459ba42",
239+
}),
237240
}),
241+
summary: "Modify a calendar event.",
238242
}),
239-
summary: "Modify a calendar event.",
240-
}) satisfies FastifyZodOpenApiSchema,
241-
onRequest: async (request, reply) => {
242-
await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]);
243-
},
243+
) satisfies FastifyZodOpenApiSchema,
244+
onRequest: fastify.authorizeFromSchema,
244245
},
245246
async (request, reply) => {
246247
if (!request.username) {
@@ -361,24 +362,25 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
361362
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().delete(
362363
"/:id",
363364
{
364-
schema: withTags(["Events"], {
365-
params: z.object({
366-
id: z.string().min(1).openapi({
367-
description: "Event ID to delete.",
368-
example: "6667e095-8b04-4877-b361-f636f459ba42",
365+
schema: withRoles(
366+
[AppRoles.EVENTS_MANAGER],
367+
withTags(["Events"], {
368+
params: z.object({
369+
id: z.string().min(1).openapi({
370+
description: "Event ID to delete.",
371+
example: "6667e095-8b04-4877-b361-f636f459ba42",
372+
}),
369373
}),
374+
// response: {
375+
// 201: z.object({
376+
// id: z.string(),
377+
// resource: z.string(),
378+
// }),
379+
// },
380+
summary: "Delete a calendar event.",
370381
}),
371-
// response: {
372-
// 201: z.object({
373-
// id: z.string(),
374-
// resource: z.string(),
375-
// }),
376-
// },
377-
summary: "Delete a calendar event.",
378-
}) satisfies FastifyZodOpenApiSchema,
379-
onRequest: async (request, reply) => {
380-
await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]);
381-
},
382+
) satisfies FastifyZodOpenApiSchema,
383+
onRequest: fastify.authorizeFromSchema,
382384
},
383385
async (request, reply) => {
384386
const id = request.params.id;

src/api/routes/iam.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,14 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
7979
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().patch(
8080
"/profile",
8181
{
82-
schema: withTags(["IAM"], {
83-
body: entraProfilePatchRequest,
84-
summary: "Update user's profile.",
85-
}),
86-
onRequest: async (request, reply) => {
87-
await fastify.authorize(request, reply, []);
88-
},
82+
schema: withRoles(
83+
[],
84+
withTags(["IAM"], {
85+
body: entraProfilePatchRequest,
86+
summary: "Update user's profile.",
87+
}),
88+
),
89+
onRequest: fastify.authorizeFromSchema,
8990
},
9091
async (request, reply) => {
9192
if (!request.tokenPayload || !request.username) {

src/api/routes/protected.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FastifyPluginAsync } from "fastify";
22
import rateLimiter from "api/plugins/rateLimiter.js";
3-
import { withTags } from "api/components/index.js";
3+
import { withRoles, withTags } from "api/components/index.js";
44

55
const protectedRoute: FastifyPluginAsync = async (fastify, _options) => {
66
await fastify.register(rateLimiter, {
@@ -11,9 +11,12 @@ const protectedRoute: FastifyPluginAsync = async (fastify, _options) => {
1111
fastify.get(
1212
"",
1313
{
14-
schema: withTags(["Generic"], {
15-
summary: "Get a user's username and roles.",
16-
}),
14+
schema: withRoles(
15+
[],
16+
withTags(["Generic"], {
17+
summary: "Get a user's username and roles.",
18+
}),
19+
),
1720
},
1821
async (request, reply) => {
1922
const roles = await fastify.authorize(request, reply, []);

src/api/routes/roomRequests.ts

Lines changed: 52 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { genericConfig, notificationRecipients } from "common/config.js";
2727
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
2828
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
2929
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
30-
import { withTags } from "api/components/index.js";
30+
import { withRoles, withTags } from "api/components/index.js";
3131
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
3232
import { z } from "zod";
3333

@@ -37,29 +37,27 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
3737
duration: 30,
3838
rateLimitIdentifier: "roomRequests",
3939
});
40-
fastify.post<{
41-
Body: RoomRequestStatusUpdatePostBody;
42-
Params: { requestId: string; semesterId: string };
43-
}>(
40+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
4441
"/:semesterId/:requestId/status",
4542
{
46-
schema: withTags(["Room Requests"], {
47-
summary: "Create status update for a room request.",
48-
params: z.object({
49-
requestId: z.string().min(1).openapi({
50-
description: "Room request ID.",
51-
example: "6667e095-8b04-4877-b361-f636f459ba42",
52-
}),
53-
semesterId: z.string().min(1).openapi({
54-
description: "Short semester slug for a given semester.",
55-
example: "sp25",
43+
schema: withRoles(
44+
[AppRoles.ROOM_REQUEST_UPDATE],
45+
withTags(["Room Requests"], {
46+
summary: "Create status update for a room request.",
47+
params: z.object({
48+
requestId: z.string().min(1).openapi({
49+
description: "Room request ID.",
50+
example: "6667e095-8b04-4877-b361-f636f459ba42",
51+
}),
52+
semesterId: z.string().min(1).openapi({
53+
description: "Short semester slug for a given semester.",
54+
example: "sp25",
55+
}),
5656
}),
57+
body: roomRequestStatusUpdateRequest,
5758
}),
58-
body: roomRequestStatusUpdateRequest,
59-
}),
60-
onRequest: async (request, reply) => {
61-
await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_UPDATE]);
62-
},
59+
),
60+
onRequest: fastify.authorizeFromSchema,
6361
},
6462
async (request, reply) => {
6563
if (!request.username) {
@@ -154,18 +152,19 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
154152
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
155153
"/:semesterId",
156154
{
157-
schema: withTags(["Room Requests"], {
158-
summary: "Get room requests for a specific semester.",
159-
params: z.object({
160-
semesterId: z.string().min(1).openapi({
161-
description: "Short semester slug for a given semester.",
162-
example: "sp25",
155+
schema: withRoles(
156+
[AppRoles.ROOM_REQUEST_CREATE],
157+
withTags(["Room Requests"], {
158+
summary: "Get room requests for a specific semester.",
159+
params: z.object({
160+
semesterId: z.string().min(1).openapi({
161+
description: "Short semester slug for a given semester.",
162+
example: "sp25",
163+
}),
163164
}),
164165
}),
165-
}),
166-
onRequest: async (request, reply) => {
167-
await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]);
168-
},
166+
),
167+
onRequest: fastify.authorizeFromSchema,
169168
},
170169
async (request, reply) => {
171170
const semesterId = request.params.semesterId;
@@ -252,13 +251,14 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
252251
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
253252
"",
254253
{
255-
schema: withTags(["Room Requests"], {
256-
summary: "Create a room request.",
257-
body: roomRequestSchema,
258-
}),
259-
onRequest: async (request, reply) => {
260-
await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]);
261-
},
254+
schema: withRoles(
255+
[AppRoles.ROOM_REQUEST_CREATE],
256+
withTags(["Room Requests"], {
257+
summary: "Create a room request.",
258+
body: roomRequestSchema,
259+
}),
260+
),
261+
onRequest: fastify.authorizeFromSchema,
262262
},
263263
async (request, reply) => {
264264
const requestId = request.id;
@@ -350,22 +350,23 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
350350
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
351351
"/:semesterId/:requestId",
352352
{
353-
schema: withTags(["Room Requests"], {
354-
summary: "Get specific room request data.",
355-
params: z.object({
356-
requestId: z.string().min(1).openapi({
357-
description: "Room request ID.",
358-
example: "6667e095-8b04-4877-b361-f636f459ba42",
359-
}),
360-
semesterId: z.string().min(1).openapi({
361-
description: "Short semester slug for a given semester.",
362-
example: "sp25",
353+
schema: withRoles(
354+
[AppRoles.ROOM_REQUEST_CREATE],
355+
withTags(["Room Requests"], {
356+
summary: "Get specific room request data.",
357+
params: z.object({
358+
requestId: z.string().min(1).openapi({
359+
description: "Room request ID.",
360+
example: "6667e095-8b04-4877-b361-f636f459ba42",
361+
}),
362+
semesterId: z.string().min(1).openapi({
363+
description: "Short semester slug for a given semester.",
364+
example: "sp25",
365+
}),
363366
}),
364367
}),
365-
}),
366-
onRequest: async (request, reply) => {
367-
await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]);
368-
},
368+
),
369+
onRequest: fastify.authorizeFromSchema,
369370
},
370371
async (request, reply) => {
371372
const requestId = request.params.requestId;

src/api/routes/stripe.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
ScanCommand,
55
} from "@aws-sdk/client-dynamodb";
66
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
7-
import { withTags } from "api/components/index.js";
7+
import { withRoles, withTags } from "api/components/index.js";
88
import { createAuditLogEntry } from "api/functions/auditLog.js";
99
import {
1010
createStripeLink,
@@ -36,12 +36,13 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
3636
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
3737
"/paymentLinks",
3838
{
39-
schema: withTags(["Stripe"], {
40-
summary: "Get available Stripe payment links.",
41-
}),
42-
onRequest: async (request, reply) => {
43-
await fastify.authorize(request, reply, [AppRoles.STRIPE_LINK_CREATOR]);
44-
},
39+
schema: withRoles(
40+
[AppRoles.STRIPE_LINK_CREATOR],
41+
withTags(["Stripe"], {
42+
summary: "Get available Stripe payment links.",
43+
}),
44+
),
45+
onRequest: fastify.authorizeFromSchema,
4546
},
4647
async (request, reply) => {
4748
let dynamoCommand;
@@ -91,13 +92,14 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
9192
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
9293
"/paymentLinks",
9394
{
94-
schema: withTags(["Stripe"], {
95-
summary: "Create a Stripe payment link.",
96-
body: invoiceLinkPostRequestSchema,
97-
}),
98-
onRequest: async (request, reply) => {
99-
await fastify.authorize(request, reply, [AppRoles.STRIPE_LINK_CREATOR]);
100-
},
95+
schema: withRoles(
96+
[AppRoles.STRIPE_LINK_CREATOR],
97+
withTags(["Stripe"], {
98+
summary: "Create a Stripe payment link.",
99+
body: invoiceLinkPostRequestSchema,
100+
}),
101+
),
102+
onRequest: fastify.authorizeFromSchema,
101103
},
102104
async (request, reply) => {
103105
if (!request.username) {

tests/live/documentation.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { expect, test } from "vitest";
2+
3+
const baseEndpoint = `https://core.aws.qa.acmuiuc.org`;
4+
5+
test("Get OpenAPI JSON", async () => {
6+
const response = await fetch(`${baseEndpoint}/api/documentation/json`);
7+
expect(response.status).toBe(200);
8+
9+
const responseDataJson = await response.json();
10+
expect(responseDataJson).toHaveProperty("openapi");
11+
expect(responseDataJson["openapi"]).toEqual("3.0.3");
12+
});
13+
14+
test("Get OpenAPI UI", async () => {
15+
const response = await fetch(`${baseEndpoint}/api/documentation`);
16+
expect(response.status).toBe(200);
17+
const contentType = response.headers.get("content-type");
18+
expect(contentType).toContain("text/html");
19+
});

0 commit comments

Comments
 (0)