验证 Google OpenID Connect JWT ID 令牌

Posted

技术标签:

【中文标题】验证 Google OpenID Connect JWT ID 令牌【英文标题】:Validating Google OpenID Connect JWT ID Token 【发布时间】:2015-06-27 17:23:42 【问题描述】:

我正在尝试升级我的 MVC 网站以使用新的 OpenID Connect 标准。 OWIN 中间件看起来很健壮,但不幸的是只支持 “form_post”响应类型。这意味着 Google 不兼容,因为它在“#”之后返回 url 中的所有令牌,因此它们永远不会到达服务器并且永远不会触发中间件。

我自己尝试在中间件中触发响应处理程序,但这似乎根本不起作用,所以我有一个简单的 javascript 文件,它解析返回的声明并将它们发布到控制器操作进行处理。

问题是,即使我在服务器端获取它们,我也无法正确解析它们。我得到的错误如下所示:

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
    '."

我的令牌验证码遵循开发 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;
    

我知道签名验证对于 JWT 并不是完全必要的,但我一点也不知道如何将其关闭。有什么想法吗?

【问题讨论】:

【参考方案1】:

我想我会发布我稍微改进的版本,它使用 JSON.Net 来解析 Google 的 X509 证书并根据“孩子”(key-id)匹配要使用的密钥。这比尝试每个证书更有效,因为非对称加密通常非常昂贵。

还删除了过时的 WebClient 和手动字符串解析代码:

    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;
        
    

【讨论】:

非常感谢您的代码 sn-p!我仍然想知道是否有办法从googleapis.com/oauth2/v3/certs 的响应中生成这些公钥/证书(用 RSACryptoServiceProvider 尝试过,但不幸的是失败了。) @Robar:v1 端点会很快消失吗?我注意到的另一件事是谷歌每天都会轮换证书,所以你需要处理缓存未命中,然后重新检索证书。 希望不是,但发现文档的当前jwks_uri 是v3 端点(请参阅accounts.google.com/.well-known/openid-configuration)。我已经通过将证书放入具有到期时间的缓存中来处理轮换证书的问题。我从获取证书的 HTTP 请求中检索到期时间,HTTP 响应有一个 max-age 集。此外,如果第一次尝试验证失败,我会重新检索证书。【参考方案2】:

问题是 JWT 中的 kid 其值是用于签署 JWT 的密钥的密钥标识符。由于您从 JWKs URI 手动构造了一组证书,因此您丢失了密钥标识符信息。然而,验证过程需要它。

您需要将tokenValidationParameters.IssuerSigningKeyResolver 设置为一个函数,该函数将返回您在上面tokenValidationParameters.IssuerSigningToken 中设置的相同键。此委托的目的是指示运行时忽略任何“匹配”语义并尝试密钥。

查看这篇文章了解更多信息:JwtSecurityTokenHandler 4.0.0 Breaking Changes?

编辑:代码:

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

【讨论】:

一旦我弄清楚了如何去做,它就完美地工作了。谢谢您的帮助。代码如下所示:tokenValidationParameters.IssuerSigningKeyResolver = (arbitrarily, declaring, these, parameters) =&gt; return new X509SecurityKey(certificate); ;【参考方案3】:

Microsoft 人员发布了支持 OpenId Connect 的 Azure V2 B2C 预览端点的代码示例。参见here,使用辅助类 OpenIdConnectionCachingSecurityTokenProvider 代码简化如下:

app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions

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

这个类是必要的,因为 OAuthBearer 中间件没有利用。 STS 默认公开的 OpenID Connect 元数据端点。

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();
        
    

【讨论】:

请注意此代码不安全(并且上面的代码已从更新的链接中消失)。在ReaderWriterLockSlim 中调用任务.Result 最终会破坏你的服务器,所有后续请求都将被阻塞,等待释放锁。如果您必须从同步中调用异步,请使用GetAwaiter().GetResult()。我们还需要使用这样的代码,并完全删除了ReaderWriterLockSlim,因为它不是必需的。【参考方案4】:

根据 Johannes Rudolph 的回答,我发布了我的解决方案。 IssuerSigningKeyResolver Delegate 中有一个编译器错误,我必须解决。

这是我现在的工作代码:

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;
        
    

【讨论】:

以上是关于验证 Google OpenID Connect JWT ID 令牌的主要内容,如果未能解决你的问题,请参考以下文章

验证 Google OpenID Connect JWT ID 令牌

Google 登录或 OpenID Connect

现在他们正在弃用他们的 OpenID2 提供程序,因此与 Google 进行 OpenID Connect 委托?

Facebook 的 OpenID Connect 配置

使用不同的 redirect_uri 和 openid.realm 参数从 Google OpenID 2.0 迁移到 OpenID Connect

使用 jumbojett/OpenID-Connect-PHP 库的 KeyCloak 身份验证流程