Compare commits

...

3 Commits

Author SHA1 Message Date
0226bf8fa3 Complete oauth / oidc 2025-06-29 17:29:24 +08:00
217b434cc4 🐛 Dozen of bugs fixes 2025-06-29 16:35:01 +08:00
f8295c6a18 ♻️ Refactored oidc 2025-06-29 11:53:44 +08:00
20 changed files with 4565 additions and 446 deletions

View File

@ -1,12 +1,19 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers; using DysonNetwork.Sphere.Storage.Handlers;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
using System.Text;
using DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using SystemClock = NodaTime.SystemClock; using SystemClock = NodaTime.SystemClock;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Sphere.Auth;
@ -22,6 +29,7 @@ public enum TokenType
{ {
AuthKey, AuthKey,
ApiKey, ApiKey,
OidcKey,
Unknown Unknown
} }
@ -39,6 +47,7 @@ public class DysonTokenAuthHandler(
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
AppDatabase database, AppDatabase database,
OidcProviderService oidc,
ICacheService cache, ICacheService cache,
FlushBufferService fbs FlushBufferService fbs
) )
@ -142,11 +151,25 @@ public class DysonTokenAuthHandler(
try try
{ {
// Split the token
var parts = token.Split('.'); var parts = token.Split('.');
if (parts.Length != 2)
return false;
switch (parts.Length)
{
// Handle JWT tokens (3 parts)
case 3:
{
var (isValid, jwtResult) = oidc.ValidateToken(token);
if (!isValid) return false;
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
if (jti is null) return false;
return Guid.TryParse(jti, out sessionId);
}
// Handle compact tokens (2 parts)
case 2:
// Original compact token validation logic
try
{
// Decode the payload // Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]); var payloadBytes = Base64UrlDecode(parts[0]);
@ -154,7 +177,7 @@ public class DysonTokenAuthHandler(
sessionId = new Guid(payloadBytes); sessionId = new Guid(payloadBytes);
// Load public key for verification // Load public key for verification
var publicKeyPem = File.ReadAllText(configuration["Jwt:PublicKeyPath"]!); var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create(); using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem); rsa.ImportFromPem(publicKeyPem);
@ -166,11 +189,22 @@ public class DysonTokenAuthHandler(
{ {
return false; return false;
} }
break;
default:
return false;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Token validation failed");
return false;
}
} }
private static byte[] Base64UrlDecode(string base64Url) private static byte[] Base64UrlDecode(string base64Url)
{ {
string padded = base64Url var padded = base64Url
.Replace('-', '+') .Replace('-', '+')
.Replace('_', '/'); .Replace('_', '/');
@ -195,20 +229,23 @@ public class DysonTokenAuthHandler(
}; };
} }
// Check for token in Authorization header // Check for token in Authorization header
var authHeader = request.Headers.Authorization.ToString(); var authHeader = request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(authHeader)) if (!string.IsNullOrEmpty(authHeader))
{ {
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{ {
var token = authHeader["Bearer ".Length..].Trim();
var parts = token.Split('.');
return new TokenInfo return new TokenInfo
{ {
Token = authHeader["Bearer ".Length..].Trim(), Token = token,
Type = TokenType.AuthKey Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
}; };
} }
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
{ {
return new TokenInfo return new TokenInfo
{ {
@ -216,8 +253,7 @@ public class DysonTokenAuthHandler(
Type = TokenType.AuthKey Type = TokenType.AuthKey
}; };
} }
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
{ {
return new TokenInfo return new TokenInfo
{ {
@ -233,10 +269,11 @@ public class DysonTokenAuthHandler(
return new TokenInfo return new TokenInfo
{ {
Token = cookieToken, Token = cookieToken,
Type = TokenType.AuthKey Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
}; };
} }
return null; return null;
} }
} }

View File

@ -73,7 +73,7 @@ public class AuthService(
return totalRequiredSteps; return totalRequiredSteps;
} }
public async Task<Session> CreateSessionAsync(Account.Account account, Instant time) public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
{ {
var challenge = new Challenge var challenge = new Challenge
{ {
@ -82,7 +82,7 @@ public class AuthService(
UserAgent = HttpContext.Request.Headers.UserAgent, UserAgent = HttpContext.Request.Headers.UserAgent,
StepRemain = 1, StepRemain = 1,
StepTotal = 1, StepTotal = 1,
Type = ChallengeType.Oidc Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
}; };
var session = new Session var session = new Session
@ -90,7 +90,8 @@ public class AuthService(
AccountId = account.Id, AccountId = account.Id,
CreatedAt = time, CreatedAt = time,
LastGrantedAt = time, LastGrantedAt = time,
Challenge = challenge Challenge = challenge,
AppId = customAppId
}; };
db.AuthChallenges.Add(challenge); db.AuthChallenges.Add(challenge);
@ -156,7 +157,7 @@ public class AuthService(
public string CreateToken(Session session) public string CreateToken(Session session)
{ {
// Load the private key for signing // Load the private key for signing
var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!); var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!);
using var rsa = RSA.Create(); using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem); rsa.ImportFromPem(privateKeyPem);
@ -263,7 +264,7 @@ public class AuthService(
sessionId = new Guid(payloadBytes); sessionId = new Guid(payloadBytes);
// Load public key for verification // Load public key for verification
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!); var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create(); using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem); rsa.ImportFromPem(publicKeyPem);

View File

@ -4,8 +4,8 @@ namespace DysonNetwork.Sphere.Auth;
public class CompactTokenService(IConfiguration config) public class CompactTokenService(IConfiguration config)
{ {
private readonly string _privateKeyPath = config["Jwt:PrivateKeyPath"] private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]
?? throw new InvalidOperationException("Jwt:PrivateKeyPath configuration is missing"); ?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing");
public string CreateToken(Session session) public string CreateToken(Session session)
{ {
@ -54,7 +54,7 @@ public class CompactTokenService(IConfiguration config)
sessionId = new Guid(payloadBytes); sessionId = new Guid(payloadBytes);
// Load public key for verification // Load public key for verification
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!); var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create(); using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem); rsa.ImportFromPem(publicKeyPem);

View File

@ -1,8 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Auth.OidcProvider.Options; using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses; using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Auth.OidcProvider.Services; using DysonNetwork.Sphere.Auth.OidcProvider.Services;
@ -10,230 +7,159 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers; namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
[Route("connect")] [Route("/auth/open")]
[ApiController] [ApiController]
public class OidcProviderController( public class OidcProviderController(
AppDatabase db,
OidcProviderService oidcService, OidcProviderService oidcService,
IConfiguration configuration,
IOptions<OidcProviderOptions> options, IOptions<OidcProviderOptions> options,
ILogger<OidcProviderController> logger ILogger<OidcProviderController> logger
) )
: ControllerBase : ControllerBase
{ {
[HttpGet("authorize")]
public async Task<IActionResult> Authorize(
[Required][FromQuery(Name = "client_id")] Guid clientId,
[Required][FromQuery(Name = "response_type")] string responseType,
[FromQuery(Name = "redirect_uri")] string? redirectUri,
[FromQuery] string? scope,
[FromQuery] string? state,
[FromQuery] string? nonce,
[FromQuery(Name = "code_challenge")] string? codeChallenge,
[FromQuery(Name = "code_challenge_method")] string? codeChallengeMethod,
[FromQuery(Name = "response_mode")] string? responseMode
)
{
// Check if user is authenticated
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
{
// Not authenticated - redirect to login with return URL
var loginUrl = "/Auth/Login";
var returnUrl = $"{Request.Path}{Request.QueryString}";
return Redirect($"{loginUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
// Validate client
var client = await oidcService.FindClientByIdAsync(clientId);
if (client == null)
return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Client not found" });
// Check if user has already granted permission to this client
// For now, we'll always show the consent page. In a real app, you might store consent decisions.
// If you want to implement "remember my decision", you would check that here.
var consentRequired = true;
if (consentRequired)
{
// Redirect to consent page with all the OAuth parameters
var consentUrl = $"/Auth/Authorize?client_id={clientId}";
if (!string.IsNullOrEmpty(responseType)) consentUrl += $"&response_type={Uri.EscapeDataString(responseType)}";
if (!string.IsNullOrEmpty(redirectUri)) consentUrl += $"&redirect_uri={Uri.EscapeDataString(redirectUri)}";
if (!string.IsNullOrEmpty(scope)) consentUrl += $"&scope={Uri.EscapeDataString(scope)}";
if (!string.IsNullOrEmpty(state)) consentUrl += $"&state={Uri.EscapeDataString(state)}";
if (!string.IsNullOrEmpty(nonce)) consentUrl += $"&nonce={Uri.EscapeDataString(nonce)}";
if (!string.IsNullOrEmpty(codeChallenge)) consentUrl += $"&code_challenge={Uri.EscapeDataString(codeChallenge)}";
if (!string.IsNullOrEmpty(codeChallengeMethod)) consentUrl += $"&code_challenge_method={Uri.EscapeDataString(codeChallengeMethod)}";
if (!string.IsNullOrEmpty(responseMode)) consentUrl += $"&response_mode={Uri.EscapeDataString(responseMode)}";
return Redirect(consentUrl);
}
// Skip redirect_uri validation for apps in Developing status
if (client.Status != CustomAppStatus.Developing)
{
// Validate redirect URI for non-Developing apps
if (!string.IsNullOrEmpty(redirectUri) && !(client.RedirectUris?.Contains(redirectUri) ?? false))
return BadRequest(
new ErrorResponse { Error = "invalid_request", ErrorDescription = "Invalid redirect_uri" });
}
else
{
logger.LogWarning("Skipping redirect_uri validation for app {AppId} in Developing status", clientId);
// If no redirect_uri is provided and we're in development, use the first one
if (string.IsNullOrEmpty(redirectUri) && client.RedirectUris?.Any() == true)
{
redirectUri = client.RedirectUris.First();
}
}
// Generate authorization code
var code = Guid.NewGuid().ToString("N");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
// In a real implementation, you'd store this code with the user's consent and requested scopes
// and validate it in the token endpoint
// For now, we'll just return the code directly (simplified for example)
var response = new AuthorizationResponse
{
Code = code,
State = state,
Scope = scope,
Issuer = options.Value.IssuerUri
};
// Redirect back to the client with the authorization code
var finalRedirectUri = new UriBuilder(redirectUri ?? client.RedirectUris?.First() ?? throw new InvalidOperationException("No redirect URI provided and no default redirect URI found"));
var query = System.Web.HttpUtility.ParseQueryString(finalRedirectUri.Query);
query["code"] = response.Code;
if (!string.IsNullOrEmpty(response.State))
query["state"] = response.State;
if (!string.IsNullOrEmpty(response.Scope))
query["scope"] = response.Scope;
finalRedirectUri.Query = query.ToString();
return Redirect(finalRedirectUri.Uri.ToString());
}
[HttpPost("token")] [HttpPost("token")]
[Consumes("application/x-www-form-urlencoded")] [Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token([FromForm] TokenRequest request) public async Task<IActionResult> Token([FromForm] TokenRequest request)
{ {
if (request.GrantType == "authorization_code") switch (request.GrantType)
{ {
// Validate client credentials // Validate client credentials
if (request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret)) case "authorization_code" when request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret):
return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Client credentials are required" }); return BadRequest("Client credentials are required");
case "authorization_code" when request.Code == null:
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value); return BadRequest("Authorization code is required");
if (client == null || !await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret)) case "authorization_code":
return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
// Validate the authorization code
var authCode = await oidcService.ValidateAuthorizationCodeAsync(
request.Code ?? string.Empty,
request.ClientId.Value,
request.RedirectUri,
request.CodeVerifier);
if (authCode == null)
{ {
logger.LogWarning("Invalid or expired authorization code: {Code}", request.Code); var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
return BadRequest(new ErrorResponse { Error = "invalid_grant", ErrorDescription = "Invalid or expired authorization code" }); if (client == null ||
} !await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
return BadRequest(new ErrorResponse
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
// Generate tokens // Generate tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync( var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: request.ClientId.Value, clientId: request.ClientId.Value,
subjectId: authCode.UserId, authorizationCode: request.Code!,
scopes: authCode.Scopes, redirectUri: request.RedirectUri,
authorizationCode: request.Code); codeVerifier: request.CodeVerifier
);
return Ok(tokenResponse); return Ok(tokenResponse);
} }
else if (request.GrantType == "refresh_token") case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
return BadRequest(new ErrorResponse
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
case "refresh_token":
{ {
// Handle refresh token request try
// In a real implementation, you would validate the refresh token {
// and issue a new access token // Decode the base64 refresh token to get the session ID
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" }); var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
var sessionId = new Guid(sessionIdBytes);
// Find the session and related data
var session = await oidcService.FindSessionByIdAsync(sessionId);
var now = SystemClock.Instance.GetCurrentInstant();
if (session?.App is null || session.ExpiredAt < now)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid or expired refresh token"
});
} }
// Get the client
var client = session.App;
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_client",
ErrorDescription = "Client not found"
});
}
// Generate new tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: session.AppId!.Value,
sessionId: session.Id
);
return Ok(tokenResponse);
}
catch (FormatException)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid refresh token format"
});
}
}
default:
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" }); return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
} }
}
[HttpGet("userinfo")] [HttpGet("userinfo")]
[Authorize(AuthenticationSchemes = "Bearer")] [Authorize]
public async Task<IActionResult> UserInfo() public async Task<IActionResult> GetUserInfo()
{ {
var authHeader = HttpContext.Request.Headers.Authorization.ToString(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser ||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
{
var loginUrl = "/Account/Login"; // Update this path to your actual login page path
var returnUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
return Redirect($"{loginUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
var token = authHeader["Bearer ".Length..].Trim();
var jwtToken = oidcService.ValidateToken(token);
if (jwtToken == null)
{
var loginUrl = "/Account/Login"; // Update this path to your actual login page path
var returnUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
return Redirect($"{loginUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
// Get user info based on the subject claim from the token
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var userName = User.FindFirstValue(ClaimTypes.Name);
var userEmail = User.FindFirstValue(ClaimTypes.Email);
// Get requested scopes from the token // Get requested scopes from the token
var scopes = jwtToken.Claims var scopes = currentSession.Challenge.Scopes;
.Where(c => c.Type == "scope")
.SelectMany(c => c.Value.Split(' '))
.ToHashSet();
var userInfo = new Dictionary<string, object> var userInfo = new Dictionary<string, object>
{ {
["sub"] = userId ?? "anonymous" ["sub"] = currentUser.Id
}; };
// Include standard claims based on scopes // Include standard claims based on scopes
if (scopes.Contains("profile") || scopes.Contains("name")) if (scopes.Contains("profile") || scopes.Contains("name"))
{ {
if (!string.IsNullOrEmpty(userName)) userInfo["name"] = currentUser.Name;
userInfo["name"] = userName; userInfo["preferred_username"] = currentUser.Nick;
} }
if (scopes.Contains("email") && !string.IsNullOrEmpty(userEmail)) var userEmail = await db.AccountContacts
.Where(c => c.Type == AccountContactType.Email && c.AccountId == currentUser.Id)
.FirstOrDefaultAsync();
if (scopes.Contains("email") && userEmail is not null)
{ {
userInfo["email"] = userEmail; userInfo["email"] = userEmail.Content;
userInfo["email_verified"] = true; // In a real app, check if email is verified userInfo["email_verified"] = userEmail.VerifiedAt is not null;
} }
return Ok(userInfo); return Ok(userInfo);
} }
[HttpGet(".well-known/openid-configuration")] [HttpGet("/.well-known/openid-configuration")]
public IActionResult GetConfiguration() public IActionResult GetConfiguration()
{ {
var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}".TrimEnd('/'); var baseUrl = configuration["BaseUrl"];
var issuer = options.Value.IssuerUri.TrimEnd('/'); var issuer = options.Value.IssuerUri.TrimEnd('/');
return Ok(new return Ok(new
{ {
issuer = issuer, issuer = issuer,
authorization_endpoint = $"{baseUrl}/connect/authorize", authorization_endpoint = $"{baseUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/connect/token", token_endpoint = $"{baseUrl}/auth/open/token",
userinfo_endpoint = $"{baseUrl}/connect/userinfo", userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
jwks_uri = $"{baseUrl}/.well-known/openid-configuration/jwks", jwks_uri = $"{baseUrl}/.well-known/jwks",
scopes_supported = new[] { "openid", "profile", "email" }, scopes_supported = new[] { "openid", "profile", "email" },
response_types_supported = new[] { "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" }, response_types_supported = new[]
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
grant_types_supported = new[] { "authorization_code", "refresh_token" }, grant_types_supported = new[] { "authorization_code", "refresh_token" },
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" }, token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
id_token_signing_alg_values_supported = new[] { "HS256" }, id_token_signing_alg_values_supported = new[] { "HS256" },
@ -247,15 +173,17 @@ public class OidcProviderController(
}); });
} }
[HttpGet("jwks")] [HttpGet("/.well-known/jwks")]
public IActionResult Jwks() public IActionResult GetJwks()
{ {
// In a production environment, you should use asymmetric keys (RSA or EC) using var rsa = options.Value.GetRsaPublicKey();
// and expose only the public key here. This is a simplified example using HMAC. if (rsa == null)
// For production, consider using RSA or EC keys and proper key rotation. {
return BadRequest("Public key is not configured");
}
var keyBytes = Encoding.UTF8.GetBytes(options.Value.SigningKey); var parameters = rsa.ExportParameters(false);
var keyId = Convert.ToBase64String(SHA256.HashData(keyBytes)[..8]) var keyId = Convert.ToBase64String(SHA256.HashData(parameters.Modulus!)[..8])
.Replace("+", "-") .Replace("+", "-")
.Replace("/", "_") .Replace("/", "_")
.Replace("=", ""); .Replace("=", "");
@ -266,11 +194,12 @@ public class OidcProviderController(
{ {
new new
{ {
kty = "oct", kty = "RSA",
use = "sig", use = "sig",
kid = keyId, kid = keyId,
k = Convert.ToBase64String(keyBytes), n = Base64UrlEncoder.Encode(parameters.Modulus!),
alg = "HS256" e = Base64UrlEncoder.Encode(parameters.Exponent!),
alg = "RS256"
} }
} }
}); });
@ -280,26 +209,34 @@ public class OidcProviderController(
public class TokenRequest public class TokenRequest
{ {
[JsonPropertyName("grant_type")] [JsonPropertyName("grant_type")]
[FromForm(Name = "grant_type")]
public string? GrantType { get; set; } public string? GrantType { get; set; }
[JsonPropertyName("code")] [JsonPropertyName("code")]
[FromForm(Name = "code")]
public string? Code { get; set; } public string? Code { get; set; }
[JsonPropertyName("redirect_uri")] [JsonPropertyName("redirect_uri")]
[FromForm(Name = "redirect_uri")]
public string? RedirectUri { get; set; } public string? RedirectUri { get; set; }
[JsonPropertyName("client_id")] [JsonPropertyName("client_id")]
[FromForm(Name = "client_id")]
public Guid? ClientId { get; set; } public Guid? ClientId { get; set; }
[JsonPropertyName("client_secret")] [JsonPropertyName("client_secret")]
[FromForm(Name = "client_secret")]
public string? ClientSecret { get; set; } public string? ClientSecret { get; set; }
[JsonPropertyName("refresh_token")] [JsonPropertyName("refresh_token")]
[FromForm(Name = "refresh_token")]
public string? RefreshToken { get; set; } public string? RefreshToken { get; set; }
[JsonPropertyName("scope")] [JsonPropertyName("scope")]
[FromForm(Name = "scope")]
public string? Scope { get; set; } public string? Scope { get; set; }
[JsonPropertyName("code_verifier")] [JsonPropertyName("code_verifier")]
[FromForm(Name = "code_verifier")]
public string? CodeVerifier { get; set; } public string? CodeVerifier { get; set; }
} }

View File

@ -7,12 +7,11 @@ namespace DysonNetwork.Sphere.Auth.OidcProvider.Models;
public class AuthorizationCodeInfo public class AuthorizationCodeInfo
{ {
public Guid ClientId { get; set; } public Guid ClientId { get; set; }
public string UserId { get; set; } = string.Empty; public Guid AccountId { get; set; }
public string RedirectUri { get; set; } = string.Empty; public string RedirectUri { get; set; } = string.Empty;
public List<string> Scopes { get; set; } = new(); public List<string> Scopes { get; set; } = new();
public string? CodeChallenge { get; set; } public string? CodeChallenge { get; set; }
public string? CodeChallengeMethod { get; set; } public string? CodeChallengeMethod { get; set; }
public string? Nonce { get; set; } public string? Nonce { get; set; }
public Instant Expiration { get; set; }
public Instant CreatedAt { get; set; } public Instant CreatedAt { get; set; }
} }

View File

@ -1,13 +1,36 @@
using System; using System.Security.Cryptography;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Options; namespace DysonNetwork.Sphere.Auth.OidcProvider.Options;
public class OidcProviderOptions public class OidcProviderOptions
{ {
public string IssuerUri { get; set; } = "https://your-issuer-uri.com"; public string IssuerUri { get; set; } = "https://your-issuer-uri.com";
public string SigningKey { get; set; } = "replace-with-a-secure-random-key"; public string? PublicKeyPath { get; set; }
public string? PrivateKeyPath { get; set; }
public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1); public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1);
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30); public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30);
public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5); public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5);
public bool RequireHttpsMetadata { get; set; } = true; public bool RequireHttpsMetadata { get; set; } = true;
public RSA? GetRsaPrivateKey()
{
if (string.IsNullOrEmpty(PrivateKeyPath) || !File.Exists(PrivateKeyPath))
return null;
var privateKey = File.ReadAllText(PrivateKeyPath);
var rsa = RSA.Create();
rsa.ImportFromPem(privateKey.AsSpan());
return rsa;
}
public RSA? GetRsaPublicKey()
{
if (string.IsNullOrEmpty(PublicKeyPath) || !File.Exists(PublicKeyPath))
return null;
var publicKey = File.ReadAllText(PublicKeyPath);
var rsa = RSA.Create();
rsa.ImportFromPem(publicKey.AsSpan());
return rsa;
}
} }

View File

@ -2,13 +2,11 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using DysonNetwork.Sphere.Auth.OidcProvider.Models; using DysonNetwork.Sphere.Auth.OidcProvider.Models;
using DysonNetwork.Sphere.Auth.OidcProvider.Options; using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses; using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Developer; using DysonNetwork.Sphere.Developer;
using Microsoft.AspNetCore.Identity; using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@ -18,7 +16,8 @@ namespace DysonNetwork.Sphere.Auth.OidcProvider.Services;
public class OidcProviderService( public class OidcProviderService(
AppDatabase db, AppDatabase db,
IClock clock, AuthService auth,
ICacheService cache,
IOptions<OidcProviderOptions> options, IOptions<OidcProviderOptions> options,
ILogger<OidcProviderService> logger ILogger<OidcProviderService> logger
) )
@ -44,6 +43,7 @@ public class OidcProviderService(
var client = await FindClientByIdAsync(clientId); var client = await FindClientByIdAsync(clientId);
if (client == null) return false; if (client == null) return false;
var clock = SystemClock.Instance;
var secret = client.Secrets var secret = client.Secrets
.Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant())) .Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant()))
.FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing .FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing
@ -53,25 +53,53 @@ public class OidcProviderService(
public async Task<TokenResponse> GenerateTokenResponseAsync( public async Task<TokenResponse> GenerateTokenResponseAsync(
Guid clientId, Guid clientId,
string subjectId, string? authorizationCode = null,
IEnumerable<string>? scopes = null, string? redirectUri = null,
string? authorizationCode = null) string? codeVerifier = null,
Guid? sessionId = null
)
{ {
var client = await FindClientByIdAsync(clientId); var client = await FindClientByIdAsync(clientId);
if (client == null) if (client == null)
throw new InvalidOperationException("Client not found"); throw new InvalidOperationException("Client not found");
Session session;
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
List<string>? scopes = null;
if (authorizationCode != null)
{
// Authorization code flow
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
if (account is null) throw new InvalidOperationException("Account was not found");
session = await auth.CreateSessionForOidcAsync(account, now);
scopes = authCode.Scopes;
}
else if (sessionId.HasValue)
{
// Refresh token flow
session = await FindSessionByIdAsync(sessionId.Value) ??
throw new InvalidOperationException("Invalid session");
// Verify the session is still valid
if (session.ExpiredAt < now)
throw new InvalidOperationException("Session has expired");
}
else
{
throw new InvalidOperationException("Either authorization code or session ID must be provided");
}
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate access token // Generate an access token
var accessToken = GenerateJwtToken(client, subjectId, expiresAt, scopes); var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var refreshToken = GenerateRefreshToken(); var refreshToken = GenerateRefreshToken(session);
// In a real implementation, you would store the token in the database
// For this example, we'll just return the token without storing it
// as we don't have a dedicated OIDC token table
return new TokenResponse return new TokenResponse
{ {
@ -83,30 +111,39 @@ public class OidcProviderService(
}; };
} }
private string GenerateJwtToken(CustomApp client, string subjectId, Instant expiresAt, IEnumerable<string>? scopes = null) private string GenerateJwtToken(
CustomApp client,
Session session,
Instant expiresAt,
IEnumerable<string>? scopes = null
)
{ {
var tokenHandler = new JwtSecurityTokenHandler(); var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_options.SigningKey); var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var tokenDescriptor = new SecurityTokenDescriptor var tokenDescriptor = new SecurityTokenDescriptor
{ {
Subject = new ClaimsIdentity(new[] Subject = new ClaimsIdentity([
{ new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
new Claim(JwtRegisteredClaimNames.Sub, subjectId), new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
new Claim(JwtRegisteredClaimNames.Iat, clock.GetCurrentInstant().ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64), ClaimValueTypes.Integer64),
new Claim("client_id", client.Id.ToString()) new Claim("client_id", client.Id.ToString())
}), ]),
Expires = expiresAt.ToDateTimeUtc(), Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri, Issuer = _options.IssuerUri,
Audience = client.Id.ToString(), Audience = client.Id.ToString()
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature)
}; };
// Add scopes as claims if provided, otherwise use client's default scopes // Try to use RSA signing if keys are available, fall back to HMAC
var rsaPrivateKey = _options.GetRsaPrivateKey();
tokenDescriptor.SigningCredentials = new SigningCredentials(
new RsaSecurityKey(rsaPrivateKey),
SecurityAlgorithms.RsaSha256
);
// Add scopes as claims if provided
var effectiveScopes = scopes?.ToList() ?? client.AllowedScopes?.ToList() ?? new List<string>(); var effectiveScopes = scopes?.ToList() ?? client.AllowedScopes?.ToList() ?? new List<string>();
if (effectiveScopes.Any()) if (effectiveScopes.Any())
{ {
@ -118,15 +155,49 @@ public class OidcProviderService(
return tokenHandler.WriteToken(token); return tokenHandler.WriteToken(token);
} }
private static string GenerateRefreshToken() public (bool isValid, JwtSecurityToken? token) ValidateToken(string token)
{ {
using var rng = RandomNumberGenerator.Create(); try
var bytes = new byte[32]; {
rng.GetBytes(bytes); var tokenHandler = new JwtSecurityTokenHandler();
return Convert.ToBase64String(bytes) var validationParameters = new TokenValidationParameters
.Replace("+", "-") {
.Replace("/", "_") ValidateIssuer = true,
.Replace("=", ""); ValidIssuer = _options.IssuerUri,
ValidateAudience = false,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
// Try to use RSA validation if public key is available
var rsaPublicKey = _options.GetRsaPublicKey();
validationParameters.IssuerSigningKey = new RsaSecurityKey(rsaPublicKey);
validationParameters.ValidateIssuerSigningKey = true;
validationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };
tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
return (true, (JwtSecurityToken)validatedToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Token validation failed");
return (false, null);
}
}
public async Task<Session?> FindSessionByIdAsync(Guid sessionId)
{
return await db.AuthSessions
.Include(s => s.Account)
.Include(s => s.Challenge)
.Include(s => s.App)
.FirstOrDefaultAsync(s => s.Id == sessionId);
}
private static string GenerateRefreshToken(Session session)
{
return Convert.ToBase64String(session.Id.ToByteArray());
} }
private static bool VerifyHashedSecret(string secret, string hashedSecret) private static bool VerifyHashedSecret(string secret, string hashedSecret)
@ -136,38 +207,9 @@ public class OidcProviderService(
return string.Equals(secret, hashedSecret, StringComparison.Ordinal); return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
} }
public JwtSecurityToken? ValidateToken(string token)
{
try
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_options.SigningKey);
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = true,
ValidIssuer = _options.IssuerUri,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
}, out var validatedToken);
return (JwtSecurityToken)validatedToken;
}
catch (Exception ex)
{
logger.LogError(ex, "Token validation failed");
return null;
}
}
private static readonly Dictionary<string, AuthorizationCodeInfo> _authorizationCodes = new();
public async Task<string> GenerateAuthorizationCodeAsync( public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId, Guid clientId,
string userId, Guid userId,
string redirectUri, string redirectUri,
IEnumerable<string> scopes, IEnumerable<string> scopes,
string? codeChallenge = null, string? codeChallenge = null,
@ -175,53 +217,52 @@ public class OidcProviderService(
string? nonce = null) string? nonce = null)
{ {
// Generate a random code // Generate a random code
var clock = SystemClock.Instance;
var code = GenerateRandomString(32); var code = GenerateRandomString(32);
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
// Store the code with its metadata // Create the authorization code info
_authorizationCodes[code] = new AuthorizationCodeInfo var authCodeInfo = new AuthorizationCodeInfo
{ {
ClientId = clientId, ClientId = clientId,
UserId = userId, AccountId = userId,
RedirectUri = redirectUri, RedirectUri = redirectUri,
Scopes = scopes.ToList(), Scopes = scopes.ToList(),
CodeChallenge = codeChallenge, CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod, CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce, Nonce = nonce,
Expiration = now.Plus(Duration.FromTimeSpan(_options.AuthorizationCodeLifetime)),
CreatedAt = now CreatedAt = now
}; };
// Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId); logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
return code; return code;
} }
public async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync( private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
string code, string code,
Guid clientId, Guid clientId,
string? redirectUri = null, string? redirectUri = null,
string? codeVerifier = null) string? codeVerifier = null
)
{ {
if (!_authorizationCodes.TryGetValue(code, out var authCode) || authCode == null) var cacheKey = $"auth:code:{code}";
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
if (!found || authCode == null)
{ {
logger.LogWarning("Authorization code not found: {Code}", code); logger.LogWarning("Authorization code not found: {Code}", code);
return null; return null;
} }
var now = clock.GetCurrentInstant();
// Check if code has expired
if (now > authCode.Expiration)
{
logger.LogWarning("Authorization code expired: {Code}", code);
_authorizationCodes.Remove(code);
return null;
}
// Verify client ID matches // Verify client ID matches
if (authCode.ClientId != clientId) if (authCode.ClientId != clientId)
{ {
logger.LogWarning("Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}", logger.LogWarning(
"Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",
code, authCode.ClientId, clientId); code, authCode.ClientId, clientId);
return null; return null;
} }
@ -256,8 +297,8 @@ public class OidcProviderService(
} }
} }
// Code is valid, remove it from the store (codes are single-use) // Code is valid, remove it from the cache (codes are single-use)
_authorizationCodes.Remove(code); await cache.RemoveAsync(cacheKey);
return authCode; return authCode;
} }

View File

@ -376,7 +376,7 @@ public class ConnectionController(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var loginSession = await auth.CreateSessionAsync(account, clock.GetCurrentInstant()); var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
var loginToken = auth.CreateToken(loginSession); var loginToken = auth.CreateToken(loginSession);
return Redirect($"/auth/token?token={loginToken}"); return Redirect($"/auth/token?token={loginToken}");
} }

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Developer;
using NodaTime; using NodaTime;
using Point = NetTopologySuite.Geometries.Point; using Point = NetTopologySuite.Geometries.Point;
@ -17,6 +18,8 @@ public class Session : ModelBase
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Account.Account Account { get; set; } = null!;
public Guid ChallengeId { get; set; } public Guid ChallengeId { get; set; }
public Challenge Challenge { get; set; } = null!; public Challenge Challenge { get; set; } = null!;
public Guid? AppId { get; set; }
public CustomApp? App { get; set; }
} }
public enum ChallengeType public enum ChallengeType

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Data.Migrations
{
/// <inheritdoc />
public partial class AuthSessionWithApp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "app_id",
table: "auth_sessions",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_app_id",
table: "auth_sessions",
column: "app_id");
migrationBuilder.AddForeignKey(
name: "fk_auth_sessions_custom_apps_app_id",
table: "auth_sessions",
column: "app_id",
principalTable: "custom_apps",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_auth_sessions_custom_apps_app_id",
table: "auth_sessions");
migrationBuilder.DropIndex(
name: "ix_auth_sessions_app_id",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "app_id",
table: "auth_sessions");
}
}
}

View File

@ -978,6 +978,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("account_id"); .HasColumnName("account_id");
b.Property<Guid?>("AppId")
.HasColumnType("uuid")
.HasColumnName("app_id");
b.Property<Guid>("ChallengeId") b.Property<Guid>("ChallengeId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("challenge_id"); .HasColumnName("challenge_id");
@ -1013,6 +1017,9 @@ namespace DysonNetwork.Sphere.Migrations
b.HasIndex("AccountId") b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_sessions_account_id"); .HasDatabaseName("ix_auth_sessions_account_id");
b.HasIndex("AppId")
.HasDatabaseName("ix_auth_sessions_app_id");
b.HasIndex("ChallengeId") b.HasIndex("ChallengeId")
.HasDatabaseName("ix_auth_sessions_challenge_id"); .HasDatabaseName("ix_auth_sessions_challenge_id");
@ -3331,6 +3338,11 @@ namespace DysonNetwork.Sphere.Migrations
.IsRequired() .IsRequired()
.HasConstraintName("fk_auth_sessions_accounts_account_id"); .HasConstraintName("fk_auth_sessions_accounts_account_id");
b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App")
.WithMany()
.HasForeignKey("AppId")
.HasConstraintName("fk_auth_sessions_custom_apps_app_id");
b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge") b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge")
.WithMany() .WithMany()
.HasForeignKey("ChallengeId") .HasForeignKey("ChallengeId")
@ -3340,6 +3352,8 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Account"); b.Navigation("Account");
b.Navigation("App");
b.Navigation("Challenge"); b.Navigation("Challenge");
}); });

