diff --git a/.github/actions/conditional/conditions b/.github/actions/conditional/conditions index 3c199cc110..70c8f91eae 100644 --- a/.github/actions/conditional/conditions +++ b/.github/actions/conditional/conditions @@ -43,6 +43,7 @@ rest/admin-ui-ext/ js services/ js js/apps/account-ui/ ci ci-webauthn js/libs/ui-shared/ ci ci-webauthn +js/libs/keycloak-js/ ci ci-quarkus # The sections below contain a sub-set of files existing in the project which are supported languages by CodeQL. # See: https://codeql.github.com/docs/codeql-overview/supported-languages-and-frameworks/ diff --git a/docs/documentation/server_admin/topics/identity-broker/suggested.adoc b/docs/documentation/server_admin/topics/identity-broker/suggested.adoc index 85a4d992e8..b756bcf9da 100644 --- a/docs/documentation/server_admin/topics/identity-broker/suggested.adoc +++ b/docs/documentation/server_admin/topics/identity-broker/suggested.adoc @@ -22,7 +22,7 @@ If you are using the JavaScript adapter, you can also achieve the same behavior ---- const keycloak = new Keycloak('keycloak.json'); -keycloak.createLoginUrl({ +await keycloak.createLoginUrl({ idpHint: 'facebook' }); ---- diff --git a/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc index 3c88f5399e..c4bd775d1c 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc @@ -303,3 +303,13 @@ The following event types are now deprecated and will be removed in a future ver = `setOrCreateChild()` method removed from JavaScript Admin Client The `groups.setOrCreateChild()` method has been removed from that JavaScript-based Admin Client. If you are still using this method then use the `createChildGroup()` or `updateChildGroup()` methods instead. + += Keycloak JS methods for login are now `async` + +Keycloak JS now utilizes the Web Crypto API to calculate the SHA-256 digests needed to support PKCE. Due to the asynchronous nature of this API the following public methods now return a `Promise`: + +- `login()` +- `createLoginUrl()` +- `createRegisterUrl()` + +Make sure to update your code to `await` these methods. diff --git a/docs/guides/securing-apps/javascript-adapter.adoc b/docs/guides/securing-apps/javascript-adapter.adoc index 8ab30e980b..7e19491c29 100644 --- a/docs/guides/securing-apps/javascript-adapter.adoc +++ b/docs/guides/securing-apps/javascript-adapter.adoc @@ -64,7 +64,7 @@ To enable the _silent_ `check-sso`, you provide a `silentCheckSsoRedirectUri` at [source,javascript] ---- -keycloak.init({ +await keycloak.init({ onLoad: 'check-sso', silentCheckSsoRedirectUri: `${r"${location.origin}"}/silent-check-sso.html` }); @@ -93,7 +93,7 @@ To enable `login-required` set `onLoad` to `login-required` and pass to the init [source,javascript] ---- -keycloak.init({ +await keycloak.init({ onLoad: 'login-required' }); ---- @@ -158,7 +158,7 @@ To enable implicit flow, you enable the *Implicit Flow Enabled* flag for the cli [source,javascript] ---- -keycloak.init({ +await keycloak.init({ flow: 'implicit' }) ---- @@ -176,7 +176,7 @@ For the Hybrid flow, you need to pass the parameter `flow` with value `hybrid` t [source,javascript] ---- -keycloak.init({ +await keycloak.init({ flow: 'hybrid' }); ---- @@ -200,7 +200,7 @@ You can activate the native mode by passing the adapter type `cordova-native` to [source,javascript] ---- -keycloak.init({ +await keycloak.init({ adapter: 'cordova-native' }); ---- @@ -242,7 +242,7 @@ import KeycloakCapacitorAdapter from 'keycloak-capacitor-adapter'; const keycloak = new Keycloak(); -keycloak.init({ +await keycloak.init({ adapter: KeycloakCapacitorAdapter, }); ---- @@ -257,7 +257,7 @@ import Keycloak, { KeycloakAdapter } from 'keycloak-js'; // Implement the 'KeycloakAdapter' interface so that all required methods are guaranteed to be present. const MyCustomAdapter: KeycloakAdapter = { - login(options) { + async login(options) { // Write your own implementation here. } @@ -266,7 +266,7 @@ const MyCustomAdapter: KeycloakAdapter = { const keycloak = new Keycloak(); -keycloak.init({ +await keycloak.init({ adapter: MyCustomAdapter, }); ---- @@ -391,7 +391,7 @@ Returns a promise that resolves when initialization completes. *login(options)* -Redirects to login form. +Redirects to login form, returns a Promise. Options is an optional Object, where: @@ -417,7 +417,7 @@ See link:{adminguide_link}#con-aia_server_administration_guide[Application Initi *createLoginUrl(options)* -Returns the URL to login form. +Returns a Promise containing the URL to login form. Options is an optional Object, which supports same options as the function `login` . @@ -445,7 +445,7 @@ Options are same as for the login method but 'action' is set to 'register' *createRegisterUrl(options)* -Returns the url to registration page. Shortcut for createLoginUrl with option action = 'register' +Returns a Promise containing the url to registration page. Shortcut for createLoginUrl with option action = 'register' Options are same as for the createLoginUrl method but 'action' is set to 'register' diff --git a/js/libs/keycloak-js/dist/keycloak.d.ts b/js/libs/keycloak-js/dist/keycloak.d.ts index 652effe5b4..8597128837 100644 --- a/js/libs/keycloak-js/dist/keycloak.d.ts +++ b/js/libs/keycloak-js/dist/keycloak.d.ts @@ -575,7 +575,7 @@ declare class Keycloak { * Returns the URL to login form. * @param options Supports same options as Keycloak#login. */ - createLoginUrl(options?: KeycloakLoginOptions): string; + createLoginUrl(options?: KeycloakLoginOptions): Promise; /** * Returns the URL to logout the user. @@ -587,7 +587,7 @@ declare class Keycloak { * Returns the URL to registration page. * @param options The options used for creating the registration URL. */ - createRegisterUrl(options?: KeycloakRegisterOptions): string; + createRegisterUrl(options?: KeycloakRegisterOptions): Promise; /** * Returns the URL to the Account Management Console. diff --git a/js/libs/keycloak-js/package.json b/js/libs/keycloak-js/package.json index 8dd0abf1be..c688fa6322 100644 --- a/js/libs/keycloak-js/package.json +++ b/js/libs/keycloak-js/package.json @@ -78,7 +78,6 @@ "shx": "^0.3.4" }, "dependencies": { - "@noble/hashes": "^1.5.0", "jwt-decode": "^4.0.0" } } diff --git a/js/libs/keycloak-js/rollup.config.ts b/js/libs/keycloak-js/rollup.config.ts index 8d2a54cf02..6912731d4b 100644 --- a/js/libs/keycloak-js/rollup.config.ts +++ b/js/libs/keycloak-js/rollup.config.ts @@ -39,7 +39,7 @@ function defineOptions({ file: path.join(targetDir, `${file}.mjs`), }, ], - external: ["@noble/hashes/sha256", "jwt-decode"], + external: ["jwt-decode"], }, // Legacy Universal Module Definition, or “UMD”, with inlined dependencies. { diff --git a/js/libs/keycloak-js/src/keycloak.js b/js/libs/keycloak-js/src/keycloak.js index d5e47daa4a..485ab18126 100755 --- a/js/libs/keycloak-js/src/keycloak.js +++ b/js/libs/keycloak-js/src/keycloak.js @@ -14,7 +14,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sha256 } from '@noble/hashes/sha256'; import { jwtDecode } from 'jwt-decode'; if (typeof Promise === 'undefined') { @@ -200,9 +199,9 @@ function Keycloak (config) { }); } - var checkSsoSilently = function() { + var checkSsoSilently = async function() { var ifrm = document.createElement("iframe"); - var src = kc.createLoginUrl({prompt: 'none', redirectUri: kc.silentCheckSsoRedirectUri}); + var src = await kc.createLoginUrl({prompt: 'none', redirectUri: kc.silentCheckSsoRedirectUri}); ifrm.setAttribute("src", src); ifrm.setAttribute("sandbox", "allow-storage-access-by-user-activation allow-scripts allow-same-origin"); ifrm.setAttribute("title", "keycloak-silent-check-sso"); @@ -371,13 +370,13 @@ function Keycloak (config) { return String.fromCharCode.apply(null, chars); } - function generatePkceChallenge(pkceMethod, codeVerifier) { + async function generatePkceChallenge(pkceMethod, codeVerifier) { if (pkceMethod !== "S256") { throw new TypeError(`Invalid value for 'pkceMethod', expected 'S256' but got '${pkceMethod}'.`); } // hash codeVerifier, then encode as url-safe base64 without padding - const hashBytes = sha256(codeVerifier); + const hashBytes = new Uint8Array(await sha256Digest(codeVerifier)); const encodedHash = bytesToBase64(hashBytes) .replace(/\+/g, '-') .replace(/\//g, '_') @@ -395,7 +394,7 @@ function Keycloak (config) { return JSON.stringify(claims); } - kc.createLoginUrl = function(options) { + kc.createLoginUrl = async function(options) { var state = createUUID(); var nonce = createUUID(); @@ -473,11 +472,15 @@ function Keycloak (config) { } if (kc.pkceMethod) { - var codeVerifier = generateCodeVerifier(96); - callbackState.pkceCodeVerifier = codeVerifier; - var pkceChallenge = generatePkceChallenge(kc.pkceMethod, codeVerifier); - url += '&code_challenge=' + pkceChallenge; - url += '&code_challenge_method=' + kc.pkceMethod; + if (!globalThis.isSecureContext) { + logWarn('[KEYCLOAK] PKCE is only supported in secure contexts (HTTPS)'); + } else { + var codeVerifier = generateCodeVerifier(96); + callbackState.pkceCodeVerifier = codeVerifier; + var pkceChallenge = await generatePkceChallenge(kc.pkceMethod, codeVerifier); + url += '&code_challenge=' + pkceChallenge; + url += '&code_challenge_method=' + kc.pkceMethod; + } } callbackStorage.add(callbackState); @@ -511,12 +514,12 @@ function Keycloak (config) { return adapter.register(options); } - kc.createRegisterUrl = function(options) { + kc.createRegisterUrl = async function(options) { if (!options) { options = {}; } options.action = 'register'; - return kc.createLoginUrl(options); + return await kc.createLoginUrl(options); } kc.createAccountUrl = function(options) { @@ -1315,8 +1318,8 @@ function Keycloak (config) { function loadAdapter(type) { if (!type || type == 'default') { return { - login: function(options) { - window.location.assign(kc.createLoginUrl(options)); + login: async function(options) { + window.location.assign(await kc.createLoginUrl(options)); return createPromise().promise; }, @@ -1428,11 +1431,11 @@ function Keycloak (config) { } return { - login: function(options) { + login: async function(options) { var promise = createPromise(); var cordovaOptions = createCordovaOptions(options); - var loginUrl = kc.createLoginUrl(options); + var loginUrl = await kc.createLoginUrl(options); var ref = cordovaOpenWindowWrapper(loginUrl, '_blank', cordovaOptions); var completed = false; @@ -1550,9 +1553,9 @@ function Keycloak (config) { loginIframe.enable = false; return { - login: function(options) { + login: async function(options) { var promise = createPromise(); - var loginUrl = kc.createLoginUrl(options); + var loginUrl = await kc.createLoginUrl(options); universalLinks.subscribe('keycloak', function(event) { universalLinks.unsubscribe('keycloak'); @@ -1748,8 +1751,22 @@ function Keycloak (config) { export default Keycloak; -// See: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem +/** + * @param {ArrayBuffer} bytes + * @see https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + */ function bytesToBase64(bytes) { const binString = String.fromCodePoint(...bytes); return btoa(binString); } + +/** + * @param {string} message + * @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#basic_example + */ +async function sha256Digest(message) { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + const hash = await crypto.subtle.digest("SHA-256", data); + return hash; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2e867f2f4..4b6f8e8a09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,9 +380,6 @@ importers: js/libs/keycloak-js: dependencies: - '@noble/hashes': - specifier: ^1.5.0 - version: 1.5.0 jwt-decode: specifier: ^4.0.0 version: 4.0.0 @@ -1096,10 +1093,6 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - '@noble/hashes@1.5.0': - resolution: {integrity: sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==} - engines: {node: ^14.21.3 || >=16} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5678,8 +5671,6 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@noble/hashes@1.5.0': {} - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5