项目
版本

贡献一个新的 Web 提供商

作为 OpenIddict 4.0 工作的一部分,OpenIddict 中添加了一个新的客户端堆栈。为了简化与提供 OAuth 2.0 和 OpenID Connect 服务的社交和企业提供商的集成,客户端堆栈中添加了一个配套包(名为 OpenIddict.Client.WebIntegration)。尽管它与 现有的 aspnet-contrib OAuth 2.0 提供商 有一些相似之处,但实际上存在重要的技术差异:

  • 与 aspnet-contrib 提供商使用的 ASP.NET Core OAuth 2.0 基础处理器不同,OpenIddict 客户端是一个同时支持 OAuth 2.0 + OpenID Connect 的双协议堆栈,这意味着它可以在执行这两个协议所需的所有安全检查的同时支持它们。
  • 虽然 aspnet-contrib 提供商仅在 ASP.NET Core 上工作,但 OpenIddict 提供商不仅可以在 ASP.NET Core 和 OWIN/ASP.NET 4.x 应用程序中使用,还可以在 Windows 和 Linux 桌面应用程序中使用 ,无需任何特定于平台的代码。
  • 与 aspnet-contrib 提供商不同,用于实现 OpenIddict Web 提供商的源代码是动态创建的,使用了 Roslyn 源生成器一个包含所有受支持提供商的 XML 文件 ,带有正确生成它们所需的配置。通过消除所有管道代码,OpenIddict Web 提供商更易于维护和更新。
  • 为了保证互操作性并做出最佳的安全选择,OpenIddict 客户端大量依赖于服务器配置元数据,这与 ASP.NET Core OAuth 2.0 基础处理器采用的方法不同,后者不支持 OpenID Connect 发现和 OAuth 2.0 授权服务器元数据规范。

由于这些差异,向 OpenIddict 堆栈贡献一个新的提供商与向 aspnet-contrib 添加提供商大不相同

为新提供商添加一个 <Provider> 节点

要添加新的 OpenIddict Web 提供商,第一步是在 OpenIddictClientWebIntegrationProviders.xml 文件中添加一个新的 <Provider> 节点。例如:

<Provider Name="Zendesk" Id="89fdfe22-c796-4227-a44a-d9cd3c467bbb"
          Documentation="https://developer.zendesk.com/documentation/live-chat/getting-started/auth/">
</Provider>

如果可用,必须添加指向官方文档的链接。如果有多种语言可用,应使用以下顺序:

  • 英语
  • 法语
  • 西班牙语
  • 其他任何语言

受支持的环境添加一个 <Environment> 节点

第二步是确定服务是否提供多个环境(例如 生产、测试或开发)。

  • 如果提供商支持多个环境,必须在 <Provider> 下为每个环境添加多个 <Environment> 节点:

    <Provider Name="Salesforce" Id="ce5bc4bc-6133-4e87-85ad-626b3c0a4427">
      <Environment Name="Production" />
    
      <Environment Name="Development" />
    </Provider>
    
  • 如果提供商不支持多环境,则只需添加一个 <Environment>Name 属性应省略):

    <Provider Name="Google" Id="e0e90ce7-adb5-4b05-9f54-594941e5d960">
      <Environment />
    </Provider>
    

为每个环境添加适当的配置

第三步是最复杂的一步:为每个添加的环境添加适当的配置

为此,您必须首先确定环境是否支持 OpenID Connect 发现或 OAuth 2.0 授权服务器元数据。在某些情况下,此信息会在官方文档中提及,但并不总是如此。按照惯例,服务器元数据通常从 https://base address/.well-known/openid-configuration 提供:如果您从此端点获取有效的 JSON 文档,则服务器支持 OpenID Connect/OAuth 2.0 服务器元数据。

服务器提供了配置端点

当服务器支持 OpenID Connect/OAuth 2.0 服务器元数据规范时,在相应的提供商地址(不包含 /.well-known/openid-configuration 部分)对应的元素上添加一个 Issuer 属性。例如,Google 在 https://accounts.google.com/.well-known/openid-configuration 公开了其发现文档,因此要使用的正确发行者(Issuer)应该是 https://accounts.google.com/

<Provider Name="Google" Id="e0e90ce7-adb5-4b05-9f54-594941e5d960">
  <Environment Issuer="https://accounts.google.com/" />
</Provider>

不幸的是,服务器仅仅暴露其元数据并不能保证返回的信息是完整或有效的。因此,必须仔细审查服务器元数据,以确保配置可以直接被 OpenIddict 使用。特别是,需要检查以下几点:

  • 返回的 issuer 节点与访问 /.well-known/openid-configuration 文档所使用的基地址相匹配。如果不匹配,应使用返回的 issuer 作为 Issuer 属性,并指定一个包含服务器元数据位置的 ConfigurationEndpoint 属性:

    <Provider Name="OrangeFrance" DisplayName="Orange France" Id="848d89f4-70e2-4a43-a6e1-d15a0fbedfff"
              Documentation="https://developer.orange.com/apis/authentication-fr/getting-started">
      <Environment Issuer="https://openid.orange.fr/"
                  ConfigurationEndpoint="https://api.orange.com/openidconnect/fr/v1/.well-known/openid-configuration" />
    </Provider>
    
  • 返回的 grant_types_supported 节点包含了授权服务器官方支持的所有授权类型。如果它没有 并且服务器支持授权码流以及其他至少一种授权类型(例如 refresh_token ,那么动态配置就需要在运行时进行修正。为此,请更新位于 OpenIddictClientWebIntegrationHandlers.Discovery.cs 中的 AmendGrantTypes 事件处理器:

    /// <summary>
    /// 包含负责为需要它的提供商修改支持的授权类型逻辑.
    /// </summary>
    public sealed class AmendGrantTypes : IOpenIddictClientHandler<HandleConfigurationResponseContext>
    {
        /// <summary>
        /// 获取分配给此处理器的默认描述符定义.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
                .UseSingletonHandler<AmendGrantTypes>()
                .SetOrder(ExtractGrantTypes.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();
    
        /// <inheritdoc/>
        public ValueTask HandleAsync(HandleConfigurationResponseContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }
    
            // 注意:有些提供商未列出他们所支持的授权类型,这阻止了 OpenIddict 客户端使用它
            // 们(除非默认假定启用了一些类型,如授权码或隐式流)。为了解决这个问题,对于需要
            // 此操作的提供商,支持的授权类型列表会被修改以包含已知的支持类型.
    
            if (context.Registration.ProviderType is
                ProviderTypes.Apple or ProviderTypes.LinkedIn or ProviderTypes.QuickBooksOnline)
            {
                context.Configuration.GrantTypesSupported.Add(GrantTypes.AuthorizationCode);
                context.Configuration.GrantTypesSupported.Add(GrantTypes.RefreshToken);
            }
    
            return default;
        }
    }
    
  • 如果已知提供商支持 OpenID Connect,返回的 scopes_supported 应包含 openid 这一特殊范围。如果它不包含这个范围,动态配置就需要在运行时进行修正。为此,请更新 OpenIddictClientWebIntegrationHandlers.Discovery.cs 中的 AmendScopes 事件处理器:

    /// <summary>
    /// 包含负责为需要此操作的提供商修改支持的范围的逻辑.
    /// </summary>
    public sealed class AmendScopes : IOpenIddictClientHandler<HandleConfigurationResponseContext>
    {
        /// <summary>
        /// 获取分配给此处理器的默认描述符定义.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
                .UseSingletonHandler<AmendScopes>()
                .SetOrder(ExtractScopes.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();
    
        /// <inheritdoc/>
        public ValueTask HandleAsync(HandleConfigurationResponseContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }
    
            // 尽管这是一个推荐的节点,但一些提供商并未在其配置中包含 "scopes_supported",
            // 因此它们被 OpenIddict 客户端视为仅支持 OAuth 2.0 的提供商。为了避免这种
            // 情况,手动添加 "openid" 范围以表明支持 OpenID Connect.
            if (context.Registration.ProviderType is ProviderTypes.EpicGames or ProviderTypes.Xero)
            {
                context.Configuration.ScopesSupported.Add(Scopes.OpenId);
            }
    
            return default;
        }
    }
    

服务器未提供配置端点

当服务器不支持 OpenID Connect/OAuth 2.0 服务器元数据规范时,需添加一个 Issuer 属性(对应于文档中给出的值或服务器的基础地址)以及 一个包含 OpenIddict 客户端与远程授权服务器通信所需静态配置的 <Configuration> 节点。例如:

<Provider Name="Reddit" Id="01ae8033-935c-43b9-8568-eaf4d08c0613">
  <Environment Issuer="https://www.reddit.com/">
    <Configuration AuthorizationEndpoint="https://www.reddit.com/api/v1/authorize"
                   TokenEndpoint="https://www.reddit.com/api/v1/access_token"
                   UserinfoEndpoint="https://oauth.reddit.com/api/v1/me">
      <GrantType Value="authorization_code" />
      <GrantType Value="refresh_token" />
    </Configuration>
  </Environment>
</Provider>

当提供商仅支持授权码流(通常搭配非过期的访问令牌)时,为了清晰起见,应移除 <GrantType> 节点,因为在没有明确指定 <GrantType> 的情况下,默认总是认为支持授权码流:

<Provider Name="Reddit" Id="01ae8033-935c-43b9-8568-eaf4d08c0613">
  <Environment Issuer="https://www.reddit.com/">
    <Configuration AuthorizationEndpoint="https://www.reddit.com/api/v1/authorize"
                   TokenEndpoint="https://www.reddit.com/api/v1/access_token"
                   UserinfoEndpoint="https://oauth.reddit.com/api/v1/me" />
  </Environment>
</Provider>

若已知提供商支持 Proof Key for Code Exchange (PKCE),则必须在 <Configuration> 下添加一个 <CodeChallengeMethod> 节点,以确保 OpenIddict 客户端将发送适当的 code_challenge/code_challenge_method 参数:

<Provider Name="Fitbit" Id="10a558b9-8c81-47cc-8941-e54d0432fd51">
  <Environment Issuer="https://www.fitbit.com/">
    <Configuration AuthorizationEndpoint="https://www.fitbit.com/oauth2/authorize"
                   TokenEndpoint="https://api.fitbit.com/oauth2/token"
                   UserinfoEndpoint="https://api.fitbit.com/1/user/-/profile.json">
      <CodeChallengeMethod Value="S256" />
    </Configuration>
  </Environment>
</Provider>

一些提供商采用了多租户配置,这种配置依赖于子域名、自定义域名或虚拟路径来区分租户实例。如果你要支持的提供商需要在其某个 URI 中添加动态部分,就必须在 <Provider> 下添加一个 <Setting> 节点来存储租户名称。一旦添加,URI 中就可以包含一个指向所需设置属性的占位符:

<Provider Name="Zendesk" Id="89fdfe22-c796-4227-a44a-d9cd3c467bbb">
  <!--
    注意:Zendesk 是一个多租户提供商,依赖子域名来标识实例。因此,以下 URI 都包含一个 {settings.Tenant} 占位符,OpenIddict 在运行时会将其替换为 Zendesk 设置中配置的租户
  -->

  <Environment Issuer="https://{settings.Tenant}.zendesk.com/">
    <Configuration AuthorizationEndpoint="https://{settings.Tenant}.zendesk.com/oauth/authorizations/new"
                   TokenEndpoint="https://{settings.Tenant}.zendesk.com/oauth/tokens"
                   UserinfoEndpoint="https://{settings.Tenant}.zendesk.com/api/v2/users/me" />
  </Environment>

  <Setting PropertyName="Tenant" ParameterName="tenant" Type="String" Required="true"
           Description="The tenant used to identify the Zendesk instance" />
</Provider>

如有必要,展开 userinfo 响应

如果提供商返回封装或嵌套的 userinfo 响应(例如,在 responsedata 节点下),必须更新 OpenIddictClientWebIntegrationHandlers.Userinfo.cs 中的 UnwrapUserinfoResponse 处理器以展开 userinfo 有效负载,并允许 OpenIddict 将它们映射为扁平的 CLR Claim 实例:

/// <summary>
/// 包含负责为需要此操作的提供商从嵌套的 JSON 节点(例如 "data")中提取 userinfo 响应的逻辑.
/// </summary>
public sealed class UnwrapUserinfoResponse : IOpenIddictClientHandler<ExtractUserinfoResponseContext>
{
    /// <summary>
    /// 获取分配给此处理器的默认描述符定义.
    /// </summary>
    public static OpenIddictClientHandlerDescriptor Descriptor { get; }
        = OpenIddictClientHandlerDescriptor.CreateBuilder<ExtractUserinfoResponseContext>()
            .UseSingletonHandler<UnwrapUserinfoResponse>()
            .SetOrder(int.MaxValue - 50_000)
            .SetType(OpenIddictClientHandlerType.BuiltIn)
            .Build();

    /// <inheritdoc/>
    public ValueTask HandleAsync(ExtractUserinfoResponseContext context)
    {
        if (context is null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        context.Response = context.Registration.ProviderType switch
        {
            // Fitbit 返回一个嵌套的 "user" 对象.
            ProviderTypes.Fitbit => new(context.Response["user"]?.GetNamedParameters() ??
                throw new InvalidOperationException(SR.FormatID0334("user"))),

            // StackExchange 返回一个包含单个元素的 "items" 数组.
            ProviderTypes.StackExchange => new(context.Response["items"]?[0]?.GetNamedParameters() ??
                throw new InvalidOperationException(SR.FormatID0334("items/0"))),

            // SubscribeStar 返回一个嵌套的 "user" 对象,该对象自身又嵌套在 GraphQL 的 "data" 节点中.
            ProviderTypes.SubscribeStar => new(context.Response["data"]?["user"]?.GetNamedParameters() ??
                throw new InvalidOperationException(SR.FormatID0334("data/user"))),

            _ => context.Response
        };

        return default;
    }
}

如果不确定提供商是否返回封装的响应,可以在成功完成授权流程后从日志中找到接收到的有效负载信息:

OpenIddict.Client.OpenIddictClientDispatcher: Information: The userinfo response returned by https://contoso.com/users/me was successfully extracted: {
  "data": {
    "username": "odile.donat",
    "name": "Odile Donat",
    "email": "odile.donat@fabrikam.com"
  }
}.

若提供商不支持标准 OpenID Connect userinfo,将提供商特定的声明映射至其 ClaimTypes 等效项

如果提供商不返回 id_token 且不提供标准的 userinfo 端点,那么它很可能使用自定义参数来表示用户标识等信息。此时,需要更新 OpenIddictClientWebIntegrationHandlers.cs 中的 MapCustomWebServicesFederationClaims 事件处理器,将这些参数映射到 .NET BCL(基础类库)中 ClaimTypes 类暴露的常规 WS-Federation 声明,这样做可以简化与诸如 ASP.NET Core Identity 等库的集成:

/// <summary>
/// 该部分包含负责为需要此功能的提供商将特定的自定义声明映射到其 WS-Federation 等效项的逻辑
/// </summary>
public sealed class MapCustomWebServicesFederationClaims : IOpenIddictClientHandler<ProcessAuthenticationContext>
{
    /// <summary>
    /// 获取分配给此处理器的默认描述符定义
    /// </summary>
    public static OpenIddictClientHandlerDescriptor Descriptor { get; }
        = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
            .AddFilter<RequireWebServicesFederationClaimMappingEnabled>()
            .UseSingletonHandler<MapCustomWebServicesFederationClaims>()
            .SetOrder(MapStandardWebServicesFederationClaims.Descriptor.Order + 1_000)
            .SetType(OpenIddictClientHandlerType.BuiltIn)
            .Build();

    /// <inheritdoc/>
    public ValueTask HandleAsync(ProcessAuthenticationContext context)
    {
        if (context is null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        context.MergedPrincipal.SetClaim(ClaimTypes.Email, context.Registration.ProviderType switch
        {
            // 服务通道将用户标识作为自定义的 "Email" 节点返回:
            ProviderTypes.ServiceChannel => (string?) context.UserinfoResponse?["Email"],

            _ => context.MergedPrincipal.GetClaim(ClaimTypes.Email)
        });

        context.MergedPrincipal.SetClaim(ClaimTypes.Name, context.Registration.ProviderType switch
        {
            // 服务通道将用户标识作为自定义的 "UserName" 节点返回:
            ProviderTypes.ServiceChannel => (string?) context.UserinfoResponse?["UserName"],

            _ => context.MergedPrincipal.GetClaim(ClaimTypes.Name)
        });

        context.MergedPrincipal.SetClaim(ClaimTypes.NameIdentifier, context.Registration.ProviderType switch
        {
            // 服务通道将用户标识作为自定义的 "UserId" 节点返回:
            ProviderTypes.ServiceChannel => (string?) context.UserinfoResponse?["UserId"],

            _ => context.MergedPrincipal.GetClaim(ClaimTypes.NameIdentifier)
        });

        return default;
    }
}

测试生成的提供商

如果目标服务完全符合标准,则在此阶段不应再需要其他配置。

为了确认这一点,请构建解决方案,并将新提供商的客户端注册添加到某个沙箱项目的其中之一:

  • 对于 OpenIddict.Sandbox.Console.Client(推荐选项),在 Program.cs 中进行。

  • 对于 OpenIddict.Sandbox.AspNet.Client(ASP.NET 4.8)或 OpenIddict.Sandbox.AspNetCore.Client(ASP.NET Core),在 Startup.cs 中进行。

    // 注册 Web 提供商集成。
    //
    // 注意:为了减轻混合攻击的风险,建议为每个提供商使用唯一的重定向终结点 URI,
    // 除非所有已注册的提供商都支持在授权响应中返回包含其 URL 的特殊 "iss" 参数。
    // 有关更多信息,请参考:
    // see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.4.
    options.UseWebProviders()
          // ... 其它提供商...
          .Add[provider name](options =>
          {
              options.SetClientId("[client identifier]");
              options.SetClientSecret("[client secret]");
              options.SetRedirectUri("callback/login/[provider name]");
    
              // 注意:根据提供商的不同,可能还需要配置其他选项。
          });
    

配置完成后,开始一次身份验证流程以测试提供商集成是否可以正常工作。

除非您同意与 OpenIddict 开发人员共享您的沙箱凭据,否则对沙箱项目所做的更改无需提交并包含在您的拉取请求中。

如有需要,为提供商添加必要的变通方法以确保其正常工作

如果认证过程中出现错误,可能需要对集成采取一个或多个变通措施以确保其正常运行:

  • 提供商可能需要将客户端凭据作为 Authorization 头的一部分使用基本认证(即 client_secret_basic)发送。实施 OpenID Connect 发现或 OAuth 2.0 授权服务器元数据的提供商通常会返回它们支持的客户端认证方法。如果提供商不公开其元数据,则支持的方法必须使用一个或多个 <TokenEndpointAuthMethod> 手动添加到静态配置中:

    <Provider Name="Twitter" Id="1fd20ab5-d3f2-40aa-8c91-094f71652c65">
      <Environment Issuer="https://twitter.com/">
        <Configuration AuthorizationEndpoint="https://twitter.com/i/oauth2/authorize"
                      TokenEndpoint="https://api.twitter.com/2/oauth2/token"
                      UserinfoEndpoint="https://api.twitter.com/2/users/me">
          <CodeChallengeMethod Value="S256" />
    
          <TokenEndpointAuthMethod Value="client_secret_basic" />
        </Configuration>
      </Environment>
    </Provider>
    
  • 提供商可能需要发送一个或多个默认或必需的范围。如果是这样,必须将默认/必需的范围添加到 <Environment> 节点中:

    <Provider Name="Twitter" Id="1fd20ab5-d3f2-40aa-8c91-094f71652c65">
      <Environment Issuer="https://twitter.com/">
        <Configuration AuthorizationEndpoint="https://twitter.com/i/oauth2/authorize"
                      TokenEndpoint="https://api.twitter.com/2/oauth2/token"
                      UserinfoEndpoint="https://api.twitter.com/2/users/me">
          <CodeChallengeMethod Value="S256" />
    
          <TokenEndpointAuthMethod Value="client_secret_basic" />
        </Configuration>
    
        <!--
          注意:Twitter 要求请求 "tweet.read" 和 "users.read" 范围以使 userinfo 端点正常工作。
          因此,这两个范围被标记为必需,即使用户未明确添加,也会始终发送它们.
        -->
    
        <Scope Name="tweet.read" Default="true" Required="true" />
        <Scope Name="users.read" Default="true" Required="true" />
      </Environment>
    </Provider>
    
  • 提供商可能需要使用不同于标准的分隔符发送范围。虽然 OAuth 2.0 规范要求使用空格分隔多个范围,但某些提供商可能要求使用不同的分隔符(通常是逗号)。如果所添加的提供商有此要求,请更新 OpenIddictClientWebIntegrationHandlers.cs 中的 FormatNonStandardScopeParameter 事件处理器以使用提供商所需的正确分隔符。

    /// <summary>
    /// 包含负责覆盖已知使用非标准格式的提供商的默认 "scope" 参数的逻辑.
    /// </summary>
    public class FormatNonStandardScopeParameter : IOpenIddictClientHandler<ProcessChallengeContext>
    {
        /// <summary>
        /// 获取分配给此处理器的默认描述符定义.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
                .AddFilter<RequireInteractiveGrantType>()
                .UseSingletonHandler<FormatNonStandardScopeParameter>()
                .SetOrder(AttachChallengeParameters.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();
    
        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessChallengeContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }
    
            context.Request.Scope = context.Registration.ProviderType switch
            {
                // 以下提供商已知使用逗号分隔的范围,而非标准格式(要求使用空格作为范围分隔符):
                ProviderTypes.Deezer or ProviderTypes.Shopify or ProviderTypes.Strava
                    => string.Join(",", context.Scopes),
    
                // 以下提供商已知使用加号分隔的范围,而非标准格式(要求使用空格作为范围分隔符):
                ProviderTypes.Trovo => string.Join("+", context.Scopes),
    
                _ => context.Request.Scope
            };
    
            return default;
        }
    }
    

如果提供商仍然无法正常工作,很遗憾,可能需要更复杂的变通办法。如果您不熟悉 OpenIddict 事件模型,请在 openiddict-core 存储库中打开一个工单以获得帮助。

openiddict-core 存储库发送拉取请求

一旦确认您的提供商能正常工作,您需要做的就是发送一个 PR,以便将其添加到 openiddict-core 存储库中,并作为下次更新的一部分与已支持的提供商一起发布。

在本文档中