Connecting Azure API Management, Azure AD B2C, and Paddle with .net 5 Razor App, Part 2

Connecting Azure API Management, Azure AD B2C, and Paddle with .net 5 Razor App, Part 2

This is part 2 of the two-parts article on how I wrapped my pre-existing internal chatbot builder API with Azure API Management and monetize it with Paddle.

Check Part 1 here

  1. Introduction
  2. Understand how to provide and monetize our API
  3. Delegate SignIn/SignUp and write a code to process and redirect AAM sign-in to AAD B2C
  4. Use Azure SDK REST API to create and authorize users
  5. Delegate subscription management and write your own custom subscription process with Paddle.
  6. Use Azure SDK REST API to manage subscriptions
  7. Delegate all other operations like ChangePassword, ChangeProfile, CloseAccount, SignOut, Renew
  8. Stick with as little code as possible
  9. Open source solution app to save you some time

Before diving into part 2, I would like to expand Part 1 a little with recent findings.

Azure API Management Delegation verification formulas

When you delegate operations it's recommended to verify that the request is coming from Azure API Management. To do so you need to calculate the HMAC-SHA512 hash. The problem here that documentation provides hash formulas only for two operations, while there are at least four different formulas needed.

Azure API Management delegate the next operations:

public enum Operations
{
    SignUp,
    SignIn,
    ChangePassword,
    ChangeProfile,
    CloseAccount,
    SignOut,
    Subscribe,
    Unsubscribe,
    Renew
}

For validation purposes, these operations can be split into four blocks with corresponding formulas

SignIn, SignUp

HMAC(salt + '\n' + returnUrl)

ChangePassword, ChangeProfile, SignOut, CloseAccount

HMAC(salt + '\n' + userId)

Subscribe

HMAC(salt + '\n' + productId + '\n' + userId)

Renew, Unsubscribe

HMAC(salt + '\n' + subscriptionId)

GitHub user API

AAD B2C GitHub auth does not give any information about a user (which is odd, probably due to missing claims or bindings). Luckily after auth, we get a token we can use to request user data from GitHub user API. The minimal required information for new AAM users is email, name, and surname.

First, we need to ensure that our sign-up flow returns the Identity provider token image.png

To get emails we will perform GET request on the emails endpoint

private const string GitHubApiForEmail = "https://api.github.com/user/emails";

private async Task<string> FindGitHubEmail(string accessToken, string email)
{
    if (string.IsNullOrEmpty(accessToken))
        throw new ArgumentNullException(nameof(accessToken));

    var client = new RestClient(GitHubApiForEmail);
    var request = new RestRequest(Method.GET);
    request.AddHeader("Authorization", $"Basic {Base64Encode("xakpc:" + accessToken)}");
    var gitResult = await client.ExecuteAsync(request);

    if (gitResult.IsSuccessful)
    {
        var emails = JsonConvert.DeserializeObject<List<EmailGitHub>>(gitResult.Content);
        email = emails.First(o => o.primary == true).email;
    }

    return email;
}

GitHubApiForEmail will return all user email, only one of them will be marked as primary - we would use it.

To get token from claims

httpContext.User.FindFirst(IdpAccessToken)?.Value

Filling user profile with additional data

I found out that by default Azure AD B2C fills nothing in the extended user profile. At least we need an email. If a user created a new account email is stored in the User Principal Name. But if GitHub is used for registration - it does not store at all. That limits my possibilities to reach users if needed.

image.png

To fix it I need to use Microsoft.Graph API to manually fill parameters after a user is created.

That's how you can do it

Setup secret key for App in Azure AD B2C Tenant and give it ReadAll rights

We already have an application we use for AAD B2C auth flows - we only need to create a secret key and give proper rights to it. image.png

So we add new client secret image.png

And read/write access to Azure Graph. Don't forget to press Grant admin consent, because we use here Client Credentials Provider for backend-only authentication. image.png

Add NuGet Packages

To work with Graph we need to install two packages

  1. Microsoft.Graph
  2. Microsoft.Graph.Auth - this one is available only in preview, so check Include prerelease or use Install-Package Microsoft.Graph.Auth -PreRelease

Create client and update user after AAM

Rest you can easily peek from the docs

Build auth provider

_confidentialClientApplication = ConfidentialClientApplicationBuilder
                .Create(configuration["AzureAdB2C:ClientId"])
                .WithTenantId(configuration["AzureAdB2C:TenantId"])
                .WithClientSecret(configuration["AzureAdB2C:ClientSecret"])
                .Build();
