From 88713b05961ec763341df25aea9b62b91cb58446 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Wed, 10 Sep 2025 16:10:57 -0400 Subject: [PATCH] Handle IAM auth better --- .../src/authentication.test.ts | 395 ++++++++++++++++++ .../src/authentication.ts | 156 +++++++ .../src/node-server.ts | 70 +--- 3 files changed, 566 insertions(+), 55 deletions(-) create mode 100644 packages/graph-explorer-proxy-server/src/authentication.test.ts create mode 100644 packages/graph-explorer-proxy-server/src/authentication.ts diff --git a/packages/graph-explorer-proxy-server/src/authentication.test.ts b/packages/graph-explorer-proxy-server/src/authentication.test.ts new file mode 100644 index 000000000..03fc0d5c5 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/authentication.test.ts @@ -0,0 +1,395 @@ +import { signRequest } from "./authentication.js"; +import aws4 from "aws4"; +import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; + +// Mock the AWS SDK credential provider +vi.mock("@aws-sdk/credential-providers", () => ({ + fromNodeProviderChain: vi.fn(), +})); + +// Mock aws4 +vi.mock("aws4", () => ({ + default: { + sign: vi.fn(), + }, +})); + +const mockCredentialProvider = vi.mocked(fromNodeProviderChain); +const mockAws4Sign = vi.mocked(aws4.sign); + +describe("signRequest", () => { + const mockCredentials = { + accessKeyId: "test-access-key", + secretAccessKey: "test-secret-key", + sessionToken: "test-session-token", + }; + + const testUrl = new URL("https://example.com/path?query=value"); + const testRequest = { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ test: "data" }), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("when IAM options are not provided", () => { + it("should return the original request unchanged", async () => { + const result = await signRequest(testUrl, testRequest); + + expect(result).toBe(testRequest); + expect(mockCredentialProvider).not.toHaveBeenCalled(); + expect(mockAws4Sign).not.toHaveBeenCalled(); + }); + }); + + describe("when IAM options are provided", () => { + const iamOptions = { + service: "neptune-db", + region: "us-east-1", + }; + + beforeEach(() => { + const mockProvider = vi.fn().mockResolvedValue(mockCredentials); + mockCredentialProvider.mockReturnValue(mockProvider); + + mockAws4Sign.mockReturnValue({ + headers: { + Authorization: "AWS4-HMAC-SHA256 Credential=...", + "X-Amz-Date": "20231201T120000Z", + }, + }); + }); + + it("should sign the request with AWS credentials", async () => { + mockAws4Sign.mockReturnValue({ + body: '{"test":"data"}', // Mock the transformed body + headers: { + Authorization: "AWS4-HMAC-SHA256 Credential=...", + "X-Amz-Date": "20231201T120000Z", + }, + }); + + const result = await signRequest(testUrl, testRequest, iamOptions); + + expect(mockCredentialProvider).toHaveBeenCalled(); + expect(mockAws4Sign).toHaveBeenCalledWith( + { + host: "example.com", + path: "/path?query=value", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: '{"test":"data"}', + service: "neptune-db", + region: "us-east-1", + }, + { + accessKeyId: "test-access-key", + secretAccessKey: "test-secret-key", + sessionToken: "test-session-token", + } + ); + + expect(result).toEqual({ + ...testRequest, + body: '{"test":"data"}', // Should return the transformed body + headers: { + "Content-Type": "application/json", + Authorization: "AWS4-HMAC-SHA256 Credential=...", + "X-Amz-Date": "20231201T120000Z", + }, + }); + }); + + it("should handle credentials without session token", async () => { + const credsWithoutToken = { + accessKeyId: "test-access-key", + secretAccessKey: "test-secret-key", + }; + + const mockProvider = vi.fn().mockResolvedValue(credsWithoutToken); + mockCredentialProvider.mockReturnValue(mockProvider); + + await signRequest(testUrl, testRequest, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith(expect.any(Object), { + accessKeyId: "test-access-key", + secretAccessKey: "test-secret-key", + }); + }); + + it("should handle GET requests without body", async () => { + const getRequest = { + method: "GET", + headers: { Accept: "application/json" }, + }; + + await signRequest(testUrl, getRequest, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + body: undefined, + }), + expect.any(Object) + ); + }); + + it("should handle requests without headers", async () => { + const requestWithoutHeaders = { + method: "POST", + body: "test body", + }; + + await signRequest(testUrl, requestWithoutHeaders, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ + headers: undefined, + }), + expect.any(Object) + ); + }); + + it("should throw error when credentials cannot be found", async () => { + const mockProvider = vi.fn().mockResolvedValue(undefined); + mockCredentialProvider.mockReturnValue(mockProvider); + + await expect( + signRequest(testUrl, testRequest, iamOptions) + ).rejects.toThrow( + "IAM is enabled but credentials cannot be found on the credential provider chain." + ); + }); + }); + + describe("body transformation and return", () => { + const iamOptions = { service: "neptune-db", region: "us-east-1" }; + + beforeEach(() => { + const mockProvider = vi.fn().mockResolvedValue(mockCredentials); + mockCredentialProvider.mockReturnValue(mockProvider); + }); + + it("should return transformed URLSearchParams body", async () => { + const params = new URLSearchParams(); + params.append("key", "value"); + const request = { method: "POST", body: params }; + + mockAws4Sign.mockReturnValue({ + body: "key=value", + headers: { Authorization: "test" }, + }); + + const result = await signRequest(testUrl, request, iamOptions); + + expect(result.body).toBe("key=value"); + }); + + it("should return transformed FormData body", async () => { + const formData = new FormData(); + formData.append("key1", "value1"); + formData.append("key2", "value2"); + const request = { method: "POST", body: formData }; + + mockAws4Sign.mockReturnValue({ + body: "key1=value1&key2=value2", + headers: { Authorization: "test" }, + }); + + const result = await signRequest(testUrl, request, iamOptions); + + expect(result.body).toBe("key1=value1&key2=value2"); + }); + + it("should return transformed Blob body", async () => { + const blob = new Blob(["blob content"], { type: "text/plain" }); + const request = { method: "POST", body: blob }; + + mockAws4Sign.mockReturnValue({ + body: "blob content", + headers: { Authorization: "test" }, + }); + + const result = await signRequest(testUrl, request, iamOptions); + + expect(result.body).toBe("blob content"); + }); + }); + + describe("mapToCompatibleBody", () => { + // We need to test the private function indirectly through signRequest + const iamOptions = { service: "neptune-db", region: "us-east-1" }; + + beforeEach(() => { + const mockProvider = vi.fn().mockResolvedValue(mockCredentials); + mockCredentialProvider.mockReturnValue(mockProvider); + mockAws4Sign.mockReturnValue({ headers: {} }); + }); + + it("should handle string body", async () => { + const request = { method: "POST", body: "string body" }; + + await signRequest(testUrl, request, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ body: "string body" }), + expect.any(Object) + ); + }); + + it("should handle URLSearchParams body", async () => { + const params = new URLSearchParams(); + params.append("key", "value"); + const request = { method: "POST", body: params }; + + await signRequest(testUrl, request, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ body: "key=value" }), + expect.any(Object) + ); + }); + + it("should handle Buffer body", async () => { + const buffer = Buffer.from("buffer content"); + const request = { method: "POST", body: buffer }; + + await signRequest(testUrl, request, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ body: buffer }), + expect.any(Object) + ); + }); + + it("should handle Blob body", async () => { + const blob = new Blob(["blob content"], { type: "text/plain" }); + const request = { method: "POST", body: blob }; + + await signRequest(testUrl, request, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ body: "blob content" }), + expect.any(Object) + ); + }); + + it("should handle null body", async () => { + const request = { method: "POST", body: null }; + + await signRequest(testUrl, request, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ body: undefined }), + expect.any(Object) + ); + }); + + it("should handle undefined body", async () => { + const request = { method: "GET" }; + + await signRequest(testUrl, request, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ body: undefined }), + expect.any(Object) + ); + }); + + it("should handle FormData body", async () => { + const formData = new FormData(); + formData.append("key1", "value1"); + formData.append("key2", "value2"); + const request = { method: "POST", body: formData }; + + await signRequest(testUrl, request, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ body: "key1=value1&key2=value2" }), + expect.any(Object) + ); + }); + + it("should throw error for FormData with File", async () => { + const formData = new FormData(); + const file = new File(["content"], "test.txt", { type: "text/plain" }); + formData.append("file", file); + const request = { method: "POST", body: formData }; + + await expect(signRequest(testUrl, request, iamOptions)).rejects.toThrow( + "File uploads are not supported." + ); + }); + + it("should handle object body with JSON.stringify fallback", async () => { + const objectBody = { key: "value", nested: { prop: 123 } }; + const request = { method: "POST", body: objectBody as any }; + + await signRequest(testUrl, request, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ + body: '{"key":"value","nested":{"prop":123}}', + }), + expect.any(Object) + ); + }); + }); + + describe("URL handling", () => { + const iamOptions = { service: "neptune-db", region: "us-east-1" }; + + beforeEach(() => { + const mockProvider = vi.fn().mockResolvedValue(mockCredentials); + mockCredentialProvider.mockReturnValue(mockProvider); + mockAws4Sign.mockReturnValue({ headers: {} }); + }); + + it("should handle URL with query parameters", async () => { + const urlWithQuery = new URL( + "https://example.com/path?param1=value1¶m2=value2" + ); + + await signRequest(urlWithQuery, { method: "GET" }, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ + host: "example.com", + path: "/path?param1=value1¶m2=value2", + }), + expect.any(Object) + ); + }); + + it("should handle URL without query parameters", async () => { + const urlWithoutQuery = new URL("https://example.com/path"); + + await signRequest(urlWithoutQuery, { method: "GET" }, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ + host: "example.com", + path: "/path", + }), + expect.any(Object) + ); + }); + + it("should handle URL with port", async () => { + const urlWithPort = new URL("https://example.com:8182/gremlin"); + + await signRequest(urlWithPort, { method: "POST" }, iamOptions); + + expect(mockAws4Sign).toHaveBeenCalledWith( + expect.objectContaining({ + host: "example.com:8182", + path: "/gremlin", + }), + expect.any(Object) + ); + }); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/authentication.ts b/packages/graph-explorer-proxy-server/src/authentication.ts new file mode 100644 index 000000000..ce42f866e --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/authentication.ts @@ -0,0 +1,156 @@ +import aws4 from "aws4"; +import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; +import type { HeadersInit, RequestInit } from "node-fetch"; + +type IamOptions = Pick; + +/** + * Signs an HTTP request using AWS IAM credentials for authentication. + * + * This function takes a URL and request configuration, and if IAM options are provided, + * it signs the request using AWS Signature Version 4 (SigV4) authentication. The signing + * process adds the necessary authentication headers to make authenticated requests to + * AWS services or other services that support AWS IAM authentication. + * + * @param url - The target URL for the HTTP request + * @param request - The request configuration object containing method, headers, body, etc. + * @param iamOptions - Optional IAM configuration specifying the AWS service and region. + * If not provided, the request is returned unmodified. + * @returns A promise that resolves to a new RequestInit object with AWS authentication + * headers added (if IAM options were provided) + * + * @example + * ```typescript + * const url = new URL('https://my-service.us-east-1.amazonaws.com/api/data'); + * const request = { + * method: 'POST', + * body: JSON.stringify({ key: 'value' }), + * headers: { 'Content-Type': 'application/json' } + * }; + * const iamOptions = { service: 'execute-api', region: 'us-east-1' }; + * + * const signedRequest = await signRequest(url, request, iamOptions); + * // signedRequest now contains AWS authentication headers + * ``` + * + * @throws {Error} When IAM options are provided but credentials cannot be retrieved + * @throws {Error} When the request body contains unsupported File uploads in FormData + */ +export async function signRequest( + url: URL, + request: RequestInit, + iamOptions?: IamOptions +): Promise { + if (!iamOptions) { + // Don't modify the request if not using IAM credentials + return request; + } + + // Convert the node-fetch RequestInit body to an aws4.Request body + const body = await mapToCompatibleBody(request.body); + + // Create the AWS signing compatible request object + const requestOptions: aws4.Request = { + host: url.host, + path: url.pathname + url.search, + method: request.method || "GET", + headers: request.headers ? { ...request.headers } : undefined, + body: body, + service: iamOptions.service, + region: iamOptions.region, + }; + + // Sign the request + const creds = await getIamCredentials(); + const signedRequest = aws4.sign(requestOptions, { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + ...(creds.sessionToken && { sessionToken: creds.sessionToken }), + }); + + // Combine the original request with the headers from the signed request + return { + ...request, + body: signedRequest.body, + headers: { + ...request.headers, + ...signedRequest.headers, + } as unknown as HeadersInit, + }; +} + +/** + * Retrieves IAM credentials from the AWS credential provider chain. + * + * This function uses the AWS SDK's fromNodeProviderChain() to automatically + * discover credentials from various sources in the following order: + * 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) + * 2. Shared credentials file (~/.aws/credentials) + * 3. EC2 instance metadata service + * 4. ECS task metadata service + * 5. Other configured credential sources + * + * @returns A promise that resolves to AWS credentials containing accessKeyId, + * secretAccessKey, and optionally sessionToken + * @throws {Error} When IAM is enabled but no credentials can be found in the + * credential provider chain + */ +async function getIamCredentials() { + const credentialProvider = fromNodeProviderChain(); + const creds = await credentialProvider(); + if (creds === undefined) { + throw new Error( + "IAM is enabled but credentials cannot be found on the credential provider chain." + ); + } + return creds; +} + +/** + * Converts a node-fetch RequestInit body to a format compatible with aws4.Request body. + * + * @param body - The request body from node-fetch RequestInit + * @returns A promise that resolves to a string representation of the body or undefined + */ +async function mapToCompatibleBody( + body: RequestInit["body"] +): Promise { + // Return undefined for null or undefined bodies + if (!body) { + return undefined; + } + + // String bodies can be used directly + if (typeof body === "string") { + return body; + } + + // Convert URLSearchParams to string representation + if (body instanceof URLSearchParams) { + return body.toString(); + } + + if (body instanceof FormData) { + const params = new URLSearchParams(); + for (const [key, value] of body.entries()) { + if (value instanceof File) { + throw new Error("File uploads are not supported."); + } + params.append(key, value); + } + return params.toString(); + } + + // Convert Buffer to string + if (body instanceof Buffer) { + return body; + } + + // Convert Blob to text string + if (body instanceof Blob) { + return await body.text(); + } + + // Fallback: stringify any other object as JSON + return JSON.stringify(body); +} diff --git a/packages/graph-explorer-proxy-server/src/node-server.ts b/packages/graph-explorer-proxy-server/src/node-server.ts index 4308f2669..fb32d689a 100644 --- a/packages/graph-explorer-proxy-server/src/node-server.ts +++ b/packages/graph-explorer-proxy-server/src/node-server.ts @@ -6,14 +6,13 @@ import https from "https"; import bodyParser from "body-parser"; import fs from "fs"; import path from "path"; -import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; -import aws4 from "aws4"; import type { IncomingHttpHeaders } from "http"; import { logger as proxyLogger, requestLoggingMiddleware } from "./logging.js"; import { clientRoot, proxyServerRoot } from "./paths.js"; import { errorHandlingMiddleware, handleError } from "./error-handler.js"; import { BooleanStringSchema, env } from "./env.js"; import { pipeline } from "stream"; +import { signRequest } from "./authentication.js"; const app = express(); @@ -34,29 +33,10 @@ interface LoggerIncomingHttpHeaders extends IncomingHttpHeaders { app.use(requestLoggingMiddleware()); -// Function to get IAM headers for AWS4 signing process. -async function getIAMHeaders(options: string | aws4.Request) { - const credentialProvider = fromNodeProviderChain(); - const creds = await credentialProvider(); - if (creds === undefined) { - throw new Error( - "IAM is enabled but credentials cannot be found on the credential provider chain." - ); - } - - const headers = aws4.sign(options, { - accessKeyId: creds.accessKeyId, - secretAccessKey: creds.secretAccessKey, - ...(creds.sessionToken && { sessionToken: creds.sessionToken }), - }); - - return headers; -} - // Function to retry fetch requests with exponential backoff. const retryFetch = async ( url: URL, - options: any, + options: RequestInit, isIamEnabled: boolean, region: string | undefined, serviceType: string, @@ -64,41 +44,20 @@ const retryFetch = async ( refetchMaxRetries = 1 ) => { for (let i = 0; i < refetchMaxRetries; i++) { - if (isIamEnabled) { - const data = await getIAMHeaders({ - host: url.hostname, - port: url.port, - path: url.pathname + url.search, - service: serviceType, - region, - method: options.method, - body: options.body ?? undefined, - }); - - options = { - host: url.hostname, - port: url.port, - path: url.pathname + url.search, - service: serviceType, - region, - method: options.method, - body: options.body ?? undefined, - headers: data.headers, - }; - } - options = { - host: url.hostname, - port: url.port, - path: url.pathname + url.search, - service: serviceType, - method: options.method, - body: options.body ?? undefined, - headers: options.headers, + const iamOptions = isIamEnabled + ? { + service: serviceType, + region: region, + } + : undefined; + const request: RequestInit = { + ...options, compress: false, // prevent automatic decompression }; + const signedRequest = await signRequest(url, request, iamOptions); try { - const res = await fetch(url.href, options); + const res = await fetch(url, signedRequest); if (!res.ok) { proxyLogger.error("!!Request failure!!"); return res; @@ -269,8 +228,9 @@ app.post("/sparql", async (req, res, next) => { const requestOptions = { method: "POST", headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/sparql-results+json", + "Content-Type": + headers["content-type"] ?? "application/x-www-form-urlencoded", + Accept: headers.accept ?? "application/sparql-results+json", }, body, };