Skip to content
Snippets Groups Projects
Commit d7c2d65f authored by Jacopo Gasparetto's avatar Jacopo Gasparetto
Browse files

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.
parent d1a46b65
No related branches found
No related tags found
No related merge requests found
Showing
with 446 additions and 33 deletions
import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import {
import { useState, useEffect } from 'react'; BrowserRouter,
import { BucketInfo } from './models/bucket'; createBrowserRouter,
import { BucketBrowser } from './routes/BucketBrowser'; Route,
import { staticRouts } from './routes'; RouterProvider,
import APIService from './services/APIService'; Routes
} from 'react-router-dom';
import { Login } from './routes/Login';
import { staticRoutes } from './routes';
import { BucketsListContext } from './services/BucketListContext'; 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() { function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false); const [bucketList, setBucketList] = useState<BucketInfo[]>([]);
const [bucketList, setBucketLists] = useState<BucketInfo[]>([]); const oAuth = useOAuth();
useEffect(() => { if (oAuth.error) {
setIsAuthenticated(APIService.isAuthenticated()); return <div>Ops... {oAuth.error.message}</div>;
APIService }
.get("buckets")
.then(data => { console.log("Is user authenticated", oAuth.isAuthenticated)
const buckets: BucketInfo[] = data["buckets"];
console.log(`Fetched ${buckets.length} buckets`); if (!oAuth.isAuthenticated) {
setBucketLists(buckets); return (
}); <BrowserRouter>
}, [isAuthenticated]); <Routes>
<Route path="/" element={<Login onClick={oAuth.signinPopup} />} />
let routes = staticRouts.map(route => { <Route path="/callback" element={<OAuthPopup />} />
</Routes>
</BrowserRouter>
)
}
let routes = staticRoutes.map(route => {
return { return {
path: route.path, path: route.path,
element: route.element element: route.element
} }
}); });
routes.push(...bucketList.map(bucketInfo => { routes.push({
return { path: "/login",
path: "/" + bucketInfo.name, element: <Login onClick={oAuth.signinPopup} />
element: <BucketBrowser bucketName={bucketInfo.name} /> });
}
}));
const router = createBrowserRouter(routes); const router = createBrowserRouter(routes);
return ( return (
......
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
import { staticRouts } from '../routes'; import { staticRoutes } from '../routes';
import { useOAuth } from '../services/OAuth2';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
export const Drawer = () => { export const Drawer = () => {
const path = window.location.pathname; 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"; let className = "h-10 hover:bg-neutral-300 rounded-lg p-2";
className += route.path === path ? " bg-neutral-200" : ""; className += route.path === path ? " bg-neutral-200" : "";
return ( return (
...@@ -13,10 +14,16 @@ export const Drawer = () => { ...@@ -13,10 +14,16 @@ export const Drawer = () => {
) )
}); });
const { user } = useOAuth();
const userName: string | null = user?.profile && user?.profile["name"] ? user?.profile["name"] : null;
return ( return (
<> <>
<img className="w-full bg-gray-100 p-4" alt="" src="/logo530.png" /> <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"> <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> <ul>
{links} {links}
</ul> </ul>
......
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App'; import App from './App';
import { OAuthProvider } from './services/OAuth2';
import './index.css';
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement 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( root.render(
<React.StrictMode> <React.StrictMode>
<App/> <OAuthProvider {...oidcConfig}>
<App />
</OAuthProvider>
</React.StrictMode> </React.StrictMode>
); );
\ No newline at end of file
...@@ -8,7 +8,7 @@ export type Route = { ...@@ -8,7 +8,7 @@ export type Route = {
element: ReactNode element: ReactNode
} }
export const staticRouts: Route[] = [ export const staticRoutes: Route[] = [
{ title: "Home", path: "/", element: <Home /> }, { title: "Home", path: "/", element: <Home /> },
{ title: "Buckets", path: "/buckets", element: <Buckets /> } { title: "Buckets", path: "/buckets", element: <Buckets /> }
]; ];
import { IOAuthState } from "./OAuthState";
import { createContext } from "react";
export interface OAuthContextProps extends IOAuthState {
signinPopup(): void;
}
export const OAuthContext = createContext<OAuthContextProps | undefined>(undefined);
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>
);
}
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>
)
}
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
}
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)
});
}
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(" ") ?? [];
}
}
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));
}
}
export * from "./OAuthProvider";
export * from "./OAuthPopup";
export * from "./OAuthContext";
export * from "./useOAuth";
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;
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment