Skip to Content
Technical Articles
Author's profile photo Piotr Tesny

AzureAD as an OpenID Connect (OIDC) and OAuth provider

AzureAD

First things first:

  • In the previous instalment I demonstrated Keycloak as an OpenID Connect (OIDC) provider.
  • This instalment is dedicated to having AzureAD as an OpenID Connect (OIDC) provider for third-party applications implemented with SAP Kyma functions.
  • Indeed, AzureAD is the Microsoft identity platform that can act as an OpenID Connect (OIDC) provider so you can create OIDC applications (so called clients) for password-less user authentication.
Good to know:

  • You can use AzureAD as an OpenID Connect (OIDC) and OAuth provider with Azure Free tier account (Pay-As-You-Go subscription) or with a trial account.
  • The OIDC/OAuth protocol supports a number of grant types that can be implemented to authenticate a user;
  • For the sake of clarity I shall be using the authorization code grant that is well supported by AzureAD.
  • Kyma functions are well-suited for rapid development/prototyping of cloud native micro-services.

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.

If you were new to Azure portal it might appear relatively “overwhelming” at the first sight. Actually it is really “overwhelming”. But stay cool man, AzureAD offers tons of good docs and resources though.

For the sake of time, I suggest to start with this very short knowledge base article to help you understand the basic concepts of OpenID Connect protocol with AzureAD:

Quovadis-Web.

Let’s have a closer look on how to use AzureAD as an IODC provider for user sign-in and an OAuth provider for granting access to Microsoft Graph API.

I did set up a Quovadis-Web application with my free AzureAD personal account (I followed the official application registration guide.)

In a nutshell, with our Quovadis-Web application, we shall obtain both a signed user JWT token and an OAuth bearer access token.

The first can be used in saml bearer assertion flows to propagate a signed user identity to any cloud native LOB application of the likes of SuccessFactor, S/4HANA Cloud, Analytics Cloud, Commerce Cloud, etc. and the latter can be used with MS Graph API.

Image: source

1. Create an OIDC client (application) with AzureAD.

Please find below a set by step Quovadis-Web application registration screenshots.

a. Goto https://portal.azure.com/#home.

This is the azure portal home screen where you can access your subscription details and locate the AzureAD service.

b. Goto Azure Active Directory service: https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/Overview

From the overview section you can look up your AzureAD directory tenant id, as depicted below:

 

c. Goto AppRegistration: https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps

You can start a new app registration and/or look up/modify features of other registered applications you own.

Through the registration process you can add your custom branding features including a graphical logo, home page url, and any applicable T&C and privacy statements that you want your users to abide by.


Next, you will need to select the application type or types , for instance
Web and SPA, and define one or more callbacks (redirect URLs) if applicable.

Indeed, depending on the authentication grant method you may need to define one or more callbacks;

For instance with the authentication code grant a callback url will allow your application to retrieve the authorisation code that is required to retrieve the bearer access token.

d. Eventually, after a successful Quovadis-Web registration we shall have obtained the following information required to implement the OIDC user sign-on programmatically:

// the app client secret may be rotated at will at any point of time
// from the AzureAD App registration wizard....
// however the client id (app id) is unmutable...

app_name = Quovadis-Web
client_id = '8e4ed817-***************************'
client_secret = 'c91******************************'

2. Get OIDC provider (issuer) metadata.

OIDC providers are also commonly being referred to as issuers.

The issuer link is https://login.microsoftonline.com/{tenant}/v2.0/

Thus the OIDC metadata can be accessed by appending the .well-known/openid-configuration suffix to the issuer url, for instance:

https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration.

For instance, wee could use a AzureAD stock personal tenant id:9188040d-6c67-4c5b-b112-36a304b66dad

https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0/.well-known/openid-configuration

https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0/.well-known/openid-configuration

{
  "token_endpoint": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/oauth2/v2.0/token",
  "token_endpoint_auth_methods_supported": [
    "client_secret_post",
    "private_key_jwt",
    "client_secret_basic"
  ],
  "jwks_uri": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/discovery/v2.0/keys", 
................(truncated)..............................................................
}

 

3. Retrieve a user JWT and Auth bearer access token

Finally let’s have some fun and joy and write some code.

  • The only pre-requisite is to have access to a SAP Kyma cluster either via SAP Business Technology Platform (BTP) or on top of SAP Gardener or on your personal SAP BTP trial account.
  • And because the code is implemented with a kyma function all that is required is a browser with the internet access.

