Technology Blogs by SAP
Learn how to extend and personalize SAP applications. Follow the SAP technology blog for insights into SAP BTP, ABAP, SAP Analytics Cloud, SAP HANA, and more.
cancel
Showing results for 
Search instead for 
Did you mean: 
quovadis
Product and Topic Expert
Product and Topic Expert





















 





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:

  • Please note all the code snippets below are provided “as is”.

  • All the x509 certificates, bearer access and refresh tokens and the likes have been redacted.

  • Images/data in this blog post is from SAP internal sandbox, sample data, or demo systems. Any resemblance to real data is purely coincidental.



 

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:

  • Keycloak supports OpenID connect protocol with a variety of grant types to authenticate users (authorization code, implicit, client credentials)

  • Different grant types can be combined together.

  • As we have enabled the standard flow which corresponds to the authorization code grant type, we need to provide a redirect URL.

  • Keyclock conveniently supports wildcards with the redirect URLs thus we can dynamically adjust the uri paths in the client application without the need to reflect the change back in Keycloak.


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:








Master realm: https://keycloak.<cluster>/auth/realms/master/.well-known/openid-configuration

Custom realm: https://keycloak.<cluster>/auth/realms/ateam-isveng/.well-known/openid-configuration

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-param...
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:
Open ID Connect (OIDC) provides a simple layer on top of oAuth 2.0 to support user authentication, providing login and profile information in the form of an encoded JSON Web Token(JWT).

Once a user logs in to an Identity Provider via OIDC this information can be used to securely access any other application or API that is implementing the same Identity Provider.

To learn more about OIDC visit https://openid.net/connect/.



 
1 Comment