207 lines
6.0 KiB
TypeScript
207 lines
6.0 KiB
TypeScript
|
import { useState, useContext, createContext, useEffect } from "react";
|
||
|
import keycloak_js from "keycloak-js";
|
||
|
import { id } from "tsafe/id";
|
||
|
import { addParamToUrl } from "powerhooks/tools/urlSearchParams";
|
||
|
import type { ReturnType } from "tsafe/ReturnType";
|
||
|
import type { Param0 } from "tsafe/Param0";
|
||
|
import { assert } from "tsafe/assert";
|
||
|
import { createKeycloakAdapter } from "keycloakify";
|
||
|
import type { NonPostableEvt } from "evt";
|
||
|
import jwt_decode from "jwt-decode";
|
||
|
|
||
|
export declare type OidcClient = OidcClient.LoggedIn | OidcClient.NotLoggedIn;
|
||
|
|
||
|
export declare namespace OidcClient {
|
||
|
export type NotLoggedIn = {
|
||
|
isUserLoggedIn: false;
|
||
|
login: (params: {
|
||
|
//To prevent infinite loop if the user click login then cancel.
|
||
|
doesCurrentHrefRequiresAuth: boolean;
|
||
|
}) => Promise<never>;
|
||
|
};
|
||
|
|
||
|
export type LoggedIn = {
|
||
|
isUserLoggedIn: true;
|
||
|
//NOTE: It changes when renewed, don't store it.
|
||
|
accessToken: string;
|
||
|
logout: (params: { redirectTo: "home" | "current page" }) => Promise<never>;
|
||
|
updateTokenInfo: () => Promise<void>;
|
||
|
};
|
||
|
}
|
||
|
|
||
|
type Params = {
|
||
|
url: string;
|
||
|
realm: string;
|
||
|
clientId: string;
|
||
|
transformUrlBeforeRedirect: (url: string) => string;
|
||
|
evtUserActivity: NonPostableEvt<void>;
|
||
|
getUiLocales: () => string;
|
||
|
log?: typeof console.log;
|
||
|
};
|
||
|
|
||
|
async function createKeycloakOidcClient(params: Params): Promise<OidcClient> {
|
||
|
const {
|
||
|
url,
|
||
|
realm,
|
||
|
clientId,
|
||
|
transformUrlBeforeRedirect,
|
||
|
evtUserActivity,
|
||
|
getUiLocales,
|
||
|
log
|
||
|
} = params;
|
||
|
|
||
|
const keycloakInstance = keycloak_js({ url, realm, clientId });
|
||
|
|
||
|
let redirectMethod: ReturnType<
|
||
|
Param0<typeof createKeycloakAdapter>["getRedirectMethod"]
|
||
|
> = "overwrite location.href";
|
||
|
|
||
|
const isAuthenticated = await keycloakInstance
|
||
|
.init({
|
||
|
"onLoad": "check-sso",
|
||
|
"silentCheckSsoRedirectUri": `${window.location.origin}/silent-sso.html`,
|
||
|
"responseMode": "query",
|
||
|
"checkLoginIframe": false,
|
||
|
"adapter": createKeycloakAdapter({
|
||
|
"transformUrlBeforeRedirect": url =>
|
||
|
[url].map(transformUrlBeforeRedirect).map(
|
||
|
url =>
|
||
|
addParamToUrl({
|
||
|
url,
|
||
|
"name": "ui_locales",
|
||
|
"value": getUiLocales()
|
||
|
}).newUrl
|
||
|
)[0],
|
||
|
keycloakInstance,
|
||
|
"getRedirectMethod": () => redirectMethod
|
||
|
})
|
||
|
})
|
||
|
.catch((error: Error) => error);
|
||
|
|
||
|
//TODO: Make sure that result is always an object.
|
||
|
if (isAuthenticated instanceof Error) {
|
||
|
throw isAuthenticated;
|
||
|
}
|
||
|
|
||
|
const login: OidcClient.NotLoggedIn["login"] = async ({
|
||
|
doesCurrentHrefRequiresAuth
|
||
|
}) => {
|
||
|
if (doesCurrentHrefRequiresAuth) {
|
||
|
redirectMethod = "location.replace";
|
||
|
}
|
||
|
|
||
|
await keycloakInstance.login({ "redirectUri": window.location.href });
|
||
|
|
||
|
return new Promise<never>(() => { });
|
||
|
};
|
||
|
|
||
|
if (!isAuthenticated) {
|
||
|
return id<OidcClient.NotLoggedIn>({
|
||
|
"isUserLoggedIn": false,
|
||
|
login
|
||
|
});
|
||
|
}
|
||
|
|
||
|
const oidcClient = id<OidcClient.LoggedIn>({
|
||
|
"isUserLoggedIn": true,
|
||
|
"accessToken": keycloakInstance.token!,
|
||
|
"logout": async ({ redirectTo }) => {
|
||
|
await keycloakInstance.logout({
|
||
|
"redirectUri": (() => {
|
||
|
switch (redirectTo) {
|
||
|
case "current page":
|
||
|
return window.location.href;
|
||
|
case "home":
|
||
|
return window.location.origin;
|
||
|
}
|
||
|
})()
|
||
|
});
|
||
|
|
||
|
return new Promise<never>(() => { });
|
||
|
},
|
||
|
"updateTokenInfo": async () => {
|
||
|
await keycloakInstance.updateToken(-1);
|
||
|
|
||
|
oidcClient.accessToken = keycloakInstance.token!;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
(function callee() {
|
||
|
const msBeforeExpiration =
|
||
|
jwt_decode<{ exp: number }>(oidcClient.accessToken)["exp"] * 1000 - Date.now();
|
||
|
|
||
|
setTimeout(async () => {
|
||
|
log?.(
|
||
|
`OIDC access token will expire in ${minValiditySecond} seconds, waiting for user activity before renewing`
|
||
|
);
|
||
|
|
||
|
await evtUserActivity.waitFor();
|
||
|
|
||
|
log?.("User activity detected. Refreshing access token now");
|
||
|
|
||
|
const error = await keycloakInstance.updateToken(-1).then(
|
||
|
() => undefined,
|
||
|
(error: Error) => error
|
||
|
);
|
||
|
|
||
|
if (error) {
|
||
|
log?.("Can't refresh OIDC access token, getting a new one");
|
||
|
//NOTE: Never resolves
|
||
|
await login({ "doesCurrentHrefRequiresAuth": true });
|
||
|
}
|
||
|
|
||
|
oidcClient.accessToken = keycloakInstance.token!;
|
||
|
|
||
|
callee();
|
||
|
}, msBeforeExpiration - minValiditySecond * 1000);
|
||
|
})();
|
||
|
|
||
|
return oidcClient;
|
||
|
}
|
||
|
|
||
|
const minValiditySecond = 25;
|
||
|
|
||
|
|
||
|
const oidcClientContext = createContext<OidcClient | undefined>(undefined);
|
||
|
|
||
|
export function createOidcClientProvider(params: Params) {
|
||
|
|
||
|
|
||
|
const prOidcClient = createKeycloakOidcClient(params);
|
||
|
|
||
|
function OidcClientProvider(props: { children: React.ReactNode; }) {
|
||
|
|
||
|
const { children } = props;
|
||
|
|
||
|
const [oidcClient, setOidcClient] = useState<OidcClient | undefined>(undefined);
|
||
|
|
||
|
useEffect(() => {
|
||
|
|
||
|
prOidcClient.then(setOidcClient);
|
||
|
|
||
|
}, []);
|
||
|
|
||
|
if (oidcClient === undefined) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
return (
|
||
|
<oidcClientContext.Provider value={oidcClient}>
|
||
|
{children}
|
||
|
</oidcClientContext.Provider>
|
||
|
);
|
||
|
|
||
|
}
|
||
|
|
||
|
|
||
|
|
||
|
return { OidcClientProvider };
|
||
|
|
||
|
}
|
||
|
|
||
|
export function useOidcClient() {
|
||
|
const oidcClient = useContext(oidcClientContext);
|
||
|
assert(oidcClient !== undefined);
|
||
|
return { oidcClient };
|
||
|
}
|