feat: oauth revamp + support for multiple grant types in oauth (#3885)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
293
packages/hoppscotch-common/src/services/oauth/flows/authCode.ts
Normal file
293
packages/hoppscotch-common/src/services/oauth/flows/authCode.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { PersistenceService } from "~/services/persistence"
|
||||
import {
|
||||
OauthAuthService,
|
||||
PersistedOAuthConfig,
|
||||
createFlowConfig,
|
||||
decodeResponseAsJSON,
|
||||
generateRandomString,
|
||||
} from "../oauth.service"
|
||||
import { z } from "zod"
|
||||
import { getService } from "~/modules/dioc"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { AuthCodeGrantTypeParams } from "@hoppscotch/data"
|
||||
|
||||
const persistenceService = getService(PersistenceService)
|
||||
const interceptorService = getService(InterceptorService)
|
||||
|
||||
const AuthCodeOauthFlowParamsSchema = AuthCodeGrantTypeParams.pick({
|
||||
authEndpoint: true,
|
||||
tokenEndpoint: true,
|
||||
clientID: true,
|
||||
clientSecret: true,
|
||||
scopes: true,
|
||||
isPKCE: true,
|
||||
codeVerifierMethod: true,
|
||||
})
|
||||
.refine(
|
||||
(params) => {
|
||||
return (
|
||||
params.authEndpoint.length >= 1 &&
|
||||
params.tokenEndpoint.length >= 1 &&
|
||||
params.clientID.length >= 1 &&
|
||||
params.clientSecret.length >= 1 &&
|
||||
(!params.scopes || params.scopes.trim().length >= 1)
|
||||
)
|
||||
},
|
||||
{
|
||||
message: "Minimum length requirement not met for one or more parameters",
|
||||
}
|
||||
)
|
||||
.refine((params) => (params.isPKCE ? !!params.codeVerifierMethod : true), {
|
||||
message: "codeVerifierMethod is required when using PKCE",
|
||||
path: ["codeVerifierMethod"],
|
||||
})
|
||||
|
||||
export type AuthCodeOauthFlowParams = z.infer<
|
||||
typeof AuthCodeOauthFlowParamsSchema
|
||||
>
|
||||
|
||||
export const getDefaultAuthCodeOauthFlowParams =
|
||||
(): AuthCodeOauthFlowParams => ({
|
||||
authEndpoint: "",
|
||||
tokenEndpoint: "",
|
||||
clientID: "",
|
||||
clientSecret: "",
|
||||
scopes: undefined,
|
||||
isPKCE: false,
|
||||
codeVerifierMethod: "S256",
|
||||
})
|
||||
|
||||
const initAuthCodeOauthFlow = async ({
|
||||
tokenEndpoint,
|
||||
clientID,
|
||||
clientSecret,
|
||||
scopes,
|
||||
authEndpoint,
|
||||
isPKCE,
|
||||
codeVerifierMethod,
|
||||
}: AuthCodeOauthFlowParams) => {
|
||||
const state = generateRandomString()
|
||||
|
||||
let codeVerifier: string | undefined
|
||||
let codeChallenge: string | undefined
|
||||
|
||||
if (isPKCE) {
|
||||
codeVerifier = generateCodeVerifier()
|
||||
codeChallenge = await generateCodeChallenge(
|
||||
codeVerifier,
|
||||
codeVerifierMethod
|
||||
)
|
||||
}
|
||||
|
||||
let oauthTempConfig: {
|
||||
state: string
|
||||
grant_type: "AUTHORIZATION_CODE"
|
||||
authEndpoint: string
|
||||
tokenEndpoint: string
|
||||
clientSecret: string
|
||||
clientID: string
|
||||
isPKCE: boolean
|
||||
codeVerifier?: string
|
||||
codeVerifierMethod?: string
|
||||
codeChallenge?: string
|
||||
scopes?: string
|
||||
} = {
|
||||
state,
|
||||
grant_type: "AUTHORIZATION_CODE",
|
||||
authEndpoint,
|
||||
tokenEndpoint,
|
||||
clientSecret,
|
||||
clientID,
|
||||
isPKCE,
|
||||
codeVerifierMethod,
|
||||
scopes,
|
||||
}
|
||||
|
||||
if (codeVerifier && codeChallenge) {
|
||||
oauthTempConfig = {
|
||||
...oauthTempConfig,
|
||||
codeVerifier,
|
||||
codeChallenge,
|
||||
}
|
||||
}
|
||||
|
||||
const localOAuthTempConfig =
|
||||
persistenceService.getLocalConfig("oauth_temp_config")
|
||||
|
||||
const persistedOAuthConfig: PersistedOAuthConfig = localOAuthTempConfig
|
||||
? { ...JSON.parse(localOAuthTempConfig) }
|
||||
: {}
|
||||
|
||||
const { grant_type, ...rest } = oauthTempConfig
|
||||
|
||||
// persist the state so we can compare it when we get redirected back
|
||||
// also persist the grant_type,tokenEndpoint and clientSecret so we can use them when we get redirected back
|
||||
persistenceService.setLocalConfig(
|
||||
"oauth_temp_config",
|
||||
JSON.stringify(<PersistedOAuthConfig>{
|
||||
...persistedOAuthConfig,
|
||||
fields: rest,
|
||||
grant_type,
|
||||
})
|
||||
)
|
||||
|
||||
let url: URL
|
||||
|
||||
try {
|
||||
url = new URL(authEndpoint)
|
||||
} catch (e) {
|
||||
return E.left("INVALID_AUTH_ENDPOINT")
|
||||
}
|
||||
|
||||
url.searchParams.set("grant_type", "authorization_code")
|
||||
url.searchParams.set("client_id", clientID)
|
||||
url.searchParams.set("state", state)
|
||||
url.searchParams.set("response_type", "code")
|
||||
url.searchParams.set("redirect_uri", OauthAuthService.redirectURI)
|
||||
|
||||
if (scopes) url.searchParams.set("scope", scopes)
|
||||
|
||||
if (codeVerifierMethod && codeChallenge) {
|
||||
url.searchParams.set("code_challenge", codeChallenge)
|
||||
url.searchParams.set("code_challenge_method", codeVerifierMethod)
|
||||
}
|
||||
|
||||
// Redirect to the authorization server
|
||||
window.location.assign(url.toString())
|
||||
|
||||
return E.right(undefined)
|
||||
}
|
||||
|
||||
const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => {
|
||||
// parse the query string
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
const code = params.get("code")
|
||||
const state = params.get("state")
|
||||
const error = params.get("error")
|
||||
|
||||
if (error) {
|
||||
return E.left("AUTH_SERVER_RETURNED_ERROR")
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return E.left("AUTH_TOKEN_REQUEST_FAILED")
|
||||
}
|
||||
|
||||
const expectedSchema = z.object({
|
||||
source: z.optional(z.string()),
|
||||
state: z.string(),
|
||||
tokenEndpoint: z.string(),
|
||||
clientSecret: z.string(),
|
||||
clientID: z.string(),
|
||||
codeVerifier: z.string().optional(),
|
||||
codeChallenge: z.string().optional(),
|
||||
})
|
||||
|
||||
const decodedLocalConfig = expectedSchema.safeParse(
|
||||
JSON.parse(localConfig).fields
|
||||
)
|
||||
|
||||
if (!decodedLocalConfig.success) {
|
||||
return E.left("INVALID_LOCAL_CONFIG")
|
||||
}
|
||||
|
||||
// check if the state matches
|
||||
if (decodedLocalConfig.data.state !== state) {
|
||||
return E.left("INVALID_STATE")
|
||||
}
|
||||
|
||||
// exchange the code for a token
|
||||
const formData = new URLSearchParams()
|
||||
formData.append("grant_type", "authorization_code")
|
||||
formData.append("code", code)
|
||||
formData.append("client_id", decodedLocalConfig.data.clientID)
|
||||
formData.append("client_secret", decodedLocalConfig.data.clientSecret)
|
||||
formData.append("redirect_uri", OauthAuthService.redirectURI)
|
||||
|
||||
if (decodedLocalConfig.data.codeVerifier) {
|
||||
formData.append("code_verifier", decodedLocalConfig.data.codeVerifier)
|
||||
}
|
||||
|
||||
const { response } = interceptorService.runRequest({
|
||||
url: decodedLocalConfig.data.tokenEndpoint,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
data: formData.toString(),
|
||||
})
|
||||
|
||||
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 withAccessTokenSchema = z.object({
|
||||
access_token: z.string(),
|
||||
})
|
||||
|
||||
const parsedTokenResponse = withAccessTokenSchema.safeParse(
|
||||
responsePayload.right
|
||||
)
|
||||
|
||||
return parsedTokenResponse.success
|
||||
? E.right(parsedTokenResponse.data)
|
||||
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
|
||||
}
|
||||
|
||||
const generateCodeVerifier = () => {
|
||||
const characters =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
const length = Math.floor(Math.random() * (128 - 43 + 1)) + 43 // Random length between 43 and 128
|
||||
let codeVerifier = ""
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * characters.length)
|
||||
codeVerifier += characters[randomIndex]
|
||||
}
|
||||
|
||||
return codeVerifier
|
||||
}
|
||||
|
||||
const generateCodeChallenge = async (
|
||||
codeVerifier: string,
|
||||
strategy: AuthCodeOauthFlowParams["codeVerifierMethod"]
|
||||
) => {
|
||||
if (strategy === "plain") {
|
||||
return codeVerifier
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(codeVerifier)
|
||||
|
||||
const buffer = await crypto.subtle.digest("SHA-256", data)
|
||||
|
||||
return encodeArrayBufferAsUrlEncodedBase64(buffer)
|
||||
}
|
||||
|
||||
const encodeArrayBufferAsUrlEncodedBase64 = (buffer: ArrayBuffer) => {
|
||||
const hashArray = Array.from(new Uint8Array(buffer))
|
||||
const hashBase64URL = btoa(String.fromCharCode(...hashArray))
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
|
||||
return hashBase64URL
|
||||
}
|
||||
|
||||
export default createFlowConfig(
|
||||
"AUTHORIZATION_CODE" as const,
|
||||
AuthCodeOauthFlowParamsSchema,
|
||||
initAuthCodeOauthFlow,
|
||||
handleRedirectForAuthCodeOauthFlow
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
OauthAuthService,
|
||||
createFlowConfig,
|
||||
decodeResponseAsJSON,
|
||||
} from "../oauth.service"
|
||||
import { z } from "zod"
|
||||
import { getService } from "~/modules/dioc"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { ClientCredentialsGrantTypeParams } from "@hoppscotch/data"
|
||||
|
||||
const interceptorService = getService(InterceptorService)
|
||||
|
||||
const ClientCredentialsFlowParamsSchema = ClientCredentialsGrantTypeParams.pick(
|
||||
{
|
||||
authEndpoint: true,
|
||||
clientID: true,
|
||||
clientSecret: true,
|
||||
scopes: true,
|
||||
}
|
||||
).refine(
|
||||
(params) => {
|
||||
return (
|
||||
params.authEndpoint.length >= 1 &&
|
||||
params.clientID.length >= 1 &&
|
||||
params.clientSecret.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
|
||||
>
|
||||
|
||||
export const getDefaultClientCredentialsFlowParams =
|
||||
(): ClientCredentialsFlowParams => ({
|
||||
authEndpoint: "",
|
||||
clientID: "",
|
||||
clientSecret: "",
|
||||
scopes: undefined,
|
||||
})
|
||||
|
||||
const initClientCredentialsOAuthFlow = async ({
|
||||
clientID,
|
||||
clientSecret,
|
||||
scopes,
|
||||
authEndpoint,
|
||||
}: ClientCredentialsFlowParams) => {
|
||||
const toast = useToast()
|
||||
|
||||
const formData = new URLSearchParams()
|
||||
formData.append("grant_type", "client_credentials")
|
||||
formData.append("client_id", clientID)
|
||||
formData.append("client_secret", clientSecret)
|
||||
|
||||
if (scopes) {
|
||||
formData.append("scope", scopes)
|
||||
}
|
||||
|
||||
const { response } = interceptorService.runRequest({
|
||||
url: authEndpoint,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
data: formData.toString(),
|
||||
})
|
||||
|
||||
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 withAccessTokenSchema = z.object({
|
||||
access_token: z.string(),
|
||||
})
|
||||
|
||||
const parsedTokenResponse = withAccessTokenSchema.safeParse(
|
||||
responsePayload.right
|
||||
)
|
||||
|
||||
if (!parsedTokenResponse.success) {
|
||||
toast.error("AUTH_TOKEN_REQUEST_INVALID_RESPONSE")
|
||||
}
|
||||
|
||||
return parsedTokenResponse.success
|
||||
? E.right(parsedTokenResponse.data)
|
||||
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
|
||||
}
|
||||
|
||||
const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => {
|
||||
// parse the query string
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
const code = params.get("code")
|
||||
const state = params.get("state")
|
||||
const error = params.get("error")
|
||||
|
||||
if (error) {
|
||||
return E.left("AUTH_SERVER_RETURNED_ERROR")
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return E.left("AUTH_TOKEN_REQUEST_FAILED")
|
||||
}
|
||||
|
||||
const expectedSchema = z.object({
|
||||
state: z.string(),
|
||||
tokenEndpoint: z.string(),
|
||||
clientSecret: z.string(),
|
||||
clientID: z.string(),
|
||||
})
|
||||
|
||||
const decodedLocalConfig = expectedSchema.safeParse(JSON.parse(localConfig))
|
||||
|
||||
if (!decodedLocalConfig.success) {
|
||||
return E.left("INVALID_LOCAL_CONFIG")
|
||||
}
|
||||
|
||||
// check if the state matches
|
||||
if (decodedLocalConfig.data.state !== state) {
|
||||
return E.left("INVALID_STATE")
|
||||
}
|
||||
|
||||
// exchange the code for a token
|
||||
const formData = new URLSearchParams()
|
||||
formData.append("code", code)
|
||||
formData.append("client_id", decodedLocalConfig.data.clientID)
|
||||
formData.append("client_secret", decodedLocalConfig.data.clientSecret)
|
||||
formData.append("redirect_uri", OauthAuthService.redirectURI)
|
||||
|
||||
const { response } = interceptorService.runRequest({
|
||||
url: decodedLocalConfig.data.tokenEndpoint,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
data: formData.toString(),
|
||||
})
|
||||
|
||||
const res = await response
|
||||
|
||||
if (E.isLeft(res)) {
|
||||
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
|
||||
}
|
||||
|
||||
const responsePayload = new TextDecoder("utf-8")
|
||||
.decode(res.right.data as any)
|
||||
.replaceAll("\x00", "")
|
||||
|
||||
const withAccessTokenSchema = z.object({
|
||||
access_token: z.string(),
|
||||
})
|
||||
|
||||
const parsedTokenResponse = withAccessTokenSchema.safeParse(
|
||||
JSON.parse(responsePayload)
|
||||
)
|
||||
|
||||
return parsedTokenResponse.success
|
||||
? E.right(parsedTokenResponse.data)
|
||||
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
|
||||
}
|
||||
|
||||
export default createFlowConfig(
|
||||
"CLIENT_CREDENTIALS" as const,
|
||||
ClientCredentialsFlowParamsSchema,
|
||||
initClientCredentialsOAuthFlow,
|
||||
handleRedirectForAuthCodeOauthFlow
|
||||
)
|
||||
135
packages/hoppscotch-common/src/services/oauth/flows/implicit.ts
Normal file
135
packages/hoppscotch-common/src/services/oauth/flows/implicit.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { PersistenceService } from "~/services/persistence"
|
||||
import {
|
||||
OauthAuthService,
|
||||
PersistedOAuthConfig,
|
||||
createFlowConfig,
|
||||
generateRandomString,
|
||||
} from "../oauth.service"
|
||||
import { z } from "zod"
|
||||
import { getService } from "~/modules/dioc"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { ImplicitOauthFlowParams } from "@hoppscotch/data"
|
||||
|
||||
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)
|
||||
)
|
||||
})
|
||||
|
||||
export type ImplicitOauthFlowParams = z.infer<
|
||||
typeof ImplicitOauthFlowParamsSchema
|
||||
>
|
||||
|
||||
export const getDefaultImplicitOauthFlowParams =
|
||||
(): ImplicitOauthFlowParams => ({
|
||||
authEndpoint: "",
|
||||
clientID: "",
|
||||
scopes: undefined,
|
||||
})
|
||||
|
||||
const initImplicitOauthFlow = async ({
|
||||
clientID,
|
||||
scopes,
|
||||
authEndpoint,
|
||||
}: ImplicitOauthFlowParams) => {
|
||||
const state = generateRandomString()
|
||||
|
||||
const localOAuthTempConfig =
|
||||
persistenceService.getLocalConfig("oauth_temp_config")
|
||||
|
||||
const persistedOAuthConfig: PersistedOAuthConfig = localOAuthTempConfig
|
||||
? { ...JSON.parse(localOAuthTempConfig) }
|
||||
: {}
|
||||
|
||||
// Persist the necessary information for retrieval while getting redirected back
|
||||
persistenceService.setLocalConfig(
|
||||
"oauth_temp_config",
|
||||
JSON.stringify(<PersistedOAuthConfig>{
|
||||
...persistedOAuthConfig,
|
||||
fields: {
|
||||
clientID,
|
||||
authEndpoint,
|
||||
scopes,
|
||||
state,
|
||||
},
|
||||
grant_type: "IMPLICIT",
|
||||
})
|
||||
)
|
||||
|
||||
let url: URL
|
||||
|
||||
try {
|
||||
url = new URL(authEndpoint)
|
||||
} catch {
|
||||
return E.left("INVALID_AUTH_ENDPOINT")
|
||||
}
|
||||
|
||||
url.searchParams.set("client_id", clientID)
|
||||
url.searchParams.set("state", state)
|
||||
url.searchParams.set("response_type", "token")
|
||||
url.searchParams.set("redirect_uri", OauthAuthService.redirectURI)
|
||||
|
||||
if (scopes) url.searchParams.set("scope", scopes)
|
||||
|
||||
// Redirect to the authorization server
|
||||
window.location.assign(url.toString())
|
||||
|
||||
return E.right(undefined)
|
||||
}
|
||||
|
||||
const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => {
|
||||
// parse the query string
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const paramsFromHash = new URLSearchParams(window.location.hash.substring(1))
|
||||
|
||||
const accessToken =
|
||||
params.get("access_token") || paramsFromHash.get("access_token")
|
||||
const state = params.get("state") || paramsFromHash.get("state")
|
||||
const error = params.get("error") || paramsFromHash.get("error")
|
||||
|
||||
if (error) {
|
||||
return E.left("AUTH_SERVER_RETURNED_ERROR")
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return E.left("AUTH_TOKEN_REQUEST_FAILED")
|
||||
}
|
||||
|
||||
const expectedSchema = z.object({
|
||||
source: z.optional(z.string()),
|
||||
state: z.string(),
|
||||
clientID: z.string(),
|
||||
})
|
||||
|
||||
const decodedLocalConfig = expectedSchema.safeParse(
|
||||
JSON.parse(localConfig).fields
|
||||
)
|
||||
|
||||
if (!decodedLocalConfig.success) {
|
||||
return E.left("INVALID_LOCAL_CONFIG")
|
||||
}
|
||||
|
||||
// check if the state matches
|
||||
if (decodedLocalConfig.data.state !== state) {
|
||||
return E.left("INVALID_STATE")
|
||||
}
|
||||
|
||||
return E.right({
|
||||
access_token: accessToken,
|
||||
})
|
||||
}
|
||||
|
||||
export default createFlowConfig(
|
||||
"IMPLICIT" as const,
|
||||
ImplicitOauthFlowParamsSchema,
|
||||
initImplicitOauthFlow,
|
||||
handleRedirectForAuthCodeOauthFlow
|
||||
)
|
||||
189
packages/hoppscotch-common/src/services/oauth/flows/password.ts
Normal file
189
packages/hoppscotch-common/src/services/oauth/flows/password.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
OauthAuthService,
|
||||
createFlowConfig,
|
||||
decodeResponseAsJSON,
|
||||
} from "../oauth.service"
|
||||
import { z } from "zod"
|
||||
import { getService } from "~/modules/dioc"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { PasswordGrantTypeParams } from "@hoppscotch/data"
|
||||
|
||||
const interceptorService = getService(InterceptorService)
|
||||
|
||||
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.clientSecret.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>
|
||||
|
||||
export const getDefaultPasswordFlowParams = (): PasswordFlowParams => ({
|
||||
authEndpoint: "",
|
||||
clientID: "",
|
||||
clientSecret: "",
|
||||
scopes: undefined,
|
||||
username: "",
|
||||
password: "",
|
||||
})
|
||||
|
||||
const initPasswordOauthFlow = async ({
|
||||
password,
|
||||
username,
|
||||
clientID,
|
||||
clientSecret,
|
||||
scopes,
|
||||
authEndpoint,
|
||||
}: PasswordFlowParams) => {
|
||||
const toast = useToast()
|
||||
|
||||
const formData = new URLSearchParams()
|
||||
formData.append("grant_type", "password")
|
||||
formData.append("client_id", clientID)
|
||||
formData.append("client_secret", clientSecret)
|
||||
formData.append("username", username)
|
||||
formData.append("password", password)
|
||||
|
||||
if (scopes) {
|
||||
formData.append("scope", scopes)
|
||||
}
|
||||
|
||||
const { response } = interceptorService.runRequest({
|
||||
url: authEndpoint,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
data: formData.toString(),
|
||||
})
|
||||
|
||||
const res = await response
|
||||
|
||||
if (E.isLeft(res) || res.right.status !== 200) {
|
||||
toast.error("AUTH_TOKEN_REQUEST_FAILED")
|
||||
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
|
||||
}
|
||||
|
||||
const responsePayload = new TextDecoder("utf-8")
|
||||
.decode(res.right.data as any)
|
||||
.replaceAll("\x00", "")
|
||||
|
||||
const withAccessTokenSchema = z.object({
|
||||
access_token: z.string(),
|
||||
})
|
||||
|
||||
const parsedTokenResponse = withAccessTokenSchema.safeParse(
|
||||
JSON.parse(responsePayload)
|
||||
)
|
||||
|
||||
if (!parsedTokenResponse.success) {
|
||||
toast.error("AUTH_TOKEN_REQUEST_INVALID_RESPONSE")
|
||||
}
|
||||
|
||||
return parsedTokenResponse.success
|
||||
? E.right(parsedTokenResponse.data)
|
||||
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
|
||||
}
|
||||
|
||||
const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => {
|
||||
// parse the query string
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
const code = params.get("code")
|
||||
const state = params.get("state")
|
||||
const error = params.get("error")
|
||||
|
||||
if (error) {
|
||||
return E.left("AUTH_SERVER_RETURNED_ERROR")
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return E.left("AUTH_TOKEN_REQUEST_FAILED")
|
||||
}
|
||||
|
||||
const expectedSchema = z.object({
|
||||
state: z.string(),
|
||||
tokenEndpoint: z.string(),
|
||||
clientSecret: z.string(),
|
||||
clientID: z.string(),
|
||||
})
|
||||
|
||||
const decodedLocalConfig = expectedSchema.safeParse(JSON.parse(localConfig))
|
||||
|
||||
if (!decodedLocalConfig.success) {
|
||||
return E.left("INVALID_LOCAL_CONFIG")
|
||||
}
|
||||
|
||||
// check if the state matches
|
||||
if (decodedLocalConfig.data.state !== state) {
|
||||
return E.left("INVALID_STATE")
|
||||
}
|
||||
|
||||
// exchange the code for a token
|
||||
const formData = new URLSearchParams()
|
||||
formData.append("code", code)
|
||||
formData.append("client_id", decodedLocalConfig.data.clientID)
|
||||
formData.append("client_secret", decodedLocalConfig.data.clientSecret)
|
||||
formData.append("redirect_uri", OauthAuthService.redirectURI)
|
||||
|
||||
const { response } = interceptorService.runRequest({
|
||||
url: decodedLocalConfig.data.tokenEndpoint,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
data: formData.toString(),
|
||||
})
|
||||
|
||||
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 withAccessTokenSchema = z.object({
|
||||
access_token: z.string(),
|
||||
})
|
||||
|
||||
const parsedTokenResponse = withAccessTokenSchema.safeParse(
|
||||
responsePayload.right
|
||||
)
|
||||
|
||||
return parsedTokenResponse.success
|
||||
? E.right(parsedTokenResponse.data)
|
||||
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
|
||||
}
|
||||
|
||||
export default createFlowConfig(
|
||||
"PASSWORD" as const,
|
||||
PasswordFlowParamsSchema,
|
||||
initPasswordOauthFlow,
|
||||
handleRedirectForAuthCodeOauthFlow
|
||||
)
|
||||
124
packages/hoppscotch-common/src/services/oauth/oauth.service.ts
Normal file
124
packages/hoppscotch-common/src/services/oauth/oauth.service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Service } from "dioc"
|
||||
import { PersistenceService } from "../persistence"
|
||||
import { ZodType, z } from "zod"
|
||||
import * as E from "fp-ts/Either"
|
||||
import authCode, { AuthCodeOauthFlowParams } from "./flows/authCode"
|
||||
import implicit, { ImplicitOauthFlowParams } from "./flows/implicit"
|
||||
import { getService } from "~/modules/dioc"
|
||||
import { HoppCollection } from "@hoppscotch/data"
|
||||
import { TeamCollection } from "~/helpers/backend/graphql"
|
||||
|
||||
export type PersistedOAuthConfig = {
|
||||
source: "REST" | "GraphQL"
|
||||
context?: {
|
||||
type: "collection-properties" | "request-tab"
|
||||
metadata: {
|
||||
collection?: HoppCollection | TeamCollection
|
||||
collectionID?: string
|
||||
}
|
||||
}
|
||||
grant_type: string
|
||||
fields?: (AuthCodeOauthFlowParams | ImplicitOauthFlowParams) & {
|
||||
state: string
|
||||
}
|
||||
token?: string
|
||||
}
|
||||
|
||||
const persistenceService = getService(PersistenceService)
|
||||
|
||||
export const grantTypesInvolvingRedirect = ["AUTHORIZATION_CODE", "IMPLICIT"]
|
||||
|
||||
export const routeOAuthRedirect = async () => {
|
||||
// get the temp data from the local storage
|
||||
const localOAuthTempConfig =
|
||||
persistenceService.getLocalConfig("oauth_temp_config")
|
||||
|
||||
if (!localOAuthTempConfig) {
|
||||
return E.left("INVALID_STATE")
|
||||
}
|
||||
|
||||
const expectedSchema = z.object({
|
||||
source: z.optional(z.string()),
|
||||
grant_type: z.string(),
|
||||
})
|
||||
|
||||
const decodedLocalConfig = expectedSchema.safeParse(
|
||||
JSON.parse(localOAuthTempConfig)
|
||||
)
|
||||
|
||||
if (!decodedLocalConfig.success) {
|
||||
return E.left("INVALID_STATE")
|
||||
}
|
||||
|
||||
// route the request to the correct flow
|
||||
const flowConfig = [authCode, implicit].find(
|
||||
(flow) => flow.flow === decodedLocalConfig.data.grant_type
|
||||
)
|
||||
|
||||
if (!flowConfig) {
|
||||
return E.left("INVALID_STATE")
|
||||
}
|
||||
|
||||
return flowConfig?.onRedirectReceived(localOAuthTempConfig)
|
||||
}
|
||||
|
||||
export function createFlowConfig<
|
||||
Flow extends string,
|
||||
AuthParams extends Record<string, unknown>,
|
||||
InitFuncReturnObject extends Record<string, unknown>,
|
||||
>(
|
||||
flow: Flow,
|
||||
params: ZodType<AuthParams>,
|
||||
init: (
|
||||
params: AuthParams
|
||||
) =>
|
||||
| E.Either<string, InitFuncReturnObject>
|
||||
| Promise<E.Either<string, InitFuncReturnObject>>
|
||||
| E.Either<string, undefined>
|
||||
| Promise<E.Either<string, undefined>>,
|
||||
onRedirectReceived: (localConfig: string) => Promise<
|
||||
E.Either<
|
||||
string,
|
||||
{
|
||||
access_token: string
|
||||
}
|
||||
>
|
||||
>
|
||||
) {
|
||||
return {
|
||||
flow,
|
||||
params,
|
||||
init,
|
||||
onRedirectReceived,
|
||||
}
|
||||
}
|
||||
|
||||
export const decodeResponseAsJSON = (response: { data: any }) => {
|
||||
try {
|
||||
const responsePayload = new TextDecoder("utf-8")
|
||||
.decode(response.data as any)
|
||||
.replaceAll("\x00", "")
|
||||
|
||||
return E.right(JSON.parse(responsePayload) as Record<string, unknown>)
|
||||
} catch (error) {
|
||||
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
|
||||
}
|
||||
}
|
||||
|
||||
export class OauthAuthService extends Service {
|
||||
public static readonly ID = "OAUTH_AUTH_SERVICE"
|
||||
|
||||
static redirectURI = `${window.location.origin}/oauth`
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
export const generateRandomString = () => {
|
||||
const length = 64
|
||||
const possible =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const values = crypto.getRandomValues(new Uint8Array(length))
|
||||
return values.reduce((acc, x) => acc + possible[x % possible.length], "")
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
|
||||
folders: [],
|
||||
requests: [
|
||||
{
|
||||
v: "2",
|
||||
v: "3",
|
||||
endpoint: "https://echo.hoppscotch.io",
|
||||
name: "Echo test",
|
||||
params: [],
|
||||
@@ -50,7 +50,7 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
|
||||
folders: [],
|
||||
requests: [
|
||||
{
|
||||
v: 2,
|
||||
v: 3,
|
||||
name: "Echo test",
|
||||
url: "https://echo.hoppscotch.io/graphql",
|
||||
headers: [],
|
||||
@@ -138,7 +138,7 @@ export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
v: "2",
|
||||
v: "3",
|
||||
},
|
||||
responseMeta: { duration: 807, statusCode: 200 },
|
||||
star: false,
|
||||
@@ -150,7 +150,7 @@ export const GQL_HISTORY_MOCK: GQLHistoryEntry[] = [
|
||||
{
|
||||
v: 1,
|
||||
request: {
|
||||
v: 2,
|
||||
v: 3,
|
||||
name: "Untitled",
|
||||
url: "https://echo.hoppscotch.io/graphql",
|
||||
query: "query Request { url }",
|
||||
@@ -171,7 +171,7 @@ export const GQL_TAB_STATE_MOCK: PersistableTabState<HoppGQLDocument> = {
|
||||
tabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc",
|
||||
doc: {
|
||||
request: {
|
||||
v: 2,
|
||||
v: 3,
|
||||
name: "Untitled",
|
||||
url: "https://echo.hoppscotch.io/graphql",
|
||||
headers: [],
|
||||
@@ -194,7 +194,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState<HoppRESTDocument> = {
|
||||
tabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa",
|
||||
doc: {
|
||||
request: {
|
||||
v: "2",
|
||||
v: "3",
|
||||
endpoint: "https://echo.hoppscotch.io",
|
||||
name: "Echo test",
|
||||
params: [],
|
||||
|
||||
Reference in New Issue
Block a user