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.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}");
|
||||
|
||||
|
@ -15,7 +15,7 @@ class SecureContextResolver {
|
|||
* @param uri The URI to check.
|
||||
* @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")) {
|
||||
return true;
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package org.keycloak.cookie;
|
||||
package org.keycloak.common.util;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
|
@ -441,6 +441,10 @@ const registerUrl = await keycloak.createRegisterUrl();
|
|||
|
||||
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
|
||||
|
||||
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>
|
||||
<#if !isSecureContext>
|
||||
<script type="module" src="${resourceCommonUrl}/vendor/web-crypto-shim/web-crypto-shim.js"></script>
|
||||
</#if>
|
||||
<#if devServerUrl?has_content>
|
||||
<script type="module">
|
||||
import { injectIntoGlobalHook } from "${devServerUrl}/@react-refresh";
|
||||
|
|
|
@ -57,6 +57,9 @@
|
|||
}
|
||||
}
|
||||
</script>
|
||||
<#if !isSecureContext>
|
||||
<script type="module" src="${resourceCommonUrl}/vendor/web-crypto-shim/web-crypto-shim.js"></script>
|
||||
</#if>
|
||||
<#if devServerUrl?has_content>
|
||||
<script type="module">
|
||||
import { injectIntoGlobalHook } from "${devServerUrl}/@react-refresh";
|
||||
|
|
|
@ -52,6 +52,10 @@ function Keycloak (config) {
|
|||
var logInfo = createLogger(console.info);
|
||||
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) {
|
||||
if (kc.didInitialize) {
|
||||
throw new Error("A 'Keycloak' instance can only be initialized once.");
|
||||
|
@ -333,20 +337,12 @@ function Keycloak (config) {
|
|||
}
|
||||
|
||||
function generateRandomData(len) {
|
||||
// use web crypto APIs if possible
|
||||
var array = null;
|
||||
var crypto = window.crypto || window.msCrypto;
|
||||
if (crypto && crypto.getRandomValues && window.Uint8Array) {
|
||||
array = new Uint8Array(len);
|
||||
crypto.getRandomValues(array);
|
||||
return array;
|
||||
if (typeof crypto === "undefined" || typeof crypto.getRandomValues === "undefined") {
|
||||
throw new Error("Web Crypto API is not available.");
|
||||
}
|
||||
|
||||
// fallback to Math random
|
||||
array = new Array(len);
|
||||
for (var j = 0; j < array.length; j++) {
|
||||
array[j] = Math.floor(256 * Math.random());
|
||||
}
|
||||
const array = new Uint8Array(len);
|
||||
crypto.getRandomValues(array);
|
||||
return array;
|
||||
}
|
||||
|
||||
|
@ -465,14 +461,16 @@ function Keycloak (config) {
|
|||
}
|
||||
|
||||
if (kc.pkceMethod) {
|
||||
if (!globalThis.isSecureContext) {
|
||||
logWarn('[KEYCLOAK] PKCE is only supported in secure contexts (HTTPS)');
|
||||
} else {
|
||||
var codeVerifier = generateCodeVerifier(96);
|
||||
try {
|
||||
const codeVerifier = generateCodeVerifier(96);
|
||||
const pkceChallenge = await generatePkceChallenge(kc.pkceMethod, codeVerifier);
|
||||
|
||||
callbackState.pkceCodeVerifier = codeVerifier;
|
||||
var pkceChallenge = await generatePkceChallenge(kc.pkceMethod, codeVerifier);
|
||||
|
||||
url += '&code_challenge=' + pkceChallenge;
|
||||
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) {
|
||||
const encoder = new TextEncoder();
|
||||
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:
|
||||
dependencies:
|
||||
'@noble/hashes':
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0
|
||||
'@patternfly-v5/patternfly':
|
||||
specifier: npm:@patternfly/patternfly@^5.3.1
|
||||
version: '@patternfly/patternfly@5.4.0'
|
||||
|
@ -1064,6 +1067,10 @@ 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'}
|
||||
|
@ -5609,6 +5616,8 @@ 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
|
||||
|
|
|
@ -3,9 +3,11 @@ package org.keycloak.cookie;
|
|||
import jakarta.ws.rs.core.Cookie;
|
||||
import jakarta.ws.rs.core.NewCookie;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.util.SecureContextResolver;
|
||||
import org.keycloak.models.KeycloakContext;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class DefaultCookieProvider implements CookieProvider {
|
||||
|
||||
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.Version;
|
||||
import org.keycloak.common.util.Environment;
|
||||
import org.keycloak.common.util.SecureContextResolver;
|
||||
import org.keycloak.models.AccountRoles;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.Constants;
|
||||
|
@ -109,6 +110,9 @@ public class AccountConsole implements AccountResourceProvider {
|
|||
.path("/")
|
||||
.build(realm);
|
||||
|
||||
final var isSecureContext = SecureContextResolver.isSecureContext(serverBaseUri);
|
||||
|
||||
map.put("isSecureContext", isSecureContext);
|
||||
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.
|
||||
// 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.Version;
|
||||
import org.keycloak.common.util.Environment;
|
||||
import org.keycloak.common.util.SecureContextResolver;
|
||||
import org.keycloak.common.util.UriUtils;
|
||||
import org.keycloak.headers.SecurityHeadersProvider;
|
||||
import org.keycloak.http.HttpRequest;
|
||||
|
@ -347,7 +348,9 @@ public class AdminConsole {
|
|||
|
||||
final var map = new HashMap<String, Object>();
|
||||
final var theme = AdminRoot.getTheme(session, realm);
|
||||
final var isSecureContext = SecureContextResolver.isSecureContext(adminBaseUri);
|
||||
|
||||
map.put("isSecureContext", isSecureContext);
|
||||
map.put("serverBaseUrl", serverBaseUrl);
|
||||
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.
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
"build:clean": "shx rm -rf vendor"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"@patternfly-v5/patternfly": "npm:@patternfly/patternfly@^5.3.1",
|
||||
"@patternfly/patternfly": "^4.224.5",
|
||||
"patternfly": "^3.59.5",
|
||||
|
|
|
@ -40,4 +40,12 @@ export default defineConfig([
|
|||
external: ["react"],
|
||||
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