feature: Added External Athentication base

This commit is contained in:
2025-10-06 19:54:25 -06:00
parent f08007eec7
commit 43b91ef560
21 changed files with 3629 additions and 4 deletions

View File

@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
public class CreateOrUpdateProviderInput
{
public int? Id { get; set; }
[Required]
[MaxLength(128)]
public string Name { get; set; }
[Required]
[MaxLength(64)]
public string ProviderType { get; set; }
public bool IsEnabled { get; set; }
[Required]
[MaxLength(512)]
public string Authority { get; set; }
[Required]
[MaxLength(256)]
public string ClientId { get; set; }
[Required]
[MaxLength(512)]
public string ClientSecret { get; set; }
[MaxLength(512)]
public string Scopes { get; set; }
[MaxLength(64)]
public string ResponseType { get; set; }
public bool RequireHttpsMetadata { get; set; }
[MaxLength(2048)]
public string ClaimMappings { get; set; }
public int DisplayOrder { get; set; }
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
/// <summary>
/// Model for external authentication request
/// </summary>
public class ExternalAuthModel
{
[Required]
public string ProviderName { get; set; }
[Required]
public string IdToken { get; set; }
}

View File

@@ -0,0 +1,50 @@
using Abp.Application.Services.Dto;
using Abp.AutoMapper;
using ASPBaseOIDC.Authorization.ExternalAuth;
using System.ComponentModel.DataAnnotations;
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
[AutoMapFrom(typeof(ExternalAuthProvider))]
[AutoMapTo(typeof(ExternalAuthProvider))]
public class ExternalAuthProviderDto : EntityDto<int>
{
[Required]
[MaxLength(128)]
public string Name { get; set; }
[Required]
[MaxLength(64)]
public string ProviderType { get; set; }
public bool IsEnabled { get; set; }
[Required]
[MaxLength(512)]
public string Authority { get; set; }
[Required]
[MaxLength(256)]
public string ClientId { get; set; }
/// <summary>
/// Client secret (masked when returning to client, full when creating/updating)
/// </summary>
[MaxLength(512)]
public string ClientSecret { get; set; }
[MaxLength(512)]
public string Scopes { get; set; }
[MaxLength(64)]
public string ResponseType { get; set; }
public bool RequireHttpsMetadata { get; set; }
[MaxLength(2048)]
public string ClaimMappings { get; set; }
public int DisplayOrder { get; set; }
public int? TenantId { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
public class TestConnectionOutput
{
public bool Success { get; set; }
public string Message { get; set; }
}

View File

@@ -0,0 +1,212 @@
using Abp.Application.Services;
using Abp.Authorization;
using Abp.Domain.Repositories;
using Abp.UI;
using ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
using ASPBaseOIDC.Authorization;
using ASPBaseOIDC.Authorization.ExternalAuth;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth;
/// <summary>
/// Application service for managing external authentication providers
/// Requires admin permissions
/// </summary>
[AbpAuthorize(PermissionNames.Pages_Users)] // TODO: Create specific permission for external auth
public class ExternalAuthProviderAppService : ApplicationService
{
private readonly ExternalAuthProviderManager _providerManager;
private readonly IRepository<ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider> _providerRepository;
public ExternalAuthProviderAppService(
ExternalAuthProviderManager providerManager,
IRepository<ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider> providerRepository)
{
_providerManager = providerManager;
_providerRepository = providerRepository;
}
/// <summary>
/// Get all providers (with secrets masked)
/// </summary>
public virtual async Task<List<ExternalAuthProviderDto>> GetAllProviders()
{
var providers = await _providerManager.GetAllAsync();
return providers.Select(p => new ExternalAuthProviderDto
{
Id = p.Id,
Name = p.Name,
ProviderType = p.ProviderType,
IsEnabled = p.IsEnabled,
Authority = p.Authority,
ClientId = p.ClientId,
ClientSecret = MaskSecret(p.ClientSecret), // Mask secret
Scopes = p.Scopes,
ResponseType = p.ResponseType,
RequireHttpsMetadata = p.RequireHttpsMetadata,
ClaimMappings = p.ClaimMappings,
DisplayOrder = p.DisplayOrder,
TenantId = p.TenantId
}).ToList();
}
/// <summary>
/// Get enabled providers for login page (public - no auth required)
/// </summary>
[AbpAllowAnonymous]
public virtual async Task<List<ExternalAuthProviderDto>> GetEnabledProviders()
{
var providers = await _providerManager.GetEnabledProvidersAsync();
return providers.Select(p => new ExternalAuthProviderDto
{
Id = p.Id,
Name = p.Name,
ProviderType = p.ProviderType,
Authority = p.Authority,
ClientId = p.ClientId,
ClientSecret = null, // Never expose secret publicly
Scopes = p.Scopes,
ResponseType = p.ResponseType,
RequireHttpsMetadata = p.RequireHttpsMetadata,
DisplayOrder = p.DisplayOrder
}).ToList();
}
/// <summary>
/// Get provider by ID (with secret masked)
/// </summary>
public virtual async Task<ExternalAuthProviderDto> GetProvider(int id)
{
var provider = await _providerManager.GetByIdAsync(id);
return new ExternalAuthProviderDto
{
Id = provider.Id,
Name = provider.Name,
ProviderType = provider.ProviderType,
IsEnabled = provider.IsEnabled,
Authority = provider.Authority,
ClientId = provider.ClientId,
ClientSecret = MaskSecret(provider.ClientSecret),
Scopes = provider.Scopes,
ResponseType = provider.ResponseType,
RequireHttpsMetadata = provider.RequireHttpsMetadata,
ClaimMappings = provider.ClaimMappings,
DisplayOrder = provider.DisplayOrder,
TenantId = provider.TenantId
};
}
/// <summary>
/// Create or update provider
/// </summary>
public virtual async Task<ExternalAuthProviderDto> CreateOrUpdateProvider(CreateOrUpdateProviderInput input)
{
ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider provider;
if (input.Id.HasValue && input.Id.Value > 0)
{
// Update existing
provider = await _providerRepository.GetAsync(input.Id.Value);
provider.Name = input.Name;
provider.ProviderType = input.ProviderType;
provider.IsEnabled = input.IsEnabled;
provider.Authority = input.Authority;
provider.ClientId = input.ClientId;
// Only update secret if not masked
if (!IsMaskedSecret(input.ClientSecret))
{
provider.ClientSecret = input.ClientSecret;
}
provider.Scopes = input.Scopes;
provider.ResponseType = input.ResponseType;
provider.RequireHttpsMetadata = input.RequireHttpsMetadata;
provider.ClaimMappings = input.ClaimMappings;
provider.DisplayOrder = input.DisplayOrder;
}
else
{
// Create new
provider = new ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider
{
TenantId = AbpSession.TenantId,
Name = input.Name,
ProviderType = input.ProviderType,
IsEnabled = input.IsEnabled,
Authority = input.Authority,
ClientId = input.ClientId,
ClientSecret = input.ClientSecret,
Scopes = input.Scopes,
ResponseType = input.ResponseType,
RequireHttpsMetadata = input.RequireHttpsMetadata,
ClaimMappings = input.ClaimMappings,
DisplayOrder = input.DisplayOrder
};
}
provider = await _providerManager.CreateOrUpdateAsync(provider);
return await GetProvider(provider.Id);
}
/// <summary>
/// Delete provider
/// </summary>
public virtual async Task DeleteProvider(int id)
{
await _providerManager.DeleteAsync(id);
}
/// <summary>
/// Test provider connection (OIDC discovery)
/// </summary>
public virtual async Task<TestConnectionOutput> TestProviderConnection(int id)
{
try
{
var success = await _providerManager.TestConnectionAsync(id);
return new TestConnectionOutput
{
Success = success,
Message = success ? "Connection successful" : "Connection failed"
};
}
catch (Exception ex)
{
return new TestConnectionOutput
{
Success = false,
Message = $"Connection failed: {ex.Message}"
};
}
}
private string MaskSecret(string secret)
{
if (string.IsNullOrEmpty(secret))
{
return null;
}
if (secret.Length <= 8)
{
return "********";
}
return secret.Substring(0, 4) + "********" + secret.Substring(secret.Length - 4);
}
private bool IsMaskedSecret(string secret)
{
return !string.IsNullOrEmpty(secret) && secret.Contains("********");
}
}

View File

@@ -18,6 +18,8 @@
<PackageReference Include="Abp.AutoMapper" Version="10.2.0" /> <PackageReference Include="Abp.AutoMapper" Version="10.2.0" />
<PackageReference Include="Abp.ZeroCore.EntityFrameworkCore" Version="10.2.0" /> <PackageReference Include="Abp.ZeroCore.EntityFrameworkCore" Version="10.2.0" />
<PackageReference Include="Castle.Windsor.MsDependencyInjection" Version="4.1.0" /> <PackageReference Include="Castle.Windsor.MsDependencyInjection" Version="4.1.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.2" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.2.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,102 @@
using Abp.Domain.Entities;
using Abp.Domain.Entities.Auditing;
using System.ComponentModel.DataAnnotations;
namespace ASPBaseOIDC.Authorization.ExternalAuth;
/// <summary>
/// External authentication provider configuration (OIDC/OAuth2)
/// Similar to Portainer's external auth system
/// </summary>
public class ExternalAuthProvider : FullAuditedEntity<int>, IMayHaveTenant
{
public const int MaxNameLength = 128;
public const int MaxAuthorityLength = 512;
public const int MaxClientIdLength = 256;
public const int MaxClientSecretLength = 512;
public const int MaxScopesLength = 512;
public const int MaxResponseTypeLength = 64;
public const int MaxClaimMappingsLength = 2048;
/// <summary>
/// Tenant ID (null for host)
/// </summary>
public int? TenantId { get; set; }
/// <summary>
/// Provider display name (e.g., "Authentik", "Keycloak", "Google")
/// </summary>
[Required]
[MaxLength(MaxNameLength)]
public string Name { get; set; }
/// <summary>
/// Provider type: "OIDC", "OAuth2", "SAML"
/// </summary>
[Required]
[MaxLength(64)]
public string ProviderType { get; set; }
/// <summary>
/// Enable/disable this provider
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// OIDC Authority URL (e.g., https://auth.acme.com/application/o/app1/)
/// </summary>
[Required]
[MaxLength(MaxAuthorityLength)]
public string Authority { get; set; }
/// <summary>
/// OAuth2/OIDC Client ID
/// </summary>
[Required]
[MaxLength(MaxClientIdLength)]
public string ClientId { get; set; }
/// <summary>
/// OAuth2/OIDC Client Secret (encrypted with SimpleStringCipher)
/// </summary>
[Required]
[MaxLength(MaxClientSecretLength)]
public string ClientSecret { get; set; }
/// <summary>
/// Space-separated scopes (e.g., "openid profile email")
/// </summary>
[MaxLength(MaxScopesLength)]
public string Scopes { get; set; }
/// <summary>
/// OAuth2 response type (e.g., "code", "id_token", "code id_token")
/// </summary>
[MaxLength(MaxResponseTypeLength)]
public string ResponseType { get; set; }
/// <summary>
/// Require HTTPS metadata endpoint
/// </summary>
public bool RequireHttpsMetadata { get; set; }
/// <summary>
/// JSON mapping for claims (e.g., {"sub": "sub", "email": "email", "name": "preferred_username"})
/// </summary>
[MaxLength(MaxClaimMappingsLength)]
public string ClaimMappings { get; set; }
/// <summary>
/// Display order in login page (lower = higher priority)
/// </summary>
public int DisplayOrder { get; set; }
public ExternalAuthProvider()
{
IsEnabled = true;
RequireHttpsMetadata = true;
ResponseType = "code";
Scopes = "openid profile email";
DisplayOrder = 0;
}
}

View File

@@ -0,0 +1,262 @@
using Abp;
using Abp.Configuration;
using Abp.Domain.Repositories;
using Abp.Domain.Services;
using Abp.Runtime.Caching;
using Abp.Runtime.Security;
using Abp.UI;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace ASPBaseOIDC.Authorization.ExternalAuth;
/// <summary>
/// Domain service for managing external authentication providers
/// Handles CRUD, encryption, validation, and caching
/// </summary>
public class ExternalAuthProviderManager : DomainService
{
private readonly IRepository<ExternalAuthProvider> _providerRepository;
private readonly ICacheManager _cacheManager;
private readonly IHttpClientFactory _httpClientFactory;
private const string CacheKey = "ExternalAuthProviders";
public ExternalAuthProviderManager(
IRepository<ExternalAuthProvider> providerRepository,
ICacheManager cacheManager,
IHttpClientFactory httpClientFactory)
{
_providerRepository = providerRepository;
_cacheManager = cacheManager;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// Get all providers (with secrets decrypted for admin)
/// </summary>
public virtual async Task<List<ExternalAuthProvider>> GetAllAsync()
{
var providers = await _providerRepository.GetAllListAsync();
return providers;
}
/// <summary>
/// Get all enabled providers (cached for performance)
/// </summary>
public virtual async Task<List<ExternalAuthProvider>> GetEnabledProvidersAsync()
{
var cache = _cacheManager.GetCache(CacheKey);
var cacheItem = await cache.GetAsync("EnabledProviders", async (key) =>
{
var providers = await _providerRepository
.GetAll()
.Where(p => p.IsEnabled)
.OrderBy(p => p.DisplayOrder)
.ToListAsync();
return (object)providers;
});
return cacheItem as List<ExternalAuthProvider> ?? new List<ExternalAuthProvider>();
}
/// <summary>
/// Get provider by ID
/// </summary>
public virtual async Task<ExternalAuthProvider> GetByIdAsync(int id)
{
var provider = await _providerRepository.FirstOrDefaultAsync(id);
if (provider == null)
{
throw new UserFriendlyException("Provider not found");
}
return provider;
}
/// <summary>
/// Get provider by name
/// </summary>
public virtual async Task<ExternalAuthProvider> GetByNameAsync(string name)
{
return await _providerRepository.FirstOrDefaultAsync(p => p.Name == name);
}
/// <summary>
/// Get provider by issuer (for token validation)
/// </summary>
public virtual async Task<ExternalAuthProvider> GetByIssuerAsync(string issuer)
{
var cache = _cacheManager.GetCache(CacheKey);
var providers = await GetEnabledProvidersAsync();
return providers.FirstOrDefault(p =>
issuer.StartsWith(p.Authority, StringComparison.OrdinalIgnoreCase) ||
p.Authority.StartsWith(issuer, StringComparison.OrdinalIgnoreCase)
);
}
/// <summary>
/// Create or update provider with validation and encryption
/// </summary>
public virtual async Task<ExternalAuthProvider> CreateOrUpdateAsync(ExternalAuthProvider provider)
{
ValidateProvider(provider);
// Encrypt client secret using ABP's SimpleStringCipher
if (!string.IsNullOrEmpty(provider.ClientSecret))
{
provider.ClientSecret = SimpleStringCipher.Instance.Encrypt(provider.ClientSecret);
}
if (provider.Id == 0)
{
await _providerRepository.InsertAsync(provider);
}
else
{
await _providerRepository.UpdateAsync(provider);
}
await CurrentUnitOfWork.SaveChangesAsync();
ClearCache();
return provider;
}
/// <summary>
/// Delete provider (soft delete)
/// </summary>
public virtual async Task DeleteAsync(int id)
{
await _providerRepository.DeleteAsync(id);
ClearCache();
}
/// <summary>
/// Test provider connectivity (OIDC discovery endpoint)
/// </summary>
public virtual async Task<bool> TestConnectionAsync(int id)
{
var provider = await GetByIdAsync(id);
return await TestConnectionAsync(provider);
}
/// <summary>
/// Test provider connectivity
/// </summary>
public virtual async Task<bool> TestConnectionAsync(ExternalAuthProvider provider)
{
try
{
var discoveryUrl = provider.Authority.TrimEnd('/') + "/.well-known/openid-configuration";
var httpClient = _httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
var response = await httpClient.GetAsync(discoveryUrl);
if (!response.IsSuccessStatusCode)
{
return false;
}
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
// Validate required OIDC fields
return doc.RootElement.TryGetProperty("issuer", out _) &&
doc.RootElement.TryGetProperty("authorization_endpoint", out _) &&
doc.RootElement.TryGetProperty("token_endpoint", out _);
}
catch
{
return false;
}
}
/// <summary>
/// Decrypt client secret for use (after retrieving from DB)
/// </summary>
public virtual string DecryptClientSecret(string encryptedSecret)
{
if (string.IsNullOrEmpty(encryptedSecret))
{
return null;
}
try
{
return SimpleStringCipher.Instance.Decrypt(encryptedSecret);
}
catch
{
// If decryption fails, assume it's already plain text (for migration scenarios)
return encryptedSecret;
}
}
/// <summary>
/// Get claim mappings as dictionary
/// </summary>
public virtual Dictionary<string, string> GetClaimMappings(ExternalAuthProvider provider)
{
if (string.IsNullOrEmpty(provider.ClaimMappings))
{
// Default mappings
return new Dictionary<string, string>
{
{ "sub", "sub" },
{ "email", "email" },
{ "name", "name" },
{ "preferred_username", "preferred_username" }
};
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(provider.ClaimMappings);
}
catch
{
return new Dictionary<string, string>();
}
}
private void ValidateProvider(ExternalAuthProvider provider)
{
if (string.IsNullOrWhiteSpace(provider.Name))
{
throw new UserFriendlyException("Provider name is required");
}
if (string.IsNullOrWhiteSpace(provider.Authority))
{
throw new UserFriendlyException("Authority URL is required");
}
if (string.IsNullOrWhiteSpace(provider.ClientId))
{
throw new UserFriendlyException("Client ID is required");
}
if (string.IsNullOrWhiteSpace(provider.ClientSecret))
{
throw new UserFriendlyException("Client Secret is required");
}
// Validate Authority is a valid URL
if (!Uri.TryCreate(provider.Authority, UriKind.Absolute, out _))
{
throw new UserFriendlyException("Authority must be a valid URL");
}
}
private void ClearCache()
{
var cache = _cacheManager.GetCache(CacheKey);
cache.Clear();
}
}

View File

@@ -0,0 +1,369 @@
using Abp;
using Abp.Authorization;
using Abp.Authorization.Users;
using Abp.Configuration;
using Abp.Domain.Repositories;
using Abp.Domain.Services;
using Abp.Domain.Uow;
using Abp.UI;
using ASPBaseOIDC.Authorization.Roles;
using ASPBaseOIDC.Authorization.Users;
using ASPBaseOIDC.Configuration;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
namespace ASPBaseOIDC.Authorization.ExternalAuth;
/// <summary>
/// Domain service for external authentication with OIDC/OAuth2 providers
/// Handles token validation, JIT provisioning, and user linking
/// </summary>
public class ExternalAuthenticationManager : DomainService
{
private readonly ExternalAuthProviderManager _providerManager;
private readonly UserManager _userManager;
private readonly RoleManager _roleManager;
private readonly IRepository<UserLogin, long> _userLoginRepository;
private readonly IRepository<UserLoginAttempt, long> _userLoginAttemptRepository;
private readonly ISettingManager _settingManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IUnitOfWorkManager _unitOfWorkManager;
public ExternalAuthenticationManager(
ExternalAuthProviderManager providerManager,
UserManager userManager,
RoleManager roleManager,
IRepository<UserLogin, long> userLoginRepository,
IRepository<UserLoginAttempt, long> userLoginAttemptRepository,
ISettingManager settingManager,
IHttpClientFactory httpClientFactory,
IUnitOfWorkManager unitOfWorkManager)
{
_providerManager = providerManager;
_userManager = userManager;
_roleManager = roleManager;
_userLoginRepository = userLoginRepository;
_userLoginAttemptRepository = userLoginAttemptRepository;
_settingManager = settingManager;
_httpClientFactory = httpClientFactory;
_unitOfWorkManager = unitOfWorkManager;
}
/// <summary>
/// Authenticate user with external OIDC token
/// Main flow: validate token → find/create user → link provider → return result
/// </summary>
public virtual async Task<ExternalAuthenticationResult> AuthenticateWithExternalTokenAsync(
string providerName,
string idToken,
int? tenantId = null)
{
// 1. Check if external auth is enabled
var isEnabled = await _settingManager.GetSettingValueAsync<bool>(AppSettingNames.ExternalAuth.Enabled);
if (!isEnabled)
{
throw new UserFriendlyException("External authentication is disabled");
}
// 2. Get provider configuration
var provider = await _providerManager.GetByNameAsync(providerName);
if (provider == null || !provider.IsEnabled)
{
throw new UserFriendlyException("Provider not found or disabled");
}
// 3. Validate JWT token
var claims = await ValidateTokenAsync(provider, idToken);
var sub = claims.FirstOrDefault(c => c.Type == "sub")?.Value;
var email = claims.FirstOrDefault(c => c.Type == "email")?.Value;
if (string.IsNullOrEmpty(sub))
{
throw new UserFriendlyException("Invalid token: 'sub' claim not found");
}
// 4. Find or create user
User user;
using (CurrentUnitOfWork.SetTenantId(tenantId))
{
user = await FindOrCreateUserAsync(provider, sub, email, claims, tenantId);
// 5. Register login attempt
await CreateLoginAttemptAsync(user, provider.Name, true, tenantId);
}
// 6. Return result with original token (passthrough)
return new ExternalAuthenticationResult
{
User = user,
AccessToken = idToken,
Provider = provider.Name,
ExpiresIn = GetTokenExpiration(idToken),
Claims = claims
};
}
/// <summary>
/// Validate JWT token with provider's JWKS
/// </summary>
private async Task<List<Claim>> ValidateTokenAsync(ExternalAuthProvider provider, string idToken)
{
try
{
var handler = new JwtSecurityTokenHandler();
// First, read token without validation to get issuer
var jwtToken = handler.ReadJwtToken(idToken);
// Download OIDC configuration
var discoveryUrl = provider.Authority.TrimEnd('/') + "/.well-known/openid-configuration";
var httpClient = _httpClientFactory.CreateClient();
var discoveryResponse = await httpClient.GetStringAsync(discoveryUrl);
var discoveryDoc = JsonDocument.Parse(discoveryResponse);
var jwksUri = discoveryDoc.RootElement.GetProperty("jwks_uri").GetString();
var issuer = discoveryDoc.RootElement.GetProperty("issuer").GetString();
// Download JWKS
var jwksResponse = await httpClient.GetStringAsync(jwksUri);
// Create validation parameters
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = provider.ClientId,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
{
var jwks = JsonDocument.Parse(jwksResponse);
var keys = jwks.RootElement.GetProperty("keys");
var signingKeys = new List<SecurityKey>();
foreach (var key in keys.EnumerateArray())
{
var keyId = key.GetProperty("kid").GetString();
if (keyId == kid)
{
var e = key.GetProperty("e").GetString();
var n = key.GetProperty("n").GetString();
var rsa = new RsaSecurityKey(new System.Security.Cryptography.RSAParameters
{
Exponent = Base64UrlEncoder.DecodeBytes(e),
Modulus = Base64UrlEncoder.DecodeBytes(n)
});
signingKeys.Add(rsa);
}
}
return signingKeys;
},
ClockSkew = TimeSpan.FromMinutes(5)
};
// Validate token
var principal = handler.ValidateToken(idToken, validationParameters, out var validatedToken);
return principal.Claims.ToList();
}
catch (Exception ex)
{
Logger.Error("Token validation failed", ex);
throw new UserFriendlyException("Invalid token: " + ex.Message);
}
}
/// <summary>
/// Find existing user or create new one (JIT provisioning)
/// </summary>
private async Task<User> FindOrCreateUserAsync(
ExternalAuthProvider provider,
string sub,
string email,
List<Claim> claims,
int? tenantId)
{
// 1. Try to find existing link by provider + sub
var existingLogin = await _userLoginRepository.FirstOrDefaultAsync(ul =>
ul.LoginProvider == provider.Name && ul.ProviderKey == sub && ul.TenantId == tenantId);
if (existingLogin != null)
{
var existingUser = await _userManager.FindByIdAsync(existingLogin.UserId.ToString());
if (existingUser != null)
{
return existingUser;
}
}
// 2. Check if auto-provisioning is enabled
var autoProvision = await _settingManager.GetSettingValueAsync<bool>(
AppSettingNames.ExternalAuth.AutoProvisionUsers);
if (!autoProvision)
{
throw new UserFriendlyException("User not found and auto-provisioning is disabled");
}
// 3. Try to find user by email (merge scenario)
User user = null;
if (!string.IsNullOrEmpty(email))
{
user = await _userManager.FindByEmailAsync(email);
}
// 4. Create new user if not found
if (user == null)
{
user = await CreateNewUserAsync(provider, claims, tenantId);
}
// 5. Link external provider to user
await LinkProviderToUserAsync(user, provider.Name, sub);
return user;
}
/// <summary>
/// Create new user from external claims
/// </summary>
private async Task<User> CreateNewUserAsync(
ExternalAuthProvider provider,
List<Claim> claims,
int? tenantId)
{
var claimMappings = _providerManager.GetClaimMappings(provider);
var email = GetClaimValue(claims, claimMappings, "email");
var name = GetClaimValue(claims, claimMappings, "name") ?? "Unknown";
var preferredUsername = GetClaimValue(claims, claimMappings, "preferred_username") ?? email?.Split('@')[0];
if (string.IsNullOrEmpty(email))
{
throw new UserFriendlyException("Email claim is required for user provisioning");
}
var user = new User
{
TenantId = tenantId,
UserName = preferredUsername ?? Guid.NewGuid().ToString("N").Substring(0, 10),
Name = name,
Surname = GetClaimValue(claims, claimMappings, "family_name") ?? name,
EmailAddress = email,
IsEmailConfirmed = true, // Trust external provider
IsActive = true,
Password = Guid.NewGuid().ToString("N") // Random password (user won't use it)
};
user.SetNormalizedNames();
var result = await _userManager.CreateAsync(user);
if (!result.Succeeded)
{
throw new UserFriendlyException("Failed to create user: " + string.Join(", ", result.Errors.Select(e => e.Description)));
}
// Assign default role if configured
var defaultRole = await _settingManager.GetSettingValueAsync(AppSettingNames.ExternalAuth.DefaultRole);
if (!string.IsNullOrEmpty(defaultRole))
{
var role = await _roleManager.FindByNameAsync(defaultRole);
if (role != null)
{
await _userManager.AddToRoleAsync(user, defaultRole);
}
}
await CurrentUnitOfWork.SaveChangesAsync();
return user;
}
/// <summary>
/// Link external provider to user account
/// </summary>
private async Task LinkProviderToUserAsync(User user, string providerName, string providerKey)
{
var existingLink = await _userLoginRepository.FirstOrDefaultAsync(ul =>
ul.UserId == user.Id && ul.LoginProvider == providerName);
if (existingLink == null)
{
await _userLoginRepository.InsertAsync(new UserLogin
{
TenantId = user.TenantId,
UserId = user.Id,
LoginProvider = providerName,
ProviderKey = providerKey
});
await CurrentUnitOfWork.SaveChangesAsync();
}
}
/// <summary>
/// Create login attempt record
/// </summary>
private async Task CreateLoginAttemptAsync(User user, string providerName, bool success, int? tenantId)
{
await _userLoginAttemptRepository.InsertAsync(new UserLoginAttempt
{
TenantId = tenantId,
UserId = user.Id,
UserNameOrEmailAddress = user.EmailAddress,
ClientIpAddress = "External",
ClientName = providerName,
BrowserInfo = "OIDC",
Result = success ? AbpLoginResultType.Success : AbpLoginResultType.InvalidPassword,
CreationTime = DateTime.UtcNow
});
await CurrentUnitOfWork.SaveChangesAsync();
}
private string GetClaimValue(List<Claim> claims, Dictionary<string, string> mappings, string claimType)
{
if (!mappings.TryGetValue(claimType, out var mappedType))
{
mappedType = claimType;
}
return claims.FirstOrDefault(c => c.Type == mappedType)?.Value;
}
private int GetTokenExpiration(string idToken)
{
try
{
var handler = new JwtSecurityTokenHandler();
var token = handler.ReadJwtToken(idToken);
var exp = token.ValidTo;
return (int)(exp - DateTime.UtcNow).TotalSeconds;
}
catch
{
return 3600; // Default 1 hour
}
}
}
/// <summary>
/// Result of external authentication
/// </summary>
public class ExternalAuthenticationResult
{
public User User { get; set; }
public string AccessToken { get; set; }
public string Provider { get; set; }
public int ExpiresIn { get; set; }
public List<Claim> Claims { get; set; }
}

View File

@@ -3,6 +3,32 @@
public static class AppSettingNames public static class AppSettingNames
{ {
public const string UiTheme = "App.UiTheme"; public const string UiTheme = "App.UiTheme";
/// <summary>
/// External Authentication Settings
/// </summary>
public static class ExternalAuth
{
/// <summary>
/// Enable/disable external authentication module globally
/// </summary>
public const string Enabled = "App.ExternalAuth.Enabled";
/// <summary>
/// Allow local login (username/password) alongside external providers
/// </summary>
public const string AllowLocalLogin = "App.ExternalAuth.AllowLocalLogin";
/// <summary>
/// Automatically provision users on first external login (JIT provisioning)
/// </summary>
public const string AutoProvisionUsers = "App.ExternalAuth.AutoProvisionUsers";
/// <summary>
/// Default role name for auto-provisioned users (empty = no role assigned)
/// </summary>
public const string DefaultRole = "App.ExternalAuth.DefaultRole";
}
} }

View File

@@ -9,7 +9,32 @@ public class AppSettingProvider : SettingProvider
{ {
return new[] return new[]
{ {
new SettingDefinition(AppSettingNames.UiTheme, "red", scopes: SettingScopes.Application | SettingScopes.Tenant | SettingScopes.User, clientVisibilityProvider: new VisibleSettingClientVisibilityProvider()) new SettingDefinition(AppSettingNames.UiTheme, "red", scopes: SettingScopes.Application | SettingScopes.Tenant | SettingScopes.User, clientVisibilityProvider: new VisibleSettingClientVisibilityProvider()),
// External Authentication Settings
new SettingDefinition(
AppSettingNames.ExternalAuth.Enabled,
"false",
scopes: SettingScopes.Application | SettingScopes.Tenant
),
new SettingDefinition(
AppSettingNames.ExternalAuth.AllowLocalLogin,
"true",
scopes: SettingScopes.Application | SettingScopes.Tenant
),
new SettingDefinition(
AppSettingNames.ExternalAuth.AutoProvisionUsers,
"true",
scopes: SettingScopes.Application | SettingScopes.Tenant
),
new SettingDefinition(
AppSettingNames.ExternalAuth.DefaultRole,
"", // Empty = no default role
scopes: SettingScopes.Application | SettingScopes.Tenant
)
}; };
} }
} }

View File

@@ -1,4 +1,5 @@
using Abp.Zero.EntityFrameworkCore; using Abp.Zero.EntityFrameworkCore;
using ASPBaseOIDC.Authorization.ExternalAuth;
using ASPBaseOIDC.Authorization.Roles; using ASPBaseOIDC.Authorization.Roles;
using ASPBaseOIDC.Authorization.Users; using ASPBaseOIDC.Authorization.Users;
using ASPBaseOIDC.MultiTenancy; using ASPBaseOIDC.MultiTenancy;
@@ -12,6 +13,8 @@ public class ASPBaseOIDCDbContext : AbpZeroDbContext<Tenant, Role, User, ASPBase
{ {
/* Define a DbSet for each entity of the application */ /* Define a DbSet for each entity of the application */
public DbSet<ExternalAuthProvider> ExternalAuthProviders { get; set; }
public ASPBaseOIDCDbContext(DbContextOptions<ASPBaseOIDCDbContext> options) public ASPBaseOIDCDbContext(DbContextOptions<ASPBaseOIDCDbContext> options)
: base(options) : base(options)
{ {

View File

@@ -0,0 +1,90 @@
using Abp;
using Abp.Runtime.Security;
using ASPBaseOIDC.Authorization.ExternalAuth;
using System.Linq;
using System.Text.Json;
namespace ASPBaseOIDC.EntityFrameworkCore.Seed.Host;
/// <summary>
/// Seeds default external authentication providers for development/demo purposes
/// </summary>
public class DefaultExternalAuthProvidersCreator
{
private readonly ASPBaseOIDCDbContext _context;
public DefaultExternalAuthProvidersCreator(ASPBaseOIDCDbContext context)
{
_context = context;
}
public void Create()
{
CreateAuthentikProvider();
CreateKeycloakProvider();
}
private void CreateAuthentikProvider()
{
var authentik = _context.ExternalAuthProviders.FirstOrDefault(p => p.Name == "Authentik");
if (authentik == null)
{
var claimMappings = new
{
sub = "sub",
email = "email",
name = "name",
preferred_username = "preferred_username",
groups = "groups"
};
_context.ExternalAuthProviders.Add(new ExternalAuthProvider
{
TenantId = null, // Host provider
Name = "Authentik",
ProviderType = "OIDC",
IsEnabled = false, // Disabled by default - admin must configure and enable
Authority = "http://localhost:9000/application/o/aspbaseoic/",
ClientId = "aspbase-client",
ClientSecret = SimpleStringCipher.Instance.Encrypt("CHANGE_THIS_SECRET"), // Encrypted
Scopes = "openid profile email",
ResponseType = "code",
RequireHttpsMetadata = false, // For local development only
ClaimMappings = JsonSerializer.Serialize(claimMappings),
DisplayOrder = 1
});
}
}
private void CreateKeycloakProvider()
{
var keycloak = _context.ExternalAuthProviders.FirstOrDefault(p => p.Name == "Keycloak");
if (keycloak == null)
{
var claimMappings = new
{
sub = "sub",
email = "email",
name = "name",
preferred_username = "preferred_username",
groups = "groups"
};
_context.ExternalAuthProviders.Add(new ExternalAuthProvider
{
TenantId = null,
Name = "Keycloak",
ProviderType = "OIDC",
IsEnabled = false,
Authority = "http://localhost:8080/realms/master/",
ClientId = "aspbase-client",
ClientSecret = SimpleStringCipher.Instance.Encrypt("CHANGE_THIS_SECRET"),
Scopes = "openid profile email",
ResponseType = "code",
RequireHttpsMetadata = false,
ClaimMappings = JsonSerializer.Serialize(claimMappings),
DisplayOrder = 2
});
}
}
}

View File

@@ -15,6 +15,7 @@ public class InitialHostDbBuilder
new DefaultLanguagesCreator(_context).Create(); new DefaultLanguagesCreator(_context).Create();
new HostRoleAndUserCreator(_context).Create(); new HostRoleAndUserCreator(_context).Create();
new DefaultSettingsCreator(_context).Create(); new DefaultSettingsCreator(_context).Create();
new DefaultExternalAuthProvidersCreator(_context).Create();
_context.SaveChanges(); _context.SaveChanges();
} }

View File

@@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ASPBaseOIDC.Migrations
{
/// <inheritdoc />
public partial class AddExternalAuthProviders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExternalAuthProviders",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TenantId = table.Column<int>(type: "integer", nullable: true),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
ProviderType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
Authority = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
ClientId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ClientSecret = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Scopes = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
ResponseType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
RequireHttpsMetadata = table.Column<bool>(type: "boolean", nullable: false),
ClaimMappings = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
DisplayOrder = table.Column<int>(type: "integer", nullable: false),
CreationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatorUserId = table.Column<long>(type: "bigint", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
LastModifierUserId = table.Column<long>(type: "bigint", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
DeleterUserId = table.Column<long>(type: "bigint", nullable: true),
DeletionTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalAuthProviders", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExternalAuthProviders");
}
}
}

View File

@@ -22,6 +22,89 @@ namespace ASPBaseOIDC.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Authority")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("ClaimMappings")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ClientId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ClientSecret")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime>("CreationTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("CreatorUserId")
.HasColumnType("bigint");
b.Property<long?>("DeleterUserId")
.HasColumnType("bigint");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("DisplayOrder")
.HasColumnType("integer");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastModifierUserId")
.HasColumnType("bigint");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ProviderType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("RequireHttpsMetadata")
.HasColumnType("boolean");
b.Property<string>("ResponseType")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Scopes")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int?>("TenantId")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("ExternalAuthProviders");
});
modelBuilder.Entity("ASPBaseOIDC.Authorization.Roles.Role", b => modelBuilder.Entity("ASPBaseOIDC.Authorization.Roles.Role", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

View File

@@ -0,0 +1,183 @@
using ASPBaseOIDC.Authorization.ExternalAuth;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading.Tasks;
namespace ASPBaseOIDC.Authentication.ExternalAuth
{
/// <summary>
/// Dynamic OIDC authentication handler that validates tokens from multiple external providers
/// Automatically detects provider by token issuer and validates accordingly
/// </summary>
public class DynamicOidcHandler : AuthenticationHandler<JwtBearerOptions>
{
private readonly ExternalAuthProviderManager _providerManager;
private readonly IHttpClientFactory _httpClientFactory;
public DynamicOidcHandler(
IOptionsMonitor<JwtBearerOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
ExternalAuthProviderManager providerManager,
IHttpClientFactory httpClientFactory)
: base(options, logger, encoder, clock)
{
_providerManager = providerManager;
_httpClientFactory = httpClientFactory;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
try
{
// 1. Extract token from Authorization header
var token = ExtractToken();
if (token == null)
{
return AuthenticateResult.NoResult();
}
// 2. Read token without validation to get issuer
var handler = new JwtSecurityTokenHandler();
if (!handler.CanReadToken(token))
{
return AuthenticateResult.NoResult();
}
var jwtToken = handler.ReadJwtToken(token);
var issuer = jwtToken.Issuer;
// 3. Find provider by issuer (cached)
var provider = await _providerManager.GetByIssuerAsync(issuer);
if (provider == null || !provider.IsEnabled)
{
Logger.LogWarning($"No enabled provider found for issuer: {issuer}");
return AuthenticateResult.NoResult();
}
// 4. Validate token with provider configuration
var validationParams = await BuildValidationParametersAsync(provider);
var principal = handler.ValidateToken(token, validationParams, out var validatedToken);
// 5. Create authentication ticket
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (SecurityTokenException ex)
{
Logger.LogWarning(ex, "Token validation failed");
return AuthenticateResult.Fail(ex.Message);
}
catch (Exception ex)
{
Logger.LogError(ex, "Unexpected error during authentication");
return AuthenticateResult.Fail("Authentication failed");
}
}
/// <summary>
/// Extract bearer token from Authorization header
/// </summary>
private string ExtractToken()
{
var authorization = Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authorization))
{
return null;
}
if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return authorization.Substring("Bearer ".Length).Trim();
}
return null;
}
/// <summary>
/// Build token validation parameters for specific provider
/// </summary>
private async Task<TokenValidationParameters> BuildValidationParametersAsync(
ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider provider)
{
// Download OIDC discovery document
var discoveryUrl = provider.Authority.TrimEnd('/') + "/.well-known/openid-configuration";
var httpClient = _httpClientFactory.CreateClient();
var discoveryResponse = await httpClient.GetStringAsync(discoveryUrl);
var discoveryDoc = JsonDocument.Parse(discoveryResponse);
var issuer = discoveryDoc.RootElement.GetProperty("issuer").GetString();
var jwksUri = discoveryDoc.RootElement.GetProperty("jwks_uri").GetString();
// Download JWKS
var jwksResponse = await httpClient.GetStringAsync(jwksUri);
var jwks = JsonDocument.Parse(jwksResponse);
return new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = provider.ClientId,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
{
return ResolveSigningKeys(jwks, kid);
},
ClockSkew = TimeSpan.FromMinutes(5)
};
}
/// <summary>
/// Resolve signing keys from JWKS
/// </summary>
private IEnumerable<SecurityKey> ResolveSigningKeys(JsonDocument jwks, string kid)
{
var keys = jwks.RootElement.GetProperty("keys");
var signingKeys = new List<SecurityKey>();
foreach (var key in keys.EnumerateArray())
{
var keyId = key.GetProperty("kid").GetString();
if (keyId == kid)
{
var kty = key.GetProperty("kty").GetString();
if (kty == "RSA")
{
var e = key.GetProperty("e").GetString();
var n = key.GetProperty("n").GetString();
var rsa = new RsaSecurityKey(new System.Security.Cryptography.RSAParameters
{
Exponent = Base64UrlEncoder.DecodeBytes(e),
Modulus = Base64UrlEncoder.DecodeBytes(n)
})
{
KeyId = keyId
};
signingKeys.Add(rsa);
}
}
}
return signingKeys;
}
}
}

View File

@@ -2,8 +2,10 @@
using Abp.Authorization.Users; using Abp.Authorization.Users;
using Abp.MultiTenancy; using Abp.MultiTenancy;
using Abp.Runtime.Security; using Abp.Runtime.Security;
using ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
using ASPBaseOIDC.Authentication.JwtBearer; using ASPBaseOIDC.Authentication.JwtBearer;
using ASPBaseOIDC.Authorization; using ASPBaseOIDC.Authorization;
using ASPBaseOIDC.Authorization.ExternalAuth;
using ASPBaseOIDC.Authorization.Users; using ASPBaseOIDC.Authorization.Users;
using ASPBaseOIDC.Models.TokenAuth; using ASPBaseOIDC.Models.TokenAuth;
using ASPBaseOIDC.MultiTenancy; using ASPBaseOIDC.MultiTenancy;
@@ -24,17 +26,20 @@ namespace ASPBaseOIDC.Controllers
private readonly ITenantCache _tenantCache; private readonly ITenantCache _tenantCache;
private readonly AbpLoginResultTypeHelper _abpLoginResultTypeHelper; private readonly AbpLoginResultTypeHelper _abpLoginResultTypeHelper;
private readonly TokenAuthConfiguration _configuration; private readonly TokenAuthConfiguration _configuration;
private readonly ExternalAuthenticationManager _externalAuthManager;
public TokenAuthController( public TokenAuthController(
LogInManager logInManager, LogInManager logInManager,
ITenantCache tenantCache, ITenantCache tenantCache,
AbpLoginResultTypeHelper abpLoginResultTypeHelper, AbpLoginResultTypeHelper abpLoginResultTypeHelper,
TokenAuthConfiguration configuration) TokenAuthConfiguration configuration,
ExternalAuthenticationManager externalAuthManager)
{ {
_logInManager = logInManager; _logInManager = logInManager;
_tenantCache = tenantCache; _tenantCache = tenantCache;
_abpLoginResultTypeHelper = abpLoginResultTypeHelper; _abpLoginResultTypeHelper = abpLoginResultTypeHelper;
_configuration = configuration; _configuration = configuration;
_externalAuthManager = externalAuthManager;
} }
[HttpPost] [HttpPost]
@@ -57,6 +62,31 @@ namespace ASPBaseOIDC.Controllers
}; };
} }
/// <summary>
/// Authenticate with external OIDC/OAuth2 provider (Authentik, Keycloak, etc.)
/// Passthrough approach: validates external token and returns it as-is
/// </summary>
[HttpPost]
[AbpAllowAnonymous]
public async Task<AuthenticateResultModel> AuthenticateExternal([FromBody] ExternalAuthModel model)
{
// Authenticate with external provider (validates token, provisions user if needed)
var result = await _externalAuthManager.AuthenticateWithExternalTokenAsync(
model.ProviderName,
model.IdToken,
AbpSession.TenantId
);
// Return original external token (passthrough approach)
return new AuthenticateResultModel
{
AccessToken = result.AccessToken, // Passthrough external token
EncryptedAccessToken = GetEncryptedAccessToken(result.AccessToken),
ExpireInSeconds = result.ExpiresIn,
UserId = result.User.Id
};
}
private string GetTenancyNameOrNull() private string GetTenancyNameOrNull()
{ {
if (!AbpSession.TenantId.HasValue) if (!AbpSession.TenantId.HasValue)

View File

@@ -1,5 +1,7 @@
using Abp.Runtime.Security; using Abp.Runtime.Security;
using ASPBaseOIDC.Authentication.ExternalAuth;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -20,7 +22,9 @@ namespace ASPBaseOIDC.Web.Host.Startup
{ {
options.DefaultAuthenticateScheme = "JwtBearer"; options.DefaultAuthenticateScheme = "JwtBearer";
options.DefaultChallengeScheme = "JwtBearer"; options.DefaultChallengeScheme = "JwtBearer";
}).AddJwtBearer("JwtBearer", options => })
// Local JWT Bearer authentication (username/password)
.AddJwtBearer("JwtBearer", options =>
{ {
options.Audience = configuration["Authentication:JwtBearer:Audience"]; options.Audience = configuration["Authentication:JwtBearer:Audience"];
@@ -49,6 +53,19 @@ namespace ASPBaseOIDC.Web.Host.Startup
{ {
OnMessageReceived = QueryStringTokenResolver OnMessageReceived = QueryStringTokenResolver
}; };
})
// Dynamic OIDC authentication for external providers (Authentik, Keycloak, etc.)
.AddScheme<JwtBearerOptions, DynamicOidcHandler>("DynamicOidc", options =>
{
// Options are handled dynamically by DynamicOidcHandler
});
// Configure authorization to accept both local and external tokens
services.AddAuthorization(options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder("JwtBearer", "DynamicOidc")
.RequireAuthenticatedUser()
.Build();
}); });
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"ConnectionStrings": { "ConnectionStrings": {
"Default": "User Id=postgres;Password=admin57$;Server=134.199.235.213;Port=5432;Database=isa_base_auth_db" "Default": "User Id=postgres;Password=admin57$;Server=134.199.235.213;Port=5432;Database=isa_base_auth"
}, },
"App": { "App": {
"ServerRootAddress": "https://localhost:44311/", "ServerRootAddress": "https://localhost:44311/",