Skip to content

Commit

Permalink
Compute SHA-256 digest for PKCE using the Web Crypto API (keycloak#33251
Browse files Browse the repository at this point in the history
)

Closes keycloak#33250

Signed-off-by: Jon Koops <jonkoops@gmail.com>
  • Loading branch information
jonkoops authored Sep 25, 2024
1 parent 6424708 commit 021a2af
Show file tree
Hide file tree
Showing 9 changed files with 63 additions and 45 deletions.
1 change: 1 addition & 0 deletions .github/actions/conditional/conditions
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});
----
Expand Down
10 changes: 10 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 11 additions & 11 deletions docs/guides/securing-apps/javascript-adapter.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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`
});
Expand Down Expand Up @@ -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'
});
----
Expand Down Expand Up @@ -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'
})
----
Expand All @@ -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'
});
----
Expand All @@ -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'
});
----
Expand Down Expand Up @@ -242,7 +242,7 @@ import KeycloakCapacitorAdapter from 'keycloak-capacitor-adapter';
const keycloak = new Keycloak();
keycloak.init({
await keycloak.init({
adapter: KeycloakCapacitorAdapter,
});
----
Expand All @@ -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.
}
Expand All @@ -266,7 +266,7 @@ const MyCustomAdapter: KeycloakAdapter = {
const keycloak = new Keycloak();
keycloak.init({
await keycloak.init({
adapter: MyCustomAdapter,
});
----
Expand Down Expand Up @@ -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:

Expand All @@ -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` .

Expand Down Expand Up @@ -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'

Expand Down
4 changes: 2 additions & 2 deletions js/libs/keycloak-js/dist/keycloak.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;

/**
* Returns the URL to logout the user.
Expand All @@ -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<string>;

/**
* Returns the URL to the Account Management Console.
Expand Down
1 change: 0 additions & 1 deletion js/libs/keycloak-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@
"shx": "^0.3.4"
},
"dependencies": {
"@noble/hashes": "^1.5.0",
"jwt-decode": "^4.0.0"
}
}
2 changes: 1 addition & 1 deletion js/libs/keycloak-js/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
{
Expand Down
57 changes: 37 additions & 20 deletions js/libs/keycloak-js/src/keycloak.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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, '_')
Expand All @@ -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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
},

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}
9 changes: 0 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 021a2af

Please sign in to comment.