✨ Support OIDC
This commit is contained in:
		| @@ -0,0 +1,305 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.Security.Claims; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using DysonNetwork.Sphere.Developer; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Options; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Responses; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Services; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Extensions.Options; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers; | ||||
|  | ||||
| [Route("connect")] | ||||
| [ApiController] | ||||
| public class OidcProviderController( | ||||
|     OidcProviderService oidcService, | ||||
|     IOptions<OidcProviderOptions> options, | ||||
|     ILogger<OidcProviderController> logger | ||||
| ) | ||||
|     : 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")] | ||||
|     [Consumes("application/x-www-form-urlencoded")] | ||||
|     public async Task<IActionResult> Token([FromForm] TokenRequest request) | ||||
|     { | ||||
|         if (request.GrantType == "authorization_code") | ||||
|         { | ||||
|             // Validate client credentials | ||||
|             if (request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret)) | ||||
|                 return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Client credentials are required" }); | ||||
|  | ||||
|             var client = await oidcService.FindClientByIdAsync(request.ClientId.Value); | ||||
|             if (client == null || !await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret)) | ||||
|                 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); | ||||
|                 return BadRequest(new ErrorResponse { Error = "invalid_grant", ErrorDescription = "Invalid or expired authorization code" }); | ||||
|             } | ||||
|              | ||||
|             // Generate tokens | ||||
|             var tokenResponse = await oidcService.GenerateTokenResponseAsync( | ||||
|                 clientId: request.ClientId.Value, | ||||
|                 subjectId: authCode.UserId, | ||||
|                 scopes: authCode.Scopes, | ||||
|                 authorizationCode: request.Code); | ||||
|  | ||||
|             return Ok(tokenResponse); | ||||
|         } | ||||
|         else if (request.GrantType == "refresh_token") | ||||
|         { | ||||
|             // Handle refresh token request | ||||
|             // In a real implementation, you would validate the refresh token | ||||
|             // and issue a new access token | ||||
|             return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" }); | ||||
|         } | ||||
|  | ||||
|         return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" }); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("userinfo")] | ||||
|     [Authorize(AuthenticationSchemes = "Bearer")] | ||||
|     public async Task<IActionResult> UserInfo() | ||||
|     { | ||||
|         var authHeader = HttpContext.Request.Headers.Authorization.ToString(); | ||||
|         if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) | ||||
|         { | ||||
|             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 | ||||
|         var scopes = jwtToken.Claims | ||||
|             .Where(c => c.Type == "scope") | ||||
|             .SelectMany(c => c.Value.Split(' ')) | ||||
|             .ToHashSet(); | ||||
|  | ||||
|         var userInfo = new Dictionary<string, object> | ||||
|         { | ||||
|             ["sub"] = userId ?? "anonymous" | ||||
|         }; | ||||
|  | ||||
|         // Include standard claims based on scopes | ||||
|         if (scopes.Contains("profile") || scopes.Contains("name")) | ||||
|         { | ||||
|             if (!string.IsNullOrEmpty(userName)) | ||||
|                 userInfo["name"] = userName; | ||||
|         } | ||||
|  | ||||
|         if (scopes.Contains("email") && !string.IsNullOrEmpty(userEmail)) | ||||
|         { | ||||
|             userInfo["email"] = userEmail; | ||||
|             userInfo["email_verified"] = true; // In a real app, check if email is verified | ||||
|         } | ||||
|  | ||||
|         return Ok(userInfo); | ||||
|     } | ||||
|  | ||||
|     [HttpGet(".well-known/openid-configuration")] | ||||
|     public IActionResult GetConfiguration() | ||||
|     { | ||||
|         var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}".TrimEnd('/'); | ||||
|         var issuer = options.Value.IssuerUri.TrimEnd('/'); | ||||
|  | ||||
|         return Ok(new | ||||
|         { | ||||
|             issuer = issuer, | ||||
|             authorization_endpoint = $"{baseUrl}/connect/authorize", | ||||
|             token_endpoint = $"{baseUrl}/connect/token", | ||||
|             userinfo_endpoint = $"{baseUrl}/connect/userinfo", | ||||
|             jwks_uri = $"{baseUrl}/.well-known/openid-configuration/jwks", | ||||
|             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" }, | ||||
|             grant_types_supported = new[] { "authorization_code", "refresh_token" }, | ||||
|             token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" }, | ||||
|             id_token_signing_alg_values_supported = new[] { "HS256" }, | ||||
|             subject_types_supported = new[] { "public" }, | ||||
|             claims_supported = new[] { "sub", "name", "email", "email_verified" }, | ||||
|             code_challenge_methods_supported = new[] { "S256" }, | ||||
|             response_modes_supported = new[] { "query", "fragment", "form_post" }, | ||||
|             request_parameter_supported = true, | ||||
|             request_uri_parameter_supported = true, | ||||
|             require_request_uri_registration = false | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("jwks")] | ||||
|     public IActionResult Jwks() | ||||
|     { | ||||
|         // In a production environment, you should use asymmetric keys (RSA or EC) | ||||
|         // and expose only the public key here. This is a simplified example using HMAC. | ||||
|         // For production, consider using RSA or EC keys and proper key rotation. | ||||
|  | ||||
|         var keyBytes = Encoding.UTF8.GetBytes(options.Value.SigningKey); | ||||
|         var keyId = Convert.ToBase64String(SHA256.HashData(keyBytes)[..8]) | ||||
|             .Replace("+", "-") | ||||
|             .Replace("/", "_") | ||||
|             .Replace("=", ""); | ||||
|  | ||||
|         return Ok(new | ||||
|         { | ||||
|             keys = new[] | ||||
|             { | ||||
|                 new | ||||
|                 { | ||||
|                     kty = "oct", | ||||
|                     use = "sig", | ||||
|                     kid = keyId, | ||||
|                     k = Convert.ToBase64String(keyBytes), | ||||
|                     alg = "HS256" | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public class TokenRequest | ||||
| { | ||||
|     [JsonPropertyName("grant_type")] | ||||
|     public string? GrantType { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("code")] | ||||
|     public string? Code { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("redirect_uri")] | ||||
|     public string? RedirectUri { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("client_id")] | ||||
|     public Guid? ClientId { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("client_secret")] | ||||
|     public string? ClientSecret { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("refresh_token")] | ||||
|     public string? RefreshToken { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("scope")] | ||||
|     public string? Scope { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("code_verifier")] | ||||
|     public string? CodeVerifier { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Models; | ||||
|  | ||||
| public class AuthorizationCodeInfo | ||||
| { | ||||
|     public Guid ClientId { get; set; } | ||||
|     public string UserId { get; set; } = string.Empty; | ||||
|     public string RedirectUri { get; set; } = string.Empty; | ||||
|     public List<string> Scopes { get; set; } = new(); | ||||
|     public string? CodeChallenge { get; set; } | ||||
|     public string? CodeChallengeMethod { get; set; } | ||||
|     public string? Nonce { get; set; } | ||||
|     public Instant Expiration { get; set; } | ||||
|     public Instant CreatedAt { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| using System; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Options; | ||||
|  | ||||
| public class OidcProviderOptions | ||||
| { | ||||
|     public string IssuerUri { get; set; } = "https://your-issuer-uri.com"; | ||||
|     public string SigningKey { get; set; } = "replace-with-a-secure-random-key"; | ||||
|     public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1); | ||||
|     public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30); | ||||
|     public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5); | ||||
|     public bool RequireHttpsMetadata { get; set; } = true; | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; | ||||
|  | ||||
| public class AuthorizationResponse | ||||
| { | ||||
|     [JsonPropertyName("code")] | ||||
|     public string Code { get; set; } = null!; | ||||
|  | ||||
|     [JsonPropertyName("state")] | ||||
|     public string? State { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("scope")] | ||||
|     public string? Scope { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("session_state")] | ||||
|     public string? SessionState { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("iss")] | ||||
|     public string? Issuer { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,20 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; | ||||
|  | ||||
| public class ErrorResponse | ||||
| { | ||||
|     [JsonPropertyName("error")] | ||||
|     public string Error { get; set; } = null!; | ||||
|  | ||||
|     [JsonPropertyName("error_description")] | ||||
|     public string? ErrorDescription { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("error_uri")] | ||||
|     public string? ErrorUri { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("state")] | ||||
|     public string? State { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; | ||||
|  | ||||
| public class TokenResponse | ||||
| { | ||||
|     [JsonPropertyName("access_token")] | ||||
|     public string AccessToken { get; set; } = null!; | ||||
|  | ||||
|     [JsonPropertyName("expires_in")] | ||||
|     public int ExpiresIn { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("token_type")] | ||||
|     public string TokenType { get; set; } = "Bearer"; | ||||
|  | ||||
|     [JsonPropertyName("refresh_token")] | ||||
|     public string? RefreshToken { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("scope")] | ||||
|     public string? Scope { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("id_token")] | ||||
|     public string? IdToken { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,301 @@ | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
| using System.Security.Claims; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.Encodings.Web; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Models; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Options; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Responses; | ||||
| using DysonNetwork.Sphere.Developer; | ||||
| using Microsoft.AspNetCore.Identity; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Services; | ||||
|  | ||||
| public class OidcProviderService( | ||||
|     AppDatabase db, | ||||
|     IClock clock, | ||||
|     IOptions<OidcProviderOptions> options, | ||||
|     ILogger<OidcProviderService> logger | ||||
| ) | ||||
| { | ||||
|     private readonly OidcProviderOptions _options = options.Value; | ||||
|  | ||||
|     public async Task<CustomApp?> FindClientByIdAsync(Guid clientId) | ||||
|     { | ||||
|         return await db.CustomApps | ||||
|             .Include(c => c.Secrets) | ||||
|             .FirstOrDefaultAsync(c => c.Id == clientId); | ||||
|     } | ||||
|  | ||||
|     public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId) | ||||
|     { | ||||
|         return await db.CustomApps | ||||
|             .Include(c => c.Secrets) | ||||
|             .FirstOrDefaultAsync(c => c.Id == appId); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret) | ||||
|     { | ||||
|         var client = await FindClientByIdAsync(clientId); | ||||
|         if (client == null) return false; | ||||
|  | ||||
|         var secret = client.Secrets | ||||
|             .Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant())) | ||||
|             .FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing | ||||
|  | ||||
|         return secret != null; | ||||
|     } | ||||
|  | ||||
|     public async Task<TokenResponse> GenerateTokenResponseAsync( | ||||
|         Guid clientId, | ||||
|         string subjectId, | ||||
|         IEnumerable<string>? scopes = null, | ||||
|         string? authorizationCode = null) | ||||
|     { | ||||
|         var client = await FindClientByIdAsync(clientId); | ||||
|         if (client == null) | ||||
|             throw new InvalidOperationException("Client not found"); | ||||
|  | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|         var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; | ||||
|         var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); | ||||
|  | ||||
|         // Generate access token | ||||
|         var accessToken = GenerateJwtToken(client, subjectId, expiresAt, scopes); | ||||
|         var refreshToken = GenerateRefreshToken(); | ||||
|  | ||||
|         // 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 | ||||
|         { | ||||
|             AccessToken = accessToken, | ||||
|             ExpiresIn = expiresIn, | ||||
|             TokenType = "Bearer", | ||||
|             RefreshToken = refreshToken, | ||||
|             Scope = scopes != null ? string.Join(" ", scopes) : null | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private string GenerateJwtToken(CustomApp client, string subjectId, Instant expiresAt, IEnumerable<string>? scopes = null) | ||||
|     { | ||||
|         var tokenHandler = new JwtSecurityTokenHandler(); | ||||
|         var key = Encoding.ASCII.GetBytes(_options.SigningKey); | ||||
|  | ||||
|         var tokenDescriptor = new SecurityTokenDescriptor | ||||
|         { | ||||
|             Subject = new ClaimsIdentity(new[] | ||||
|             { | ||||
|                 new Claim(JwtRegisteredClaimNames.Sub, subjectId), | ||||
|                 new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), | ||||
|                 new Claim(JwtRegisteredClaimNames.Iat, clock.GetCurrentInstant().ToUnixTimeSeconds().ToString(), | ||||
|                     ClaimValueTypes.Integer64), | ||||
|                 new Claim("client_id", client.Id.ToString()) | ||||
|             }), | ||||
|             Expires = expiresAt.ToDateTimeUtc(), | ||||
|             Issuer = _options.IssuerUri, | ||||
|             Audience = client.Id.ToString(), | ||||
|             SigningCredentials = new SigningCredentials( | ||||
|                 new SymmetricSecurityKey(key), | ||||
|                 SecurityAlgorithms.HmacSha256Signature) | ||||
|         }; | ||||
|  | ||||
|         // Add scopes as claims if provided, otherwise use client's default scopes | ||||
|         var effectiveScopes = scopes?.ToList() ?? client.AllowedScopes?.ToList() ?? new List<string>(); | ||||
|         if (effectiveScopes.Any()) | ||||
|         { | ||||
|             tokenDescriptor.Subject.AddClaims( | ||||
|                 effectiveScopes.Select(scope => new Claim("scope", scope))); | ||||
|         } | ||||
|  | ||||
|         var token = tokenHandler.CreateToken(tokenDescriptor); | ||||
|         return tokenHandler.WriteToken(token); | ||||
|     } | ||||
|  | ||||
|     private static string GenerateRefreshToken() | ||||
|     { | ||||
|         using var rng = RandomNumberGenerator.Create(); | ||||
|         var bytes = new byte[32]; | ||||
|         rng.GetBytes(bytes); | ||||
|         return Convert.ToBase64String(bytes) | ||||
|             .Replace("+", "-") | ||||
|             .Replace("/", "_") | ||||
|             .Replace("=", ""); | ||||
|     } | ||||
|  | ||||
|     private static bool VerifyHashedSecret(string secret, string hashedSecret) | ||||
|     { | ||||
|         // In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2 | ||||
|         // For now, we'll do a simple comparison, but you should replace this with proper hashing | ||||
|         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( | ||||
|         Guid clientId, | ||||
|         string userId, | ||||
|         string redirectUri, | ||||
|         IEnumerable<string> scopes, | ||||
|         string? codeChallenge = null, | ||||
|         string? codeChallengeMethod = null, | ||||
|         string? nonce = null) | ||||
|     { | ||||
|         // Generate a random code | ||||
|         var code = GenerateRandomString(32); | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|          | ||||
|         // Store the code with its metadata | ||||
|         _authorizationCodes[code] = new AuthorizationCodeInfo | ||||
|         { | ||||
|             ClientId = clientId, | ||||
|             UserId = userId, | ||||
|             RedirectUri = redirectUri, | ||||
|             Scopes = scopes.ToList(), | ||||
|             CodeChallenge = codeChallenge, | ||||
|             CodeChallengeMethod = codeChallengeMethod, | ||||
|             Nonce = nonce, | ||||
|             Expiration = now.Plus(Duration.FromTimeSpan(_options.AuthorizationCodeLifetime)), | ||||
|             CreatedAt = now | ||||
|         }; | ||||
|          | ||||
|         logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId); | ||||
|         return code; | ||||
|     } | ||||
|      | ||||
|     public async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync( | ||||
|         string code, | ||||
|         Guid clientId, | ||||
|         string? redirectUri = null, | ||||
|         string? codeVerifier = null) | ||||
|     { | ||||
|         if (!_authorizationCodes.TryGetValue(code, out var authCode) || authCode == null) | ||||
|         { | ||||
|             logger.LogWarning("Authorization code not found: {Code}", code); | ||||
|             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 | ||||
|         if (authCode.ClientId != clientId) | ||||
|         { | ||||
|             logger.LogWarning("Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",  | ||||
|                 code, authCode.ClientId, clientId); | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         // Verify redirect URI if provided | ||||
|         if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri) | ||||
|         { | ||||
|             logger.LogWarning("Redirect URI mismatch for code {Code}", code); | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         // Verify PKCE code challenge if one was provided during authorization | ||||
|         if (!string.IsNullOrEmpty(authCode.CodeChallenge)) | ||||
|         { | ||||
|             if (string.IsNullOrEmpty(codeVerifier)) | ||||
|             { | ||||
|                 logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code); | ||||
|                 return null; | ||||
|             } | ||||
|              | ||||
|             var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch | ||||
|             { | ||||
|                 "S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"), | ||||
|                 "PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"), | ||||
|                 _ => false // Unsupported code challenge method | ||||
|             }; | ||||
|              | ||||
|             if (!isValid) | ||||
|             { | ||||
|                 logger.LogWarning("PKCE code verifier validation failed for code {Code}", code); | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Code is valid, remove it from the store (codes are single-use) | ||||
|         _authorizationCodes.Remove(code); | ||||
|          | ||||
|         return authCode; | ||||
|     } | ||||
|      | ||||
|     private static string GenerateRandomString(int length) | ||||
|     { | ||||
|         const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"; | ||||
|         var random = RandomNumberGenerator.Create(); | ||||
|         var result = new char[length]; | ||||
|          | ||||
|         for (int i = 0; i < length; i++) | ||||
|         { | ||||
|             var randomNumber = new byte[4]; | ||||
|             random.GetBytes(randomNumber); | ||||
|             var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length); | ||||
|             result[i] = chars[index]; | ||||
|         } | ||||
|          | ||||
|         return new string(result); | ||||
|     } | ||||
|      | ||||
|     private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(codeVerifier)) return false; | ||||
|          | ||||
|         if (method == "S256") | ||||
|         { | ||||
|             using var sha256 = SHA256.Create(); | ||||
|             var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); | ||||
|             var base64 = Base64UrlEncoder.Encode(hash); | ||||
|             return string.Equals(base64, codeChallenge, StringComparison.Ordinal); | ||||
|         } | ||||
|          | ||||
|         if (method == "PLAIN") | ||||
|         { | ||||
|             return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal); | ||||
|         } | ||||
|          | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user