api-client/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts
Anwarul Islam 82d9367843
feat: add JWT authentication support (#5079)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2025-05-28 15:41:47 +05:30

825 lines
24 KiB
TypeScript

import {
Environment,
FormDataKeyValue,
HoppRESTAuth,
HoppRESTHeader,
HoppRESTHeaders,
HoppRESTParam,
HoppRESTParams,
HoppRESTReqBody,
HoppRESTRequest,
parseBodyEnvVariables,
parseRawKeyValueEntriesE,
parseTemplateString,
parseTemplateStringE,
} from "@hoppscotch/data"
import { AwsV4Signer } from "aws4fetch"
import * as A from "fp-ts/Array"
import * as E from "fp-ts/Either"
import { flow, pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as RA from "fp-ts/ReadonlyArray"
import * as S from "fp-ts/string"
import qs from "qs"
import { combineLatest, Observable } from "rxjs"
import { map } from "rxjs/operators"
import { arrayFlatMap, arraySort } from "../functional/array"
import { toFormData } from "../functional/formData"
import { tupleWithSameKeysToRecord } from "../functional/record"
import { isJSONContentType } from "./contenttypes"
import { stripComments } from "../editor/linting/jsonc"
import {
DigestAuthParams,
fetchInitialDigestAuthInfo,
generateDigestAuthHeader,
} from "../auth/digest"
import { calculateHawkHeader, generateJWTToken } from "@hoppscotch/data"
export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
/**
* The effective final URL.
*
* This contains path, params and environment variables all applied to it
*/
effectiveFinalURL: string
effectiveFinalHeaders: HoppRESTHeaders
effectiveFinalParams: HoppRESTParams
effectiveFinalBody: FormData | string | null | File | Blob
effectiveFinalRequestVariables: { key: string; value: string }[]
}
/**
* Get headers that can be generated by authorization config of the request
* @param req Request to check
* @param envVars Currently active environment variables
* @param auth Authorization config to check
* @param parse Whether to parse the template strings
* @param showKeyIfSecret Whether to show the key if the value is a secret
* @returns The list of headers
*/
export const getComputedAuthHeaders = async (
envVars: Environment["variables"],
req?:
| HoppRESTRequest
| {
auth: HoppRESTAuth
headers: HoppRESTHeaders
},
auth?: HoppRESTRequest["auth"],
parse = true,
showKeyIfSecret = false
) => {
const request = auth ? { auth: auth ?? { authActive: false } } : req
/**
* Handling Authorization header priority rules:
*
* 1. If a user-defined "Authorization" header exists in the request:
* a. We generally give it priority over auth-generated headers
* b. EXCEPTION: API Key auth that uses a different header name should still be included
*
* 2. We need to check both:
* - req.auth (the current request's auth settings)
* - auth param (possibly inherited auth from a parent collection)
*
* 3. Only return empty array (blocking auth headers) when:
* - Neither req.auth nor auth param is using API Key auth, OR
* - API Key auth is being used but specifically with the "Authorization" header name
* - This prevents API Key auth from being blocked when using custom header names
*/
if (req && req.headers.find((h) => h.key.toLowerCase() === "authorization")) {
// Only return empty array if not using API key auth or if API key is using "authorization" header
if (
(!req.auth ||
req.auth.authType !== "api-key" ||
req.auth.key.toLowerCase() === "authorization") &&
(!auth ||
auth.authType !== "api-key" ||
auth.key.toLowerCase() === "authorization")
) {
return []
}
}
if (!request) return []
if (!request.auth || !request.auth.authActive) return []
const headers: HoppRESTHeader[] = []
// TODO: Support a better b64 implementation than btoa ?
if (request.auth.authType === "basic") {
const username = parse
? parseTemplateString(
request.auth.username,
envVars,
false,
showKeyIfSecret
)
: request.auth.username
const password = parse
? parseTemplateString(
request.auth.password,
envVars,
false,
showKeyIfSecret
)
: request.auth.password
headers.push({
active: true,
key: "Authorization",
value: `Basic ${btoa(`${username}:${password}`)}`,
description: "",
})
} else if (request.auth.authType === "digest") {
const { method, endpoint } = request as HoppRESTRequest
// Step 1: Fetch the initial auth info (nonce, realm, etc.)
const authInfo = await fetchInitialDigestAuthInfo(
parseTemplateString(endpoint, envVars),
method
)
const reqBody = getFinalBodyFromRequest(
req as HoppRESTRequest,
envVars,
showKeyIfSecret
)
// Step 2: Set up the parameters for the digest authentication header
const digestAuthParams: DigestAuthParams = {
username: parseTemplateString(request.auth.username, envVars),
password: parseTemplateString(request.auth.password, envVars),
realm: request.auth.realm
? parseTemplateString(request.auth.realm, envVars)
: authInfo.realm,
nonce: request.auth.nonce
? parseTemplateString(authInfo.nonce, envVars)
: authInfo.nonce,
endpoint: parseTemplateString(endpoint, envVars),
method,
algorithm: request.auth.algorithm ?? authInfo.algorithm,
qop: request.auth.qop
? parseTemplateString(request.auth.qop, envVars)
: authInfo.qop,
opaque: request.auth.opaque
? parseTemplateString(request.auth.opaque, envVars)
: authInfo.opaque,
reqBody: typeof reqBody === "string" ? reqBody : "",
}
// Step 3: Generate the Authorization header
const authHeaderValue = await generateDigestAuthHeader(digestAuthParams)
headers.push({
active: true,
key: "Authorization",
value: authHeaderValue,
description: "",
})
} else if (
request.auth.authType === "bearer" ||
(request.auth.authType === "oauth-2" && request.auth.addTo === "HEADERS")
) {
const token =
request.auth.authType === "bearer"
? request.auth.token
: request.auth.grantTypeInfo.token
headers.push({
active: true,
key: "Authorization",
value: `Bearer ${
parse
? parseTemplateString(token, envVars, false, showKeyIfSecret)
: token
}`,
description: "",
})
} else if (request.auth.authType === "api-key") {
const { key, addTo } = request.auth
if (addTo === "HEADERS" && key) {
headers.push({
active: true,
key: parseTemplateString(key, envVars, false, showKeyIfSecret),
value: parse
? parseTemplateString(
request.auth.value ?? "",
envVars,
false,
showKeyIfSecret
)
: (request.auth.value ?? ""),
description: "",
})
}
} else if (request.auth.authType === "aws-signature") {
const { addTo } = request.auth
if (addTo === "HEADERS") {
const currentDate = new Date()
const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "")
const { method, endpoint } = req as HoppRESTRequest
const body = getFinalBodyFromRequest(request, envVars)
const signer = new AwsV4Signer({
method: method,
body: body?.toString(),
datetime: amzDate,
accessKeyId: parseTemplateString(request.auth.accessKey, envVars),
secretAccessKey: parseTemplateString(request.auth.secretKey, envVars),
region:
parseTemplateString(request.auth.region, envVars) ?? "us-east-1",
service: parseTemplateString(request.auth.serviceName, envVars),
sessionToken:
request.auth.serviceToken &&
parseTemplateString(request.auth.serviceToken, envVars),
url: parseTemplateString(endpoint, envVars),
})
const sign = await signer.sign()
sign.headers.forEach((x, k) => {
headers.push({
active: true,
key: k,
value: x,
description: "",
})
})
}
} else if (request.auth.authType === "hawk") {
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
method: method, // HTTP method
id: parseTemplateString(request.auth.authId, envVars),
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
? parseTemplateString(request.auth.nonce, envVars)
: undefined,
ext: request.auth.ext
? parseTemplateString(request.auth.ext, envVars)
: undefined,
app: request.auth.app
? parseTemplateString(request.auth.app, envVars)
: undefined,
dlg: request.auth.dlg
? parseTemplateString(request.auth.dlg, envVars)
: undefined,
timestamp: request.auth.timestamp
? parseInt(parseTemplateString(request.auth.timestamp, envVars), 10)
: undefined,
})
headers.push({
active: true,
key: "Authorization",
value: hawkHeader,
description: "",
})
} else if (
request.auth.authType === "jwt" &&
request.auth.addTo === "HEADERS"
) {
const token = await generateJWTToken({
algorithm: request.auth.algorithm || "HS256",
secret: parseTemplateString(request.auth.secret, envVars, false),
privateKey: parseTemplateString(request.auth.privateKey, envVars, false),
payload: parseTemplateString(request.auth.payload, envVars, false),
jwtHeaders: parseTemplateString(request.auth.jwtHeaders, envVars, false),
isSecretBase64Encoded: request.auth.isSecretBase64Encoded,
})
if (token) {
// Get prefix (defaults to "Bearer " if not specified)
const headerPrefix = parseTemplateString(
request.auth.headerPrefix,
envVars,
false,
showKeyIfSecret
)
headers.push({
active: true,
key: "Authorization",
value: `${headerPrefix}${token}`,
description: "",
})
}
}
return headers
}
/**
* Get headers that can be generated by body config of the request
* @param req Request to check
* @returns The list of headers
*/
export const getComputedBodyHeaders = (
req:
| HoppRESTRequest
| {
auth: HoppRESTAuth
headers: HoppRESTHeaders
}
): HoppRESTHeader[] => {
// If a content-type is already defined, that will override this
if (
req.headers.find(
(req) => req.active && req.key.toLowerCase() === "content-type"
)
)
return []
if (!("body" in req)) return []
// Body should have a non-null content-type
if (!req.body || req.body.contentType === null) return []
if (
req.body &&
req.body.contentType === "application/octet-stream" &&
req.body.body
) {
const filename = req.body.body.name
const fileType = req.body.body.type
const contentType = fileType ? fileType : "application/octet-stream"
return [
{
active: true,
key: "content-type",
value: contentType,
description: "",
},
{
active: true,
key: "Content-Disposition",
value: `attachment; filename="${filename}"`,
description: "",
},
]
}
return [
{
active: true,
key: "content-type",
value: req.body.contentType,
description: "",
},
]
}
export type ComputedHeader = {
source: "auth" | "body"
header: HoppRESTHeader
}
/**
* Returns a list of headers that will be added during execution of the request
* For e.g, Authorization headers maybe added if an Auth Mode is defined on REST
* @param req The request to check
* @param envVars The environment variables active
* @param parse Whether to parse the template strings
* @param showKeyIfSecret Whether to show the key if the value is a secret
* @returns The headers that are generated along with the source of that header
*/
export const getComputedHeaders = async (
req:
| HoppRESTRequest
| {
auth: HoppRESTAuth
headers: HoppRESTHeaders
},
envVars: Environment["variables"],
parse = true,
showKeyIfSecret = false
): Promise<ComputedHeader[]> => {
return [
...(
await getComputedAuthHeaders(
envVars,
req,
undefined,
parse,
showKeyIfSecret
)
).map((header) => ({
source: "auth" as const,
header,
})),
...getComputedBodyHeaders(req).map((header) => ({
source: "body" as const,
header,
})),
]
}
export type ComputedParam = {
source: "auth"
param: HoppRESTParam
}
/**
* Returns a list of params that will be added during execution of the request
* For e.g, Authorization params (like API-key) maybe added if an Auth Mode is defined on REST
* @param req The request to check
* @param envVars The environment variables active
* @returns The params that are generated along with the source of that header
*/
export const getComputedParams = async (
req: HoppRESTRequest,
envVars: Environment["variables"]
): Promise<ComputedParam[]> => {
// When this gets complex, its best to split this function off (like with getComputedHeaders)
// API-key auth can be added to query params
if (!req.auth || !req.auth.authActive) return []
if (
req.auth.authType !== "api-key" &&
req.auth.authType !== "oauth-2" &&
req.auth.authType !== "aws-signature" &&
req.auth.authType !== "jwt"
)
return []
if (req.auth.addTo !== "QUERY_PARAMS") return []
if (req.auth.authType === "aws-signature") {
const { addTo } = req.auth
const params: ComputedParam[] = []
if (addTo === "QUERY_PARAMS") {
const currentDate = new Date()
const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "")
const signer = new AwsV4Signer({
method: req.method,
datetime: amzDate,
signQuery: true,
accessKeyId: parseTemplateString(req.auth.accessKey, envVars),
secretAccessKey: parseTemplateString(req.auth.secretKey, envVars),
region: parseTemplateString(req.auth.region, envVars) ?? "us-east-1",
service: parseTemplateString(req.auth.serviceName, envVars),
sessionToken:
req.auth.serviceToken &&
parseTemplateString(req.auth.serviceToken, envVars),
url: parseTemplateString(req.endpoint, envVars),
})
const sign = await signer.sign()
for (const [k, v] of sign.url.searchParams) {
params.push({
source: "auth" as const,
param: {
active: true,
key: k,
value: v,
description: "",
},
})
}
}
return params
}
if (req.auth.authType === "api-key") {
return [
{
source: "auth" as const,
param: {
active: true,
key: parseTemplateString(req.auth.key, envVars, false, true),
value: parseTemplateString(req.auth.value, envVars, false, true),
description: "",
},
},
]
}
if (req.auth.authType === "oauth-2") {
const { grantTypeInfo } = req.auth
return [
{
source: "auth",
param: {
active: true,
key: "access_token",
value: parseTemplateString(grantTypeInfo.token, envVars),
description: "",
},
},
]
}
if (req.auth.authType === "jwt") {
const token = await generateJWTToken({
algorithm: req.auth.algorithm || "HS256",
secret: parseTemplateString(req.auth.secret, envVars, false),
privateKey: parseTemplateString(req.auth.privateKey, envVars, false),
payload: parseTemplateString(req.auth.payload, envVars, false),
jwtHeaders: parseTemplateString(req.auth.jwtHeaders, envVars, false),
isSecretBase64Encoded: req.auth.isSecretBase64Encoded,
})
if (token) {
// Get param name (defaults to "token" if not specified)
const paramName = parseTemplateString(req.auth.paramName, envVars)
return [
{
source: "auth",
param: {
active: true,
key: paramName,
value: token,
description: "",
},
},
]
}
return []
}
return []
}
// Resolves environment variables in the body
export const resolvesEnvsInBody = (
body: HoppRESTReqBody,
env: Environment
): HoppRESTReqBody => {
if (!body.contentType) return body
if (body.contentType === "application/octet-stream") {
return body
}
if (body.contentType === "multipart/form-data") {
if (!body.body) {
return {
contentType: null,
body: null,
}
}
return {
contentType: "multipart/form-data",
body: body.body.map(
(entry) =>
<FormDataKeyValue>{
active: entry.active,
isFile: entry.isFile,
key: parseTemplateString(entry.key, env.variables, false, true),
value: entry.isFile
? entry.value
: parseTemplateString(entry.value, env.variables, false, true),
contentType: entry.contentType,
}
),
}
}
let bodyContent = ""
if (isJSONContentType(body.contentType))
bodyContent = stripComments(body.body)
if (body.contentType === "application/x-www-form-urlencoded") {
bodyContent = body.body
}
return {
contentType: body.contentType,
body: parseTemplateString(bodyContent, env.variables, false, true),
}
}
function getFinalBodyFromRequest(
request: HoppRESTRequest,
envVariables: Environment["variables"],
showKeyIfSecret = false
): FormData | Blob | string | null {
if (request.body.contentType === null) return null
if (request.body.contentType === "application/x-www-form-urlencoded") {
const parsedBodyRecord = pipe(
request.body.body ?? "",
parseRawKeyValueEntriesE,
E.map(
flow(
RA.toArray,
/**
* Filtering out empty keys and non-active pairs.
*/
A.filter(({ active, key }) => active && !S.isEmpty(key)),
/**
* Mapping each key-value to template-string-parser with either on array,
* which will be resolved in further steps.
*/
A.map(({ key, value }) => [
parseTemplateStringE(key, envVariables, false, showKeyIfSecret),
parseTemplateStringE(value, envVariables, false, showKeyIfSecret),
]),
/**
* Filtering and mapping only right-eithers for each key-value as [string, string].
*/
A.filterMap(([key, value]) =>
E.isRight(key) && E.isRight(value)
? O.some([key.right, value.right] as [string, string])
: O.none
),
tupleWithSameKeysToRecord,
(obj) => qs.stringify(obj, { indices: false })
)
)
)
return E.isRight(parsedBodyRecord) ? parsedBodyRecord.right : null
}
if (request.body.contentType === "multipart/form-data") {
return pipe(
request.body.body ?? [],
A.filter(
(x) =>
x.key !== "" &&
x.active &&
(typeof x.value === "string" ||
(x.value.length > 0 && x.value[0] instanceof File))
), // Remove empty keys and unsetted file
// Sort files down
arraySort((a, b) => {
if (a.isFile) return 1
if (b.isFile) return -1
return 0
}),
// FormData allows only a single blob in an entry,
// we split array blobs into separate entries (FormData will then join them together during exec)
arrayFlatMap((x) =>
x.isFile
? // @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,
}))
: [
{
key: parseTemplateString(x.key, envVariables),
value: parseTemplateString(x.value, envVariables),
contentType: x.contentType,
},
]
),
toFormData
)
}
if (request.body.contentType === "application/octet-stream") {
return request.body.body
}
let bodyContent = request.body.body ?? ""
if (isJSONContentType(request.body.contentType))
bodyContent = stripComments(request.body.body)
// body can be null if the content-type is not set
return parseBodyEnvVariables(bodyContent, envVariables)
}
/**
* Outputs an executable request format with environment variables applied
*
* @param request The request to source from
* @param environment The environment to apply
* @param showKeyIfSecret Whether to show the key if the value is a secret
*
* @returns An object with extra fields defining a complete request
*/
export async function getEffectiveRESTRequest(
request: HoppRESTRequest,
environment: Environment,
showKeyIfSecret = false
): Promise<EffectiveHoppRESTRequest> {
const effectiveFinalHeaders = pipe(
(await getComputedHeaders(request, environment.variables)).map(
(h) => h.header
),
A.concat(request.headers),
A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({
active: true,
key: parseTemplateString(
x.key,
environment.variables,
false,
showKeyIfSecret
),
value: parseTemplateString(
x.value,
environment.variables,
false,
showKeyIfSecret
),
description: x.description,
}))
)
const effectiveFinalParams = pipe(
(await getComputedParams(request, environment.variables)).map(
(p) => p.param
),
A.concat(request.params),
A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({
active: true,
key: parseTemplateString(
x.key,
environment.variables,
false,
showKeyIfSecret
),
value: parseTemplateString(
x.value,
environment.variables,
false,
showKeyIfSecret
),
description: x.description,
}))
)
const effectiveFinalRequestVariables = pipe(
request.requestVariables,
A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({
active: true,
key: parseTemplateString(x.key, environment.variables),
value: parseTemplateString(x.value, environment.variables),
}))
)
const effectiveFinalBody = getFinalBodyFromRequest(
request,
environment.variables,
showKeyIfSecret
)
return {
...request,
effectiveFinalURL: parseTemplateString(
request.endpoint,
environment.variables,
false,
showKeyIfSecret
),
effectiveFinalHeaders,
effectiveFinalParams,
effectiveFinalBody,
effectiveFinalRequestVariables,
}
}
/**
* Creates an Observable Stream that emits HoppRESTRequests whenever
* the input streams emit a value
*
* @param request$ The request stream containing request data
* @param environment$ The environment stream containing environment data to apply
*
* @returns Observable Stream for the Effective Request Object
*/
export function getEffectiveRESTRequestStream(
request$: Observable<HoppRESTRequest>,
environment$: Observable<Environment>
): Observable<Promise<EffectiveHoppRESTRequest>> {
return combineLatest([request$, environment$]).pipe(
map(async ([request, env]) => await getEffectiveRESTRequest(request, env))
)
}