feat(common): support advanced parameters in OAuth grant types (#5287)

Adds support for advanced parameters in `implicit`, `password`, and `client_credentials` grant types.
This commit is contained in:
Anwarul Islam 2025-08-27 13:35:37 +06:00 committed by jamesgeorge007
parent 6f942a7c30
commit 1df781ec0a
9 changed files with 1677 additions and 1397 deletions

View file

@ -0,0 +1,301 @@
import { HoppGQLAuthOAuth2, HoppRESTAuthOAuth2 } from "@hoppscotch/data"
import { Ref, ref, watch } from "vue"
export type AuthRequestParam = {
id: number
key: string
value: string
active: boolean
sendIn?: "headers" | "url" | "body"
}
export type TokenRequestParam = {
id: number
key: string
value: string
active: boolean
sendIn: "headers" | "url" | "body"
}
let paramsIdCounter = 1000
export const useOAuth2AdvancedParams = (
auth: Ref<HoppRESTAuthOAuth2 | HoppGQLAuthOAuth2>
) => {
// Auth Request Parameters
const workingAuthRequestParams = ref<AuthRequestParam[]>([
{ id: paramsIdCounter++, key: "", value: "", active: true },
])
// Token Request Parameters
const workingTokenRequestParams = ref<TokenRequestParam[]>([
{
id: paramsIdCounter++,
key: "",
value: "",
sendIn: "body",
active: true,
},
])
// Refresh Request Parameters
const workingRefreshRequestParams = ref<TokenRequestParam[]>([
{
id: paramsIdCounter++,
key: "",
value: "",
sendIn: "body",
active: true,
},
])
// Watch for changes in working auth request params
watch(
workingAuthRequestParams,
(newParams: AuthRequestParam[]) => {
// Auto-add empty row when the last row is filled
if (newParams.length > 0 && newParams[newParams.length - 1].key !== "") {
workingAuthRequestParams.value.push({
id: paramsIdCounter++,
key: "",
value: "",
active: true,
})
}
// Update auth.value.grantTypeInfo with non-empty params
const nonEmptyParams = newParams.filter(
(p: AuthRequestParam) => p.key !== "" || p.value !== ""
)
if ("authRequestParams" in auth.value.grantTypeInfo) {
auth.value.grantTypeInfo.authRequestParams = nonEmptyParams.map(
(param) => ({
id: param.id,
key: param.key,
value: param.value,
active: param.active,
})
)
}
},
{ deep: true }
)
// Watch for changes in working token request params
watch(
workingTokenRequestParams,
(newParams: TokenRequestParam[]) => {
// Auto-add empty row when the last row is filled
if (newParams.length > 0 && newParams[newParams.length - 1].key !== "") {
workingTokenRequestParams.value.push({
id: paramsIdCounter++,
key: "",
value: "",
sendIn: "body",
active: true,
})
}
// Update auth.value.grantTypeInfo with non-empty params
const nonEmptyParams = newParams.filter(
(p: TokenRequestParam) => p.key !== "" || p.value !== ""
)
if ("tokenRequestParams" in auth.value.grantTypeInfo) {
auth.value.grantTypeInfo.tokenRequestParams = nonEmptyParams.map(
(param) => ({
id: param.id,
key: param.key,
value: param.value,
sendIn: param.sendIn,
active: param.active,
})
)
}
},
{ deep: true }
)
// Watch for changes in working refresh request params
watch(
workingRefreshRequestParams,
(newParams: TokenRequestParam[]) => {
// Auto-add empty row when the last row is filled
if (newParams.length > 0 && newParams[newParams.length - 1].key !== "") {
workingRefreshRequestParams.value.push({
id: paramsIdCounter++,
key: "",
value: "",
sendIn: "body",
active: true,
})
}
// Update auth.value.grantTypeInfo with non-empty params
const nonEmptyParams = newParams.filter(
(p: TokenRequestParam) => p.key !== "" || p.value !== ""
)
if ("refreshRequestParams" in auth.value.grantTypeInfo) {
auth.value.grantTypeInfo.refreshRequestParams = nonEmptyParams.map(
(param) => ({
id: param.id,
key: param.key,
value: param.value,
sendIn: param.sendIn,
active: param.active,
})
)
}
},
{ deep: true }
)
// Functions for auth request params management
const addAuthRequestParam = () => {
workingAuthRequestParams.value.push({
id: paramsIdCounter++,
key: "",
value: "",
active: true,
})
}
const updateAuthRequestParam = (index: number, payload: AuthRequestParam) => {
workingAuthRequestParams.value[index] = payload
}
const deleteAuthRequestParam = (index: number) => {
if (workingAuthRequestParams.value.length > 1) {
workingAuthRequestParams.value.splice(index, 1)
}
}
// Functions for token request params management
const addTokenRequestParam = () => {
workingTokenRequestParams.value.push({
id: paramsIdCounter++,
key: "",
value: "",
sendIn: "body",
active: true,
})
}
const updateTokenRequestParam = (
index: number,
payload: TokenRequestParam
) => {
workingTokenRequestParams.value[index] = payload
}
const deleteTokenRequestParam = (index: number) => {
if (workingTokenRequestParams.value.length > 1) {
workingTokenRequestParams.value.splice(index, 1)
}
}
// Functions for refresh request params management
const addRefreshRequestParam = () => {
workingRefreshRequestParams.value.push({
id: paramsIdCounter++,
key: "",
value: "",
sendIn: "body",
active: true,
})
}
const updateRefreshRequestParam = (
index: number,
payload: TokenRequestParam
) => {
workingRefreshRequestParams.value[index] = payload
}
const deleteRefreshRequestParam = (index: number) => {
if (workingRefreshRequestParams.value.length > 1) {
workingRefreshRequestParams.value.splice(index, 1)
}
}
// Initialize advanced parameters from the auth object when component mounts
const initializeParams = () => {
if (
"authRequestParams" in auth.value.grantTypeInfo &&
auth.value.grantTypeInfo.authRequestParams &&
auth.value.grantTypeInfo.authRequestParams.length > 0
) {
workingAuthRequestParams.value =
auth.value.grantTypeInfo.authRequestParams.map((param) => ({
id: param.id || paramsIdCounter++,
key: param.key,
value: param.value,
active: param.active,
}))
}
if (
"tokenRequestParams" in auth.value.grantTypeInfo &&
auth.value.grantTypeInfo.tokenRequestParams &&
auth.value.grantTypeInfo.tokenRequestParams.length > 0
) {
workingTokenRequestParams.value = [
...auth.value.grantTypeInfo.tokenRequestParams.map((param) => ({
id: param.id || paramsIdCounter++,
key: param.key,
value: param.value,
sendIn: param.sendIn || "body",
active: param.active,
})),
{
id: paramsIdCounter++,
key: "",
value: "",
sendIn: "body",
active: true,
},
]
}
if (
"refreshRequestParams" in auth.value.grantTypeInfo &&
auth.value.grantTypeInfo.refreshRequestParams &&
auth.value.grantTypeInfo.refreshRequestParams.length > 0
) {
workingRefreshRequestParams.value = [
...auth.value.grantTypeInfo.refreshRequestParams.map((param) => ({
id: param.id || paramsIdCounter++,
key: param.key,
value: param.value,
sendIn: param.sendIn || "body",
active: param.active,
})),
{
id: paramsIdCounter++,
key: "",
value: "",
sendIn: "body",
active: true,
},
]
}
}
return {
workingAuthRequestParams,
workingTokenRequestParams,
workingRefreshRequestParams,
addAuthRequestParam,
updateAuthRequestParam,
deleteAuthRequestParam,
addTokenRequestParam,
updateTokenRequestParam,
deleteTokenRequestParam,
addRefreshRequestParam,
updateRefreshRequestParam,
deleteRefreshRequestParam,
initializeParams,
}
}

View file

@ -0,0 +1,824 @@
import { HoppGQLAuthOAuth2, HoppRESTAuthOAuth2 } from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { Ref, computed } from "vue"
import { z } from "zod"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { refWithCallbackOnChange } from "~/composables/ref"
import {
replaceTemplateString,
replaceTemplateStringsInObjectValues,
} from "~/helpers/auth"
import authCode, {
AuthCodeOauthFlowParams,
AuthCodeOauthRefreshParams,
getDefaultAuthCodeOauthFlowParams,
} from "~/services/oauth/flows/authCode"
import clientCredentials, {
ClientCredentialsFlowParams,
getDefaultClientCredentialsFlowParams,
} from "~/services/oauth/flows/clientCredentials"
import implicit, {
ImplicitOauthFlowParams,
getDefaultImplicitOauthFlowParams,
} from "~/services/oauth/flows/implicit"
import passwordFlow, {
PasswordFlowParams,
getDefaultPasswordFlowParams,
} from "~/services/oauth/flows/password"
import { AuthRequestParam, TokenRequestParam } from "./useOAuth2AdvancedParams"
export type GrantTypes = z.infer<
typeof HoppRESTAuthOAuth2
>["grantTypeInfo"]["grantType"]
export const useOAuth2GrantTypes = (
auth: Ref<HoppRESTAuthOAuth2 | HoppGQLAuthOAuth2>,
setAccessTokenInActiveContext: (
accessToken?: string,
refreshToken?: string
) => void,
workingAuthRequestParams: Ref<AuthRequestParam[]>,
workingTokenRequestParams: Ref<TokenRequestParam[]>,
workingRefreshRequestParams: Ref<TokenRequestParam[]>,
pkceTippyActions: Ref<HTMLElement | null>,
clientAuthenticationTippyActions: Ref<HTMLElement | null>
) => {
const t = useI18n()
const toast = useToast()
// Helper function to prepare request parameters
const prepareRequestParams = (
params: Ref<AuthRequestParam[] | TokenRequestParam[]>
) => {
return params.value
.filter((p) => p.active && p.key && p.value)
.map((p) => ({
id: p.id,
key: replaceTemplateString(p.key),
value: replaceTemplateString(p.value),
active: p.active,
sendIn: p.sendIn || "body",
}))
}
const preparedAuthRequestParams = computed(() => {
return prepareRequestParams(workingAuthRequestParams)
})
const preparedTokenRequestParams = computed(() => {
return prepareRequestParams(workingTokenRequestParams)
})
const preparedRefreshRequestParams = computed(() => {
return prepareRequestParams(workingRefreshRequestParams)
})
const grantTypeMap: Record<
GrantTypes,
"authCode" | "clientCredentials" | "password" | "implicit"
> = {
AUTHORIZATION_CODE: "authCode",
CLIENT_CREDENTIALS: "clientCredentials",
IMPLICIT: "implicit",
PASSWORD: "password",
} as const
const grantTypeDefaultPayload = {
AUTHORIZATION_CODE: getDefaultAuthCodeOauthFlowParams,
CLIENT_CREDENTIALS: getDefaultClientCredentialsFlowParams,
IMPLICIT: getDefaultImplicitOauthFlowParams,
PASSWORD: getDefaultPasswordFlowParams,
} as const
const supportedGrantTypes = [
{
id: "authCode" as const,
label: t("authorization.oauth.label_auth_code"),
formElements: computed(() => {
if (!(auth.value.grantTypeInfo.grantType === "AUTHORIZATION_CODE")) {
return
}
const grantType = auth.value.grantTypeInfo
const authEndpoint = refWithCallbackOnChange(
grantType?.authEndpoint,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
authEndpoint: value,
}
}
)
const tokenEndpoint = refWithCallbackOnChange(
grantType?.tokenEndpoint,
(value) => {
if (!("tokenEndpoint" in auth.value.grantTypeInfo)) {
return
}
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
tokenEndpoint: value,
}
}
)
const clientID = refWithCallbackOnChange(
grantType?.clientID,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
clientID: value,
}
}
)
const clientSecret = refWithCallbackOnChange(
grantType?.clientSecret,
(value) => {
if (!("clientSecret" in auth.value.grantTypeInfo)) {
return
}
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
clientSecret: value ?? "",
}
}
)
const scopes = refWithCallbackOnChange(
grantType?.scopes ? grantType.scopes : undefined,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
scopes: value,
}
}
)
const isPKCE = refWithCallbackOnChange(
auth.value.grantTypeInfo.isPKCE,
(value) => {
if (!("isPKCE" in auth.value.grantTypeInfo)) {
return
}
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
isPKCE: value,
}
}
)
const codeChallenge: Ref<{
id: "plain" | "S256"
label: string
} | null> = refWithCallbackOnChange(
auth.value.grantTypeInfo.codeVerifierMethod
? {
id: auth.value.grantTypeInfo.codeVerifierMethod,
label:
auth.value.grantTypeInfo.codeVerifierMethod === "plain"
? "Plain"
: "SHA-256",
}
: null,
(value) => {
if (!("codeVerifierMethod" in auth.value.grantTypeInfo) || !value) {
return
}
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
codeVerifierMethod: value.id,
}
}
)
const refreshToken = async () => {
const grantTypeInfo = auth.value.grantTypeInfo
if (!("refreshToken" in grantTypeInfo)) {
return E.left("NO_REFRESH_TOKEN_PRESENT" as const)
}
const refreshToken = grantTypeInfo.refreshToken
if (!refreshToken) {
return E.left("NO_REFRESH_TOKEN_PRESENT" as const)
}
const params: AuthCodeOauthRefreshParams = {
clientID: clientID.value,
clientSecret: clientSecret.value,
tokenEndpoint: tokenEndpoint.value,
refreshToken,
}
const unwrappedParams = replaceTemplateStringsInObjectValues(params)
const refreshTokenFunc = authCode.refreshToken
if (!refreshTokenFunc) {
return E.left("REFRESH_TOKEN_FUNCTION_NOT_DEFINED" as const)
}
const res = await refreshTokenFunc(unwrappedParams)
if (E.isLeft(res)) {
return E.left("OAUTH_REFRESH_TOKEN_FAILED" as const)
}
setAccessTokenInActiveContext(
res.right.access_token,
res.right.refresh_token
)
return E.right(undefined)
}
const runAction = async () => {
const params: AuthCodeOauthFlowParams = {
authEndpoint: authEndpoint.value,
tokenEndpoint: tokenEndpoint.value,
clientID: clientID.value,
clientSecret: clientSecret.value,
scopes: scopes.value,
isPKCE: isPKCE.value,
codeVerifierMethod: codeChallenge.value?.id,
authRequestParams: preparedAuthRequestParams.value,
tokenRequestParams: preparedTokenRequestParams.value,
refreshRequestParams: preparedRefreshRequestParams.value,
}
const unwrappedParams = replaceTemplateStringsInObjectValues(params)
const parsedArgs = authCode.params.safeParse(unwrappedParams)
if (!parsedArgs.success) {
return E.left("VALIDATION_FAILED" as const)
}
const res = await authCode.init(parsedArgs.data)
if (E.isLeft(res)) {
return res
}
return E.right(undefined)
}
const pkceElements = computed(() => {
const checkbox = {
id: "isPKCE",
label: t("authorization.oauth.label_use_pkce"),
type: "checkbox" as const,
ref: isPKCE,
onChange: (e: Event) => {
const target = e.target as HTMLInputElement
isPKCE.value = target.checked
},
}
return isPKCE.value
? [
checkbox,
{
id: "codeChallenge",
label: t("authorization.oauth.label_code_challenge"),
type: "dropdown" as const,
ref: codeChallenge,
tippyRefName: "pkceTippyActions",
tippyRef: pkceTippyActions,
options: [
{
id: "plain" as const,
label: "Plain",
},
{
id: "S256" as const,
label: "SHA-256",
},
],
},
]
: [checkbox]
})
const elements = computed(() => {
return [
...pkceElements.value,
{
id: "authEndpoint",
label: t("authorization.oauth.label_authorization_endpoint"),
type: "text" as const,
ref: authEndpoint,
},
{
id: "tokenEndpoint",
label: t("authorization.oauth.label_token_endpoint"),
type: "text" as const,
ref: tokenEndpoint,
},
{
id: "clientId",
label: t("authorization.oauth.label_client_id"),
type: "text" as const,
ref: clientID,
},
{
id: "clientSecret",
label: t("authorization.oauth.label_client_secret"),
type: "text" as const,
ref: clientSecret,
},
{
id: "scopes",
label: t("authorization.oauth.label_scopes"),
type: "text" as const,
ref: scopes,
},
]
})
return {
runAction,
refreshToken,
elements,
}
}),
},
{
id: "clientCredentials" as const,
label: t("authorization.oauth.label_client_credentials"),
formElements: computed(() => {
if (!(auth.value.grantTypeInfo.grantType === "CLIENT_CREDENTIALS")) {
return
}
const grantTypeInfo = auth.value.grantTypeInfo
const authEndpoint = refWithCallbackOnChange(
grantTypeInfo?.authEndpoint,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
authEndpoint: value,
}
}
)
const clientID = refWithCallbackOnChange(
grantTypeInfo?.clientID,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
clientID: value,
}
}
)
const clientSecret = refWithCallbackOnChange(
grantTypeInfo?.clientSecret,
(value) => {
if (!("clientSecret" in auth.value.grantTypeInfo)) {
return
}
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
clientSecret: value,
}
}
)
const scopes = refWithCallbackOnChange(
grantTypeInfo?.scopes ? grantTypeInfo.scopes : undefined,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
scopes: value,
}
}
)
const clientAuthentication = refWithCallbackOnChange(
grantTypeInfo.clientAuthentication
? grantTypeInfo.clientAuthentication === "AS_BASIC_AUTH_HEADERS"
? {
id: "AS_BASIC_AUTH_HEADERS" as const,
label: t("authorization.oauth.label_send_as_basic_auth"),
}
: {
id: "IN_BODY" as const,
label: t("authorization.oauth.label_send_in_body"),
}
: {
id: "IN_BODY" as const,
label: t("authorization.oauth.label_send_in_body"),
},
(value) => {
if (!("clientAuthentication" in auth.value.grantTypeInfo)) {
return
}
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
clientAuthentication: value.id,
}
}
)
const runAction = async () => {
const values: ClientCredentialsFlowParams =
replaceTemplateStringsInObjectValues({
authEndpoint: authEndpoint.value,
clientID: clientID.value,
clientSecret: clientSecret.value,
scopes: scopes.value,
clientAuthentication: clientAuthentication.value.id,
tokenRequestParams: preparedTokenRequestParams.value,
refreshRequestParams: preparedRefreshRequestParams.value,
})
const parsedArgs = clientCredentials.params.safeParse(values)
if (!parsedArgs.success) {
return E.left("VALIDATION_FAILED" as const)
}
const res = await clientCredentials.init(parsedArgs.data)
if (E.isLeft(res)) {
return E.left("OAUTH_TOKEN_FETCH_FAILED" as const)
}
setAccessTokenInActiveContext(res.right?.access_token)
toast.success(t("authorization.oauth.token_fetched_successfully"))
return E.right(undefined)
}
const elements = computed(() => {
return [
{
id: "authEndpoint",
label: t("authorization.oauth.label_authorization_endpoint"),
type: "text" as const,
ref: authEndpoint,
},
{
id: "clientId",
label: t("authorization.oauth.label_client_id"),
type: "text" as const,
ref: clientID,
},
{
id: "clientSecret",
label: t("authorization.oauth.label_client_secret"),
type: "text" as const,
ref: clientSecret,
},
{
id: "scopes",
label: t("authorization.oauth.label_scopes"),
type: "text" as const,
ref: scopes,
},
{
id: "clientAuthentication",
label: t("authorization.oauth.label_send_as"),
type: "dropdown" as const,
ref: clientAuthentication,
tippyRefName: "clientAuthenticationTippyActions",
tippyRef: clientAuthenticationTippyActions,
options: [
{
id: "IN_BODY" as const,
label: t("authorization.oauth.label_send_in_body"),
},
{
id: "AS_BASIC_AUTH_HEADERS" as const,
label: t("authorization.oauth.label_send_as_basic_auth"),
},
],
},
]
})
return {
runAction,
elements,
}
}),
},
{
id: "password" as const,
label: "Password",
formElements: computed(() => {
if (!(auth.value.grantTypeInfo.grantType === "PASSWORD")) {
return
}
const grantTypeInfo = auth.value.grantTypeInfo
const authEndpoint = refWithCallbackOnChange(
grantTypeInfo?.authEndpoint,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
authEndpoint: value,
}
}
)
const clientID = refWithCallbackOnChange(
grantTypeInfo?.clientID,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
clientID: value,
}
}
)
const clientSecret = refWithCallbackOnChange(
grantTypeInfo?.clientSecret,
(value) => {
if (!("clientSecret" in auth.value.grantTypeInfo)) {
return
}
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
clientSecret: value,
}
}
)
const scopes = refWithCallbackOnChange(
grantTypeInfo?.scopes ? grantTypeInfo.scopes : undefined,
(value) => {
auth.value.grantTypeInfo.scopes = value
}
)
const username = refWithCallbackOnChange(
grantTypeInfo?.username,
(value) => {
if (!("username" in auth.value.grantTypeInfo)) {
return
}
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
username: value,
}
}
)
const password = refWithCallbackOnChange(
grantTypeInfo?.password,
(value) => {
if (!("password" in auth.value.grantTypeInfo)) {
return
}
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
password: value,
}
}
)
const runAction = async () => {
const values: PasswordFlowParams =
replaceTemplateStringsInObjectValues({
authEndpoint: authEndpoint.value,
clientID: clientID.value,
clientSecret: clientSecret.value,
scopes: scopes.value,
username: username.value,
password: password.value,
tokenRequestParams: preparedTokenRequestParams.value,
refreshRequestParams: preparedRefreshRequestParams.value,
})
const parsedArgs = passwordFlow.params.safeParse(values)
if (!parsedArgs.success) {
return E.left("VALIDATION_FAILED" as const)
}
const res = await passwordFlow.init(parsedArgs.data)
if (E.isLeft(res)) {
return E.left("OAUTH_TOKEN_FETCH_FAILED" as const)
}
setAccessTokenInActiveContext(res.right?.access_token)
toast.success(t("authorization.oauth.token_fetched_successfully"))
return E.right(undefined)
}
const elements = computed(() => {
return [
{
id: "authEndpoint",
label: t("authorization.oauth.label_authorization_endpoint"),
type: "text" as const,
ref: authEndpoint,
},
{
id: "clientId",
label: t("authorization.oauth.label_client_id"),
type: "text" as const,
ref: clientID,
},
{
id: "clientSecret",
label: t("authorization.oauth.label_client_secret"),
type: "text" as const,
ref: clientSecret,
},
{
id: "username",
label: t("authorization.oauth.label_username"),
type: "text" as const,
ref: username,
},
{
id: "password",
label: t("authorization.oauth.label_password"),
type: "text" as const,
ref: password,
},
{
id: "scopes",
label: t("authorization.oauth.label_scopes"),
type: "text" as const,
ref: scopes,
},
]
})
return {
runAction,
elements,
}
}),
},
{
id: "implicit" as const,
label: t("authorization.oauth.label_implicit"),
formElements: computed(() => {
const grantTypeInfo = auth.value.grantTypeInfo
const authEndpoint = refWithCallbackOnChange(
grantTypeInfo?.authEndpoint,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
authEndpoint: value,
}
}
)
const clientID = refWithCallbackOnChange(
grantTypeInfo?.clientID,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
clientID: value,
}
}
)
const scopes = refWithCallbackOnChange(
grantTypeInfo?.scopes ? grantTypeInfo.scopes : undefined,
(value) => {
auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo,
scopes: value,
}
}
)
const runAction = () => {
const values: ImplicitOauthFlowParams =
replaceTemplateStringsInObjectValues({
authEndpoint: authEndpoint.value,
clientID: clientID.value,
scopes: scopes.value,
authRequestParams: preparedAuthRequestParams.value,
refreshRequestParams: preparedRefreshRequestParams.value,
})
const unwrappedValues = replaceTemplateStringsInObjectValues(values)
const parsedArgs = implicit.params.safeParse(unwrappedValues)
if (!parsedArgs.success) {
return E.left("VALIDATION_FAILED" as const)
}
implicit.init(parsedArgs.data)
return E.right(undefined)
}
const elements = computed(() => {
return [
{
id: "authEndpoint",
label: t("authorization.oauth.label_authorization_endpoint"),
type: "text" as const,
ref: authEndpoint,
},
{
id: "clientId",
label: t("authorization.oauth.label_client_id"),
type: "text" as const,
ref: clientID,
},
{
id: "scopes",
label: t("authorization.oauth.label_scopes"),
type: "text" as const,
ref: scopes,
},
]
})
return {
runAction,
elements,
}
}),
},
]
const selectedGrantTypeID = computed(() => {
const currentGrantType = auth.value.grantTypeInfo.grantType
return grantTypeMap[currentGrantType]
})
const selectedGrantType = computed(() => {
return supportedGrantTypes.find(
(grantType) => grantType.id === selectedGrantTypeID.value
)
})
const changeSelectedGrantType = (
grantType: (typeof supportedGrantTypes)[number]["id"]
) => {
const keys = Object.keys(grantTypeMap) as GrantTypes[]
const grantTypeToSet = keys.find((key) => grantTypeMap[key] === grantType)
if (grantTypeToSet) {
auth.value.grantTypeInfo.grantType = grantTypeToSet
const getDefaultPayload = grantTypeDefaultPayload[grantTypeToSet]
if (getDefaultPayload) {
auth.value.grantTypeInfo = {
...getDefaultPayload(),
...auth.value.grantTypeInfo,
}
}
}
}
const runAction = computed(() => {
return selectedGrantType.value?.formElements.value?.runAction
})
const runTokenRefresh = computed(() => {
if (selectedGrantType.value?.id === "authCode") {
return selectedGrantType.value?.formElements.value?.refreshToken
}
return null
})
const currentOAuthGrantTypeFormElements = computed(() => {
return selectedGrantType.value?.formElements.value?.elements.value
})
return {
supportedGrantTypes,
selectedGrantTypeID,
selectedGrantType,
changeSelectedGrantType,
runAction,
runTokenRefresh,
currentOAuthGrantTypeFormElements,
}
}

View file

@ -43,17 +43,9 @@ export const commonOAuth2RefreshParams = [
"resource",
]
export const sendInOptions = [
{
label: "Request Body",
value: "body",
},
{
label: "Request URL",
value: "url",
},
{
label: "Request Headers",
value: "headers",
},
]
export const sendInOptions = ["body", "url", "headers"] as const
export const sendInOptionsLabels = {
body: "Request Body",
url: "Request URL",
headers: "Request Headers",
}

View file

@ -11,6 +11,7 @@ import { getService } from "~/modules/dioc"
import * as E from "fp-ts/Either"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { content } from "@hoppscotch/kernel"
import { refreshToken, OAuth2ParamSchema } from "../utils"
const persistenceService = getService(PersistenceService)
const interceptorService = getService(KernelInterceptorService)
@ -25,31 +26,12 @@ const AuthCodeOauthFlowParamsSchema = z
isPKCE: z.boolean(),
codeVerifierMethod: z.enum(["plain", "S256"]).optional(),
authRequestParams: z.array(
z.object({
id: z.number(),
key: z.string(),
value: z.string(),
active: z.boolean(),
})
),
refreshRequestParams: z.array(
z.object({
id: z.number(),
key: z.string(),
value: z.string(),
active: z.boolean(),
sendIn: z.enum(["headers", "url", "body"]).optional(),
})
),
tokenRequestParams: z.array(
z.object({
id: z.number(),
key: z.string(),
value: z.string(),
active: z.boolean(),
sendIn: z.enum(["headers", "url", "body"]).optional(),
OAuth2ParamSchema.omit({
sendIn: true,
})
),
refreshRequestParams: z.array(OAuth2ParamSchema),
tokenRequestParams: z.array(OAuth2ParamSchema),
})
.refine(
(params) => {
@ -354,57 +336,6 @@ const encodeArrayBufferAsUrlEncodedBase64 = (buffer: ArrayBuffer) => {
return hashBase64URL
}
const refreshToken = async ({
tokenEndpoint,
clientID,
refreshToken,
clientSecret,
}: AuthCodeOauthRefreshParams) => {
const { response } = interceptorService.execute({
id: Date.now(),
url: tokenEndpoint,
method: "POST",
version: "HTTP/1.1",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
content: content.urlencoded({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientID,
...(clientSecret && {
client_secret: clientSecret,
}),
}),
})
const res = await response
if (E.isLeft(res)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const responsePayload = decodeResponseAsJSON(res.right)
if (E.isLeft(responsePayload)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const withAccessTokenAndRefreshTokenSchema = z.object({
access_token: z.string(),
refresh_token: z.string().optional(),
})
const parsedTokenResponse = withAccessTokenAndRefreshTokenSchema.safeParse(
responsePayload.right
)
return parsedTokenResponse.success
? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
}
export default createFlowConfig(
"AUTHORIZATION_CODE" as const,
AuthCodeOauthFlowParamsSchema,

View file

@ -8,33 +8,35 @@ import { getService } from "~/modules/dioc"
import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import { useToast } from "~/composables/toast"
import { ClientCredentialsGrantTypeParams } from "@hoppscotch/data"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { RelayRequest, content } from "@hoppscotch/kernel"
import { parseBytesToJSON } from "~/helpers/functional/json"
import { refreshToken, OAuth2ParamSchema } from "../utils"
const interceptorService = getService(KernelInterceptorService)
const ClientCredentialsFlowParamsSchema = ClientCredentialsGrantTypeParams.pick(
{
authEndpoint: true,
clientID: true,
clientSecret: true,
scopes: true,
clientAuthentication: true,
}
).refine(
(params) => {
return (
params.authEndpoint.length >= 1 &&
params.clientID.length >= 1 &&
(!params.scopes || params.scopes.length >= 1)
)
},
{
message: "Minimum length requirement not met for one or more parameters",
}
)
const ClientCredentialsFlowParamsSchema = z
.object({
authEndpoint: z.string(),
clientID: z.string(),
clientSecret: z.string().optional(),
scopes: z.string().optional(),
clientAuthentication: z.enum(["AS_BASIC_AUTH_HEADERS", "IN_BODY"]),
tokenRequestParams: z.array(OAuth2ParamSchema),
refreshRequestParams: z.array(OAuth2ParamSchema),
})
.refine(
(params) => {
return (
params.authEndpoint.length >= 1 &&
params.clientID.length >= 1 &&
(!params.scopes || params.scopes.length >= 1)
)
},
{
message: "Minimum length requirement not met for one or more parameters",
}
)
export type ClientCredentialsFlowParams = z.infer<
typeof ClientCredentialsFlowParamsSchema
@ -47,6 +49,8 @@ export const getDefaultClientCredentialsFlowParams =
clientSecret: "",
scopes: undefined,
clientAuthentication: "IN_BODY",
tokenRequestParams: [],
refreshRequestParams: [],
})
const initClientCredentialsOAuthFlow = async (
@ -169,56 +173,115 @@ export default createFlowConfig(
"CLIENT_CREDENTIALS" as const,
ClientCredentialsFlowParamsSchema,
initClientCredentialsOAuthFlow,
handleRedirectForAuthCodeOauthFlow
handleRedirectForAuthCodeOauthFlow,
refreshToken
)
const getPayloadForViaBasicAuthHeader = (
payload: Omit<ClientCredentialsFlowParams, "clientAuthentication">
): RelayRequest => {
const { clientID, clientSecret, scopes, authEndpoint } = payload
const getPayloadForViaBasicAuthHeader = ({
clientID,
clientSecret,
scopes,
authEndpoint,
tokenRequestParams,
}: ClientCredentialsFlowParams): RelayRequest => {
// RFC 6749 Section 2.3.1 states that the client ID and secret should be URL encoded.
const encodedClientID = encodeBasicAuthComponent(clientID)
const encodedClientSecret = encodeBasicAuthComponent(clientSecret || "")
const basicAuthToken = btoa(`${encodedClientID}:${encodedClientSecret}`)
const headers: Record<string, string> = {
Authorization: `Basic ${basicAuthToken}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
}
const bodyParams: Record<string, string> = {
grant_type: "client_credentials",
...(scopes && { scope: scopes }),
}
const urlParams: Record<string, string> = {}
// Process additional token request parameters
if (tokenRequestParams) {
tokenRequestParams
.filter((param) => param.active && param.key && param.value)
.forEach((param) => {
if (param.sendIn === "headers") {
headers[param.key] = param.value
} else if (param.sendIn === "url") {
urlParams[param.key] = param.value
} else {
// Default to body
bodyParams[param.key] = param.value
}
})
}
const url = new URL(authEndpoint)
Object.entries(urlParams).forEach(([key, value]) => {
url.searchParams.set(key, value)
})
return {
id: Date.now(),
url: authEndpoint,
url: url.toString(),
method: "POST",
version: "HTTP/1.1",
headers: {
Authorization: `Basic ${basicAuthToken}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
content: content.urlencoded({
grant_type: "client_credentials",
...(scopes && { scope: scopes }),
}),
headers,
content: content.urlencoded(bodyParams),
}
}
const getPayloadForViaBody = (
payload: Omit<ClientCredentialsFlowParams, "clientAuthentication">
): RelayRequest => {
const { clientID, clientSecret, scopes, authEndpoint } = payload
const getPayloadForViaBody = ({
clientID,
clientSecret,
scopes,
authEndpoint,
tokenRequestParams,
}: ClientCredentialsFlowParams): RelayRequest => {
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
}
const bodyParams: Record<string, string> = {
grant_type: "client_credentials",
client_id: clientID,
...(clientSecret && { client_secret: clientSecret }),
...(scopes && { scope: scopes }),
}
const urlParams: Record<string, string> = {}
// Process additional token request parameters
if (tokenRequestParams) {
tokenRequestParams
.filter((param) => param.active && param.key && param.value)
.forEach((param) => {
if (param.sendIn === "headers") {
headers[param.key] = param.value
} else if (param.sendIn === "url") {
urlParams[param.key] = param.value
} else {
// Default to body
bodyParams[param.key] = param.value
}
})
}
const url = new URL(authEndpoint)
Object.entries(urlParams).forEach(([key, value]) => {
url.searchParams.set(key, value)
})
return {
id: Date.now(),
url: authEndpoint,
url: url.toString(),
method: "POST",
version: "HTTP/1.1",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
content: content.urlencoded({
grant_type: "client_credentials",
client_id: clientID,
...(clientSecret && { client_secret: clientSecret }),
...(scopes && { scope: scopes }),
}),
headers,
content: content.urlencoded(bodyParams),
}
}

View file

@ -8,21 +8,29 @@ import {
import { z } from "zod"
import { getService } from "~/modules/dioc"
import * as E from "fp-ts/Either"
import { ImplicitOauthFlowParams } from "@hoppscotch/data"
import { OAuth2ParamSchema } from "../utils"
const persistenceService = getService(PersistenceService)
const ImplicitOauthFlowParamsSchema = ImplicitOauthFlowParams.pick({
authEndpoint: true,
clientID: true,
scopes: true,
}).refine((params) => {
return (
params.authEndpoint.length >= 1 &&
params.clientID.length >= 1 &&
(params.scopes === undefined || params.scopes.length >= 1)
)
})
const ImplicitOauthFlowParamsSchema = z
.object({
authEndpoint: z.string(),
clientID: z.string(),
scopes: z.string().optional(),
authRequestParams: z.array(
OAuth2ParamSchema.omit({
sendIn: true,
})
),
refreshRequestParams: z.array(OAuth2ParamSchema),
})
.refine((params) => {
return (
params.authEndpoint.length >= 1 &&
params.clientID.length >= 1 &&
(params.scopes === undefined || params.scopes.length >= 1)
)
})
export type ImplicitOauthFlowParams = z.infer<
typeof ImplicitOauthFlowParamsSchema
@ -33,12 +41,15 @@ export const getDefaultImplicitOauthFlowParams =
authEndpoint: "",
clientID: "",
scopes: undefined,
authRequestParams: [],
refreshRequestParams: [],
})
const initImplicitOauthFlow = async ({
clientID,
scopes,
authEndpoint,
authRequestParams,
}: ImplicitOauthFlowParams) => {
const state = generateRandomString()
@ -59,6 +70,7 @@ const initImplicitOauthFlow = async ({
authEndpoint,
scopes,
state,
authRequestParams,
},
grant_type: "IMPLICIT",
})
@ -79,6 +91,15 @@ const initImplicitOauthFlow = async ({
if (scopes) url.searchParams.set("scope", scopes)
// Process additional auth request parameters
if (authRequestParams) {
authRequestParams
.filter((param) => param.active && param.key && param.value)
.forEach((param) => {
url.searchParams.set(param.key, param.value)
})
}
// Redirect to the authorization server
window.location.assign(url.toString())

View file

@ -9,33 +9,37 @@ import { z } from "zod"
import { getService } from "~/modules/dioc"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { useToast } from "~/composables/toast"
import { PasswordGrantTypeParams } from "@hoppscotch/data"
import { content } from "@hoppscotch/kernel"
import { parseBytesToJSON } from "~/helpers/functional/json"
import { OAuth2ParamSchema, refreshToken } from "../utils"
const interceptorService = getService(KernelInterceptorService)
const PasswordFlowParamsSchema = PasswordGrantTypeParams.pick({
authEndpoint: true,
clientID: true,
clientSecret: true,
scopes: true,
username: true,
password: true,
}).refine(
(params) => {
return (
params.authEndpoint.length >= 1 &&
params.clientID.length >= 1 &&
params.username.length >= 1 &&
params.password.length >= 1 &&
(!params.scopes || params.scopes.length >= 1)
)
},
{
message: "Minimum length requirement not met for one or more parameters",
}
)
const PasswordFlowParamsSchema = z
.object({
authEndpoint: z.string(),
clientID: z.string(),
clientSecret: z.string().optional(),
scopes: z.string().optional(),
username: z.string(),
password: z.string(),
tokenRequestParams: z.array(OAuth2ParamSchema),
refreshRequestParams: z.array(OAuth2ParamSchema),
})
.refine(
(params) => {
return (
params.authEndpoint.length >= 1 &&
params.clientID.length >= 1 &&
params.username.length >= 1 &&
params.password.length >= 1 &&
(!params.scopes || params.scopes.length >= 1)
)
},
{
message: "Minimum length requirement not met for one or more parameters",
}
)
export type PasswordFlowParams = z.infer<typeof PasswordFlowParamsSchema>
@ -46,6 +50,8 @@ export const getDefaultPasswordFlowParams = (): PasswordFlowParams => ({
scopes: undefined,
username: "",
password: "",
tokenRequestParams: [],
refreshRequestParams: [],
})
const initPasswordOauthFlow = async ({
@ -55,30 +61,58 @@ const initPasswordOauthFlow = async ({
clientSecret,
scopes,
authEndpoint,
tokenRequestParams,
}: PasswordFlowParams) => {
const toast = useToast()
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
}
const bodyParams: Record<string, string> = {
grant_type: "password",
client_id: clientID,
username,
password,
...(clientSecret && {
client_secret: clientSecret,
}),
...(scopes && {
scope: scopes,
}),
}
const urlParams: Record<string, string> = {}
// Process additional token request parameters
if (tokenRequestParams) {
tokenRequestParams
.filter((param) => param.active && param.key && param.value)
.forEach((param) => {
if (param.sendIn === "headers") {
headers[param.key] = param.value
} else if (param.sendIn === "url") {
urlParams[param.key] = param.value
} else {
// Default to body
bodyParams[param.key] = param.value
}
})
}
const url = new URL(authEndpoint)
Object.entries(urlParams).forEach(([key, value]) => {
url.searchParams.set(key, value)
})
const { response } = interceptorService.execute({
id: Date.now(),
url: authEndpoint,
url: url.toString(),
method: "POST",
version: "HTTP/1.1",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
content: content.urlencoded({
grant_type: "password",
client_id: clientID,
username,
password,
...(clientSecret && {
client_secret: clientSecret,
}),
...(scopes && {
scope: scopes,
}),
}),
headers,
content: content.urlencoded(bodyParams),
})
const res = await response
@ -191,5 +225,6 @@ export default createFlowConfig(
"PASSWORD" as const,
PasswordFlowParamsSchema,
initPasswordOauthFlow,
handleRedirectForAuthCodeOauthFlow
handleRedirectForAuthCodeOauthFlow,
refreshToken
)

View file

@ -0,0 +1,122 @@
import * as E from "fp-ts/Either"
import { z } from "zod"
import { getService } from "~/modules/dioc"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { content } from "@hoppscotch/kernel"
import { decodeResponseAsJSON } from "./oauth.service"
const interceptorService = getService(KernelInterceptorService)
// Type definition for refresh request parameters
export type RefreshRequestParam = {
id: number
key: string
value: string
active: boolean
sendIn?: "headers" | "url" | "body"
}
// Unified refresh token parameters for all OAuth flows
export type RefreshTokenParams = {
tokenEndpoint: string
clientID: string
refreshToken: string
clientSecret?: string
refreshRequestParams?: Array<RefreshRequestParam>
}
/**
* Unified refresh token function for all OAuth flows
* Supports both basic flows (authCode) and advanced flows (password, clientCredentials)
* with optional advanced parameters
*/
export const refreshToken = async ({
tokenEndpoint,
clientID,
refreshToken,
clientSecret,
refreshRequestParams,
}: RefreshTokenParams) => {
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
}
const bodyParams: Record<string, string> = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientID,
...(clientSecret && {
client_secret: clientSecret,
}),
}
const urlParams: Record<string, string> = {}
// Process additional refresh request parameters (if provided)
if (refreshRequestParams) {
refreshRequestParams
.filter((param) => param.active && param.key && param.value)
.forEach((param) => {
if (param.sendIn === "headers") {
headers[param.key] = param.value
} else if (param.sendIn === "url") {
urlParams[param.key] = param.value
} else {
// Default to body
bodyParams[param.key] = param.value
}
})
}
const url = new URL(tokenEndpoint)
Object.entries(urlParams).forEach(([key, value]) => {
url.searchParams.set(key, value)
})
const { response } = interceptorService.execute({
id: Date.now(),
url: url.toString(),
method: "POST",
version: "HTTP/1.1",
headers,
content: content.urlencoded(bodyParams),
})
const res = await response
if (E.isLeft(res)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const responsePayload = decodeResponseAsJSON(res.right)
if (E.isLeft(responsePayload)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const withAccessTokenAndRefreshTokenSchema = z.object({
access_token: z.string(),
refresh_token: z.string().optional(),
})
const parsedTokenResponse = withAccessTokenAndRefreshTokenSchema.safeParse(
responsePayload.right
)
return parsedTokenResponse.success
? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
}
/**
* Common OAuth2 parameter schema with all possible fields
* Used as base for both auth requests and advanced token/refresh requests
*/
export const OAuth2ParamSchema = z.object({
id: z.number(),
key: z.string(),
value: z.string(),
active: z.boolean(),
sendIn: z.enum(["headers", "url", "body"]).optional(),
})