From 28256192435465ba9650ca80e3c177ec85ecdfe0 Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Thu, 16 May 2019 19:17:00 +0200 Subject: [PATCH] KEYCLOAK-1033 Add PKCE support for JS Adapter This adds support for the "S256" code_challenge_method to the JS Adapter. Note that the method "plain" was deliberately left out as is not recommended to be used in new applications. Note that this PR includes two libraries: - [base64-js]{@link https://github.com/beatgammit/base64-js} - [js-sha256]{@link https://github.com/emn178/js-sha256} `base64-js` is needed for cross-browser support for decoding the Uint8ArrayBuffer returned by `crypto.getRandomValues` to a PKCE compatible base64 string. `js-sha256` library is required because the `crypto.subtle.digest` support is not available for all browsers. The PKCE codeVerifier is stored in the callbackStore of the JS Adapter. Note: This PR is based on #5255 which got messed up during a rebase. --- .../oidc/js/src/main/resources/keycloak.d.ts | 10 +- .../oidc/js/src/main/resources/keycloak.js | 107 ++++++++++++++++-- .../util/javascript/JSObjectBuilder.java | 9 ++ .../javascript/JavascriptAdapterTest.java | 13 ++- 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/adapters/oidc/js/src/main/resources/keycloak.d.ts b/adapters/oidc/js/src/main/resources/keycloak.d.ts index ab7878b492..17420eaf1c 100644 --- a/adapters/oidc/js/src/main/resources/keycloak.d.ts +++ b/adapters/oidc/js/src/main/resources/keycloak.d.ts @@ -34,7 +34,8 @@ declare namespace Keycloak { type KeycloakResponseMode = 'query'|'fragment'; type KeycloakResponseType = 'code'|'id_token token'|'code id_token token'; type KeycloakFlow = 'standard'|'implicit'|'hybrid'; - type KeycloakPromiseType = 'native' + type KeycloakPromiseType = 'native'; + type KeycloakPkceMethod = 'S256'; interface KeycloakInitOptions { /** @@ -117,6 +118,13 @@ declare namespace Keycloak { * Keycloak specific promise objects. */ promiseType?: KeycloakPromiseType; + + /** + * Configures the Proof Key for Code Exchange (PKCE) method to use. + * The currently allowed method is 'S256'. + * If not configured, PKCE will not be used. + */ + pkceMethod?: KeycloakPkceMethod; } interface KeycloakLoginOptions { diff --git a/adapters/oidc/js/src/main/resources/keycloak.js b/adapters/oidc/js/src/main/resources/keycloak.js index eb993593f3..63039f75bd 100755 --- a/adapters/oidc/js/src/main/resources/keycloak.js +++ b/adapters/oidc/js/src/main/resources/keycloak.js @@ -115,6 +115,14 @@ if(initOptions.redirectUri) { kc.redirectUri = initOptions.redirectUri; } + + if (initOptions.pkceMethod) { + if (initOptions.pkceMethod !== "S256") { + throw 'Invalid value for pkceMethod'; + } + kc.pkceMethod = initOptions.pkceMethod; + } + } if (!kc.responseMode) { @@ -242,6 +250,53 @@ return adapter.login(options); } + 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; + } + + // fallback to Math random + array = new Array(len); + for (var j = 0; j < array.length; j++) { + array[j] = Math.floor(256 * Math.random()); + } + return array; + } + + function generateCodeVerifier(len) { + return generateRandomString(len, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'); + } + + function generateRandomString(len, alphabet){ + var randomData = generateRandomData(len); + var chars = new Array(len); + for (var i = 0; i < len; i++) { + chars[i] = alphabet.charCodeAt(randomData[i] % alphabet.length); + } + return String.fromCharCode.apply(null, chars); + } + + function generatePkceChallenge(pkceMethod, codeVerifier) { + switch (pkceMethod) { + // The use of the "plain" method is considered insecure and therefore not supported. + case "S256": + // hash codeVerifier, then encode as url-safe base64 without padding + var hashBytes = new Uint8Array(sha256.arrayBuffer(codeVerifier)); + var encodedHash = base64js.fromByteArray(hashBytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/\=/g, ''); + return encodedHash; + default: + throw 'Invalid value for pkceMethod'; + } + } + kc.createLoginUrl = function(options) { var state = createUUID(); var nonce = createUUID(); @@ -252,14 +307,12 @@ state: state, nonce: nonce, redirectUri: encodeURIComponent(redirectUri) - } + }; if (options && options.prompt) { callbackState.prompt = options.prompt; } - callbackStorage.add(callbackState); - var baseUrl; if (options && options.action == 'register') { baseUrl = kc.endpoints.register(); @@ -285,9 +338,9 @@ + '&response_mode=' + encodeURIComponent(kc.responseMode) + '&response_type=' + encodeURIComponent(kc.responseType) + '&scope=' + encodeURIComponent(scope); - if (useNonce) { - url = url + '&nonce=' + encodeURIComponent(nonce); - } + if (useNonce) { + url = url + '&nonce=' + encodeURIComponent(nonce); + } if (options && options.prompt) { url += '&prompt=' + encodeURIComponent(options.prompt); @@ -313,6 +366,16 @@ url += '&kc_locale=' + encodeURIComponent(options.kcLocale); } + if (options && 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; + } + + callbackStorage.add(callbackState); + return url; } @@ -596,6 +659,10 @@ params += '&redirect_uri=' + oauth.redirectUri; + if (oauth.pkceCodeVerifier) { + params += '&code_verifier=' + oauth.pkceCodeVerifier; + } + req.withCredentials = true; req.onreadystatechange = function() { @@ -883,11 +950,8 @@ } function createUUID() { - var s = []; var hexDigits = '0123456789abcdef'; - for (var i = 0; i < 36; i++) { - s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1); - } + var s = generateRandomString(36, hexDigits).split(""); s[14] = '4'; s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1); s[8] = s[13] = s[18] = s[23] = '-'; @@ -916,6 +980,7 @@ oauth.redirectUri = oauthState.redirectUri; oauth.storedNonce = oauthState.nonce; oauth.prompt = oauthState.prompt; + oauth.pkceCodeVerifier = oauthState.pkceCodeVerifier; } return oauth; @@ -1561,3 +1626,25 @@ } } })( window ); + +/** Additional support libraries */ + +/** + * [js-sha256]{@link https://github.com/emn178/js-sha256} + * + * @version 0.9.0 + * @author Chen, Yi-Cyuan [emn178@gmail.com] + * @copyright Chen, Yi-Cyuan 2014-2017 + * @license MIT + */ +!function(){"use strict";function t(t,i){i?(d[0]=d[16]=d[1]=d[2]=d[3]=d[4]=d[5]=d[6]=d[7]=d[8]=d[9]=d[10]=d[11]=d[12]=d[13]=d[14]=d[15]=0,this.blocks=d):this.blocks=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],t?(this.h0=3238371032,this.h1=914150663,this.h2=812702999,this.h3=4144912697,this.h4=4290775857,this.h5=1750603025,this.h6=1694076839,this.h7=3204075428):(this.h0=1779033703,this.h1=3144134277,this.h2=1013904242,this.h3=2773480762,this.h4=1359893119,this.h5=2600822924,this.h6=528734635,this.h7=1541459225),this.block=this.start=this.bytes=this.hBytes=0,this.finalized=this.hashed=!1,this.first=!0,this.is224=t}function i(i,r,s){var e,n=typeof i;if("string"===n){var o,a=[],u=i.length,c=0;for(e=0;e>6,a[c++]=128|63&o):o<55296||o>=57344?(a[c++]=224|o>>12,a[c++]=128|o>>6&63,a[c++]=128|63&o):(o=65536+((1023&o)<<10|1023&i.charCodeAt(++e)),a[c++]=240|o>>18,a[c++]=128|o>>12&63,a[c++]=128|o>>6&63,a[c++]=128|63&o);i=a}else{if("object"!==n)throw new Error(h);if(null===i)throw new Error(h);if(f&&i.constructor===ArrayBuffer)i=new Uint8Array(i);else if(!(Array.isArray(i)||f&&ArrayBuffer.isView(i)))throw new Error(h)}i.length>64&&(i=new t(r,!0).update(i).array());var y=[],p=[];for(e=0;e<64;++e){var l=i[e]||0;y[e]=92^l,p[e]=54^l}t.call(this,r,s),this.update(p),this.oKeyPad=y,this.inner=!0,this.sharedMemory=s}var h="input is invalid type",r="object"==typeof window,s=r?window:{};s.JS_SHA256_NO_WINDOW&&(r=!1);var e=!r&&"object"==typeof self,n=!s.JS_SHA256_NO_NODE_JS&&"object"==typeof process&&process.versions&&process.versions.node;n?s=global:e&&(s=self);var o=!s.JS_SHA256_NO_COMMON_JS&&"object"==typeof module&&module.exports,a="function"==typeof define&&define.amd,f=!s.JS_SHA256_NO_ARRAY_BUFFER&&"undefined"!=typeof ArrayBuffer,u="0123456789abcdef".split(""),c=[-2147483648,8388608,32768,128],y=[24,16,8,0],p=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298],l=["hex","array","digest","arrayBuffer"],d=[];!s.JS_SHA256_NO_NODE_JS&&Array.isArray||(Array.isArray=function(t){return"[object Array]"===Object.prototype.toString.call(t)}),!f||!s.JS_SHA256_NO_ARRAY_BUFFER_IS_VIEW&&ArrayBuffer.isView||(ArrayBuffer.isView=function(t){return"object"==typeof t&&t.buffer&&t.buffer.constructor===ArrayBuffer});var A=function(i,h){return function(r){return new t(h,!0).update(r)[i]()}},w=function(i){var h=A("hex",i);n&&(h=b(h,i)),h.create=function(){return new t(i)},h.update=function(t){return h.create().update(t)};for(var r=0;r>2]|=t[n]<>2]|=s<>2]|=(192|s>>6)<>2]|=(128|63&s)<=57344?(a[e>>2]|=(224|s>>12)<>2]|=(128|s>>6&63)<>2]|=(128|63&s)<>2]|=(240|s>>18)<>2]|=(128|s>>12&63)<>2]|=(128|s>>6&63)<>2]|=(128|63&s)<=64?(this.block=a[16],this.start=e-64,this.hash(),this.hashed=!0):this.start=e}return this.bytes>4294967295&&(this.hBytes+=this.bytes/4294967296<<0,this.bytes=this.bytes%4294967296),this}},t.prototype.finalize=function(){if(!this.finalized){this.finalized=!0;var t=this.blocks,i=this.lastByteIndex;t[16]=this.block,t[i>>2]|=c[3&i],this.block=t[16],i>=56&&(this.hashed||this.hash(),t[0]=this.block,t[16]=t[1]=t[2]=t[3]=t[4]=t[5]=t[6]=t[7]=t[8]=t[9]=t[10]=t[11]=t[12]=t[13]=t[14]=t[15]=0),t[14]=this.hBytes<<3|this.bytes>>>29,t[15]=this.bytes<<3,this.hash()}},t.prototype.hash=function(){var t,i,h,r,s,e,n,o,a,f=this.h0,u=this.h1,c=this.h2,y=this.h3,l=this.h4,d=this.h5,A=this.h6,w=this.h7,b=this.blocks;for(t=16;t<64;++t)i=((s=b[t-15])>>>7|s<<25)^(s>>>18|s<<14)^s>>>3,h=((s=b[t-2])>>>17|s<<15)^(s>>>19|s<<13)^s>>>10,b[t]=b[t-16]+i+b[t-7]+h<<0;for(a=u&c,t=0;t<64;t+=4)this.first?(this.is224?(e=300032,w=(s=b[0]-1413257819)-150054599<<0,y=s+24177077<<0):(e=704751109,w=(s=b[0]-210244248)-1521486534<<0,y=s+143694565<<0),this.first=!1):(i=(f>>>2|f<<30)^(f>>>13|f<<19)^(f>>>22|f<<10),r=(e=f&u)^f&c^a,w=y+(s=w+(h=(l>>>6|l<<26)^(l>>>11|l<<21)^(l>>>25|l<<7))+(l&d^~l&A)+p[t]+b[t])<<0,y=s+(i+r)<<0),i=(y>>>2|y<<30)^(y>>>13|y<<19)^(y>>>22|y<<10),r=(n=y&f)^y&u^e,A=c+(s=A+(h=(w>>>6|w<<26)^(w>>>11|w<<21)^(w>>>25|w<<7))+(w&l^~w&d)+p[t+1]+b[t+1])<<0,i=((c=s+(i+r)<<0)>>>2|c<<30)^(c>>>13|c<<19)^(c>>>22|c<<10),r=(o=c&y)^c&f^n,d=u+(s=d+(h=(A>>>6|A<<26)^(A>>>11|A<<21)^(A>>>25|A<<7))+(A&w^~A&l)+p[t+2]+b[t+2])<<0,i=((u=s+(i+r)<<0)>>>2|u<<30)^(u>>>13|u<<19)^(u>>>22|u<<10),r=(a=u&c)^u&y^o,l=f+(s=l+(h=(d>>>6|d<<26)^(d>>>11|d<<21)^(d>>>25|d<<7))+(d&A^~d&w)+p[t+3]+b[t+3])<<0,f=s+(i+r)<<0;this.h0=this.h0+f<<0,this.h1=this.h1+u<<0,this.h2=this.h2+c<<0,this.h3=this.h3+y<<0,this.h4=this.h4+l<<0,this.h5=this.h5+d<<0,this.h6=this.h6+A<<0,this.h7=this.h7+w<<0},t.prototype.hex=function(){this.finalize();var t=this.h0,i=this.h1,h=this.h2,r=this.h3,s=this.h4,e=this.h5,n=this.h6,o=this.h7,a=u[t>>28&15]+u[t>>24&15]+u[t>>20&15]+u[t>>16&15]+u[t>>12&15]+u[t>>8&15]+u[t>>4&15]+u[15&t]+u[i>>28&15]+u[i>>24&15]+u[i>>20&15]+u[i>>16&15]+u[i>>12&15]+u[i>>8&15]+u[i>>4&15]+u[15&i]+u[h>>28&15]+u[h>>24&15]+u[h>>20&15]+u[h>>16&15]+u[h>>12&15]+u[h>>8&15]+u[h>>4&15]+u[15&h]+u[r>>28&15]+u[r>>24&15]+u[r>>20&15]+u[r>>16&15]+u[r>>12&15]+u[r>>8&15]+u[r>>4&15]+u[15&r]+u[s>>28&15]+u[s>>24&15]+u[s>>20&15]+u[s>>16&15]+u[s>>12&15]+u[s>>8&15]+u[s>>4&15]+u[15&s]+u[e>>28&15]+u[e>>24&15]+u[e>>20&15]+u[e>>16&15]+u[e>>12&15]+u[e>>8&15]+u[e>>4&15]+u[15&e]+u[n>>28&15]+u[n>>24&15]+u[n>>20&15]+u[n>>16&15]+u[n>>12&15]+u[n>>8&15]+u[n>>4&15]+u[15&n];return this.is224||(a+=u[o>>28&15]+u[o>>24&15]+u[o>>20&15]+u[o>>16&15]+u[o>>12&15]+u[o>>8&15]+u[o>>4&15]+u[15&o]),a},t.prototype.toString=t.prototype.hex,t.prototype.digest=function(){this.finalize();var t=this.h0,i=this.h1,h=this.h2,r=this.h3,s=this.h4,e=this.h5,n=this.h6,o=this.h7,a=[t>>24&255,t>>16&255,t>>8&255,255&t,i>>24&255,i>>16&255,i>>8&255,255&i,h>>24&255,h>>16&255,h>>8&255,255&h,r>>24&255,r>>16&255,r>>8&255,255&r,s>>24&255,s>>16&255,s>>8&255,255&s,e>>24&255,e>>16&255,e>>8&255,255&e,n>>24&255,n>>16&255,n>>8&255,255&n];return this.is224||a.push(o>>24&255,o>>16&255,o>>8&255,255&o),a},t.prototype.array=t.prototype.digest,t.prototype.arrayBuffer=function(){this.finalize();var t=new ArrayBuffer(this.is224?28:32),i=new DataView(t);return i.setUint32(0,this.h0),i.setUint32(4,this.h1),i.setUint32(8,this.h2),i.setUint32(12,this.h3),i.setUint32(16,this.h4),i.setUint32(20,this.h5),i.setUint32(24,this.h6),this.is224||i.setUint32(28,this.h7),t},i.prototype=new t,i.prototype.finalize=function(){if(t.prototype.finalize.call(this),this.inner){this.inner=!1;var i=this.array();t.call(this,this.is224,this.sharedMemory),this.update(this.oKeyPad),this.update(i),t.prototype.finalize.call(this)}};var B=w();B.sha256=B,B.sha224=w(!0),B.sha256.hmac=_(),B.sha224.hmac=_(!0),o?module.exports=B:(s.sha256=B.sha256,s.sha224=B.sha224,a&&define(function(){return B}))}(); + +/** + * [base64-js]{@link https://github.com/beatgammit/base64-js} + * + * @version v1.3.0 + * @author Kirill, Fomichev + * @copyright Kirill, Fomichev 2014 + * @license MIT + */ +(function(r){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=r()}else if(typeof define==="function"&&define.amd){define([],r)}else{var e;if(typeof window!=="undefined"){e=window}else if(typeof global!=="undefined"){e=global}else if(typeof self!=="undefined"){e=self}else{e=this}e.base64js=r()}})(function(){var r,e,n;return function(){function r(e,n,t){function o(f,i){if(!n[f]){if(!e[f]){var u="function"==typeof require&&require;if(!i&&u)return u(f,!0);if(a)return a(f,!0);var v=new Error("Cannot find module '"+f+"'");throw v.code="MODULE_NOT_FOUND",v}var d=n[f]={exports:{}};e[f][0].call(d.exports,function(r){var n=e[f][1][r];return o(n||r)},d,d.exports,r,e,n,t)}return n[f].exports}for(var a="function"==typeof require&&require,f=0;f0){throw new Error("Invalid string. Length must be a multiple of 4")}var n=r.indexOf("=");if(n===-1)n=e;var t=n===e?0:4-n%4;return[n,t]}function d(r){var e=v(r);var n=e[0];var t=e[1];return(n+t)*3/4-t}function c(r,e,n){return(e+n)*3/4-n}function h(r){var e;var n=v(r);var t=n[0];var f=n[1];var i=new a(c(r,t,f));var u=0;var d=f>0?t-4:t;for(var h=0;h>16&255;i[u++]=e>>8&255;i[u++]=e&255}if(f===2){e=o[r.charCodeAt(h)]<<2|o[r.charCodeAt(h+1)]>>4;i[u++]=e&255}if(f===1){e=o[r.charCodeAt(h)]<<10|o[r.charCodeAt(h+1)]<<4|o[r.charCodeAt(h+2)]>>2;i[u++]=e>>8&255;i[u++]=e&255}return i}function s(r){return t[r>>18&63]+t[r>>12&63]+t[r>>6&63]+t[r&63]}function l(r,e,n){var t;var o=[];for(var a=e;au?u:i+f))}if(o===1){e=r[n-1];a.push(t[e>>2]+t[e<<4&63]+"==")}else if(o===2){e=(r[n-2]<<8)+r[n-1];a.push(t[e>>10]+t[e>>4&63]+t[e<<2&63]+"=")}return a.join("")}},{}]},{},[])("/")}); \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/javascript/JSObjectBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/javascript/JSObjectBuilder.java index bb1560f252..f5bd2b8d02 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/javascript/JSObjectBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/javascript/JSObjectBuilder.java @@ -69,6 +69,15 @@ public class JSObjectBuilder { } + public JSObjectBuilder pkceS256() { + return pkceMethod("S256"); + } + + private JSObjectBuilder pkceMethod(String method) { + arguments.put("pkceMethod", method); + return this; + } + public String build() { StringBuilder argument = new StringBuilder("{"); String comma = ""; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/javascript/JavascriptAdapterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/javascript/JavascriptAdapterTest.java index e556ae6e54..0bbecd11cc 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/javascript/JavascriptAdapterTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/javascript/JavascriptAdapterTest.java @@ -141,7 +141,18 @@ public class JavascriptAdapterTest extends AbstractJavascriptTest { .init(defaultArguments(), this::assertSuccessfullyLoggedIn) .login("{kcLocale: 'en'}", assertLocaleIsSet("en")); } - + + @Test + public void testLoginWithPkceS256() { + JSObjectBuilder pkceS256 = defaultArguments().pkceS256(); + testExecutor.init(pkceS256, this::assertInitNotAuth) + .login(this::assertOnLoginPage) + .loginForm(testUser, this::assertOnTestAppUrl) + .init(pkceS256, this::assertSuccessfullyLoggedIn) + .logout(this::assertOnTestAppUrl) + .init(pkceS256, this::assertInitNotAuth); + } + @Test public void testRefreshToken() { testExecutor.init(defaultArguments(), this::assertInitNotAuth)