From ab4b205980561bcfbee398554d170f8b285ac222 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 22 Oct 2023 12:39:00 +0200 Subject: [PATCH] Migrate from keycloak-js to oidc-spa --- README.md | 2 +- package.json | 3 +- src/App/App.tsx | 180 ++++++++++----- src/App/oidc.tsx | 213 ------------------ src/keycloak-theme/login/KcApp.tsx | 4 - src/keycloak-theme/login/pages/Login.tsx | 25 +- .../login/valuesTransferredOverUrl.ts | 123 ---------- yarn.lock | 42 ++-- 8 files changed, 170 insertions(+), 422 deletions(-) delete mode 100644 src/App/oidc.tsx delete mode 100644 src/keycloak-theme/login/valuesTransferredOverUrl.ts diff --git a/README.md b/README.md index 60ed840..8b8cf65 100644 --- a/README.md +++ b/README.md @@ -220,4 +220,4 @@ jobs: EOF ``` -You can also remove `jwt-decode`, `keycloak-js`, `powerhooks` and `tsafe` from your dependencies. +You can also remove `oidc-spa`, `powerhooks` and `tsafe` from your dependencies. diff --git a/package.json b/package.json index c3969c3..2419387 100755 --- a/package.json +++ b/package.json @@ -27,8 +27,7 @@ "keywords": [], "dependencies": { "evt": "^2.4.15", - "jwt-decode": "^3.1.2", - "keycloak-js": "^21.0.1", + "oidc-spa": "^1.0.1", "keycloakify": "^8.1.3", "powerhooks": "^0.26.8", "react": "18.1.0", 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,86 +1,69 @@ 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 keycloakClientId= "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 { 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 ( - + - + ); } + function ContextualizedApp() { - const { oidcClient } = useOidcClient(); - - let accountUrl = `${keycloakUrl}/realms/${keycloakRealm}/account`; - - // Set the language the user will get on the account page - accountUrl = addParamToUrl({ - url: accountUrl, - name: "kc_locale", - value: "en" - }).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({ - url: accountUrl, - name: "referrer", - value: keycloakClient - }).newUrl; - - accountUrl = addParamToUrl({ - url: accountUrl, - name: "referrer_uri", - value: window.location.href - }).newUrl; + const { oidc } = useOidc(); return (
{ - oidcClient.isUserLoggedIn ? - <> -

You are authenticated !

- {/* On older Keycloak version its /auth/realms instead of /realms */} - Link to your Keycloak account -
{JSON.stringify(jwt_decode(oidcClient.getAccessToken()), null, 2)}
- - + oidc.isUserLoggedIn ? + oidc.logout({ redirectTo: "home" })} /> : - <> - - + } logo test_image @@ -90,4 +73,85 @@ function ContextualizedApp() {
); + +} + +function AuthenticatedRoute(props: { logout: () => void; }) { + + const { logout } = props; + + const { user } = useUser(); + + return ( + <> +

Hello {user.name} !

+ Link to your Keycloak account + +
{JSON.stringify(user, null, 2)}
+ + ); + +} + +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 = addQueryParamToUrl({ + url: accountUrl, + name: "kc_locale", + 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 = addQueryParamToUrl({ + url: accountUrl, + name: "referrer", + value: keycloakClientId + }).newUrl; + + accountUrl = addQueryParamToUrl({ + url: accountUrl, + name: "referrer_uri", + value: window.location.href + }).newUrl; + + 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; - }; - - export type LoggedIn = { - isUserLoggedIn: true; - getAccessToken: () => string; - logout: (params: { redirectTo: "home" | "current page" }) => Promise; - //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; - }; -} - -type Params = { - url: string; - realm: string; - clientId: string; - transformUrlBeforeRedirect?: (url: string) => string; - getUiLocales?: () => string; - log?: typeof console.log; -}; - -async function createKeycloakOidcClient(params: Params): Promise { - const { - url, - realm, - clientId, - transformUrlBeforeRedirect, - getUiLocales, - log - } = params; - - const keycloakInstance = new Keycloak_js({ url, realm, clientId }); - - let redirectMethod: ReturnType< - Param0["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(() => { }); - }; - - if (!isAuthenticated) { - return id({ - "isUserLoggedIn": false, - login - }); - } - - let currentAccessToken = keycloakInstance.token!; - - const oidcClient = id({ - "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(() => { }); - }, - "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(undefined); - -export function createOidcClientProvider(params: Params) { - - - const prOidcClient = createKeycloakOidcClient(params); - - function OidcClientProvider(props: { children: React.ReactNode; }) { - - const { children } = props; - - const [oidcClient, setOidcClient] = useState(undefined); - - useEffect(() => { - - prOidcClient.then(setOidcClient); - - }, []); - - if (oidcClient === undefined) { - return null; - } - - return ( - - {children} - - ); - - } - - 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, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; @@ -60,7 +71,7 @@ export default function Login(props: PageProps {realm.password && ( @@ -71,8 +82,8 @@ export default function Login(props: PageProps {msg("rememberMe")} @@ -149,8 +160,8 @@ export default function Login(props: PageProps { - 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; -} diff --git a/yarn.lock b/yarn.lock index 981f31d..9324bc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4706,7 +4706,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.0.2, base64-js@^1.5.1: +base64-js@^1.0.2: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -5801,6 +5801,11 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" +crypto-js@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.1.1.tgz#9e485bcf03521041bd85844786b83fb7619736cf" + integrity sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw== + crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" @@ -9613,11 +9618,6 @@ js-sdsl@^4.1.4: resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.4.0.tgz#8b437dbe642daa95760400b602378ed8ffea8430" integrity sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg== -js-sha256@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966" - integrity sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA== - js-string-escape@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" @@ -9760,14 +9760,6 @@ jwt-decode@^3.1.2: resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== -keycloak-js@^21.0.1: - version "21.0.2" - resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-21.0.2.tgz#1d3c2079d3c23850df4f253a868926861c51c488" - integrity sha512-i05i3VBPhQ867EgjA+OYPlf8YUPiUwtrU2zv4j8tvZIdRvhJY8f+mp1ZvRJl/GMRb+XhJs9BDknyBMrIspwDkw== - dependencies: - base64-js "^1.5.1" - js-sha256 "^0.9.0" - keycloakify@^8.1.3: version "8.1.3" resolved "https://registry.yarnpkg.com/keycloakify/-/keycloakify-8.1.3.tgz#813836b013dc93d5cf43a5b476c9f19537e5435c" @@ -10917,6 +10909,23 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== +oidc-client-ts@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-2.3.0.tgz#43c90f1f0cc3be2e4ede38b8c68642ba00bfa8f6" + integrity sha512-7RUKU+TJFQo+4X9R50IGJAIDF18uRBaFXyZn4VVCfwmwbSUhKcdDnw4zgeut3uEXkiD3NqURq+d88sDPxjf1FA== + dependencies: + crypto-js "^4.1.1" + jwt-decode "^3.1.2" + +oidc-spa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/oidc-spa/-/oidc-spa-1.0.0.tgz#f050722af4820ba30e94ec326ed7b35e4085170d" + integrity sha512-8m8tibYq5yTM5taLPBu4fuy1muCMwZWna/f/xxRbfC+DbA8pcwLH/fPI4F3GQ3AqQcnmJdFPhTkmZHX3YA5NiQ== + dependencies: + jwt-decode "^3.1.2" + oidc-client-ts "^2.3.0" + tsafe "^1.6.5" + on-finished@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -14294,6 +14303,11 @@ tsafe@^1.6.0: resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.6.0.tgz#48a9bd0a4c43df43d289bdfc1d89f0d7fffbd612" integrity sha512-wlUeRBnyN3EN2chXznpLm7vBEvJLEOziDU+MN6NRlD99AkwmXgtChNQhp+V97VyRa3Bp05IaL4Cocsc7JlyEUg== +tsafe@^1.6.5: + version "1.6.5" + resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.6.5.tgz#74943b69190069168a53d2accd6d07891cf1cce8" + integrity sha512-895zss8xqqHKTc28sHGIfZKnt3C5jrstB1DyPr/h3/flK0zojsZUMQL1/W4ytdDW6KI4Oth62nb9rrxmA3s3Iw== + tsconfig-paths@^3.14.1: version "3.14.2" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz#6e32f1f79412decd261f92d633a9dc1cfa99f088"