diff --git a/scripts/copyKeycloakResourcesToStorybookStaticDir.ts b/scripts/copyKeycloakResourcesToStorybookStaticDir.ts index b666c27b..fd6b4368 100644 --- a/scripts/copyKeycloakResourcesToStorybookStaticDir.ts +++ b/scripts/copyKeycloakResourcesToStorybookStaticDir.ts @@ -1,7 +1,7 @@ import { join as pathJoin } from "path"; import { copyKeycloakResourcesToPublic } from "../src/bin/shared/copyKeycloakResourcesToPublic"; import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions"; -import { LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT } from "../src/bin/shared/constants"; +import { LOGIN_THEME_RESOURCES_FROM_KEYCLOAK_VERSION_DEFAULT } from "../src/bin/shared/constants"; export async function copyKeycloakResourcesToStorybookStaticDir() { await copyKeycloakResourcesToPublic({ @@ -11,7 +11,7 @@ export async function copyKeycloakResourcesToStorybookStaticDir() { npmConfigGetCwd: pathJoin(__dirname, "..") }), loginThemeResourcesFromKeycloakVersion: - LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT, + LOGIN_THEME_RESOURCES_FROM_KEYCLOAK_VERSION_DEFAULT, publicDirPath: pathJoin(__dirname, "..", ".storybook", "static") } }); diff --git a/scripts/generate-i18n-messages.ts b/scripts/generate-i18n-messages.ts index 89affa4f..8aa3cc9c 100644 --- a/scripts/generate-i18n-messages.ts +++ b/scripts/generate-i18n-messages.ts @@ -13,6 +13,10 @@ import { downloadKeycloakDefaultTheme } from "../src/bin/shared/downloadKeycloak import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath"; import { deepAssign } from "../src/tools/deepAssign"; import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions"; +import { + THEME_TYPES, + LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 +} from "../src/bin/shared/constants"; // NOTE: To run without argument when we want to generate src/i18n/generated_kcMessages files, // update the version array for generating for newer version. @@ -21,68 +25,77 @@ import { getProxyFetchOptions } from "../src/bin/tools/fetchProxyOptions"; const propertiesParser = require("properties-parser"); async function main() { - const keycloakVersion = "24.0.4"; - const thisCodebaseRootDirPath = getThisCodebaseRootDirPath(); - const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({ - keycloakVersion, - buildContext: { - cacheDirPath: pathJoin( - thisCodebaseRootDirPath, - "node_modules", - ".cache", - "keycloakify" - ), - fetchOptions: getProxyFetchOptions({ - npmConfigGetCwd: thisCodebaseRootDirPath - }) - } - }); - type Dictionary = { [idiomId: string]: string }; - const record: { [typeOfPage: string]: { [language: string]: Dictionary } } = {}; + const record: { [themeType: string]: { [language: string]: Dictionary } } = {}; - { - const baseThemeDirPath = pathJoin(defaultThemeDirPath, "base"); - const re = new RegExp( - `^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$` - ); - - crawl({ - dirPath: baseThemeDirPath, - returnedPathsType: "relative to dirPath" - }).forEach(filePath => { - const match = filePath.match(re); - - if (match === null) { - return; + for (const themeType of THEME_TYPES) { + const { defaultThemeDirPath } = await downloadKeycloakDefaultTheme({ + keycloakVersion: (() => { + switch (themeType) { + case "login": + return "25.0.4"; + case "account": + return LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1; + } + })(), + buildContext: { + cacheDirPath: pathJoin( + thisCodebaseRootDirPath, + "node_modules", + ".cache", + "keycloakify" + ), + fetchOptions: getProxyFetchOptions({ + npmConfigGetCwd: thisCodebaseRootDirPath + }) } - - const [, typeOfPage, language] = match; - - (record[typeOfPage] ??= {})[language.replace(/_/g, "-")] = Object.fromEntries( - Object.entries( - propertiesParser.parse( - fs - .readFileSync(pathJoin(baseThemeDirPath, filePath)) - .toString("utf8") - ) as Record - ) - .map(([key, value]) => [key, value.replace(/''/g, "'")]) - .map(([key, value]) => [ - key === "locale_pt_BR" ? "locale_pt-BR" : key, - value - ]) - .map(([key, value]) => [key, key === "termsText" ? "" : value]) - ); }); - } - Object.keys(record).forEach(themeType => { - if (themeType !== "login" && themeType !== "account") { - return; + { + const baseThemeDirPath = pathJoin(defaultThemeDirPath, "base"); + const re = new RegExp( + `^([^\\${pathSep}]+)\\${pathSep}messages\\${pathSep}messages_([^.]+).properties$` + ); + + crawl({ + dirPath: baseThemeDirPath, + returnedPathsType: "relative to dirPath" + }).forEach(filePath => { + const match = filePath.match(re); + + if (match === null) { + return; + } + + const [, themeType_here, language] = match; + + if (themeType_here !== themeType) { + return; + } + + (record[themeType] ??= {})[language.replace(/_/g, "-")] = + Object.fromEntries( + Object.entries( + propertiesParser.parse( + fs + .readFileSync(pathJoin(baseThemeDirPath, filePath)) + .toString("utf8") + ) as Record + ) + .map(([key, value]) => [key, value.replace(/''/g, "'")]) + .map(([key, value]) => [ + key === "locale_pt_BR" ? "locale_pt-BR" : key, + value + ]) + .map(([key, value]) => [ + key, + key === "termsText" ? "" : value + ]) + ); + }); } const recordForThemeType = record[themeType]; @@ -99,6 +112,29 @@ async function main() { assert(false); })(); + /* Migration helper + + console.log({ themeType }); + + { + + const all = new Set(); + + languages.forEach(languages => all.add(languages)); + const currentlySupportedLanguages = Object.keys(keycloakifyExtraMessages); + currentlySupportedLanguages.forEach(languages => all.add(languages)); + + all.forEach(language => { + console.log([ + `"${language}": `, + `isInLanguages: ${languages.includes(language)}`, + `isInKeycloakifyExtraMessages: ${currentlySupportedLanguages.includes(language)}` + ].join(" ")) + }); + + } + */ + assert( same(languages, Object.keys(keycloakifyExtraMessages), { takeIntoAccountArraysOrdering: false @@ -180,7 +216,7 @@ async function main() { "utf8" ) ); - }); + } } const keycloakifyExtraMessages_login: Record< @@ -203,6 +239,7 @@ const keycloakifyExtraMessages_login: Record< | "nl" | "no" | "pl" + | "pt" | "pt-BR" | "ru" | "sk" @@ -210,7 +247,9 @@ const keycloakifyExtraMessages_login: Record< | "th" | "tr" | "uk" - | "zh-CN", + | "ka" + | "zh-CN" + | "zh-TW", Record< | "shouldBeEqual" | "shouldBeDifferent" @@ -434,6 +473,17 @@ const keycloakifyExtraMessages_login: Record< addValue: "Dodaj wartość", languages: "Języki" }, + pt: { + shouldBeEqual: "{0} deve ser igual a {1}", + shouldBeDifferent: "{0} deve ser diferente de {1}", + shouldMatchPattern: "O padrão deve corresponder: `/{0}/`", + mustBeAnInteger: "Deve ser um número inteiro", + notAValidOption: "Não é uma opção válida", + selectAnOption: "Selecione uma opção", + remove: "Remover", + addValue: "Adicionar valor", + languages: "Idiomas" + }, "pt-BR": { shouldBeEqual: "{0} deve ser igual a {1}", shouldBeDifferent: "{0} deve ser diferente de {1}", @@ -511,6 +561,17 @@ const keycloakifyExtraMessages_login: Record< addValue: "Додати значення", languages: "Мови" }, + ka: { + shouldBeEqual: "{0} უნდა იყოს ტოლი {1}-სთვის", + shouldBeDifferent: "{0} უნდა იყოს სხვა {1}-სთვის", + shouldMatchPattern: "შაბლონს უნდა ემთხვევა: `/{0}/`", + mustBeAnInteger: "უნდა იყოს მთელი რიცხვი", + notAValidOption: "არასწორი ვარიანტი", + selectAnOption: "აირჩიეთ ვარიანტი", + remove: "წაშალეთ", + addValue: "დაამატეთ მნიშვნელობა", + languages: "ენები" + }, "zh-CN": { shouldBeEqual: "{0} 应该等于 {1}", shouldBeDifferent: "{0} 应该不同于 {1}", @@ -521,6 +582,17 @@ const keycloakifyExtraMessages_login: Record< remove: "移除", addValue: "添加值", languages: "语言" + }, + "zh-TW": { + shouldBeEqual: "{0} 應該等於 {1}", + shouldBeDifferent: "{0} 應該不同於 {1}", + shouldMatchPattern: "模式應匹配: `/{0}/`", + mustBeAnInteger: "必須是整數", + notAValidOption: "不是有效選項", + selectAnOption: "選擇一個選項", + remove: "移除", + addValue: "添加值", + languages: "語言" } /* spell-checker: enable */ }; @@ -532,9 +604,7 @@ const keycloakifyExtraMessages_account: Record< | "cs" | "da" | "de" - | "el" | "es" - | "fa" | "fi" | "fr" | "hu" @@ -549,9 +619,7 @@ const keycloakifyExtraMessages_account: Record< | "ru" | "sk" | "sv" - | "th" | "tr" - | "uk" | "zh-CN", Record<"newPasswordSameAsOld" | "passwordConfirmNotMatch", string> > = { @@ -580,18 +648,10 @@ const keycloakifyExtraMessages_account: Record< newPasswordSameAsOld: "Das neue Passwort muss sich vom alten unterscheiden", passwordConfirmNotMatch: "Passwortbestätigung stimmt nicht überein" }, - el: { - newPasswordSameAsOld: "Ο νέος κωδικός πρόσβασης πρέπει να διαφέρει από τον παλιό", - passwordConfirmNotMatch: "Η επιβεβαίωση του κωδικού πρόσβασης δεν ταιριάζει" - }, es: { newPasswordSameAsOld: "La nueva contraseña debe ser diferente de la anterior", passwordConfirmNotMatch: "La confirmación de la contraseña no coincide" }, - fa: { - newPasswordSameAsOld: "رمز عبور جدید باید با رمز عبور قبلی متفاوت باشد", - passwordConfirmNotMatch: "تأیید رمز عبور مطابقت ندارد" - }, fi: { newPasswordSameAsOld: "Uusi salasana on oltava erilainen kuin vanha", passwordConfirmNotMatch: "Salasanan vahvistus ei täsmää" @@ -649,18 +709,10 @@ const keycloakifyExtraMessages_account: Record< newPasswordSameAsOld: "Det nya lösenordet måste skilja sig från det gamla", passwordConfirmNotMatch: "Lösenordsbekräftelsen matchar inte" }, - th: { - newPasswordSameAsOld: "รหัสผ่านใหม่ต้องต่างจากรหัสผ่านเดิม", - passwordConfirmNotMatch: "การยืนยันรหัสผ่านไม่ตรงกัน" - }, tr: { newPasswordSameAsOld: "Yeni şifre eskisinden farklı olmalıdır", passwordConfirmNotMatch: "Şifre doğrulama eşleşmiyor" }, - uk: { - newPasswordSameAsOld: "Новий пароль повинен відрізнятися від старого", - passwordConfirmNotMatch: "Підтвердження пароля не співпадає" - }, "zh-CN": { newPasswordSameAsOld: "新密码必须与旧密码不同", passwordConfirmNotMatch: "密码确认不匹配" diff --git a/src/bin/shared/buildContext.ts b/src/bin/shared/buildContext.ts index b0c5439d..4c45d449 100644 --- a/src/bin/shared/buildContext.ts +++ b/src/bin/shared/buildContext.ts @@ -15,7 +15,7 @@ import * as child_process from "child_process"; import { VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES, BUILD_FOR_KEYCLOAK_MAJOR_VERSION_ENV_NAME, - LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT + LOGIN_THEME_RESOURCES_FROM_KEYCLOAK_VERSION_DEFAULT } from "./constants"; import type { KeycloakVersionRange } from "./KeycloakVersionRange"; import { exclude } from "tsafe"; @@ -547,7 +547,7 @@ export function getBuildContext(params: { `${themeNames[0]}-keycloak-theme`, loginThemeResourcesFromKeycloakVersion: buildOptions.loginThemeResourcesFromKeycloakVersion ?? - LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT, + LOGIN_THEME_RESOURCES_FROM_KEYCLOAK_VERSION_DEFAULT, projectDirPath, projectBuildDirPath, keycloakifyBuildDirPath: (() => { diff --git a/src/bin/shared/constants.ts b/src/bin/shared/constants.ts index 255a1fdf..ad015654 100644 --- a/src/bin/shared/constants.ts +++ b/src/bin/shared/constants.ts @@ -50,7 +50,9 @@ export const LOGIN_THEME_PAGE_IDS = [ "login-recovery-authn-code-input.ftl", "login-reset-otp.ftl", "login-x509-info.ftl", - "webauthn-error.ftl" + "webauthn-error.ftl", + "login-passkeys-conditional-authenticate.ftl", + "login-idp-link-confirm-override.ftl" ] as const; export const ACCOUNT_THEME_PAGE_IDS = [ @@ -70,4 +72,4 @@ export const CONTAINER_NAME = "keycloak-keycloakify"; export const FALLBACK_LANGUAGE_TAG = "en"; -export const LOGIN_THEME_RESOURCES_FROMkEYCLOAK_VERSION_DEFAULT = "24.0.4"; +export const LOGIN_THEME_RESOURCES_FROM_KEYCLOAK_VERSION_DEFAULT = "24.0.4"; diff --git a/src/bin/shared/downloadKeycloakDefaultTheme.ts b/src/bin/shared/downloadKeycloakDefaultTheme/downloadKeycloakDefaultTheme.ts similarity index 86% rename from src/bin/shared/downloadKeycloakDefaultTheme.ts rename to src/bin/shared/downloadKeycloakDefaultTheme/downloadKeycloakDefaultTheme.ts index c72e251a..f03bfaf0 100644 --- a/src/bin/shared/downloadKeycloakDefaultTheme.ts +++ b/src/bin/shared/downloadKeycloakDefaultTheme/downloadKeycloakDefaultTheme.ts @@ -1,8 +1,10 @@ -import { join as pathJoin, relative as pathRelative } from "path"; -import { type BuildContext } from "./buildContext"; +import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path"; +import { type BuildContext } from "../buildContext"; import { assert } from "tsafe/assert"; -import { LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 } from "./constants"; -import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive"; +import { LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 } from "../constants"; +import { downloadAndExtractArchive } from "../../tools/downloadAndExtractArchive"; +import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath"; +import * as fsPr from "fs/promises"; export type BuildContextLike = { cacheDirPath: string; @@ -20,6 +22,8 @@ export async function downloadKeycloakDefaultTheme(params: { let kcNodeModulesKeepFilePaths: Set | undefined = undefined; let kcNodeModulesKeepFilePaths_lastAccountV1: Set | undefined = undefined; + let areExtraAssetsFor24Copied = false; + const { extractedDirPath } = await downloadAndExtractArchive({ url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`, cacheDirPath: buildContext.cacheDirPath, @@ -32,8 +36,6 @@ export async function downloadKeycloakDefaultTheme(params: { return; } - const { readFile, writeFile } = params; - skip_keycloak_v2: { if (!fileRelativePath.startsWith(pathJoin("keycloak.v2"))) { break skip_keycloak_v2; @@ -42,6 +44,8 @@ export async function downloadKeycloakDefaultTheme(params: { return; } + const { readFile, writeFile } = params; + last_account_v1_transformations: { if (LAST_KEYCLOAK_VERSION_WITH_ACCOUNT_V1 !== keycloakVersion) { break last_account_v1_transformations; @@ -168,6 +172,42 @@ export async function downloadKeycloakDefaultTheme(params: { } } + copy_extra_assets: { + if (keycloakVersion !== "24.0.4") { + break copy_extra_assets; + } + + if (areExtraAssetsFor24Copied) { + break copy_extra_assets; + } + + const extraAssetsDirPath = pathJoin( + getThisCodebaseRootDirPath(), + "src", + "bin", + __dirname.split(`${pathSep}bin${pathSep}`)[1], + "extra-assets" + ); + + await Promise.all( + ["webauthnAuthenticate.js", "passkeysConditionalAuth.js"].map( + async fileBasename => + writeFile({ + fileRelativePath: pathJoin( + "base", + "login", + "resources", + "js", + fileBasename + ), + modifiedData: await fsPr.readFile( + pathJoin(extraAssetsDirPath, fileBasename) + ) + }) + ) + ); + } + skip_unused_resources: { if (keycloakVersion !== "24.0.4") { break skip_unused_resources; diff --git a/src/bin/shared/downloadKeycloakDefaultTheme/extra-assets/passkeysConditionalAuth.js b/src/bin/shared/downloadKeycloakDefaultTheme/extra-assets/passkeysConditionalAuth.js new file mode 100644 index 00000000..5fcbb4ed --- /dev/null +++ b/src/bin/shared/downloadKeycloakDefaultTheme/extra-assets/passkeysConditionalAuth.js @@ -0,0 +1,79 @@ +import { base64url } from "rfc4648"; +import { returnSuccess, returnFailure } from "./webauthnAuthenticate.js"; + +export function initAuthenticate(input) { + // Check if WebAuthn is supported by this browser + if (!window.PublicKeyCredential) { + returnFailure(input.errmsg); + return; + } + if (input.isUserIdentified || typeof PublicKeyCredential.isConditionalMediationAvailable === "undefined") { + document.getElementById("kc-form-passkey-button").style.display = 'block'; + } else { + tryAutoFillUI(input); + } +} + +function doAuthenticate(input) { + // Check if WebAuthn is supported by this browser + if (!window.PublicKeyCredential) { + returnFailure(input.errmsg); + return; + } + + const publicKey = { + rpId : input.rpId, + challenge: base64url.parse(input.challenge, { loose: true }) + }; + + publicKey.allowCredentials = !input.isUserIdentified ? [] : getAllowCredentials(); + + if (input.createTimeout !== 0) { + publicKey.timeout = input.createTimeout * 1000; + } + + if (input.userVerification !== 'not specified') { + publicKey.userVerification = input.userVerification; + } + + return navigator.credentials.get({ + publicKey: publicKey, + ...input.additionalOptions + }); +} + +async function tryAutoFillUI(input) { + const isConditionalMediationAvailable = await PublicKeyCredential.isConditionalMediationAvailable(); + if (isConditionalMediationAvailable) { + document.getElementById("kc-form-login").style.display = "block"; + input.additionalOptions = { mediation: 'conditional'}; + try { + const result = await doAuthenticate(input); + returnSuccess(result); + } catch (error) { + returnFailure(error); + } + } else { + document.getElementById("kc-form-passkey-button").style.display = 'block'; + } +} + +function getAllowCredentials() { + const allowCredentials = []; + const authnUse = document.forms['authn_select'].authn_use_chk; + if (authnUse !== undefined) { + if (authnUse.length === undefined) { + allowCredentials.push({ + id: base64url.parse(authnUse.value, {loose: true}), + type: 'public-key', + }); + } else { + authnUse.forEach((entry) => + allowCredentials.push({ + id: base64url.parse(entry.value, {loose: true}), + type: 'public-key', + })); + } + } + return allowCredentials; +} \ No newline at end of file diff --git a/src/bin/shared/downloadKeycloakDefaultTheme/extra-assets/webauthnAuthenticate.js b/src/bin/shared/downloadKeycloakDefaultTheme/extra-assets/webauthnAuthenticate.js new file mode 100644 index 00000000..eaa05b69 --- /dev/null +++ b/src/bin/shared/downloadKeycloakDefaultTheme/extra-assets/webauthnAuthenticate.js @@ -0,0 +1,82 @@ +import { base64url } from "rfc4648"; + +export async function authenticateByWebAuthn(input) { + if (!input.isUserIdentified) { + try { + const result = await doAuthenticate([], input.challenge, input.userVerification, input.rpId, input.createTimeout, input.errmsg); + returnSuccess(result); + } catch (error) { + returnFailure(error); + } + return; + } + checkAllowCredentials(input.challenge, input.userVerification, input.rpId, input.createTimeout, input.errmsg); +} + +async function checkAllowCredentials(challenge, userVerification, rpId, createTimeout, errmsg) { + const allowCredentials = []; + const authnUse = document.forms['authn_select'].authn_use_chk; + if (authnUse !== undefined) { + if (authnUse.length === undefined) { + allowCredentials.push({ + id: base64url.parse(authnUse.value, {loose: true}), + type: 'public-key', + }); + } else { + authnUse.forEach((entry) => + allowCredentials.push({ + id: base64url.parse(entry.value, {loose: true}), + type: 'public-key', + })); + } + } + try { + const result = await doAuthenticate(allowCredentials, challenge, userVerification, rpId, createTimeout, errmsg); + returnSuccess(result); + } catch (error) { + returnFailure(error); + } +} + +function doAuthenticate(allowCredentials, challenge, userVerification, rpId, createTimeout, errmsg) { + // Check if WebAuthn is supported by this browser + if (!window.PublicKeyCredential) { + returnFailure(errmsg); + return; + } + + const publicKey = { + rpId : rpId, + challenge: base64url.parse(challenge, { loose: true }) + }; + + if (createTimeout !== 0) { + publicKey.timeout = createTimeout * 1000; + } + + if (allowCredentials.length) { + publicKey.allowCredentials = allowCredentials; + } + + if (userVerification !== 'not specified') { + publicKey.userVerification = userVerification; + } + + return navigator.credentials.get({publicKey}); +} + +export function returnSuccess(result) { + document.getElementById("clientDataJSON").value = base64url.stringify(new Uint8Array(result.response.clientDataJSON), { pad: false }); + document.getElementById("authenticatorData").value = base64url.stringify(new Uint8Array(result.response.authenticatorData), { pad: false }); + document.getElementById("signature").value = base64url.stringify(new Uint8Array(result.response.signature), { pad: false }); + document.getElementById("credentialId").value = result.id; + if (result.response.userHandle) { + document.getElementById("userHandle").value = base64url.stringify(new Uint8Array(result.response.userHandle), { pad: false }); + } + document.getElementById("webauth").submit(); +} + +export function returnFailure(err) { + document.getElementById("error").value = err; + document.getElementById("webauth").submit(); +} \ No newline at end of file diff --git a/src/bin/shared/downloadKeycloakDefaultTheme/index.ts b/src/bin/shared/downloadKeycloakDefaultTheme/index.ts new file mode 100644 index 00000000..c8acd724 --- /dev/null +++ b/src/bin/shared/downloadKeycloakDefaultTheme/index.ts @@ -0,0 +1 @@ +export * from "./downloadKeycloakDefaultTheme"; diff --git a/src/login/DefaultPage.tsx b/src/login/DefaultPage.tsx index 278f2013..9ddfbe09 100644 --- a/src/login/DefaultPage.tsx +++ b/src/login/DefaultPage.tsx @@ -40,6 +40,8 @@ const LoginRecoveryAuthnCodeInput = lazy(() => import("keycloakify/login/pages/L const LoginResetOtp = lazy(() => import("keycloakify/login/pages/LoginResetOtp")); const LoginX509Info = lazy(() => import("keycloakify/login/pages/LoginX509Info")); const WebauthnError = lazy(() => import("keycloakify/login/pages/WebauthnError")); +const LoginPasskeysConditionalAuthenticate = lazy(() => import("keycloakify/login/pages/LoginPasskeysConditionalAuthenticate")); +const LoginIdpLinkConfirmOverride = lazy(() => import("keycloakify/login/pages/LoginIdpLinkConfirmOverride")); type DefaultPageProps = PageProps & { UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>; @@ -121,6 +123,10 @@ export default function DefaultPage(props: DefaultPageProps) { return ; case "webauthn-error.ftl": return ; + case "login-passkeys-conditional-authenticate.ftl": + return ; + case "login-idp-link-confirm-override.ftl": + return ; } assert>(false); })()} diff --git a/src/login/KcContext/KcContext.ts b/src/login/KcContext/KcContext.ts index a5db6561..a091225e 100644 --- a/src/login/KcContext/KcContext.ts +++ b/src/login/KcContext/KcContext.ts @@ -59,7 +59,9 @@ export type KcContext = | KcContext.LoginRecoveryAuthnCodeInput | KcContext.LoginResetOtp | KcContext.LoginX509Info - | KcContext.WebauthnError; + | KcContext.WebauthnError + | KcContext.LoginPasskeysConditionalAuthenticate + | KcContext.LoginIdpLinkConfirmOverride; assert(); @@ -577,6 +579,40 @@ export declare namespace KcContext { pageId: "webauthn-error.ftl"; isAppInitiatedAction?: boolean; }; + + export type LoginPasskeysConditionalAuthenticate = Common & { + pageId: "login-passkeys-conditional-authenticate.ftl"; + realm: { + registrationAllowed: boolean; + password: boolean; + }; + url: { + registrationUrl: string; + }; + registrationDisabled: boolean; + isUserIdentified: boolean | "true" | "false"; + challenge: string; + userVerification: string; + rpId: string; + createTimeout: number | string; + + authenticators?: { + authenticators: WebauthnAuthenticate.WebauthnAuthenticator[]; + }; + shouldDisplayAuthenticators?: boolean; + usernameHidden?: boolean; + login: { + username?: string; + }; + }; + + export type LoginIdpLinkConfirmOverride = Common & { + pageId: "login-idp-link-confirm-override.ftl"; + url: { + loginRestartFlowUrl: string; + }; + idpDisplayName: string; + }; } export type UserProfile = { diff --git a/src/login/KcContext/kcContextMocks.ts b/src/login/KcContext/kcContextMocks.ts index 030fb089..99343466 100644 --- a/src/login/KcContext/kcContextMocks.ts +++ b/src/login/KcContext/kcContextMocks.ts @@ -567,6 +567,39 @@ export const kcContextMocks = [ pageId: "webauthn-error.ftl", ...kcContextCommonMock, isAppInitiatedAction: true + }), + id({ + pageId: "login-passkeys-conditional-authenticate.ftl", + ...kcContextCommonMock, + url: { + ...kcContextCommonMock.url, + registrationUrl: "#" + }, + realm: { + ...kcContextCommonMock.realm, + password: true, + registrationAllowed: true + }, + registrationDisabled: false, + isUserIdentified: "false", + challenge: "", + userVerification: "not specified", + rpId: "", + createTimeout: "0", + authenticators: { + authenticators: [] + }, + shouldDisplayAuthenticators: false, + login: {} + }), + id({ + pageId: "login-idp-link-confirm-override.ftl", + ...kcContextCommonMock, + url: { + ...kcContextCommonMock.url, + loginRestartFlowUrl: "#" + }, + idpDisplayName: "Google" }) ]; diff --git a/src/login/pages/LoginIdpLinkConfirmOverride.tsx b/src/login/pages/LoginIdpLinkConfirmOverride.tsx new file mode 100644 index 00000000..dbad8a32 --- /dev/null +++ b/src/login/pages/LoginIdpLinkConfirmOverride.tsx @@ -0,0 +1,40 @@ +import { getKcClsx } from "keycloakify/login/lib/kcClsx"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../KcContext"; +import type { I18n } from "../i18n"; + +// NOTE: Added with Keycloak 25 +export default function LoginIdpLinkConfirmOverride(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + const { url, idpDisplayName } = kcContext; + + const { msg } = i18n; + + return ( + + ); +} diff --git a/src/login/pages/LoginPasskeysConditionalAuthenticate.tsx b/src/login/pages/LoginPasskeysConditionalAuthenticate.tsx new file mode 100644 index 00000000..bb3f8b70 --- /dev/null +++ b/src/login/pages/LoginPasskeysConditionalAuthenticate.tsx @@ -0,0 +1,252 @@ +import { useEffect, Fragment } from "react"; +import { clsx } from "keycloakify/tools/clsx"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import { getKcClsx } from "keycloakify/login/lib/kcClsx"; +import { assert } from "keycloakify/tools/assert"; +import type { KcContext } from "../KcContext"; +import type { I18n } from "../i18n"; + +// NOTE: From Keycloak 25.0.4 +export default function LoginPasskeysConditionalAuthenticate( + props: PageProps, I18n> +) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { + messagesPerField, + login, + url, + usernameHidden, + shouldDisplayAuthenticators, + authenticators, + registrationDisabled, + realm, + isUserIdentified, + challenge, + userVerification, + rpId, + createTimeout + } = kcContext; + + const { msg, msgStr, advancedMsg } = i18n; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "LoginRecoveryAuthnCodeConfig", + scriptTags: [ + { + type: "module", + textContent: ` + import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js"; + import { initAuthenticate } from "${url.resourcesPath}/js/passkeysConditionalAuth.js"; + + const authButton = document.getElementById('authenticateWebAuthnButton'); + const input = { + isUserIdentified : ${isUserIdentified}, + challenge : '${challenge}', + userVerification : '${userVerification}', + rpId : '${rpId}', + createTimeout : ${createTimeout}, + errmsg : "${msgStr("webauthn-unsupported-browser-text")}" + }; + authButton.addEventListener("click", () => { + authenticateByWebAuthn(input); + }); + + const args = { + isUserIdentified : ${isUserIdentified}, + challenge : '${challenge}', + userVerification : '${userVerification}', + rpId : '${rpId}', + createTimeout : ${createTimeout}, + errmsg : "${msgStr("passkey-unsupported-browser-text")}" + }; + + document.addEventListener("DOMContentLoaded", (event) => initAuthenticate(args)); + ` + } + ] + }); + + useEffect(() => { + insertScriptTags(); + }, []); + + return ( + + ); +} diff --git a/stories/login/pages/LoginIdpLinkConfirmOverride.stories.tsx b/stories/login/pages/LoginIdpLinkConfirmOverride.stories.tsx new file mode 100644 index 00000000..88d5fdaf --- /dev/null +++ b/stories/login/pages/LoginIdpLinkConfirmOverride.stories.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { createKcPageStory } from "../KcPageStory"; + +const { KcPageStory } = createKcPageStory({ pageId: "login-idp-link-confirm-override.ftl" }); + +const meta = { + title: "login/login-idp-link-confirm-override.ftl", + component: KcPageStory +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +}; diff --git a/stories/login/pages/LoginPasskeysConditionalAuthenticate.stories.tsx b/stories/login/pages/LoginPasskeysConditionalAuthenticate.stories.tsx new file mode 100644 index 00000000..c1e3ab64 --- /dev/null +++ b/stories/login/pages/LoginPasskeysConditionalAuthenticate.stories.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { createKcPageStory } from "../KcPageStory"; + +const { KcPageStory } = createKcPageStory({ pageId: "login-passkeys-conditional-authenticate.ftl" }); + +const meta = { + title: "login/login-passkeys-conditional-authenticate.ftl", + component: KcPageStory +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +};