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:
Jon Koops 2024-10-03 12:51:23 +02:00 committed by GitHub
parent e8d8de8936
commit aacdf80664
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 96 additions and 23 deletions

View file

@ -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;
} }

View file

@ -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;

View file

@ -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.

View file

@ -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";

View file

@ -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";

View file

@ -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);
} }
/** /**

View file

@ -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

View file

@ -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);

View file

@ -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.

View file

@ -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.

View file

@ -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",

View file

@ -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,
},
]); ]);

View 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;
}
});
}