Skip to content
11 changes: 11 additions & 0 deletions src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,16 @@ public static class Claims
/// The user's gender. F: Female; M: Male.
/// </summary>
public const string Gender = "urn:alipay:gender";

/// <summary>
/// OpenID is the unique identifier for Alipay users at the application level.
/// See https://opendocs.alipay.com/mini/0ai2i6
/// </summary>
public const string OpenId = "urn:alipay:open_id";

/// <summary>
/// The internal identifier for Alipay users will no longer be independently available going forward and will be replaced by OpenID.
/// </summary>
public const string UserId = "urn:alipay:user_id";
Comment on lines +35 to +39
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On reflection, if this is going to be deprecated, it would be better not to expose it as a public member. Otherwise it feels like we're adding a new API that's immediately obsolete.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserId cannot be modified to private because applications created before OpenId or legacy code still require it. For newly created applications, OpenId should be used unless a support ticket is submitted to request the platform to enable legacy UserId. I have updated the code and added the ObsoleteAttribute.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a new public API that is immediately obsolete serves no useful purpose to the user and adds maintenance burden for us to remove it in the future (which is then itself a breaking change).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intention was to delete the property entirely, not just to remove the [Obsolete] attribute.

}
}
31 changes: 29 additions & 2 deletions src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ protected override Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
return base.HandleRemoteAuthenticateAsync();
}

private const string SignType = "RSA2";

private async Task AddCertificateSignatureParametersAsync(SortedDictionary<string, string?> parameters)
{
ArgumentNullException.ThrowIfNull(Options.PrivateKey);
ArgumentNullException.ThrowIfNull(Options.ApplicationCertificateSnKeyId);
ArgumentNullException.ThrowIfNull(Options.RootCertificateSnKeyId);

var app_cert_sn = await Options.PrivateKey(Options.ApplicationCertificateSnKeyId, Context.RequestAborted);
var alipay_root_cert_sn = await Options.PrivateKey(Options.RootCertificateSnKeyId, Context.RequestAborted);

parameters["app_cert_sn"] = AlipayCertificationUtil.GetCertSN(app_cert_sn.Span);
parameters["alipay_root_cert_sn"] = AlipayCertificationUtil.GetRootCertSN(alipay_root_cert_sn.Span, SignType);
}

protected override async Task<OAuthTokenResponse> ExchangeCodeAsync([NotNull] OAuthCodeExchangeContext context)
{
// See https://opendocs.alipay.com/apis/api_9/alipay.system.oauth.token for details.
Expand All @@ -55,10 +70,16 @@ protected override async Task<OAuthTokenResponse> ExchangeCodeAsync([NotNull] OA
["format"] = "JSON",
["grant_type"] = "authorization_code",
["method"] = "alipay.system.oauth.token",
["sign_type"] = "RSA2",
["sign_type"] = SignType,
["timestamp"] = TimeProvider.GetUtcNow().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["version"] = "1.0",
};

if (Options.UseCertificateSignatures)
{
await AddCertificateSignatureParametersAsync(tokenRequestParameters);
}

tokenRequestParameters.Add("sign", GetRSA2Signature(tokenRequestParameters));

// PKCE https://tools.ietf.org/html/rfc7636#section-4.5, see BuildChallengeUrl
Expand Down Expand Up @@ -103,10 +124,16 @@ protected override async Task<AuthenticationTicket> CreateTicketAsync(
["charset"] = "utf-8",
["format"] = "JSON",
["method"] = "alipay.user.info.share",
["sign_type"] = "RSA2",
["sign_type"] = SignType,
["timestamp"] = TimeProvider.GetUtcNow().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["version"] = "1.0",
};

if (Options.UseCertificateSignatures)
{
await AddCertificateSignatureParametersAsync(parameters);
}

parameters.Add("sign", GetRSA2Signature(parameters));

var address = QueryHelpers.AddQueryString(Options.UserInformationEndpoint, parameters);
Expand Down
49 changes: 49 additions & 0 deletions src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,54 @@ public AlipayAuthenticationOptions()
ClaimActions.MapJsonKey(Claims.Gender, "gender");
ClaimActions.MapJsonKey(Claims.Nickname, "nick_name");
ClaimActions.MapJsonKey(Claims.Province, "province");
ClaimActions.MapJsonKey(Claims.OpenId, "open_id");
ClaimActions.MapJsonKey(Claims.UserId, "user_id");
}

/// <summary>
/// Gets or sets a value indicating whether to use certificate mode for signing calls.
/// <para>https://opendocs.alipay.com/common/057k53?pathHash=e18d6f77#%E8%AF%81%E4%B9%A6%E6%A8%A1%E5%BC%8F</para>
/// </summary>
public bool UseCertificateSignatures { get; set; }

/// <summary>
/// Gets or sets the optional ID for your Sign in with Application Public Key Certificate SN(app_cert_sn).
/// <para>https://opendocs.alipay.com/support/01raux</para>
/// </summary>
public string? ApplicationCertificateSnKeyId { get; set; }

