feat(common): enhance HAWK authentication with payload hash calculation (#5067)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
2232f0291c
commit
3980847983
3 changed files with 112 additions and 23 deletions
|
|
@ -121,15 +121,14 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TODO: implement include payload hash -->
|
||||
<!-- <div class="px-4 my-6">
|
||||
<div class="px-4 my-6">
|
||||
<HoppSmartCheckbox
|
||||
:on="auth.includePayloadHash"
|
||||
@change="auth.includePayloadHash = !auth.includePayloadHash"
|
||||
>
|
||||
Include Payload Hash
|
||||
</HoppSmartCheckbox>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
|
|||
|
|
@ -248,7 +248,14 @@ export const getComputedAuthHeaders = async (
|
|||
})
|
||||
}
|
||||
} else if (request.auth.authType === "hawk") {
|
||||
const { method, endpoint } = req as HoppRESTRequest
|
||||
const { method, endpoint, body } = req as HoppRESTRequest
|
||||
|
||||
// Get the body content for payload hash calculation
|
||||
const payload = getFinalBodyFromRequest(
|
||||
req as HoppRESTRequest,
|
||||
envVars,
|
||||
showKeyIfSecret
|
||||
)
|
||||
|
||||
const hawkHeader = await calculateHawkHeader({
|
||||
url: parseTemplateString(endpoint, envVars), // URL
|
||||
|
|
@ -257,6 +264,10 @@ export const getComputedAuthHeaders = async (
|
|||
key: parseTemplateString(request.auth.authKey, envVars),
|
||||
algorithm: request.auth.algorithm,
|
||||
|
||||
// Add content type and payload
|
||||
contentType: body.contentType,
|
||||
payload,
|
||||
|
||||
// advanced parameters (optional)
|
||||
includePayloadHash: request.auth.includePayloadHash,
|
||||
nonce: request.auth.nonce
|
||||
|
|
@ -608,7 +619,8 @@ function getFinalBodyFromRequest(
|
|||
// we split array blobs into separate entries (FormData will then join them together during exec)
|
||||
arrayFlatMap((x) =>
|
||||
x.isFile
|
||||
? x.value.map((v) => ({
|
||||
? // @ts-expect-error TODO: Fix this type error
|
||||
x.value.map((v) => ({
|
||||
key: parseTemplateString(x.key, envVariables),
|
||||
value: v as string | Blob,
|
||||
contentType: x.contentType,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { HoppRESTRequest } from "../rest"
|
||||
|
||||
interface HawkOptions {
|
||||
id: string
|
||||
key: string
|
||||
algorithm: "sha256" | "sha1"
|
||||
method: string
|
||||
url: string
|
||||
includePayloadHash: boolean
|
||||
payload?: string | FormData | File | null
|
||||
|
||||
// Optional parameters
|
||||
user?: string
|
||||
|
|
@ -14,6 +14,11 @@ interface HawkOptions {
|
|||
app?: string
|
||||
dlg?: string
|
||||
timestamp?: number
|
||||
|
||||
// Payload options
|
||||
includePayloadHash: boolean
|
||||
payload?: string | FormData | File | null | Blob
|
||||
contentType?: HoppRESTRequest["body"]["contentType"]
|
||||
}
|
||||
|
||||
async function generateNonce(length: number = 6): Promise<string> {
|
||||
|
|
@ -31,6 +36,19 @@ function sha256Hash(data: string): Promise<ArrayBuffer> {
|
|||
return crypto.subtle.digest("SHA-256", dataBuffer)
|
||||
}
|
||||
|
||||
function sha1Hash(data: string): Promise<ArrayBuffer> {
|
||||
const encoder = new TextEncoder()
|
||||
const dataBuffer = encoder.encode(data)
|
||||
return crypto.subtle.digest("SHA-1", dataBuffer)
|
||||
}
|
||||
|
||||
async function hashData(
|
||||
data: string,
|
||||
algorithm: "sha256" | "sha1"
|
||||
): Promise<ArrayBuffer> {
|
||||
return algorithm === "sha256" ? sha256Hash(data) : sha1Hash(data)
|
||||
}
|
||||
|
||||
async function hmacSign(
|
||||
key: string,
|
||||
message: string,
|
||||
|
|
@ -59,24 +77,74 @@ async function hmacSign(
|
|||
return btoa(String.fromCharCode.apply(null, [...new Uint8Array(signature)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize line endings to '\n' to ensure consistent hash generation
|
||||
*/
|
||||
function normalizeLineEndings(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of a payload for hash calculation
|
||||
* This function needs to exactly match the server's payload hash calculation
|
||||
*/
|
||||
async function getPayloadContent(
|
||||
payload: string | FormData | File | null
|
||||
payload: string | FormData | File | Blob | null,
|
||||
contentType: string
|
||||
): Promise<string> {
|
||||
if (!payload) return ""
|
||||
|
||||
// For form data
|
||||
if (payload instanceof FormData) {
|
||||
const pairs: string[] = []
|
||||
payload.forEach((value, key) => {
|
||||
pairs.push(`${key}=${value}`)
|
||||
})
|
||||
return pairs.join("&")
|
||||
if (contentType === "multipart/form-data") {
|
||||
// For multipart form data, we need to extract the parts
|
||||
const parts: string[] = []
|
||||
payload.forEach((value, key) => {
|
||||
if (typeof value === "string") {
|
||||
parts.push(`${key}=${value}`)
|
||||
} else {
|
||||
// For file parts, use the file name
|
||||
parts.push(`${key}=${value instanceof File ? value.name : "blob"}`)
|
||||
}
|
||||
})
|
||||
return normalizeLineEndings(parts.join("&"))
|
||||
} else {
|
||||
// For url-encoded form data
|
||||
const pairs: string[] = []
|
||||
payload.forEach((value, key) => {
|
||||
if (typeof value === "string") {
|
||||
pairs.push(`${key}=${encodeURIComponent(value)}`)
|
||||
}
|
||||
})
|
||||
return normalizeLineEndings(pairs.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
if (payload instanceof File) {
|
||||
return await payload.text()
|
||||
// For blob/file types
|
||||
if (payload instanceof Blob) {
|
||||
try {
|
||||
const text = await payload.text()
|
||||
return normalizeLineEndings(text)
|
||||
} catch (e) {
|
||||
console.error("Failed to read blob content", e)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
return payload.toString()
|
||||
// Handle JSON specifically
|
||||
if (contentType.includes("application/json") && typeof payload === "string") {
|
||||
try {
|
||||
// Parse and re-stringify to ensure consistent formatting
|
||||
const jsonObj = JSON.parse(payload)
|
||||
return normalizeLineEndings(JSON.stringify(jsonObj))
|
||||
} catch (e) {
|
||||
// If not valid JSON, use as-is
|
||||
return normalizeLineEndings(payload.toString())
|
||||
}
|
||||
}
|
||||
|
||||
// Default: convert to string and normalize line endings
|
||||
return normalizeLineEndings(payload.toString())
|
||||
}
|
||||
|
||||
export async function calculateHawkHeader(
|
||||
|
|
@ -113,13 +181,23 @@ export async function calculateHawkHeader(
|
|||
|
||||
// Calculate payload hash if needed
|
||||
if (options.includePayloadHash && options.payload) {
|
||||
const content = await getPayloadContent(options.payload)
|
||||
const contentType = "text/plain"
|
||||
const hashBase = `hawk.1.payload\n${contentType}\n${content}\n`
|
||||
const contentHash = await sha256Hash(hashBase)
|
||||
artifacts.hash = btoa(
|
||||
String.fromCharCode.apply(null, [...new Uint8Array(contentHash)])
|
||||
)
|
||||
try {
|
||||
const contentType = options.contentType || "text/plain"
|
||||
const content = await getPayloadContent(options.payload, contentType)
|
||||
|
||||
// Create the normalized payload string as per HAWK spec
|
||||
const normalizedPayload = `hawk.1.payload\n${contentType}\n${content}\n`
|
||||
|
||||
// Hash the normalized payload
|
||||
const contentHash = await hashData(normalizedPayload, options.algorithm)
|
||||
|
||||
// Convert hash to base64
|
||||
artifacts.hash = btoa(
|
||||
String.fromCharCode.apply(null, [...new Uint8Array(contentHash)])
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error calculating payload hash:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the string to sign according to Hawk spec
|
||||
|
|
|
|||
Loading…
Reference in a new issue