Sign-up and sign-in with Discord using Entra External ID

This article describes how to add authentication for a Discord account to a sign-up and sign-in flow in an external tenant.

The code for this article can found in https://github.com/chrispadgettlivecom/ciam-functions.

Architecture

The following diagram illustrates the sign-up and sign-in flow.

This sign-up and sign-in flow requires an Azure function that retrieves claims for a Discord user and then returns an ID token containing these user claims to an external tenant.

For information about Discord and OAuth 2.0, see https://discord.com/developers/docs/topics/oauth2.

Prerequisites

  1. Create an external tenant.
  2. Create a sign-up and sign-in user flow.

Create the Discord functions

Create a functions application

  1. Create a functions application. This functions application must be created in the Basic tier or higher.
  2. Create a self-signed certificate and then export the public certificate with the private key. This self-signed certificate is used to sign the ID tokens, which contain claims that are retrieved for a Discord user, that are returned to the external tenant.
  3. Add the self-signed certificate to the functions application and then configure this self-signed certificate for use in the function code.
  4. Add the self-signed certificate’s thumbprint as the DISCORD_SIGNING_CERTIFICATE_THUMBPRINT app setting to the functions application.
[
  ...
  {
    "name": "DISCORD_SIGNING_CERTIFICATE_THUMBPRINT",
    "value": "<your-certificate-thumbprint>",
    "slotSetting": false
  },
]

Create an OpenID Connect configuration function

Create a C# function containing the following code. This implements a GetDiscordOpenIdConnectConfiguration function that returns the OpenID Connect configuration information for the Discord identity provider to the external tenant. Then publish this C# functions to the functions application.

using CiamFunctionsApplication.Extensions.Http;
using CiamFunctionsApplication.Models.Shared;
using CiamFunctionsApplication.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace CiamFunctionsApplication.Functions.Discord
{
    public class GetDiscordOpenIdConnectConfigurationFunction
    {
        private readonly ILogger<GetDiscordOpenIdConnectConfigurationFunction> _logger;
        private readonly ISigningCredentialsProvider _signingCredentialsProvider;

        public GetDiscordOpenIdConnectConfigurationFunction(ILogger<GetDiscordOpenIdConnectConfigurationFunction> logger, ISigningCredentialsProvider signingCredentialsProvider)
        {
            _logger = logger;
            _signingCredentialsProvider = signingCredentialsProvider;
        }

        [Function("GetDiscordOpenIdConnectConfiguration")]
        public IActionResult Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "discord/.well-known/openid-configuration")] HttpRequest request)
        {
            var responseModel = new GetOpenIdConnectConfigurationResponseModel
            {
                Issuer = $"{request.GetBaseUrl()}api/discord",
                AuthorizationEndpoint = "https://discord.com/api/oauth2/authorize",
                TokenEndpoint = $"{request.GetBaseUrl()}api/discord/GetDiscordClaims",
                TokenEndpointAuthMethodsSupported =
                [
                    "client_secret_post"
                ],
                JwksUri = $"{request.GetBaseUrl()}api/discord/.well-known/keys",
                ResponseTypesSupported =
                [
                    "code"
                ],
                SubjectTypesSupported =
                [
                    "public"
                ]
            };

            var signingCredentials = _signingCredentialsProvider.GetSigningCredentials("DISCORD_SIGNING_CERTIFICATE_THUMBPRINT");

            responseModel.IdTokenSigningAlgValuesSupported =
            [
                signingCredentials.Algorithm
            ];

            return new OkObjectResult(responseModel);
        }
    }
}

The GetOpenIdConnectConfigurationResponseModel class is implemented as follows:

using System.Text.Json.Serialization;

namespace CiamFunctionsApplication.Models.Shared
{
    public class GetOpenIdConnectConfigurationResponseModel
    {
        [JsonPropertyName("issuer")]
        public string? Issuer { get; set; }

        [JsonPropertyName("authorization_endpoint")]
        public string? AuthorizationEndpoint { get; set; }

        [JsonPropertyName("token_endpoint")]
        public string? TokenEndpoint { get; set; }

        [JsonPropertyName("token_endpoint_auth_methods_supported")]
        public ICollection<string>? TokenEndpointAuthMethodsSupported { get; set; }

        [JsonPropertyName("jwks_uri")]
        public string? JwksUri { get; set; }

        [JsonPropertyName("response_types_supported")]
        public ICollection<string>? ResponseTypesSupported { get; set; }

        [JsonPropertyName("subject_types_supported")]
        public ICollection<string>? SubjectTypesSupported { get; set; }

        [JsonPropertyName("id_token_signing_alg_values_supported")]
        public ICollection<string>? IdTokenSigningAlgValuesSupported { get; set; }
    }
}

Create a JSON Web Key Set function

Create a C# function containing the following code. This implements a GetDiscordJsonWebKeySet function that returns the JSON Web Key Set (JWKS) information for the Discord identity provider to the external tenant. Then publish this C# functions to the functions application.

using System.Security.Cryptography.X509Certificates;
using CiamFunctionsApplication.Models.Shared;
using CiamFunctionsApplication.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;

using JsonWebKey = CiamFunctionsApplication.Models.JsonWebKey;

namespace CiamFunctionsApplication.Functions.Discord
{
    public class GetDiscordJsonWebKeySetFunction
    {
        private readonly ILogger<GetDiscordJsonWebKeySetFunction> _logger;
        private readonly ISigningCredentialsProvider _signingCredentialsProvider;

        public GetDiscordJsonWebKeySetFunction(ILogger<GetDiscordJsonWebKeySetFunction> logger, ISigningCredentialsProvider signingCredentialsProvider)
        {
            _logger = logger;
            _signingCredentialsProvider = signingCredentialsProvider;
        }

        [Function("GetDiscordJsonWebKeySet")]
        public IActionResult Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "discord/.well-known/keys")] HttpRequest request)
        {
            var responseModel = new GetJsonWebKeySetResponseModel();

            try
            {
                var signingCredentials = _signingCredentialsProvider.GetSigningCredentials("DISCORD_SIGNING_CERTIFICATE_THUMBPRINT") as X509SigningCredentials;
                var signingCertificate = signingCredentials!.Certificate;
                var signingCertificateHash = signingCertificate.GetCertHash();
                var signingCertificatePublicKey = signingCertificate.GetRSAPublicKey();
                var signingCertificatePublicKeyParameters = signingCertificatePublicKey!.ExportParameters(false);

                responseModel.Keys =
                [
                    new JsonWebKey
                {
                    Alg = signingCredentials.Algorithm,
                    E = Base64UrlEncoder.Encode(signingCertificatePublicKeyParameters.Exponent),
                    Kid = signingCredentials.Kid,
                    Kty = "RSA",
                    N = Base64UrlEncoder.Encode(signingCertificatePublicKeyParameters.Modulus),
                    Nbf = new DateTimeOffset(signingCertificate.NotBefore).ToUnixTimeSeconds(),
                    Use = "sig",
                    X5c =
                    [
                        Convert.ToBase64String(signingCertificate.Export(X509ContentType.Cert))
                    ],
                    X5t = Base64UrlEncoder.Encode(signingCertificateHash)
                }
                ];
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message);
            }

            return new OkObjectResult(responseModel);
        }
    }
}

The GetJsonWebKeySetResponseModel and JsonWebKey classes are implemented as follows:

using System.Text.Json.Serialization;

namespace CiamFunctionsApplication.Models
{
    public  class GetJsonWebKeySetResponseModel
    {
        [JsonPropertyName("keys")]
        public ICollection<JsonWebKey>? Keys { get; set; }
    }
}

and

using System.Text.Json.Serialization;

namespace CiamFunctionsApplication.Models
{
    public class JsonWebKey
    {
        [JsonPropertyName("alg")]
        public string? Alg { get; set; }

        [JsonPropertyName("e")]
        public string? E { get; set; }

        [JsonPropertyName("kid")]
        public string? Kid { get; set; }

        [JsonPropertyName("kty")]
        public string? Kty { get; set; }

        [JsonPropertyName("n")]
        public string? N { get; set; }

        [JsonPropertyName("nbf")]
        public long? Nbf { get; set; }

        [JsonPropertyName("use")]
        public string? Use { get; set; }

        [JsonPropertyName("x5c")]
        public ICollection<string>? X5c { get; set; }

        [JsonPropertyName("x5t")]
        public string? X5t { get; set; }

    }
}

Create a claims function

Create a C# function containing the following code. This implements a GetDiscordClaims function that retrieves claims for a Discord user and then returns an ID token containing these user claims to the external tenant. Then publish this C# functions to the functions application.

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json;
using CiamFunctionsApplication.Models.Discord;
using CiamFunctionsApplication.Models.Shared;
using CiamFunctionsApplication.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;

namespace CiamFunctionsApplication.Functions.Discord
{
    public class GetDiscordClaimsFunction
    {
        private readonly ILogger<GetDiscordClaimsFunction> _logger;
        private readonly ISigningCredentialsProvider _signingCredentialsProvider;

        public GetDiscordClaimsFunction(ILogger<GetDiscordClaimsFunction> logger, ISigningCredentialsProvider signingCredentialsProvider)
        {
            _logger = logger ?? throw new ArgumentNullException(nameof(logger));
            _signingCredentialsProvider = signingCredentialsProvider ?? throw new ArgumentNullException(nameof(signingCredentialsProvider));
        }

        [Function("GetDiscordClaims")]
        public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "discord/GetDiscordClaims")] HttpRequest request)
        {
            var requestForm = await request.ReadFormAsync();

            using (var httpClient = new HttpClient())
            {
                var tokenRequestData = new List<KeyValuePair<string, string>>
                {
                    new("grant_type", requestForm["grant_type"].FirstOrDefault()!),
                    new("client_id", requestForm["client_id"].FirstOrDefault()!),
                    new("client_secret", requestForm["client_secret"].FirstOrDefault()!),
                    new("redirect_uri", requestForm["redirect_uri"].FirstOrDefault()!),
                    new("code", requestForm["code"].FirstOrDefault()!),
                    new("code_verifier", requestForm["code_verifier"].FirstOrDefault()!)
                };

                using (var tokenRequestMessage = new HttpRequestMessage(HttpMethod.Post, "https://discord.com/api/oauth2/token")
                {
                    Content = new FormUrlEncodedContent(tokenRequestData)
                })
                {
                    using (var tokenResponseMessage = await httpClient.SendAsync(tokenRequestMessage))
                    {
                        var tokenResponseContent = await tokenResponseMessage.Content.ReadAsStringAsync();
                        var tokenResponseData = JsonSerializer.Deserialize<TokenResponseModel>(tokenResponseContent);

                        using (var userInformationRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me"))
                        {
                            userInformationRequestMessage.Headers.Add("Authorization", $"Bearer {tokenResponseData!.AccessToken}");

                            using (var userInformationResponseMessage = await httpClient.SendAsync(userInformationRequestMessage))
                            {
                                var userInformationResponseContent = await userInformationResponseMessage.Content.ReadAsStringAsync();
                                var userInformationResponseData = JsonSerializer.Deserialize<UserInfoResponseModel>(userInformationResponseContent);
                                var nowUtc = DateTimeOffset.UtcNow;

                                var idTokenClaims = new List<Claim>
                                {
                                    new("sub", userInformationResponseData!.Id!),
                                    new("iat", nowUtc.ToUnixTimeSeconds().ToString().ToLowerInvariant(), ClaimValueTypes.Integer64),
                                    new("email", userInformationResponseData.Email!),
                                    new("email_verified", true.ToString().ToLowerInvariant(), ClaimValueTypes.Boolean)
                                };

                                var idToken = new JwtSecurityToken(
                                    issuer: $"{request.GetBaseUrl()}api/discord",
                                    audience: requestForm["client_id"].FirstOrDefault(),
                                    claims: idTokenClaims,
                                    notBefore: nowUtc.UtcDateTime,
                                    expires: nowUtc.AddSeconds(300).UtcDateTime,
                                    signingCredentials: _signingCredentialsProvider.GetSigningCredentials("DISCORD_SIGNING_CERTIFICATE_THUMBPRINT") as X509SigningCredentials
                                );

                                var idTokenHandler = new JwtSecurityTokenHandler();

                                var responseModel = new GetClaimsResponseModel
                                {
                                    TokenType = tokenResponseData.TokenType,
                                    AccessToken = tokenResponseData.AccessToken,
                                    ExpiresIn = tokenResponseData.ExpiresIn,
                                    IdToken = idTokenHandler.WriteToken(idToken)
                                };

                                return new OkObjectResult(responseModel);
                            }
                        }
                    }
                }
            }
        }
    }
}

The GetClaimsResponseModel class is implemented as follows:

using System.Text.Json.Serialization;

namespace CiamFunctionsApplication.Models
{
    public class GetClaimsResponseModel
    {
        [JsonPropertyName("access_token")]
        public string? AccessToken { get; set; }

        [JsonPropertyName("token_type")]
        public string? TokenType { get; set; }

        [JsonPropertyName("expires_in")]
        public int? ExpiresIn { get; set;  }

        [JsonPropertyName("id_token")]
        public string? IdToken { get; set; }
    }
}

Create the signing credentials provider

Create the ISigningCredentialsProvider interface using the following code.

using Microsoft.IdentityModel.Tokens;

namespace CiamFunctionsApplication.Providers
{
    public interface ISigningCredentialsProvider
    {
        SigningCredentials GetSigningCredentials(string certificateThumbprintEnvironmentVariableName);
    }
}

Create the X509SigningCredentialsProvider class using the following code.

using System.Security.Cryptography.X509Certificates;
using Microsoft.IdentityModel.Tokens;

namespace CiamFunctionsApplication.Providers
{
    internal class X509SigningCredentialsProvider : ISigningCredentialsProvider
    {
        public SigningCredentials GetSigningCredentials(string certificateThumbprintEnvironmentVariableName)
        {
            var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
            store.Open(OpenFlags.ReadOnly);
            var certificates = store.Certificates.Find(X509FindType.FindByThumbprint, Environment.GetEnvironmentVariable(certificateThumbprintEnvironmentVariableName)!, false);

            if (certificates.Count == 0)
            {
                throw new Exception("Certificate not found.");
            }

            var certificate = certificates[0];
            return new X509SigningCredentials(certificate);
        }
    }
}

Add the X509SigningCredentialsProvider class as a singleton service to the functions application.

using CiamFunctionsApplication.Providers;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .ConfigureServices(services =>
    {
        services.AddApplicationInsightsTelemetryWorkerService();
        services.ConfigureFunctionsApplicationInsights();
        services.AddSingleton<ISigningCredentialsProvider, X509SigningCredentialsProvider>();
    })
    .Build();

host.Run();

Configure a Discord application

  1. Log in to https://discordapp.com/developers/applications/.
  2. On the Applications page, select New Application. In the Create An Application dialogue, enter an application name and then select Create.
  3. On the General Information page, select OAuth2.
  4. On the OAuth2 page, copy the Client ID field.
  5. On the OAuth2 page, select Reset Secret. In the Regenerate Secret Key? dialogue, select Yes, do it! On the OAuth2 page, copy the Client Secret field.
  6. On the OAuth2 page, select Add Redirect, enter the following redirect URLs and then select Save Changes:
    1. If you use the built-in domain:
      • https://<your-tenant-subdomain>.ciamlogin.com/<your-tenant-id>/federation/oauth2
      • https://<your-tenant-subdomain>.ciamlogin.com/<your-tenant-subdomain>.onmicrosoft.com/federation/oauth2
    2. If you use a custom domain:
      • https://<your-custom-domain-name>/<your-tenant-id>/federation/oauth2
      • https://<your-custom-domain-name>/<your-tenant-subdomain>.onmicrosoft.com/federation/oauth2

Configure Discord as an identity provider

  1. Log in to Microsoft Entra Admin Center.
  2. On the Home page, select Identity, External Identities, and then All identity providers.
  3. On the All Identity providers page, select Custom. In the Custom tab, select Add new > OpenID Connect.
  4. In the Add OpenID Connect pane and then the Basics tab, enter the following fields and then select Review + create:
    • Display name: Discord
    • Well-known endpoint: https://<your-functions-application-name>.azurewebsites.net/api/discord/.well-known/openid-configuration
    • Issuer URI: https://<your-functions-application-name>.azurewebsites.net/api/discord
    • Client ID: The Client ID field that was copied in the Configure a Discord application section.
    • Client authentication: client_secret
    • Client secret: The Client Secret field that was copied in the Configure a Discord application section.
    • Scope: openid identify email. This scope field must contain the openid scope in order to receive the ID token from the Azure function.
    • Response type: code
  5. In the Add OpenID Connect pane and then the Review tab, select Create.

Add the Discord identity provider to the sign-up and sign-in user flow

  1. Log in to Microsoft Entra Admin Center.
  2. On the Home page, select Identity, External Identities, and then User flows.
  3. On the User flows page, select the sign-up and sign-in user flow to which the Discord identity provider is to be added.
  4. On the user flow page, select Identity providers.
  5. On the Identity providers page, select the Discord identity provider and then select Save.

Read more recent blogs

Get started on the right path to cloud success today. Our Crew are standing by to answer your questions and get you up and running.