Implementing Sign In with Apple in ASP.NET Core

OpenID Connect

Apple have improved their OpenID Connect implementation, which thankfully means that some of the workarounds in this article are no longer necessary. I’ll update the article soon.

.NET Core

Sign in with Apple was recently released as part of Apple’s WWDC 2019 conference. They’ve essentially weighed into the identity provider space with the username and password being handled by Apple ID and 2FA handled by your registered Apple devices.

Sign In with Apple gives us a new alternative to other social login providers such as Google and Facebook. However, unlike those services, it seems to be more aimed at identity and authentication, rather than access to services such as Google calendar.

The major value add is the ability to create a “private relay email”. So instead of giving the website your actual email address, you instead give them an Apple one, created specifically for that website. This way we get to keep our email private, whilst still having the ability to receive mail from that website.

In this article, I’m going to take a brief look at how Sign In with Apple is hooked together, and then show a proof of concept integration using ASP.NET Core.

Skip to the code.

Sign In with Apple: How It Works

The good news is, Sign In with Apple is OpenID Connect in everything but name. It has an authorization endpoint, a token endpoint, we send it a client ID, redirect URI, state, and we get an identity token in return. However, it does have a few caveats.

Authorization Request

The authorization URL is the same for every application, and the client specific configuration is registered in Apple’s developer portal as a Service ID.

Example Sign In with Apple Authorization Request

https://appleid.apple.com/auth/authorize
  ?client_id=com.scottbrady91.authdemo.service
  &redirect_uri=https://www.scottbrady91.com/signin-apple
  &state=123
  &response_type=code
  &response_mode=form_post

If you supply a nonce, it will unfortunately not be included in any returned identity tokens. s_hash, c_hash when using the hybrid flow, and PKCE support do not seem to be present either. As a result, I’m struggling to see any protection from code injection attacks in Apple’s implementation.

You can define requested scopes in your authorization request (e.g. openid or email. profile is not supported), however, during testing if I included this parameter it caused Apple’s consent page on https://appleid.apple.com/appleauth/auth/oauth/consent to fail with an HTTP 500 error.

There does not seem to be a discovery endpoint either, so endpoints will have to be defined manually. Thankfully, they do have a JWKS endpoint for public keys.

Token Request

Apple does not support simple shared secrets for client authorization. Instead, they use a custom implementation, similar to JWT Bearer Token for Client Authentication found in RFC 7523. Apple supply you with a private key with which to sign the token and require you to use the ES256 signing algorithm.

This JWT must also have:

  • An issuer (iss) value set to your Apple Team ID (found in the membership area of the Apple developer portal)
  • An expiry (exp) of under 6 months (allowing for long-lived or short-lived credentials)
  • An audience (aud) value of https://appleid.apple.com
  • A subject (sub) value equal to your client_id (Service ID)

You can find more details on the client secret type in Apple’s documentation.

Example JWT Secret

Encoded
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjb20uc2NvdHRicmFkeTkxLmF1dGhkZW1vLnNlcnZpY2UiLCJuYmYiOjE1NTk5ODE5NDAsImV4cCI6MTU1OTk4MjI0MCwiaWF0IjoxNTU5OTgxOTQwLCJpc3MiOiI2MlFNMjk1NzhOIiwiYXVkIjoiaHR0cHM6Ly9hcHBsZWlkLmFwcGxlLmNvbSJ9.ZxYzYTJUcjGq46NKrqHhi53yOWp6mmvq5WECg2qMq6Xg-5qeOpJXmi0Qf1YZsA2OG4RJwlFHObWbhF5ebdUZfA
Decoded
{
  "alg": "ES256",
  "typ": "JWT"
}
{
  "sub": "com.scottbrady91.authdemo.service",
  "nbf": 1559981940,
  "exp": 1559982240,
  "iat": 1559981940,
  "iss": "62QM29578N",
  "aud": "https://appleid.apple.com"
}

Example Sign in with Apple Token Request

POST https://appleid.apple.com/auth/token HTTP/1.1
User-Agent: Microsoft ASP.NET Core OpenIdConnect handler
Content-Type: application/x-www-form-urlencoded

client_id=com.scottbrady91.authdemo.service
    &code=c17591c6ce9a942b19729925667457fa4.0.nvrr.nVvlSbFTgMm9mpg-uKvlYg
    &grant_type=authorization_code
    &redirect_uri=http%3A%2F%2Flocal.test%3A5000%2Fsignin-apple
    &client_secret=eyJhbGciOiJFU[...]A2OG4RJwlFHObWbhF5ebdUZfA

Note that Apple’s token endpoint requires a User-Agent header to succeed validation.

Example Sign In with Apple Token Response

{
    "access_token": "aa4c538c4896649b89d39b7e355f5b4fb.0.nvrr.pJR4425acx26Gu4LxXUwAw",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "r3dc7e086e35e4db99583cf2a61d27785.0.nvrr.JcsmhqE0ZzNGj-h3BDN5rg",
    "id_token": "eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLnNjb3R0YnJhZHk5MS5hdXRoZGVtby5zZXJ2aWNlIiwiZXhwIjoxNTU5OTgyNTQyLCJpYXQiOjE1NTk5ODE5NDIsInN1YiI6IjAwMDUxMS4xMDViODM3OGZhYmM0MTBjYjI4NTc2ZTA4YzI0NDgzZS4wNjU5IiwiYXRfaGFzaCI6IlAxeGFqX2VEaFZLLWhxd1RNWmZfckEifQ.DCVaN3QngqtBQuoXzwTrr9MywmwZy6tZF6ljLsNBH7cJMaIn3pnYPAs9rD_YAmF2ihZS0DJtKlpinZ1LmmMVvBqebD1N7Bl7iDWxuAyFLkU1xQQZYIWP_JhoHXhHFGTls_e7YZZDlgEKPPSZdimGs8byr1c9uaagIY8cSjwAk8t8egazMgYQstuu7SRb7JJs2rm2lDFWXR61AmeRbej8khSYEhn-28uJY2DHQWwamKj0ABcFTmq4Cn3kKiIPm3r5fYxFHOUOl01av_uRGtG9bPORv5nb9CPHSZM6qZu2rxWcybfnLJlExbM12Q0yLQHxk5_gtC6OJOM8dfFfWwB0Vg"
}

ASP.NET Core Authentication using Sign In with Apple

Let’s see how to integrate this in ASP.NET Core. First, we’ll need to do some setup within Apple’s developer portal, access to which will unfortunately put you back at least £79 if you don’t already have an account.

Apple Developer Portal Setup

Apple have some good step-by-step instructions for creating a client application in their developer portal, however, the requirements boil down to:

  1. Create an App ID that is configured for “Sign In with Apple”
  2. Create a Service ID that is configured for “Sign In with Apple” (including a verified domain and redirect URIs)
  3. Create a key that can be used by your created Service ID

Unfortunately, you can’t use localhost redirect URIs, but you can use dev domains such as .test. In my example, I set my verified domain as www.scottbrady91.com and had my redirect URL as http://local.test:5000/signin-apple (with an associated host record on my machine).

ASP.NET Core Integration

We’ll start by adding cookie and OpenID Connect authentication handlers to our application, that will start by looking something like the following:

services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = "cookie";
        options.DefaultChallengeScheme = "apple";
    })
    .AddCookie("cookie")
    .AddOpenIdConnect("apple", async options =>
    {
        options.ResponseType = "code";
        options.SignInScheme = "cookie";
        options.Scope.Clear(); // otherwise I had consent request issues

        // TODO
    });

Not forgetting the call to UseAuthentication in your Configure method, and a route somewhere with an AuthorizeAttribute or a call to challenge.

We’re going to be using the authorization code flow, due to the lack of c_hash or PKCE support. Becuase there is no c_hash, the existing ASP.NET Core OpenID Connect validation will not support the use of the hybrid flow, as it requires this security check when a code and id_token are received at the same time.

