diff --git a/api.planx.uk/modules/auth/middleware.ts b/api.planx.uk/modules/auth/middleware.ts index cb313ae57d..041ee5ea29 100644 --- a/api.planx.uk/modules/auth/middleware.ts +++ b/api.planx.uk/modules/auth/middleware.ts @@ -171,7 +171,7 @@ export const useRoleAuth: UseRoleAuth = }); }; -// Convenience methods +// Convenience methods for role-based access export const useTeamViewerAuth = useRoleAuth([ "teamViewer", "teamEditor", @@ -179,3 +179,15 @@ export const useTeamViewerAuth = useRoleAuth([ ]); export const useTeamEditorAuth = useRoleAuth(["teamEditor", "platformAdmin"]); export const usePlatformAdminAuth = useRoleAuth(["platformAdmin"]); + +/** + * Allow any logged in user to access route, without checking roles + */ +export const useLoginAuth: RequestHandler = (req, res, next) => useJWT(req, res, () => ( + req?.user?.sub + ? next() + : next({ + status: 401, + message: "No authorization token was found", + }) +)); \ No newline at end of file diff --git a/api.planx.uk/modules/auth/service.ts b/api.planx.uk/modules/auth/service.ts index e6201b1170..db8dd538f1 100644 --- a/api.planx.uk/modules/auth/service.ts +++ b/api.planx.uk/modules/auth/service.ts @@ -8,6 +8,7 @@ export const buildJWT = async (email: string): Promise => { const data = { sub: user.id.toString(), + email, "https://hasura.io/jwt/claims": generateHasuraClaimsForUser(user), }; diff --git a/api.planx.uk/modules/misc/controller.ts b/api.planx.uk/modules/misc/controller.ts new file mode 100644 index 0000000000..8090efb5d3 --- /dev/null +++ b/api.planx.uk/modules/misc/controller.ts @@ -0,0 +1,20 @@ +import { RequestHandler } from "express"; +import { getClient } from "../../client"; +import { userContext } from "../auth/middleware"; +import { ServerError } from "../../errors"; + +export const getLoggedInUserDetails: RequestHandler = async (_req, res, next) => { + try { + const $client = getClient(); + + const email = userContext.getStore()?.user.email + if (!email) throw new ServerError({ message: "User email missing from request", status: 400 }) + + const user = await $client.user.getByEmail(email); + if (!user) throw new ServerError({ message: `Unable to locate user with email ${email}`, status: 400 }) + + res.json(user); + } catch (error) { + next(error) + } +}; \ No newline at end of file diff --git a/api.planx.uk/modules/misc/docs.yaml b/api.planx.uk/modules/misc/docs.yaml new file mode 100644 index 0000000000..452955b95c --- /dev/null +++ b/api.planx.uk/modules/misc/docs.yaml @@ -0,0 +1,54 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +tags: + - name: misc + description: Miscellaneous +paths: + /me: + get: + summary: Get information about currently logged in user + tags: + - misc + security: + - userJWT: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + id: + type: integer + format: int32 + example: 123 + firstName: + type: string + example: Albert + lastName: + type: string + example: Einstein + email: + type: string + example: albert@princeton.edu + isPlatformAdmin: + type: boolean + example: true + teams: + type: array + items: + type: object + properties: + teamId: + type: integer + format: int32 + example: 123 + role: + type: string + enum: ["teamEditor", "teamViewer"] + example: "teamEditor" + '401': + $ref: '#/components/responses/Unauthorised' diff --git a/api.planx.uk/modules/misc/routes.test.ts b/api.planx.uk/modules/misc/routes.test.ts new file mode 100644 index 0000000000..0fa5be05f7 --- /dev/null +++ b/api.planx.uk/modules/misc/routes.test.ts @@ -0,0 +1,103 @@ +import supertest from "supertest"; +import app from "../../server"; +import { authHeader, getJWT } from "../../tests/mockJWT"; +import { userContext } from "../auth/middleware"; + +const getStoreMock = jest.spyOn(userContext, "getStore"); + +const mockGetByEmail = jest.fn().mockResolvedValue({ + id: 36, + firstName: "Albert", + lastName: "Einstein", + email: "albert@princeton.edu", + isPlatformAdmin: true, + teams: [ + { + teamId: 1, + role: "teamEditor" + }, + { + teamId: 24, + role: "teamEditor" + } + ] +}); + +jest.mock("@opensystemslab/planx-core", () => { + return { + CoreDomainClient: jest.fn().mockImplementation(() => ({ + user: { + getByEmail: () => mockGetByEmail(), + } + })) + } +}); + +describe("/me endpoint", () => { + beforeEach(() => { + getStoreMock.mockReturnValue({ + user: { + sub: "123", + email: "test@opensystemslab.io", + jwt: getJWT({ role: "teamEditor" }) + } + }); + }); + + it("returns an error if authorization headers are not set", async () => { + await supertest(app) + .get("/me") + .expect(401) + .then((res) => { + expect(res.body).toEqual({ + error: "No authorization token was found", + }); + }); + }); + + it("returns an error for invalid user context", async () => { + getStoreMock.mockReturnValue({ + user: { + sub: "123", + email: undefined, + jwt: getJWT({ role: "teamEditor" }) + } + }); + + await supertest(app) + .get("/me") + .set(authHeader({ role: "teamEditor" })) + .expect(400) + .then((res) => { + expect(res.body).toEqual({ + error: "User email missing from request", + }); + }); + }); + + it("returns an error for an invalid email address", async () => { + mockGetByEmail.mockResolvedValueOnce(null) + + await supertest(app) + .get("/me") + .set(authHeader({ role: "teamEditor" })) + .expect(400) + .then((res) => { + expect(res.body).toEqual({ + error: "Unable to locate user with email test@opensystemslab.io", + }); + }); + }); + + + it("returns user details for a logged in user", async () => { + await supertest(app) + .get("/me") + .set(authHeader({ role: "teamEditor" })) + .expect(200) + .then((res) => { + expect(res.body).toHaveProperty("email", "albert@princeton.edu"); + expect(res.body.teams).toHaveLength(2); + }); + }); +}); diff --git a/api.planx.uk/modules/misc/routes.ts b/api.planx.uk/modules/misc/routes.ts new file mode 100644 index 0000000000..89962da319 --- /dev/null +++ b/api.planx.uk/modules/misc/routes.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import { useLoginAuth } from "../auth/middleware"; +import { getLoggedInUserDetails } from "./controller"; + +const router = Router(); + +router.get("/me", useLoginAuth, getLoggedInUserDetails); + +export default router; \ No newline at end of file diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 8b7b574e72..e548140a98 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -79,6 +79,7 @@ import { getSessionSummary } from "./admin/session/summary"; import { googleStrategy } from "./modules/auth/strategy/google"; import authRoutes from "./modules/auth/routes"; import teamRoutes from "./modules/team/routes"; +import miscRoutes from "./modules/misc/routes"; import { useSwaggerDocs } from "./docs"; import { Role } from "@opensystemslab/planx-core/types"; @@ -192,6 +193,7 @@ app.use(passport.session()); app.use(urlencoded({ extended: true })); app.use(authRoutes); +app.use(miscRoutes); app.use("/team", teamRoutes); app.use("/gis", router); @@ -211,72 +213,6 @@ app.get("/hasura", async function (_req, res, next) { } }); -/** - * @swagger - * /me: - * get: - * summary: Get information about currently logged in user - * tags: - * - misc - * security: - * - userJWT: [] - * responses: - * '401': - * $ref: '#/components/responses/Unauthorised' - * '200': - * description: OK - * content: - * application/json: - * schema: - * type: object - * properties: - * id: - * type: integer - * format: int32 - * example: 123 - * first_name: - * type: string - * example: Albert - * last_name: - * type: string - * example: Einstein - * email: - * type: string - * example: albert@princeton.edu - * created_at: - * type: string - * example: 2020-08-11T11:28:38.237493+00:00 - * updated_at: - * type: string - * example: 2023-08-11T11:28:38.237493+00:00 - */ -app.get("/me", usePlatformAdminAuth, async function (req, res, next) { - try { - const user = await adminClient.request( - gql` - query ($id: Int!) { - users_by_pk(id: $id) { - id - first_name - last_name - email - created_at - updated_at - } - } - `, - { id: req.user?.sub }, - ); - - if (!user.users_by_pk) - next({ status: 404, message: `User (${req.user?.sub}) not found` }); - - res.json(user.users_by_pk); - } catch (err) { - next(err); - } -}); - app.get("/gis", (_req, _res, next) => { next({ status: 400, @@ -616,6 +552,7 @@ declare global { interface User { jwt: string; sub?: string; + email?: string; "https://hasura.io/jwt/claims"?: { "x-hasura-allowed-roles": Role[]; }; diff --git a/api.planx.uk/tests/mockJWT.ts b/api.planx.uk/tests/mockJWT.ts index 706c834b12..5e90671e6f 100644 --- a/api.planx.uk/tests/mockJWT.ts +++ b/api.planx.uk/tests/mockJWT.ts @@ -4,6 +4,7 @@ import { sign } from "jsonwebtoken"; function getJWT({ role }: { role: Role }) { const data = { sub: "123", + email: "test@opensystemslab.io", "https://hasura.io/jwt/claims": { "x-hasura-allowed-roles": [role], "x-hasura-default-role": role,