diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 071247b01b0da57b829e0a4c59e487aacf0a1274..43a5a687a1c4c365145aff2a69e13c5a3630e0f2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,41 +1,51 @@ -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -import { useState, useEffect } from 'react'; -import { BucketInfo } from './models/bucket'; -import { BucketBrowser } from './routes/BucketBrowser'; -import { staticRouts } from './routes'; -import APIService from './services/APIService'; +import { + BrowserRouter, + createBrowserRouter, + Route, + RouterProvider, + Routes +} from 'react-router-dom'; +import { Login } from './routes/Login'; +import { staticRoutes } from './routes'; import { BucketsListContext } from './services/BucketListContext'; +import { useOAuth } from './services/OAuth2'; +import { OAuthPopup } from './services/OAuth2'; +import { useState } from 'react'; +import { BucketInfo } from './models/bucket'; function App() { - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [bucketList, setBucketLists] = useState<BucketInfo[]>([]); - - useEffect(() => { - setIsAuthenticated(APIService.isAuthenticated()); - APIService - .get("buckets") - .then(data => { - const buckets: BucketInfo[] = data["buckets"]; - console.log(`Fetched ${buckets.length} buckets`); - setBucketLists(buckets); - }); - }, [isAuthenticated]); - - let routes = staticRouts.map(route => { + const [bucketList, setBucketList] = useState<BucketInfo[]>([]); + const oAuth = useOAuth(); + + if (oAuth.error) { + return <div>Ops... {oAuth.error.message}</div>; + } + + console.log("Is user authenticated", oAuth.isAuthenticated) + + if (!oAuth.isAuthenticated) { + return ( + <BrowserRouter> + <Routes> + <Route path="/" element={<Login onClick={oAuth.signinPopup} />} /> + <Route path="/callback" element={<OAuthPopup />} /> + </Routes> + </BrowserRouter> + ) + } + + let routes = staticRoutes.map(route => { return { path: route.path, element: route.element } }); - routes.push(...bucketList.map(bucketInfo => { - return { - path: "/" + bucketInfo.name, - element: <BucketBrowser bucketName={bucketInfo.name} /> - } - })); - + routes.push({ + path: "/login", + element: <Login onClick={oAuth.signinPopup} /> + }); const router = createBrowserRouter(routes); return ( diff --git a/frontend/src/commons/costants.ts b/frontend/src/commons/costants.ts new file mode 100644 index 0000000000000000000000000000000000000000..65aece87dc4fc23845ea82886bba8005c8b042c4 --- /dev/null +++ b/frontend/src/commons/costants.ts @@ -0,0 +1,4 @@ + +export const OAUTH_STATE_STORAGE_KEY = "oauth-state-key"; +export const OAUTH_RESPONSE_MESSAGE_TYPE = "oauth2-response" +export const API_ENDPOINT = "/api/v1" \ No newline at end of file diff --git a/frontend/src/components/Drawer.tsx b/frontend/src/components/Drawer.tsx index 73d9395fe95aa9056d0e42c9251eba67210be726..cd141ad4ad5662a9cf9359bf6ee63d05ee17254f 100644 --- a/frontend/src/components/Drawer.tsx +++ b/frontend/src/components/Drawer.tsx @@ -1,9 +1,10 @@ -import { staticRouts } from '../routes'; +import { staticRoutes } from '../routes'; +import { useOAuth } from '../services/OAuth2'; import { Link } from 'react-router-dom'; export const Drawer = () => { const path = window.location.pathname; - const links = staticRouts.map(route => { + const links = staticRoutes.map(route => { let className = "h-10 hover:bg-neutral-300 rounded-lg p-2"; className += route.path === path ? " bg-neutral-200" : ""; return ( @@ -13,10 +14,16 @@ export const Drawer = () => { ) }); + const { user } = useOAuth(); + const userName: string | null = user?.profile && user?.profile["name"] ? user?.profile["name"] : null; return ( <> + <img className="w-full bg-gray-100 p-4" alt="" src="/logo530.png" /> <nav className="h-full p-4 bg-gray-100 dark:bg-gray-800"> + { + userName ? <div className='p-4 text-xl font-semibold'>{userName}</div> : null + } <ul> {links} </ul> diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index ed876c64fd116da302fe4294f089cb19394189fd..f3a66c962f459d2f4fc29382ecaaa05563aa096d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,14 +1,26 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import './index.css'; import App from './App'; +import { OAuthProvider } from './services/OAuth2'; +import './index.css'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); +const oidcConfig = { + authority: "https://keycloak-demo.cloud.cnaf.infn.it:8222", + client_id: "66a8f7e8-a5ef-4ef1-8e2e-3389f1170ae7", + redirect_uri: "http://localhost:8080/callback", + scope: "openid email profile offline_access", + grant_type: "authorization_code", + response_type: "code" +}; + root.render( <React.StrictMode> - <App/> + <OAuthProvider {...oidcConfig}> + <App /> + </OAuthProvider> </React.StrictMode> ); \ No newline at end of file diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index db3e4852900fe25502b093efa7a7447497741934..12b8477a9b859651a386a27189a6f4ea79c72592 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -8,7 +8,7 @@ export type Route = { element: ReactNode } -export const staticRouts: Route[] = [ +export const staticRoutes: Route[] = [ { title: "Home", path: "/", element: <Home /> }, { title: "Buckets", path: "/buckets", element: <Buckets /> } ]; diff --git a/frontend/src/services/OAuth2/OAuthContext.ts b/frontend/src/services/OAuth2/OAuthContext.ts new file mode 100644 index 0000000000000000000000000000000000000000..10c4d3821689f1ab3a1a28d679446ea2265788ba --- /dev/null +++ b/frontend/src/services/OAuth2/OAuthContext.ts @@ -0,0 +1,8 @@ +import { IOAuthState } from "./OAuthState"; +import { createContext } from "react"; + +export interface OAuthContextProps extends IOAuthState { + signinPopup(): void; +} + +export const OAuthContext = createContext<OAuthContextProps | undefined>(undefined); diff --git a/frontend/src/services/OAuth2/OAuthPopup.tsx b/frontend/src/services/OAuth2/OAuthPopup.tsx new file mode 100644 index 0000000000000000000000000000000000000000..52798f3a3dd3f9d085fbe7a4ce97085540208f03 --- /dev/null +++ b/frontend/src/services/OAuth2/OAuthPopup.tsx @@ -0,0 +1,48 @@ +import { useEffect } from "react"; +import { OAUTH_RESPONSE_MESSAGE_TYPE, OAUTH_STATE_STORAGE_KEY } from "../../commons/costants"; + +const checkState = (receivedState: string) => { + const state = sessionStorage.getItem(OAUTH_STATE_STORAGE_KEY); + console.log("received:", receivedState, "expceted:", state); + return state === receivedState; +} + +const queryToObject = (query: string) => { + const parameters = new URLSearchParams(query); + return Object.fromEntries(parameters.entries()); +} + +export const OAuthPopup = () => { + useEffect(() => { + const payload = queryToObject(window.location.search.split("?")[1]) + const state = payload && payload.state; + const error = payload && payload.error; + + if (!window.opener) { + throw new Error("No window opener"); + } + + if (error) { + window.opener.postMessage({ + type: OAUTH_RESPONSE_MESSAGE_TYPE, + error: decodeURI(error) || "OAuth error: An error has occured." + }); + } else if (state && checkState(state)) { + window.opener.postMessage({ + type: OAUTH_RESPONSE_MESSAGE_TYPE, + payload + }) + } else { + window.opener.postMessage({ + type: OAUTH_RESPONSE_MESSAGE_TYPE, + error: "OAuth error: State mismatch." + }); + } + }, []); + + return ( + <div className="font-lg mx-auto mt-10"> + Loading... + </div> + ); +} diff --git a/frontend/src/services/OAuth2/OAuthProvider.tsx b/frontend/src/services/OAuth2/OAuthProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..feb36a15c3c03a899c36d1de6ebcfd689d311333 --- /dev/null +++ b/frontend/src/services/OAuth2/OAuthProvider.tsx @@ -0,0 +1,184 @@ +import { useCallback, useRef, useState } from "react" +import { OAuthContext } from "./OAuthContext" +import { IOAuthState, initialOAuthState } from "./OAuthState" +import { OAUTH_RESPONSE_MESSAGE_TYPE, OAUTH_STATE_STORAGE_KEY } from "../../commons/costants" +import { tokenPostRequest } from "./OAuthTokenRequest" +import { OidcToken, OidcClientSettings } from "./OidcConfig" +import { User } from "./User" + + +interface OAuthProviderProps extends OidcClientSettings { + children?: React.ReactNode; +} + +type Timer = ReturnType<typeof setTimeout>; + + +const getAuthorizationUrl = (props: OidcClientSettings) => { + let url = `${props.authority}` + + `/authorize?` + + `&redirect_uri=${props.redirect_uri}` + + `&client_id=${props.client_id}` + + url += props.client_secret ? `&client_secret=${props.client_secret}` : ""; + url += props.response_type ? `&response_type=${props.response_type}` : ""; + url += props.scope ? `&scope=${props.scope}` : ""; + url += props.state ? `&state=${props.state}` : ""; + return url; +} + +const generateState = () => { + const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let array = new Uint8Array(40); + window.crypto.getRandomValues(array); + array = array.map((x) => validChars.codePointAt(x % validChars.length) || 0); + const randomState = String.fromCharCode.apply(null, Array.from(array)); + return randomState; +}; + +const saveState = (state: string) => { + sessionStorage.setItem(OAUTH_STATE_STORAGE_KEY, state); +} + +const openWindow = (url: string) => { + return window.open(url); +} + +const closePopup = (popupRef: React.MutableRefObject<Window | undefined>) => { + popupRef.current?.close(); +} + +const cleanup = ( + intervalRef: React.MutableRefObject<Timer | undefined>, + popupRef: React.MutableRefObject<Window | undefined>, + handleMessageListener: (message: any) => Promise<void>) => { + clearInterval(intervalRef.current); + closePopup(popupRef); + window.removeEventListener('message', handleMessageListener); +} + +const parseJwt = (token: string) => { + var base64Payload = token.split('.')[1]; + var payload = window.atob(base64Payload); + return JSON.parse(payload); +} + +export const OAuthProvider = (props: OAuthProviderProps): JSX.Element => { + const { children } = props; + const [oAuthState, setOAuthState] = useState<IOAuthState>(initialOAuthState); + const popupRef = useRef<Window>(); + const intervalRef = useRef<Timer>(); + + const signinPopup = useCallback(() => { + // 1. Init + setOAuthState(initialOAuthState); + + // 2. Generate and save state + const state = generateState(); + saveState(state); + + // 3. Open window + const url = getAuthorizationUrl({ ...props, state }); + popupRef.current = openWindow(url) || undefined; + + // 4. Register message listener + async function handleMessageListener(message: any) { + + if (message?.data?.type !== OAUTH_RESPONSE_MESSAGE_TYPE) { + return; + } + + try { + const errorMaybe = message && message.data && message.data.error; + if (errorMaybe) { + setOAuthState({ + isLoading: false, + isAuthenticated: false, + error: errorMaybe && new Error(errorMaybe) + }); + console.log(errorMaybe); + } else { + const { code } = message && message.data && message.data.payload; + // Sent POST request to backend + tokenPostRequest({ + client_id: props.client_id, + redirect_uri: props.redirect_uri, + code: code, + grant_type: props.grant_type, + code_verifier: undefined + }) + .then(response => response.json()) + .then(data => { + const token = OidcToken.createTokenFromResponse(data); + const user = new User({ + session_state: null, + profile: parseJwt(token.access_token), + token: token + }); + // The user is now logged in + console.log(parseJwt(token.access_token)); + setOAuthState({ + isLoading: false, + isAuthenticated: true, + user: user + }); + }) + .catch((err) => { + console.error(err); + setOAuthState({ + isLoading: false, + isAuthenticated: false, + error: err instanceof Error ? err : new Error("Uknown error") + }); + }); + } + } catch (err) { + setOAuthState({ + isLoading: false, + isAuthenticated: false, + error: err instanceof Error ? err : new Error("Uknown error") + }); + console.log(err); + } finally { + cleanup(intervalRef, popupRef, handleMessageListener); + } + } + window.addEventListener('message', handleMessageListener); + + // 5. Begin interval to check if popup was closed forcelly by the user + intervalRef.current = setInterval(() => { + const popupClosed = !popupRef.current + || !popupRef.current.window + || popupRef.current.window.closed; + if (popupClosed) { + setOAuthState({ + isAuthenticated: false, + isLoading: false, + }); + console.warn("Warning: Popup was closed before completing authentication."); + cleanup(intervalRef, popupRef, handleMessageListener); + } + }, 250); + + // 6. Remove listener(s) on unmount + return () => { + window.removeEventListener('message', handleMessageListener); + if (intervalRef.current) clearInterval(intervalRef.current); + }; + + }, [props]); + + return ( + <OAuthContext.Provider + value={{ + signinPopup: signinPopup, + isAuthenticated: oAuthState.isAuthenticated, + isLoading: oAuthState.isLoading, + error: oAuthState.error, + user: oAuthState.user + }} + > + {children} + </OAuthContext.Provider> + ) +} diff --git a/frontend/src/services/OAuth2/OAuthState.ts b/frontend/src/services/OAuth2/OAuthState.ts new file mode 100644 index 0000000000000000000000000000000000000000..044f55c15c4b4796408ca10bf10797efe3c1defe --- /dev/null +++ b/frontend/src/services/OAuth2/OAuthState.ts @@ -0,0 +1,15 @@ +import type { User } from './User'; + +export interface IOAuthState { + user?: User | null; + isLoading: boolean; + isAuthenticated: boolean; + activeNavigator?: "signinRedirect"; + error?: Error; +} + +export const initialOAuthState: IOAuthState = { + user: null, + isLoading: true, + isAuthenticated: false +} diff --git a/frontend/src/services/OAuth2/OAuthTokenRequest.ts b/frontend/src/services/OAuth2/OAuthTokenRequest.ts new file mode 100644 index 0000000000000000000000000000000000000000..97358c1350bdd768e01dabfd016aab6836b0bb4b --- /dev/null +++ b/frontend/src/services/OAuth2/OAuthTokenRequest.ts @@ -0,0 +1,22 @@ +import { API_ENDPOINT } from "../../commons/costants"; + +const defaultGrantType = "authorization_code"; + +interface TokenRequestParams { + client_id: string; + redirect_uri: string; + code: string; + grant_type?: string; + code_verifier?: string; +} + +export function tokenPostRequest(params: TokenRequestParams): Promise<Response> { + params.grant_type = params.grant_type ?? defaultGrantType; + return fetch(API_ENDPOINT + "/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(params) + }); +} diff --git a/frontend/src/services/OAuth2/OidcConfig.ts b/frontend/src/services/OAuth2/OidcConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4d7966079aa7502c4955bd538928651d7e646dd --- /dev/null +++ b/frontend/src/services/OAuth2/OidcConfig.ts @@ -0,0 +1,59 @@ +class Timer { + static getEpochTime(): number { + return Math.floor(Date.now() / 1000); + } +} + +export interface OidcClientSettings { + authority: string, + client_id: string + redirect_uri: string + client_secret?: string + response_type?: string + scope?: string + state?: string + grant_type?: string +} + +export interface IOidcToken { + id_token: string, + access_token: string, + refresh_token?: string, + token_type: string, + expires_in: number, + scope?: string, +} + +export class OidcToken { + id_token: string; + access_token: string; + refresh_token?: string; + token_type: string; + expires_at: number; + scope?: string; + + private constructor(args: IOidcToken) { + this.id_token = args.id_token; + this.access_token = args.access_token; + this.refresh_token = args.refresh_token; + this.token_type = args.token_type; + this.expires_at = args.expires_in; + this.scope = args.scope; + } + + static createTokenFromResponse(data: any): OidcToken { + return new OidcToken(data); + } + + public get expires_in(): number { + return this.expires_in - Timer.getEpochTime(); + } + + get expired(): boolean | undefined { + return this.expires_in <= 0; + } + + get scopes(): string[] { + return this.scope?.split(" ") ?? []; + } +} diff --git a/frontend/src/services/OAuth2/User.ts b/frontend/src/services/OAuth2/User.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f76ac66d5102af0afff71434b8cf7451543d2df --- /dev/null +++ b/frontend/src/services/OAuth2/User.ts @@ -0,0 +1,30 @@ +import { OidcToken } from "./OidcConfig"; + + +export class User { + session_state: string | null; + profile: undefined; + token?: OidcToken; + readonly state: unknown; + + constructor(args: { + session_state: string | null, + profile: undefined, + token?: OidcToken + }) { + this.session_state = args.session_state ?? null; + this.profile = args.profile; + } + + toStorageString(): string { + return JSON.stringify({ + session_state: this.session_state, + profile: this.profile, + token: this.token + }); + } + + fromStorageString(storageString: string): User { + return new User(JSON.parse(storageString)); + } +} diff --git a/frontend/src/services/OAuth2/index.ts b/frontend/src/services/OAuth2/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfcf7fa6d933f5d5dfbeab85fa98eb26036bd7ad --- /dev/null +++ b/frontend/src/services/OAuth2/index.ts @@ -0,0 +1,4 @@ +export * from "./OAuthProvider"; +export * from "./OAuthPopup"; +export * from "./OAuthContext"; +export * from "./useOAuth"; diff --git a/frontend/src/services/OAuth2/useOAuth.ts b/frontend/src/services/OAuth2/useOAuth.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b79d18701145f8e7c0f319f26fb2c7e3027c7ba --- /dev/null +++ b/frontend/src/services/OAuth2/useOAuth.ts @@ -0,0 +1,10 @@ +import { OAuthContext, OAuthContextProps } from "./OAuthContext"; +import { useContext } from "react"; + +export const useOAuth = (): OAuthContextProps => { + const context = useContext(OAuthContext); + if (!context) { + throw new Error("OAuthProvider context is undefined, please verify you are calling useOAuth() as child of a <OAuthProvider> componet."); + } + return context; +}