/// <summary>
/// Gets or sets the optional ID for your Sign in with Alipay Root Certificate SN.
/// <para>https://opendocs.alipay.com/support/01rauy</para>
/// </summary>
public string? RootCertificateSnKeyId { get; set; }

/// <summary>
/// Gets or sets an optional delegate to get the client's private key which is passed
/// the value of the <see cref="ApplicationCertificateSnKeyId"/> or <see cref="RootCertificateSnKeyId"/> property and the <see cref="CancellationToken"/>
/// associated with the current HTTP request.
/// </summary>
/// <remarks>
/// The private key should be in PKCS #8 (<c>.p8</c>) format.
/// </remarks>
public Func<string, CancellationToken, Task<ReadOnlyMemory<char>>>? PrivateKey { get; set; }

/// <inheritdoc />
public override void Validate()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this also validate that PublicKey is not null?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
base.Validate();

if (UseCertificateSignatures)
{
if (string.IsNullOrEmpty(ApplicationCertificateSnKeyId))
{
throw new ArgumentException($"The '{nameof(ApplicationCertificateSnKeyId)}' option must be provided if the '{nameof(UseCertificateSignatures)}' option is set to true.", nameof(ApplicationCertificateSnKeyId));
}

if (string.IsNullOrEmpty(RootCertificateSnKeyId))
{
throw new ArgumentException($"The '{nameof(RootCertificateSnKeyId)}' option must be provided if the '{nameof(UseCertificateSignatures)}' option is set to true.", nameof(RootCertificateSnKeyId));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
* for more information concerning the license and the contributors participating to this project.
*/

using AspNet.Security.OAuth.Alipay;
using Microsoft.Extensions.FileProviders;

namespace Microsoft.Extensions.DependencyInjection;

/// <summary>
/// Extension methods to configure Sign in with Alipay authentication capabilities for an HTTP application pipeline.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it actually called "Sign in with Alipay", or is this just copy-paste where "Apple" has been changed to "Alipay"?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it's called "Sign In Alipay", not "Sign In with Alipay"?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be “Sign in with Alipay” because, like Apple, it uses a third-party platform account for login. This will redirect you to an Alipay webpage displaying a QR code. Use the Alipay mobile app to scan the code and log in.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Sign In with Apple" is the name of the product (docs), "Sign in with Apple" is the description of what the user does.

"Sign In Alipay" appears to be the name, as it is not grammatically correct English to be a description of what the user is doing.

/// </summary>
public static class AlipayAuthenticationOptionsExtensions
{
/// <summary>
/// Configures the application to use a specified private key to generate a client secret for the provider.
/// </summary>
/// <param name="options">The Apple authentication options to configure.</param>
/// <param name="privateKeyFile">
/// A delegate to a method to return the <see cref="IFileInfo"/> for the private
/// key which is passed the value of <see cref="AlipayAuthenticationOptions.ApplicationCertificateSnKeyId"/> or <see cref="AlipayAuthenticationOptions.RootCertificateSnKeyId"/>.
/// </param>
/// <returns>
/// The value of the <paramref name="options"/> argument.
/// </returns>
public static AlipayAuthenticationOptions UsePrivateKey(
[NotNull] this AlipayAuthenticationOptions options,
[NotNull] Func<string, IFileInfo> privateKeyFile)
{
options.UseCertificateSignatures = true;
options.PrivateKey = async (keyId, cancellationToken) =>
{
var fileInfo = privateKeyFile(keyId);

using var stream = fileInfo.CreateReadStream();
using var reader = new StreamReader(stream);

return (await reader.ReadToEndAsync(cancellationToken)).AsMemory();
};

return options;
}
}
105 changes: 105 additions & 0 deletions src/AspNet.Security.OAuth.Alipay/AlipayCertificationUtil.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
* for more information concerning the license and the contributors participating to this project.
*/

using System.Buffers;
using System.Globalization;
using System.Numerics;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;

namespace AspNet.Security.OAuth.Alipay;

/// <summary>
/// https://github.com/alipay/alipay-sdk-net-all/blob/b482d75d322e740760f9230d2a3859090af642a7/v2/AlipaySDKNet.Standard/Util/AntCertificationUtil.cs
/// </summary>
internal static class AlipayCertificationUtil
{
public static string GetCertSN(ReadOnlySpan<char> certContent)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What actually is the return value? SN suggests a serial number, but it actually appears to be the MD5 hash of the serial number.

Copy link
Author

@AigioL AigioL Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SN is not merely a serial number, but rather the MD5 value of Issuer + SerialNumber or Reverse. The final output is a lowercase hex MD5 string. Official documentation provides no additional explanation; the naming convention SN in Java code originates from the official C# code, hence the retention of the original name.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the name is wrong then, because it doesn't reflect what it is. I don't think the original Alipay code is particularly good C#, so saying "the official code is like that" is not a justification to push back on any of the comments I'm giving on the code here, as ultimately if this PR is accepted and merged it becomes ours to maintain.

{
using var cert = X509Certificate2.CreateFromPem(certContent);
return GetCertSN(cert);
}

public static string GetCertSN(X509Certificate2 cert)
{
var issuerDN = cert.Issuer.Replace(", ", ",", StringComparison.InvariantCulture);
var serialNumber = new BigInteger(cert.GetSerialNumber()).ToString(CultureInfo.InvariantCulture);

if (issuerDN.StartsWith("CN", StringComparison.InvariantCulture))
{
return CalculateMd5(issuerDN + serialNumber);
}

var attributes = issuerDN.Split(',');
Array.Reverse(attributes);
return CalculateMd5(string.Join(',', attributes) + serialNumber);
}

public static string GetRootCertSN(ReadOnlySpan<char> rootCertContent, string signType = "RSA2")
{
var rootCertSN = string.Join('_', GetRootCertSNCore(rootCertContent, signType));
return rootCertSN;
}

private static IEnumerable<string> GetRootCertSNCore(X509Certificate2Collection x509Certificates, string signType)
{
foreach (X509Certificate2 cert in x509Certificates)
{
var signatureAlgorithm = cert.SignatureAlgorithm.Value;
if (signatureAlgorithm != null)
{
if ((signType.StartsWith("RSA", StringComparison.InvariantCultureIgnoreCase) &&
signatureAlgorithm.StartsWith("1.2.840.113549.1.1", StringComparison.InvariantCultureIgnoreCase)) ||
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on this search, "1.2.840.113549.1.1." seems more correct?

Otherwise would it pick up values in 11-19, 100-199, 1000-1999 etc. that it shouldn't?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That just sounds to me like both their C# and Java implementations are wrong and over-eagerly matching.

(signType.StartsWith("SM2", StringComparison.InvariantCultureIgnoreCase) &&
signatureAlgorithm.StartsWith("1.2.156.10197.1.501", StringComparison.InvariantCultureIgnoreCase)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, "1.2.156.10197.1.501." for a value from the "SM2 Signing with SM3" family?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the certificate signature algorithm OID 1.2.156.10197.1.501 indicates that this certificate uses the SM2 signature algorithm based on SM3. SM2 certificate data, similar to RSA algorithm certificates, includes the certificate version, serial number, issuer, subject information, subject public key, validity period, certificate extensions, etc. However, the public key algorithm for SM2 certificates uses the OID identifier for the ECC algorithm, while the public key parameters use the OID identifier for the SM2 national cryptographic algorithm.

{
yield return GetCertSN(cert);
}
}
}
}

private static IEnumerable<string> GetRootCertSNCore(ReadOnlySpan<char> rootCertContent, string signType)
{
X509Certificate2Collection x509Certificates = [];
x509Certificates.ImportFromPem(rootCertContent);
return GetRootCertSNCore(x509Certificates, signType);
}

/// <summary>
/// https://github.com/dotnet/runtime/blob/v9.0.8/src/libraries/System.Text.Json/Common/JsonConstants.cs#L12
/// </summary>
private const int StackallocByteThreshold = 256;

private static string CalculateMd5(ReadOnlySpan<char> chars)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is still overcomplicated really. This isn't a hot path.

The whole thing could just be:

var buffer = Encoding.UTF8.GetBytes(chars);
return MD5.HashData(buffer);

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have balanced high performance with code conciseness. The return value here should be a lowercase hex string. The updated code uses stackalloc instead of byte[] to avoid fragmented memory.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically yes, but I don't think it's of importance here against a simpler implementation.

Copy link
Author

@AigioL AigioL Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest submission has been simplified.

private static string CalculateMd5(string s)
{
var buffer = Encoding.UTF8.GetBytes(s);
Span<byte> hash = stackalloc byte[MD5.HashSizeInBytes];
#pragma warning disable CA5351
MD5.HashData(buffer, hash);
#pragma warning restore CA5351
return Convert.ToHexStringLower(hash);
}

{
var lenU8 = Encoding.UTF8.GetMaxByteCount(chars.Length);
byte[]? array = null;
Span<byte> bytes = lenU8 <= StackallocByteThreshold ?
stackalloc byte[StackallocByteThreshold] :
(array = ArrayPool<byte>.Shared.Rent(lenU8));
try
{
Encoding.UTF8.TryGetBytes(chars, bytes, out var bytesWritten);
bytes = bytes[..bytesWritten];

Span<byte> hash = stackalloc byte[MD5.HashSizeInBytes];
#pragma warning disable CA5351
MD5.HashData(bytes, hash);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return value should be checked.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MD5 algorithm has a fixed length, so there is no need to check the returned result. If the input array is too short, it will throw an exception.

#pragma warning restore CA5351

return Convert.ToHexStringLower(hash);
}
finally
{
if (array != null)
{
ArrayPool<byte>.Shared.Return(array);
}
}
}
}