Validating Google OpenID Connect JWT ID Token

11,526

Solution 1

The problem is the kid in the JWT whose value is the key identifier of the key was used to sign the JWT. Since you construct an array of certificates manually from the JWKs URI, you lose the key identifier information. The validation procedure however requires it.

You'll need to set tokenValidationParameters.IssuerSigningKeyResolver to a function that will return the same key that you set above in tokenValidationParameters.IssuerSigningToken. The purpose of this delegate is to instruct the runtime to ignore any 'matching' semantics and just try the key.

See this article for more information: JwtSecurityTokenHandler 4.0.0 Breaking Changes?

Edit: the code:

tokenValidationParameters.IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) => { return new X509SecurityKey(certificate); };

Solution 2

I thought I'd post my slightly improved version which uses JSON.Net to parse Googles' X509 Certificates and matches the key to use based on the "kid" (key-id). This is a bit more efficient than trying each certificate, since asymmetric crypto is usually quite expensive.

Also removed out-dated WebClient and manual string parsing code:

    static Lazy<Dictionary<string, X509Certificate2>> Certificates = new Lazy<Dictionary<string, X509Certificate2>>( FetchGoogleCertificates );
    static Dictionary<string, X509Certificate2> FetchGoogleCertificates()
    {
        using (var http = new HttpClient())
        {
            var json = http.GetStringAsync( "https://www.googleapis.com/oauth2/v1/certs" ).Result;

            var dictionary = JsonConvert.DeserializeObject<Dictionary<string, string>>( json );
            return dictionary.ToDictionary( x => x.Key, x => new X509Certificate2( Encoding.UTF8.GetBytes( x.Value ) ) );
        }
    }

    JwtSecurityToken ValidateIdentityToken( string idToken )
    {
        var token = new JwtSecurityToken( idToken );
        var jwtHandler = new JwtSecurityTokenHandler();

        var certificates = Certificates.Value;

        try
        {
            // Set up token validation
            var tokenValidationParameters = new TokenValidationParameters();
            tokenValidationParameters.ValidAudience = _clientId;
            tokenValidationParameters.ValidIssuer = "accounts.google.com";
            tokenValidationParameters.IssuerSigningTokens = certificates.Values.Select( x => new X509SecurityToken( x ) );
            tokenValidationParameters.IssuerSigningKeys = certificates.Values.Select( x => new X509SecurityKey( x ) );
            tokenValidationParameters.IssuerSigningKeyResolver = ( s, securityToken, identifier, parameters ) =>
            {
                return identifier.Select( x =>
                {
                    if (!certificates.ContainsKey( x.Id ))
                        return null;

                    return new X509SecurityKey( certificates[ x.Id ] );
                } ).First( x => x != null );
            };

            SecurityToken jwt;
            var claimsPrincipal = jwtHandler.ValidateToken( idToken, tokenValidationParameters, out jwt );
            return (JwtSecurityToken)jwt;
        }
        catch (Exception ex)
        {
            _trace.Error( typeof( GoogleOAuth2OpenIdHybridClient ).Name, ex );
            return null;
        }
    }

Solution 3

Based on the answer from Johannes Rudolph I post my solution. There is a compiler error in IssuerSigningKeyResolver Delegate which I had to solve.

This is my working code now:

using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;

namespace QuapiNet.Service
{
    public class JwtTokenValidation
    {
        public async Task<Dictionary<string, X509Certificate2>> FetchGoogleCertificates()
        {
            using (var http = new HttpClient())
            {
                var response = await http.GetAsync("https://www.googleapis.com/oauth2/v1/certs");

                var dictionary = await response.Content.ReadAsAsync<Dictionary<string, string>>();
                return dictionary.ToDictionary(x => x.Key, x => new X509Certificate2(Encoding.UTF8.GetBytes(x.Value)));
            }
        }

        private string CLIENT_ID = "xxxxx.apps.googleusercontent.com";

        public async Task<ClaimsPrincipal> ValidateToken(string idToken)
        {
            var certificates = await this.FetchGoogleCertificates();

            TokenValidationParameters tvp = new TokenValidationParameters()
            {
                ValidateActor = false, // check the profile ID

                ValidateAudience = true, // check the client ID
                ValidAudience = CLIENT_ID,

                ValidateIssuer = true, // check token came from Google
                ValidIssuers = new List<string> { "accounts.google.com", "https://accounts.google.com" },

                ValidateIssuerSigningKey = true,
                RequireSignedTokens = true,
                IssuerSigningKeys = certificates.Values.Select(x => new X509SecurityKey(x)),
                IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
                {
                    return certificates
                    .Where(x => x.Key.ToUpper() == kid.ToUpper())
                    .Select(x => new X509SecurityKey(x.Value));
                },
                ValidateLifetime = true,
                RequireExpirationTime = true,
                ClockSkew = TimeSpan.FromHours(13)
            };

            JwtSecurityTokenHandler jsth = new JwtSecurityTokenHandler();
            SecurityToken validatedToken;
            ClaimsPrincipal cp = jsth.ValidateToken(idToken, tvp, out validatedToken);

            return cp;
        }
    }
}

Solution 4

The folks at Microsoft posted code sample for Azure V2 B2C Preview endpoint that support OpenId Connect. See here, with the helper class OpenIdConnectionCachingSecurityTokenProvider the code is simplified as follows:

app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions
{
    AccessTokenFormat = new JwtFormat(new TokenValidationParameters
    {
       ValidAudiences = new[] { googleClientId },
    }, new OpenIdConnectCachingSecurityTokenProvider("https://accounts.google.com/.well-known/openid-configuration"))});

This class is necessary because the OAuthBearer Middleware does not leverage. The OpenID Connect metadata endpoint exposed by the STS by default.

public class OpenIdConnectCachingSecurityTokenProvider : IIssuerSecurityTokenProvider
{
    public ConfigurationManager<OpenIdConnectConfiguration> _configManager;
    private string _issuer;
    private IEnumerable<SecurityToken> _tokens;
    private readonly string _metadataEndpoint;

    private readonly ReaderWriterLockSlim _synclock = new ReaderWriterLockSlim();

    public OpenIdConnectCachingSecurityTokenProvider(string metadataEndpoint)
    {
        _metadataEndpoint = metadataEndpoint;
        _configManager = new ConfigurationManager<OpenIdConnectConfiguration>(metadataEndpoint);

        RetrieveMetadata();
    }

    /// <summary>
    /// Gets the issuer the credentials are for.
    /// </summary>
    /// <value>
    /// The issuer the credentials are for.
    /// </value>
    public string Issuer
    {
        get
        {
            RetrieveMetadata();
            _synclock.EnterReadLock();
            try
            {
                return _issuer;
            }
            finally
            {
                _synclock.ExitReadLock();
            }
        }
    }

    /// <summary>
    /// Gets all known security tokens.
    /// </summary>
    /// <value>
    /// All known security tokens.
    /// </value>
    public IEnumerable<SecurityToken> SecurityTokens
    {
        get
        {
            RetrieveMetadata();
            _synclock.EnterReadLock();
            try
            {
                return _tokens;
            }
            finally
            {
                _synclock.ExitReadLock();
            }
        }
    }

    private void RetrieveMetadata()
    {
        _synclock.EnterWriteLock();
        try
        {
            OpenIdConnectConfiguration config = _configManager.GetConfigurationAsync().Result;
            _issuer = config.Issuer;
            _tokens = config.SigningTokens;
        }
        finally
        {
            _synclock.ExitWriteLock();
        }
    }
}
Share:
11,526

Related videos on Youtube

ReimTime
Author by

ReimTime

Don't fake the funk.

Updated on June 04, 2022

Comments

  • ReimTime
    ReimTime almost 2 years

    I'm trying to upgrade my MVC website to use the new OpenID Connect standard. The OWIN middleware seems to be pretty robust, but unfortunately only supports the "form_post" response type. This means that Google isn't compatible, as it returns all the tokens in a the url after a "#", so they never reach the server and never trigger the middleware.

    I've tried to trigger the response handlers in the middleware myself, but that doesn't seem to work at all, so I've got a simply javascript file that parses out the returned claims and POSTs them to a controller action for processing.

    Problem is, even when I get them on the server side I can't parse them correctly. The error I get looks like this:

    IDX10500: Signature validation failed. Unable to resolve     
    SecurityKeyIdentifier: 'SecurityKeyIdentifier
    (
       IsReadOnly = False,
       Count = 1,
       Clause[0] = System.IdentityModel.Tokens.NamedKeySecurityKeyIdentifierClause
    ),
    token: '{
        "alg":"RS256",
        "kid":"073a3204ec09d050f5fd26460d7ddaf4b4ec7561"
    }.
    {
        "iss":"accounts.google.com",
        "sub":"100330116539301590598",
        "azp":"1061880999501-b47blhmmeprkvhcsnqmhfc7t20gvlgfl.apps.googleusercontent.com",
        "nonce":"7c8c3656118e4273a397c7d58e108eb1",
        "email_verified":true,
        "aud":"1061880999501-b47blhmmeprkvhcsnqmhfc7t20gvlgfl.apps.googleusercontent.com",
        "iat":1429556543,"exp\":1429560143
        }'."
    }
    

    My token verification code follows the example outlined by the good people developing IdentityServer

        private async Task<IEnumerable<Claim>> ValidateIdentityTokenAsync(string idToken, string state)
        {
            // New Stuff
            var token = new JwtSecurityToken(idToken);
            var jwtHandler = new JwtSecurityTokenHandler();
            byte[][] certBytes = getGoogleCertBytes();
    
            for (int i = 0; i < certBytes.Length; i++)
            {
                var certificate = new X509Certificate2(certBytes[i]);
                var certToken = new X509SecurityToken(certificate);
    
                // Set up token validation
                var tokenValidationParameters = new TokenValidationParameters();
                tokenValidationParameters.ValidAudience = googleClientId;
                tokenValidationParameters.IssuerSigningToken = certToken;
                tokenValidationParameters.ValidIssuer = "accounts.google.com";
    
                try
                {
                    // Validate
                    SecurityToken jwt;
                    var claimsPrincipal = jwtHandler.ValidateToken(idToken, tokenValidationParameters, out jwt);
                    if (claimsPrincipal != null)
                    {
                        // Valid
                        idTokenStatus = "Valid";
                    }
                }
                catch (Exception e)
                {
                    if (idTokenStatus != "Valid")
                    {
                        // Invalid?
    
                    }
                }
            }
    
            return token.Claims;
        }
    
        private byte[][] getGoogleCertBytes()
        {
            // The request will be made to the authentication server.
            WebRequest request = WebRequest.Create(
                "https://www.googleapis.com/oauth2/v1/certs"
            );
    
            StreamReader reader = new StreamReader(request.GetResponse().GetResponseStream());
    
            string responseFromServer = reader.ReadToEnd();
    
            String[] split = responseFromServer.Split(':');
    
            // There are two certificates returned from Google
            byte[][] certBytes = new byte[2][];
            int index = 0;
            UTF8Encoding utf8 = new UTF8Encoding();
            for (int i = 0; i < split.Length; i++)
            {
                if (split[i].IndexOf(beginCert) > 0)
                {
                    int startSub = split[i].IndexOf(beginCert);
                    int endSub = split[i].IndexOf(endCert) + endCert.Length;
                    certBytes[index] = utf8.GetBytes(split[i].Substring(startSub, endSub).Replace("\\n", "\n"));
                    index++;
                }
            }
            return certBytes;
        }
    

    I know that Signature validation isn't completely necessary for JWTs but I haven't the slightest idea how to turn it off. Any ideas?

  • ReimTime
    ReimTime about 9 years
    Once I figured out how to do it, this worked perfectly. Thanks for the help. Code looks like this: tokenValidationParameters.IssuerSigningKeyResolver = (arbitrarily, declaring, these, parameters) => { return new X509SecurityKey(certificate); };
  • Robar
    Robar about 9 years
    Thank you very much for your code snippet! I'm still wondering if there is a way to generate those public keys/certificates from the response of googleapis.com/oauth2/v3/certs (Tried it with RSACryptoServiceProvider, but unfortunately failed.)
  • aaddesso
    aaddesso about 9 years
    @Robar: is the v1 endpoint going to go away anytime soon? One other thing I noticed is that google rotates the certs about daily, so you need to deal with cache misses and then re-retrieve certs.
  • Robar
    Robar about 9 years
    Hopefully not, but the current jwks_uri of the discovery document is the v3 endpoint (see accounts.google.com/.well-known/openid-configuration). I already have handled the issue with the rotating certs, by putting the certs into a cache with an expiry time. I retrieve the expiry time from the HTTP request which gets the certs, the HTTP response has a max-age set. Additionally I make one re-retrieval of the certs if the validation fails at the first attempt.
  • Ashwin
    Ashwin over 5 years
    Just a note this code is not safe (and the code above is gone from the updated link). Calling the tasks .Result inside of the ReaderWriterLockSlim will break your server eventually, and all subsequent requests will be blocked waiting for the lock to be released. If you have to call async from sync, use GetAwaiter().GetResult(). We also still needed to use code like this, and removed the ReaderWriterLockSlim entirely as it's not required.