✨ 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; } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user