OAuth 2.0 Authorization Code Flow with Azure Functions and Microsoft Identity – Part 2: ID Tokens and Refresh Tokens

This is part two in a three part series on Authorization Code Flow with Microsoft Identity. If you haven’t started at Part 1, I would suggest you do as it would make more sense than starting from here.

Previously I had written about how to use Azure Functions to create an OAuth 2.0 Authentication Code flow to work with your static front-ends. This discussed a backend service taking an authentication code, using it to validate against the Microsoft Identity service, and returning an access token back to the user.

The problem, though, is that the access token is only short lived, and generally only works for an hour; after that you need to get a new access token, which means your app needs to navigate away from what it is doing, authenticate, and redirect back to your app. If you don’t have an incredible state machine in your app, things will get lost.

When the Function App you set up in the previous post calls the Microsoft Identity service for an access token, it also returns back a bit more stuff too, notably the ID Token and Refresh Token. I intend on demonstrating how to use these to refresh a user’s access token without all that redirect hassle.

Working with the ID Token

ID Tokens are an element of the OpenID Connect (OIDC) specification, allowing your app to validate the user to ensure they are the right person to get a new access token.

OIDC ID tokens are JSON Web Tokens (JWTs), and as such can be inspected to view the contents. If you step through your Azure Function you can grab the value of the ID token as an encoded string, but it can be decoded so the contents can be viewed/used. Head on over the https://jwt.ms and paste the copied token into the field, you should see something like this:

Now what you see may not make a lot of sense, but there are a few here I want to point out specifically:

  • aud identifies the Client ID for the application we are authenticating against
  • exp defines when the ID Token expires
  • sub uniquely identifies the user to this application, and this application only
  • oid uniquely identifies the user to the tenant, regardless of application running it. This is not always available available in an ID token

You can get full explanations of what you are seeing by clicking Claims just above the decoded output.

Side Note: You may also be able to inspect an access token just like the above, but this should not be relied on in a production environment as Microsoft does not guarantee that access tokens will always be a JWT.

In your Function App, we need to install the System.IdentityModel.Tokens.Jwt NuGet Package so we can see the contents of a token, and later on validate an ID token

Install-Package System.IdentityModel.Tokens.Jwt

Now, in your MicrosoftIdentityClient class, add in the following function, which will return a unique identifier for the user, encoded for some obfuscation.

public static string GetIdTokenUniqueUser(string idToken)
{
    JwtSecurityToken securityToken = new JwtSecurityToken(idToken);
    string tid = securityToken.Payload.Claims.FirstOrDefault(claim => claim.Type == "tid").Value;
    string sub = securityToken.Payload.Claims.FirstOrDefault(claim => claim.Type == "sub").Value;

    return Convert.ToBase64String(Encoding.UTF8.GetBytes($"{tid}{sub}"));
}

While you are there, adjust GetAccessTokenFromAuthorizationCode to return the Id Token and Refresh Token, alongside the access token

public async Task<(string idToken, string accessToken, string refreshToken)> GetAccessTokenFromAuthorizationCode(string authCode)
{
	string redirectUrl = "https://localhost/auth";
	string scopes = "openid offline_access https://graph.microsoft.com/user.read";

	Uri requestUri = new Uri($"{hostUrl}/{this.tenantId}/oauth2/v2.0/token");

	List<KeyValuePair<string, string>> content = new List<KeyValuePair<string, string>>()
	{
		new KeyValuePair<string, string>("client_id", this.clientId),
		new KeyValuePair<string, string>("scope", scopes),
		new KeyValuePair<string, string>("grant_type", "authorization_code"),
		new KeyValuePair<string, string>("code", authCode),
		new KeyValuePair<string, string>("redirect_uri", redirectUrl),
		new KeyValuePair<string, string>("client_secret", this.clientSecret)
	};

	HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, requestUri)
	{
		Content = new FormUrlEncodedContent(content),
	};

	HttpResponseMessage response = await httpClient.SendAsync(request);

	string responseContent = await response.Content.ReadAsStringAsync();
	dynamic responseObject = JsonConvert.DeserializeObject(responseContent);

	if (response.IsSuccessStatusCode)
	{
		return (responseObject.id_token, responseObject.access_token, responseObject.refresh_token);
	}
	else if (response.StatusCode == HttpStatusCode.BadRequest)
	{
		// Something failed along the way, and there will be an error in there if the error code is 400
		// Handle it however you want.
		throw new Exception((string)responseObject.error_message);
	}
	else
	{
		// ¯\_(ツ)_/¯
		throw new Exception("Something bad happened");
	}
}

For now, that will be enough to move on to refresh tokens.

Working with Refresh Tokens

Refresh tokens are long-lived tokens that cannot be used to access resources themselves, but are used, along with the client id and secret, to acquire a new access token when needed. A refresh token should never be sent to a front end app and only ever securely stored on the backend, additionally, the front-end should never store the client secret. Both the refresh token and the client secret should only ever be used by a backend service.

