✨ Manual setup account connections
🐛 Fix infinite oauth token reconnect websocket due to missing device id 🐛 Fix IP forwarded headers didn't work
This commit is contained in:
		| @@ -10,4 +10,6 @@ public class AppleMobileSignInRequest | ||||
|     public required string IdentityToken { get; set; } | ||||
|     [Required] | ||||
|     public required string AuthorizationCode { get; set; } | ||||
|     [Required] | ||||
|     public required string DeviceId { get; set; } | ||||
| } | ||||
|   | ||||
| @@ -140,8 +140,14 @@ public class AppleOidcService( | ||||
|         var keyId = _configuration["Oidc:Apple:KeyId"]; | ||||
|         var privateKeyPath = _configuration["Oidc:Apple:PrivateKeyPath"]; | ||||
|  | ||||
|         if (string.IsNullOrEmpty(teamId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(keyId) || | ||||
|             string.IsNullOrEmpty(privateKeyPath)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Apple OIDC configuration is missing required values (TeamId, ClientId, KeyId, PrivateKeyPath)."); | ||||
|         } | ||||
|          | ||||
|         // Read the private key | ||||
|         var privateKey = File.ReadAllText(privateKeyPath!); | ||||
|         var privateKey = File.ReadAllText(privateKeyPath); | ||||
|  | ||||
|         // Create the JWT header | ||||
|         var header = new Dictionary<string, object> | ||||
|   | ||||
| @@ -10,136 +10,6 @@ namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
| /// </summary> | ||||
| [ApiController] | ||||
| [Route("/auth/callback")] | ||||
| public class AuthCallbackController( | ||||
|     AppDatabase db, | ||||
|     AccountService accounts, | ||||
|     MagicSpellService spells, | ||||
|     AuthService auth, | ||||
|     IServiceProvider serviceProvider, | ||||
|     Account.AccountUsernameService accountUsernameService | ||||
| ) | ||||
|     : ControllerBase | ||||
| public class AuthCallbackController : ControllerBase | ||||
| { | ||||
|     [HttpPost("apple")] | ||||
|     public async Task<ActionResult> AppleCallbackPost( | ||||
|         [FromForm] string code, | ||||
|         [FromForm(Name = "id_token")] string idToken, | ||||
|         [FromForm] string? state = null, | ||||
|         [FromForm] string? user = null) | ||||
|     { | ||||
|         return await ProcessOidcCallback("apple", new OidcCallbackData | ||||
|         { | ||||
|             Code = code, | ||||
|             IdToken = idToken, | ||||
|             State = state, | ||||
|             RawData = user | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private async Task<ActionResult> ProcessOidcCallback(string provider, OidcCallbackData callbackData) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             // Get the appropriate provider service | ||||
|             var oidcService = GetOidcService(provider); | ||||
|  | ||||
|             // Process the callback | ||||
|             var userInfo = await oidcService.ProcessCallbackAsync(callbackData); | ||||
|  | ||||
|             if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId)) | ||||
|             { | ||||
|                 return BadRequest($"Email or user ID is missing from {provider}'s response"); | ||||
|             } | ||||
|  | ||||
|             // First, check if we already have a connection with this provider ID | ||||
|             var existingConnection = await db.AccountConnections | ||||
|                 .Include(c => c.Account) | ||||
|                 .FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId); | ||||
|  | ||||
|             if (existingConnection is not null) | ||||
|                 return await CreateSessionAndRedirect( | ||||
|                     oidcService, | ||||
|                     userInfo, | ||||
|                     existingConnection.Account, | ||||
|                     callbackData.State | ||||
|                 ); | ||||
|  | ||||
|             // If no existing connection, try to find an account by email | ||||
|             var account = await accounts.LookupAccount(userInfo.Email); | ||||
|             if (account == null) | ||||
|             { | ||||
|                 // Create a new account using the AccountService | ||||
|                 account = await accounts.CreateAccount(userInfo); | ||||
|             } | ||||
|  | ||||
|             // Create a session for the user | ||||
|             var session = await oidcService.CreateSessionForUserAsync(userInfo, account); | ||||
|  | ||||
|             // Generate token | ||||
|             var token = auth.CreateToken(session); | ||||
|  | ||||
|             // Determine where to redirect | ||||
|             var redirectUrl = "/"; | ||||
|             if (!string.IsNullOrEmpty(callbackData.State)) | ||||
|             { | ||||
|                 // Use state as redirect URL (should be validated in production) | ||||
|                 redirectUrl = callbackData.State; | ||||
|             } | ||||
|  | ||||
|             // Set the token as a cookie | ||||
|             Response.Cookies.Append(AuthConstants.TokenQueryParamName, token, new CookieOptions | ||||
|             { | ||||
|                 HttpOnly = true, | ||||
|                 Secure = true, | ||||
|                 SameSite = SameSiteMode.Lax, | ||||
|                 Expires = DateTimeOffset.UtcNow.AddDays(30) | ||||
|             }); | ||||
|  | ||||
|             return Redirect(redirectUrl); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             return BadRequest($"Error processing {provider} Sign In: {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}") | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a session and redirects the user with a token | ||||
|     /// </summary> | ||||
|     private async Task<ActionResult> CreateSessionAndRedirect(OidcService oidcService, OidcUserInfo userInfo, | ||||
|         Account.Account account, string? state) | ||||
|     { | ||||
|         // Create a session for the user | ||||
|         var session = await oidcService.CreateSessionForUserAsync(userInfo, account); | ||||
|  | ||||
|         // Generate token | ||||
|         var token = auth.CreateToken(session); | ||||
|  | ||||
|         // Determine where to redirect | ||||
|         var redirectUrl = "/"; | ||||
|         if (!string.IsNullOrEmpty(state)) | ||||
|             redirectUrl = state; | ||||
|  | ||||
|         // Set the token as a cookie | ||||
|         Response.Cookies.Append(AuthConstants.TokenQueryParamName, token, new CookieOptions | ||||
|         { | ||||
|             HttpOnly = true, | ||||
|             Secure = true, | ||||
|             SameSite = SameSiteMode.Lax, | ||||
|             Expires = DateTimeOffset.UtcNow.AddDays(30) | ||||
|         }); | ||||
|  | ||||
|         return Redirect(redirectUrl); | ||||
|     } | ||||
| } | ||||
| @@ -2,13 +2,20 @@ using DysonNetwork.Sphere.Account; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/api/connections")] | ||||
| [Route("/api/accounts/me/connections")] | ||||
| [Authorize] | ||||
| public class ConnectionController(AppDatabase db) : ControllerBase | ||||
| public class ConnectionController( | ||||
|     AppDatabase db,  | ||||
|     IEnumerable<OidcService> oidcServices,  | ||||
|     AccountService accountService,  | ||||
|     AuthService authService, | ||||
|     IClock clock | ||||
| ) : ControllerBase | ||||
| { | ||||
|     [HttpGet] | ||||
|     public async Task<ActionResult<List<AccountConnection>>> GetConnections() | ||||
| @@ -40,4 +47,212 @@ public class ConnectionController(AppDatabase db) : ControllerBase | ||||
|  | ||||
|         return Ok(); | ||||
|     } | ||||
|  | ||||
|     public class ConnectProviderRequest | ||||
|     { | ||||
|         public string Provider { get; set; } = null!; | ||||
|         public string? ReturnUrl { get; set; } | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Initiates manual connection to an OAuth provider for the current user | ||||
|     /// </summary> | ||||
|     [HttpPost("connect")] | ||||
|     public async Task<ActionResult<object>> InitiateConnection([FromBody] ConnectProviderRequest request) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var oidcService = oidcServices.FirstOrDefault(s => s.ProviderName.Equals(request.Provider, StringComparison.OrdinalIgnoreCase)); | ||||
|         if (oidcService == null) | ||||
|             return BadRequest($"Provider '{request.Provider}' is not supported"); | ||||
|  | ||||
|         var existingConnection = await db.AccountConnections | ||||
|             .AnyAsync(c => c.AccountId == currentUser.Id && c.Provider == oidcService.ProviderName); | ||||
|  | ||||
|         if (existingConnection) | ||||
|             return BadRequest($"You already have a {request.Provider} connection"); | ||||
|  | ||||
|         var state = Guid.NewGuid().ToString("N"); | ||||
|         var nonce = Guid.NewGuid().ToString("N"); | ||||
|         HttpContext.Session.SetString($"oidc_state_{state}", $"{currentUser.Id}|{request.Provider}|{nonce}"); | ||||
|  | ||||
|         var finalReturnUrl = !string.IsNullOrEmpty(request.ReturnUrl) ? request.ReturnUrl : "/settings/connections"; | ||||
|         HttpContext.Session.SetString($"oidc_return_url_{state}", finalReturnUrl); | ||||
|  | ||||
|         var authUrl = oidcService.GetAuthorizationUrl(state, nonce); | ||||
|  | ||||
|         return Ok(new | ||||
|         { | ||||
|             authUrl, | ||||
|             message = $"Redirect to this URL to connect your {request.Provider} account" | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     [AllowAnonymous] | ||||
|     [Route("/auth/callback/{provider}")] | ||||
|     [HttpGet, HttpPost] | ||||
|     public async Task<IActionResult> HandleCallback([FromRoute] string provider) | ||||
|     { | ||||
|         var oidcService = oidcServices.FirstOrDefault(s => s.ProviderName.Equals(provider, StringComparison.OrdinalIgnoreCase)); | ||||
|         if (oidcService == null) | ||||
|             return BadRequest($"Provider '{provider}' is not supported."); | ||||
|  | ||||
|         var callbackData = await ExtractCallbackData(Request); | ||||
|         if (callbackData.State == null) | ||||
|             return BadRequest("State parameter is missing."); | ||||
|  | ||||
|         var sessionState = HttpContext.Session.GetString($"oidc_state_{callbackData.State!}"); | ||||
|         HttpContext.Session.Remove($"oidc_state_{callbackData.State}"); | ||||
|  | ||||
|         // If sessionState is present, it's a manual connection flow for an existing user. | ||||
|         if (sessionState != null) | ||||
|         { | ||||
|             var stateParts = sessionState.Split('|'); | ||||
|             if (stateParts.Length != 3 || !stateParts[1].Equals(provider, StringComparison.OrdinalIgnoreCase)) | ||||
|                 return BadRequest("State mismatch."); | ||||
|  | ||||
|             var accountId = Guid.Parse(stateParts[0]); | ||||
|             return await HandleManualConnection(provider, oidcService, callbackData, accountId); | ||||
|         } | ||||
|          | ||||
|         // Otherwise, it's a login or registration flow. | ||||
|         return await HandleLoginOrRegistration(provider, oidcService, callbackData); | ||||
|     } | ||||
|  | ||||
|     private async Task<IActionResult> HandleManualConnection(string provider, OidcService oidcService, OidcCallbackData callbackData, Guid accountId) | ||||
|     { | ||||
|         OidcUserInfo userInfo; | ||||
|         try | ||||
|         { | ||||
|             userInfo = await oidcService.ProcessCallbackAsync(callbackData); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             return BadRequest($"Error processing callback: {ex.Message}"); | ||||
|         } | ||||
|  | ||||
|         var existingConnection = await db.AccountConnections | ||||
|             .FirstOrDefaultAsync(c => c.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase) && c.ProvidedIdentifier == userInfo.UserId); | ||||
|  | ||||
|         if (existingConnection != null && existingConnection.AccountId != accountId) | ||||
|         { | ||||
|             return BadRequest($"This {provider} account is already linked to another user."); | ||||
|         } | ||||
|  | ||||
|         var userConnection = await db.AccountConnections | ||||
|             .FirstOrDefaultAsync(c => c.AccountId == accountId && c.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase)); | ||||
|  | ||||
|         if (userConnection != null) | ||||
|         { | ||||
|             userConnection.AccessToken = userInfo.AccessToken; | ||||
|             userConnection.RefreshToken = userInfo.RefreshToken; | ||||
|             userConnection.LastUsedAt = clock.GetCurrentInstant(); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             db.AccountConnections.Add(new AccountConnection | ||||
|             { | ||||
|                 AccountId = accountId, | ||||
|                 Provider = provider, | ||||
|                 ProvidedIdentifier = userInfo.UserId!, | ||||
|                 AccessToken = userInfo.AccessToken, | ||||
|                 RefreshToken = userInfo.RefreshToken, | ||||
|                 LastUsedAt = clock.GetCurrentInstant() | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         var returnUrl = HttpContext.Session.GetString($"oidc_return_url_{callbackData.State}"); | ||||
|         HttpContext.Session.Remove($"oidc_return_url_{callbackData.State}"); | ||||
|  | ||||
|         return Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl); | ||||
|     } | ||||
|  | ||||
|     private async Task<IActionResult> HandleLoginOrRegistration(string provider, OidcService oidcService, OidcCallbackData callbackData) | ||||
|     { | ||||
|         OidcUserInfo userInfo; | ||||
|         try | ||||
|         { | ||||
|             userInfo = await oidcService.ProcessCallbackAsync(callbackData); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             return BadRequest($"Error processing callback: {ex.Message}"); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId)) | ||||
|         { | ||||
|             return BadRequest($"Email or user ID is missing from {provider}'s response"); | ||||
|         } | ||||
|  | ||||
|         var connection = await db.AccountConnections | ||||
|             .Include(c => c.Account) | ||||
|             .FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId); | ||||
|  | ||||
|         if (connection != null) | ||||
|         { | ||||
|             // Login existing user | ||||
|             var session = await authService.CreateSessionAsync(connection.Account, clock.GetCurrentInstant()); | ||||
|             var token = authService.CreateToken(session); | ||||
|             return Redirect($"/?token={token}"); | ||||
|         } | ||||
|  | ||||
|         var account = await accountService.LookupAccount(userInfo.Email); | ||||
|         if (account == null) | ||||
|         { | ||||
|             // Register new user | ||||
|             account = await accountService.CreateAccount(userInfo); | ||||
|         } | ||||
|  | ||||
|         if (account == null) | ||||
|         { | ||||
|             return BadRequest("Unable to create or link account."); | ||||
|         } | ||||
|  | ||||
|         // Create connection for new or existing user | ||||
|         var newConnection = new AccountConnection | ||||
|         { | ||||
|             Account = account, | ||||
|             Provider = provider, | ||||
|             ProvidedIdentifier = userInfo.UserId!, | ||||
|             AccessToken = userInfo.AccessToken, | ||||
|             RefreshToken = userInfo.RefreshToken, | ||||
|             LastUsedAt = clock.GetCurrentInstant() | ||||
|         }; | ||||
|         db.AccountConnections.Add(newConnection); | ||||
|  | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         var loginSession = await authService.CreateSessionAsync(account, clock.GetCurrentInstant()); | ||||
|         var loginToken = authService.CreateToken(loginSession); | ||||
|         return Redirect($"/?token={loginToken}"); | ||||
|     } | ||||
|  | ||||
|     private async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request) | ||||
|     { | ||||
|         var data = new OidcCallbackData(); | ||||
|         if (request.Method == "GET") | ||||
|         { | ||||
|             data.Code = request.Query["code"].FirstOrDefault() ?? ""; | ||||
|             data.IdToken = request.Query["id_token"].FirstOrDefault() ?? ""; | ||||
|             data.State = request.Query["state"].FirstOrDefault(); | ||||
|         } | ||||
|         else if (request.Method == "POST" && request.HasFormContentType) | ||||
|         { | ||||
|             var form = await request.ReadFormAsync(); | ||||
|             data.Code = form["code"].FirstOrDefault() ?? ""; | ||||
|             data.IdToken = form["id_token"].FirstOrDefault() ?? ""; | ||||
|             data.State = form["state"].FirstOrDefault(); | ||||
|             if (form.ContainsKey("user")) | ||||
|             { | ||||
|                 data.RawData = form["user"].FirstOrDefault(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return data; | ||||
|     } | ||||
|  | ||||
|  | ||||
| } | ||||
							
								
								
									
										95
									
								
								DysonNetwork.Sphere/Auth/OpenId/DiscordOidcService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								DysonNetwork.Sphere/Auth/OpenId/DiscordOidcService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| using System.Net.Http.Json; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
|  | ||||
| public class DiscordOidcService : OidcService | ||||
| { | ||||
|     public DiscordOidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db) | ||||
|         : base(configuration, httpClientFactory, db) | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public override string ProviderName => "Discord"; | ||||
|     protected override string DiscoveryEndpoint => ""; // Discord doesn't have a standard OIDC discovery endpoint | ||||
|     protected override string ConfigSectionName => "Discord"; | ||||
|  | ||||
|     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" }, | ||||
|             { "scope", "identify email" }, | ||||
|             { "state", state }, | ||||
|         }; | ||||
|  | ||||
|         var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); | ||||
|         return $"https://discord.com/api/oauth2/authorize?{queryString}"; | ||||
|     } | ||||
|  | ||||
|     public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) | ||||
|     { | ||||
|         var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); | ||||
|         if (tokenResponse?.AccessToken == null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Failed to obtain access token from Discord"); | ||||
|         } | ||||
|  | ||||
|         var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken); | ||||
|  | ||||
|         userInfo.AccessToken = tokenResponse.AccessToken; | ||||
|         userInfo.RefreshToken = tokenResponse.RefreshToken; | ||||
|  | ||||
|         return userInfo; | ||||
|     } | ||||
|  | ||||
|     protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, string? codeVerifier = null) | ||||
|     { | ||||
|         var config = GetProviderConfig(); | ||||
|         var client = _httpClientFactory.CreateClient(); | ||||
|  | ||||
|         var content = new FormUrlEncodedContent(new Dictionary<string, string> | ||||
|         { | ||||
|             { "client_id", config.ClientId }, | ||||
|             { "client_secret", config.ClientSecret }, | ||||
|             { "grant_type", "authorization_code" }, | ||||
|             { "code", code }, | ||||
|             { "redirect_uri", config.RedirectUri }, | ||||
|         }); | ||||
|  | ||||
|         var response = await client.PostAsync("https://discord.com/api/oauth2/token", content); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         return await response.Content.ReadFromJsonAsync<OidcTokenResponse>(); | ||||
|     } | ||||
|  | ||||
|     private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken) | ||||
|     { | ||||
|         var client = _httpClientFactory.CreateClient(); | ||||
|         var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me"); | ||||
|         request.Headers.Add("Authorization", $"Bearer {accessToken}"); | ||||
|  | ||||
|         var response = await client.SendAsync(request); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         var json = await response.Content.ReadAsStringAsync(); | ||||
|         var discordUser = JsonDocument.Parse(json).RootElement; | ||||
|  | ||||
|         var userId = discordUser.GetProperty("id").GetString() ?? ""; | ||||
|         var avatar = discordUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null; | ||||
|  | ||||
|         return new OidcUserInfo | ||||
|         { | ||||
|             UserId = userId, | ||||
|             Email = (discordUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null) ?? "", | ||||
|             EmailVerified = discordUser.TryGetProperty("verified", out var verifiedElement) && verifiedElement.GetBoolean(), | ||||
|             DisplayName = (discordUser.TryGetProperty("global_name", out var globalNameElement) ? globalNameElement.GetString() : null) ?? "", | ||||
|             PreferredUsername = discordUser.GetProperty("username").GetString() ?? "", | ||||
|             ProfilePictureUrl = !string.IsNullOrEmpty(avatar) ? $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png" : "", | ||||
|             Provider = ProviderName | ||||
|         }; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										121
									
								
								DysonNetwork.Sphere/Auth/OpenId/GitHubOidcService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								DysonNetwork.Sphere/Auth/OpenId/GitHubOidcService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | ||||
| using System.Net.Http.Json; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
|  | ||||
| public class GitHubOidcService : OidcService | ||||
| { | ||||
|     public GitHubOidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db) | ||||
|         : base(configuration, httpClientFactory, db) | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public override string ProviderName => "GitHub"; | ||||
|     protected override string DiscoveryEndpoint => ""; // GitHub doesn't have a standard OIDC discovery endpoint | ||||
|     protected override string ConfigSectionName => "GitHub"; | ||||
|  | ||||
|     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 }, | ||||
|             { "scope", "user:email" }, | ||||
|             { "state", state }, | ||||
|         }; | ||||
|  | ||||
|         var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); | ||||
|         return $"https://github.com/login/oauth/authorize?{queryString}"; | ||||
|     } | ||||
|  | ||||
|     public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) | ||||
|     { | ||||
|         var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); | ||||
|         if (tokenResponse?.AccessToken == null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Failed to obtain access token from GitHub"); | ||||
|         } | ||||
|  | ||||
|         var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken); | ||||
|  | ||||
|         userInfo.AccessToken = tokenResponse.AccessToken; | ||||
|         userInfo.RefreshToken = tokenResponse.RefreshToken; | ||||
|  | ||||
|         return userInfo; | ||||
|     } | ||||
|  | ||||
|     protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, string? codeVerifier = null) | ||||
|     { | ||||
|         var config = GetProviderConfig(); | ||||
|         var client = _httpClientFactory.CreateClient(); | ||||
|  | ||||
|         var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token") | ||||
|         { | ||||
|             Content = new FormUrlEncodedContent(new Dictionary<string, string> | ||||
|             { | ||||
|                 { "client_id", config.ClientId }, | ||||
|                 { "client_secret", config.ClientSecret }, | ||||
|                 { "code", code }, | ||||
|                 { "redirect_uri", config.RedirectUri }, | ||||
|             }) | ||||
|         }; | ||||
|         tokenRequest.Headers.Add("Accept", "application/json"); | ||||
|  | ||||
|         var response = await client.SendAsync(tokenRequest); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         return await response.Content.ReadFromJsonAsync<OidcTokenResponse>(); | ||||
|     } | ||||
|  | ||||
|     private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken) | ||||
|     { | ||||
|         var client = _httpClientFactory.CreateClient(); | ||||
|         var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user"); | ||||
|         request.Headers.Add("Authorization", $"Bearer {accessToken}"); | ||||
|         request.Headers.Add("User-Agent", "DysonNetwork.Sphere"); | ||||
|  | ||||
|         var response = await client.SendAsync(request); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         var json = await response.Content.ReadAsStringAsync(); | ||||
|         var githubUser = JsonDocument.Parse(json).RootElement; | ||||
|  | ||||
|         var email = githubUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null; | ||||
|         if (string.IsNullOrEmpty(email)) | ||||
|         { | ||||
|             email = await GetPrimaryEmailAsync(accessToken); | ||||
|         } | ||||
|  | ||||
|         return new OidcUserInfo | ||||
|         { | ||||
|             UserId = githubUser.GetProperty("id").GetInt64().ToString(), | ||||
|             Email = email, | ||||
|             DisplayName = githubUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "", | ||||
|             PreferredUsername = githubUser.GetProperty("login").GetString() ?? "", | ||||
|             ProfilePictureUrl = githubUser.TryGetProperty("avatar_url", out var avatarElement) ? avatarElement.GetString() ?? "" : "", | ||||
|             Provider = ProviderName | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private async Task<string?> GetPrimaryEmailAsync(string accessToken) | ||||
|     { | ||||
|         var client = _httpClientFactory.CreateClient(); | ||||
|         var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user/emails"); | ||||
|         request.Headers.Add("Authorization", $"Bearer {accessToken}"); | ||||
|         request.Headers.Add("User-Agent", "DysonNetwork.Sphere"); | ||||
|  | ||||
|         var response = await client.SendAsync(request); | ||||
|         if (!response.IsSuccessStatusCode) return null; | ||||
|  | ||||
|         var emails = await response.Content.ReadFromJsonAsync<List<GitHubEmail>>(); | ||||
|         return emails?.FirstOrDefault(e => e.Primary)?.Email; | ||||
|     } | ||||
|  | ||||
|     private class GitHubEmail | ||||
|     { | ||||
|         public string Email { get; set; } = ""; | ||||
|         public bool Primary { get; set; } | ||||
|         public bool Verified { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -29,7 +29,7 @@ public class OidcController( | ||||
|             var nonce = Guid.NewGuid().ToString(); | ||||
|  | ||||
|             // Get the authorization URL and redirect the user | ||||
|             var authUrl = oidcService.GetAuthorizationUrl(state, nonce); | ||||
|             var authUrl = oidcService.GetAuthorizationUrl(state ?? "/", nonce); | ||||
|             return Redirect(authUrl); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
| @@ -43,7 +43,8 @@ public class OidcController( | ||||
|     /// Handles Apple authentication directly from mobile apps | ||||
|     /// </summary> | ||||
|     [HttpPost("apple/mobile")] | ||||
|     public async Task<ActionResult<AuthController.TokenExchangeResponse>> AppleMobileSignIn([FromBody] AppleMobileSignInRequest request) | ||||
|     public async Task<ActionResult<AuthController.TokenExchangeResponse>> AppleMobileSignIn( | ||||
|         [FromBody] AppleMobileSignInRequest request) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
| @@ -65,7 +66,12 @@ public class OidcController( | ||||
|             var account = await FindOrCreateAccount(userInfo, "apple"); | ||||
|  | ||||
|             // Create session using the OIDC service | ||||
|             var session = await appleService.CreateSessionForUserAsync(userInfo, account); | ||||
|             var session = await appleService.CreateSessionForUserAsync( | ||||
|                 userInfo, | ||||
|                 account, | ||||
|                 HttpContext, | ||||
|                 request.DeviceId | ||||
|             ); | ||||
|  | ||||
|             // Generate token using existing auth service | ||||
|             var token = authService.CreateToken(session); | ||||
|   | ||||
| @@ -1,7 +1,5 @@ | ||||
| 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; | ||||
| @@ -15,6 +13,8 @@ namespace DysonNetwork.Sphere.Auth.OpenId; | ||||
| /// </summary> | ||||
| public abstract class OidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db) | ||||
| { | ||||
|     protected readonly IHttpClientFactory _httpClientFactory = httpClientFactory; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the unique identifier for this provider | ||||
|     /// </summary> | ||||
| @@ -67,7 +67,8 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto | ||||
|     /// <summary> | ||||
|     /// Exchange the authorization code for tokens | ||||
|     /// </summary> | ||||
|     protected async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, string? codeVerifier = null) | ||||
|     protected virtual async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, | ||||
|         string? codeVerifier = null) | ||||
|     { | ||||
|         var config = GetProviderConfig(); | ||||
|         var discoveryDocument = await GetDiscoveryDocumentAsync(); | ||||
| @@ -160,7 +161,12 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto | ||||
|     /// 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) | ||||
|     public async Task<Session> CreateSessionForUserAsync( | ||||
|         OidcUserInfo userInfo, | ||||
|         Account.Account account, | ||||
|         HttpContext request, | ||||
|         string deviceId | ||||
|     ) | ||||
|     { | ||||
|         // Create or update the account connection | ||||
|         var connection = await db.AccountConnections | ||||
| @@ -194,7 +200,10 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto | ||||
|             Platform = ChallengePlatform.Unidentified, | ||||
|             Audiences = [ProviderName], | ||||
|             Scopes = ["*"], | ||||
|             AccountId = account.Id | ||||
|             AccountId = account.Id, | ||||
|             DeviceId = deviceId, | ||||
|             IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null, | ||||
|             UserAgent = request.Request.Headers.UserAgent, | ||||
|         }; | ||||
|  | ||||
|         await db.AuthChallenges.AddAsync(challenge); | ||||
| @@ -202,9 +211,10 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto | ||||
|         // Create a session | ||||
|         var session = new Session | ||||
|         { | ||||
|             LastGrantedAt = now, | ||||
|             AccountId = account.Id, | ||||
|             ChallengeId = challenge.Id, | ||||
|             CreatedAt = now, | ||||
|             LastGrantedAt = now, | ||||
|             Challenge = challenge | ||||
|         }; | ||||
|  | ||||
|         await db.AuthSessions.AddAsync(session); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user