From d7c2d65fe637ef8e76b752c10d453a0cd706b26e Mon Sep 17 00:00:00 2001
From: Jacopo Gasparetto <jacopo.gasparetto@cnaf.infn.it>
Date: Sat, 15 Apr 2023 17:23:10 +0200
Subject: [PATCH] Add OAuth2/OpenID Connect login

Implements basic OAuth2/OpenID client for the INDIGO-IAM v1.8.0 Authorization server.
This is a custom implementation since it relies on the provided service backend to
request an access token.

In an ideal situation, the Authorization Code w/PKCE flow will be used to securely
exchange the access token without storing the client secret key inside the public
frontend code.

Since the current version of INDIGO-IAM appears to have the PKCE functionality broken,
this implentation relies on the backend service to inject the client secret to the
POST, which is then forwarded to IAM. At this point, the access token is returned to
the backend service and eventually back to the front end client.

When IAM will support the Authorization Code w/PKCE flow functionality, the usage of
a better library is recommended.
---
 frontend/src/App.tsx                          |  66 ++++---
 frontend/src/commons/costants.ts              |   4 +
 frontend/src/components/Drawer.tsx            |  11 +-
 frontend/src/index.tsx                        |  16 +-
 frontend/src/routes/index.tsx                 |   2 +-
 frontend/src/services/OAuth2/OAuthContext.ts  |   8 +
 frontend/src/services/OAuth2/OAuthPopup.tsx   |  48 +++++
 .../src/services/OAuth2/OAuthProvider.tsx     | 184 ++++++++++++++++++
 frontend/src/services/OAuth2/OAuthState.ts    |  15 ++
 .../src/services/OAuth2/OAuthTokenRequest.ts  |  22 +++
 frontend/src/services/OAuth2/OidcConfig.ts    |  59 ++++++
 frontend/src/services/OAuth2/User.ts          |  30 +++
 frontend/src/services/OAuth2/index.ts         |   4 +
 frontend/src/services/OAuth2/useOAuth.ts      |  10 +
 14 files changed, 446 insertions(+), 33 deletions(-)
 create mode 100644 frontend/src/commons/costants.ts
 create mode 100644 frontend/src/services/OAuth2/OAuthContext.ts
 create mode 100644 frontend/src/services/OAuth2/OAuthPopup.tsx
 create mode 100644 frontend/src/services/OAuth2/OAuthProvider.tsx
 create mode 100644 frontend/src/services/OAuth2/OAuthState.ts
 create mode 100644 frontend/src/services/OAuth2/OAuthTokenRequest.ts
 create mode 100644 frontend/src/services/OAuth2/OidcConfig.ts
 create mode 100644 frontend/src/services/OAuth2/User.ts
 create mode 100644 frontend/src/services/OAuth2/index.ts
 create mode 100644 frontend/src/services/OAuth2/useOAuth.ts

diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 071247b..43a5a68 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 0000000..65aece8
--- /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 73d9395..cd141ad 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 ed876c6..f3a66c9 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 db3e485..12b8477 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 0000000..10c4d38
--- /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 0000000..52798f3
--- /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 0000000..feb36a1
--- /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 0000000..044f55c
--- /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 0000000..97358c1
--- /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 0000000..c4d7966
--- /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 0000000..2f76ac6
--- /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 0000000..cfcf7fa
--- /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 0000000..4b79d18
--- /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;
+}
-- 
GitLab