有时候,会注意到在使用某些 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-browserhttps://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession?language=objc

NONCE 模式

参考《webview 复用微信小程序获取用户信息的解决方案 - Jeff Tian的文章 - 知乎 》,可以通过 authCode 方案解决(本质上,这是一个 OAuth 中的一个 NONCE 模式),其时序图如下:
image.png

除了这个方案,也可以利用 OAuth 2.0 中的令牌交换流程来解决这个共享会话的问题。

令牌交换流程

详见: https://datatracker.ietf.org/doc/html/rfc8693,该流程可以使用一个客户端拿到的令牌,去换取另一个客户端的令牌。这一般可以用在委托目的,但不局限于此。比如,还可以用这个流程来改善 WebView 中的单点登录体验。

令牌交换流程在 Duende IdentityServer 中的一个实现

在线体验

令牌交换,需要先得到一个令牌。可以利用昨天的《我又做了一个网页版的设备码授权许可登录流程 - Jeff Tian的文章 - 知乎 》获取一个身份令牌。
image.png

然后,打开 https://id6.azurewebsites.net/token-exchange-flow.html?lang=en-US,将上一步的身份令牌粘贴进去后,点击登录,即可获取到新的访问令牌:
image.png

代码改动

完整的提交见: 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 },
        });
}

}