How to produce JWT with Google OAuth2 compatible a

2019-03-10 20:02发布

问题:

I'm trying to create a JWT to authorize with a service account as described in Google documentation using System.IdentityModel.Tokens.Jwt. I have the following code:

byte[] key = Convert.FromBase64String("...");
var certificate = new X509Certificate2(key, "notasecret");

DateTime now = DateTime.UtcNow;
TimeSpan span = now - UnixEpoch;
Claim[] claims =
{
    new Claim("iss", "email@developer.gserviceaccount.com"),
    new Claim("scope", "https://www.googleapis.com/auth/plus.me"),
    new Claim("aud", "https://accounts.google.com/o/oauth2/token"),
    new Claim("iat", span.TotalSeconds.ToString()),
    new Claim("exp", span.Add(TimeSpan.FromHours(1)).TotalSeconds.ToString())
};

JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler();
var descriptor = new SecurityTokenDescriptor
{
    SigningCredentials = new SigningCredentials(
        new InMemorySymmetricSecurityKey(key),
        "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256",
        "http://www.w3.org/2001/04/xmlenc#sha256"),
    Subject = new ClaimsIdentity(claims)
};

JwtSecurityToken jwtSecurityToken = (JwtSecurityToken)handler.CreateToken(descriptor);
string json = handler.WriteToken(jwtSecurityToken);

which outputs:

{ "typ" : "JWT" , "alg" : "HS256" }

While Google explicitly states it supports SHA-256:

Service accounts rely on the RSA SHA-256 algorithm and the JWT token format

According to wtSecurityTokenHandler.InboundAlgorithmMap:

RS256 => http://www.w3.org/2001/04/xmldsig-more#rsa-sha256
HS256 => http://www.w3.org/2001/04/xmldsig-more#hmac-sha256 

So when I change my code:

new SigningCredentials(
    new InMemorySymmetricSecurityKey(key),
        "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
        "http://www.w3.org/2001/04/xmlenc#sha256");

I'm getting an exception:

System.InvalidOperationException: IDX10632: SymmetricSecurityKey.GetKeyedHashAlgorithm( 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' ) threw an exception.
SymmetricSecurityKey: 'System.IdentityModel.Tokens.InMemorySymmetricSecurityKey'
SignatureAlgorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', check to make sure the SignatureAlgorithm is supported.

Does it mean Microsoft doesn't support the algorithm Google supports exclusively?

回答1:

It has been a while since this question was asked but I think that for future people coming on this page, it may be worth knowing that it's dead easy to get the same results in a few lines of code with the .NET Google Auth API (whose nuget is available here: Google.Apis.Auth

using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using Google.Apis.Auth.OAuth2;

namespace GoogleTest
{
    public class GoogleOAuth2
    {
        /// <summary>
        /// Authorization scope for our requests
        /// </summary>
        private readonly string _defaultScope;

        /// <summary>
        /// Service account will be of the form nnnnnnn@developer.gserviceaccount.com
        /// </summary>
        private readonly string _serviceAccount;

        /// <summary>
        /// Set this to the full path to your service account private key file.
        /// </summary>
        private readonly string _certificateFile;

        public GoogleOAuth2(string defaultScope, string serviceAccount, string certificateFile)
        {
            _defaultScope = defaultScope;
            _serviceAccount = serviceAccount;
            _certificateFile = certificateFile;
        }

        /// <summary>
        /// Access Token returned by Google Token Server
        /// </summary>
        public string AccessToken { get; set; }

        public async Task<bool> RequestAccessTokenAsync()
        {
            var certificate = new X509Certificate2(_certificateFile, "notasecret", X509KeyStorageFlags.Exportable);
            var serviceAccountCredential = new ServiceAccountCredential(new ServiceAccountCredential.Initializer(_serviceAccount)
            {
                Scopes = new[] { _defaultScope }
            }.FromCertificate(certificate));

            var status = await serviceAccountCredential.RequestAccessTokenAsync(CancellationToken.None);
            if (status)
                AccessToken = serviceAccountCredential.Token.AccessToken;
            return status;
        }
    }
}

To get the Access Token, you just have to call the method RequestAccessTokenAsync and if the result is successful, you've got your token in the AccessToken property.

Note that this implementation assumes that in the developers console, you have exported your private key as a .P12 file.

Hope this answer will help.



回答2:

private static async Task<string> GetAuthorizationToken(GoogleAuthOptions authOptions)
{
    string jwt = CreateJwt(authOptions);

    var dic = new Dictionary<string, string>
    {
        { "grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer" },
        { "assertion", jwt }
    };
    var content = new FormUrlEncodedContent(dic);

    var httpClient = new HttpClient { BaseAddress = new Uri("https://accounts.google.com") };
    var response = await httpClient.PostAsync("/o/oauth2/token", content);
    response.EnsureSuccessStatusCode();

    dynamic dyn = await response.Content.ReadAsAsync<dynamic>();
    return dyn.access_token;
}

private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);

private static string CreateJwt(GoogleAuthOptions authOptions)
{
    var certificate = new X509Certificate2(Convert.FromBase64String(authOptions.CertificateKey), authOptions.CertificateSecret);

    DateTime now = DateTime.UtcNow;
    var claimset = new
    {
        iss = authOptions.Issuer,
        scope = "https://www.googleapis.com/auth/plus.me",
        aud = authOptions.Audience,
        iat = ((int)now.Subtract(UnixEpoch).TotalSeconds).ToString(CultureInfo.InvariantCulture),
        exp = ((int)now.AddMinutes(55).Subtract(UnixEpoch).TotalSeconds).ToString(CultureInfo.InvariantCulture)
    };

    // header
    var header = new { typ = "JWT", alg = "RS256" };

    // encoded header
    var headerSerialized = JsonConvert.SerializeObject(header);
    var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);
    var headerEncoded = TextEncodings.Base64Url.Encode(headerBytes);

    // encoded claimset
    var claimsetSerialized = JsonConvert.SerializeObject(claimset);
    var claimsetBytes = Encoding.UTF8.GetBytes(claimsetSerialized);
    var claimsetEncoded = TextEncodings.Base64Url.Encode(claimsetBytes);

    // input
    var input = String.Join(".", headerEncoded, claimsetEncoded);
    var inputBytes = Encoding.UTF8.GetBytes(input);

    // signiture
    var rsa = (RSACryptoServiceProvider)certificate.PrivateKey;
    var cspParam = new CspParameters
    {
        KeyContainerName = rsa.CspKeyContainerInfo.KeyContainerName,
        KeyNumber = rsa.CspKeyContainerInfo.KeyNumber == KeyNumber.Exchange ? 1 : 2
    };
    var cryptoServiceProvider = new RSACryptoServiceProvider(cspParam) { PersistKeyInCsp = false };
    var signatureBytes = cryptoServiceProvider.SignData(inputBytes, "SHA256");
    var signatureEncoded = TextEncodings.Base64Url.Encode(signatureBytes);

    // jwt
    return String.Join(".", headerEncoded, claimsetEncoded, signatureEncoded);
}


回答3:

I had to modify @abatishchev's code slightly. Otherwise, it had problems generating a certificate when deployed to non-development environments.

The problem was two-fold. If the certificate was not flagged as exportable, it would throw an exception saying something like "keyset does not exist". It would only happen on the servers, not locally, so I suspect server versions of Windows are more restrictive.

Also, it would throw a cryptography exception about a computer trust issue because certificate was created in a user keyset. Our application pools were set not to import the user profile in the advanced options, which you could do. But it was not an option for us due to compatibility issues with other apps. Setting the certificate to be created in the machine keyset mitigates the problem.

The 2 changed sections are marked with comments.

private static async Task<string> GetAuthorizationToken(GoogleAuthOptions authOptions)
{
    string jwt = CreateJwt(authOptions);

    var dic = new Dictionary<string, string>
    {
        { "grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer" },
        { "assertion", jwt }
    };
    var content = new FormUrlEncodedContent(dic);

    var httpClient = new HttpClient { BaseAddress = new Uri("https://accounts.google.com") };
    var response = await httpClient.PostAsync("/o/oauth2/token", content);
    response.EnsureSuccessStatusCode();

    dynamic dyn = await response.Content.ReadAsAsync<dynamic>();
    return dyn.access_token;
}

private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);

private static string CreateJwt(GoogleAuthOptions authOptions)
{
    /* changed */
    const X509KeyStorageFlags certificateFlags = X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable;

    var certificate = new X509Certificate2(Convert.FromBase64String(authOptions.CertificateKey), authOptions.CertificateSecret, certificateFlags);
    /* end of change */

    DateTime now = DateTime.UtcNow;
    var claimset = new
    {
        iss = authOptions.Issuer,
        scope = "https://www.googleapis.com/auth/plus.me",
        aud = authOptions.Audience,
        iat = ((int)now.Subtract(UnixEpoch).TotalSeconds).ToString(CultureInfo.InvariantCulture),
        exp = ((int)now.AddMinutes(55).Subtract(UnixEpoch).TotalSeconds).ToString(CultureInfo.InvariantCulture)
    };

    // header
    var header = new { typ = "JWT", alg = "RS256" };

    // encoded header
    var headerSerialized = JsonConvert.SerializeObject(header);
    var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);
    var headerEncoded = TextEncodings.Base64Url.Encode(headerBytes);

    // encoded claimset
    var claimsetSerialized = JsonConvert.SerializeObject(claimset);
    var claimsetBytes = Encoding.UTF8.GetBytes(claimsetSerialized);
    var claimsetEncoded = TextEncodings.Base64Url.Encode(claimsetBytes);

    // input
    var input = String.Join(".", headerEncoded, claimsetEncoded);
    var inputBytes = Encoding.UTF8.GetBytes(input);

    // signiture
    var rsa = (RSACryptoServiceProvider)certificate.PrivateKey;
    var cspParam = new CspParameters
    {
        KeyContainerName = rsa.CspKeyContainerInfo.KeyContainerName,
        /* changed */
        KeyNumber = (int) KeyNumber.Exchange,       
        Flags = CspProviderFlags.UseMachineKeyStore
        /* end of change */
    };
    var cryptoServiceProvider = new RSACryptoServiceProvider(cspParam) { PersistKeyInCsp = false };
    var signatureBytes = cryptoServiceProvider.SignData(inputBytes, "SHA256");
    var signatureEncoded = TextEncodings.Base64Url.Encode(signatureBytes);

    // jwt
    return String.Join(".", headerEncoded, claimsetEncoded, signatureEncoded);
}