Skip to content

Instantly share code, notes, and snippets.

@pjeby
Last active April 10, 2023 18:23
Show Gist options
  • Save pjeby/b7c2791466776d1ce7d48ba054c0f954 to your computer and use it in GitHub Desktop.
Save pjeby/b7c2791466776d1ce7d48ba054c0f954 to your computer and use it in GitHub Desktop.
Keycloak JS Authenticator: Fetch emails from Github and Twitter APIs

Keycloak One-click Registration for Github and Twitter

Unlike Google and Facebook, Twitter and Github require extra steps to obtain a user's email address: extra steps that Keycloak doesn't do! Instead, Twitter and Github users must manually enter an email that then has to be verified.

This script fixes that problem. It's a Javascript Authenticator for Keycloak that can be placed as a required step at the beginning of a copy of the "first broker login" authentication flow -- which can then be set as the first broker flow for the Github and Twitter identity providers.

Then, when a user registers with Keycloak using one of these providers, this script invokes the right APIs to get the user's email. Twitter only has one email, so it returns that. For Github, it returns the account's primary email address, if it is verified and not a users.noreply.github.com address. If there is no primary, the first public verified address is returned. If there are no public verified addresses, the first verified address is returned.

Since Twitter requires a validated email and this script only returns Github emails marked verified, the emails returned by this script can be considered verified. Thus, you can configure Keycloak to trust emails from Github and Twitter, giving their users the ability to register an account with just one click.

SimpleHttp = Java.type("org.keycloak.broker.provider.util.SimpleHttp");
IdentityBrokerService = Java.type("org.keycloak.services.resources.IdentityBrokerService");
function twitterEmail(bc, token) {
var u = 'https://api.twitter.com/1.1/account/verify_credentials.json', q='',
idpc = IdentityBrokerService.getIdentityProvider(session, realm, 'twitter').getConfig(),
params = {
include_email: 'true',
oauth_consumer_key: idpc.getClientId(),
oauth_nonce: ''+Math.random(),
oauth_signature_method: 'HMAC-SHA1',
oauth_timestamp: Math.floor(Date.now()/1000),
oauth_token: token.oauth_token,
oauth_version: '1.0',
};
params.oauth_signature = oauthSignature.generate(
'GET', u, params, idpc.getClientSecret(), token.oauth_token_secret
);
Object.keys(params).forEach(function(k) { q += (q==='' ? '?' : '&') + k + '=' + params[k]; } );
return JSON.parse( SimpleHttp.doGet(u+q, session).asString() ).email;
}
function githubEmail(bc, token) {
var emails = JSON.parse( SimpleHttp.doGet("https://api.github.com/user/emails", session).header("Authorization", "token " + token).asString() ),
verified = emails.filter(function(e) {
return e.verified && (e.email.split('@').pop() !== 'users.noreply.github.com')
}),
primary = verified.filter(function (e) { return e.primary; }),
publics = verified.filter(function (e) { return e.visibility === 'public'; });
LOG.info(script.name + " emails: " + JSON.stringify(emails));
if (primary.length) return primary[0].email;
if (publics.length) return public[0].email;
if (verified.length) return verified[0].email;
}
/**
* The following variables are available for convenience:
* user - current user {@see org.keycloak.models.UserModel}
* realm - current realm {@see org.keycloak.models.RealmModel}
* session - current KeycloakSession {@see org.keycloak.models.KeycloakSession}
* httpRequest - current HttpRequest {@see org.jboss.resteasy.spi.HttpRequest}
* script - current script {@see org.keycloak.models.ScriptModel}
* authenticationSession - current authentication session {@see org.keycloak.sessions.AuthenticationSessionModel}
* LOG - current logger {@see org.jboss.logging.Logger}
*
* You one can extract current http request headers via:
* httpRequest.getHttpHeaders().getHeaderString("Forwarded")
*
* @param context {@see org.keycloak.authentication.AuthenticationFlowContext}
*/
function authenticate(context) {
var bc = JSON.parse(authenticationSession.getAuthNote("BROKERED_CONTEXT")),
idpID = bc.identityProviderId,
token = bc.contextData.FEDERATED_ACCESS_TOKEN.data
email = bc.email;
if (email) return context.success();
try {
if (idpID === 'twitter') email = twitterEmail(bc, JSON.parse(token));
if (idpID === 'github') email = githubEmail(bc, token);
} catch (e) {
LOG.error(script.name + " error: " + e);
}
LOG.info(
script.name + " provider: " + idpID + " user: " + bc.brokerUsername +
" email: " + email
);
if (email) {
bc.email = email;
authenticationSession.setAuthNote("BROKERED_CONTEXT", JSON.stringify(bc));
}
context.success();
}
window = this;
var CryptoJS=CryptoJS||function(t,e){var r={},n=r.lib={},i=function(){},o=n.Base={extend:function(t){i.prototype=this;var e=new i;return t&&e.mixIn(t),e.hasOwnProperty("init")||(e.init=function(){e.$super.init.apply(this,arguments)}),e.init.prototype=e,e.$super=this,e},create:function(){var t=this.extend();return t.init.apply(t,arguments),t},init:function(){},mixIn:function(t){for(var e in t)t.hasOwnProperty(e)&&(this[e]=t[e]);t.hasOwnProperty("toString")&&(this.toString=t.toString)},clone:function(){return this.init.prototype.extend(this)}},a=n.WordArray=o.extend({init:function(t,e){t=this.words=t||[],this.sigBytes=void 0!=e?e:4*t.length},toString:function(t){return(t||h).stringify(this)},concat:function(t){var e=this.words,r=t.words,n=this.sigBytes;if(t=t.sigBytes,this.clamp(),n%4)for(var i=0;i<t;i++)e[n+i>>>2]|=(r[i>>>2]>>>24-i%4*8&255)<<24-(n+i)%4*8;else if(65535<r.length)for(i=0;i<t;i+=4)e[n+i>>>2]=r[i>>>2];else e.push.apply(e,r);return this.sigBytes+=t,this},clamp:function(){var e=this.words,r=this.sigBytes;e[r>>>2]&=4294967295<<32-r%4*8,e.length=t.ceil(r/4)},clone:function(){var t=o.clone.call(this);return t.words=this.words.slice(0),t},random:function(e){for(var r=[],n=0;n<e;n+=4)r.push(4294967296*t.random()|0);return new a.init(r,e)}}),s=r.enc={},h=s.Hex={stringify:function(t){var e=t.words;t=t.sigBytes;for(var r=[],n=0;n<t;n++){var i=e[n>>>2]>>>24-n%4*8&255;r.push((i>>>4).toString(16)),r.push((15&i).toString(16))}return r.join("")},parse:function(t){for(var e=t.length,r=[],n=0;n<e;n+=2)r[n>>>3]|=parseInt(t.substr(n,2),16)<<24-n%8*4;return new a.init(r,e/2)}},c=s.Latin1={stringify:function(t){var e=t.words;t=t.sigBytes;for(var r=[],n=0;n<t;n++)r.push(String.fromCharCode(e[n>>>2]>>>24-n%4*8&255));return r.join("")},parse:function(t){for(var e=t.length,r=[],n=0;n<e;n++)r[n>>>2]|=(255&t.charCodeAt(n))<<24-n%4*8;return new a.init(r,e)}},u=s.Utf8={stringify:function(t){try{return decodeURIComponent(escape(c.stringify(t)))}catch(t){throw Error("Malformed UTF-8 data")}},parse:function(t){return c.parse(unescape(encodeURIComponent(t)))}},f=n.BufferedBlockAlgorithm=o.extend({reset:function(){this._data=new a.init,this._nDataBytes=0},_append:function(t){"string"==typeof t&&(t=u.parse(t)),this._data.concat(t),this._nDataBytes+=t.sigBytes},_process:function(e){var r=this._data,n=r.words,i=r.sigBytes,o=this.blockSize,s=i/(4*o);if(e=(s=e?t.ceil(s):t.max((0|s)-this._minBufferSize,0))*o,i=t.min(4*e,i),e){for(var h=0;h<e;h+=o)this._doProcessBlock(n,h);h=n.splice(0,e),r.sigBytes-=i}return new a.init(h,i)},clone:function(){var t=o.clone.call(this);return t._data=this._data.clone(),t},_minBufferSize:0});n.Hasher=f.extend({cfg:o.extend(),init:function(t){this.cfg=this.cfg.extend(t),this.reset()},reset:function(){f.reset.call(this),this._doReset()},update:function(t){return this._append(t),this._process(),this},finalize:function(t){return t&&this._append(t),this._doFinalize()},blockSize:16,_createHelper:function(t){return function(e,r){return new t.init(r).finalize(e)}},_createHmacHelper:function(t){return function(e,r){return new p.HMAC.init(t,r).finalize(e)}}});var p=r.algo={};return r}(Math);!function(){var t=CryptoJS,e=(i=t.lib).WordArray,r=i.Hasher,n=[],i=t.algo.SHA1=r.extend({_doReset:function(){this._hash=new e.init([1732584193,4023233417,2562383102,271733878,3285377520])},_doProcessBlock:function(t,e){for(var r=this._hash.words,i=r[0],o=r[1],a=r[2],s=r[3],h=r[4],c=0;80>c;c++){if(16>c)n[c]=0|t[e+c];else{var u=n[c-3]^n[c-8]^n[c-14]^n[c-16];n[c]=u<<1|u>>>31}u=(i<<5|i>>>27)+h+n[c],u=20>c?u+(1518500249+(o&a|~o&s)):40>c?u+(1859775393+(o^a^s)):60>c?u+((o&a|o&s|a&s)-1894007588):u+((o^a^s)-899497514),h=s,s=a,a=o<<30|o>>>2,o=i,i=u}r[0]=r[0]+i|0,r[1]=r[1]+o|0,r[2]=r[2]+a|0,r[3]=r[3]+s|0,r[4]=r[4]+h|0},_doFinalize:function(){var t=this._data,e=t.words,r=8*this._nDataBytes,n=8*t.sigBytes;return e[n>>>5]|=128<<24-n%32,e[14+(n+64>>>9<<4)]=Math.floor(r/4294967296),e[15+(n+64>>>9<<4)]=r,t.sigBytes=4*e.length,this._process(),this._hash},clone:function(){var t=r.clone.call(this);return t._hash=this._hash.clone(),t}});t.SHA1=r._createHelper(i),t.HmacSHA1=r._createHmacHelper(i)}(),function(){var t=CryptoJS,e=t.enc.Utf8;t.algo.HMAC=t.lib.Base.extend({init:function(t,r){t=this._hasher=new t.init,"string"==typeof r&&(r=e.parse(r));var n=t.blockSize,i=4*n;r.sigBytes>i&&(r=t.finalize(r)),r.clamp();for(var o=this._oKey=r.clone(),a=this._iKey=r.clone(),s=o.words,h=a.words,c=0;c<n;c++)s[c]^=1549556828,h[c]^=909522486;o.sigBytes=a.sigBytes=i,this.reset()},reset:function(){var t=this._hasher;t.reset(),t.update(this._iKey)},update:function(t){return this._hasher.update(t),this},finalize:function(t){var e=this._hasher;return t=e.finalize(t),e.reset(),e.finalize(this._oKey.clone().concat(t))}})}(),function(){var t=CryptoJS,e=t.lib.WordArray;t.enc.Base64={stringify:function(t){var e=t.words,r=t.sigBytes,n=this._map;t.clamp(),t=[];for(var i=0;i<r;i+=3)for(var o=(e[i>>>2]>>>24-i%4*8&255)<<16|(e[i+1>>>2]>>>24-(i+1)%4*8&255)<<8|e[i+2>>>2]>>>24-(i+2)%4*8&255,a=0;4>a&&i+.75*a<r;a++)t.push(n.charAt(o>>>6*(3-a)&63));if(e=n.charAt(64))for(;t.length%4;)t.push(e);return t.join("")},parse:function(t){var r=t.length,n=this._map;(i=n.charAt(64))&&-1!=(i=t.indexOf(i))&&(r=i);for(var i=[],o=0,a=0;a<r;a++)if(a%4){var s=n.indexOf(t.charAt(a-1))<<a%4*2,h=n.indexOf(t.charAt(a))>>>6-a%4*2;i[o>>>2]|=(s|h)<<24-o%4*8,o++}return e.create(i,o)},_map:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="}}(),function(){var t=function(){function t(){}function e(t){return decodeURIComponent(t.replace(/\+/g," "))}function r(t,e){var r=t.charAt(0),n=e.split(r);return r===t?n:(t=parseInt(t.substring(1),10),n[t<0?n.length+t:t-1])}function n(t,r){for(var n=t.charAt(0),i=r.split("&"),o=[],a={},s=[],h=t.substring(1),c=0,u=i.length;c<u;c++)if((o=i[c].match(/(.*?)=(.*)/))||(o=[i[c],i[c],""]),""!==o[1].replace(/\s/g,"")){if(o[2]=e(o[2]||""),h===o[1])return o[2];(s=o[1].match(/(.*)\[([0-9]+)\]/))?(a[s[1]]=a[s[1]]||[],a[s[1]][s[2]]=o[2]):a[o[1]]=o[2]}return n===t?a:a[h]}return function(e,i){var o,a={};if("tld?"===e)return t();if(i=i||window.location.toString(),!e)return i;if(e=e.toString(),o=i.match(/^mailto:([^\/].+)/))a.protocol="mailto",a.email=o[1];else{if((o=i.match(/(.*?)\/#\!(.*)/))&&(i=o[1]+o[2]),(o=i.match(/(.*?)#(.*)/))&&(a.hash=o[2],i=o[1]),a.hash&&e.match(/^#/))return n(e,a.hash);if((o=i.match(/(.*?)\?(.*)/))&&(a.query=o[2],i=o[1]),a.query&&e.match(/^\?/))return n(e,a.query);if((o=i.match(/(.*?)\:?\/\/(.*)/))&&(a.protocol=o[1].toLowerCase(),i=o[2]),(o=i.match(/(.*?)(\/.*)/))&&(a.path=o[2],i=o[1]),a.path=(a.path||"").replace(/^([^\/])/,"/$1"),e.match(/^[\-0-9]+$/)&&(e=e.replace(/^([^\/])/,"/$1")),e.match(/^\//))return r(e,a.path.substring(1));if((o=r("/-1",a.path.substring(1)))&&(o=o.match(/(.*?)\.(.*)/))&&(a.file=o[0],a.filename=o[1],a.fileext=o[2]),(o=i.match(/(.*)\:([0-9]+)$/))&&(a.port=o[2],i=o[1]),(o=i.match(/(.*?)@(.*)/))&&(a.auth=o[1],i=o[2]),a.auth&&(o=a.auth.match(/(.*)\:(.*)/),a.user=o?o[1]:a.auth,a.pass=o?o[2]:void 0),a.hostname=i.toLowerCase(),"."===e.charAt(0))return r(e,a.hostname);t()&&(o=a.hostname.match(t()))&&(a.tld=o[3],a.domain=o[2]?o[2]+"."+o[3]:void 0,a.sub=o[1]||void 0),a.port=a.port||("https"===a.protocol?"443":"80"),a.protocol=a.protocol||("443"===a.port?"https":"http")}return e in a?a[e]:"{}"===e?a:void 0}}();"function"==typeof window.define&&window.define.amd?window.define("js-url",[],function(){return t}):(void 0!==window.jQuery&&window.jQuery.extend({url:function(t,e){return window.url(t,e)}}),window.url=t)}(),function(){"use strict";function t(){}function e(t,e,s){s=new o(s).get(),this._httpMethod=new r(t).get(),this._url=new n(e).get(),this._parameters=new i(s).get(),this._rfc3986=new a}function r(t){this._httpMethod=t||""}function n(t){this._url=t||""}function i(t){this._parameters=t||{},this._sortedKeys=[],this._normalizedParameters=[],this._rfc3986=new a,this._sortParameters(),this._concatenateParameters()}function o(t){this._parameters={},this._loadParameters(t||{})}function a(){}function s(t,e,r){this._rfc3986=new a,this._text=t,this._key=this._rfc3986.encode(e)+"&"+this._rfc3986.encode(r),this._base64EncodedHash=new h(this._text,this._key).getBase64EncodedHash()}function h(t,e){this._cryptoJS=c?require("crypto-js"):CryptoJS,this._text=t||"",this._key=e||"",this._hash=this._cryptoJS.HmacSHA1(this._text,this._key)}var c="undefined"!=typeof module&&void 0!==module.exports;t.prototype.generate=function(t,r,n,i,o,a){var h=new e(t,r,n).generate(),c=!0;return a&&(c=a.encodeSignature),new s(h,i,o).generate(c)},e.prototype={generate:function(){return this._rfc3986.encode(this._httpMethod)+"&"+this._rfc3986.encode(this._url)+"&"+this._rfc3986.encode(this._parameters)}},r.prototype={get:function(){return this._httpMethod.toUpperCase()}},n.prototype={get:function(){if(!this._url)return this._url;-1==this._url.indexOf("://")&&(this._url="http://"+this._url);var t=c?this.parseInNode():this.parseInBrowser(),e=(t.scheme||"http").toLowerCase(),r=(t.authority||"").toLocaleLowerCase(),n=t.path||"",i=t.port||"";(80==i&&"http"==e||443==i&&"https"==e)&&(i="");var o=e+"://"+r;return o+=i?":"+i:"","/"==n&&-1===this._url.indexOf(o+n)&&(n=""),this._url=(e?e+"://":"")+r+(i?":"+i:"")+n,this._url},parseInBrowser:function(){return{scheme:url("protocol",this._url).toLowerCase(),authority:url("hostname",this._url).toLocaleLowerCase(),port:url("port",this._url),path:url("path",this._url)}},parseInNode:function(){var t=require("uri-js").parse(this._url),e=t.scheme;return":"==e.charAt(e.length-1)&&(e=e.substring(0,e.length-1)),{scheme:e,authority:t.host,port:t.port,path:t.path}}},i.prototype={_sortParameters:function(){var t,e;for(t in this._parameters)this._parameters.hasOwnProperty(t)&&(e=this._rfc3986.encode(t),this._sortedKeys.push(e));this._sortedKeys.sort()},_concatenateParameters:function(){var t;for(t=0;t<this._sortedKeys.length;t++)this._normalizeParameter(this._sortedKeys[t])},_normalizeParameter:function(t){var e,r,n=this._rfc3986.decode(t),i=this._parameters[n];for(i.sort(),e=0;e<i.length;e++)r=this._rfc3986.encode(i[e]),this._normalizedParameters.push(t+"="+r)},get:function(){return this._normalizedParameters.join("&")}},o.prototype={_loadParameters:function(t){t instanceof Array?this._loadParametersFromArray(t):"object"==typeof t&&this._loadParametersFromObject(t)},_loadParametersFromArray:function(t){var e;for(e=0;e<t.length;e++)this._loadParametersFromObject(t[e])},_loadParametersFromObject:function(t){var e;for(e in t)if(t.hasOwnProperty(e)){var r=this._getStringFromParameter(t[e]);this._loadParameterValue(e,r)}},_loadParameterValue:function(t,e){var r;if(e instanceof Array){for(r=0;r<e.length;r++){var n=this._getStringFromParameter(e[r]);this._addParameter(t,n)}0==e.length&&this._addParameter(t,"")}else this._addParameter(t,e)},_getStringFromParameter:function(t){var e=t||"";try{"number"!=typeof t&&"boolean"!=typeof t||(e=t.toString())}catch(t){}return e},_addParameter:function(t,e){this._parameters[t]||(this._parameters[t]=[]),this._parameters[t].push(e)},get:function(){return this._parameters}},a.prototype={encode:function(t){return t?encodeURIComponent(t).replace(/[!'()]/g,escape).replace(/\*/g,"%2A"):""},decode:function(t){return t?decodeURIComponent(t):""}},s.prototype={generate:function(t){return!1===t?this._base64EncodedHash:this._rfc3986.encode(this._base64EncodedHash)}},h.prototype={getBase64EncodedHash:function(){return this._hash.toString(this._cryptoJS.enc.Base64)}};var u=new t;u.SignatureBaseString=e,u.HttpMethodElement=r,u.UrlElement=n,u.ParametersElement=i,u.ParametersLoader=o,u.Rfc3986=a,u.HmacSha1Signature=s,u.HmacSha1=h,c?module.exports=u:window.oauthSignature=u}();
@smneal
Copy link

smneal commented Oct 13, 2020

Hi @pjeby, I can't seem to get this to work. At the moment it seems that email comes back automatically from GitHub but the area I'm interested in here is how to retrieve the token so it can be used in future requests to GitHub to retrieve things like organisation and team details.

Would it be possible to explain a little more about the setup in the copy of the First Broker Login Authentication flow. I've added the script several times at different stages in the flow and added context.forkWithErrorMessage(new FormMessage('label',idpID)); return; just after email = bc.email; to see if bc is bringing back anything and to troubleshoot, but it just seems to log me in gracefully without throwing an error at all.

@pjeby
Copy link
Author

pjeby commented Oct 13, 2020

Hi @smneal. The script needs to be the very first step in the process, set to REQUIRED, and the flow you add it to has to be selected as the "First Login Flow" field on the identity provider's configuration (Github in this case).

I would check your Keycloak logs to see if any of the info or error log messages are appearing, so you can see if the auth script is being run at all. I'm assuming you're also starting keycloak with the necessary flags to 1) enable scripts to run at all, and 2) manage them via the web UI. (Most newer versions of Keycloak don't enable script functionality by default.)

Also, quick question: when you say that email comes back automatically from Github, do you mean that your version of Keycloak does this without the script?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment