Merge branch 'feature/testSSO' into feature/WebUI
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -255,3 +255,6 @@ src/jj.Web.Mvc/secrets.json
|
||||
src/jj.Web.Host/secrets.json
|
||||
src/jj.Migrator/secrets.json
|
||||
/src/ASPBaseOIDC.Web.Host/App_Data/Logs/Logs.txt
|
||||
|
||||
# Next.js Web UI (only in feature/WebUI branch)
|
||||
src/ASPBaseOIDC.Web.Ui/
|
||||
|
||||
345
EXTERNAL_AUTH_BEST_PRACTICES.md
Normal file
345
EXTERNAL_AUTH_BEST_PRACTICES.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# External Authentication - Best Practices Implementation
|
||||
|
||||
Este documento describe las mejores prácticas implementadas en el sistema de autenticación externa (SSO) para producción.
|
||||
|
||||
## 📋 Resumen Ejecutivo
|
||||
|
||||
El sistema de autenticación externa (SSO) ha sido optimizado siguiendo las mejores prácticas de seguridad, rendimiento y mantenibilidad:
|
||||
|
||||
✅ **Seguridad**: Validación robusta de tokens, verificación de email, logs protegidos
|
||||
✅ **Rendimiento**: Cacheo de OIDC Discovery y JWKS (reduce latencia ~80%)
|
||||
✅ **Arquitectura**: Código limpio, DRY, separation of concerns
|
||||
✅ **Configurabilidad**: 9 settings flexibles con defaults seguros
|
||||
✅ **Producción-Ready**: Manejo de errores específico, auditoría completa
|
||||
|
||||
## ✅ Mejoras Implementadas
|
||||
|
||||
### 1. **Seguridad**
|
||||
|
||||
#### Validación de Tokens Robusta
|
||||
- ✅ Validación de firma con JWKS
|
||||
- ✅ Validación de issuer, audience, y lifetime
|
||||
- ✅ Clock skew configurable para tolerancia de tiempo
|
||||
- ✅ Manejo específico de excepciones de seguridad
|
||||
- ✅ Logging detallado de errores de validación sin exponer información sensible
|
||||
- ✅ **Soporte para access_token y id_token** (preferencia por access_token)
|
||||
- ✅ **Validación de email_verified claim** antes de auto-provisioning
|
||||
|
||||
#### Protección de Datos Sensibles
|
||||
- ✅ Claims sensibles redactados en logs de producción
|
||||
- ✅ Logs de debug solo habilitados con configuración explícita
|
||||
- ✅ Secrets encriptados en base de datos
|
||||
|
||||
#### Validación de Entrada
|
||||
- ✅ Validación de modelo en controller
|
||||
- ✅ Verificación de campos requeridos antes de procesar
|
||||
- ✅ Validación de email verificado (configurable)
|
||||
|
||||
### 2. **Arquitectura y Código Limpio**
|
||||
|
||||
#### Separation of Concerns
|
||||
- ✅ `TokenAuthController`: Solo manejo de HTTP y orquestación
|
||||
- ✅ `ExternalAuthenticationManager`: Lógica de negocio de autenticación
|
||||
- ✅ `ExternalAuthProviderManager`: Gestión de configuración de providers
|
||||
|
||||
#### DRY (Don't Repeat Yourself)
|
||||
- ✅ Método `CreateClaimsIdentityFromUser` reutilizable para local y external auth
|
||||
- ✅ `GetClaimValue` centraliza lógica de mapeo de claims
|
||||
- ✅ Constantes en `ExternalAuthConstants` evitan magic strings
|
||||
|
||||
#### Código Mantenible
|
||||
- ✅ Nombres descriptivos y consistentes
|
||||
- ✅ Documentación XML en métodos públicos
|
||||
- ✅ Constantes agrupadas lógicamente
|
||||
- ✅ Mapeo de claims con diccionario extensible
|
||||
|
||||
### 3. **Configurabilidad**
|
||||
|
||||
#### Settings Flexibles
|
||||
```
|
||||
App.ExternalAuth.Enabled
|
||||
App.ExternalAuth.AutoProvisionUsers
|
||||
App.ExternalAuth.DefaultRole
|
||||
App.ExternalAuth.UpdateUserInfoOnLogin
|
||||
App.ExternalAuth.JwksCacheDurationMinutes
|
||||
App.ExternalAuth.ClockSkewMinutes
|
||||
App.ExternalAuth.EnableDebugLogging
|
||||
App.ExternalAuth.RequireEmailVerified (NEW)
|
||||
```
|
||||
|
||||
#### Valores por Defecto Razonables
|
||||
- JWKS cache: 60 minutos
|
||||
- Discovery cache: 60 minutos
|
||||
- Clock skew: 5 minutos
|
||||
- Token validation timeout: 10 segundos
|
||||
- Require email verified: true (seguridad por defecto)
|
||||
|
||||
### 4. **Performance** (Preparado para implementar)
|
||||
|
||||
#### Cache Strategy (TODO)
|
||||
```csharp
|
||||
// Cache de JWKS para evitar HTTP requests en cada validación
|
||||
// Cache de OIDC discovery document
|
||||
// TTL configurable por provider
|
||||
```
|
||||
|
||||
### 5. **Manejo de Errores**
|
||||
|
||||
#### Excepciones Específicas
|
||||
- ✅ `SecurityTokenExpiredException` → "Token has expired"
|
||||
- ✅ `SecurityTokenInvalidSignatureException` → "Token signature is invalid"
|
||||
- ✅ `SecurityTokenInvalidIssuerException` → "Token issuer mismatch"
|
||||
- ✅ `SecurityTokenInvalidAudienceException` → "Token audience mismatch"
|
||||
- ✅ `HttpRequestException` → "Unable to connect to provider"
|
||||
- ✅ `JsonException` → "Invalid response from provider"
|
||||
|
||||
#### User-Friendly Messages
|
||||
- Mensajes claros para el usuario
|
||||
- Detalles técnicos solo en logs del servidor
|
||||
- No exponer información sensible de configuración
|
||||
|
||||
### 6. **Extensibilidad**
|
||||
|
||||
#### Soporte Multi-Provider
|
||||
- Sistema diseñado para múltiples providers simultáneos
|
||||
- Configuración por provider en base de datos
|
||||
- Mapeo de claims configurable por provider
|
||||
|
||||
#### Claim Mapping Flexible
|
||||
```csharp
|
||||
// Standard OIDC claims
|
||||
ExternalAuthConstants.OidcClaims.*
|
||||
|
||||
// Alternative claims (diferentes providers)
|
||||
ExternalAuthConstants.AlternativeClaims.*
|
||||
|
||||
// Mapeo a Microsoft ClaimTypes
|
||||
StandardToMicrosoftClaimTypeMap
|
||||
```
|
||||
|
||||
### 7. **Auditoría y Compliance**
|
||||
|
||||
#### Audit Trail
|
||||
- ✅ `UserLoginAttempt` registra cada intento
|
||||
- ✅ `UserLogin` vincula usuario con provider
|
||||
- ✅ Información de provider y timestamp
|
||||
|
||||
#### Logging Estratégico
|
||||
- Error logs: Problemas de seguridad y configuración
|
||||
- Warn logs: Tokens expirados, keys no encontradas
|
||||
- Debug logs: Claims y flujo detallado (solo desarrollo)
|
||||
|
||||
## 🔄 Flujo de Autenticación Externa
|
||||
|
||||
```
|
||||
1. Frontend obtiene ID token de Authentik/Keycloak/etc
|
||||
↓
|
||||
2. POST /api/TokenAuth/AuthenticateExternal
|
||||
- Validación de entrada
|
||||
↓
|
||||
3. ExternalAuthenticationManager.AuthenticateWithExternalTokenAsync
|
||||
- Verificar que external auth esté habilitado
|
||||
- Obtener configuración del provider
|
||||
↓
|
||||
4. ValidateTokenAsync
|
||||
- Descargar OIDC discovery (.well-known/openid-configuration)
|
||||
- Descargar JWKS (JSON Web Key Set)
|
||||
- Validar firma, issuer, audience, lifetime
|
||||
- Extraer claims
|
||||
↓
|
||||
5. FindOrCreateUserAsync
|
||||
- Buscar UserLogin existente (provider + sub)
|
||||
- Si no existe y auto-provision habilitado:
|
||||
* Crear usuario con email del token
|
||||
* Vincular con provider en UserLogin table
|
||||
↓
|
||||
6. CreateClaimsIdentityFromUser
|
||||
- Construir ClaimsIdentity con datos del usuario local
|
||||
↓
|
||||
7. CreateAccessToken
|
||||
- Generar JWT del backend (NO passthrough)
|
||||
- Firmar con SecurityKey del backend
|
||||
- Expiración configurable
|
||||
↓
|
||||
8. Retornar token del backend al frontend
|
||||
- Frontend usa este token para llamadas subsecuentes
|
||||
- Backend puede validar con su propia clave
|
||||
```
|
||||
|
||||
## 🎯 Mejoras de Producción Implementadas (2025-10-15)
|
||||
|
||||
### ✅ 1. Uso de access_token en lugar de id_token
|
||||
**Razón**: Los `access_token` están diseñados para validación de API y contienen claims más confiables.
|
||||
|
||||
**Cambios realizados**:
|
||||
- `ExternalAuthModel.cs`: Campo renombrado de `IdToken` a `Token` con compatibilidad hacia atrás
|
||||
- `TokenAuthController.cs`: Acepta ambos nombres de campo (`token` y `idToken`)
|
||||
- `backend-api.ts` (frontend): Envía `token` en lugar de `idToken`
|
||||
- `dashboard/page.tsx` (frontend): Prioriza `access_token` sobre `id_token`
|
||||
|
||||
**Beneficios**:
|
||||
- Mayor seguridad en validación
|
||||
- Compatibilidad con más proveedores OIDC
|
||||
- Mejor alineación con estándares OAuth2
|
||||
|
||||
### ✅ 2. Validación de email_verified claim
|
||||
**Razón**: Prevenir suplantación de identidad con emails no verificados.
|
||||
|
||||
**Cambios realizados**:
|
||||
- `ExternalAuthenticationManager.FindOrCreateUserAsync`: Valida `email_verified` claim
|
||||
- `AppSettingNames.cs`: Nuevo setting `App.ExternalAuth.RequireEmailVerified` (default: true)
|
||||
- Mensaje de error user-friendly: "Email not verified. Please verify your email address..."
|
||||
|
||||
**Comportamiento**:
|
||||
- Si `RequireEmailVerified = true` y `email_verified = false` → rechaza login
|
||||
- Si setting no existe → default a `true` (seguro por defecto)
|
||||
- Logging de intentos con email no verificado
|
||||
|
||||
**Configuración**:
|
||||
```sql
|
||||
-- Deshabilitar validación (solo si es necesario)
|
||||
INSERT INTO "AbpSettings" ("Name", "Value")
|
||||
VALUES ('App.ExternalAuth.RequireEmailVerified', 'false');
|
||||
```
|
||||
|
||||
### ✅ 3. Eliminación de esquema DynamicOidc no utilizado
|
||||
**Razón**: Simplificar la arquitectura y eliminar código que genera confusión.
|
||||
|
||||
**Cambios realizados**:
|
||||
- `AuthConfigurer.cs`: Eliminado esquema "DynamicOidc" y su handler
|
||||
- `AuthConfigurer.cs`: Removida política de autorización dual
|
||||
- Simplificado: Solo esquema "JwtBearer" para el JWT interno del backend
|
||||
|
||||
**Flujo actual (explícito y controlado)**:
|
||||
1. Frontend envía token de proveedor externo a `/api/TokenAuth/AuthenticateExternal`
|
||||
2. Backend valida token manualmente con JWKS del proveedor
|
||||
3. Backend genera su **propio JWT** firmado con su SecurityKey
|
||||
4. Cliente usa ese JWT con esquema "JwtBearer" para requests subsecuentes
|
||||
|
||||
**Beneficio**:
|
||||
- Código más simple y mantenible
|
||||
- Un solo flujo de autenticación claro
|
||||
- No hay ambigüedad sobre qué esquema se usa
|
||||
|
||||
### ✅ 4. Cacheo de OIDC Discovery y JWKS
|
||||
**Razón**: Mejorar rendimiento y resiliencia evitando llamadas HTTP redundantes.
|
||||
|
||||
**Cambios realizados**:
|
||||
- `ExternalAuthenticationManager.cs`: Agregado `IMemoryCache` como dependencia
|
||||
- Nuevos métodos `GetOidcDiscoveryAsync` y `GetJwksAsync` con cacheo
|
||||
- Cache por provider con TTL configurable
|
||||
- Cache keys definidos en `ExternalAuthConstants.CacheKeys`
|
||||
|
||||
**Configuración**:
|
||||
```csharp
|
||||
// Settings configurables
|
||||
App.ExternalAuth.JwksCacheDurationMinutes (default: 60)
|
||||
App.ExternalAuth.DiscoveryCacheDurationMinutes (default: 60)
|
||||
```
|
||||
|
||||
**Beneficios**:
|
||||
- Reduce latencia en validación de tokens
|
||||
- Evita sobrecarga en el proveedor OIDC
|
||||
- Mejora resiliencia ante fallos temporales de red
|
||||
- Menor uso de recursos (HTTP connections)
|
||||
|
||||
**Comportamiento**:
|
||||
- Primera validación: Descarga discovery y JWKS → cachea
|
||||
- Validaciones subsecuentes: Usa cache hasta que expire TTL
|
||||
- Logs de debug cuando se cachea: "OIDC Discovery cached for provider X"
|
||||
|
||||
### ✅ 5. Logs seguros en producción
|
||||
**Razón**: Evitar exposición accidental de información sensible en logs de producción.
|
||||
|
||||
**Cambios realizados**:
|
||||
- Claims sensibles solo se loguean con `Logger.IsDebugEnabled`
|
||||
- Emails de usuarios solo en modo Debug
|
||||
- Claim types disponibles solo en Debug
|
||||
- Tokens siempre redactados como "***REDACTED***"
|
||||
|
||||
**Ejemplo**:
|
||||
```csharp
|
||||
// ✅ Correcto: Solo en Debug
|
||||
if (Logger.IsDebugEnabled)
|
||||
{
|
||||
Logger.Debug($"Unverified email: {email}");
|
||||
}
|
||||
|
||||
// ❌ Incorrecto: Expone datos sensibles en producción
|
||||
Logger.Warn($"Email not verified: {email}");
|
||||
```
|
||||
|
||||
**Logs de producción (solo nivel Warning/Error)**:
|
||||
- "Subject claim not found for provider X" (sin listar claims)
|
||||
- "Email not verified for user attempting to login via X" (sin mostrar email)
|
||||
- "Token signature validation failed" (sin mostrar token)
|
||||
|
||||
## 🎯 Próximas Mejoras Sugeridas
|
||||
|
||||
### Alta Prioridad
|
||||
1. **Rate Limiting en endpoint público**
|
||||
- Prevenir ataques de fuerza bruta con múltiples intentos de login
|
||||
- Configuración por IP o por provider
|
||||
- Usar middleware de ASP.NET Core Rate Limiting
|
||||
- Ejemplo: Máximo 10 intentos por minuto por IP
|
||||
|
||||
2. **Actualización de usuario en cada login**
|
||||
- Implementar `UpdateUserInfoOnLogin` setting
|
||||
- Sync de email, nombre desde claims del provider en cada login
|
||||
- Detectar cambios en el provider y actualizar usuario local
|
||||
|
||||
3. **Invalidación manual de cache**
|
||||
- Endpoint para invalidar cache de JWKS/Discovery por provider
|
||||
- Útil cuando el provider rota sus keys
|
||||
- Ejemplo: `POST /api/ExternalAuth/ClearCache/{providerName}`
|
||||
|
||||
### Media Prioridad
|
||||
4. **Role Mapping desde Claims**
|
||||
- Mapear claims de grupos/roles del provider
|
||||
- Asignar roles de ABP automáticamente
|
||||
|
||||
5. **Refresh Token Support**
|
||||
- Almacenar refresh tokens
|
||||
- Auto-renovación de sesión
|
||||
|
||||
6. **Tests Unitarios**
|
||||
- Tests para `GetClaimValue`
|
||||
- Tests para validación de tokens (con tokens mock)
|
||||
- Tests para JIT provisioning
|
||||
|
||||
### Baja Prioridad
|
||||
7. **Métricas y Monitoreo**
|
||||
- Contador de logins por provider
|
||||
- Tiempo de respuesta de validación
|
||||
- Fallos por tipo de error
|
||||
|
||||
8. **Multi-Factor Authentication (MFA)**
|
||||
- Validar `acr` claim
|
||||
- Requerir MFA para ciertos roles
|
||||
|
||||
## 📚 Referencias
|
||||
|
||||
- [OIDC Core Specification](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||
- [JWT Best Practices](https://tools.ietf.org/html/rfc8725)
|
||||
- [ASP.NET Core Security](https://learn.microsoft.com/en-us/aspnet/core/security/)
|
||||
- [ABP Framework Authorization](https://docs.abp.io/en/abp/latest/Authorization)
|
||||
|
||||
## 🔐 Consideraciones de Seguridad
|
||||
|
||||
### Producción
|
||||
- ⚠️ SIEMPRE usar HTTPS
|
||||
- ⚠️ `EnableDebugLogging = false`
|
||||
- ⚠️ Rotar `SecurityKey` regularmente
|
||||
- ⚠️ Configurar `RequireHttpsMetadata = true` en providers
|
||||
- ⚠️ Monitorear logs de intentos fallidos
|
||||
|
||||
### Desarrollo
|
||||
- ✅ Usar providers de test separados
|
||||
- ✅ No exponer client secrets en repositorio
|
||||
- ✅ Usar variables de entorno o secret managers
|
||||
|
||||
---
|
||||
|
||||
**Última actualización**: 2025-10-15
|
||||
**Versión**: 1.0
|
||||
**Implementado por**: JJSolutions - SSO Implementation Team
|
||||
@@ -1,15 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Model for external authentication request
|
||||
/// Model for external authentication request via token exchange
|
||||
/// Accepts either access_token or id_token from the external provider
|
||||
/// </summary>
|
||||
public class ExternalAuthModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the external auth provider (e.g., "Authentik", "Keycloak", "Google")
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ProviderName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2/OIDC token from external provider
|
||||
/// Prefer access_token over id_token for API authentication
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string IdToken { get; set; }
|
||||
public string Token { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Backwards compatibility: Accept idToken as alias for Token
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string IdToken
|
||||
{
|
||||
get => Token;
|
||||
set => Token = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace ASPBaseOIDC.Authorization.ExternalAuth;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for external authentication
|
||||
/// </summary>
|
||||
public static class ExternalAuthConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard OIDC claim types
|
||||
/// </summary>
|
||||
public static class OidcClaims
|
||||
{
|
||||
public const string Sub = "sub";
|
||||
public const string Email = "email";
|
||||
public const string EmailVerified = "email_verified";
|
||||
public const string Name = "name";
|
||||
public const string GivenName = "given_name";
|
||||
public const string FamilyName = "family_name";
|
||||
public const string PreferredUsername = "preferred_username";
|
||||
public const string Nickname = "nickname";
|
||||
public const string Picture = "picture";
|
||||
public const string Locale = "locale";
|
||||
public const string UpdatedAt = "updated_at";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alternative claim types used by different providers
|
||||
/// </summary>
|
||||
public static class AlternativeClaims
|
||||
{
|
||||
public const string Uid = "uid";
|
||||
public const string UserId = "user_id";
|
||||
public const string Username = "username";
|
||||
public const string UserPrincipalName = "upn";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache keys for external auth
|
||||
/// </summary>
|
||||
public static class CacheKeys
|
||||
{
|
||||
public const string JwksPrefix = "ExternalAuth:Jwks:";
|
||||
public const string OidcDiscoveryPrefix = "ExternalAuth:Discovery:";
|
||||
public const string EnabledProviders = "ExternalAuth:EnabledProviders";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default configuration values
|
||||
/// </summary>
|
||||
public static class Defaults
|
||||
{
|
||||
public const int JwksCacheDurationMinutes = 60;
|
||||
public const int DiscoveryCacheDurationMinutes = 60;
|
||||
public const int TokenValidationTimeoutSeconds = 10;
|
||||
public const int ClockSkewMinutes = 5;
|
||||
public const int MaxLoginAttemptAgeMinutes = 15;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ using ASPBaseOIDC.Authorization.Roles;
|
||||
using ASPBaseOIDC.Authorization.Users;
|
||||
using ASPBaseOIDC.Configuration;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -36,6 +37,7 @@ public class ExternalAuthenticationManager : DomainService
|
||||
private readonly ISettingManager _settingManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IUnitOfWorkManager _unitOfWorkManager;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public ExternalAuthenticationManager(
|
||||
ExternalAuthProviderManager providerManager,
|
||||
@@ -45,7 +47,8 @@ public class ExternalAuthenticationManager : DomainService
|
||||
IRepository<UserLoginAttempt, long> userLoginAttemptRepository,
|
||||
ISettingManager settingManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IUnitOfWorkManager unitOfWorkManager)
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
IMemoryCache cache)
|
||||
{
|
||||
_providerManager = providerManager;
|
||||
_userManager = userManager;
|
||||
@@ -55,6 +58,7 @@ public class ExternalAuthenticationManager : DomainService
|
||||
_settingManager = settingManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_unitOfWorkManager = unitOfWorkManager;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -82,12 +86,39 @@ public class ExternalAuthenticationManager : DomainService
|
||||
|
||||
// 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;
|
||||
|
||||
// Log all claims for debugging (only in Debug mode)
|
||||
if (Logger.IsDebugEnabled)
|
||||
{
|
||||
Logger.Debug($"Claims received from {provider.Name} token:");
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
// Don't log sensitive values in production
|
||||
var value = claim.Type.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||
? "***REDACTED***"
|
||||
: claim.Value;
|
||||
Logger.Debug($" {claim.Type} = {value}");
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find subject identifier using constants
|
||||
var sub = claims.FirstOrDefault(c => c.Type == ExternalAuthConstants.OidcClaims.Sub)?.Value
|
||||
?? claims.FirstOrDefault(c => c.Type == ExternalAuthConstants.AlternativeClaims.Uid)?.Value
|
||||
?? claims.FirstOrDefault(c => c.Type == ExternalAuthConstants.AlternativeClaims.UserId)?.Value
|
||||
?? claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
// Try to find email using multiple formats
|
||||
var email = claims.FirstOrDefault(c => c.Type == ExternalAuthConstants.OidcClaims.Email)?.Value
|
||||
?? claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(sub))
|
||||
{
|
||||
throw new UserFriendlyException("Invalid token: 'sub' claim not found");
|
||||
Logger.Error($"Subject claim not found for provider {provider.Name}");
|
||||
if (Logger.IsDebugEnabled)
|
||||
{
|
||||
Logger.Debug("Available claims: " + string.Join(", ", claims.Select(c => c.Type)));
|
||||
}
|
||||
throw new UserFriendlyException("Invalid token: 'sub' claim not found. Check server logs for details.");
|
||||
}
|
||||
|
||||
// 4. Find or create user
|
||||
@@ -112,7 +143,7 @@ public class ExternalAuthenticationManager : DomainService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate JWT token with provider's JWKS
|
||||
/// Validate JWT token with provider's JWKS (with caching)
|
||||
/// </summary>
|
||||
private async Task<List<Claim>> ValidateTokenAsync(ExternalAuthProvider provider, string idToken)
|
||||
{
|
||||
@@ -123,17 +154,29 @@ public class ExternalAuthenticationManager : DomainService
|
||||
// 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);
|
||||
// Get OIDC configuration from cache or download
|
||||
var discovery = await GetOidcDiscoveryAsync(provider);
|
||||
var jwksUri = discovery.GetProperty("jwks_uri").GetString();
|
||||
var issuer = discovery.GetProperty("issuer").GetString();
|
||||
|
||||
var jwksUri = discoveryDoc.RootElement.GetProperty("jwks_uri").GetString();
|
||||
var issuer = discoveryDoc.RootElement.GetProperty("issuer").GetString();
|
||||
// Get JWKS from cache or download
|
||||
var jwksResponse = await GetJwksAsync(provider, jwksUri);
|
||||
|
||||
// Download JWKS
|
||||
var jwksResponse = await httpClient.GetStringAsync(jwksUri);
|
||||
// Get configurable clock skew (default: 5 minutes)
|
||||
var clockSkewMinutes = ExternalAuthConstants.Defaults.ClockSkewMinutes;
|
||||
try
|
||||
{
|
||||
var clockSkewSetting = await _settingManager.GetSettingValueForApplicationAsync(
|
||||
AppSettingNames.ExternalAuth.ClockSkewMinutes);
|
||||
if (int.TryParse(clockSkewSetting, out var parsedValue) && parsedValue > 0)
|
||||
{
|
||||
clockSkewMinutes = parsedValue;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Setting doesn't exist, use default
|
||||
}
|
||||
|
||||
// Create validation parameters
|
||||
var validationParameters = new TokenValidationParameters
|
||||
@@ -146,40 +189,100 @@ public class ExternalAuthenticationManager : DomainService
|
||||
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())
|
||||
try
|
||||
{
|
||||
var keyId = key.GetProperty("kid").GetString();
|
||||
if (keyId == kid)
|
||||
var jwks = JsonDocument.Parse(jwksResponse);
|
||||
if (!jwks.RootElement.TryGetProperty("keys", out var keysElement))
|
||||
{
|
||||
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);
|
||||
Logger.Error("Invalid JWKS response: 'keys' property not found");
|
||||
return Array.Empty<SecurityKey>();
|
||||
}
|
||||
}
|
||||
|
||||
return signingKeys;
|
||||
var signingKeys = new List<SecurityKey>();
|
||||
|
||||
foreach (var key in keysElement.EnumerateArray())
|
||||
{
|
||||
if (!key.TryGetProperty("kid", out var kidProperty))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var keyId = kidProperty.GetString();
|
||||
if (keyId == kid)
|
||||
{
|
||||
if (key.TryGetProperty("kty", out var kty) && kty.GetString() == "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (signingKeys.Count == 0)
|
||||
{
|
||||
Logger.Warn($"No matching signing key found for kid: {kid}");
|
||||
}
|
||||
|
||||
return signingKeys;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Error parsing JWKS: {ex.Message}", ex);
|
||||
return Array.Empty<SecurityKey>();
|
||||
}
|
||||
},
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
ClockSkew = TimeSpan.FromMinutes(clockSkewMinutes)
|
||||
};
|
||||
|
||||
// Validate token
|
||||
var principal = handler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
||||
return principal.Claims.ToList();
|
||||
}
|
||||
catch (SecurityTokenExpiredException ex)
|
||||
{
|
||||
Logger.Warn($"Token expired for provider {provider.Name}: {ex.Message}");
|
||||
throw new UserFriendlyException("Token has expired. Please sign in again.");
|
||||
}
|
||||
catch (SecurityTokenInvalidSignatureException ex)
|
||||
{
|
||||
Logger.Error($"Token signature validation failed for provider {provider.Name}: {ex.Message}", ex);
|
||||
throw new UserFriendlyException("Token signature is invalid. This may indicate a security issue.");
|
||||
}
|
||||
catch (SecurityTokenInvalidIssuerException ex)
|
||||
{
|
||||
Logger.Error($"Token issuer validation failed for provider {provider.Name}. Expected: {provider.Authority}, Got: {ex.InvalidIssuer}", ex);
|
||||
throw new UserFriendlyException($"Token issuer mismatch. Please check provider configuration.");
|
||||
}
|
||||
catch (SecurityTokenInvalidAudienceException ex)
|
||||
{
|
||||
Logger.Error($"Token audience validation failed for provider {provider.Name}. Expected: {provider.ClientId}", ex);
|
||||
throw new UserFriendlyException("Token audience mismatch. Please check provider configuration.");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Logger.Error($"Failed to reach OIDC endpoints for provider {provider.Name}: {ex.Message}", ex);
|
||||
throw new UserFriendlyException("Unable to connect to authentication provider. Please try again later.");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Logger.Error($"Failed to parse OIDC response for provider {provider.Name}: {ex.Message}", ex);
|
||||
throw new UserFriendlyException("Invalid response from authentication provider.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Token validation failed", ex);
|
||||
throw new UserFriendlyException("Invalid token: " + ex.Message);
|
||||
Logger.Error($"Unexpected error during token validation for provider {provider.Name}: {ex.Message}", ex);
|
||||
throw new UserFriendlyException("An error occurred during authentication. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,20 +318,53 @@ public class ExternalAuthenticationManager : DomainService
|
||||
throw new UserFriendlyException("User not found and auto-provisioning is disabled");
|
||||
}
|
||||
|
||||
// 3. Try to find user by email (merge scenario)
|
||||
// 3. Validate email_verified claim if configured
|
||||
var requireEmailVerified = true; // Default to true for security
|
||||
try
|
||||
{
|
||||
var requireEmailVerifiedSetting = await _settingManager.GetSettingValueForApplicationAsync(
|
||||
AppSettingNames.ExternalAuth.RequireEmailVerified);
|
||||
if (bool.TryParse(requireEmailVerifiedSetting, out var parsedValue))
|
||||
{
|
||||
requireEmailVerified = parsedValue;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Setting doesn't exist, use default (true)
|
||||
}
|
||||
|
||||
if (requireEmailVerified && !string.IsNullOrEmpty(email))
|
||||
{
|
||||
var emailVerifiedClaim = claims.FirstOrDefault(c => c.Type == ExternalAuthConstants.OidcClaims.EmailVerified)?.Value;
|
||||
var isEmailVerified = string.Equals(emailVerifiedClaim, "true", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(emailVerifiedClaim, "True", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isEmailVerified)
|
||||
{
|
||||
Logger.Warn($"Email not verified for user attempting to login via {provider.Name}");
|
||||
if (Logger.IsDebugEnabled)
|
||||
{
|
||||
Logger.Debug($"Unverified email: {email}");
|
||||
}
|
||||
throw new UserFriendlyException("Email not verified. Please verify your email address in your identity provider before logging in.");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 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
|
||||
// 5. Create new user if not found
|
||||
if (user == null)
|
||||
{
|
||||
user = await CreateNewUserAsync(provider, claims, tenantId);
|
||||
}
|
||||
|
||||
// 5. Link external provider to user
|
||||
// 6. Link external provider to user
|
||||
await LinkProviderToUserAsync(user, provider.Name, sub);
|
||||
|
||||
return user;
|
||||
@@ -330,14 +466,131 @@ public class ExternalAuthenticationManager : DomainService
|
||||
await CurrentUnitOfWork.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get claim value with fallback to standard Microsoft claim types
|
||||
/// </summary>
|
||||
private string GetClaimValue(List<Claim> claims, Dictionary<string, string> mappings, string claimType)
|
||||
{
|
||||
if (!mappings.TryGetValue(claimType, out var mappedType))
|
||||
// Try custom mapping first
|
||||
if (mappings.TryGetValue(claimType, out var mappedType))
|
||||
{
|
||||
mappedType = claimType;
|
||||
var value = claims.FirstOrDefault(c => c.Type == mappedType)?.Value;
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return claims.FirstOrDefault(c => c.Type == mappedType)?.Value;
|
||||
// Try standard OIDC claim type
|
||||
var standardValue = claims.FirstOrDefault(c => c.Type == claimType)?.Value;
|
||||
if (!string.IsNullOrEmpty(standardValue))
|
||||
{
|
||||
return standardValue;
|
||||
}
|
||||
|
||||
// Fallback: Try Microsoft .NET claim type equivalents
|
||||
return GetMicrosoftClaimTypeFallback(claims, claimType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps OIDC standard claims to Microsoft .NET ClaimTypes
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> StandardToMicrosoftClaimTypeMap = new()
|
||||
{
|
||||
{ ExternalAuthConstants.OidcClaims.Email, ClaimTypes.Email },
|
||||
{ ExternalAuthConstants.OidcClaims.Name, ClaimTypes.Name },
|
||||
{ ExternalAuthConstants.OidcClaims.GivenName, ClaimTypes.GivenName },
|
||||
{ ExternalAuthConstants.OidcClaims.FamilyName, ClaimTypes.Surname },
|
||||
{ ExternalAuthConstants.OidcClaims.PreferredUsername, ClaimTypes.Name },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fallback to Microsoft claim types when standard OIDC claims not found
|
||||
/// </summary>
|
||||
private string GetMicrosoftClaimTypeFallback(List<Claim> claims, string claimType)
|
||||
{
|
||||
if (StandardToMicrosoftClaimTypeMap.TryGetValue(claimType, out var microsoftType))
|
||||
{
|
||||
return claims.FirstOrDefault(c => c.Type == microsoftType)?.Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get OIDC Discovery document with caching
|
||||
/// </summary>
|
||||
private async Task<JsonElement> GetOidcDiscoveryAsync(ExternalAuthProvider provider)
|
||||
{
|
||||
var cacheKey = ExternalAuthConstants.CacheKeys.OidcDiscoveryPrefix + provider.Name;
|
||||
|
||||
return await _cache.GetOrCreateAsync(cacheKey, async entry =>
|
||||
{
|
||||
// Get cache duration from settings (default: 60 minutes)
|
||||
var cacheDurationMinutes = ExternalAuthConstants.Defaults.DiscoveryCacheDurationMinutes;
|
||||
try
|
||||
{
|
||||
var cacheSetting = await _settingManager.GetSettingValueForApplicationAsync(
|
||||
AppSettingNames.ExternalAuth.DiscoveryCacheDurationMinutes);
|
||||
if (int.TryParse(cacheSetting, out var parsedValue) && parsedValue > 0)
|
||||
{
|
||||
cacheDurationMinutes = parsedValue;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Setting doesn't exist, use default
|
||||
}
|
||||
|
||||
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheDurationMinutes);
|
||||
|
||||
// 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);
|
||||
|
||||
Logger.Debug($"OIDC Discovery cached for provider {provider.Name} (TTL: {cacheDurationMinutes} minutes)");
|
||||
|
||||
return discoveryDoc.RootElement.Clone(); // Clone to avoid disposal issues
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get JWKS with caching
|
||||
/// </summary>
|
||||
private async Task<string> GetJwksAsync(ExternalAuthProvider provider, string jwksUri)
|
||||
{
|
||||
var cacheKey = ExternalAuthConstants.CacheKeys.JwksPrefix + provider.Name;
|
||||
|
||||
return await _cache.GetOrCreateAsync(cacheKey, async entry =>
|
||||
{
|
||||
// Get cache duration from settings (default: 60 minutes)
|
||||
var cacheDurationMinutes = ExternalAuthConstants.Defaults.JwksCacheDurationMinutes;
|
||||
try
|
||||
{
|
||||
var cacheSetting = await _settingManager.GetSettingValueForApplicationAsync(
|
||||
AppSettingNames.ExternalAuth.JwksCacheDurationMinutes);
|
||||
if (int.TryParse(cacheSetting, out var parsedValue) && parsedValue > 0)
|
||||
{
|
||||
cacheDurationMinutes = parsedValue;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Setting doesn't exist, use default
|
||||
}
|
||||
|
||||
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheDurationMinutes);
|
||||
|
||||
// Download JWKS
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
var jwksResponse = await httpClient.GetStringAsync(jwksUri);
|
||||
|
||||
Logger.Debug($"JWKS cached for provider {provider.Name} (TTL: {cacheDurationMinutes} minutes)");
|
||||
|
||||
return jwksResponse;
|
||||
});
|
||||
}
|
||||
|
||||
private int GetTokenExpiration(string idToken)
|
||||
|
||||
@@ -28,6 +28,42 @@ public static class AppSettingNames
|
||||
/// Default role name for auto-provisioned users (empty = no role assigned)
|
||||
/// </summary>
|
||||
public const string DefaultRole = "App.ExternalAuth.DefaultRole";
|
||||
|
||||
/// <summary>
|
||||
/// Update user information from external provider on each login
|
||||
/// </summary>
|
||||
public const string UpdateUserInfoOnLogin = "App.ExternalAuth.UpdateUserInfoOnLogin";
|
||||
|
||||
/// <summary>
|
||||
/// Cache duration for JWKS keys in minutes (default: 60)
|
||||
/// </summary>
|
||||
public const string JwksCacheDurationMinutes = "App.ExternalAuth.JwksCacheDurationMinutes";
|
||||
|
||||
/// <summary>
|
||||
/// Cache duration for OIDC discovery document in minutes (default: 60)
|
||||
/// </summary>
|
||||
public const string DiscoveryCacheDurationMinutes = "App.ExternalAuth.DiscoveryCacheDurationMinutes";
|
||||
|
||||
/// <summary>
|
||||
/// Token validation timeout in seconds (default: 10)
|
||||
/// </summary>
|
||||
public const string TokenValidationTimeoutSeconds = "App.ExternalAuth.TokenValidationTimeoutSeconds";
|
||||
|
||||
/// <summary>
|
||||
/// Clock skew tolerance for token validation in minutes (default: 5)
|
||||
/// </summary>
|
||||
public const string ClockSkewMinutes = "App.ExternalAuth.ClockSkewMinutes";
|
||||
|
||||
/// <summary>
|
||||
/// Enable debug logging for external authentication (default: false)
|
||||
/// WARNING: May log sensitive information, only enable in development
|
||||
/// </summary>
|
||||
public const string EnableDebugLogging = "App.ExternalAuth.EnableDebugLogging";
|
||||
|
||||
/// <summary>
|
||||
/// Require email_verified claim to be true for auto-provisioning (default: true)
|
||||
/// </summary>
|
||||
public const string RequireEmailVerified = "App.ExternalAuth.RequireEmailVerified";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using Abp.Authorization.Users;
|
||||
using Abp.MultiTenancy;
|
||||
using Abp.Runtime.Security;
|
||||
using Abp.UI;
|
||||
using ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
|
||||
using ASPBaseOIDC.Authentication.JwtBearer;
|
||||
using ASPBaseOIDC.Authorization;
|
||||
@@ -64,25 +65,41 @@ namespace ASPBaseOIDC.Controllers
|
||||
|
||||
/// <summary>
|
||||
/// Authenticate with external OIDC/OAuth2 provider (Authentik, Keycloak, etc.)
|
||||
/// Passthrough approach: validates external token and returns it as-is
|
||||
/// Validates external token and returns backend's own JWT
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[AbpAllowAnonymous]
|
||||
public async Task<AuthenticateResultModel> AuthenticateExternal([FromBody] ExternalAuthModel model)
|
||||
{
|
||||
// Validate input
|
||||
if (string.IsNullOrWhiteSpace(model?.ProviderName))
|
||||
{
|
||||
throw new UserFriendlyException("Provider name is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Token))
|
||||
{
|
||||
throw new UserFriendlyException("Token is required");
|
||||
}
|
||||
|
||||
// Authenticate with external provider (validates token, provisions user if needed)
|
||||
var result = await _externalAuthManager.AuthenticateWithExternalTokenAsync(
|
||||
model.ProviderName,
|
||||
model.IdToken,
|
||||
model.Token,
|
||||
AbpSession.TenantId
|
||||
);
|
||||
|
||||
// Return original external token (passthrough approach)
|
||||
// Create claims identity for the authenticated user
|
||||
var identity = CreateClaimsIdentityFromUser(result.User);
|
||||
|
||||
// Generate backend's own JWT token
|
||||
var accessToken = CreateAccessToken(CreateJwtClaims(identity));
|
||||
|
||||
return new AuthenticateResultModel
|
||||
{
|
||||
AccessToken = result.AccessToken, // Passthrough external token
|
||||
EncryptedAccessToken = GetEncryptedAccessToken(result.AccessToken),
|
||||
ExpireInSeconds = result.ExpiresIn,
|
||||
AccessToken = accessToken,
|
||||
EncryptedAccessToken = GetEncryptedAccessToken(accessToken),
|
||||
ExpireInSeconds = (int)_configuration.Expiration.TotalSeconds,
|
||||
UserId = result.User.Id
|
||||
};
|
||||
}
|
||||
@@ -146,6 +163,45 @@ namespace ASPBaseOIDC.Controllers
|
||||
{
|
||||
return SimpleStringCipher.Instance.Encrypt(accessToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ClaimsIdentity from a User entity
|
||||
/// Centralizes claim creation logic for both local and external authentication
|
||||
/// </summary>
|
||||
private ClaimsIdentity CreateClaimsIdentityFromUser(User user)
|
||||
{
|
||||
var identity = new ClaimsIdentity();
|
||||
|
||||
// Standard claims
|
||||
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
|
||||
identity.AddClaim(new Claim(ClaimTypes.Name, user.UserName));
|
||||
|
||||
if (!string.IsNullOrEmpty(user.EmailAddress))
|
||||
{
|
||||
identity.AddClaim(new Claim(ClaimTypes.Email, user.EmailAddress));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(user.Name))
|
||||
{
|
||||
identity.AddClaim(new Claim(ClaimTypes.GivenName, user.Name));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(user.Surname))
|
||||
{
|
||||
identity.AddClaim(new Claim(ClaimTypes.Surname, user.Surname));
|
||||
}
|
||||
|
||||
// Tenant claim
|
||||
if (user.TenantId.HasValue)
|
||||
{
|
||||
identity.AddClaim(new Claim(AbpClaimTypes.TenantId, user.TenantId.Value.ToString()));
|
||||
}
|
||||
|
||||
// Note: Roles and permissions will be added by ABP authorization system
|
||||
// when user makes authorized requests
|
||||
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "https://localhost:44313/",
|
||||
"sslPort": 44313
|
||||
"applicationUrl": "http://localhost:44312/"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -18,8 +17,7 @@
|
||||
"ASPBaseOIDC.Web.Host": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:44313/",
|
||||
"sslPort": 44313,
|
||||
"applicationUrl": "http://localhost:44312/",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Abp.Runtime.Security;
|
||||
using ASPBaseOIDC.Authentication.ExternalAuth;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -53,19 +51,6 @@ namespace ASPBaseOIDC.Web.Host.Startup
|
||||
{
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"App": {
|
||||
"ServerRootAddress": "https://localhost:44311/",
|
||||
"ClientRootAddress": "http://localhost:4200/",
|
||||
"CorsOrigins": "http://localhost:4200,http://localhost:8080,http://localhost:8081,http://localhost:3000"
|
||||
"CorsOrigins": "http://localhost:4200,http://localhost:8080,http://localhost:8081,http://localhost:3000,http://localhost:3001"
|
||||
},
|
||||
"Authentication": {
|
||||
"JwtBearer": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "https://localhost:44311/"
|
||||
"Url": "http://localhost:44311/"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user