刷新 IdentityServer4 客户端中的访问令牌

Refreshing access tokens in IdentityServer4 clients

我想知道如何使用混合流程刷新 IdentityServer4 客户端中的访问令牌,它是使用 ASP.NET Core MVC 构建的。

如果我正确理解了整个概念,客户端首先需要具有 "offline_access" 范围才能使用刷新令牌,这是启用短期访问令牌和撤销刷新能力的最佳实践阻止向客户端颁发任何新访问令牌的令牌。

我成功获得了访问令牌和刷新令牌,但我应该如何处理 MVC 客户端中访问令牌的实际更新过程?

OpenId Connect (OIDC) 中间件可以自动处理吗?或者我应该在调用 WEB Api 的任何地方检查访问令牌的过期时间,基本上检查访问令牌是否已过期或将很快过期(即将到来的 30 秒),然后通过调用刷新访问令牌使用刷新令牌的令牌端点?

是否建议在我的控制器操作方法中使用 IdentityModel2TokenClient 扩展方法 RequestRefreshTokenAsync 来调用令牌端点?

我看到代码在 OIDC 中间件事件中请求访问令牌并使用响应存储包含过期日期时间的声明。问题是我的 OIDC 不知何故已经自动请求访问令牌,因此在收到第一个访问令牌后直接请求新的访问令牌感觉不太好。

控制器操作方法示例没有访问令牌刷新逻辑:

public async Task<IActionResult> GetInvoices()
    {
        var token = await HttpContext.Authentication.GetTokenAsync("access_token");

        var client = new HttpClient();
        client.SetBearerToken(token);

        var response = await client.GetStringAsync("http://localhost:5001/api/getInvoices");
        ViewBag.Json = JArray.Parse(response).ToString();

        return View();
    }

我找到了两种可能的解决方案,两者都是相同的,但在 OIDC 中间件中发生的时间不同。在事件中,我提取访问令牌过期时间值并将其存储为声明,稍后可用于检查是否可以使用当前访问令牌调用 Web API 或者我是否应该请求新访问使用刷新令牌的令牌。

如果有人可以就这些事件中哪些更适合使用提供任何意见,我将不胜感激。

var oidcOptions = new OpenIdConnectOptions
{
      AuthenticationScheme = appSettings.OpenIdConnect.AuthenticationScheme,
      SignInScheme = appSettings.OpenIdConnect.SignInScheme,

      Authority = appSettings.OpenIdConnect.Authority,
      RequireHttpsMetadata = _hostingEnvironment.IsDevelopment() ? false : true,
      PostLogoutRedirectUri = appSettings.OpenIdConnect.PostLogoutRedirectUri,

      ClientId = appSettings.OpenIdConnect.ClientId,
      ClientSecret = appSettings.OpenIdConnect.ClientSecret,
      ResponseType = appSettings.OpenIdConnect.ResponseType,

      UseTokenLifetime = appSettings.OpenIdConnect.UseTokenLifetime,
      SaveTokens = appSettings.OpenIdConnect.SaveTokens,
      GetClaimsFromUserInfoEndpoint = appSettings.OpenIdConnect.GetClaimsFromUserInfoEndpoint,

      Events = new OpenIdConnectEvents
      {
          OnTicketReceived = TicketReceived,
          OnUserInformationReceived = UserInformationReceived
      },

      TokenValidationParameters = new TokenValidationParameters
      {                    
          NameClaimType = appSettings.OpenIdConnect.NameClaimType,
          RoleClaimType = appSettings.OpenIdConnect.RoleClaimType
      }
  };
  oidcOptions.Scope.Clear();
  foreach (var scope in appSettings.OpenIdConnect.Scopes)
  {
      oidcOptions.Scope.Add(scope);
  }
  app.UseOpenIdConnectAuthentication(oidcOptions);

下面是一些我可以选择的事件示例:

        public async Task TicketReceived(TicketReceivedContext trc)
    {
        await Task.Run(() =>
        {
            Debug.WriteLine("TicketReceived");

            //Alternatives to get the expires_at value
            //var expiresAt1 = trc.Ticket.Properties.GetTokens().SingleOrDefault(t => t.Name == "expires_at").Value;
            //var expiresAt2 = trc.Ticket.Properties.GetTokenValue("expires_at");
            //var expiresAt3 = trc.Ticket.Properties.Items[".Token.expires_at"];

            //Outputs:
            //expiresAt1 = "2016-12-19T11:58:24.0006542+00:00"
            //expiresAt2 = "2016-12-19T11:58:24.0006542+00:00"
            //expiresAt3 = "2016-12-19T11:58:24.0006542+00:00"

            //Remove OIDC protocol claims ("iss","aud","exp","iat","auth_time","nonce","acr","amr","azp","nbf","c_hash","sid","idp")
            ClaimsPrincipal p = TransformClaims(trc.Ticket.Principal);

            //var identity = p.Identity as ClaimsIdentity;

            // keep track of access token expiration
            //identity.AddClaim(new Claim("expires_at1", expiresAt1.ToString()));
            //identity.AddClaim(new Claim("expires_at2", expiresAt2.ToString()));
            //identity.AddClaim(new Claim("expires_at3", expiresAt3.ToString()));

            //Todo: Check if it's OK to replace principal instead of the ticket, currently I can't make it work when replacing the whole ticket.
            //trc.Ticket = new AuthenticationTicket(p, trc.Ticket.Properties, trc.Ticket.AuthenticationScheme);
            trc.Principal = p;                
        });
    }

