diff --git a/spa/src/api/apps/index.ts b/spa/src/api/apps/index.ts index 4b4594aa9c..15cf2725a7 100644 --- a/spa/src/api/apps/index.ts +++ b/spa/src/api/apps/index.ts @@ -1,9 +1,7 @@ import { GetGitHubAppsUrlResponse } from "../../rest-interfaces/oauth-types"; -import { AxiosInstanceWithJWT } from "../axiosInstance"; -import { AxiosResponse } from "axios"; +import { axiosRest } from "../axiosInstance"; -const GitHubApps = { - getAppNewInstallationUrl: (): Promise> => AxiosInstanceWithJWT.get("/rest/app/cloud/installation/new"), +export default { + getAppNewInstallationUrl: () => axiosRest.get("/rest/app/cloud/installation/new"), }; -export default GitHubApps; diff --git a/spa/src/api/auth/index.ts b/spa/src/api/auth/index.ts index 1777bfd7e1..5a1dbdf662 100644 --- a/spa/src/api/auth/index.ts +++ b/spa/src/api/auth/index.ts @@ -1,11 +1,7 @@ import { GetRedirectUrlResponse, ExchangeTokenResponse } from "../../rest-interfaces/oauth-types"; -import { AxiosInstanceWithJWT } from "../axiosInstance"; -import { AxiosResponse } from "axios"; +import { axiosRest } from "../axiosInstance"; -const GitHubAuth = { - generateOAuthUrl: (): Promise> => AxiosInstanceWithJWT.get("/rest/app/cloud/oauth/redirectUrl"), - exchangeToken: (code: string, state: string): Promise> => - AxiosInstanceWithJWT.post("/rest/app/cloud/oauth/exchangeToken", { code, state }) +export default { + generateOAuthUrl: () => axiosRest.get("/rest/app/cloud/oauth/redirectUrl"), + exchangeToken: (code: string, state: string) => axiosRest.post("/rest/app/cloud/oauth/exchangeToken", { code, state }), }; - -export default GitHubAuth; diff --git a/spa/src/api/axiosInstance.ts b/spa/src/api/axiosInstance.ts index 446c7c3f94..8702d8e476 100644 --- a/spa/src/api/axiosInstance.ts +++ b/spa/src/api/axiosInstance.ts @@ -6,25 +6,60 @@ const getHeaders = (): Promise => new Promise(resolve => { }); }); -const AxiosInstanceWithJWT = axios.create({ +const axiosRest = axios.create({ timeout: 3000 }); - // Adding the token in the headers through interceptors because it is an async value -AxiosInstanceWithJWT.interceptors.request.use(async (config) => { +axiosRest.interceptors.request.use(async (config) => { config.headers.Authorization = await getHeaders(); return config; }); -const AxiosInstanceWithGHToken = async (gitHubToken: string) => axios.create({ - timeout: 3000, - headers: { - "github-auth": gitHubToken, - Authorization: await getHeaders() +/* + * IMPORTANT + * This is a secret store of the github access token + * DO NOT export/exposed this store + * Only write operation is allowed + */ +let gitHubToken: string | undefined = undefined; + +const clearGitHubToken = () => { + gitHubToken = undefined; +} + +const setGitHubToken = (newToken: string) => { + gitHubToken = newToken; +} + +const hasGitHubToken = () => { + if (!!gitHubToken) { + return true; } + return false; +} + +const axiosGitHub = axios.create({ + timeout: 3000 +}); +axiosGitHub.interceptors.request.use(async (config) => { + config.headers["Authorization"] = `Bearer ${gitHubToken}`; + return config; +}); + +const axiosRestWithGitHubToken = axios.create({ + timeout: 3000 +}); +axiosRestWithGitHubToken.interceptors.request.use(async (config) => { + config.headers.Authorization = await getHeaders(); + config.headers["github-auth"] = gitHubToken; + return config; }); export { - AxiosInstanceWithJWT, - AxiosInstanceWithGHToken + axiosGitHub, + axiosRest, + axiosRestWithGitHubToken, + clearGitHubToken, + setGitHubToken, + hasGitHubToken, }; diff --git a/spa/src/api/github/index.ts b/spa/src/api/github/index.ts new file mode 100644 index 0000000000..66503cbaeb --- /dev/null +++ b/spa/src/api/github/index.ts @@ -0,0 +1,6 @@ +import { UsersGetAuthenticatedResponse } from "../../rest-interfaces/oauth-types"; +import { axiosGitHub } from "../axiosInstance"; + +export default { + getUserDetails: () => axiosGitHub.get("https://api.github.com/user"), +}; diff --git a/spa/src/api/index.ts b/spa/src/api/index.ts index 41803d34d1..ed07f9a109 100644 --- a/spa/src/api/index.ts +++ b/spa/src/api/index.ts @@ -1,11 +1,15 @@ import Token from "./token"; -import GitHubAuth from "./auth"; -import GitHubApps from "./apps"; +import Auth from "./auth"; +import App from "./apps"; +import Orgs from "./orgs"; +import GitHub from "./github"; const ApiRequest = { token: Token, - githubAuth: GitHubAuth, - gitHubApp: GitHubApps, + auth: Auth, + gitHub: GitHub, + app: App, + orgs: Orgs }; export default ApiRequest; diff --git a/spa/src/api/orgs/index.ts b/spa/src/api/orgs/index.ts new file mode 100644 index 0000000000..1a67e3507a --- /dev/null +++ b/spa/src/api/orgs/index.ts @@ -0,0 +1,7 @@ +import { OrganizationsResponse } from "../../rest-interfaces/oauth-types"; +import { axiosRestWithGitHubToken } from "../axiosInstance"; + +export default { + getOrganizations: async () => axiosRestWithGitHubToken.get("/rest/app/cloud/org"), + connectOrganization: async (orgId: number) => axiosRestWithGitHubToken.post("/rest/app/cloud/org", { installationId: orgId }), +}; diff --git a/spa/src/api/token/index.ts b/spa/src/api/token/index.ts index 26c2aa19c2..1c17120b19 100644 --- a/spa/src/api/token/index.ts +++ b/spa/src/api/token/index.ts @@ -1,19 +1,7 @@ -import axios, { AxiosResponse } from "axios"; -import { OrganizationsResponse, UsersGetAuthenticatedResponse } from "../../rest-interfaces/oauth-types"; -import { AxiosInstanceWithGHToken } from "../axiosInstance"; +import { clearGitHubToken, setGitHubToken, hasGitHubToken } from "../axiosInstance"; -const Token = { - getUserDetails: (token: string): Promise> => axios.get("https://api.github.com/user", { - headers: { Authorization: `Bearer ${token}`} - }), - getOrganizations: async (token: string): Promise> => { - const instance = await AxiosInstanceWithGHToken(token); - return instance.get("/rest/app/cloud/org"); - }, - connectOrganization: async (token: string, orgId: number): Promise> => { - const instance = await AxiosInstanceWithGHToken(token); - return instance.post("/rest/app/cloud/org", { installationId: orgId }); - }, +export default { + hasGitHubToken, + clearGitHubToken, + setGitHubToken, }; - -export default Token; diff --git a/spa/src/global.d.ts b/spa/src/global.d.ts index c7e2550a64..760fedd867 100644 --- a/spa/src/global.d.ts +++ b/spa/src/global.d.ts @@ -1,7 +1,6 @@ import { OrganizationsResponse } from "./rest-interfaces/oauth-types.ts"; declare global { - let OAuthManagerInstance: OAuthManagerType; const AP: AtlassianPlugin; } @@ -16,13 +15,3 @@ interface AtlassianPlugin { } } -export interface OAuthManagerType { - checkValidity: () => Promise; - fetchOrgs: () => Promise; - connectOrg: (orgId: number) => Promise; - authenticateInGitHub: () => Promise; - finishOAuthFlow: (code: string, state: string) => Promise; - getUserDetails: () => { username: string | undefined, email: string | undefined }; - clear: () => void; - installNewApp: (onFinish: () => void) => Promise; -} diff --git a/spa/src/index.tsx b/spa/src/index.tsx index e4e7db97ee..7a822065eb 100644 --- a/spa/src/index.tsx +++ b/spa/src/index.tsx @@ -7,19 +7,12 @@ import { Route, Routes, } from "react-router-dom"; -import OauthManager from "./oauth-manager"; import StartConnection from "./pages/StartConnection"; import ConfigSteps from "./pages/ConfigSteps"; import Connected from "./pages/Connected"; -/** - * This is the global variable for handling Auth related methods - */ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -globalThis.OAuthManagerInstance = OauthManager(); - const App = () => { + setGlobalTheme({ light: "light", dark: "dark", diff --git a/spa/src/oauth-manager/index.tsx b/spa/src/oauth-manager/index.tsx deleted file mode 100644 index b025f6273c..0000000000 --- a/spa/src/oauth-manager/index.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import ApiRequest from "../api"; -import { OAuthManagerType } from "../global"; - -const STATE_KEY = "oauth-localStorage-state"; - -const FIFTEEN_MINUTES_IN_MS = 15 * 60 * 1000; - -const OauthManager = (): OAuthManagerType => { - let accessToken: string | undefined; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - let refreshToken: string | undefined; - let username: string | undefined; - let email: string | undefined; - - async function checkValidity() { - if (!accessToken) return; - try { - const res = await ApiRequest.token.getUserDetails(accessToken); - username = res.data.login; - email = res.data.email; - - return res.status === 200; - }catch (e) { - console.error(e, "Failed to check validity"); - return false; - } - } - - async function fetchOrgs() { - if (!accessToken) return; - - try { - const response = await ApiRequest.token.getOrganizations(accessToken); - return response.data; - } catch (e) { - console.error(e, "Failed to fetch organizations"); - return undefined; - } - } - - async function connectOrg(orgId: number) { - if (!accessToken) return; - - try { - const response = await ApiRequest.token.connectOrganization(accessToken, orgId); - return response.status === 200; - } catch (e) { - console.error(e, "Failed to fetch organizations"); - return false; - } - } - - async function authenticateInGitHub() { - const res = await ApiRequest.githubAuth.generateOAuthUrl(); - if (res.data.redirectUrl && res.data.state) { - window.localStorage.setItem(STATE_KEY, res.data.state); - window.open(res.data.redirectUrl); - } - } - - async function finishOAuthFlow(code: string, state: string) { - - if (!code) return false; - if (!state) return false; - - const prevState = window.localStorage.getItem(STATE_KEY); - window.localStorage.removeItem(STATE_KEY); - if (state !== prevState) return false; - - const token = await ApiRequest.githubAuth.exchangeToken(code, state); - if (token.data.accessToken) { - setTokens(token.data.accessToken, token.data.refreshToken); - return true; - } - - return false; - } - - async function installNewApp(onFinish: () => void) { - const app = await ApiRequest.gitHubApp.getAppNewInstallationUrl(); - const exp = new Date(new Date().getTime() + FIFTEEN_MINUTES_IN_MS); - document.cookie = `is-spa=true; expires=${exp.toUTCString()}; path=/; SameSite=None; Secure`; - const winInstall = window.open(app.data.appInstallationUrl, "_blank"); - const hdlWinInstall = setInterval(() => { - if (winInstall?.closed) { - try { - document.cookie = "is-spa=;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=None; Secure"; - onFinish(); - } finally { - clearInterval(hdlWinInstall); - } - } - }, 1000); - } - - function setTokens(at: string, rt: string) { - accessToken = at; - refreshToken = rt; - } - - function getUserDetails() { - return { - username, - email - }; - } - - function clear() { - accessToken = undefined; - refreshToken = undefined; - username = undefined; - email = undefined; - } - - return { - checkValidity, - fetchOrgs, - connectOrg, - authenticateInGitHub, - finishOAuthFlow, - getUserDetails, - clear, - installNewApp, - }; -}; - -export default OauthManager; diff --git a/spa/src/pages/ConfigSteps/index.tsx b/spa/src/pages/ConfigSteps/index.tsx index 173c01365b..ac6a3d7977 100644 --- a/spa/src/pages/ConfigSteps/index.tsx +++ b/spa/src/pages/ConfigSteps/index.tsx @@ -13,6 +13,8 @@ import OfficeBuildingIcon from "@atlaskit/icon/glyph/office-building"; import { useNavigate } from "react-router-dom"; import { ErrorType } from "../../rest-interfaces/oauth-types"; import Error from "../../components/Error"; +import AppManager from "../../services/app-manager"; +import OAuthManager from "../../services/oauth-manager"; type GitHubOptionType = { selectedOption: number; @@ -85,7 +87,7 @@ const Paragraph = styled.div` const ConfigSteps = () => { const navigate = useNavigate(); - const { username, email } = OAuthManagerInstance.getUserDetails(); + const { username, email } = OAuthManager.getUserDetails(); const isAuthenticated = !!(username && email); const originalUrl = window.location.origin; @@ -125,7 +127,7 @@ const ConfigSteps = () => { const getOrganizations = async () => { setLoaderForOrgFetching(true); - const response = await OAuthManagerInstance.fetchOrgs(); + const response = await AppManager.fetchOrgs(); if (response) { setOrganizations(response?.orgs.map((org: any) => ({ label: org.account.login, @@ -140,7 +142,7 @@ const ConfigSteps = () => { const handler = async (event: any) => { if (event.origin !== originalUrl) return; if (event.data?.code) { - const success = await OAuthManagerInstance.finishOAuthFlow(event.data?.code, event.data?.state); + const success = await OAuthManager.finishOAuthFlow(event.data?.code, event.data?.state); // TODO: add some visual input in case of errors if (!success) return; } @@ -158,9 +160,9 @@ const ConfigSteps = () => { }, []); useEffect(() => { - OAuthManagerInstance.checkValidity().then((status: boolean | undefined) => { + OAuthManager.checkValidity().then((status: boolean | undefined) => { if (status) { - setLoggedInUser(OAuthManagerInstance.getUserDetails().username); + setLoggedInUser(OAuthManager.getUserDetails().username); setLoaderForLogin(false); getOrganizations(); } @@ -171,7 +173,7 @@ const ConfigSteps = () => { switch (selectedOption) { case 1: { setLoaderForLogin(true); - await OAuthManagerInstance.authenticateInGitHub(); + await OAuthManager.authenticateInGitHub(); break; } case 2: { @@ -186,7 +188,7 @@ const ConfigSteps = () => { const logout = () => { window.open("https://github.com/logout"); - OAuthManagerInstance.clear(); + OAuthManager.clear(); setIsLoggedIn(false); setCompletedStep1(false); setLoaderForLogin(false); @@ -200,7 +202,7 @@ const ConfigSteps = () => { const connectGitHubOrg = async () => { if (selectedOrg?.value) { setLoaderForOrgConnection(true); - const connected = await OAuthManagerInstance.connectOrg(selectedOrg?.value); + const connected = await AppManager.connectOrg(selectedOrg?.value); if (connected) { navigate("/spa/connected"); } @@ -209,7 +211,7 @@ const ConfigSteps = () => { }; const installNewOrg = async () => { - await OAuthManagerInstance.installNewApp(() => { + await AppManager.installNewApp(() => { getOrganizations(); }); }; diff --git a/spa/src/pages/ConfigSteps/test.tsx b/spa/src/pages/ConfigSteps/test.tsx index ff03cca7bf..5f71ea421c 100644 --- a/spa/src/pages/ConfigSteps/test.tsx +++ b/spa/src/pages/ConfigSteps/test.tsx @@ -3,6 +3,11 @@ import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { BrowserRouter } from "react-router-dom"; import ConfigSteps from "./index"; +import OAuthManager from "../../services/oauth-manager"; +import AppManager from "../../services/app-manager"; + +jest.mock("../../services/oauth-manager"); +jest.mock("../../services/app-manager"); const Authenticated = { checkValidity: jest.fn().mockReturnValue(Promise.resolve(true)), @@ -29,15 +34,20 @@ const UnAuthenticated = { getToken: jest.fn() } }; -(global as any).OAuthManagerInstance = UnAuthenticated; window.open = jest.fn(); -test("Connect GitHub Screen - Initial Loading of the page when not authenticated", () => { - render( - - - - ); +test("Connect GitHub Screen - Initial Loading of the page when not authenticated", async () => { + + jest.mocked(OAuthManager).getUserDetails = UnAuthenticated.getUserDetails; + jest.mocked(OAuthManager).checkValidity = UnAuthenticated.checkValidity; + + await act(async () => { + render( + + + + ); + }); expect(screen.queryByText("Connect Github to Jira")).toBeInTheDocument(); expect(screen.queryByText("GitHub Cloud")).toBeInTheDocument(); @@ -47,11 +57,17 @@ test("Connect GitHub Screen - Initial Loading of the page when not authenticated }); test("Connect GitHub Screen - Checking the GitHub Enterprise flow when not authenticated", async () => { - render( - - - - ); + + jest.mocked(OAuthManager).getUserDetails = UnAuthenticated.getUserDetails; + jest.mocked(OAuthManager).checkValidity = UnAuthenticated.checkValidity; + + await act(async () => { + render( + + + + ); + }); await act(() => userEvent.click(screen.getByText("GitHub Enterprise Server"))); await act(() => userEvent.click(screen.getByText("Authorize in GitHub"))); @@ -60,26 +76,38 @@ test("Connect GitHub Screen - Checking the GitHub Enterprise flow when not authe }); test("Connect GitHub Screen - Checking the GitHub Cloud flow when not authenticated", async () => { - render( - - - - ); + + jest.mocked(OAuthManager).getUserDetails = UnAuthenticated.getUserDetails; + jest.mocked(OAuthManager).checkValidity = UnAuthenticated.checkValidity; + jest.mocked(OAuthManager).authenticateInGitHub = UnAuthenticated.authenticateInGitHub; + + await act(async () => { + render( + + + + ); + }); await act(() => userEvent.click(screen.getByText("GitHub Cloud"))); await act(() => userEvent.click(screen.getByText("Authorize in GitHub"))); - expect(OAuthManagerInstance.authenticateInGitHub).toHaveBeenCalled(); + expect(OAuthManager.authenticateInGitHub).toHaveBeenCalled(); }); -test("Connect GitHub Screen - Checking the GitHub Cloud flow when authenticated", () => { - (global as any).OAuthManagerInstance = Authenticated; +test("Connect GitHub Screen - Checking the GitHub Cloud flow when authenticated", async () => { + + jest.mocked(OAuthManager).getUserDetails = Authenticated.getUserDetails; + jest.mocked(OAuthManager).checkValidity = Authenticated.checkValidity; + jest.mocked(AppManager).fetchOrgs = Authenticated.fetchOrgs; - render( - - - - ); + await act(async () => { + render( + + + + ); + }); expect(screen.queryByText("GitHub Cloud")).not.toBeInTheDocument(); expect(screen.queryByText("GitHub Enterprise Server")).not.toBeInTheDocument(); @@ -88,13 +116,18 @@ test("Connect GitHub Screen - Checking the GitHub Cloud flow when authenticated" }); test("Connect GitHub Screen - Changing GitHub login when authenticated", async () => { - (global as any).OAuthManagerInstance = Authenticated; - render( - - - - ); + jest.mocked(OAuthManager).getUserDetails = Authenticated.getUserDetails; + jest.mocked(OAuthManager).checkValidity = Authenticated.checkValidity; + jest.mocked(AppManager).fetchOrgs = Authenticated.fetchOrgs; + + await act(async () => { + render( + + + + ); + }); await act(() => userEvent.click(screen.getByText("Log in and authorize"))); @@ -108,3 +141,4 @@ test("Connect GitHub Screen - Changing GitHub login when authenticated", async ( expect(screen.queryByText("GitHub Enterprise Server")).toBeInTheDocument(); expect(screen.queryByText("Authorize in GitHub")).toBeInTheDocument(); }); + diff --git a/spa/src/services/app-manager/index.ts b/spa/src/services/app-manager/index.ts new file mode 100644 index 0000000000..5992199a59 --- /dev/null +++ b/spa/src/services/app-manager/index.ts @@ -0,0 +1,53 @@ +import Api from "../../api"; +import { OrganizationsResponse } from "../../rest-interfaces/oauth-types"; + +const FIFTEEN_MINUTES_IN_MS = 15 * 60 * 1000; + +async function fetchOrgs(): Promise { + + if (!Api.token.hasGitHubToken()) return { orgs: [] }; + + try { + const response = await Api.orgs.getOrganizations(); + return response.data; + } catch (e) { + console.error(e, "Failed to fetch organizations"); + return { orgs: [] }; + } +} + +async function connectOrg(orgId: number): Promise { + + if (!Api.token.hasGitHubToken()) return false; + + try { + const response = await Api.orgs.connectOrganization(orgId); + return response.status === 200; + } catch (e) { + console.error(e, "Failed to fetch organizations"); + return false; + } +} + +async function installNewApp(onFinish: () => void): Promise { + const app = await Api.app.getAppNewInstallationUrl(); + const exp = new Date(new Date().getTime() + FIFTEEN_MINUTES_IN_MS); + document.cookie = `is-spa=true; expires=${exp.toUTCString()}; path=/; SameSite=None; Secure`; + const winInstall = window.open(app.data.appInstallationUrl, "_blank"); + const hdlWinInstall = setInterval(() => { + if (winInstall?.closed) { + try { + document.cookie = "is-spa=;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=None; Secure"; + onFinish(); + } finally { + clearInterval(hdlWinInstall); + } + } + }, 1000); +} + +export default { + fetchOrgs, + connectOrg, + installNewApp +}; diff --git a/spa/src/services/oauth-manager/index.ts b/spa/src/services/oauth-manager/index.ts new file mode 100644 index 0000000000..389cf961f4 --- /dev/null +++ b/spa/src/services/oauth-manager/index.ts @@ -0,0 +1,70 @@ +import Api from "../../api"; + +const STATE_KEY = "oauth-localStorage-state"; + +let username: string | undefined; +let email: string | undefined; + +async function checkValidity(): Promise { + + if (!Api.token.hasGitHubToken()) return false; + + try { + const res = await Api.gitHub.getUserDetails(); + username = res.data.login; + email = res.data.email; + + return res.status === 200; + }catch (e) { + console.error(e, "Failed to check validity"); + return false; + } +} + +async function authenticateInGitHub(): Promise { + const res = await Api.auth.generateOAuthUrl(); + if (res.data.redirectUrl && res.data.state) { + window.localStorage.setItem(STATE_KEY, res.data.state); + window.open(res.data.redirectUrl); + } +} + +async function finishOAuthFlow(code: string, state: string): Promise { + + if (!code) return false; + if (!state) return false; + + const prevState = window.localStorage.getItem(STATE_KEY); + window.localStorage.removeItem(STATE_KEY); + if (state !== prevState) return false; + + const token = await Api.auth.exchangeToken(code, state); + if (token.data.accessToken) { + Api.token.setGitHubToken(token.data.accessToken); + return true; + } + + return false; +} + +function getUserDetails() { + return { + username, + email + }; +} + +function clear() { + Api.token.clearGitHubToken(); + username = undefined; + email = undefined; +} + +export default { + checkValidity, + authenticateInGitHub, + finishOAuthFlow, + getUserDetails, + clear, +}; +