fix: allow aws signature to work with query params (#5231)

Co-authored-by: Twix <twix@macbookpro.home>
Co-authored-by: Anwarul Islam <anwaarulislaam@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Rafael Laidlaw 2025-07-27 10:23:15 -04:00 committed by GitHub
parent e5bb60f8b3
commit 913be953c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 530 additions and 42 deletions

View file

@ -0,0 +1,431 @@
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"
import { makeRESTRequest } from "@hoppscotch/data"
import {
generateAwsSignatureAuthHeaders,
generateAwsSignatureAuthParams,
} from "../aws-signature"
import type { HoppRESTAuth, Environment } from "@hoppscotch/data"
vi.mock("aws4fetch", () => ({
AwsV4Signer: vi.fn().mockImplementation((config) => ({
sign: vi.fn().mockResolvedValue({
headers: new Map([
[
"Authorization",
"AWS4-HMAC-SHA256 Credential=test-key/20240101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=test-signature",
],
["X-Amz-Date", "20240101T120000Z"],
["Host", "s3.amazonaws.com"],
]),
url: new URL(config.url),
}),
})),
}))
vi.mock("~/helpers/utils/EffectiveURL", () => ({
getFinalBodyFromRequest: vi.fn().mockReturnValue("test body"),
}))
describe("AWS Signature Auth", () => {
const mockEnvVars: Environment["variables"] = [
{
key: "AWS_ACCESS_KEY",
secret: false,
initialValue: "test-access-key",
currentValue: "test-access-key",
},
{
key: "AWS_SECRET_KEY",
secret: true,
initialValue: "test-secret-key",
currentValue: "test-secret-key",
},
{
key: "AWS_REGION",
secret: false,
initialValue: "us-east-1",
currentValue: "us-east-1",
},
{
key: "AWS_SERVICE",
secret: false,
initialValue: "s3",
currentValue: "s3",
},
]
// Helper function to create base auth configuration
const createBaseAuth = (
overrides: Partial<HoppRESTAuth & { authType: "aws-signature" }> = {}
): HoppRESTAuth & { authType: "aws-signature" } => ({
authType: "aws-signature",
authActive: true,
addTo: "HEADERS",
accessKey: "test-access-key",
secretKey: "test-secret-key",
region: "us-east-1",
serviceName: "s3",
serviceToken: "",
...overrides,
})
// Helper function to create base request
const createBaseRequest = (
overrides: Partial<Parameters<typeof makeRESTRequest>[0]> = {}
) => {
const baseRequest: Parameters<typeof makeRESTRequest>[0] = {
method: "GET",
endpoint: "https://s3.amazonaws.com/bucket/key",
name: "Test Request",
params: [],
headers: [],
preRequestScript: "",
testScript: "",
auth: {
authType: "inherit",
authActive: true,
},
body: {
contentType: null,
body: null,
},
requestVariables: [],
responses: {},
}
return makeRESTRequest({ ...baseRequest, ...overrides })
}
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date("2024-01-01T12:00:00Z"))
})
afterEach(() => {
vi.useRealTimers()
})
describe("generateAwsSignatureAuthHeaders", () => {
test("should return empty array when addTo is not HEADERS", async () => {
const auth = createBaseAuth({ addTo: "QUERY_PARAMS" })
const request = createBaseRequest()
const result = await generateAwsSignatureAuthHeaders(
auth,
request,
mockEnvVars
)
expect(result).toEqual([])
})
test("should generate AWS signature headers correctly", async () => {
const auth = createBaseAuth() // uses default HEADERS addTo
const request = createBaseRequest()
const result = await generateAwsSignatureAuthHeaders(
auth,
request,
mockEnvVars
)
expect(result).toHaveLength(3)
expect(result).toEqual([
{
active: true,
key: "Authorization",
value:
"AWS4-HMAC-SHA256 Credential=test-key/20240101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=test-signature",
description: "",
},
{
active: true,
key: "X-Amz-Date",
value: "20240101T120000Z",
description: "",
},
{
active: true,
key: "Host",
value: "s3.amazonaws.com",
description: "",
},
])
})
test("should parse template strings for auth parameters", async () => {
const auth = createBaseAuth({
accessKey: "<<AWS_ACCESS_KEY>>",
secretKey: "<<AWS_SECRET_KEY>>",
region: "<<AWS_REGION>>",
serviceName: "<<AWS_SERVICE>>",
})
const request = createBaseRequest()
const result = await generateAwsSignatureAuthHeaders(
auth,
request,
mockEnvVars
)
expect(result).toHaveLength(3)
})
test("should handle request parameters and sort them alphabetically", async () => {
const auth = createBaseAuth()
const request = createBaseRequest({
params: [
{ active: true, key: "z-param", value: "value1", description: "" },
{ active: true, key: "a-param", value: "value2", description: "" },
{ active: false, key: "inactive", value: "value3", description: "" },
{ active: true, key: "", value: "empty-key", description: "" },
],
})
const result = await generateAwsSignatureAuthHeaders(
auth,
request,
mockEnvVars
)
expect(result).toHaveLength(3)
})
test("should handle session token when provided", async () => {
const auth = createBaseAuth({ serviceToken: "test-session-token" })
const request = createBaseRequest()
const result = await generateAwsSignatureAuthHeaders(
auth,
request,
mockEnvVars
)
expect(result).toHaveLength(3)
})
test("should default to us-east-1 region when region is empty", async () => {
const auth = createBaseAuth({ region: "" })
const request = createBaseRequest()
const result = await generateAwsSignatureAuthHeaders(
auth,
request,
mockEnvVars
)
expect(result).toHaveLength(3)
})
})
describe("generateAwsSignatureAuthParams", () => {
test("should return empty array when addTo is not QUERY_PARAMS", async () => {
const auth = createBaseAuth({ addTo: "HEADERS" })
const request = createBaseRequest()
const result = await generateAwsSignatureAuthParams(
auth,
request,
mockEnvVars
)
expect(result).toEqual([])
})
test("should generate AWS signature query parameters correctly", async () => {
const { AwsV4Signer } = await import("aws4fetch")
vi.mocked(AwsV4Signer).mockImplementation(
(config) =>
({
sign: vi.fn().mockResolvedValue({
headers: new Map(),
url: new URL(
config.url +
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test-key%2F20240101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20240101T120000Z&X-Amz-SignedHeaders=host&X-Amz-Signature=test-signature"
),
}),
}) as any
)
const auth = createBaseAuth({ addTo: "QUERY_PARAMS" })
const request = createBaseRequest()
const result = await generateAwsSignatureAuthParams(
auth,
request,
mockEnvVars
)
expect(result).toHaveLength(5)
expect(result).toEqual([
{
active: true,
key: "X-Amz-Algorithm",
value: "AWS4-HMAC-SHA256",
description: "",
},
{
active: true,
key: "X-Amz-Credential",
value: "test-key/20240101/us-east-1/s3/aws4_request",
description: "",
},
{
active: true,
key: "X-Amz-Date",
value: "20240101T120000Z",
description: "",
},
{
active: true,
key: "X-Amz-SignedHeaders",
value: "host",
description: "",
},
{
active: true,
key: "X-Amz-Signature",
value: "test-signature",
description: "",
},
])
})
test("should exclude original request parameters from result", async () => {
const { AwsV4Signer } = await import("aws4fetch")
vi.mocked(AwsV4Signer).mockImplementation(
(config) =>
({
sign: vi.fn().mockResolvedValue({
headers: new Map(),
url: new URL(
config.url +
"?original-param=value&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature"
),
}),
}) as any
)
const auth = createBaseAuth({ addTo: "QUERY_PARAMS" })
const request = createBaseRequest({
params: [
{
active: true,
key: "original-param",
value: "value",
description: "",
},
],
})
const result = await generateAwsSignatureAuthParams(
auth,
request,
mockEnvVars
)
// only return AWS signature parameters, not the original parameter
expect(result).toHaveLength(2)
expect(result.find((p) => p.key === "original-param")).toBeUndefined()
expect(result.find((p) => p.key === "X-Amz-Algorithm")).toBeDefined()
expect(result.find((p) => p.key === "X-Amz-Signature")).toBeDefined()
})
test("should handle template strings in endpoint", async () => {
const { AwsV4Signer } = await import("aws4fetch")
vi.mocked(AwsV4Signer).mockImplementation(
(config) =>
({
sign: vi.fn().mockResolvedValue({
headers: new Map(),
url: new URL(
config.url +
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature"
),
}),
}) as any
)
const auth = createBaseAuth({ addTo: "QUERY_PARAMS" })
const envVarsWithHost: Environment["variables"] = [
...mockEnvVars,
{
key: "HOST",
secret: false,
initialValue: "s3.amazonaws.com",
currentValue: "s3.amazonaws.com",
},
]
const request = createBaseRequest({
endpoint: "https://<<HOST>>/bucket/key",
})
const result = await generateAwsSignatureAuthParams(
auth,
request,
envVarsWithHost
)
expect(result).toHaveLength(2)
})
test("should sort existing parameters alphabetically before signing", async () => {
const { AwsV4Signer } = await import("aws4fetch")
vi.mocked(AwsV4Signer).mockImplementation(
(config) =>
({
sign: vi.fn().mockResolvedValue({
headers: new Map(),
url: new URL(
config.url +
"?z-param=value1&a-param=value2&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature"
),
}),
}) as any
)
const auth = createBaseAuth({ addTo: "QUERY_PARAMS" })
const request = createBaseRequest({
params: [
{ active: true, key: "z-param", value: "value1", description: "" },
{ active: true, key: "a-param", value: "value2", description: "" },
{ active: false, key: "inactive", value: "value3", description: "" },
],
})
const result = await generateAwsSignatureAuthParams(
auth,
request,
mockEnvVars
)
// exclude original parameters and only return AWS signature parameters
expect(result).toHaveLength(2)
expect(result.find((p) => p.key === "z-param")).toBeUndefined()
expect(result.find((p) => p.key === "a-param")).toBeUndefined()
expect(result.find((p) => p.key === "inactive")).toBeUndefined()
})
test("should handle empty or missing session token", async () => {
const { AwsV4Signer } = await import("aws4fetch")
vi.mocked(AwsV4Signer).mockImplementation(
(config) =>
({
sign: vi.fn().mockResolvedValue({
headers: new Map(),
url: new URL(
config.url +
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature"
),
}),
}) as any
)
const auth = createBaseAuth({ addTo: "QUERY_PARAMS" })
const request = createBaseRequest()
const result = await generateAwsSignatureAuthParams(
auth,
request,
mockEnvVars
)
expect(result).toHaveLength(2)
})
})
})