我还有 UserInformationReceived 事件,我不确定是否应该使用它而不是 TicketReceived 事件。

        public async Task UserInformationReceived(UserInformationReceivedContext uirc)
    {
        await Task.Run(() =>
        {
            Debug.WriteLine("UserInformationReceived");

            ////Alternatives to get the expires_at value
            //var expiresAt4 = uirc.Ticket.Properties.GetTokens().SingleOrDefault(t => t.Name == "expires_at").Value;
            //var expiresAt5 = uirc.Ticket.Properties.GetTokenValue("expires_at");
            //var expiresAt6 = uirc.Ticket.Properties.Items[".Token.expires_at"];
            //var expiresIn1 = uirc.ProtocolMessage.ExpiresIn;

            //Outputs:
            //expiresAt4 = "2016-12-19T11:58:24.0006542+00:00"
            //expiresAt5 = "2016-12-19T11:58:24.0006542+00:00"
            //expiresAt6 = "2016-12-19T11:58:24.0006542+00:00"
            //expiresIn = "60" <-- The 60 seconds test interval for the access token lifetime is configured in the IdentityServer client configuration settings

            var identity = uirc.Ticket.Principal.Identity as ClaimsIdentity;

            //Keep track of access token expiration
            //Add a claim with information about when the access token is expired, it's possible that I instead should use expiresAt4, expiresAt5 or expiresAt6 
            //instead of manually calculating the expire time.
            //This claim will later be checked before calling Web API's and if needed a new access token will be requested via the IdentityModel2 library.
            //identity.AddClaim(new Claim("expires_at4", expiresAt4.ToString()));
            //identity.AddClaim(new Claim("expires_at5", expiresAt5.ToString()));
            //identity.AddClaim(new Claim("expires_at6", expiresAt6.ToString()));
            //identity.AddClaim(new Claim("expires_in1", expiresIn1.ToString()));
            identity.AddClaim(new Claim("expires_in", DateTime.Now.AddSeconds(Convert.ToDouble(uirc.ProtocolMessage.ExpiresIn)).ToLocalTime().ToString()));
            //identity.AddClaim(new Claim("expires_in3", DateTime.Now.AddSeconds(Convert.ToDouble(uirc.ProtocolMessage.ExpiresIn)).ToString()));

            //The following is not needed when to OIDC middleware CookieAuthenticationOptions.SaveTokens = true
            //identity.AddClaim(new Claim("access_token", uirc.ProtocolMessage.AccessToken));
            //identity.Claims.Append(new Claim("refresh_token", uirc.ProtocolMessage.RefreshToken));
            //identity.AddClaim(new Claim("id_token", uirc.ProtocolMessage.IdToken));                
        });
    }

OIDC 中间件不会为您处理这件事。它在检测到 HTTP 401 响应时执行,然后将用户重定向到 IdentityServer 登录页面。重定向到您的 MVC 应用程序后,它会将声明转换为 ClaimsIdentity 并将其传递给 Cookies 中间件,中间件会将其具体化为会话 cookie。

只要 cookie 仍然有效,所有其他请求都不会涉及 OIDC 中间件。

所以你必须自己处理这件事。您要考虑的另一件事是,无论何时您要刷新访问令牌,都必须更新现有的访问令牌,以免丢失它。如果您不这样做,会话 cookie 将始终包含相同的标记 - 原始标记 - 并且您每次都会刷新它。

我找到的一个解决方案是将其挂接到 Cookies 中间件中。 这是一般流程:

  • 在每次请求时,使用 Cookies 中间件事件检查访问令牌
  • 如果快到过期时间,请申请一个新的
  • 替换 ClaimsIdentity
  • 中的新访问和刷新令牌
  • 指示 Cookies 中间件更新会话 cookie,以便它包含新令牌

我喜欢这种方法的一点是,在您的 MVC 代码中,您几乎可以保证始终拥有有效的访问令牌,除非引用令牌连续多次失败。

我不喜欢的是它与 MVC 紧密相关——更具体地说是 Cookies 中间件——所以它不是真正的可移植。

大家可以看看this GitHub repo我整理的。它确实使用了 IdentityModel,因为它处理了所有事情并隐藏了您必须对 IdentityServer 进行的 HTTP 调用的大部分复杂性。

我在 ASP.NET Core 2.0 中创建了一个基于动作过滤器和 OIDC 中间件的解决方案。

AJAX 请求也将通过操作过滤器,因此更新访问 token/refresh 令牌。

https://gist.github.com/devJ0n/43c6888161169e09fec542d2dc12af09