From bf013a108b97ffdfea385a392bcabb93f4c4982a Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 15 Jun 2025 13:11:45 +0800 Subject: [PATCH] :bricks: OAuth login infra --- DysonNetwork.Sphere/Account/Account.cs | 14 + .../Account/AccountUsernameService.cs | 152 ++++++++++ DysonNetwork.Sphere/AppDatabase.cs | 102 ++++--- .../Auth/AuthCallbackController.cs | 181 ++++++++++++ .../Auth/OpenId/AppleOidcService.cs | 267 +++++++++++++++++ .../Auth/OpenId/ConnectionController.cs | 43 +++ .../Auth/OpenId/GoogleOidcService.cs | 184 ++++++++++++ .../Auth/OpenId/OidcController.cs | 48 ++++ .../Auth/OpenId/OidcService.cs | 268 ++++++++++++++++++ .../Auth/OpenId/OidcUserInfo.cs | 19 ++ .../DysonNetwork.Sphere.csproj | 1 + DysonNetwork.Sphere/Program.cs | 4 + DysonNetwork.Sphere/README.md | 62 ++++ DysonNetwork.Sphere/appsettings.json | 16 +- 14 files changed, 1314 insertions(+), 47 deletions(-) create mode 100644 DysonNetwork.Sphere/Account/AccountUsernameService.cs create mode 100644 DysonNetwork.Sphere/Auth/AuthCallbackController.cs create mode 100644 DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs create mode 100644 DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs create mode 100644 DysonNetwork.Sphere/Auth/OpenId/GoogleOidcService.cs create mode 100644 DysonNetwork.Sphere/Auth/OpenId/OidcController.cs create mode 100644 DysonNetwork.Sphere/Auth/OpenId/OidcService.cs create mode 100644 DysonNetwork.Sphere/Auth/OpenId/OidcUserInfo.cs create mode 100644 DysonNetwork.Sphere/README.md diff --git a/DysonNetwork.Sphere/Account/Account.cs b/DysonNetwork.Sphere/Account/Account.cs index 452454f..fe96fce 100644 --- a/DysonNetwork.Sphere/Account/Account.cs +++ b/DysonNetwork.Sphere/Account/Account.cs @@ -172,4 +172,18 @@ public enum AccountAuthFactorType EmailCode, InAppCode, TimedCode +} + +public class AccountConnection : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(4096)] public string Provider { get; set; } = null!; + [MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!; + + [MaxLength(4096)] public string? AccessToken { get; set; } + [MaxLength(4096)] public string? RefreshToken { get; set; } + public Instant? LastUsedAt { get; set; } + + public Guid AccountId { get; set; } + public Account Account { get; set; } = null!; } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/AccountUsernameService.cs b/DysonNetwork.Sphere/Account/AccountUsernameService.cs new file mode 100644 index 0000000..c873d02 --- /dev/null +++ b/DysonNetwork.Sphere/Account/AccountUsernameService.cs @@ -0,0 +1,152 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Sphere.Account; + +/// +/// Service for handling username generation and validation +/// +public class AccountUsernameService(AppDatabase db) +{ + private readonly Random _random = new Random(); + + /// + /// Generates a unique username based on the provided base name + /// + /// The preferred username + /// A unique username + public async Task GenerateUniqueUsernameAsync(string baseName) + { + // Sanitize the base name + var sanitized = SanitizeUsername(baseName); + + // If the base name is empty after sanitization, use a default + if (string.IsNullOrEmpty(sanitized)) + { + sanitized = "user"; + } + + // Check if the sanitized name is available + if (!await IsUsernameExistsAsync(sanitized)) + { + return sanitized; + } + + // Try up to 10 times with random numbers + for (int i = 0; i < 10; i++) + { + var suffix = _random.Next(1000, 9999); + var candidate = $"{sanitized}{suffix}"; + + if (!await IsUsernameExistsAsync(candidate)) + { + return candidate; + } + } + + // If all attempts fail, use a timestamp + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return $"{sanitized}{timestamp}"; + } + + /// + /// Generates a display name, adding numbers if needed + /// + /// The preferred display name + /// A display name with optional suffix + public Task GenerateUniqueDisplayNameAsync(string baseName) + { + // If the base name is empty, use a default + if (string.IsNullOrEmpty(baseName)) + { + baseName = "User"; + } + + // Truncate if too long + if (baseName.Length > 50) + { + baseName = baseName.Substring(0, 50); + } + + // Since display names can be duplicated, just return the base name + // But add a random suffix to make it more unique visually + var suffix = _random.Next(1000, 9999); + return Task.FromResult($"{baseName}{suffix}"); + } + + /// + /// Sanitizes a username by removing invalid characters and converting to lowercase + /// + public string SanitizeUsername(string username) + { + if (string.IsNullOrEmpty(username)) + return string.Empty; + + // Replace spaces and special characters with underscores + var sanitized = Regex.Replace(username, @"[^a-zA-Z0-9_\-]", ""); + + // Convert to lowercase + sanitized = sanitized.ToLowerInvariant(); + + // Ensure it starts with a letter + if (sanitized.Length > 0 && !char.IsLetter(sanitized[0])) + { + sanitized = "u" + sanitized; + } + + // Truncate if too long + if (sanitized.Length > 30) + { + sanitized = sanitized[..30]; + } + + return sanitized; + } + + /// + /// Checks if a username already exists + /// + public async Task IsUsernameExistsAsync(string username) + { + return await db.Accounts.AnyAsync(a => a.Name == username); + } + + /// + /// Generates a username from an email address + /// + /// The email address to generate a username from + /// A unique username derived from the email + public async Task GenerateUsernameFromEmailAsync(string email) + { + if (string.IsNullOrEmpty(email)) + return await GenerateUniqueUsernameAsync("user"); + + // Extract the local part of the email (before the @) + var localPart = email.Split('@')[0]; + + // Use the local part as the base for username generation + return await GenerateUniqueUsernameAsync(localPart); + } + + /// + /// Generates a display name from an email address + /// + /// The email address to generate a display name from + /// A display name derived from the email + public async Task GenerateDisplayNameFromEmailAsync(string email) + { + if (string.IsNullOrEmpty(email)) + return await GenerateUniqueDisplayNameAsync("User"); + + // Extract the local part of the email (before the @) + var localPart = email.Split('@')[0]; + + // Capitalize first letter and replace dots/underscores with spaces + var displayName = Regex.Replace(localPart, @"[._-]+", " "); + + // Capitalize words + displayName = System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(displayName); + + return await GenerateUniqueDisplayNameAsync(displayName); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index 3ba4bde..277b6d3 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -1,6 +1,15 @@ using System.Linq.Expressions; +using System.Reflection; +using DysonNetwork.Sphere.Account; +using DysonNetwork.Sphere.Auth; +using DysonNetwork.Sphere.Chat; +using DysonNetwork.Sphere.Developer; using DysonNetwork.Sphere.Permission; +using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Publisher; +using DysonNetwork.Sphere.Realm; +using DysonNetwork.Sphere.Sticker; +using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Wallet; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; @@ -32,24 +41,25 @@ public class AppDatabase( public DbSet PermissionGroups { get; set; } public DbSet PermissionGroupMembers { get; set; } - public DbSet MagicSpells { get; set; } + public DbSet MagicSpells { get; set; } public DbSet Accounts { get; set; } - public DbSet AccountProfiles { get; set; } - public DbSet AccountContacts { get; set; } - public DbSet AccountAuthFactors { get; set; } - public DbSet AccountRelationships { get; set; } - public DbSet AccountStatuses { get; set; } - public DbSet AccountCheckInResults { get; set; } - public DbSet Notifications { get; set; } - public DbSet NotificationPushSubscriptions { get; set; } - public DbSet Badges { get; set; } - public DbSet ActionLogs { get; set; } + public DbSet AccountConnections { get; set; } + public DbSet AccountProfiles { get; set; } + public DbSet AccountContacts { get; set; } + public DbSet AccountAuthFactors { get; set; } + public DbSet AccountRelationships { get; set; } + public DbSet AccountStatuses { get; set; } + public DbSet AccountCheckInResults { get; set; } + public DbSet Notifications { get; set; } + public DbSet NotificationPushSubscriptions { get; set; } + public DbSet Badges { get; set; } + public DbSet ActionLogs { get; set; } - public DbSet AuthSessions { get; set; } - public DbSet AuthChallenges { get; set; } + public DbSet AuthSessions { get; set; } + public DbSet AuthChallenges { get; set; } - public DbSet Files { get; set; } - public DbSet FileReferences { get; set; } + public DbSet Files { get; set; } + public DbSet FileReferences { get; set; } public DbSet Publishers { get; set; } public DbSet PublisherMembers { get; set; } @@ -57,30 +67,30 @@ public class AppDatabase( public DbSet PublisherFeatures { get; set; } public DbSet Posts { get; set; } - public DbSet PostReactions { get; set; } - public DbSet PostTags { get; set; } - public DbSet PostCategories { get; set; } - public DbSet PostCollections { get; set; } + public DbSet PostReactions { get; set; } + public DbSet PostTags { get; set; } + public DbSet PostCategories { get; set; } + public DbSet PostCollections { get; set; } public DbSet Realms { get; set; } - public DbSet RealmMembers { get; set; } + public DbSet RealmMembers { get; set; } - public DbSet ChatRooms { get; set; } - public DbSet ChatMembers { get; set; } - public DbSet ChatMessages { get; set; } - public DbSet ChatRealtimeCall { get; set; } - public DbSet ChatReactions { get; set; } + public DbSet ChatRooms { get; set; } + public DbSet ChatMembers { get; set; } + public DbSet ChatMessages { get; set; } + public DbSet ChatRealtimeCall { get; set; } + public DbSet ChatReactions { get; set; } public DbSet Stickers { get; set; } - public DbSet StickerPacks { get; set; } + public DbSet StickerPacks { get; set; } public DbSet Wallets { get; set; } - public DbSet WalletPockets { get; set; } - public DbSet PaymentOrders { get; set; } - public DbSet PaymentTransactions { get; set; } + public DbSet WalletPockets { get; set; } + public DbSet PaymentOrders { get; set; } + public DbSet PaymentTransactions { get; set; } - public DbSet CustomApps { get; set; } - public DbSet CustomAppSecrets { get; set; } + public DbSet CustomApps { get; set; } + public DbSet CustomAppSecrets { get; set; } public DbSet WalletSubscriptions { get; set; } public DbSet WalletCoupons { get; set; } @@ -141,13 +151,13 @@ public class AppDatabase( .HasForeignKey(pg => pg.GroupId) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() + modelBuilder.Entity() .HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId }); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(r => r.Account) .WithMany(a => a.OutgoingRelationships) .HasForeignKey(r => r.AccountId); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(r => r.Related) .WithMany(a => a.IncomingRelationships) .HasForeignKey(r => r.RelatedId); @@ -202,49 +212,49 @@ public class AppDatabase( .WithMany(c => c.Posts) .UsingEntity(j => j.ToTable("post_collection_links")); - modelBuilder.Entity() + modelBuilder.Entity() .HasKey(pm => new { pm.RealmId, pm.AccountId }); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(pm => pm.Realm) .WithMany(p => p.Members) .HasForeignKey(pm => pm.RealmId) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(pm => pm.Account) .WithMany() .HasForeignKey(pm => pm.AccountId) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() + modelBuilder.Entity() .HasKey(pm => new { pm.Id }); - modelBuilder.Entity() + modelBuilder.Entity() .HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId }); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(pm => pm.ChatRoom) .WithMany(p => p.Members) .HasForeignKey(pm => pm.ChatRoomId) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(pm => pm.Account) .WithMany() .HasForeignKey(pm => pm.AccountId) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(m => m.ForwardedMessage) .WithMany() .HasForeignKey(m => m.ForwardedMessageId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(m => m.RepliedMessage) .WithMany() .HasForeignKey(m => m.RepliedMessageId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(m => m.Room) .WithMany() .HasForeignKey(m => m.RoomId) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(m => m.Sender) .WithMany() .HasForeignKey(m => m.SenderId) @@ -256,7 +266,7 @@ public class AppDatabase( if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue; var method = typeof(AppDatabase) .GetMethod(nameof(SetSoftDeleteFilter), - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! + BindingFlags.NonPublic | BindingFlags.Static)! .MakeGenericMethod(entityType.ClrType); method.Invoke(null, [modelBuilder]); diff --git a/DysonNetwork.Sphere/Auth/AuthCallbackController.cs b/DysonNetwork.Sphere/Auth/AuthCallbackController.cs new file mode 100644 index 0000000..e9d0310 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/AuthCallbackController.cs @@ -0,0 +1,181 @@ +using DysonNetwork.Sphere.Account; +using Microsoft.AspNetCore.Mvc; +using DysonNetwork.Sphere.Auth.OpenId; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Sphere.Auth; + +/// +/// This controller is designed to handle the OAuth callback. +/// +[ApiController] +[Route("/auth/callback")] +public class AuthCallbackController( + AppDatabase db, + AccountService accounts, + MagicSpellService spells, + AuthService auth, + IServiceProvider serviceProvider, + Account.AccountUsernameService accountUsernameService +) + : ControllerBase +{ + [HttpPost("apple")] + public async Task 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 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) + { + // Generate username and display name from email + var username = await accountUsernameService.GenerateUsernameFromEmailAsync(userInfo.Email); + var displayName = await accountUsernameService.GenerateDisplayNameFromEmailAsync(userInfo.Email); + + // Create a new account + account = new Account.Account + { + Name = username, + Nick = displayName, + Contacts = new List + { + new() + { + Type = AccountContactType.Email, + Content = userInfo.Email, + VerifiedAt = userInfo.EmailVerified ? SystemClock.Instance.GetCurrentInstant() : null, + IsPrimary = true + } + }, + Profile = new Profile() + }; + + // Save the account + await db.Accounts.AddAsync(account); + await db.SaveChangesAsync(); + + // Do the usual steps + var spell = await spells.CreateMagicSpell( + account, + MagicSpellType.AccountActivation, + new Dictionary + { + { "contact_method", account.Contacts.First().Content } + }, + expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromDays(7)) + ); + await spells.NotifyMagicSpell(spell, true); + } + + // 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(), + "google" => serviceProvider.GetRequiredService(), + // Add more providers as needed + _ => throw new ArgumentException($"Unsupported provider: {provider}") + }; + } + + /// + /// Creates a session and redirects the user with a token + /// + private async Task 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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs b/DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs new file mode 100644 index 0000000..1efc392 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs @@ -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; + +/// +/// Implementation of OpenID Connect service for Apple Sign In +/// +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 + { + { "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 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(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 ValidateTokenAsync(string idToken) + { + // Get Apple's public keys + var jwksJson = await GetAppleJwksAsync(); + var jwks = JsonSerializer.Deserialize(jwksJson) ?? new AppleJwks { Keys = new List() }; + + // 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 BuildTokenRequestParameters(string code, ProviderConfiguration config, + string? codeVerifier) + { + var parameters = new Dictionary + { + { "client_id", config.ClientId }, + { "client_secret", GenerateClientSecret() }, + { "code", code }, + { "grant_type", "authorization_code" }, + { "redirect_uri", config.RedirectUri } + }; + + return parameters; + } + + private async Task GetAppleJwksAsync() + { + var client = _httpClientFactory.CreateClient(); + var response = await client.GetAsync("https://appleid.apple.com/auth/keys"); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync(); + } + + /// + /// Generates a client secret for Apple Sign In using JWT + /// + 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 + { + { "alg", "ES256" }, + { "kid", keyId } + }; + + // Create the JWT payload + var payload = new Dictionary + { + { "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 Keys { get; set; } = new List(); +} + +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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs b/DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs new file mode 100644 index 0000000..2526140 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs @@ -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>> 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 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(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/OpenId/GoogleOidcService.cs b/DysonNetwork.Sphere/Auth/OpenId/GoogleOidcService.cs new file mode 100644 index 0000000..7bdb21b --- /dev/null +++ b/DysonNetwork.Sphere/Auth/OpenId/GoogleOidcService.cs @@ -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; + +/// +/// Implementation of OpenID Connect service for Google Sign In +/// +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 + { + { "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 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>(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 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(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 +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs b/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs new file mode 100644 index 0000000..50ac8eb --- /dev/null +++ b/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs @@ -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(), + "google" => serviceProvider.GetRequiredService(), + // Add more providers as needed + _ => throw new ArgumentException($"Unsupported provider: {provider}") + }; + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/OpenId/OidcService.cs b/DysonNetwork.Sphere/Auth/OpenId/OidcService.cs new file mode 100644 index 0000000..6239437 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/OpenId/OidcService.cs @@ -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; + +/// +/// Base service for OpenID Connect authentication providers +/// +public abstract class OidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db) +{ + /// + /// Gets the unique identifier for this provider + /// + public abstract string ProviderName { get; } + + /// + /// Gets the OIDC discovery document endpoint + /// + protected abstract string DiscoveryEndpoint { get; } + + /// + /// Gets configuration section name for this provider + /// + protected abstract string ConfigSectionName { get; } + + /// + /// Gets the authorization URL for initiating the authentication flow + /// + public abstract string GetAuthorizationUrl(string state, string nonce); + + /// + /// Process the callback from the OIDC provider + /// + public abstract Task ProcessCallbackAsync(OidcCallbackData callbackData); + + /// + /// Gets the provider configuration + /// + protected ProviderConfiguration GetProviderConfig() + { + return new ProviderConfiguration + { + ClientId = configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "", + ClientSecret = configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "", + RedirectUri = configuration[$"Oidc:{ConfigSectionName}:RedirectUri"] ?? "" + }; + } + + /// + /// Retrieves the OpenID Connect discovery document + /// + protected async Task GetDiscoveryDocumentAsync() + { + var client = httpClientFactory.CreateClient(); + var response = await client.GetAsync(DiscoveryEndpoint); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + /// + /// Exchange the authorization code for tokens + /// + protected async Task 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(); + } + + /// + /// Build the token request parameters + /// + protected virtual Dictionary BuildTokenRequestParameters(string code, ProviderConfiguration config, + string? codeVerifier) + { + var parameters = new Dictionary + { + { "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; + } + + /// + /// Validates and extracts information from an ID token + /// + 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 + }; + } + + /// + /// Creates a challenge and session for an authenticated user + /// Also creates or updates the account connection + /// + public async Task 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; + } +} + +/// +/// Provider configuration from app settings +/// +public class ProviderConfiguration +{ + public string ClientId { get; set; } = ""; + public string ClientSecret { get; set; } = ""; + public string RedirectUri { get; set; } = ""; +} + +/// +/// OIDC Discovery Document +/// +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; } +} + +/// +/// Response from the token endpoint +/// +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; } +} + +/// +/// Data received in the callback from an OIDC provider +/// +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; } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/OpenId/OidcUserInfo.cs b/DysonNetwork.Sphere/Auth/OpenId/OidcUserInfo.cs new file mode 100644 index 0000000..5b3a40d --- /dev/null +++ b/DysonNetwork.Sphere/Auth/OpenId/OidcUserInfo.cs @@ -0,0 +1,19 @@ +namespace DysonNetwork.Sphere.Auth.OpenId; + +/// +/// Represents the user information from an OIDC provider +/// +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; } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 4827ce7..084f9d2 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -147,6 +147,7 @@ True NotificationResource.resx + diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index 68c1622..7d55d2b 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -6,6 +6,7 @@ using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Email; using DysonNetwork.Sphere.Activity; using DysonNetwork.Sphere.Auth; +using DysonNetwork.Sphere.Auth.OpenId; using DysonNetwork.Sphere.Chat; using DysonNetwork.Sphere.Chat.Realtime; using DysonNetwork.Sphere.Connection; @@ -200,6 +201,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/DysonNetwork.Sphere/README.md b/DysonNetwork.Sphere/README.md new file mode 100644 index 0000000..502541b --- /dev/null +++ b/DysonNetwork.Sphere/README.md @@ -0,0 +1,62 @@ +# OpenID Connect Integration + +This project includes a reusable OpenID Connect client implementation that can be used with multiple providers. + +## Supported Providers + +- Apple Sign In +- Google Sign In + +## How to Add a New Provider + +1. Create a new class that inherits from `OidcService` in the `Auth/OpenId` directory +2. Implement the abstract methods and properties +3. Register the service in `Program.cs` +4. Add the provider's configuration to `appsettings.json` +5. Add the provider to the `GetOidcService` method in both `OidcController` and `AuthCallbackController` + +## Configuration + +### Apple Sign In + +```json +"Apple": { + "ClientId": "YOUR_APPLE_CLIENT_ID", // Your Service ID from Apple Developer portal + "TeamId": "YOUR_APPLE_TEAM_ID", // Your Team ID from Apple Developer portal + "KeyId": "YOUR_APPLE_KEY_ID", // Key ID for the private key + "PrivateKeyPath": "./apple_auth_key.p8", // Path to your .p8 private key file + "RedirectUri": "https://your-app.com/auth/callback/apple" // Your callback URL +} +``` + +### Google Sign In + +```json +"Google": { + "ClientId": "YOUR_GOOGLE_CLIENT_ID", // Your OAuth client ID + "ClientSecret": "YOUR_GOOGLE_CLIENT_SECRET", // Your OAuth client secret + "RedirectUri": "https://your-app.com/auth/callback/google" // Your callback URL +} +``` + +## Usage + +To initiate the OpenID Connect flow, redirect the user to: + +``` +/auth/login/{provider}?returnUrl=/your-return-path +``` + +Where `{provider}` is one of the supported providers (e.g., `apple`, `google`). + +## Authentication Flow + +1. User is redirected to the provider's authentication page +2. After successful authentication, the provider redirects back to your callback endpoint +3. The callback endpoint processes the response and creates or retrieves the user account +4. A session is created for the user and a token is issued +5. The user is redirected back to the specified return URL with the token set as a cookie + +## Customization + +The base `OidcService` class provides common functionality for all providers. You can override any of its methods in your provider-specific implementations to customize the behavior. diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index 2c337b5..fc13931 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -83,5 +83,19 @@ }, "GeoIp": { "DatabasePath": "./Keys/GeoLite2-City.mmdb" + }, + "Oidc": { + "Google": { + "ClientId": "YOUR_GOOGLE_CLIENT_ID", + "ClientSecret": "YOUR_GOOGLE_CLIENT_SECRET", + "RedirectUri": "https://your-app.com/auth/callback/google" + }, + "Apple": { + "ClientId": "YOUR_APPLE_CLIENT_ID", + "TeamId": "YOUR_APPLE_TEAM_ID", + "KeyId": "YOUR_APPLE_KEY_ID", + "PrivateKeyPath": "./apple_auth_key.p8", + "RedirectUri": "https://your-app.com/auth/callback/apple" + } } -} +} \ No newline at end of file