I’ve also cleared scopes for this example, due to an odd HTTP 500 error I would receive from Apple’s consent endpoint.

Since Apple doesn’t seem to have an OpenID Connect discovery document, we must configure some of the URLs ourselves by using properties in OpenIdConnectConfiguration:

options.Configuration = new OpenIdConnectConfiguration
{
    AuthorizationEndpoint = "https://appleid.apple.com/auth/authorize",
    TokenEndpoint = "https://appleid.apple.com/auth/token",
};

The client_id will be the Service ID that was created for Sign In with Apple. Our callback path must match a redirect URI configured in that Service.

options.ClientId = "com.scottbrady91.authdemo.service"; // Service ID
options.CallbackPath = "/signin-apple"; // corresponding to your redirect URI

Now for our secret generation. When we generate a key within the Apple developer portal, it is given to us as a .p8 file. Luckily, this is something we can read with relative ease, using the CngKey class.

public static class TokenGenerator
{
    public static string CreateNewToken()
    {
        const string iss = "62QM29578N"; // your account's team ID found in the dev portal
        const string aud = "https://appleid.apple.com";
        const string sub = "com.scottbrady91.authdemo.service"; // same as client_id
        const string privateKey = "MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgnbfHJQO9feC7yKOenScNctvHUP+Hp3AdOKnjUC3Ee9GgCgYIKoZIzj0DAQehRANCAATMgckuqQ1MhKALhLT/CA9lZrLA+VqTW/iIJ9GKimtC2GP02hCc5Vac8WuN6YjynF3JPWKTYjg2zqex5Sdn9Wj+"; // contents of .p8 file
        
        var cngKey = CngKey.Import(
          Convert.FromBase64String(privateKey), 
          CngKeyBlobFormat.Pkcs8PrivateBlob);
        
        var handler = new JwtSecurityTokenHandler();
        var token = handler.CreateJwtSecurityToken(
            issuer: iss,
            audience: aud,
            subject: new ClaimsIdentity(new List<Claim> {new Claim("sub", sub)}),
            expires: DateTime.UtcNow.AddMinutes(5), // expiry can be a maximum of 6 months
            issuedAt: DateTime.UtcNow,
            notBefore: DateTime.UtcNow,
            signingCredentials: new SigningCredentials(
              new ECDsaSecurityKey(new ECDsaCng(cngKey)), SecurityAlgorithms.EcdsaSha256));

        return handler.WriteToken(token);
    }
}

Using this helper method, we can generate a new client secret per request via the OnAuthorizationCodeReceived event:

options.Events.OnAuthorizationCodeReceived = context =>
{
    context.TokenEndpointRequest.ClientSecret = TokenGenerator.CreateNewToken();
    return Task.CompletedTask;
};

Now that we have everything set up to get tokens, we need to be able to validate incoming identity tokens.

We can let the defaults handle things such as expiration validation and audience (client ID), but we’ll need to specify the issuer as https://appleid.apple.com.

// Expected identity token iss value
options.TokenValidationParameters.ValidIssuer = "https://appleid.apple.com";

Next, we’ll need the public key that will be used to validate the signature of the token. Thankfully, Apple does have a JWKS endpoint, found on https://appleid.apple.com/auth/keys. Incoming identity tokens will be signed using RS256.

// Expected identity token signing key
var jwks = await new HttpClient().GetStringAsync("https://appleid.apple.com/auth/keys");
options.TokenValidationParameters.IssuerSigningKey = new JsonWebKeySet(jwks).Keys.FirstOrDefault();

And finally, we need to disable nonce validation, as this is something that Apple does not support.

// Disable nonce validation (not supported by Apple)
options.ProtocolValidator.RequireNonce = false;

You should now be able to login using Sign In with Apple.

Source Code

You can find a working sample of this code on GitHub. You’ll just need to provide your own Service ID, redirect URI, and signing key.

Further Reading