♻️ Moved services & controllers to Pass
This commit is contained in:
298
DysonNetwork.Pass/Auth/OpenId/OidcService.cs
Normal file
298
DysonNetwork.Pass/Auth/OpenId/OidcService.cs
Normal file
@ -0,0 +1,298 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Sphere.Auth.OpenId;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||
|
||||
/// <summary>
|
||||
/// Base service for OpenID Connect authentication providers
|
||||
/// </summary>
|
||||
public abstract class OidcService(
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AppDatabase db,
|
||||
AuthService auth,
|
||||
ICacheService cache
|
||||
)
|
||||
{
|
||||
protected readonly IConfiguration Configuration = configuration;
|
||||
protected readonly IHttpClientFactory HttpClientFactory = httpClientFactory;
|
||||
protected readonly AppDatabase Db = db;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this provider
|
||||
/// </summary>
|
||||
public abstract string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OIDC discovery document endpoint
|
||||
/// </summary>
|
||||
protected abstract string DiscoveryEndpoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets configuration section name for this provider
|
||||
/// </summary>
|
||||
protected abstract string ConfigSectionName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the authorization URL for initiating the authentication flow
|
||||
/// </summary>
|
||||
public abstract string GetAuthorizationUrl(string state, string nonce);
|
||||
|
||||
/// <summary>
|
||||
/// Process the callback from the OIDC provider
|
||||
/// </summary>
|
||||
public abstract Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the provider configuration
|
||||
/// </summary>
|
||||
protected ProviderConfiguration GetProviderConfig()
|
||||
{
|
||||
return new ProviderConfiguration
|
||||
{
|
||||
ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
|
||||
ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
|
||||
RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the OpenID Connect discovery document
|
||||
/// </summary>
|
||||
protected virtual async Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
|
||||
{
|
||||
// Construct a cache key unique to the current provider:
|
||||
var cacheKey = $"oidc-discovery:{ProviderName}";
|
||||
|
||||
// Try getting the discovery document from cache first:
|
||||
var (found, cachedDoc) = await cache.GetAsyncWithStatus<OidcDiscoveryDocument>(cacheKey);
|
||||
if (found && cachedDoc != null)
|
||||
{
|
||||
return cachedDoc;
|
||||
}
|
||||
|
||||
// If it's not cached, fetch from the actual discovery endpoint:
|
||||
var client = HttpClientFactory.CreateClient();
|
||||
var response = await client.GetAsync(DiscoveryEndpoint);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var doc = await response.Content.ReadFromJsonAsync<OidcDiscoveryDocument>();
|
||||
|
||||
// Store the discovery document in the cache for a while (e.g., 15 minutes):
|
||||
if (doc is not null)
|
||||
await cache.SetAsync(cacheKey, doc, TimeSpan.FromMinutes(15));
|
||||
|
||||
return doc;
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exchange the authorization code for tokens
|
||||
/// </summary>
|
||||
protected virtual async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
|
||||
string? codeVerifier = null)
|
||||
{
|
||||
var config = GetProviderConfig();
|
||||
var discoveryDocument = await GetDiscoveryDocumentAsync();
|
||||
|
||||
if (discoveryDocument?.TokenEndpoint == null)
|
||||
{
|
||||
throw new InvalidOperationException("Token endpoint not found in discovery document");
|
||||
}
|
||||
|
||||
var client = HttpClientFactory.CreateClient();
|
||||
var content = new FormUrlEncodedContent(BuildTokenRequestParameters(code, config, codeVerifier));
|
||||
|
||||
var response = await client.PostAsync(discoveryDocument.TokenEndpoint, content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the token request parameters
|
||||
/// </summary>
|
||||
protected virtual Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config,
|
||||
string? codeVerifier)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.ClientId },
|
||||
{ "code", code },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "redirect_uri", config.RedirectUri }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(config.ClientSecret))
|
||||
{
|
||||
parameters.Add("client_secret", config.ClientSecret);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(codeVerifier))
|
||||
{
|
||||
parameters.Add("code_verifier", codeVerifier);
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and extracts information from an ID token
|
||||
/// </summary>
|
||||
protected virtual OidcUserInfo ValidateAndExtractIdToken(string idToken,
|
||||
TokenValidationParameters validationParameters)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
handler.ValidateToken(idToken, validationParameters, out _);
|
||||
|
||||
var jwtToken = handler.ReadJwtToken(idToken);
|
||||
|
||||
// Extract standard claims
|
||||
var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
var email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||
var emailVerified = jwtToken.Claims.FirstOrDefault(c => c.Type == "email_verified")?.Value == "true";
|
||||
var name = jwtToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
|
||||
var givenName = jwtToken.Claims.FirstOrDefault(c => c.Type == "given_name")?.Value;
|
||||
var familyName = jwtToken.Claims.FirstOrDefault(c => c.Type == "family_name")?.Value;
|
||||
var preferredUsername = jwtToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
|
||||
var picture = jwtToken.Claims.FirstOrDefault(c => c.Type == "picture")?.Value;
|
||||
|
||||
// Determine preferred username - try different options
|
||||
var username = preferredUsername;
|
||||
if (string.IsNullOrEmpty(username))
|
||||
{
|
||||
// Fall back to email local part if no preferred username
|
||||
username = !string.IsNullOrEmpty(email) ? email.Split('@')[0] : null;
|
||||
}
|
||||
|
||||
return new OidcUserInfo
|
||||
{
|
||||
UserId = userId,
|
||||
Email = email,
|
||||
EmailVerified = emailVerified,
|
||||
FirstName = givenName ?? "",
|
||||
LastName = familyName ?? "",
|
||||
DisplayName = name ?? $"{givenName} {familyName}".Trim(),
|
||||
PreferredUsername = username ?? "",
|
||||
ProfilePictureUrl = picture,
|
||||
Provider = ProviderName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a challenge and session for an authenticated user
|
||||
/// Also creates or updates the account connection
|
||||
/// </summary>
|
||||
public async Task<Challenge> CreateChallengeForUserAsync(
|
||||
OidcUserInfo userInfo,
|
||||
Shared.Models.Account account,
|
||||
HttpContext request,
|
||||
string deviceId
|
||||
)
|
||||
{
|
||||
// Create or update the account connection
|
||||
var connection = await Db.AccountConnections
|
||||
.FirstOrDefaultAsync(c => c.Provider == ProviderName &&
|
||||
c.ProvidedIdentifier == userInfo.UserId &&
|
||||
c.AccountId == account.Id
|
||||
);
|
||||
|
||||
if (connection is null)
|
||||
{
|
||||
connection = new AccountConnection
|
||||
{
|
||||
Provider = ProviderName,
|
||||
ProvidedIdentifier = userInfo.UserId ?? "",
|
||||
AccessToken = userInfo.AccessToken,
|
||||
RefreshToken = userInfo.RefreshToken,
|
||||
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
AccountId = account.Id
|
||||
};
|
||||
await Db.AccountConnections.AddAsync(connection);
|
||||
}
|
||||
|
||||
// Create a challenge that's already completed
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var challenge = new Challenge
|
||||
{
|
||||
ExpiredAt = now.Plus(Duration.FromHours(1)),
|
||||
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
|
||||
Type = ChallengeType.Oidc,
|
||||
Platform = ChallengePlatform.Unidentified,
|
||||
Audiences = [ProviderName],
|
||||
Scopes = ["*"],
|
||||
AccountId = account.Id,
|
||||
DeviceId = deviceId,
|
||||
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
|
||||
UserAgent = request.Request.Headers.UserAgent,
|
||||
};
|
||||
challenge.StepRemain--;
|
||||
if (challenge.StepRemain < 0) challenge.StepRemain = 0;
|
||||
|
||||
await Db.AuthChallenges.AddAsync(challenge);
|
||||
await Db.SaveChangesAsync();
|
||||
|
||||
return challenge;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider configuration from app settings
|
||||
/// </summary>
|
||||
public class ProviderConfiguration
|
||||
{
|
||||
public string ClientId { get; set; } = "";
|
||||
public string ClientSecret { get; set; } = "";
|
||||
public string RedirectUri { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OIDC Discovery Document
|
||||
/// </summary>
|
||||
public class OidcDiscoveryDocument
|
||||
{
|
||||
[JsonPropertyName("authorization_endpoint")]
|
||||
public string? AuthorizationEndpoint { get; set; }
|
||||
|
||||
[JsonPropertyName("token_endpoint")] public string? TokenEndpoint { get; set; }
|
||||
|
||||
[JsonPropertyName("userinfo_endpoint")]
|
||||
public string? UserinfoEndpoint { get; set; }
|
||||
|
||||
[JsonPropertyName("jwks_uri")] public string? JwksUri { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from the token endpoint
|
||||
/// </summary>
|
||||
public class OidcTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")] public string? AccessToken { get; set; }
|
||||
|
||||
[JsonPropertyName("token_type")] public string? TokenType { get; set; }
|
||||
|
||||
[JsonPropertyName("expires_in")] public int ExpiresIn { get; set; }
|
||||
|
||||
[JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; }
|
||||
|
||||
[JsonPropertyName("id_token")] public string? IdToken { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data received in the callback from an OIDC provider
|
||||
/// </summary>
|
||||
public class OidcCallbackData
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
public string IdToken { get; set; } = "";
|
||||
public string? State { get; set; }
|
||||
public string? RawData { get; set; }
|
||||
}
|
Reference in New Issue
Block a user