View File

@ -4,14 +4,14 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
using DysonNetwork.Sphere.Auth.OidcProvider.Services; using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Developer;
namespace DysonNetwork.Sphere.Pages.Auth; namespace DysonNetwork.Sphere.Pages.Auth;
[Authorize]
public class AuthorizeModel(OidcProviderService oidcService) : PageModel public class AuthorizeModel(OidcProviderService oidcService) : PageModel
{ {
[BindProperty(SupportsGet = true)] [BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
public string? ReturnUrl { get; set; }
[BindProperty(SupportsGet = true, Name = "client_id")] [BindProperty(SupportsGet = true, Name = "client_id")]
[Required(ErrorMessage = "The client_id parameter is required")] [Required(ErrorMessage = "The client_id parameter is required")]
@ -25,14 +25,11 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
[BindProperty(SupportsGet = true, Name = "redirect_uri")] [BindProperty(SupportsGet = true, Name = "redirect_uri")]
public string? RedirectUri { get; set; } public string? RedirectUri { get; set; }
[BindProperty(SupportsGet = true)] [BindProperty(SupportsGet = true)] public string? Scope { get; set; }
public string? Scope { get; set; }
[BindProperty(SupportsGet = true)] [BindProperty(SupportsGet = true)] public string? State { get; set; }
public string? State { get; set; }
[BindProperty(SupportsGet = true)] [BindProperty(SupportsGet = true)] public string? Nonce { get; set; }
public string? Nonce { get; set; }
[BindProperty(SupportsGet = true, Name = "code_challenge")] [BindProperty(SupportsGet = true, Name = "code_challenge")]
@ -51,6 +48,12 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
public async Task<IActionResult> OnGetAsync() public async Task<IActionResult> OnGetAsync()
{ {
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account)
{
var returnUrl = Uri.EscapeDataString($"{Request.Path}{Request.QueryString}");
return RedirectToPage("/Auth/Login", new { returnUrl });
}
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId)) if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
{ {
ModelState.AddModelError("client_id", "Invalid client_id format"); ModelState.AddModelError("client_id", "Invalid client_id format");
@ -66,6 +69,14 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
return NotFound("Client not found"); return NotFound("Client not found");
} }
if (client.Status != CustomAppStatus.Developing)
{
// Validate redirect URI for non-Developing apps
if (!string.IsNullOrEmpty(RedirectUri) && !(client.RedirectUris?.Contains(RedirectUri) ?? false))
return BadRequest(
new ErrorResponse { Error = "invalid_request", ErrorDescription = "Invalid redirect_uri" });
}
AppName = client.Name; AppName = client.Name;
AppLogo = client.LogoUri; AppLogo = client.LogoUri;
AppUri = client.ClientUri; AppUri = client.ClientUri;
@ -76,7 +87,9 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
public async Task<IActionResult> OnPostAsync(bool allow) public async Task<IActionResult> OnPostAsync(bool allow)
{ {
// First validate the client ID if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser) return Unauthorized();
// First, validate the client ID
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId)) if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
{ {
ModelState.AddModelError("client_id", "Invalid client_id format"); ModelState.AddModelError("client_id", "Invalid client_id format");
@ -85,7 +98,7 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
ClientId = clientId; ClientId = clientId;
// Check if client exists // Check if a client exists
var client = await oidcService.FindClientByIdAsync(ClientId); var client = await oidcService.FindClientByIdAsync(ClientId);
if (client == null) if (client == null)
{ {
@ -119,7 +132,7 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
// Generate authorization code // Generate authorization code
var authCode = await oidcService.GenerateAuthorizationCodeAsync( var authCode = await oidcService.GenerateAuthorizationCodeAsync(
clientId: ClientId, clientId: ClientId,
userId: User.Identity?.Name ?? string.Empty, userId: currentUser.Id,
redirectUri: RedirectUri, redirectUri: RedirectUri,
scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(), scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(),
codeChallenge: CodeChallenge, codeChallenge: CodeChallenge,
@ -135,9 +148,7 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
// Add state if provided (for CSRF protection) // Add state if provided (for CSRF protection)
if (!string.IsNullOrEmpty(State)) if (!string.IsNullOrEmpty(State))
{
query["state"] = State; query["state"] = State;
}
// Set the query string // Set the query string
redirectUri.Query = query.ToString(); redirectUri.Query = query.ToString();

View File

@ -26,7 +26,7 @@
{ {
<div class="mb-4"> <div class="mb-4">
<form method="post" asp-page-handler="SelectFactor" class="w-full" id="factor-@factor.Id"> <form method="post" asp-page-handler="SelectFactor" class="w-full" id="factor-@factor.Id">
<input type="hidden" name="factorId" value="@factor.Id"/> <input type="hidden" name="SelectedFactorId" value="@factor.Id"/>
@if (factor.Type == AccountAuthFactorType.EmailCode) @if (factor.Type == AccountAuthFactorType.EmailCode)
{ {

View File

@ -50,10 +50,14 @@ public class SelectFactorModel(
try try
{ {
// For OTP factors that require code delivery // For OTP factors that require code delivery
if (factor.Type == AccountAuthFactorType.EmailCode if (
&& string.IsNullOrWhiteSpace(Hint)) factor.Type == AccountAuthFactorType.EmailCode
&& string.IsNullOrWhiteSpace(Hint)
)
{ {
ModelState.AddModelError(string.Empty, $"Please provide a {factor.Type.ToString().ToLower().Replace("code", "")} to send the code to."); ModelState.AddModelError(string.Empty,
$"Please provide a {factor.Type.ToString().ToLower().Replace("code", "")} to send the code to."
);
await LoadChallengeAndFactors(); await LoadChallengeAndFactors();
return Page(); return Page();
} }
@ -62,31 +66,30 @@ public class SelectFactorModel(
} }
catch (Exception ex) catch (Exception ex)
{ {
ModelState.AddModelError(string.Empty, $"An error occurred while sending the verification code: {ex.Message}"); ModelState.AddModelError(string.Empty,
$"An error occurred while sending the verification code: {ex.Message}");
await LoadChallengeAndFactors(); await LoadChallengeAndFactors();
return Page(); return Page();
} }
// Redirect to verify page with return URL if available // Redirect to verify page with return URL if available
if (!string.IsNullOrEmpty(ReturnUrl)) return !string.IsNullOrEmpty(ReturnUrl)
{ ? RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id, returnUrl = ReturnUrl })
return RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id, returnUrl = ReturnUrl }); : RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id });
}
return RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id });
} }
private async Task LoadChallengeAndFactors() private async Task LoadChallengeAndFactors()
{ {
AuthChallenge = await db.AuthChallenges AuthChallenge = await db.AuthChallenges
.Include(e => e.Account) .Include(e => e.Account)
.ThenInclude(e => e.AuthFactors)
.FirstOrDefaultAsync(e => e.Id == Id); .FirstOrDefaultAsync(e => e.Id == Id);
if (AuthChallenge != null) if (AuthChallenge != null)
{ {
AuthFactors = AuthChallenge.Account.AuthFactors AuthFactors = await db.AccountAuthFactors
.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }) .Where(e => e.AccountId == AuthChallenge.Account.Id)
.ToList(); .Where(e => e.EnabledAt != null && e.Trustworthy >= 1)
.ToListAsync();
} }
} }

View File

@ -8,46 +8,27 @@ using NodaTime;
namespace DysonNetwork.Sphere.Pages.Auth namespace DysonNetwork.Sphere.Pages.Auth
{ {
public class VerifyFactorModel : PageModel public class VerifyFactorModel(
{
private readonly AppDatabase _db;
private readonly AccountService _accounts;
private readonly AuthService _auth;
private readonly ActionLogService _als;
private readonly IConfiguration _configuration;
private readonly IHttpClientFactory _httpClientFactory;
[BindProperty(SupportsGet = true)]
public Guid Id { get; set; }
[BindProperty(SupportsGet = true)]
public Guid FactorId { get; set; }
[BindProperty(SupportsGet = true)]
public string? ReturnUrl { get; set; }
[BindProperty, Required]
public string Code { get; set; } = string.Empty;
public Challenge? AuthChallenge { get; set; }
public AccountAuthFactor? Factor { get; set; }
public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode;
public VerifyFactorModel(
AppDatabase db, AppDatabase db,
AccountService accounts, AccountService accounts,
AuthService auth, AuthService auth,
ActionLogService als, ActionLogService als,
IConfiguration configuration, IConfiguration configuration,
IHttpClientFactory httpClientFactory) IHttpClientFactory httpClientFactory
)
: PageModel
{ {
_db = db; [BindProperty(SupportsGet = true)] public Guid Id { get; set; }
_accounts = accounts;
_auth = auth; [BindProperty(SupportsGet = true)] public Guid FactorId { get; set; }
_als = als;
_configuration = configuration; [BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
_httpClientFactory = httpClientFactory;
} [BindProperty, Required] public string Code { get; set; } = string.Empty;
public Challenge? AuthChallenge { get; set; }
public AccountAuthFactor? Factor { get; set; }
public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode;
public async Task<IActionResult> OnGetAsync() public async Task<IActionResult> OnGetAsync()
{ {
@ -73,25 +54,25 @@ namespace DysonNetwork.Sphere.Pages.Auth
try try
{ {
if (await _accounts.VerifyFactorCode(Factor, Code)) if (await accounts.VerifyFactorCode(Factor, Code))
{ {
AuthChallenge.StepRemain -= Factor.Trustworthy; AuthChallenge.StepRemain -= Factor.Trustworthy;
AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain); AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain);
AuthChallenge.BlacklistFactors.Add(Factor.Id); AuthChallenge.BlacklistFactors.Add(Factor.Id);
_db.Update(AuthChallenge); db.Update(AuthChallenge);
_als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
new Dictionary<string, object> new Dictionary<string, object>
{ {
{ "challenge_id", AuthChallenge.Id }, { "challenge_id", AuthChallenge.Id },
{ "factor_id", Factor?.Id.ToString() ?? string.Empty } { "factor_id", Factor?.Id.ToString() ?? string.Empty }
}, Request, AuthChallenge.Account); }, Request, AuthChallenge.Account);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
if (AuthChallenge.StepRemain == 0) if (AuthChallenge.StepRemain == 0)
{ {
_als.CreateActionLogFromRequest(ActionLogType.NewLogin, als.CreateActionLogFromRequest(ActionLogType.NewLogin,
new Dictionary<string, object> new Dictionary<string, object>
{ {
{ "challenge_id", AuthChallenge.Id }, { "challenge_id", AuthChallenge.Id },
@ -104,7 +85,7 @@ namespace DysonNetwork.Sphere.Pages.Auth
else else
{ {
// If more steps are needed, redirect back to select factor // If more steps are needed, redirect back to select factor
return RedirectToPage("SelectFactor", new { id = Id }); return RedirectToPage("SelectFactor", new { id = Id, returnUrl = ReturnUrl });
} }
} }
else else
@ -117,10 +98,10 @@ namespace DysonNetwork.Sphere.Pages.Auth
if (AuthChallenge != null) if (AuthChallenge != null)
{ {
AuthChallenge.FailedAttempts++; AuthChallenge.FailedAttempts++;
_db.Update(AuthChallenge); db.Update(AuthChallenge);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
_als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
new Dictionary<string, object> new Dictionary<string, object>
{ {
{ "challenge_id", AuthChallenge.Id }, { "challenge_id", AuthChallenge.Id },
@ -136,13 +117,13 @@ namespace DysonNetwork.Sphere.Pages.Auth
private async Task LoadChallengeAndFactor() private async Task LoadChallengeAndFactor()
{ {
AuthChallenge = await _db.AuthChallenges AuthChallenge = await db.AuthChallenges
.Include(e => e.Account) .Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id); .FirstOrDefaultAsync(e => e.Id == Id);
if (AuthChallenge?.Account != null) if (AuthChallenge?.Account != null)
{ {
Factor = await _db.AccountAuthFactors Factor = await db.AccountAuthFactors
.FirstOrDefaultAsync(e => e.Id == FactorId && .FirstOrDefaultAsync(e => e.Id == FactorId &&
e.AccountId == AuthChallenge.Account.Id && e.AccountId == AuthChallenge.Account.Id &&
e.EnabledAt != null && e.EnabledAt != null &&
@ -152,14 +133,14 @@ namespace DysonNetwork.Sphere.Pages.Auth
private async Task<IActionResult> ExchangeTokenAndRedirect() private async Task<IActionResult> ExchangeTokenAndRedirect()
{ {
var challenge = await _db.AuthChallenges var challenge = await db.AuthChallenges
.Include(e => e.Account) .Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id); .FirstOrDefaultAsync(e => e.Id == Id);
if (challenge == null) return BadRequest("Authorization code not found or expired."); if (challenge == null) return BadRequest("Authorization code not found or expired.");
if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed."); if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed.");
var session = await _db.AuthSessions var session = await db.AuthSessions
.FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id); .FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id);
if (session == null) if (session == null)
@ -171,15 +152,15 @@ namespace DysonNetwork.Sphere.Pages.Auth
Account = challenge.Account, Account = challenge.Account,
Challenge = challenge, Challenge = challenge,
}; };
_db.AuthSessions.Add(session); db.AuthSessions.Add(session);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
var token = _auth.CreateToken(session); var token = auth.CreateToken(session);
Response.Cookies.Append("access_token", token, new CookieOptions Response.Cookies.Append(AuthConstants.CookieTokenName, token, new CookieOptions
{ {
HttpOnly = true, HttpOnly = true,
Secure = !_configuration.GetValue<bool>("Debug"), Secure = !configuration.GetValue<bool>("Debug"),
SameSite = SameSiteMode.Strict, SameSite = SameSiteMode.Strict,
Path = "/" Path = "/"
}); });
@ -191,7 +172,8 @@ namespace DysonNetwork.Sphere.Pages.Auth
} }
// Check TempData for return URL (in case it was passed through multiple steps) // Check TempData for return URL (in case it was passed through multiple steps)
if (TempData.TryGetValue("ReturnUrl", out var tempReturnUrl) && tempReturnUrl is string returnUrl && !string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) if (TempData.TryGetValue("ReturnUrl", out var tempReturnUrl) && tempReturnUrl is string returnUrl &&
!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
{ {
return Redirect(returnUrl); return Redirect(returnUrl);
} }

View File

@ -17,7 +17,7 @@
<div class="flex items-center ml-auto"> <div class="flex items-center ml-auto">
@if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _)) @if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _))
{ {
<a href="/Account/Profile" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Profile</a> <a href="/web/account/profile" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Profile</a>
<form method="post" asp-page="/Account/Profile" asp-page-handler="Logout" class="inline"> <form method="post" asp-page="/Account/Profile" asp-page-handler="Logout" class="inline">
<button type="submit" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Logout</button> <button type="submit" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Logout</button>
</form> </form>
@ -31,20 +31,11 @@
</nav> </nav>
</header> </header>
@* The header 64px + The footer 56px = 118px *@ @* The header 64px *@
<main class="h-full"> <main class="h-full">
@RenderBody() @RenderBody()
</main> </main>
<footer class="bg-white dark:bg-gray-800 fixed bottom-0 left-0 right-0 shadow-[0_-1px_3px_0_rgba(0,0,0,0.1)]">
<div class="container-default" style="padding: 1rem 0;">
<p class="text-center text-gray-500 dark:text-gray-400">
&copy; @DateTime.Now.Year Solsynth LLC. All
rights reserved.
</p>
</div>
</footer>
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
</body> </body>
</html> </html>

View File

@ -24,6 +24,7 @@ using NodaTime.Serialization.SystemTextJson;
using StackExchange.Redis; using StackExchange.Redis;
using System.Text.Json; using System.Text.Json;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Services; using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using DysonNetwork.Sphere.Connection.WebReader; using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Developer; using DysonNetwork.Sphere.Developer;
@ -235,6 +236,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<SafetyService>(); services.AddScoped<SafetyService>();
services.AddScoped<DiscoveryService>(); services.AddScoped<DiscoveryService>();
services.AddScoped<CustomAppService>(); services.AddScoped<CustomAppService>();
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>(); services.AddScoped<OidcProviderService>();
return services; return services;

View File

@ -23,10 +23,19 @@
} }
} }
}, },
"Jwt": { "AuthToken": {
"PublicKeyPath": "Keys/PublicKey.pem", "PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem" "PrivateKeyPath": "Keys/PrivateKey.pem"
}, },
"OidcProvider": {
"IssuerUri": "https://nt.solian.app",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem",
"AccessTokenLifetime": "01:00:00",
"RefreshTokenLifetime": "30.00:00:00",
"AuthorizationCodeLifetime": "00:30:00",
"RequireHttpsMetadata": true
},
"Tus": { "Tus": {
"StorePath": "Uploads" "StorePath": "Uploads"
}, },

View File

@ -50,6 +50,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5703920a18f94462b4354fab05326e6519a200_003F35_003F8536fc49_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5703920a18f94462b4354fab05326e6519a200_003F35_003F8536fc49_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtBearerExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff611a1225a3445458f2ca3f102eed5bdcd10_003F07_003F030df6ba_003FJwtBearerExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtBearerExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff611a1225a3445458f2ca3f102eed5bdcd10_003F07_003F030df6ba_003FJwtBearerExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F19f907d47c6f4a2ea68238bf22de133a16600_003Fa0_003F66d8c35f_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F477051138f1f40de9077b7b1cdc55c6215fb0_003Ff5_003Fd716e016_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F477051138f1f40de9077b7b1cdc55c6215fb0_003Ff5_003Fd716e016_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKestrelServerLimits_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e2e5dfcafad4407b569dd5df56a2fbf274e00_003Fa4_003F39445f62_003FKestrelServerLimits_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKestrelServerLimits_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e2e5dfcafad4407b569dd5df56a2fbf274e00_003Fa4_003F39445f62_003FKestrelServerLimits_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKnownResamplers_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003Fb3_003Fcdb3e080_003FKnownResamplers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKnownResamplers_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003Fb3_003Fcdb3e080_003FKnownResamplers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@ -60,6 +61,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFoundResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F28_003F290250f5_003FNotFoundResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFoundResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F28_003F290250f5_003FNotFoundResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFound_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff2c049af93e430aac427e8ff3cc9edd8763d5c9f006d7121ed1c5921585cba_003FNotFound_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFound_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff2c049af93e430aac427e8ff3cc9edd8763d5c9f006d7121ed1c5921585cba_003FNotFound_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANpgsqlEntityTypeBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fccb1faacaea4420db96b09857fc56178a1600_003Fd9_003F9acf9507_003FNpgsqlEntityTypeBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANpgsqlEntityTypeBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fccb1faacaea4420db96b09857fc56178a1600_003Fd9_003F9acf9507_003FNpgsqlEntityTypeBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3bef61b8a21d4c8e96872ecdd7782fa0e55000_003F79_003F4ab1c673_003FNullable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003F6a_003Fea17bf26_003FNullable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003F6a_003Fea17bf26_003FNullable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>