diff options
| author | 2023-10-22 12:39:00 +0200 | |
|---|---|---|
| committer | 2023-10-22 12:39:00 +0200 | |
| commit | ab4b205980561bcfbee398554d170f8b285ac222 (patch) | |
| tree | 8cc552852e003442289501a1866db9f4f02c6723 /src | |
| parent | Bump version (diff) | |
Migrate from keycloak-js to oidc-spa
Diffstat (limited to 'src')
| -rw-r--r-- | src/App/App.tsx | 174 | ||||
| -rw-r--r-- | src/App/oidc.tsx | 213 | ||||
| -rw-r--r-- | src/keycloak-theme/login/KcApp.tsx | 4 | ||||
| -rw-r--r-- | src/keycloak-theme/login/pages/Login.tsx | 25 | ||||
| -rw-r--r-- | src/keycloak-theme/login/valuesTransferredOverUrl.ts | 123 |
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; -} |