ClientCredentialProvider authProvider = new ClientCredentialProvider(_confidentialClientApplication);

Create a user to patch

var user = new User
{
    Mail = parameters.Email,                           
};

Do update

await graphClient.Users[userId]
                    .Request()
                    .UpdateAsync(user);

Now we have all the required information about users, let's move to subscriptions.

Delegate subscription management and write your own custom subscription process with Paddle.

Subscription delegation starts with the same delegation endpoint where you can perform a redirect based on the operation.

yourwebsite.com/apimdelegation?operation={operation}&productId={product to subscribe to}&userId={user making request}&salt={string}&sig={string}

Based on the type of operation we could prepare a redirect link and navigate the user to the subscription management page.

public IActionResult OnGet()
{
    var operation = Enum.Parse<Operations>(Request.Query["operation"].FirstOrDefault());
    var returnUrl = Request.Query["returnUrl"].FirstOrDefault();

    if (!ValidateHmac(operation, returnUrl))
    {
        return Unauthorized();
    }

    switch (operation)
    {
        ...
        case Operations.Subscribe:
        case Operations.Unsubscribe:
            var urlPaddle = new UriBuilder("https", host)
            {
                Path = "PaddlePay",
                Query = Request.QueryString.Value
            };
            return Redirect(urlPaddle.Uri.ToString());
        default:
            break;
    }
}

PaddlePay page is the only page of the project that actually contains the frontend part. This is done because I use Paddle Overlay Checkout. But the frontend is extremely simple

@page
@model ApiChat.Web.Auth.Pages.PaddlePayModel
@{
    Layout = "_LayoutBack";
}

<div class="checkout-container"></div>

@{
}

Subscribe

On subscribe, AAM provides you with a product name, which should be matched to the Paddle Subscription Plan.

AAM Products image.png

Paddle Subscription Plans image.png

We should manually match Paddle Id with product name, for example through the dictionary

if (operation == Operations.Subscribe)
{
    UserId = HttpContext.User.FindFirst(SignInDelegationModel.NameIdentifierSchemas).Value;
    if (string.IsNullOrWhiteSpace(UserId)) return Unauthorized();

    Product = Request.Query["productId"].FirstOrDefault();
    if (string.IsNullOrWhiteSpace(Product)) return BadRequest(Product);
    ProductId = Products[Product];
    if (ProductId == 0) return BadRequest(Product);
    ...

Then we need to extract user email and optionally user Country to make the Paddle experience a bit smoother

    ...
    var user = await _apiManagementService.GetUser(UserId);
    Email = user.Email;

    var country = HttpContext.User.FindFirst("country")?.Value;
    if (country != null)
    {
        var regions = CultureInfo.GetCultures(CultureTypes.SpecificCultures).Select(x => new RegionInfo(x.LCID));
        Region = regions.FirstOrDefault(region => region.EnglishName.Contains(country))?.TwoLetterISORegionName;
    }
}

All this information will be passed to the JavaScript function OpenPaddle. You can check more about parameters here

function OpenPaddle(ProductId, UserId, ProductName, SuccessUrl, Email, Region) {
        Paddle.Checkout.open({
            product: ProductId,
            email: Email,
            country: Region,
            passthrough: UserId + ':' + ProductName,
            allowQuantity: false,
            disableLogout: true,
            success: SuccessUrl,
            closeCallback: function (data) {
                console.log(data.eventData);
                history.back();
            },
        });
    }

And to setup

@:SetupPaddle(@Model.VendorPaddle);
@:OpenPaddle(@Model.ProductId, '@Model.UserId', '@Model.Product', '@Model.ProfileUrl', '@Model.Email', '@Model.Region');

Full PaddlePay page source code.

Handle Subscription Success

To handle subscription successes you need to add a webhook - that would be the APIController POST method. This method will handle paddle answers and will use already familiar to you ApiManagementClient to create or update subscription.