Please note that I will be detailing how to store a refresh token in Azure Storage below, how you do so is up to you and how you wish to do so securely is up to you also. I am deliberately storing these values locally so nothing is being stored publicly, and I will not be detailing how to store this securely in Azure.

Storing the Refresh Tokens

Firstly, add the Microsoft.Azure.Cosmos.Table NuGet package to your Functions project. Although this appears to be the CosmosDB Table storage library, it is also the recommended library for Azure Storage Tables as well. If you were interested in using CosmosDB for your storage, this allows for minimal effort to do so.

Next, add the following line to your local.settings.json, which says we will use the Azure Storage Emulator locally for this work.

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "TenantId": "yourtenantdomain.com",
    "ClientId": "The application Client Id",
    "ClientSecret": "The Client Secret you created earlier"
+   "TableStorage": "UseDevelopmentStorage=true"
  }
}

Create a new Class, RefreshTokenEntity, and fill it in with the following code. This specifies the table structure in Azure Storage.

public class RefreshTokenEntity : TableEntity
{
    public string RefreshToken { get; set; }

    public RefreshTokenEntity(string audience, string userId)
    {
        this.PartitionKey = audience;
        this.RowKey = userId;
    }
}

And now create another class, AzureStorageClient, for interacting with the table storage.

public class AzureStorageClient
{
	private readonly CloudTable refreshTokenTable;

	public AzureStorageClient(string connectionString)
	{
		CloudStorageAccount storageAccount = CloudStorageAccount.Parse(connectionString);
		CloudTableClient tableClient = storageAccount.CreateCloudTableClient(new TableClientConfiguration());

		this.refreshTokenTable = tableClient.GetTableReference("refreshTokens");
		this.refreshTokenTable.CreateIfNotExists();
	}

	public async Task<RefreshTokenEntity> AddOrUpdateRefreshToken(string audience, string userId, string refreshToken)
	{
		RefreshTokenEntity tokenEntity = new RefreshTokenEntity(audience, userId)
		{
			RefreshToken = refreshToken
		};

		TableResult tableResult = await this.refreshTokenTable.ExecuteAsync(TableOperation.InsertOrReplace(tokenEntity));

        return tableResult.Result as RefreshTokenEntity;
    }
}

When used, this will store the client id as the Partition Key, an obfuscated tenant-specific user id as the Row Key, and the refresh token.

If someone gains access to this data when they shouldn’t, they will not know who this refresh token is for; and even though the client id is specified, the refresh token can not be used to obtain a fresh access token without the client secret, which I’ll show more on later. So this data stores nothing identifying, nor is it any of it useful on its own. Just don’t go storing client secrets anywhere near this data.

OK, last little bit is to work on the Function trigger.

For prettyness, add a new struct, ReturnValue, and give it the following detail

public struct ReturnValue
{
	public string AccessToken { get; set; }
 
	public string IdToken { get; set; }
}

Now make the following changes to your trigger function

