Technical Articles
AzureAD as an OpenID Connect (OIDC) and OAuth provider
AzureAD |
First things first:
|
Good to know:
Disclaimer:
|
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 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: From the overview section you can look up your AzureAD directory tenant id, as depicted below:
c. Goto AppRegistration: 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.
d. Eventually, after a successful Quovadis-Web registration we shall have obtained the following information required to implement the OIDC user sign-on programmatically:
|
2. Get OIDC provider (issuer) metadata.
OIDC providers are also commonly being referred to as issuers. The issuer link is 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/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.
Once registered, the Quovadis-Web app communicates with the Microsoft identity platform by sending requests to the oauth endpoints as follows:
where {tenant} needs to be substituted with your AzureAD tenant id….as shown above. Good to know:
|
//-----------------------------------------------------------------------------------------------------------------------------------
// 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_azure; // current client is azure
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_azure; // deafult client is azure
// 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:
Good to know:
|
Additional resources.