[HttpPost]
public IActionResult Post()
{
    var request = new PaddleWebhookRequest(Request.Form);

    var productId = request.Passthrough.Split(':').Last();
    var userId = request.Passthrough.Split(':').First();

    // subscription processing moved to background worker for quick reply
    _backgroundQueue.Enqueue(async ct =>
    {
    Trace.TraceInformation($"Subscription alert: {request.AlertId}|{request.AlertName}|{request.Status}; User:{request.UserId}; Sub:{request.SubscriptionId}; Plan:{request.SubscriptionPlanId}");
    switch (request.AlertName)
    {
        case "subscription_created":
            if (request.Status == "active")
            {
                await AddOrUpdateProductSubscribtion(productId, userId, request.SubscriptionId, SubscriptionState.Active);
            }
            break;
     ...
      }
   }
}

Unsubscribe

Unsubscribe works similarly, but all that you have here is a subscriptionId. When you created a subscription earlier, you used the subscriptionId that Paddle provided. And to cancel it we need a cancel URL.

Usually, it comes back with a subscription webhook and should be stored, but as another option, you can request it separately for every user from Paddle API.

if (operation == Operations.Unsubscribe)
{
    var subscriptionId = Request.Query["subscriptionId"].FirstOrDefault();

    if (string.IsNullOrWhiteSpace(subscriptionId)) return BadRequest(subscriptionId);
    var user = await _paddleService.GetSubscriptionUsers(subscriptionId);

    if (user == null) return BadRequest(subscriptionId);

    CancelUrl = WebUtility.UrlDecode(user.cancel_url);
}

Use Azure SDK REST API to manage subscriptions

Here I will only show you the required methods to manage subscriptions, it's all standard.

To Subscribe you need to create and set Subscription as Active

public Task SubscribeAsync(string productId, string userId, string subscriptionId)
{
    return AddOrUpdateProductSubscribtion(productId, userId, subscriptionId, SubscriptionState.Active);
}

To Cancel you need to set the Subscription state as Cancelled.

public Task UnsubscribeAsync(string productId, string userId, string subscriptionId)
{
    return AddOrUpdateProductSubscribtion(productId, userId, subscriptionId, SubscriptionState.Cancelled);
}

And finally, we do an Upsert operation

public async Task SubscribtionCreateOrUpdateAsync(string sid, SubscriptionCreateParameters parameters)
        {
            try
            {
                var apiManagement = new ApiManagementClient(new TokenCredentials(_tokenProvider))
                {
                    SubscriptionId = _subscriptionId
                };

                await apiManagement.Subscription.CreateOrUpdateAsync(_resourceGroupName, _serviceName, sid, parameters, true);
            }
            catch (Exception ex)
            {
                Trace.TraceInformation(ex.ToString());
                throw;
            }
        }

Delegate all other operations like ChangePassword, ChangeProfile, CloseAccount, SignOut, Renew

Most of these operations will be handled by Azure AD B2C. CloseAccount and Renew are not handled by me for now, but I will probably add them in the future.

All these operations can be handled by proper redirection by our auth web app.

switch (operation)
{
    case Operations.ChangePassword:
        return Redirect($"/MicrosoftIdentity/Account/ResetPassword");
    case Operations.ChangeProfile:
        return Redirect($"/MicrosoftIdentity/Account/EditProfile");
    case Operations.CloseAccount:
        break;
    case Operations.SignOut:
        return Redirect($"/MicrosoftIdentity/Account/SignOut");
}

The only thing I want to add here is a SignOut. By default, if you use MicrosoftIdentity NuGet SignOut will redirect you to the /SignedOut page.

You don't need it, because you already have a default signed-out page of the AAM: developer portal.

To override this redirect you should replace SignedOut.cshtml, you can do it by adding a file with the same name into the same directory, like this image.png It will produce a warning message

Warning    CS0436    The type 'Areas_MicrosoftIdentity_Pages_Account_SignedOut' in 'C:\Users\...\ApiChat.Web.Auth\obj\Debug\net5.0\Razor\Areas\MicrosoftIdentity\Pages\Account\SignedOut.cshtml.g.cs' conflicts with the imported type 'Areas_MicrosoftIdentity_Pages_Account_SignedOut' in 'Microsoft.Identity.Web.UI.Views, Version=1.6.0.0, Culture=neutral, PublicKeyToken=0a613f4dd989e8ae'. 
Using the type defined in 'C:\Users\...\ApiChat.Web.Auth\obj\Debug\net5.0\Razor\Areas\MicrosoftIdentity\Pages\Account\SignedOut.cshtml.g.cs'.    
ApiChat.Web.Auth    C:\Users\...\ApiChat.Web.Auth\obj\Debug\net5.0\Razor\Areas\MicrosoftIdentity\Pages\Account\SignedOut.cshtml.g.cs

But on the run when SignOut, it will be redirected to your page which should contain a redirect to the AAM developer portal

public IActionResult OnGet()
{
    return Redirect(AamHome.ToString());
}

Conclusion

It took me and my team almost 2 months to learn everything and create a fully operational API with Azure B2C AD, API Management, GitHub, and Paddle for API.chat.

Hope it saves you some time.