Once registered, the Quovadis-Web app communicates with the Microsoft identity platform by sending requests to the oauth endpoints as follows:

https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize

https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token

where {tenant} needs to be substituted with your AzureAD tenant id….as shown above.

Good to know:

  • As we shall be leveraging the open-id library so the code is pretty much the same regardless of the OIDC provider…
    tokenSet.id_token​ holds the user JWT token
    tokenSet.access_token holds the bearer access token
//-----------------------------------------------------------------------------------------------------------------------------------
// https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/Overview/appId/{client_id}

//-------------------------------------------------------------------------------------------------------------------------------------
// 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_azure = { 
    client: { // issuerUrl: https://login.microsoftonline.com/{tenant}/v2.0
        id: '8e4ed817-*************************',
        secret: 'c91*****************************'
    },
    auth: {
        authorizeHost: 'https://login.microsoftonline.com/{tenant}', 
        authorizePath: 'oauth2/v2.0/authorize',
        tokenHost: 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token', 
        //tokenPath: '', 
    },
    options: {
        authorizationMethod: 'body'
    }
};

//--------------------------------------------------------------------------------------------------------
// https://www.npmjs.com/package/openid-client
// https://github.com/jcawley5/oidc-sample-app/blob/master/app/auth/sapOIDC.js
const axios = require('axios')
const { Issuer } = require('openid-client');
const { generators } = require('openid-client');
const { TokenSet } = require('openid-client');

const scopes_supported_azure = "openid email user.read mail.read";
//const scopes_supported =  "openid eamil https://graph.microsoft.com/.default"

var client_azure;

const azure_issuerUrl = 'https://login.microsoftonline.com/{tenant}/v2.0';

const redirecturi_azure = 'https://poster.<client-cluster>/auth/web';

//-------------------------------------------------------------------------------------------------------------------------------------
//
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(azureIssuer) {
    //token_endpoint_auth_method: client_secret_basic - may cause an issue due to the encoding of the client_secret
    //if special characters exist
    //xsuaa: client_secret_post
    //sapias:client_secret_basic
    //https://tools.ietf.org/html/rfc6749#section-2.3.1
    //
    if (azureIssuer !== null) {
      client_azure = new azureIssuer.Client({
                      client_id: credentials_azure.client.id,
                      client_secret: credentials_azure.client.secret,
                      redirect_uris: [redirecturi_azure],
                      response_types: ["code"],
                      token_endpoint_auth_method: "client_secret_post",
                    });
    }

    console.log(client_azure);
    return client_azure;
}

//-------------------------------------------------------------------------------------------------------------------------------------
function getmetadata(event) { // ?issuer=azure

  if (typeof (event.extensions.request.query.issuer) !== 'undefined') {
     if (event.extensions.request.query.issuer === 'azure') {
       return JSON.stringify(azureIssuer.metadata, null, 2);
     }
  }

  return JSON.stringify(sapIssuer.metadata, null, 2);
}        

//-------------------------------------------------------------------------------------------------------------------------------------
async function getuserinfo(event) { // ?issuer=azure

  let refreshParams = {
      client_id: credentials_azure.client.id,
      client_secret: credentials_azure.client.secret,
      scope: scopes_supported,
    };

  let client = client_azure; // current client

  if (typeof (event.extensions.request.query.issuer) !== 'undefined') {
     if (event.extensions.request.query.issuer === 'azure') {

      // https://graph.microsoft.com/v1.0/me
      try {
        let accessToken = process.env[azureConfigMap];
        console.log("serialized access token: ", accessToken);
        accessToken = JSON.parse(accessToken);
        console.log("deserialized access token: ", accessToken);
        tokenSet = new TokenSet(accessToken);
        
        const response = await axios.get('https://graph.microsoft.com/v1.0/me', {headers : { "Authorization":  'Bearer ' + tokenSet.access_token}});
        return JSON.stringify(response.data, null, 2); 
      }
      catch (err) {
          console.log("An error occured: ", err);
          return JSON.stringify(err, null, 2);
        }

     }
  }

  try {
    let accessToken = process.env[azureConfigMap];
    console.log("serialized access token: ", accessToken);
    accessToken = JSON.parse(accessToken);
    console.log("deserialized access token: ", accessToken);
    
    tokenSet = new TokenSet(accessToken);
    const userinfo = await client.userinfo(tokenSet);
    console.log('userinfo %j', userinfo);
    return JSON.stringify(userinfo, null, 2); 
  }
  catch (err) {
      console.log("An error occured: ", err);
      return err.message;
    }
}

//-------------------------------------------------------------------------------------------------------------------------------------
//
async function authorizationcode_openid_client(event) {

  let client = client_keycloak; // current client
  let scope = scopes_supported_azure; //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_azure) {

  // 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 want to 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_azure';
    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;
}

and here you go: the entry point of a kyma function:

//
var azureIssuer, initialized = false;

//--------------------------------------------------------------------------
//
module.exports = { 
  main: async function (event, context) {

    if (initialized === false) {
      initialized = true;
      azureIssuer = await discover_openid_issuer(azure_issuerUrl);
      client = init_issuer_clients(azureIssuer);
    }

    switch (event.extensions.request.path) {
      case '/openid': {
        return authorizationcode_openid_client(event);
      }
     
      case '/auth/web': {
        return callback_openid_client(event, redirecturi_azure); 
      }

      case '/logonresponse_openid' : {
        return logonresponse_openid(event);
      }

      case '/getmetadata' : {
        return getmetadata(event);
      }

      case '/getuserinfo' : {
        return getuserinfo(event);
      }
     }
    }
  }
}

 

Using the kubectl port-forward mechanism we can run any kyma function endpoint locally without the need of exposing the endpoints to public internet.

http://localhost:8080/getuserinfo
{
  "sub": "<sub>",
  "name": "<username>",
  "family_name": "<family_name>",
  "given_name": "<given_name>",
  "picture": "https://graph.microsoft.com/v1.0/me/photo/$value"
}

 

And this is how we can acquire a user JWT token (and the MS Graph access bearer token).

The endpoint will print out the id_token but you can easily modify to print out the access_token as well.

http://localhost:8080/openid?issuer=azure

{
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzIxxxxxxxxxxxxxx.eyJhdWQiOiI43mqZZUzxxxxxxxxxxxxxxxxxxxxxxx
}

On successful call return we may look up the user JWT token using this handy tool: https://jwt.ms/, as depicted below:

https://jwt.ms/

Next, we can look up the user information via a call to MS Graph API

http://localhost:8080/getuserinfo?issuer=azure
{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users/$entity",
  "@odata.id": "https://graph.microsoft.com/v2/{tenant}/directoryObjects/{id}/Microsoft.DirectoryServices.User",
  "businessPhones": [],
  "displayName": "<displayName>",
  "givenName": "<givenName>",
  "jobTitle": null,
  "mail": null,
  "mobilePhone": null,
  "officeLocation": null,
  "preferredLanguage": "en",
  "surname": "<surname>",
  "userPrincipalName": "<userPrincipalName>",
  "id": "<id>"
}

 

4. Use the user JWT to get access to oauth protected resources with saml bearer assertion and BTP destination service

 

The main idea is to leverage the acquired user JWT token as the user’s identity to get to access to some third-party oauth protected asset.

It is assumed that the user’s identity we are aiming to propagate does exist on the target system; otherwise the access will be denied.

The user identity verification is achieved by sending the target system a signed saml assertion with the client application user claim. If the identity is asserted the target system will issue a bearer access token to allow for the remote resource access.

SAP BTP destination service can help retrieving the bearer access token so you can call your target system REST or ODATA API for instance:

https://{apim tenant}/oem-azure/opendocument/?story=ateam-isveng&issuer=azure

 

Good to know:

  • If you are interested in how to implement the saml bearer assertion flow with Analytics Cloud or SuccessFactors or S/4HANA Cloud and the likes, please refer to the this guide, namely Quovadis or how to find your destination?

 

 

Additional resources.

 

Microsoft docs | Azure

Associate or add an Azure subscription to your Azure Active Directory tenant

Troubleshooting articles

AADSTS50107: Requested federation realm object ‘https://sts.windows.net/xxxxxxxxxxxxxx’ does not exist error is coming while calling the token api with assertion token

Assertion is malformed and cannot be read!

You can’t sign in to Office 365 from multiple federated domains

 

 

Assigned tags

      Be the first to leave a comment
      You must be Logged on to comment or reply to a post.