import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import * as AuthUtils from "../../src/utils/auth"; import * as Auth from "../../src/middlewares/auth"; import { DecodedIdToken } from "firebase-admin/auth"; import { NextFunction, Request, Response } from "express"; import { getCachedConfiguration } from "../../src/init/configuration"; import * as ApeKeys from "../../src/dal/ape-keys"; import { ObjectId } from "mongodb"; import { hashSync } from "bcrypt"; import MonkeyError from "../../src/utils/error"; import * as Misc from "../../src/utils/misc"; import crypto from "crypto"; import { EndpointMetadata, RequestAuthenticationOptions, } from "@monkeytype/contracts/util/api"; import * as Prometheus from "../../src/utils/prometheus"; import { TsRestRequestWithContext } from "../../src/api/types"; import { enableMonkeyErrorExpects } from "../__testData__/monkey-error"; enableMonkeyErrorExpects(); const mockDecodedToken: DecodedIdToken = { uid: "123456789", email: "newuser@mail.com", iat: 0, } as DecodedIdToken; vi.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken); const mockApeKey = { _id: new ObjectId(), uid: "123", name: "test", hash: hashSync("key", 5), createdOn: Date.now(), modifiedOn: Date.now(), lastUsedOn: Date.now(), useCount: 0, enabled: true, }; vi.spyOn(ApeKeys, "getApeKey").mockResolvedValue(mockApeKey); vi.spyOn(ApeKeys, "updateLastUsedOn").mockResolvedValue(); const isDevModeMock = vi.spyOn(Misc, "isDevEnvironment"); let mockRequest: Partial; let mockResponse: Partial; let nextFunction: NextFunction; describe("middlewares/auth", () => { beforeEach(async () => { isDevModeMock.mockReturnValue(true); let config = await getCachedConfiguration(true); config.apeKeys.acceptKeys = true; mockRequest = { baseUrl: "/api/v1", route: { path: "/", }, headers: { authorization: "Bearer 123456789", }, ctx: { configuration: config, decodedToken: { type: "None", uid: "", email: "", }, }, }; mockResponse = { json: vi.fn(), }; nextFunction = vi.fn((error) => { if (error) { throw error; } return "Next function called"; }) as unknown as NextFunction; }); afterEach(() => { isDevModeMock.mockClear(); }); describe("authenticateTsRestRequest", () => { const prometheusRecordAuthTimeMock = vi.spyOn(Prometheus, "recordAuthTime"); const prometheusIncrementAuthMock = vi.spyOn(Prometheus, "incrementAuth"); const timingSafeEqualMock = vi.spyOn(crypto, "timingSafeEqual"); beforeEach(() => { timingSafeEqualMock.mockClear().mockReturnValue(true); [prometheusIncrementAuthMock, prometheusRecordAuthTimeMock].forEach( (it) => it.mockClear(), ); }); it("should fail if token is not fresh", async () => { //GIVEN Date.now = vi.fn(() => 60001); const expectedError = new MonkeyError( 401, "Unauthorized\nStack: This endpoint requires a fresh token", ); //WHEN await expect(() => authenticate({}, { requireFreshToken: true }), ).rejects.toMatchMonkeyError(expectedError); //THEN expect(nextFunction).toHaveBeenLastCalledWith( expect.toMatchMonkeyError(expectedError), ); expect(prometheusIncrementAuthMock).not.toHaveBeenCalled(); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); }); it("should allow the request if token is fresh", async () => { //GIVEN Date.now = vi.fn(() => 10000); //WHEN const result = await authenticate({}, { requireFreshToken: true }); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("Bearer"); expect(decodedToken?.email).toBe(mockDecodedToken.email); expect(decodedToken?.uid).toBe(mockDecodedToken.uid); expect(nextFunction).toHaveBeenCalledOnce(); expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("Bearer"); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); }); it("should allow the request if apeKey is supported", async () => { //WHEN const result = await authenticate( { headers: { authorization: "ApeKey aWQua2V5" } }, { acceptApeKeys: true }, ); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("ApeKey"); expect(decodedToken?.email).toBe(""); expect(decodedToken?.uid).toBe("123"); expect(nextFunction).toHaveBeenCalledTimes(1); }); it("should fail with apeKey if apeKey is not supported", async () => { //WHEN await expect(() => authenticate( { headers: { authorization: "ApeKey aWQua2V5" } }, { acceptApeKeys: false }, ), ).rejects.toThrow("This endpoint does not accept ApeKeys"); //THEN }); it("should fail with apeKey if apeKeys are disabled", async () => { //GIVEN //@ts-expect-error mockRequest.ctx.configuration.apeKeys.acceptKeys = false; //WHEN await expect(() => authenticate( { headers: { authorization: "ApeKey aWQua2V5" } }, { acceptApeKeys: false }, ), ).rejects.toThrow("ApeKeys are not being accepted at this time"); //THEN }); it("should allow the request with authentation on public endpoint", async () => { //WHEN const result = await authenticate({}, { isPublic: true }); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("Bearer"); expect(decodedToken?.email).toBe(mockDecodedToken.email); expect(decodedToken?.uid).toBe(mockDecodedToken.uid); expect(nextFunction).toHaveBeenCalledTimes(1); }); it("should allow the request without authentication on public endpoint", async () => { //WHEN const result = await authenticate({ headers: {} }, { isPublic: true }); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("None"); expect(decodedToken?.email).toBe(""); expect(decodedToken?.uid).toBe(""); expect(nextFunction).toHaveBeenCalledTimes(1); expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("None"); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); }); it("should allow the request with apeKey on public endpoint", async () => { //WHEN const result = await authenticate( { headers: { authorization: "ApeKey aWQua2V5" } }, { isPublic: true }, ); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("ApeKey"); expect(decodedToken?.email).toBe(""); expect(decodedToken?.uid).toBe("123"); expect(nextFunction).toHaveBeenCalledTimes(1); expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey"); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); }); it("should allow request with Uid on dev", async () => { //WHEN const result = await authenticate({ headers: { authorization: "Uid 123" }, }); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("Bearer"); expect(decodedToken?.email).toBe(""); expect(decodedToken?.uid).toBe("123"); expect(nextFunction).toHaveBeenCalledTimes(1); }); it("should allow request with Uid and email on dev", async () => { const result = await authenticate({ headers: { authorization: "Uid 123|test@example.com" }, }); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("Bearer"); expect(decodedToken?.email).toBe("test@example.com"); expect(decodedToken?.uid).toBe("123"); expect(nextFunction).toHaveBeenCalledTimes(1); }); it("should fail request with Uid on non-dev", async () => { //GIVEN isDevModeMock.mockReturnValue(false); //WHEN / THEN await expect(() => authenticate({ headers: { authorization: "Uid 123" } }), ).rejects.toMatchMonkeyError( new MonkeyError(401, "Bearer type uid is not supported"), ); }); it("should fail without authentication", async () => { await expect(() => authenticate({ headers: {} })).rejects.toThrow( "Unauthorized\nStack: endpoint: /api/v1 no authorization header found", ); //THEH expect(prometheusIncrementAuthMock).not.toHaveBeenCalled(); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith( "None", "failure", expect.anything(), expect.anything(), ); }); it("should fail with empty authentication", async () => { await expect(() => authenticate({ headers: { authorization: "" } }), ).rejects.toThrow( "Unauthorized\nStack: endpoint: /api/v1 no authorization header found", ); //THEH expect(prometheusIncrementAuthMock).not.toHaveBeenCalled(); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith( "", "failure", expect.anything(), expect.anything(), ); }); it("should fail with missing authentication token", async () => { await expect(() => authenticate({ headers: { authorization: "Bearer" } }), ).rejects.toThrow( "Missing authentication token\nStack: authenticateWithAuthHeader", ); //THEH expect(prometheusIncrementAuthMock).not.toHaveBeenCalled(); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith( "Bearer", "failure", expect.anything(), expect.anything(), ); }); it("should fail with unknown authentication scheme", async () => { await expect(() => authenticate({ headers: { authorization: "unknown format" } }), ).rejects.toThrow( 'Unknown authentication scheme\nStack: The authentication scheme "unknown" is not implemented', ); //THEH expect(prometheusIncrementAuthMock).not.toHaveBeenCalled(); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith( "unknown", "failure", expect.anything(), expect.anything(), ); }); it("should record country if provided", async () => { const prometheusRecordRequestCountryMock = vi.spyOn( Prometheus, "recordRequestCountry", ); await authenticate( { headers: { "cf-ipcountry": "gb" } }, { isPublic: true }, ); //THEN expect(prometheusRecordRequestCountryMock).toHaveBeenCalledWith( "gb", expect.anything(), ); }); it("should allow the request with authentation on dev public endpoint", async () => { //WHEN const result = await authenticate({}, { isPublicOnDev: true }); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("Bearer"); expect(decodedToken?.email).toBe(mockDecodedToken.email); expect(decodedToken?.uid).toBe(mockDecodedToken.uid); expect(nextFunction).toHaveBeenCalledTimes(1); }); it("should allow the request without authentication on dev public endpoint", async () => { //WHEN const result = await authenticate( { headers: {} }, { isPublicOnDev: true }, ); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("None"); expect(decodedToken?.email).toBe(""); expect(decodedToken?.uid).toBe(""); expect(nextFunction).toHaveBeenCalledTimes(1); expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("None"); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); }); it("should allow the request with apeKey on dev public endpoint", async () => { //WHEN const result = await authenticate( { headers: { authorization: "ApeKey aWQua2V5" } }, { acceptApeKeys: true, isPublicOnDev: true }, ); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("ApeKey"); expect(decodedToken?.email).toBe(""); expect(decodedToken?.uid).toBe("123"); expect(nextFunction).toHaveBeenCalledTimes(1); expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey"); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); }); it("should allow with apeKey if apeKeys are disabled on dev public endpoint", async () => { //GIVEN //@ts-expect-error mockRequest.ctx.configuration.apeKeys.acceptKeys = false; //WHEN const result = await authenticate( { headers: { authorization: "ApeKey aWQua2V5" } }, { acceptApeKeys: true, isPublicOnDev: true }, ); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("ApeKey"); expect(decodedToken?.email).toBe(""); expect(decodedToken?.uid).toBe("123"); expect(nextFunction).toHaveBeenCalledTimes(1); expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey"); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); }); it("should allow the request with authentation on dev public endpoint in production", async () => { //WHEN isDevModeMock.mockReturnValue(false); const result = await authenticate({}, { isPublicOnDev: true }); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("Bearer"); expect(decodedToken?.email).toBe(mockDecodedToken.email); expect(decodedToken?.uid).toBe(mockDecodedToken.uid); expect(nextFunction).toHaveBeenCalledTimes(1); }); it("should fail without authentication on dev public endpoint in production", async () => { //WHEN isDevModeMock.mockReturnValue(false); //THEN await expect(() => authenticate({ headers: {} }, { isPublicOnDev: true }), ).rejects.toThrow("Unauthorized"); }); it("should allow with apeKey on dev public endpoint in production", async () => { //WHEN isDevModeMock.mockReturnValue(false); const result = await authenticate( { headers: { authorization: "ApeKey aWQua2V5" } }, { acceptApeKeys: true, isPublicOnDev: true }, ); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("ApeKey"); expect(decodedToken?.email).toBe(""); expect(decodedToken?.uid).toBe("123"); expect(nextFunction).toHaveBeenCalledTimes(1); expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey"); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); }); it("should allow githubwebhook with header", async () => { vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET"); //WHEN const result = await authenticate( { headers: { "x-hub-signature-256": "the-signature" }, body: { action: "published", release: { id: 1 } }, }, { isGithubWebhook: true }, ); //THEN const decodedToken = result.decodedToken; expect(decodedToken?.type).toBe("GithubWebhook"); expect(decodedToken?.email).toBe(""); expect(decodedToken?.uid).toBe(""); expect(nextFunction).toHaveBeenCalledTimes(1); expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("GithubWebhook"); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); expect(timingSafeEqualMock).toHaveBeenCalledWith( Buffer.from( "sha256=ff0f3080539e9df19153f6b5b5780f66e558d61038e6cf5ecf4efdc7266a7751", ), Buffer.from("the-signature"), ); }); it("should fail githubwebhook with mismatched signature", async () => { vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET"); timingSafeEqualMock.mockReturnValue(false); await expect(() => authenticate( { headers: { "x-hub-signature-256": "the-signature" }, body: { action: "published", release: { id: 1 } }, }, { isGithubWebhook: true }, ), ).rejects.toThrow("Github webhook signature invalid"); //THEH expect(prometheusIncrementAuthMock).not.toHaveBeenCalled(); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith( "None", "failure", expect.anything(), expect.anything(), ); }); it("should fail without header when endpoint is using githubwebhook", async () => { vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET"); await expect(() => authenticate( { headers: {}, body: { action: "published", release: { id: 1 } }, }, { isGithubWebhook: true }, ), ).rejects.toThrow("Missing Github signature header"); //THEH expect(prometheusIncrementAuthMock).not.toHaveBeenCalled(); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith( "None", "failure", expect.anything(), expect.anything(), ); }); it("should fail with missing GITHUB_WEBHOOK_SECRET when endpoint is using githubwebhook", async () => { vi.stubEnv("GITHUB_WEBHOOK_SECRET", ""); await expect(() => authenticate( { headers: { "x-hub-signature-256": "the-signature" }, body: { action: "published", release: { id: 1 } }, }, { isGithubWebhook: true }, ), ).rejects.toThrow("Missing Github Webhook Secret"); //THEH expect(prometheusIncrementAuthMock).not.toHaveBeenCalled(); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith( "None", "failure", expect.anything(), expect.anything(), ); }); it("should throw 500 if something went wrong when validating the signature when endpoint is using githubwebhook", async () => { vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET"); timingSafeEqualMock.mockImplementation(() => { throw new Error("could not validate"); }); await expect(() => authenticate( { headers: { "x-hub-signature-256": "the-signature" }, body: { action: "published", release: { id: 1 } }, }, { isGithubWebhook: true }, ), ).rejects.toThrow( "Failed to authenticate Github webhook: could not validate", ); //THEH expect(prometheusIncrementAuthMock).not.toHaveBeenCalled(); expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith( "None", "failure", expect.anything(), expect.anything(), ); }); }); }); async function authenticate( request: Partial, authenticationOptions?: RequestAuthenticationOptions, ): Promise<{ decodedToken: Auth.DecodedToken }> { const mergedRequest = { ...mockRequest, ...request, tsRestRoute: { metadata: { authenticationOptions } as EndpointMetadata, }, } as any; await Auth.authenticateTsRestRequest()( mergedRequest, mockResponse as Response, nextFunction, ); return { decodedToken: mergedRequest.ctx.decodedToken }; }