OAuth 2.0 的授权码许可流程,我自认为已经对它了如指掌了。不就是几个跳转流程嘛:要登录一个应用,先跳转到授权服务,展示一个登录界面。用户输入凭据后,拿到授权码返回到应用前端。应用服务从其前端的 url 上的查询字符串里获取到授权码,结合预先申请的客户端凭据向授权服务器换取用户的令牌。总之,其序列图如下: 在实际情况下,上图的第三方应用包含了前端应用和后端应用。前端应用和用户打交道,而像换取令牌等步骤应该在后端完成以保证不泄露应用秘钥。不过,对于授权码流程来说,这个第三方应用就是授权服务器的客户端,在整个流程里,我们先将它做为一个整体来看待。

画出这样的序列图,仍然是在纸上谈兵。能画出序列图,和能写出代码,是两回事。今天,我来写段代码,以让自己真的相信自己是完全搞懂了这个流程。其实之前已经写了大量的文章和示例应用来对接标准的 OAuth 2.0 授权服务器,虽然其示例都已经在线上运行,但或多或少利用了标准的 OAuth 2.0 客户端,自己只是做了一些配置而已。今天,想裸写一个客户端来和 OAuth 2.0 授权服务器打交道。

另外,它不再是一个线上运行着的示例,而是一个可以重复运行的自动化测试用例。这样,也可以做为一个安全栅栏,方便自己后续迭代 OAuth 2.0 授权服务器时,不会带来功能性的破坏。

授权服务器的选择

这一次,仍然针对的是 Duende IdentityServer。对于 Keycloak 的授权码流程详解,之前写过一篇文章《Keycloak 使用授权码换取令牌过程详解 - Jeff Tian的文章 - 知乎 》介绍。

针对 Duende IdentityServer 的挑战性在于,我对 ASP.NET 以及 C# 比较生疏,要写一个自动化的客户端测试,更加不知如何下手。好在有 ChatGPT 帮忙,虽然最终仍然费了九牛二虎之力,但是终于完成了!要是没有 ChatGPT,我可能需要一整年的时间才能完成(调研、学习、上手练)。不过,仅仅是可以跑起来了,代码质量并不算高(一个很长的方法)。但是,这是一个非常大的里程碑了,后面将代码重构得更好,只是锦上添花的事情了。

最终代码提交

https://github.com/Jeff-Tian/IdentityServer/commit/31e35dd0526ad887aa7f3eec899990ae737fed9d

添加测试工程

要添加的自动化测试,会调用 Host.Main 下运行的 Web 服务,所以需要引用 Host.Main.csproj 工程。由于是测试工程,需要引用 Microsoft.NET.Test.Sdk。总之,最后的测试工程描述文件如下:

xml

<PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>

    <IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
    <PackageReference Include=Microsoft.NET.Test.Sdk Version=17.1.0 />
    <PackageReference Include=NUnit Version=3.13.3 />
    <PackageReference Include=NUnit3TestAdapter Version=4.2.1 />
    <PackageReference Include=NUnit.Analyzers Version=3.3.0 />
    <PackageReference Include=coverlet.collector Version=3.1.2 />
    <PackageReference Include=xunit.assert Version=2.4.2 />
    <PackageReference Include=xunit.extensibility.core Version=2.4.2 />
    <PackageReference Include=xunit.extensibility.execution Version=2.4.2 />
    <PackageReference Include=xunit.runner.visualstudio Version=2.4.5>
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
</ItemGroup>

<ItemGroup>
  <ProjectReference Include=....hostsAspNetIdentityHost.AspNetIdentity.csproj />
  <ProjectReference Include=....hostsmainHost.Main.csproj />
</ItemGroup>

添加测试类

这个测试会启动 Host.Main 主工程的 Web 服务,因此测试类需要实现 IClassFixture 接口,一般单元测试是不用这样的。

csharp public class IdTokenTest : IClassFixture<WebApplicationFactory> { private readonly ITestOutputHelper _testOutputHelper; private readonly HttpClient _client;

public IdTokenTest(WebApplicationFactory<Program> factory, ITestOutputHelper testOutputHelper)    {
    _testOutputHelper = testOutputHelper;
    _client = _factory.CreateClient(new WebApplicationFactoryClientOptions
    {
        AllowAutoRedirect = false,
    });
}

}

其中 Program 类,是主工程启动 Web 服务的入口类。由于 dotnet 允许入口文件不写类,所以如果你的入口文件可能是没有类的。但是如果要能够允许测试工程启动,就必须定义一个类,哪怕是一个空的 partial 类:

csharp public partial class Program {}

另外,注意在实例化 _client 时,一定将选项中的 AllowAutoRedirect 设置为 false。它默认为 true,会自动跟踪 302 页面跳转。在很多时候,这会让我们更省心,少写很多代码。但是我们的测试中会涉及很多跳转,特别是将授权码回传给第三方应用时,需要拦截 url,并从查询字符串中提取这个授权码,所以一定不能使用默认的跟踪 302 页面跳转功能!这一点,卡了我很久很久……
image.png
image.png
image.png
image.png

配置发现

自动从配置接口获取后面需要用到的 API 端点。 csharp var response = await _client.GetAsync(/.well-known/openid-configuration); response.EnsureSuccessStatusCode(); json.TryGetValue(token_endpoint, out _tokenEndpoint); Assert.Equal(https://localhost/connect/token, _tokenEndpoint.ToString()); var userInfoEndpoint = json[userinfo_endpoint]; Assert.Equal(https://localhost/connect/userinfo, userInfoEndpoint.ToString());

登录授权

这一步特别难,因为需要用户输入用户名密码,也就是需要将页面解析出来,并使用原始 HTTP 请求来模拟表单提交。 csharp const string redirectUrl = http://localhost:3000/api/auth/callback/id6;

    var requestUri =
        ${_authorizeEndpoint}?client_id=inversify&response_type=code&scope=openid%20profile&redirect_uri={redirectUrl}&state=123&nonce=xyz;
    
    var authPage =
        await _client.GetAsync(
            requestUri);
    authPage.Headers.Location.Should().Be(
        https://localhost/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3Dinversify%26response_type%3Dcode%26scope%3Dopenid%2520profile%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A3000%252Fapi%252Fauth%252Fcallback%252Fid6%26state%3D123%26nonce%3Dxyz);

    var redirectedAuthPage = await _client.GetAsync(authPage.Headers.Location);
    var authPageContent = await redirectedAuthPage.Content.ReadAsStringAsync();

    var doc = new HtmlDocument();
    doc.LoadHtml(authPageContent);

    var loginUrl = doc.DocumentNode.Descendants(form)
        .FirstOrDefault()?.GetAttributeValue(action, );
    loginUrl = $http://localhost{loginUrl};

    if (string.IsNullOrEmpty(loginUrl))
    {
        throw new InvalidOperationException(Cannot find login URL.);
    }

    var inputs = doc.DocumentNode.Descendants(input)
        .Where(i => i.GetAttributeValue(type, ).Equals(text) ||
                    i.GetAttributeValue(type, ).Equals(password))
        .ToDictionary(i => i.GetAttributeValue(name, ), i => i.GetAttributeValue(value, ));
    inputs[Input.Username] = alice;
    inputs[Input.Password] = alice;
    inputs[Input.Button] = login;

    var requestVerificationTokenInput = doc.DocumentNode.Descendants(input)
        .FirstOrDefault(i => i.GetAttributeValue(name, ).Equals(__RequestVerificationToken));

    if (requestVerificationTokenInput != null)
    {
        inputs[__RequestVerificationToken] = requestVerificationTokenInput.GetAttributeValue(value, );
    }

    var returnUrlInput = doc.DocumentNode.Descendants(input)
        .FirstOrDefault(i => i.GetAttributeValue(name, ).Equals(Input.ReturnUrl));

    if (returnUrlInput is not null)
    {
        var returnUrl = WebUtility.HtmlDecode(returnUrlInput.GetAttributeValue(value, ))
            .Replace( , %20);
        // returnUrl = Uri.EscapeDataString(returnUrl);

        // Assert.Equal(
        // %2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3Dinversify%26response_type%3Dcode%26scope%3Dopenid%2520profile%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A3000%252Fapi%252Fauth%252Fcallback%252Fid6%26state%3D123%26nonce%3Dxyz,
        // returnUrl);
        inputs[Input.ReturnUrl] = returnUrl;
    }

    var loginRequest = new HttpRequestMessage(HttpMethod.Post, loginUrl)
    {
        Content = new FormUrlEncodedContent(inputs),
    };

    var loginResponse = await _client.SendAsync(loginRequest);
    Assert.Equal(HttpStatusCode.Redirect, loginResponse.StatusCode);
    var cookie = loginResponse.Headers.GetValues(Set-Cookie);
    var cookieString = string.Join(;, cookie);
    _testOutputHelper.WriteLine(cookieString);

首先,由于禁用了自动跟踪 302 跳转,这里不得不手动从响应头的 Location 字段获取跳转后的 URL 并再次请求该 URL;由于请求该 URL 后是一个需要用户交互的登录页面,所以接着使用了 HtmlDocument 去加载这个页面;然后要查询表单中的输入框以设置测试用户名和密码。不仅如此,还得寻找隐藏字段,并记录下来,在后面的表单提交中发送,不然的话,会得到 400 Bad Request。

在后面的表单提交之后,**还得再次手动从响应头中的获取 Set-Cookie 字段的值,并保存下来,并在后面请求令牌时带上,不然会再次被跳转到登录页!**要不是写了这个测试,我还真不知道这一点。

处理回调以及提取授权码

登录成功后,登录响应不仅设置了 Cookie,还将授权码带在了跳转目标的 URL 上。在保存了 Cookie 之后,还得从登录响应头中的 Location 字段里提取授权码,虽然简单,但是琐碎,好在 ChatGPT 在这一点上,写的代码完全不用修改: csharp

    var callbackRequest = new HttpRequestMessage(HttpMethod.Get, loginResponse.Headers.Location);
    callbackRequest.Headers.Add(Cookie, cookieString);
    var callbackResponse = await _client.SendAsync(callbackRequest);
    Assert.Equal(HttpStatusCode.Redirect, callbackResponse.StatusCode);
    Assert.Contains(redirectUrl, callbackResponse.Headers.Location?.ToString());

    var loginContent = callbackResponse.Headers.Location?.ToString();

    var start = loginContent.IndexOf(code=, StringComparison.Ordinal) + 5;
    var end = loginContent.IndexOf(&, start, StringComparison.Ordinal);
    var code = loginContent.Substring(start, end - start);
    _testOutputHelper.WriteLine(code);

请求令牌

经过了上面的复杂过程,这一步就相对简单得多: csharp var tokenResponse = await _client.RequestAuthorizationCodeTokenAsync(new AuthorizationCodeTokenRequest { Address = _tokenEndpoint.ToString(), ClientId = inversify, ClientSecret = id6_secret, RedirectUri = redirectUrl, Code = code }); tokenResponse.AccessToken.Should().NotBeNullOrWhiteSpace(); tokenResponse.IdentityToken.Should().NotBeNullOrWhiteSpace();

请求用户信息

前面的测试,覆盖了 OAuth 2.0 的授权码协议部分。我们顺便再验证一下用户信息端点,验证 OIDC 能力。 csharp

    var userInfoResponse = await _client.GetUserInfoAsync(new UserInfoRequest
    {
        Address = userInfoEndpoint.ToString(),
        Token = tokenResponse.AccessToken
    });

    _testOutputHelper.WriteLine(userInfoResponse.Json.ToString());
    userInfoResponse.Json.ToString().Should().Be(

{name:Alice Smith,given_name:Alice,family_name:Smith,website:http://alice.com,sub:1} );

总结

通过这个测试,不仅更加理解了整个授权码流程(比如 Cookie 的重要性!),还学会了如何写 dotnet Web 服务的集成测试。整个授权码流程中,主要有以下几个关键 API 端点被使用到了(之前各种对接使用的客户端,内置了对它们的发现和调用):

发现端点

一般标准的 OpenID Connect 协议的实现者,都会在 .well-known/openid-configuration 端点公开配置信息。比如 https://id6.azurewebsites.net/.well-known/openid-configuration 的响应如下: json { issuer: https://id6.azurewebsites.net, jwks_uri: https://id6.azurewebsites.net/.well-known/openid-configuration/jwks, authorization_endpoint: https://id6.azurewebsites.net/connect/authorize, token_endpoint: https://id6.azurewebsites.net/connect/token, userinfo_endpoint: https://id6.azurewebsites.net/connect/userinfo, end_session_endpoint: https://id6.azurewebsites.net/connect/endsession, check_session_iframe: https://id6.azurewebsites.net/connect/checksession, revocation_endpoint: https://id6.azurewebsites.net/connect/revocation, introspection_endpoint: https://id6.azurewebsites.net/connect/introspect, device_authorization_endpoint: https://id6.azurewebsites.net/connect/deviceauthorization, backchannel_authentication_endpoint: https://id6.azurewebsites.net/connect/ciba, frontchannel_logout_supported: true, frontchannel_logout_session_supported: true, backchannel_logout_supported: true, backchannel_logout_session_supported: true, scopes_supported: [ openid, profile, email, custom.profile, IdentityServerApi, resource1.scope1, resource1.scope2, resource2.scope1, resource2.scope2, resource3.scope1, resource3.scope2, scope3, scope4, shared.scope, transaction, offline_access ], claims_supported: [ sub, name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, updated_at, email, email_verified, location, address ], grant_types_supported: [ authorization_code, client_credentials, refresh_token, implicit, password, urn:ietf:params:oauth:grant-type:device_code, urn:openid:params:grant-type:ciba, custom, custom.nosubject ], response_types_supported: [ code, token, id_token, id_token token, code id_token, code token, code id_token token ], response_modes_supported: [ form_post, query, fragment ], token_endpoint_auth_methods_supported: [ client_secret_basic, client_secret_post, private_key_jwt ], id_token_signing_alg_values_supported: [ RS256 ], subject_types_supported: [ public ], code_challenge_methods_supported: [ plain, S256 ], request_parameter_supported: true, request_object_signing_alg_values_supported: [ RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, HS256, HS384, HS512 ], request_uri_parameter_supported: true, authorization_response_iss_parameter_supported: true, backchannel_token_delivery_modes_supported: [ poll ], backchannel_user_code_parameter_supported: true }

授权端点

这个页面会重定向至让用户输入凭据的页面,当用户输入正确的凭据后,授权服务会再次重定向回第三方应用:
image.png

调用这个端点时,需要传入 client_id 以及其他参数,比如:

json https://authorization.server/connect/authorize?client_id=client_id&response_type=code&scope=openid%20profile%20email&redirect_uri=redirect_uri&state=123&nonce=xyz

用户登录并授权之后,授权码会以 URL 查询字符串形式传回第三方应用:

json https://your.application.url/redirect_uri?code=8FCF7967CCAF7180156C4893A46A3C4D904C717E998FF52FF82E9C00ED16125F-1&scope=openid%20profile%20email&state=123&session_state=-A4y9wa8gx4btgTrVj3HCI6OhURL4PsiZBcOLYmx7Yk.3B809B07D68954DC3036C9D015E33C03&iss=https://authorization.server

令牌端点

第三方应用使用该端点来用授权码交换令牌:
image.png

用户信息端点

有前面的端点,服务就是实现了 OAuth 2.0 的协议。而实现这个端点,就是提供了 OIDC 的能力了。通过它,第三方应用可以用令牌换取到用户的信息:
image.png

彩蛋

写完这个测试,我顺便还研究了一下更多的相关的内容。

结束会话(退出登录)端点

前面都是用户会话的建立过程,对于结束会话(退出登录),第三方应用也需要和授权服务打交道(通知授权服务)。不然,仅仅应用内退出登录,是无效的。因为用户只要一点击登录,不需要输入凭据,就会自动进入登录状态。
image.png

当应用退出登录时,需要将用户重定向至结束会话端点,并传入用户的身份令牌以及登录完成后的跳转链接。比如:

json https://authorization.server/connect/endsession?id_token_hint=${id_token}&post_logout_redirect_uri=${encodeURIComponent(application url)}

这样,用户在退出登录时会看到这样的页面:
image.png

排障指引

为什么获取到的用户信息中,没有邮箱?

image.png
检查一下在调用授权端点时,有没有传入 email 这个 scope?如果这一步没有传 email,而仅在之后的获取用户令牌以及获取用户信息时,传入 email scope 的话,是拿不到用户的 email 的。因为在之前的授权步骤,用户没有机会授权给你 email 。

为什么请求令牌接口没有返回身份令牌?

image.png
原因同上。

检查一下在调用授权端点时,有没有传入 openid 这个 scope?如果这一步没有传 openid,而仅在之后的获取用户令牌时,传入 openid scope 的话,是拿不到用户的 id_token 的。因为在之前的授权步骤,用户没有机会授权给你 id_token。

总之,要获取用户的哪部分信息,就得让用户授权相应的 scope。一般常用的 scope 是 openid profile email。

为什么不能退出登录?

检查应用的退出登录,是否对接了结束会话这个端点。