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:
parent
e5bb60f8b3
commit
913be953c3
2 changed files with 530 additions and 42 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue