Technical Articles
Keyclock as an OpenID Connect (OIDC) provider.
|
In the previous instalment I demonstrated Keycloak in action as an SAML WebSSO Identity Provider. However, and likewise SAP IAS, Azure AD and many other IDPs, each Keycloak tenant (realm) can act as an OpenID Connect (OIDC) provider so you can create OIDC clients (applications) for user authentication. The OIDC protocol supports a number of grant types that can be implemented to authenticate a user; with the preferred type being the authorization code flow that is supported by Keycloak. |
Disclaimer:
|
Putting it all together
1. Create an OIDC client (application) with Keycloak IDP.
That’s a relatively straightforward operation that you will do in your realm (aka tenant), as depicted below:
Good to know:
Next we may want to (re-)generate the client secret. (Credentials tab) And finally we can conveniently download the OIDC client settings in json format. (Installation tab), as shown below: |
{
"realm": "ateam-isveng",
"auth-server-url": "https://keycloak<cluster>/auth/",
"ssl-required": "external",
"resource": "quovadis-oidc",
"credentials": {
"secret": "<client_secret>"
},
"confidential-port": 0
}
Please note the resource, "quovadis-oidc" is the client_id.
2. Get OIDC provider metadata.
We can discover keycloak OIDC provider metadata by appending the /.well-known/openid-configuration suffix to the provider tenant (realm) path as follows:
Here goes the custom realm OIDC metadata discovery endpoint:https://keycloak.<cluster>/auth/realms/ateam-isveng/.well-known/openid-configuration |
// both redacted and truncated...
//
https://keycloak.<cluster>/auth/realms/ateam-isveng/.well-known/openid-configuration
{
"issuer": "https://keycloak/auth/realms/ateam-isveng",
"authorization_endpoint": "https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect/auth",
"token_endpoint": "https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect/token",
"introspection_endpoint": "https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect/token/introspect",
"userinfo_endpoint": "https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect/userinfo",
"end_session_endpoint": "https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect/logout",
"jwks_uri": "https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect/certs",
"check_session_iframe": "https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect/login-status-iframe.html",
"grant_types_supported": [
"authorization_code",
"implicit",
"refresh_token",
"password",
"client_credentials",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:openid:params:grant-type:ciba"
],
"response_types_supported": [
"code",
"none",
"id_token",
"token",
"id_token token",
"code id_token",
"code token",
"code id_token token"
],
}
“authorization_endpoint”: “https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect/auth”, “token_endpoint”: “https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect/token”, |
3. Retrieve a user JWT with Keyclock OIDC provider
Everything is done in nodejs with the openid-client in. a kyma function, as depicted below:
//--------------------------------------------------------------------------------------------------------
// https://www.npmjs.com/package/openid-client
// https://github.com/jcawley5/oidc-sample-app/blob/master/app/auth/sapOIDC.js
const { Issuer } = require('openid-client');
const { generators } = require('openid-client');
const scopes_supported = "openid email";
var client_keycloak;
//-------------------------------------------------------------------------------------------------------------------------------------
// store the code_verifier in your framework's session mechanism, if it is a cookie based solution
// it should be httpOnly (not readable by javascript) and encrypted.
//
const code_verifier = generators.codeVerifier();
//-------------------------------------------------------------------------------------------------------------------------------------
//
const credentials_keycloak = {
client: {
id: 'quovadis-oidc',
secret: '<secret>'
},
auth: {
authorizeHost: 'https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect',
authorizePath: 'auth',
tokenHost: 'https://keycloak.<cluster>/auth/realms/ateam-isveng/protocol/openid-connect',
tokenPath: 'token',
},
options: {
authorizationMethod: 'body'
}
};
//-------------------------------------------------------------------------------------------------------------------------------------
//
const keycloak_issuerUrl = 'https://keycloak.<cluster>/auth/realms/ateam-isveng';
//-------------------------------------------------------------------------------------------------------------------------------------
const redirecturi_keycloak = 'https://poster.<client-cluster>/auth/keycloak';
//-------------------------------------------------------------------------------------------------------------------------------------
//
async function discover_openid_issuer(issuerUrl) {
try {
issuer = await Issuer.discover(issuerUrl);
console.log("Discovered issuer %s %O", issuer.issuer, issuer.metadata);
return issuer;
} catch (error) {
console.log(error);
return null;
}
}
//-------------------------------------------------------------------------------------------------------------------------------------
//
function init_issuer_clients(keycloakIssuer) {
//https://tools.ietf.org/html/rfc6749#section-2.3.1
if (keycloakIssuer !== null) {
client_keycloak = new keycloakIssuer.Client({
client_id: credentials_keycloak.client.id,
client_secret: credentials_keycloak.client.secret,
redirect_uris: [redirecturi_keycloak],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
});
}
console.log(client_keycloak);
return client_keycloak);
}
//-------------------------------------------------------------------------------------------------------------------------------------
//
function getmetadata(event) { // ?issuer=keycloak
if (typeof (event.extensions.request.query.issuer) !== 'undefined') {
if (event.extensions.request.query.issuer === 'keycloak') {
return JSON.stringify(keycloakIssuer.metadata, null, 2);
}
}
return JSON.stringify(sapIssuer.metadata, null, 2);
}
//-------------------------------------------------------------------------------------------------------------------------------------
//
async function authorizationcode_openid_client(event) {
let client = client_keycloak; // current client
let scope = scopes_supported;
try {
const code_challenge = generators.codeChallenge(code_verifier);
let authorizationUri = client.authorizationUrl({
scope: scope,
access_type: 'offline',
prompt: 'consent',
//resource: redirect_url,
code_challenge,
code_challenge_method: 'S256',
});
console.log("authorizationcode_openid_client: authorizationUri: " + authorizationUri);
event.extensions.response.redirect(authorizationUri);
return authorizationUri;
} catch (error) {
console.log(error);
}
}
//-------------------------------------------------------------------------------------------------------------------------------------
//
async function callback_openid_client(event, redirecturi_id = redirecturi_keycloak) {
// https://github.com/panva/node-openid-client/blob/main/docs/README.md#clientcallbackredirecturi-parameters-checks-extras
let client = client_keycloak; // deafult client
// Returns recognized callback parameters from a provided input.
const params = client.callbackParams(event.extensions.request);
console.log ('params: ', params);
try {
tokenSet = await client.callback(redirecturi_id, params, { code_verifier }, { exchangeBody: {access_type: 'offline',}});
console.log('received and validated tokens %j', tokenSet);
console.log('validated ID Token claims %j', tokenSet.claims());
console.log("callback_openid_client: " + JSON.stringify(tokenSet));
// you might securely save the token in a secret place...like a config map for instance or in a database or in a vault...
//await k8spatchcm();
// for the sake of simplicity the token is returned both as a param to another endpoint and a nd as a safe server-side cookie
let token_cookie_header = 'token_keycloak';
event.extensions.response.clearCookie(token_cookie_header);
event.extensions.response.cookie(token_cookie_header, tokenSet.id_token, { httpOnly: true, secure: true, sameSite: 'none' }); // tokenSet.access_token
event.extensions.response.set(token_cookie_header, tokenSet.id_token); // tokenSet.access_token
return event.extensions.response.redirect('/logonresponse_openid?token=' + tokenSet.id_token); // tokenSet.access_token
}
catch (err) {
console.log("An error occured: ", err);
return err;
};
}
//-------------------------------------------------------------------------------------------------------------------------
//
function logonresponse_openid(event) {
console.log("inside logonresponse_openid...");
let token = "";
// is token passed via a parameter from the callback ?
if (typeof (event.extensions.request.query.token) !== "undefined") {
token = event.extensions.request.query.token;
}
else {
console.log(event.extensions.request.get('cookie'));
var cookies = cookie.parse(event.extensions.request.get('cookie') || '');
token = cookies.token_openid;
//let accessToken = cookies.serialized;
}
console.log("logonresponse_openid: token: ", token);
return token;
}
Last but not least let us leverage the power of simplicity of a kyma function…
//--------------------------------------------------------------------------
//
var keycloakIssuer, initialized = false;
//--------------------------------------------------------------------------
//
module.exports = {
main: async function (event, context) {
if (initialized === false) {
initialized = true;
keycloakIssuer = await discover_openid_issuer(keycloak_issuerUrl);
client = init_issuer_clients(keycloakIssuer);
}
switch (event.extensions.request.path) {
case '/openid': {
return authorizationcode_openid_client(event);
}
case '/auth/keycloak': {
return callback_openid_client(event, redirecturi_keycloak);
}
case '/logonresponse_openid' : {
return logonresponse_openid(event);
}
case '/getmetadata' : {
return getmetadata(event);
}
}
}
}
}
We can expose the function via an API rule and use any of its endpoints from a browser.
![]() |
For instance, here goes the JWT token that we got by calling the /openid endpoint….
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2aldHUHFtMTBpcnhadjVELXYwMHdmVVlXejJpbkFiU0lTR
..............................(truncated).......................................
c7VbWJeIdKqh6Vyry8NQJovD6D1mQMjJjcdDZhCQdILAXj3Kr6Fp3MY647FKJd3DcPO_ucKytmwuoEtbWPiERe3rk3jNWnGynPUpupOgit8zRPZpw
// redacted
HEADER:ALGORITHM & TOKEN TYPE
{
"alg": "RS256",
"typ": "JWT",
"kid": "<kid>"
}
PAYLOAD:DATA
{
"exp": 1629753985,
"iat": 1629717986,
"auth_time": 1629717985,
"jti": "<jti>",
"iss": "https://<cluster>/auth/realms/ateam-isveng",
"aud": "quovadis-oidc",
"sub": "<sub>",
"typ": "ID",
"azp": "quovadis-oidc",
"session_state": "<session_state>",
"at_hash": "<at_hash>",
"acr": "1",
"sid": "<sid>",
"email_verified": true,
"name": "<name>",
"preferred_username": "<preferred_username>",
"given_name": "<given_name>",
"family_name": "<family_name>",
"email": "<email>"
}
Conclusion
The above code is provided “as is”. It can be used as a coding template with virtually any other OIDC provider. However, as all OIDC providers have their own quirks small coding adjustment may be required… For the sake of brevity I omitted the refresh token logic from the above code…. If you would like to off-load this coding effort you might want to consider existing public libraries for instance: https://github.com/oauth2-proxy/oauth2-proxy
|
Additional resources.
The following blog has a very good a good intro to Open ID Connect (OIDC) protocol. Quoting after Jamie Cawley:
|
Nice post Piotr Tesny !
Hi Venkateswara Y Guptha it can be interesting to test with the future SAP BOE43S02 release which support now OpenID
!