View file

@ -3,12 +3,90 @@ import {
HoppRESTAuth,
HoppRESTHeader,
HoppRESTParam,
HoppRESTParams,
HoppRESTRequest,
parseTemplateString,
} from "@hoppscotch/data"
import { AwsV4Signer } from "aws4fetch"
import { getFinalBodyFromRequest } from "~/helpers/utils/EffectiveURL"
type SignOptions = {
auth: HoppRESTAuth & { authType: "aws-signature" }
request: HoppRESTRequest
envVars: Environment["variables"]
signQuery?: boolean
}
function processQueryParameters(
params: HoppRESTParams,
envVars: Environment["variables"],
baseUrl: string
): { url: URL; sortedParams: Array<{ key: string; value: string }> } {
const url = new URL(baseUrl)
// add existing query parameters from the request in lexicographical order as per AWS documentation
const sortedParams = params
.filter((param) => param.active && param.key !== "")
.map((param) => ({
key: parseTemplateString(param.key, envVars),
value: parseTemplateString(param.value, envVars),
}))
.sort((a, b) => a.key.localeCompare(b.key))
sortedParams.forEach((param) => {
url.searchParams.append(param.key, param.value)
})
return { url, sortedParams }
}
async function signAWSRequest({
auth,
request,
envVars,
signQuery = false,
}: SignOptions) {
const currentDate = new Date()
const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "")
const baseUrl = parseTemplateString(request.endpoint, envVars)
const { url, sortedParams } = processQueryParameters(
request.params,
envVars,
baseUrl
)
const accessKeyId = parseTemplateString(auth.accessKey, envVars)
const secretAccessKey = parseTemplateString(auth.secretKey, envVars)
const region = parseTemplateString(auth.region, envVars) ?? "us-east-1"
const service = parseTemplateString(auth.serviceName, envVars)
const sessionToken = auth.serviceToken
? parseTemplateString(auth.serviceToken, envVars)
: undefined
const signerConfig: ConstructorParameters<typeof AwsV4Signer>[0] = {
method: request.method,
datetime: amzDate,
accessKeyId,
secretAccessKey,
region,
service,
sessionToken,
url: url.toString(),
signQuery,
}
if (!signQuery) {
const body = getFinalBodyFromRequest(request, envVars)
signerConfig.body = body?.toString()
}
const signer = new AwsV4Signer(signerConfig)
const sign = await signer.sign()
return { sign, sortedParams }
}
export async function generateAwsSignatureAuthHeaders(
auth: HoppRESTAuth & { authType: "aws-signature" },
request: HoppRESTRequest,
@ -16,33 +94,19 @@ export async function generateAwsSignatureAuthHeaders(
): Promise<HoppRESTHeader[]> {
if (auth.addTo !== "HEADERS") return []
const currentDate = new Date()
const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "")
const { method, endpoint } = request
const body = getFinalBodyFromRequest(request, envVars)
const signer = new AwsV4Signer({
method: method,
body: body?.toString(),
datetime: amzDate,
accessKeyId: parseTemplateString(auth.accessKey, envVars),
secretAccessKey: parseTemplateString(auth.secretKey, envVars),
region: parseTemplateString(auth.region, envVars) ?? "us-east-1",
service: parseTemplateString(auth.serviceName, envVars),
sessionToken:
auth.serviceToken && parseTemplateString(auth.serviceToken, envVars),
url: parseTemplateString(endpoint, envVars),
const { sign } = await signAWSRequest({
auth,
request,
envVars,
signQuery: false,
})
const sign = await signer.sign()
const headers: HoppRESTHeader[] = []
sign.headers.forEach((value, key) => {
headers.push({
active: true,
key: key,
value: value,
key,
value,
description: "",
})
})
@ -57,32 +121,25 @@ export async function generateAwsSignatureAuthParams(
): Promise<HoppRESTParam[]> {
if (auth.addTo !== "QUERY_PARAMS") return []
const currentDate = new Date()
const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "")
const signer = new AwsV4Signer({
method: request.method,
datetime: amzDate,
const { sign, sortedParams } = await signAWSRequest({
auth,
request,
envVars,
signQuery: true,
accessKeyId: parseTemplateString(auth.accessKey, envVars),
secretAccessKey: parseTemplateString(auth.secretKey, envVars),
region: parseTemplateString(auth.region, envVars) ?? "us-east-1",
service: parseTemplateString(auth.serviceName, envVars),
sessionToken:
auth.serviceToken && parseTemplateString(auth.serviceToken, envVars),
url: parseTemplateString(request.endpoint, envVars),
})
const sign = await signer.sign()
const params: HoppRESTParam[] = []
const originalParams = new Set(sortedParams.map((param) => param.key))
for (const [key, value] of sign.url.searchParams) {
params.push({
active: true,
key: key,
value: value,
description: "",
})
if (!originalParams.has(key)) {
params.push({
active: true,
key,
value,
description: "",
})
}
}
return params