I have an app for Microsoft teams that uses Azure SSO login.
The authentication flow is as follows:
sso-token
from Microsoft graph API and sends it to back-end./token
route of Microsoft graph API with the sso-token
and scopes
(including offline_access
) to fetch the access_token
and refresh_token
.refresh_token
as well as access_token
and the back-end returns 200
status.
But if consent is required, then the back-end sends a 403
status back to the front-end.403
status, the front-end now starts the consent flow and makes a call to /authorize
route with client-id
and scopes
(including offline_access
). In this case, after the user has consented, I am not getting the refresh_token
from Microsoft, instead only access_token
is returned.Now, I have a feature to send Microsoft teams activity notifications to users from the app and for that, I need to refresh the access token from time to time without user interaction as the process of sending activity notifications is happening from the back-end. And since the back-end is not getting the refresh_token
in the scenario where user consent was required, after some time the access_token
saved in the back-end is expiring and all the API calls for sending activity notifications result in an error.
access_token
and refresh_token
using sso-token
in the back-end.// other import statements
//...
const {
MS_CLIENT_ID,
MS_CLIENT_SECRET,
MS_GRAPH_SCOPES,
} = require('../config/env');
const graphScopes = `https://graph.microsoft.com/User.Read https://graph.microsoft.com/${MS_GRAPH_SCOPES} offline_access`;
// receives the sso-token as parameter from front-end
const getAccessToken = async (ssoToken) => {
try {
const { tid } = jwt_decode(ssoToken);
if (!tid) throw new Error('sso token is malformed');
const accessTokenQueryParams = new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
client_id: MS_CLIENT_ID,
client_secret: MS_CLIENT_SECRET,
assertion: ssoToken,
scope: graphScopes,
requested_token_use: 'on_behalf_of',
}).toString();
const { data, status } = await axios({
method: 'POST',
url: `https://login.microsoftonline.com/${tid}/oauth2/v2.0/token`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
data: accessTokenQueryParams,
});
if (status != 200) throw new Error('could not exchange access token');
return data;
} catch (error) {
console.error({
function: 'getAccessToken',
dir: __dirname,
file: __filename,
error:
error.response && error.response.data ? error.response.data : error,
});
return null;
}
};
sso-token
sent from front-end.router.get('/auth', async (req, res) => {
try {
const {
ssoToken,
teamId,
teamName = '',
channelId,
channelName = '',
} = req.query;
const { tid } = jwt_decode(ssoToken);
if (!tid || !teamId)
return res
.status(500)
.json({ errors: [{ msg: 'Could not exchange access token' }] });
// CALLING THE UTILITY FUNCSTION WITH SSO TOKEN
const data = await getAccessToken(ssoToken);
if (!data || !data.access_token)
res
.status(403)
.json({ errors: [{ msg: 'User must consent or perform MFA' }] });
const { access_token: accessToken, refresh_token: refreshToken } = data;
console.log({ accessToken, refreshToken });
if (!accessToken)
return res
.status(500)
.json({ errors: [{ msg: 'Could not exchange access token' }] });
// SUCCESS
// return 200 status
//...
} catch (error) {
console.error({
error:
error.response && error.response.data ? error.response.data : error,
person: req.person,
company: req.company,
headers: req.headers,
params: req.params,
url: req.originalUrl,
});
// this error should trigger the consent flow in the client.
res
.status(403)
.json({ errors: [{ msg: 'User must consent or perform MFA' }] });
}
});
ConsentPopup.js
(Component responsible for starting the user consentt flow)class ConsentPopup extends React.Component {
componentDidMount() {
console.log("consentPopUp initialized");
microsoftTeams.initialize();
// Get the user context in order to extract the tenant ID
microsoftTeams.getContext((context, error) => {
let tenant = context["tid"]; //Tenant ID of the logged in user
let client_id = env.REACT_APP_AZURE_APP_REGISTRATION_ID;
let queryParams = {
tenant: `${tenant}`,
client_id: `${client_id}`,
response_type: "token", //token_id in other samples is only needed if using open ID
// offline_access is Added for RefreshToken
scope: "https://graph.microsoft.com/User.Read https://graph.microsoft.com/TeamsActivity.Send offline_access",
redirect_uri: window.location.origin + "/auth-end",
nonce: crypto.randomBytes(16).toString("base64"),
};
let url = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize?`;
queryParams = new URLSearchParams(queryParams).toString();
let authorizeEndpoint = url + queryParams;
//Redirect to the Azure authorization endpoint. When that flow completes, the user will be directed to auth-end
// GO TO ClosePopup.js
console.log(authorizeEndpoint);
window.location.assign(authorizeEndpoint);
});
}
render() {
return (
<div>
<h1>Please wait...</h1>
</div>
);
}
}
ClosePopup.js
(Component responsible for completing the user consentt flow)class ClosePopup extends React.Component {
componentDidMount(){
microsoftTeams.initialize();
//The Azure implicit grant flow injects the result into the window.location.hash object. Parse it to find the results.
let hashParams = this.getHashParameters();
console.log({hashParams});
//If consent has been successfully granted, the Graph ACCESS TOKEN and REFRESH TOKEN should be present as a field in the dictionary.
if (hashParams["access_token"]){
//Notifify the showConsentDialogue function in Tab.js that authorization succeeded. The success callback should fire.
//SENDING BOTH REFRESH TOKEN AND ACCESS TOKEN
microsoftTeams.authentication.notifySuccess(hashParams);
} else {
microsoftTeams.authentication.notifyFailure("Consent failed");
}
}
getHashParameters() {
let hashParams = {};
console.log({hash: window.location.hash, hashSubStr: window.location.hash.substr(1)});
window.location.hash.substr(1).split("&").forEach(function(item) {
let [key,value] = item.split('=');
hashParams[key] = decodeURIComponent(value);
});
console.log('Get Hash Params');
console.log({hashParams});
return hashParams;
}
render() {
return (
<div>
<h1>Consent flow complete.</h1>
</div>
);
}
}
export default ClosePopup;
The grant (consent) may not be immediate. As of my experience, it can be up to a 30 seconds. I.e. the grant call returns, but the backend still does not "work".
You could try to just keep querying until you get the refresh_token in the second case. I.e. just do it multiple times (in the backend). I.e. try exchanging the sso-token more than once. I think in a few seconds, the refresh_token should start coming.