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