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
|
||||
)
|
||||
Reference in New Issue
Block a user