有时候,会注意到在使用某些 App 时,明明已经登录了,但是打开了某个页面时,却要求再登录一次。这其实是因为该页面是一个 Web View。甚至,有时候会遇到明明在这个 Web View 里已经再次登录了,下次打开另一个页面(另一个 Web View)时,又要求登录。这是由于在手机上,使用了某些 Web View(如 WKWebView),而这个 WebView 在登录后,其登录信息保存在 ASWebAuthenticationSession 中,它既不能在 App 和 WebView 之间共享,也不能在多个不同的 WebView 实例间共享。更多信息,可以参考: https://learn.microsoft.com/en-us/azure/active-directory/develop/customize-webviews#in-app-browser 和 https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession?language=objc。
NONCE 模式
参考《<font style=color:rgb(18, 18, 18);>webview 复用微信小程序获取用户信息的解决方案 - Jeff Tian的文章 - 知乎 》,可以通过 authCode 方案解决(本质上,这是一个 OAuth 中的一个 NONCE 模式),其时序图如下:
除了这个方案,也可以利用 OAuth 2.0 中的令牌交换流程来解决这个共享会话的问题。
令牌交换流程
详见: https://datatracker.ietf.org/doc/html/rfc8693,该流程可以使用一个客户端拿到的令牌,去换取另一个客户端的令牌。这一般可以用在委托目的,但不局限于此。比如,还可以用这个流程来改善 WebView 中的单点登录体验。
令牌交换流程在 Duende IdentityServer 中的一个实现
在线体验
令牌交换,需要先得到一个令牌。可以利用昨天的《<font style=color:rgb(18, 18, 18);>我又做了一个网页版的设备码授权许可登录流程 - Jeff Tian的文章 - 知乎 》获取一个身份令牌。
然后,打开 https://id6.azurewebsites.net/token-exchange-flow.html?lang=en-US,将上一步的身份令牌粘贴进去后,点击登录,即可获取到新的访问令牌:
代码改动
完整的提交见: https://github.com/Jeff-Tian/IdentityServer/commit/aef89c5fe14527390753e68daa81783ac86d6db5
主要的改动是增加了这个文件: hosts/main/Validators/TokenExchangeGrantValidator.cs,并且在依赖注入中将其注入。
csharp using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using Duende.IdentityServer.Models; using Duende.IdentityServer.Validation; using IdentityModel;
namespace IdentityServerHost.Validators;
public class TokenExchangeGrantValidator : IExtensionGrantValidator { private readonly ITokenValidator _validator;
public TokenExchangeGrantValidator(ITokenValidator validator)
{
_validator = validator;
}
public string GrantType => OidcConstants.GrantTypes.TokenExchange;
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest);
var subjectToken = context.Request.Raw.Get(OidcConstants.TokenRequest.SubjectToken);
var subjectTokenType = context.Request.Raw.Get(OidcConstants.TokenRequest.SubjectTokenType);
if (string.IsNullOrWhiteSpace(subjectToken))
{
await Console.Error.WriteLineAsync(subject_token is missing);
return;
}
if (!string.Equals(subjectTokenType, OidcConstants.TokenTypeIdentifiers.AccessToken,
StringComparison.OrdinalIgnoreCase))
{
await Console.Error.WriteLineAsync(subject_token_type is not access_token);
return;
}
var validationResult = await _validator.ValidateIdentityTokenAsync(subjectToken);
if (validationResult.IsError)
{
await Console.Error.WriteLineAsync($subject_token is invalid: {subjectToken});
await Console.Error.WriteLineAsync(validationResult.Error);
return;
}
await Console.Error.WriteLineAsync(JsonSerializer.Serialize(validationResult.Claims, new JsonSerializerOptions()
{
ReferenceHandler = ReferenceHandler.Preserve
}));
var sub = validationResult.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Subject)?.Value ?? unknown-sub;
var clientId = validationResult.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.ClientId)?.Value ??
validationResult.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Audience)?.Value ??
unknown-client;
context.Request.ClientId = clientId;
context.Result = new GrantValidationResult(sub, GrantType, validationResult.Claims, clientId,
new Dictionary<string, object>
{
{ OidcConstants.TokenResponse.IssuedTokenType, OidcConstants.TokenTypeIdentifiers.AccessToken },
});
}
}