[FunctionName("AuthHttpTrigger")]
public static async Task<IActionResult> Run(
	[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
	ILogger log)
{
	// Get the authentication code from the request payload
	string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
	dynamic data = JsonConvert.DeserializeObject(requestBody);
	string authCode = data.authCode;

	// Get the Application details from the settings
	string tenantId = Environment.GetEnvironmentVariable("TenantId", EnvironmentVariableTarget.Process);
	string clientId = Environment.GetEnvironmentVariable("ClientId", EnvironmentVariableTarget.Process);
	string clientSecret = Environment.GetEnvironmentVariable("ClientSecret", EnvironmentVariableTarget.Process);
	string storageConnectionString = Environment.GetEnvironmentVariable("TableStorage", EnvironmentVariableTarget.Process);

	// Get the access token from MS Identity
	MicrosoftIdentityClient idClient = new MicrosoftIdentityClient(clientId, clientSecret, tenantId);
	(string idToken, string accessToken, string refreshToken) tokens = await idClient.GetAccessTokenFromAuthorizationCode(authCode);

	// Save the refresh token to an Azure Storage Table
	AzureStorageClient azureStorageClient = new AzureStorageClient(storageConnectionString);
	await azureStorageClient.AddOrUpdateRefreshToken(clientId, MicrosoftIdentityClient.GetIdTokenUniqueUser(tokens.idToken), tokens.refreshToken);

	return new OkObjectResult(new ReturnValue
	{
		AccessToken = tokens.accessToken,
		IdToken = tokens.idToken
	});
}

We have done quite a bit here, where are we at?

So far, the Function App has been set up to:

  1. Accept an authorization code from the front end
  2. Request an id token, access token, and refresh token from Microsoft Identity
  3. Extract the oid value from the id token
  4. Store the refresh token specific to the client (aud) and user (oid) in an Azure Storage Table
  5. Return the access token, and id token to the front-end

If you do the authentication steps from the previous post to get an authorization code, and send that to the function, you will see that we are getting both the access token, and id token.

Why do we return both now? Because an access token can’t be used to validate a user if the front-end needs a new access token, but an id token can. Your front-end will need to store the id token for use when calling our back end for a refreshed access token.

Hold on to that id token, we will need it shortly.

Now, though, we are going to:

  1. Create a new Function trigger that accepts an ID token
  2. Call Microsoft Identity to get a new access token and id token, based on the refresh token
  3. Return the new access and id tokens back to the front-end

After that, your front-end will periodically be able to call this back-end and request a new access token by passing up the id token, without needing to redirect the browser to get the authorization code. This should be done on a timer before the id token expires.

Refreshing the Access Token

In the MicrosoftIdentityClient class, add in the following new function to call the Microsoft Identity service and refresh your tokens. This is very much like the other method to get the tokens from an authorization code, but now the call to Microsoft Identity is adjusted to use a refresh token.

public async Task<(string idToken, string accessToken, string refreshToken)> GetAccessTokenFromRefreshToken(string refreshToken)
{
	string scopes = "openid offline_access https://graph.microsoft.com/user.read";

	Uri requestUri = new Uri($"{hostUrl}/{this.tenantId}/oauth2/v2.0/token");

	List<KeyValuePair<string, string>> content = new List<KeyValuePair<string, string>>()
	{
		new KeyValuePair<string, string>("client_id", this.clientId),
		new KeyValuePair<string, string>("scope", scopes),
		new KeyValuePair<string, string>("grant_type", "refresh_token"),
		new KeyValuePair<string, string>("refresh_token", refreshToken),
		new KeyValuePair<string, string>("client_secret", this.clientSecret)
	};

	HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, requestUri)
	{
		Content = new FormUrlEncodedContent(content),
	};

	HttpResponseMessage response = await httpClient.SendAsync(request);

	string responseContent = await response.Content.ReadAsStringAsync();
	dynamic responseObject = JsonConvert.DeserializeObject(responseContent);

	if (response.IsSuccessStatusCode)
	{
		return (responseObject.id_token, responseObject.access_token, responseObject.refresh_token);
	}
	else if (response.StatusCode == HttpStatusCode.BadRequest)
	{
		// Something failed along the way, and there will be an error in there if the error code is 400
		// Handle it however you want.
		throw new Exception((string)responseObject.error_message);
	}
	else
	{
		// ¯\_(ツ)_/¯
		throw new Exception("Something bad happened");
	}
}

You should be able to see in the call to Microsoft Identity, we are also passing up the client secret. Without this, refresh tokens are useless, so make sure it is kept secret.

Create yourself a new HttpTrigger function, and populate the function with the following; which accepts an id token, extracts the oid value, which is used to get the refresh token, then calls the previous function we just made to get the new access and id tokens, then returns them to the caller.

[FunctionName("RefreshHttpTrigger")]
public static async Task<IActionResult> Run(
	[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req,
	ILogger log)
{
	// Get the authentication code from the request payload
	string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
	dynamic data = JsonConvert.DeserializeObject(requestBody);
	string idToken = data.idToken;

	// Get the Application details from the settings
	string tenantId = Environment.GetEnvironmentVariable("TenantId", EnvironmentVariableTarget.Process);
	string clientId = Environment.GetEnvironmentVariable("ClientId", EnvironmentVariableTarget.Process);
	string clientSecret = Environment.GetEnvironmentVariable("ClientSecret", EnvironmentVariableTarget.Process);
	string storageConnectionString = Environment.GetEnvironmentVariable("TableStorage", EnvironmentVariableTarget.Process);

	// Get the Object ID
	MicrosoftIdentityClient idClient = new MicrosoftIdentityClient(clientId, clientSecret, tenantId);
	string userId = MicrosoftIdentityClient.GetIdTokenUniqueUser(idToken);

	// Get the refresh token 
	AzureStorageClient azureStorageClient = new AzureStorageClient(storageConnectionString);
	string refreshToken = await azureStorageClient.GetRefreshToken(clientId, userId);

	// Get a new access token from the refresh token
	(string idToken, string accessToken, string refreshToken) tokens = await idClient.GetAccessTokenFromRefreshToken(refreshToken);

	// Save the refresh token to an Azure Storage Table
	await azureStorageClient.AddOrUpdateRefreshToken(clientId, userId, tokens.refreshToken);

	return new OkObjectResult(new ReturnValue
	{
		AccessToken = tokens.accessToken,
		IdToken = tokens.idToken
	});
}

Let’s test it out!

Finally! Open up Postman, and send a POST request to the freshly-made function using the id token you kept from earlier. You will see again that an access token and id token are returned, and if you really wanted to do a comparison, you will see they differ from the previous call.

But that id token could come from anywhere!

You may have noticed that the token we sent up just extracted the aud and sub, which is uniquely identifies the user for the Application and tenant, but no validating the token was done. We could have used an expired token, or a token that has been adjusted and no longer matches the signature, for example. In fact, any readable id token where the aud and sub match can be used to refresh the access token for this little Azure AD App we have made. In the next post, I’ll show you how to validate a token so we can be more comfortable the caller is who they say they are.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at WordPress.com.

Up ↑

%d bloggers like this: