🧱 OAuth login infra
This commit is contained in:
		
							
								
								
									
										267
									
								
								DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										267
									
								
								DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,267 @@ | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
|  | ||||
| /// <summary> | ||||
| /// Implementation of OpenID Connect service for Apple Sign In | ||||
| /// </summary> | ||||
| public class AppleOidcService( | ||||
|     IConfiguration configuration, | ||||
|     IHttpClientFactory httpClientFactory, | ||||
|     AppDatabase db | ||||
| ) | ||||
|     : OidcService(configuration, httpClientFactory, db) | ||||
| { | ||||
|     private readonly IConfiguration _configuration = configuration; | ||||
|     private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; | ||||
|  | ||||
|     public override string ProviderName => "apple"; | ||||
|     protected override string DiscoveryEndpoint => "https://appleid.apple.com/.well-known/openid-configuration"; | ||||
|     protected override string ConfigSectionName => "Apple"; | ||||
|  | ||||
|     public override string GetAuthorizationUrl(string state, string nonce) | ||||
|     { | ||||
|         var config = GetProviderConfig(); | ||||
|  | ||||
|         var queryParams = new Dictionary<string, string> | ||||
|         { | ||||
|             { "client_id", config.ClientId }, | ||||
|             { "redirect_uri", config.RedirectUri }, | ||||
|             { "response_type", "code id_token" }, | ||||
|             { "scope", "name email" }, | ||||
|             { "response_mode", "form_post" }, | ||||
|             { "state", state }, | ||||
|             { "nonce", nonce } | ||||
|         }; | ||||
|  | ||||
|         var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); | ||||
|         return $"https://appleid.apple.com/auth/authorize?{queryString}"; | ||||
|     } | ||||
|  | ||||
|     public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) | ||||
|     { | ||||
|         // Verify and decode the id_token | ||||
|         var userInfo = await ValidateTokenAsync(callbackData.IdToken); | ||||
|  | ||||
|         // If user data is provided in first login, parse it | ||||
|         if (!string.IsNullOrEmpty(callbackData.RawData)) | ||||
|         { | ||||
|             var userData = JsonSerializer.Deserialize<AppleUserData>(callbackData.RawData); | ||||
|             if (userData?.Name != null) | ||||
|             { | ||||
|                 userInfo.FirstName = userData.Name.FirstName ?? ""; | ||||
|                 userInfo.LastName = userData.Name.LastName ?? ""; | ||||
|                 userInfo.DisplayName = $"{userInfo.FirstName} {userInfo.LastName}".Trim(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Exchange authorization code for access token (optional, if you need the access token) | ||||
|         if (string.IsNullOrEmpty(callbackData.Code)) return userInfo; | ||||
|         var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); | ||||
|         if (tokenResponse == null) return userInfo; | ||||
|         userInfo.AccessToken = tokenResponse.AccessToken; | ||||
|         userInfo.RefreshToken = tokenResponse.RefreshToken; | ||||
|  | ||||
|         return userInfo; | ||||
|     } | ||||
|  | ||||
|     private async Task<OidcUserInfo> ValidateTokenAsync(string idToken) | ||||
|     { | ||||
|         // Get Apple's public keys | ||||
|         var jwksJson = await GetAppleJwksAsync(); | ||||
|         var jwks = JsonSerializer.Deserialize<AppleJwks>(jwksJson) ?? new AppleJwks { Keys = new List<AppleKey>() }; | ||||
|  | ||||
|         // Parse the JWT header to get the key ID | ||||
|         var handler = new JwtSecurityTokenHandler(); | ||||
|         var jwtToken = handler.ReadJwtToken(idToken); | ||||
|         var kid = jwtToken.Header.Kid; | ||||
|  | ||||
|         // Find the matching key | ||||
|         var key = jwks.Keys.FirstOrDefault(k => k.Kid == kid); | ||||
|         if (key == null) | ||||
|         { | ||||
|             throw new SecurityTokenValidationException("Unable to find matching key in Apple's JWKS"); | ||||
|         } | ||||
|  | ||||
|         // Create the validation parameters | ||||
|         var validationParameters = new TokenValidationParameters | ||||
|         { | ||||
|             ValidateIssuer = true, | ||||
|             ValidIssuer = "https://appleid.apple.com", | ||||
|             ValidateAudience = true, | ||||
|             ValidAudience = GetProviderConfig().ClientId, | ||||
|             ValidateLifetime = true, | ||||
|             IssuerSigningKey = key.ToSecurityKey() | ||||
|         }; | ||||
|  | ||||
|         return ValidateAndExtractIdToken(idToken, validationParameters); | ||||
|     } | ||||
|  | ||||
|     protected override Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config, | ||||
|         string? codeVerifier) | ||||
|     { | ||||
|         var parameters = new Dictionary<string, string> | ||||
|         { | ||||
|             { "client_id", config.ClientId }, | ||||
|             { "client_secret", GenerateClientSecret() }, | ||||
|             { "code", code }, | ||||
|             { "grant_type", "authorization_code" }, | ||||
|             { "redirect_uri", config.RedirectUri } | ||||
|         }; | ||||
|  | ||||
|         return parameters; | ||||
|     } | ||||
|  | ||||
|     private async Task<string> GetAppleJwksAsync() | ||||
|     { | ||||
|         var client = _httpClientFactory.CreateClient(); | ||||
|         var response = await client.GetAsync("https://appleid.apple.com/auth/keys"); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         return await response.Content.ReadAsStringAsync(); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Generates a client secret for Apple Sign In using JWT | ||||
|     /// </summary> | ||||
|     private string GenerateClientSecret() | ||||
|     { | ||||
|         var now = DateTime.UtcNow; | ||||
|         var teamId = _configuration["Oidc:Apple:TeamId"]; | ||||
|         var clientId = _configuration["Oidc:Apple:ClientId"]; | ||||
|         var keyId = _configuration["Oidc:Apple:KeyId"]; | ||||
|         var privateKeyPath = _configuration["Oidc:Apple:PrivateKeyPath"]; | ||||
|  | ||||
|         // Read the private key | ||||
|         var privateKey = File.ReadAllText(privateKeyPath!); | ||||
|  | ||||
|         // Create the JWT header | ||||
|         var header = new Dictionary<string, object> | ||||
|         { | ||||
|             { "alg", "ES256" }, | ||||
|             { "kid", keyId } | ||||
|         }; | ||||
|  | ||||
|         // Create the JWT payload | ||||
|         var payload = new Dictionary<string, object> | ||||
|         { | ||||
|             { "iss", teamId }, | ||||
|             { "iat", ToUnixTimeSeconds(now) }, | ||||
|             { "exp", ToUnixTimeSeconds(now.AddMinutes(5)) }, | ||||
|             { "aud", "https://appleid.apple.com" }, | ||||
|             { "sub", clientId } | ||||
|         }; | ||||
|  | ||||
|         // Convert header and payload to Base64Url | ||||
|         var headerJson = JsonSerializer.Serialize(header); | ||||
|         var payloadJson = JsonSerializer.Serialize(payload); | ||||
|         var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); | ||||
|         var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); | ||||
|  | ||||
|         // Create the signature | ||||
|         var dataToSign = $"{headerBase64}.{payloadBase64}"; | ||||
|         var signature = SignWithECDsa(dataToSign, privateKey); | ||||
|  | ||||
|         // Combine all parts | ||||
|         return $"{headerBase64}.{payloadBase64}.{signature}"; | ||||
|     } | ||||
|  | ||||
|     private long ToUnixTimeSeconds(DateTime dateTime) | ||||
|     { | ||||
|         return new DateTimeOffset(dateTime).ToUnixTimeSeconds(); | ||||
|     } | ||||
|  | ||||
|     private string SignWithECDsa(string dataToSign, string privateKey) | ||||
|     { | ||||
|         using var ecdsa = ECDsa.Create(); | ||||
|         ecdsa.ImportFromPem(privateKey); | ||||
|  | ||||
|         var bytes = Encoding.UTF8.GetBytes(dataToSign); | ||||
|         var signature = ecdsa.SignData(bytes, HashAlgorithmName.SHA256); | ||||
|  | ||||
|         return Base64UrlEncode(signature); | ||||
|     } | ||||
|  | ||||
|     private string Base64UrlEncode(byte[] data) | ||||
|     { | ||||
|         return Convert.ToBase64String(data) | ||||
|             .Replace('+', '-') | ||||
|             .Replace('/', '_') | ||||
|             .TrimEnd('='); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public class AppleUserData | ||||
| { | ||||
|     [JsonPropertyName("name")] public AppleNameData? Name { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("email")] public string? Email { get; set; } | ||||
| } | ||||
|  | ||||
| public class AppleNameData | ||||
| { | ||||
|     [JsonPropertyName("firstName")] public string? FirstName { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("lastName")] public string? LastName { get; set; } | ||||
| } | ||||
|  | ||||
| public class AppleJwks | ||||
| { | ||||
|     [JsonPropertyName("keys")] public List<AppleKey> Keys { get; set; } = new List<AppleKey>(); | ||||
| } | ||||
|  | ||||
| public class AppleKey | ||||
| { | ||||
|     [JsonPropertyName("kty")] public string? Kty { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("kid")] public string? Kid { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("use")] public string? Use { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("alg")] public string? Alg { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("n")] public string? N { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("e")] public string? E { get; set; } | ||||
|  | ||||
|     public SecurityKey ToSecurityKey() | ||||
|     { | ||||
|         if (Kty != "RSA" || string.IsNullOrEmpty(N) || string.IsNullOrEmpty(E)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Invalid key data"); | ||||
|         } | ||||
|  | ||||
|         var parameters = new RSAParameters | ||||
|         { | ||||
|             Modulus = Base64UrlDecode(N), | ||||
|             Exponent = Base64UrlDecode(E) | ||||
|         }; | ||||
|  | ||||
|         var rsa = RSA.Create(); | ||||
|         rsa.ImportParameters(parameters); | ||||
|  | ||||
|         return new RsaSecurityKey(rsa); | ||||
|     } | ||||
|  | ||||
|     private byte[] Base64UrlDecode(string input) | ||||
|     { | ||||
|         var output = input | ||||
|             .Replace('-', '+') | ||||
|             .Replace('_', '/'); | ||||
|  | ||||
|         switch (output.Length % 4) | ||||
|         { | ||||
|             case 0: break; | ||||
|             case 2: output += "=="; break; | ||||
|             case 3: output += "="; break; | ||||
|             default: throw new InvalidOperationException("Invalid base64url string"); | ||||
|         } | ||||
|  | ||||
|         return Convert.FromBase64String(output); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										43
									
								
								DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/api/connections")] | ||||
| [Authorize] | ||||
| public class ConnectionController(AppDatabase db) : ControllerBase | ||||
| { | ||||
|     [HttpGet] | ||||
|     public async Task<ActionResult<List<AccountConnection>>> GetConnections() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var connections = await db.AccountConnections | ||||
|             .Where(c => c.AccountId == currentUser.Id) | ||||
|             .Select(c => new { c.Id, c.AccountId, c.Provider, c.ProvidedIdentifier }) | ||||
|             .ToListAsync(); | ||||
|         return Ok(connections); | ||||
|     } | ||||
|  | ||||
|     [HttpDelete("{id:guid}")] | ||||
|     public async Task<ActionResult> RemoveConnection(Guid id) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var connection = await db.AccountConnections | ||||
|             .Where(c => c.Id == id && c.AccountId == currentUser.Id) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (connection == null) | ||||
|             return NotFound(); | ||||
|  | ||||
|         db.AccountConnections.Remove(connection); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         return Ok(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										184
									
								
								DysonNetwork.Sphere/Auth/OpenId/GoogleOidcService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								DysonNetwork.Sphere/Auth/OpenId/GoogleOidcService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,184 @@ | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
| using System.Net.Http.Json; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
|  | ||||
| /// <summary> | ||||
| /// Implementation of OpenID Connect service for Google Sign In | ||||
| /// </summary> | ||||
| public class GoogleOidcService( | ||||
|     IConfiguration configuration, | ||||
|     IHttpClientFactory httpClientFactory, | ||||
|     AppDatabase db | ||||
| ) | ||||
|     : OidcService(configuration, httpClientFactory, db) | ||||
| { | ||||
|     private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; | ||||
|      | ||||
|     public override string ProviderName => "google"; | ||||
|     protected override string DiscoveryEndpoint => "https://accounts.google.com/.well-known/openid-configuration"; | ||||
|     protected override string ConfigSectionName => "Google"; | ||||
|  | ||||
|     public override string GetAuthorizationUrl(string state, string nonce) | ||||
|     { | ||||
|         var config = GetProviderConfig(); | ||||
|         var discoveryDocument = GetDiscoveryDocumentAsync().GetAwaiter().GetResult(); | ||||
|  | ||||
|         if (discoveryDocument?.AuthorizationEndpoint == null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authorization endpoint not found in discovery document"); | ||||
|         } | ||||
|  | ||||
|         // Generate code verifier and challenge for PKCE | ||||
|         var codeVerifier = GenerateCodeVerifier(); | ||||
|         var codeChallenge = GenerateCodeChallenge(codeVerifier); | ||||
|  | ||||
|         // Store code verifier in session or cache for later use | ||||
|         // For simplicity, we'll append it to the state parameter in this example | ||||
|         var combinedState = $"{state}|{codeVerifier}"; | ||||
|  | ||||
|         var queryParams = new Dictionary<string, string> | ||||
|         { | ||||
|             { "client_id", config.ClientId }, | ||||
|             { "redirect_uri", config.RedirectUri }, | ||||
|             { "response_type", "code" }, | ||||
|             { "scope", "openid email profile" }, | ||||
|             { "state", combinedState }, | ||||
|             { "nonce", nonce }, | ||||
|             { "code_challenge", codeChallenge }, | ||||
|             { "code_challenge_method", "S256" } | ||||
|         }; | ||||
|  | ||||
|         var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); | ||||
|         return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}"; | ||||
|     } | ||||
|  | ||||
|     public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) | ||||
|     { | ||||
|         // Extract code verifier from state | ||||
|         string? codeVerifier = null; | ||||
|         var state = callbackData.State ?? ""; | ||||
|  | ||||
|         if (state.Contains('|')) | ||||
|         { | ||||
|             var parts = state.Split('|'); | ||||
|             state = parts[0]; | ||||
|             codeVerifier = parts.Length > 1 ? parts[1] : null; | ||||
|             callbackData.State = state; // Set the clean state back | ||||
|         } | ||||
|  | ||||
|         // Exchange the code for tokens | ||||
|         var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, codeVerifier); | ||||
|         if (tokenResponse?.IdToken == null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Failed to obtain ID token from Google"); | ||||
|         } | ||||
|  | ||||
|         // Validate the ID token | ||||
|         var userInfo = await ValidateTokenAsync(tokenResponse.IdToken); | ||||
|  | ||||
|         // Set tokens on the user info | ||||
|         userInfo.AccessToken = tokenResponse.AccessToken; | ||||
|         userInfo.RefreshToken = tokenResponse.RefreshToken; | ||||
|  | ||||
|         // Try to fetch additional profile data if userinfo endpoint is available | ||||
|         try  | ||||
|         { | ||||
|             var discoveryDocument = await GetDiscoveryDocumentAsync(); | ||||
|             if (discoveryDocument?.UserinfoEndpoint != null && !string.IsNullOrEmpty(tokenResponse.AccessToken)) | ||||
|             { | ||||
|                 var client = _httpClientFactory.CreateClient(); | ||||
|                 client.DefaultRequestHeaders.Authorization =  | ||||
|                     new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken); | ||||
|  | ||||
|                 var userInfoResponse = await client.GetFromJsonAsync<Dictionary<string, object>>(discoveryDocument.UserinfoEndpoint); | ||||
|  | ||||
|                 if (userInfoResponse != null) | ||||
|                 { | ||||
|                     // Extract any additional fields that might be available | ||||
|                     if (userInfoResponse.TryGetValue("picture", out var picture) && picture != null) | ||||
|                     { | ||||
|                         userInfo.ProfilePictureUrl = picture.ToString(); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         catch (Exception) | ||||
|         { | ||||
|             // Ignore errors when fetching additional profile data | ||||
|         } | ||||
|  | ||||
|         return userInfo; | ||||
|     } | ||||
|  | ||||
|     private async Task<OidcUserInfo> ValidateTokenAsync(string idToken) | ||||
|     { | ||||
|         var discoveryDocument = await GetDiscoveryDocumentAsync(); | ||||
|         if (discoveryDocument?.JwksUri == null) | ||||
|         { | ||||
|             throw new InvalidOperationException("JWKS URI not found in discovery document"); | ||||
|         } | ||||
|  | ||||
|         // Get Google's signing keys | ||||
|         var client = _httpClientFactory.CreateClient(); | ||||
|         var jwksResponse = await client.GetFromJsonAsync<JsonWebKeySet>(discoveryDocument.JwksUri); | ||||
|         if (jwksResponse == null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Failed to retrieve JWKS from Google"); | ||||
|         } | ||||
|  | ||||
|         // Parse the JWT to get the key ID | ||||
|         var handler = new JwtSecurityTokenHandler(); | ||||
|         var jwtToken = handler.ReadJwtToken(idToken); | ||||
|         var kid = jwtToken.Header.Kid; | ||||
|  | ||||
|         // Find the matching key | ||||
|         var signingKey = jwksResponse.Keys.FirstOrDefault(k => k.Kid == kid); | ||||
|         if (signingKey == null) | ||||
|         { | ||||
|             throw new SecurityTokenValidationException("Unable to find matching key in Google's JWKS"); | ||||
|         } | ||||
|  | ||||
|         // Create validation parameters | ||||
|         var validationParameters = new TokenValidationParameters | ||||
|         { | ||||
|             ValidateIssuer = true, | ||||
|             ValidIssuer = "https://accounts.google.com", | ||||
|             ValidateAudience = true, | ||||
|             ValidAudience = GetProviderConfig().ClientId, | ||||
|             ValidateLifetime = true, | ||||
|             IssuerSigningKey = signingKey | ||||
|         }; | ||||
|  | ||||
|         return ValidateAndExtractIdToken(idToken, validationParameters); | ||||
|     } | ||||
|  | ||||
|     #region PKCE Support | ||||
|  | ||||
|     public string GenerateCodeVerifier() | ||||
|     { | ||||
|         var randomBytes = new byte[32]; // 256 bits | ||||
|         using (var rng = RandomNumberGenerator.Create()) | ||||
|             rng.GetBytes(randomBytes); | ||||
|  | ||||
|         return Convert.ToBase64String(randomBytes) | ||||
|             .Replace('+', '-') | ||||
|             .Replace('/', '_') | ||||
|             .TrimEnd('='); | ||||
|     } | ||||
|  | ||||
|     public string GenerateCodeChallenge(string codeVerifier) | ||||
|     { | ||||
|         using var sha256 = System.Security.Cryptography.SHA256.Create(); | ||||
|         var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); | ||||
|         return Convert.ToBase64String(challengeBytes) | ||||
|             .Replace('+', '-') | ||||
|             .Replace('/', '_') | ||||
|             .TrimEnd('='); | ||||
|     } | ||||
|  | ||||
|     #endregion | ||||
| } | ||||
							
								
								
									
										48
									
								
								DysonNetwork.Sphere/Auth/OpenId/OidcController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								DysonNetwork.Sphere/Auth/OpenId/OidcController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/auth/login")] | ||||
| public class OidcController( | ||||
|     IServiceProvider serviceProvider, | ||||
|     AppDatabase db, | ||||
|     Account.AccountService accountService, | ||||
|     AuthService authService | ||||
| ) | ||||
|     : ControllerBase | ||||
| { | ||||
|     [HttpGet("{provider}")] | ||||
|     public ActionResult SignIn([FromRoute] string provider, [FromQuery] string? returnUrl = "/") | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             // Get the appropriate provider service | ||||
|             var oidcService = GetOidcService(provider); | ||||
|  | ||||
|             // Generate state (containing return URL) and nonce | ||||
|             var state = returnUrl; | ||||
|             var nonce = Guid.NewGuid().ToString(); | ||||
|  | ||||
|             // Get the authorization URL and redirect the user | ||||
|             var authUrl = oidcService.GetAuthorizationUrl(state, nonce); | ||||
|             return Redirect(authUrl); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             return BadRequest($"Error initiating OpenID Connect flow: {ex.Message}"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private OidcService GetOidcService(string provider) | ||||
|     { | ||||
|         return provider.ToLower() switch | ||||
|         { | ||||
|             "apple" => serviceProvider.GetRequiredService<AppleOidcService>(), | ||||
|             "google" => serviceProvider.GetRequiredService<GoogleOidcService>(), | ||||
|             // Add more providers as needed | ||||
|             _ => throw new ArgumentException($"Unsupported provider: {provider}") | ||||
|         }; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										268
									
								
								DysonNetwork.Sphere/Auth/OpenId/OidcService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										268
									
								
								DysonNetwork.Sphere/Auth/OpenId/OidcService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,268 @@ | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
| using System.Net.Http.Json; | ||||
| using System.Security.Claims; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
|  | ||||
| /// <summary> | ||||
| /// Base service for OpenID Connect authentication providers | ||||
| /// </summary> | ||||
| public abstract class OidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db) | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Gets the unique identifier for this provider | ||||
|     /// </summary> | ||||
|     public abstract string ProviderName { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the OIDC discovery document endpoint | ||||
|     /// </summary> | ||||
|     protected abstract string DiscoveryEndpoint { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets configuration section name for this provider | ||||
|     /// </summary> | ||||
|     protected abstract string ConfigSectionName { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the authorization URL for initiating the authentication flow | ||||
|     /// </summary> | ||||
|     public abstract string GetAuthorizationUrl(string state, string nonce); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Process the callback from the OIDC provider | ||||
|     /// </summary> | ||||
|     public abstract Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the provider configuration | ||||
|     /// </summary> | ||||
|     protected ProviderConfiguration GetProviderConfig() | ||||
|     { | ||||
|         return new ProviderConfiguration | ||||
|         { | ||||
|             ClientId = configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "", | ||||
|             ClientSecret = configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "", | ||||
|             RedirectUri = configuration[$"Oidc:{ConfigSectionName}:RedirectUri"] ?? "" | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Retrieves the OpenID Connect discovery document | ||||
|     /// </summary> | ||||
|     protected async Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync() | ||||
|     { | ||||
|         var client = httpClientFactory.CreateClient(); | ||||
|         var response = await client.GetAsync(DiscoveryEndpoint); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|         return await response.Content.ReadFromJsonAsync<OidcDiscoveryDocument>(); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Exchange the authorization code for tokens | ||||
|     /// </summary> | ||||
|     protected async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, string? codeVerifier = null) | ||||
|     { | ||||
|         var config = GetProviderConfig(); | ||||
|         var discoveryDocument = await GetDiscoveryDocumentAsync(); | ||||
|  | ||||
|         if (discoveryDocument?.TokenEndpoint == null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Token endpoint not found in discovery document"); | ||||
|         } | ||||
|  | ||||
|         var client = httpClientFactory.CreateClient(); | ||||
|         var content = new FormUrlEncodedContent(BuildTokenRequestParameters(code, config, codeVerifier)); | ||||
|  | ||||
|         var response = await client.PostAsync(discoveryDocument.TokenEndpoint, content); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         return await response.Content.ReadFromJsonAsync<OidcTokenResponse>(); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Build the token request parameters | ||||
|     /// </summary> | ||||
|     protected virtual Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config, | ||||
|         string? codeVerifier) | ||||
|     { | ||||
|         var parameters = new Dictionary<string, string> | ||||
|         { | ||||
|             { "client_id", config.ClientId }, | ||||
|             { "code", code }, | ||||
|             { "grant_type", "authorization_code" }, | ||||
|             { "redirect_uri", config.RedirectUri } | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(config.ClientSecret)) | ||||
|         { | ||||
|             parameters.Add("client_secret", config.ClientSecret); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(codeVerifier)) | ||||
|         { | ||||
|             parameters.Add("code_verifier", codeVerifier); | ||||
|         } | ||||
|  | ||||
|         return parameters; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Validates and extracts information from an ID token | ||||
|     /// </summary> | ||||
|     protected virtual OidcUserInfo ValidateAndExtractIdToken(string idToken, | ||||
|         TokenValidationParameters validationParameters) | ||||
|     { | ||||
|         var handler = new JwtSecurityTokenHandler(); | ||||
|         handler.ValidateToken(idToken, validationParameters, out _); | ||||
|  | ||||
|         var jwtToken = handler.ReadJwtToken(idToken); | ||||
|  | ||||
|         // Extract standard claims | ||||
|         var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; | ||||
|         var email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value; | ||||
|         var emailVerified = jwtToken.Claims.FirstOrDefault(c => c.Type == "email_verified")?.Value == "true"; | ||||
|         var name = jwtToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value; | ||||
|         var givenName = jwtToken.Claims.FirstOrDefault(c => c.Type == "given_name")?.Value; | ||||
|         var familyName = jwtToken.Claims.FirstOrDefault(c => c.Type == "family_name")?.Value; | ||||
|         var preferredUsername = jwtToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value; | ||||
|         var picture = jwtToken.Claims.FirstOrDefault(c => c.Type == "picture")?.Value; | ||||
|  | ||||
|         // Determine preferred username - try different options | ||||
|         var username = preferredUsername; | ||||
|         if (string.IsNullOrEmpty(username)) | ||||
|         { | ||||
|             // Fall back to email local part if no preferred username | ||||
|             username = !string.IsNullOrEmpty(email) ? email.Split('@')[0] : null; | ||||
|         } | ||||
|  | ||||
|         return new OidcUserInfo | ||||
|         { | ||||
|             UserId = userId, | ||||
|             Email = email, | ||||
|             EmailVerified = emailVerified, | ||||
|             FirstName = givenName ?? "", | ||||
|             LastName = familyName ?? "", | ||||
|             DisplayName = name ?? $"{givenName} {familyName}".Trim(), | ||||
|             PreferredUsername = username ?? "", | ||||
|             ProfilePictureUrl = picture, | ||||
|             Provider = ProviderName | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a challenge and session for an authenticated user | ||||
|     /// Also creates or updates the account connection | ||||
|     /// </summary> | ||||
|     public async Task<Session> CreateSessionForUserAsync(OidcUserInfo userInfo, Account.Account account) | ||||
|     { | ||||
|         // Create or update the account connection | ||||
|         var connection = await db.AccountConnections | ||||
|             .FirstOrDefaultAsync(c => c.Provider == ProviderName && | ||||
|                                       c.ProvidedIdentifier == userInfo.UserId && | ||||
|                                       c.AccountId == account.Id | ||||
|             ); | ||||
|  | ||||
|         if (connection is null) | ||||
|         { | ||||
|             connection = new AccountConnection | ||||
|             { | ||||
|                 Provider = ProviderName, | ||||
|                 ProvidedIdentifier = userInfo.UserId ?? "", | ||||
|                 AccessToken = userInfo.AccessToken, | ||||
|                 RefreshToken = userInfo.RefreshToken, | ||||
|                 LastUsedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(), | ||||
|                 AccountId = account.Id | ||||
|             }; | ||||
|             await db.AccountConnections.AddAsync(connection); | ||||
|         } | ||||
|  | ||||
|         // Create a challenge that's already completed | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|         var challenge = new Challenge | ||||
|         { | ||||
|             ExpiredAt = now.Plus(Duration.FromHours(1)), | ||||
|             StepTotal = 1, | ||||
|             StepRemain = 0, // Already verified by provider | ||||
|             Platform = ChallengePlatform.Unidentified, | ||||
|             Audiences = [ProviderName], | ||||
|             Scopes = ["*"], | ||||
|             AccountId = account.Id | ||||
|         }; | ||||
|  | ||||
|         await db.AuthChallenges.AddAsync(challenge); | ||||
|  | ||||
|         // Create a session | ||||
|         var session = new Session | ||||
|         { | ||||
|             LastGrantedAt = now, | ||||
|             Account = account, | ||||
|             Challenge = challenge, | ||||
|         }; | ||||
|  | ||||
|         await db.AuthSessions.AddAsync(session); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         return session; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Provider configuration from app settings | ||||
| /// </summary> | ||||
| public class ProviderConfiguration | ||||
| { | ||||
|     public string ClientId { get; set; } = ""; | ||||
|     public string ClientSecret { get; set; } = ""; | ||||
|     public string RedirectUri { get; set; } = ""; | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// OIDC Discovery Document | ||||
| /// </summary> | ||||
| public class OidcDiscoveryDocument | ||||
| { | ||||
|     [JsonPropertyName("authorization_endpoint")] | ||||
|     public string? AuthorizationEndpoint { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("token_endpoint")] public string? TokenEndpoint { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("userinfo_endpoint")] | ||||
|     public string? UserinfoEndpoint { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("jwks_uri")] public string? JwksUri { get; set; } | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Response from the token endpoint | ||||
| /// </summary> | ||||
| public class OidcTokenResponse | ||||
| { | ||||
|     [JsonPropertyName("access_token")] public string? AccessToken { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("token_type")] public string? TokenType { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("expires_in")] public int ExpiresIn { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("id_token")] public string? IdToken { get; set; } | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Data received in the callback from an OIDC provider | ||||
| /// </summary> | ||||
| public class OidcCallbackData | ||||
| { | ||||
|     public string Code { get; set; } = ""; | ||||
|     public string IdToken { get; set; } = ""; | ||||
|     public string? State { get; set; } | ||||
|     public string? CodeVerifier { get; set; } | ||||
|     public string? RawData { get; set; } | ||||
| } | ||||
							
								
								
									
										19
									
								
								DysonNetwork.Sphere/Auth/OpenId/OidcUserInfo.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								DysonNetwork.Sphere/Auth/OpenId/OidcUserInfo.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents the user information from an OIDC provider | ||||
| /// </summary> | ||||
| public class OidcUserInfo | ||||
| { | ||||
|     public string? UserId { get; set; } | ||||
|     public string? Email { get; set; } | ||||
|     public bool EmailVerified { get; set; } | ||||
|     public string FirstName { get; set; } = ""; | ||||
|     public string LastName { get; set; } = ""; | ||||
|     public string DisplayName { get; set; } = ""; | ||||
|     public string PreferredUsername { get; set; } = ""; | ||||
|     public string? ProfilePictureUrl { get; set; } | ||||
|     public string Provider { get; set; } = ""; | ||||
|     public string? RefreshToken { get; set; } | ||||
|     public string? AccessToken { get; set; } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user