Add shim for Web Crypto API to admin and account console (#33480)
Closes #33330 Signed-off-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
parent
e8d8de8936
commit
aacdf80664
13 changed files with 96 additions and 23 deletions
|
@ -1,9 +1,9 @@
|
||||||
package org.keycloak.cookie;
|
package org.keycloak.common.util;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
class SecureContextResolver {
|
public class SecureContextResolver {
|
||||||
|
|
||||||
private static final Pattern LOCALHOST_IPV4 = Pattern.compile("127.\\d{1,3}.\\d{1,3}.\\d{1,3}");
|
private static final Pattern LOCALHOST_IPV4 = Pattern.compile("127.\\d{1,3}.\\d{1,3}.\\d{1,3}");
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ class SecureContextResolver {
|
||||||
* @param uri The URI to check.
|
* @param uri The URI to check.
|
||||||
* @return Whether the URI can be considered potentially trustworthy.
|
* @return Whether the URI can be considered potentially trustworthy.
|
||||||
*/
|
*/
|
||||||
static boolean isSecureContext(URI uri) {
|
public static boolean isSecureContext(URI uri) {
|
||||||
if (uri.getScheme().equals("https")) {
|
if (uri.getScheme().equals("https")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package org.keycloak.cookie;
|
package org.keycloak.common.util;
|
||||||
|
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
|
@ -441,6 +441,10 @@ const registerUrl = await keycloak.createRegisterUrl();
|
||||||
|
|
||||||
Make sure to update your code to `await` these methods.
|
Make sure to update your code to `await` these methods.
|
||||||
|
|
||||||
|
== A secure context is now required
|
||||||
|
|
||||||
|
Keycloak JS now requires a link:https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts[secure context] to run. The reason for this is that the library now uses the Web Crypto API to calculate the SHA-256 digests needed to support PKCE. This API is only available in secure contexts, which are contexts that are served over HTTPS, `localhost` or a `.localhost` domain. If you are using the library in a non-secure context you'll need to update your development environment to use a secure context.
|
||||||
|
|
||||||
= Stricter startup behavior for build-time options
|
= Stricter startup behavior for build-time options
|
||||||
|
|
||||||
When the provided build-time options differ at startup from the values persisted in the server image during the last optimized {project_name} build, {project_name} will now fail to start. Previously, a warning message was displayed in such cases.
|
When the provided build-time options differ at startup from the values persisted in the server image during the last optimized {project_name} build, {project_name} will now fail to start. Previously, a warning message was displayed in such cases.
|
||||||
|
|
|
@ -57,6 +57,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<#if !isSecureContext>
|
||||||
|
<script type="module" src="${resourceCommonUrl}/vendor/web-crypto-shim/web-crypto-shim.js"></script>
|
||||||
|
</#if>
|
||||||
<#if devServerUrl?has_content>
|
<#if devServerUrl?has_content>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { injectIntoGlobalHook } from "${devServerUrl}/@react-refresh";
|
import { injectIntoGlobalHook } from "${devServerUrl}/@react-refresh";
|
||||||
|
|
|
@ -57,6 +57,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<#if !isSecureContext>
|
||||||
|
<script type="module" src="${resourceCommonUrl}/vendor/web-crypto-shim/web-crypto-shim.js"></script>
|
||||||
|
</#if>
|
||||||
<#if devServerUrl?has_content>
|
<#if devServerUrl?has_content>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { injectIntoGlobalHook } from "${devServerUrl}/@react-refresh";
|
import { injectIntoGlobalHook } from "${devServerUrl}/@react-refresh";
|
||||||
|
|
|
@ -52,6 +52,10 @@ function Keycloak (config) {
|
||||||
var logInfo = createLogger(console.info);
|
var logInfo = createLogger(console.info);
|
||||||
var logWarn = createLogger(console.warn);
|
var logWarn = createLogger(console.warn);
|
||||||
|
|
||||||
|
if (!globalThis.isSecureContext) {
|
||||||
|
logWarn('[KEYCLOAK] Keycloak JS should only be used in a secure context: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts');
|
||||||
|
}
|
||||||
|
|
||||||
kc.init = function (initOptions) {
|
kc.init = function (initOptions) {
|
||||||
if (kc.didInitialize) {
|
if (kc.didInitialize) {
|
||||||
throw new Error("A 'Keycloak' instance can only be initialized once.");
|
throw new Error("A 'Keycloak' instance can only be initialized once.");
|
||||||
|
@ -333,20 +337,12 @@ function Keycloak (config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateRandomData(len) {
|
function generateRandomData(len) {
|
||||||
// use web crypto APIs if possible
|
if (typeof crypto === "undefined" || typeof crypto.getRandomValues === "undefined") {
|
||||||
var array = null;
|
throw new Error("Web Crypto API is not available.");
|
||||||
var crypto = window.crypto || window.msCrypto;
|
|
||||||
if (crypto && crypto.getRandomValues && window.Uint8Array) {
|
|
||||||
array = new Uint8Array(len);
|
|
||||||
crypto.getRandomValues(array);
|
|
||||||
return array;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback to Math random
|
const array = new Uint8Array(len);
|
||||||
array = new Array(len);
|
crypto.getRandomValues(array);
|
||||||
for (var j = 0; j < array.length; j++) {
|
|
||||||
array[j] = Math.floor(256 * Math.random());
|
|
||||||
}
|
|
||||||
return array;
|
return array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -465,14 +461,16 @@ function Keycloak (config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kc.pkceMethod) {
|
if (kc.pkceMethod) {
|
||||||
if (!globalThis.isSecureContext) {
|
try {
|
||||||
logWarn('[KEYCLOAK] PKCE is only supported in secure contexts (HTTPS)');
|
const codeVerifier = generateCodeVerifier(96);
|
||||||
} else {
|
const pkceChallenge = await generatePkceChallenge(kc.pkceMethod, codeVerifier);
|
||||||
var codeVerifier = generateCodeVerifier(96);
|
|
||||||
callbackState.pkceCodeVerifier = codeVerifier;
|
callbackState.pkceCodeVerifier = codeVerifier;
|
||||||
var pkceChallenge = await generatePkceChallenge(kc.pkceMethod, codeVerifier);
|
|
||||||
url += '&code_challenge=' + pkceChallenge;
|
url += '&code_challenge=' + pkceChallenge;
|
||||||
url += '&code_challenge_method=' + kc.pkceMethod;
|
url += '&code_challenge_method=' + kc.pkceMethod;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Failed to generate PKCE challenge.", { cause: error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1741,8 +1739,12 @@ function bytesToBase64(bytes) {
|
||||||
async function sha256Digest(message) {
|
async function sha256Digest(message) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const data = encoder.encode(message);
|
const data = encoder.encode(message);
|
||||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
||||||
return hash;
|
if (typeof crypto === "undefined" || typeof crypto.subtle === "undefined") {
|
||||||
|
throw new Error("Web Crypto API is not available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await crypto.subtle.digest("SHA-256", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -452,6 +452,9 @@ importers:
|
||||||
|
|
||||||
themes:
|
themes:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@noble/hashes':
|
||||||
|
specifier: ^1.5.0
|
||||||
|
version: 1.5.0
|
||||||
'@patternfly-v5/patternfly':
|
'@patternfly-v5/patternfly':
|
||||||
specifier: npm:@patternfly/patternfly@^5.3.1
|
specifier: npm:@patternfly/patternfly@^5.3.1
|
||||||
version: '@patternfly/patternfly@5.4.0'
|
version: '@patternfly/patternfly@5.4.0'
|
||||||
|
@ -1064,6 +1067,10 @@ packages:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
react-dom: ^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':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
@ -5609,6 +5616,8 @@ snapshots:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
|
'@noble/hashes@1.5.0': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
|
|
|
@ -3,9 +3,11 @@ package org.keycloak.cookie;
|
||||||
import jakarta.ws.rs.core.Cookie;
|
import jakarta.ws.rs.core.Cookie;
|
||||||
import jakarta.ws.rs.core.NewCookie;
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.common.util.SecureContextResolver;
|
||||||
import org.keycloak.models.KeycloakContext;
|
import org.keycloak.models.KeycloakContext;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public class DefaultCookieProvider implements CookieProvider {
|
public class DefaultCookieProvider implements CookieProvider {
|
||||||
|
|
||||||
private static final Logger logger = Logger.getLogger(DefaultCookieProvider.class);
|
private static final Logger logger = Logger.getLogger(DefaultCookieProvider.class);
|
||||||
|
|
|
@ -9,6 +9,7 @@ import org.keycloak.authentication.requiredactions.DeleteAccount;
|
||||||
import org.keycloak.common.Profile;
|
import org.keycloak.common.Profile;
|
||||||
import org.keycloak.common.Version;
|
import org.keycloak.common.Version;
|
||||||
import org.keycloak.common.util.Environment;
|
import org.keycloak.common.util.Environment;
|
||||||
|
import org.keycloak.common.util.SecureContextResolver;
|
||||||
import org.keycloak.models.AccountRoles;
|
import org.keycloak.models.AccountRoles;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.Constants;
|
import org.keycloak.models.Constants;
|
||||||
|
@ -109,6 +110,9 @@ public class AccountConsole implements AccountResourceProvider {
|
||||||
.path("/")
|
.path("/")
|
||||||
.build(realm);
|
.build(realm);
|
||||||
|
|
||||||
|
final var isSecureContext = SecureContextResolver.isSecureContext(serverBaseUri);
|
||||||
|
|
||||||
|
map.put("isSecureContext", isSecureContext);
|
||||||
map.put("serverBaseUrl", serverBaseUrl);
|
map.put("serverBaseUrl", serverBaseUrl);
|
||||||
// TODO: Some variables are deprecated and only exist to provide backwards compatibility for older themes, they should be removed in a future version.
|
// TODO: Some variables are deprecated and only exist to provide backwards compatibility for older themes, they should be removed in a future version.
|
||||||
// Note that these should be removed from the template of the Account Console as well.
|
// Note that these should be removed from the template of the Account Console as well.
|
||||||
|
|
|
@ -33,6 +33,7 @@ import org.keycloak.common.ClientConnection;
|
||||||
import org.keycloak.common.Profile;
|
import org.keycloak.common.Profile;
|
||||||
import org.keycloak.common.Version;
|
import org.keycloak.common.Version;
|
||||||
import org.keycloak.common.util.Environment;
|
import org.keycloak.common.util.Environment;
|
||||||
|
import org.keycloak.common.util.SecureContextResolver;
|
||||||
import org.keycloak.common.util.UriUtils;
|
import org.keycloak.common.util.UriUtils;
|
||||||
import org.keycloak.headers.SecurityHeadersProvider;
|
import org.keycloak.headers.SecurityHeadersProvider;
|
||||||
import org.keycloak.http.HttpRequest;
|
import org.keycloak.http.HttpRequest;
|
||||||
|
@ -347,7 +348,9 @@ public class AdminConsole {
|
||||||
|
|
||||||
final var map = new HashMap<String, Object>();
|
final var map = new HashMap<String, Object>();
|
||||||
final var theme = AdminRoot.getTheme(session, realm);
|
final var theme = AdminRoot.getTheme(session, realm);
|
||||||
|
final var isSecureContext = SecureContextResolver.isSecureContext(adminBaseUri);
|
||||||
|
|
||||||
|
map.put("isSecureContext", isSecureContext);
|
||||||
map.put("serverBaseUrl", serverBaseUrl);
|
map.put("serverBaseUrl", serverBaseUrl);
|
||||||
map.put("adminBaseUrl", adminBaseUrl);
|
map.put("adminBaseUrl", adminBaseUrl);
|
||||||
// TODO: Some variables are deprecated and only exist to provide backwards compatibility for older themes, they should be removed in a future version.
|
// TODO: Some variables are deprecated and only exist to provide backwards compatibility for older themes, they should be removed in a future version.
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
"build:clean": "shx rm -rf vendor"
|
"build:clean": "shx rm -rf vendor"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^1.5.0",
|
||||||
"@patternfly-v5/patternfly": "npm:@patternfly/patternfly@^5.3.1",
|
"@patternfly-v5/patternfly": "npm:@patternfly/patternfly@^5.3.1",
|
||||||
"@patternfly/patternfly": "^4.224.5",
|
"@patternfly/patternfly": "^4.224.5",
|
||||||
"patternfly": "^3.59.5",
|
"patternfly": "^3.59.5",
|
||||||
|
|
|
@ -40,4 +40,12 @@ export default defineConfig([
|
||||||
external: ["react"],
|
external: ["react"],
|
||||||
plugins,
|
plugins,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
input: "src/main/js/web-crypto-shim.js",
|
||||||
|
output: {
|
||||||
|
dir: path.join(targetDir, "web-crypto-shim"),
|
||||||
|
format: "es",
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
34
themes/src/main/js/web-crypto-shim.js
Normal file
34
themes/src/main/js/web-crypto-shim.js
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { sha256 } from '@noble/hashes/sha256';
|
||||||
|
|
||||||
|
// Shim for Web Crypto API specifically for Keycloak JS, as this API can sometimes be missing, for example in an insecure context:
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
|
||||||
|
// Since we have decided to support insecure contexts, we (sadly) need to provide a fallback for the Web Crypto API.
|
||||||
|
if (typeof crypto === "undefined") {
|
||||||
|
globalThis.crypto = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof crypto.subtle === "undefined") {
|
||||||
|
Object.defineProperty(crypto, "subtle", {
|
||||||
|
value: {
|
||||||
|
digest: async (algorithm, data) => {
|
||||||
|
if (algorithm === "SHA-256") {
|
||||||
|
return sha256(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unsupported algorithm");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof crypto.getRandomValues === "undefined") {
|
||||||
|
Object.defineProperty(crypto, "getRandomValues", {
|
||||||
|
value: (array) => {
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
array[i] = Math.floor(Math.random() * 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue