174 lines
7.6 KiB
C#
174 lines
7.6 KiB
C#
using Microsoft.IdentityModel.Protocols;
|
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using QuestionsApi.Models;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
public interface IExternalAuthProvider
|
|
{
|
|
string Id { get; }
|
|
string DisplayName { get; }
|
|
bool IsConfigured { get; }
|
|
string CallbackUrl { get; }
|
|
string BuildAuthorizationUrl(ExternalAuthTransaction transaction);
|
|
Task<ExternalProviderClaims?> VerifyCallbackAsync(
|
|
IQueryCollection query,
|
|
ExternalAuthTransaction transaction,
|
|
CancellationToken cancellationToken);
|
|
}
|
|
|
|
public sealed class SteamExternalAuthProvider(
|
|
SteamOpenIdService openId,
|
|
IConfiguration configuration) : IExternalAuthProvider
|
|
{
|
|
public string Id => ExternalAuthProviders.Steam;
|
|
public string DisplayName => "Steam";
|
|
public bool IsConfigured => true;
|
|
public string CallbackUrl => configuration["ExternalAuth:SteamReturnUrl"] ??
|
|
$"{Realm()}/api/Auth/external/steam/callback";
|
|
|
|
public string BuildAuthorizationUrl(ExternalAuthTransaction transaction) =>
|
|
SteamOpenIdService.BuildAuthenticationUrl(Realm(), CallbackUrlWithTransaction(transaction.Id));
|
|
|
|
public async Task<ExternalProviderClaims?> VerifyCallbackAsync(
|
|
IQueryCollection query,
|
|
ExternalAuthTransaction transaction,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var subject = await openId.VerifyCallbackAsync(
|
|
query, CallbackUrlWithTransaction(transaction.Id), cancellationToken);
|
|
return subject is null ? null : new ExternalProviderClaims(subject, null, false);
|
|
}
|
|
|
|
private string Realm() =>
|
|
(configuration["Steam:Realm"] ?? "https://s3q.ranaze.com").TrimEnd('/');
|
|
|
|
private string CallbackUrlWithTransaction(string transactionId) =>
|
|
$"{CallbackUrl}?transactionId={Uri.EscapeDataString(transactionId)}";
|
|
}
|
|
|
|
public sealed class GoogleExternalAuthProvider : IExternalAuthProvider
|
|
{
|
|
private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
|
|
private const string DiscoveryEndpoint = "https://accounts.google.com/.well-known/openid-configuration";
|
|
private readonly IHttpClientFactory _httpClients;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly IConfigurationManager<OpenIdConnectConfiguration> _discovery;
|
|
|
|
public GoogleExternalAuthProvider(IHttpClientFactory httpClients, IConfiguration configuration)
|
|
{
|
|
_httpClients = httpClients;
|
|
_configuration = configuration;
|
|
_discovery = new ConfigurationManager<OpenIdConnectConfiguration>(
|
|
DiscoveryEndpoint, new OpenIdConnectConfigurationRetriever());
|
|
}
|
|
|
|
public string Id => ExternalAuthProviders.Google;
|
|
public string DisplayName => "Google";
|
|
public string CallbackUrl => _configuration["Google:ReturnUrl"] ?? "";
|
|
public bool IsConfigured =>
|
|
!string.IsNullOrWhiteSpace(ClientId) &&
|
|
!string.IsNullOrWhiteSpace(ClientSecret) &&
|
|
Uri.TryCreate(CallbackUrl, UriKind.Absolute, out var callback) && callback.Scheme == Uri.UriSchemeHttps;
|
|
|
|
private string ClientId => _configuration["Google:ClientId"] ?? "";
|
|
private string ClientSecret => _configuration["Google:ClientSecret"] ?? "";
|
|
|
|
public string BuildAuthorizationUrl(ExternalAuthTransaction transaction)
|
|
{
|
|
var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(transaction.CodeVerifier ?? "")));
|
|
return AuthorizationEndpoint + "?" + Query(new Dictionary<string, string>
|
|
{
|
|
["client_id"] = ClientId,
|
|
["redirect_uri"] = CallbackUrl,
|
|
["response_type"] = "code",
|
|
["scope"] = "openid email profile",
|
|
["state"] = transaction.Id,
|
|
["nonce"] = transaction.Nonce ?? "",
|
|
["code_challenge"] = challenge,
|
|
["code_challenge_method"] = "S256",
|
|
["prompt"] = "select_account"
|
|
});
|
|
}
|
|
|
|
public async Task<ExternalProviderClaims?> VerifyCallbackAsync(
|
|
IQueryCollection query,
|
|
ExternalAuthTransaction transaction,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!IsConfigured || !string.IsNullOrWhiteSpace(query["error"]) ||
|
|
!CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(query["state"].ToString()),
|
|
Encoding.UTF8.GetBytes(transaction.Id)))
|
|
return null;
|
|
var code = query["code"].ToString();
|
|
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(transaction.CodeVerifier)) return null;
|
|
|
|
var client = _httpClients.CreateClient(nameof(GoogleExternalAuthProvider));
|
|
client.Timeout = TimeSpan.FromSeconds(15);
|
|
using var response = await client.PostAsync(TokenEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["client_id"] = ClientId,
|
|
["client_secret"] = ClientSecret,
|
|
["code"] = code,
|
|
["code_verifier"] = transaction.CodeVerifier,
|
|
["grant_type"] = "authorization_code",
|
|
["redirect_uri"] = CallbackUrl
|
|
}), cancellationToken);
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
|
|
if (!payload.RootElement.TryGetProperty("id_token", out var idTokenElement)) return null;
|
|
var idToken = idTokenElement.GetString();
|
|
if (string.IsNullOrWhiteSpace(idToken)) return null;
|
|
|
|
var oidc = await _discovery.GetConfigurationAsync(cancellationToken);
|
|
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
|
ClaimsPrincipal principal;
|
|
try
|
|
{
|
|
principal = handler.ValidateToken(idToken, new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuers = ["https://accounts.google.com", "accounts.google.com"],
|
|
ValidateAudience = true,
|
|
ValidAudience = ClientId,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKeys = oidc.SigningKeys,
|
|
ClockSkew = TimeSpan.FromMinutes(2)
|
|
}, out _);
|
|
}
|
|
catch (SecurityTokenException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var subject = principal.FindFirstValue("sub");
|
|
var nonce = principal.FindFirstValue("nonce");
|
|
var email = principal.FindFirstValue("email");
|
|
var verified = string.Equals(principal.FindFirstValue("email_verified"), "true",
|
|
StringComparison.OrdinalIgnoreCase);
|
|
if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(nonce) ||
|
|
!CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(nonce), Encoding.UTF8.GetBytes(transaction.Nonce ?? "")))
|
|
return null;
|
|
return new ExternalProviderClaims(subject, email, verified);
|
|
}
|
|
|
|
internal static string NewVerifier() => Base64Url(RandomNumberGenerator.GetBytes(48));
|
|
internal static string NewNonce() => Base64Url(RandomNumberGenerator.GetBytes(32));
|
|
|
|
private static string Query(Dictionary<string, string> values) => string.Join("&", values.Select(pair =>
|
|
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
|
|
|
|
private static string Base64Url(byte[] bytes) =>
|
|
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
}
|