aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorGravatar Joseph Garrone <[email protected]>2023-10-22 12:39:00 +0200
committerGravatar Joseph Garrone <[email protected]>2023-10-22 12:39:00 +0200
commitab4b205980561bcfbee398554d170f8b285ac222 (patch)
tree8cc552852e003442289501a1866db9f4f02c6723 /src
parentBump version (diff)
Migrate from keycloak-js to oidc-spa
Diffstat (limited to 'src')
-rw-r--r--src/App/App.tsx174
-rw-r--r--src/App/oidc.tsx213
-rw-r--r--src/keycloak-theme/login/KcApp.tsx4
-rw-r--r--src/keycloak-theme/login/pages/Login.tsx25
-rw-r--r--src/keycloak-theme/login/valuesTransferredOverUrl.ts123
5 files changed, 137 insertions, 402 deletions
diff --git a/src/App/App.tsx b/src/App/App.tsx
index b6146dc..8fd8a10 100644
--- a/src/App/App.tsx
+++ b/src/App/App.tsx
@@ -1,93 +1,157 @@
import "./App.css";
import logo from "./logo.svg";
import myimg from "./myimg.png";
-import { createOidcClientProvider, useOidcClient } from "./oidc";
-import { addFooToQueryParams, addBarToQueryParams } from "../keycloak-theme/login/valuesTransferredOverUrl";
-import jwt_decode from "jwt-decode";
-import { addParamToUrl } from "powerhooks/tools/urlSearchParams";
+import { useMemo } from "react";
+import { createOidcProvider, useOidc } from "oidc-spa/react";
+import { addQueryParamToUrl } from "oidc-spa/tools/urlQueryParams";
+import { decodeJwt } from "oidc-spa";
+import { assert } from "tsafe/assert";
//On older Keycloak version you need the /auth (e.g: http://localhost:8080/auth)
//On newer version you must remove it (e.g: http://localhost:8080 )
const keycloakUrl = "https://auth.code.gouv.fr/auth";
const keycloakRealm = "keycloakify";
-const keycloakClient= "starter";
-
-const { OidcClientProvider } = createOidcClientProvider({
- url: keycloakUrl,
- realm: keycloakRealm,
- clientId: keycloakClient,
- //This function will be called just before redirecting,
- //it should return the current langue.
- //kcContext.locale.currentLanguageTag will be what this function returned just before redirecting.
- getUiLocales: () => "en",
- transformUrlBeforeRedirect: url =>
- [url]
- //Instead of foo and bar you could have isDark for example or any other state that you wish to
- //transfer from the main app to the login pages.
- .map(url => addFooToQueryParams({ url, value: { foo: 42 } }))
- .map(url => addBarToQueryParams({ url, value: "value of bar transferred to login page" }))
- [0],
- log: console.log
+const keycloakClientId= "starter";
+
+const { OidcProvider } = createOidcProvider({
+ issuerUri: `${keycloakUrl}/realms/${keycloakRealm}`,
+ clientId: keycloakClientId,
+ transformUrlBeforeRedirect: url => {
+
+ // This adding ui_locales to the url will ensure the consistency of the language between the app and the login pages
+ // If your app implements a i18n system (like i18nifty.dev for example) you should use this and replace "en" by the
+ // current language of the app.
+ // On the other side you will find kcContext.locale.currentLanguageTag to be whatever you set here.
+ url = addQueryParamToUrl({
+ url,
+ "name": "ui_locales",
+ "value": "en",
+ }).newUrl;
+
+ // If you want to pass some custom state to the login pages...
+ // See in src/keycloak-theme/pages/Login.tsx how it's retrieved.
+ url = addQueryParamToUrl({
+ url,
+ "name": "my_custom_param",
+ "value": "value of foo transferred to login page",
+ }).newUrl;
+
+ return url;
+
+ },
+ // Uncomment if your app is not hosted at the origin and update /foo/bar/baz.
+ //silentSsoUrl: `${window.location.origin}/foo/bar/baz/silent-sso.html`,
});
export default function App() {
return (
- <OidcClientProvider>
+ <OidcProvider>
<ContextualizedApp />
- </OidcClientProvider>
+ </OidcProvider>
);
}
+
function ContextualizedApp() {
- const { oidcClient } = useOidcClient();
+ const { oidc } = useOidc();
+
+ return (
+ <div className="App">
+ <header className="App-header">
+ {
+ oidc.isUserLoggedIn ?
+ <AuthenticatedRoute logout={() => oidc.logout({ redirectTo: "home" })} />
+ :
+ <button onClick={() => oidc.login({ doesCurrentHrefRequiresAuth: false })}>Login</button>
+ }
+ <img src={logo} className="App-logo" alt="logo" />
+ <img src={myimg} alt="test_image" />
+ <p style={{ "fontFamily": '"Work Sans"' }}>Hello world</p>
+ <p>Check out all keycloak pages in the <a href="https://storybook.keycloakify.dev/storybook">Storybook</a>!</p>
+ <p>Once you've identified the ones you want to customize run <code>npx eject-keycloak-page</code></p>
+ </header>
+ </div>
+ );
+
+}
+
+function AuthenticatedRoute(props: { logout: () => void; }) {
+
+ const { logout } = props;
+
+ const { user } = useUser();
+
+ return (
+ <>
+ <h1>Hello {user.name} !</h1>
+ <a href={buildAccountUrl({ locale: "en" })}>Link to your Keycloak account</a>
+ <button onClick={logout}>Logout</button>
+ <pre style={{ textAlign: "left" }}>{JSON.stringify(user, null, 2)}</pre>
+ </>
+ );
+
+}
+
+function useUser() {
+ const { oidc } = useOidc();
+
+ assert(oidc.isUserLoggedIn, "This hook can only be used when the user is logged in");
+
+ const { idToken } = oidc.getTokens();
+
+ const user = useMemo(
+ () =>
+ decodeJwt<{
+ // Use https://jwt.io/ to tell what's in your idToken
+ // It will depend of your Keycloak configuration.
+ // Here I declare only two field on the type but actually there are
+ // Many more things available.
+ sub: string;
+ name: string;
+ preferred_username: string;
+ // This is a custom attribute set up in our Keycloak configuration
+ // it's not present by default.
+ // See https://docs.keycloakify.dev/realtime-input-validation#getting-your-custom-user-attribute-to-be-included-in-the-jwt
+ favorite_pet: "cat" | "dog" | "bird";
+ }>(idToken),
+ [idToken]
+ );
+
+ return { user };
+}
+
+function buildAccountUrl(
+ params: {
+ locale: string;
+ }
+){
+
+ const { locale } = params;
let accountUrl = `${keycloakUrl}/realms/${keycloakRealm}/account`;
// Set the language the user will get on the account page
- accountUrl = addParamToUrl({
+ accountUrl = addQueryParamToUrl({
url: accountUrl,
name: "kc_locale",
- value: "en"
+ value: locale
}).newUrl;
// Enable to redirect to the app from the account page we'll get the referrer_uri under kcContext.referrer.url
// It's useful to avoid hard coding the app url in the keycloak config
- accountUrl = addParamToUrl({
+ accountUrl = addQueryParamToUrl({
url: accountUrl,
name: "referrer",
- value: keycloakClient
+ value: keycloakClientId
}).newUrl;
- accountUrl = addParamToUrl({
+ accountUrl = addQueryParamToUrl({
url: accountUrl,
name: "referrer_uri",
value: window.location.href
}).newUrl;
- return (
- <div className="App">
- <header className="App-header">
- {
- oidcClient.isUserLoggedIn ?
- <>
- <h1>You are authenticated !</h1>
- {/* On older Keycloak version its /auth/realms instead of /realms */}
- <a href={accountUrl}>Link to your Keycloak account</a>
- <pre style={{ textAlign: "left" }}>{JSON.stringify(jwt_decode(oidcClient.getAccessToken()), null, 2)}</pre>
- <button onClick={() => oidcClient.logout({ redirectTo: "home" })}>Logout</button>
- </>
- :
- <>
- <button onClick={() => oidcClient.login({ doesCurrentHrefRequiresAuth: false })}>Login</button>
- </>
- }
- <img src={logo} className="App-logo" alt="logo" />
- <img src={myimg} alt="test_image" />
- <p style={{ "fontFamily": '"Work Sans"' }}>Hello world</p>
- <p>Check out all keycloak pages in the <a href="https://storybook.keycloakify.dev/storybook">Storybook</a>!</p>
- <p>Once you've identified the ones you want to customize run <code>npx eject-keycloak-page</code></p>
- </header>
- </div>
- );
+ return accountUrl;
+
}
diff --git a/src/App/oidc.tsx b/src/App/oidc.tsx
deleted file mode 100644
index 44eb9e1..0000000
--- a/src/App/oidc.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-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 jwt_decode from "jwt-decode";
-import { Evt } from "evt";
-
-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 access a page that requires to
- //be authenticated but cancel (clicks back).
- doesCurrentHrefRequiresAuth: boolean;
- }) => Promise<never>;
- };
-
- export type LoggedIn = {
- isUserLoggedIn: true;
- getAccessToken: () => string;
- logout: (params: { redirectTo: "home" | "current page" }) => Promise<never>;
- //If we have sent a API request to change user's email for example
- //and we want that jwt_decode(oidcClient.getAccessToken()).email be the new email
- //in this case we would call this method...
- updateTokenInfos: () => Promise<void>;
- };
-}
-
-type Params = {
- url: string;
- realm: string;
- clientId: string;
- transformUrlBeforeRedirect?: (url: string) => string;
- getUiLocales?: () => string;
- log?: typeof console.log;
-};
-
-async function createKeycloakOidcClient(params: Params): Promise<OidcClient> {
- const {
- url,
- realm,
- clientId,
- transformUrlBeforeRedirect,
- getUiLocales,
- log
- } = params;
-
- const keycloakInstance = new 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 ?? (url => url))
- .map(
- getUiLocales === undefined ?
- (url => url) :
- 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
- });
- }
-
- let currentAccessToken = keycloakInstance.token!;
-
- const oidcClient = id<OidcClient.LoggedIn>({
- "isUserLoggedIn": true,
- "getAccessToken": () => currentAccessToken,
- "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>(() => { });
- },
- "updateTokenInfos": async () => {
- await keycloakInstance.updateToken(-1);
-
- currentAccessToken = keycloakInstance.token!;
- }
- });
-
- (function callee() {
- const msBeforeExpiration = jwt_decode<{ exp: number }>(currentAccessToken)["exp"] * 1000 - Date.now();
-
- setTimeout(async () => {
-
- log?.(`OIDC access token will expire in ${minValiditySecond} seconds, waiting for user activity before renewing`);
-
- await Evt.merge([
- Evt.from(document, "mousemove"),
- Evt.from(document, "keydown")
- ]).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 });
- }
-
- currentAccessToken = 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 };
-}
diff --git a/src/keycloak-theme/login/KcApp.tsx b/src/keycloak-theme/login/KcApp.tsx
index 120ec1d..683a7c2 100644
--- a/src/keycloak-theme/login/KcApp.tsx
+++ b/src/keycloak-theme/login/KcApp.tsx
@@ -7,10 +7,6 @@ import { useI18n } from "./i18n";
const Template = lazy(() => import("./Template"));
const DefaultTemplate = lazy(() => import("keycloakify/login/Template"));
-// You can uncomment this to see the values passed by the main app before redirecting.
-//import { foo, bar } from "./valuesTransferredOverUrl";
-//console.log(`Values passed by the main app in the URL parameter:`, { foo, bar });
-
const Login = lazy(() => import("./pages/Login"));
// If you can, favor register-user-profile.ftl over register.ftl, see: https://docs.keycloakify.dev/realtime-input-validation
const Register = lazy(() => import("./pages/Register"));
diff --git a/src/keycloak-theme/login/pages/Login.tsx b/src/keycloak-theme/login/pages/Login.tsx
index 1de3f27..abc250b 100644
--- a/src/keycloak-theme/login/pages/Login.tsx
+++ b/src/keycloak-theme/login/pages/Login.tsx
@@ -5,6 +5,17 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
+import { retrieveQueryParamFromUrl } from "oidc-spa/tools/urlQueryParams";
+
+const result = retrieveQueryParamFromUrl({
+ "url": window.location.href,
+ "name": "my_custom_param",
+});
+
+if (result.wasPresent) {
+ console.log("my_custom_param", result.value);
+}
+
export default function Login(props: PageProps<Extract<KcContext, { pageId: "login.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@@ -60,7 +71,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
id="kc-form-wrapper"
className={clsx(
realm.password &&
- social.providers && [getClassName("kcFormSocialAccountContentClass"), getClassName("kcFormSocialAccountClass")]
+ social.providers && [getClassName("kcFormSocialAccountContentClass"), getClassName("kcFormSocialAccountClass")]
)}
>
{realm.password && (
@@ -71,8 +82,8 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
const label = !realm.loginWithEmailAllowed
? "username"
: realm.registrationEmailAsUsername
- ? "email"
- : "usernameOrEmail";
+ ? "email"
+ : "usernameOrEmail";
const autoCompleteHelper: typeof label = label === "usernameOrEmail" ? "username" : label;
@@ -123,8 +134,8 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
type="checkbox"
{...(login.rememberMe === "on"
? {
- "checked": true
- }
+ "checked": true
+ }
: {})}
/>
{msg("rememberMe")}
@@ -149,8 +160,8 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
name="credentialId"
{...(auth?.selectedCredential !== undefined
? {
- "value": auth.selectedCredential
- }
+ "value": auth.selectedCredential
+ }
: {})}
/>
<input
diff --git a/src/keycloak-theme/login/valuesTransferredOverUrl.ts b/src/keycloak-theme/login/valuesTransferredOverUrl.ts
deleted file mode 100644
index 18dd4f9..0000000
--- a/src/keycloak-theme/login/valuesTransferredOverUrl.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { kcContext } from "./kcContext";
-import {
- retrieveParamFromUrl,
- addParamToUrl,
- updateSearchBarUrl
-} from "powerhooks/tools/urlSearchParams";
-import { capitalize } from "tsafe/capitalize";
-
-export const { foo, addFooToQueryParams } = (() => {
- const queryParamName = "foo";
-
- type Type = { foo: number; };
-
- const value = (()=> {
-
- const unparsedValue = read({ queryParamName });
-
- if( unparsedValue === undefined ){
- return undefined;
- }
-
- return JSON.parse(unparsedValue) as Type;
-
- })();
-
- function addToUrlQueryParams(params: {
- url: string;
- value: Type;
- }): string {
- const { url, value } = params;
-
- return addParamToUrl({
- url,
- "name": queryParamName,
- "value": JSON.stringify(value)
- }).newUrl;
- }
-
- const out = {
- [queryParamName]: value,
- [`add${capitalize(queryParamName)}ToQueryParams` as const]: addToUrlQueryParams
- } as const;
-
- return out;
-})();
-
-export const { bar, addBarToQueryParams } = (() => {
- const queryParamName = "bar";
-
- type Type = string;
-
- const value = (()=> {
-
- const unparsedValue = read({ queryParamName });
-
- if( unparsedValue === undefined ){
- return undefined;
- }
-
- return JSON.parse(unparsedValue) as Type;
-
- })();
-
- function addToUrlQueryParams(params: {
- url: string;
- value: Type;
- }): string {
- const { url, value } = params;
-
- return addParamToUrl({
- url,
- "name": queryParamName,
- "value": JSON.stringify(value)
- }).newUrl;
- }
-
- const out = {
- [queryParamName]: value,
- [`add${capitalize(queryParamName)}ToQueryParams` as const]: addToUrlQueryParams
- } as const;
-
- return out;
-})();
-
-
-function read(params: { queryParamName: string }): string | undefined {
- if (kcContext === undefined || process.env.NODE_ENV !== "production") {
- //NOTE: We do something only if we are really in Keycloak
- return undefined;
- }
-
- const { queryParamName } = params;
-
- read_from_url: {
- const result = retrieveParamFromUrl({
- "url": window.location.href,
- "name": queryParamName
- });
-
- if (!result.wasPresent) {
- break read_from_url;
- }
-
- const { newUrl, value: serializedValue } = result;
-
- updateSearchBarUrl(newUrl);
-
- localStorage.setItem(queryParamName, serializedValue);
-
- return serializedValue;
- }
-
- //Reading from local storage
- const serializedValue = localStorage.getItem(queryParamName);
-
- if (serializedValue === null) {
- throw new Error(
- `Missing ${queryParamName} in URL when redirecting to login page`
- );
- }
-
- return serializedValue;
-}