🧱 OAuth login infra

This commit is contained in:
LittleSheep 2025-06-15 13:11:45 +08:00
parent d00917fb39
commit bf013a108b
14 changed files with 1314 additions and 47 deletions

View File

@ -172,4 +172,18 @@ public enum AccountAuthFactorType
EmailCode, EmailCode,
InAppCode, InAppCode,
TimedCode 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!;
} }

View File

@ -0,0 +1,152 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Account;
/// <summary>
/// Service for handling username generation and validation
/// </summary>
public class AccountUsernameService(AppDatabase db)
{
private readonly Random _random = new Random();
/// <summary>
/// Generates a unique username based on the provided base name
/// </summary>
/// <param name="baseName">The preferred username</param>
/// <returns>A unique username</returns>
public async Task<string> 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}";
}
/// <summary>
/// Generates a display name, adding numbers if needed
/// </summary>
/// <param name="baseName">The preferred display name</param>
/// <returns>A display name with optional suffix</returns>
public Task<string> 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}");
}
/// <summary>
/// Sanitizes a username by removing invalid characters and converting to lowercase
/// </summary>
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;
}
/// <summary>
/// Checks if a username already exists
/// </summary>
public async Task<bool> IsUsernameExistsAsync(string username)
{
return await db.Accounts.AnyAsync(a => a.Name == username);
}
/// <summary>
/// Generates a username from an email address
/// </summary>
/// <param name="email">The email address to generate a username from</param>
/// <returns>A unique username derived from the email</returns>
public async Task<string> 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);
}
/// <summary>
/// Generates a display name from an email address
/// </summary>
/// <param name="email">The email address to generate a display name from</param>
/// <returns>A display name derived from the email</returns>
public async Task<string> 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);
}
}

View File

@ -1,6 +1,15 @@
using System.Linq.Expressions; 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.Permission;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Sticker;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet; using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
@ -32,24 +41,25 @@ public class AppDatabase(
public DbSet<PermissionGroup> PermissionGroups { get; set; } public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; } public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<Account.MagicSpell> MagicSpells { get; set; } public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<Account.Account> Accounts { get; set; } public DbSet<Account.Account> Accounts { get; set; }
public DbSet<Account.Profile> AccountProfiles { get; set; } public DbSet<AccountConnection> AccountConnections { get; set; }
public DbSet<Account.AccountContact> AccountContacts { get; set; } public DbSet<Profile> AccountProfiles { get; set; }
public DbSet<Account.AccountAuthFactor> AccountAuthFactors { get; set; } public DbSet<AccountContact> AccountContacts { get; set; }
public DbSet<Account.Relationship> AccountRelationships { get; set; } public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Account.Status> AccountStatuses { get; set; } public DbSet<Relationship> AccountRelationships { get; set; }
public DbSet<Account.CheckInResult> AccountCheckInResults { get; set; } public DbSet<Status> AccountStatuses { get; set; }
public DbSet<Account.Notification> Notifications { get; set; } public DbSet<CheckInResult> AccountCheckInResults { get; set; }
public DbSet<Account.NotificationPushSubscription> NotificationPushSubscriptions { get; set; } public DbSet<Notification> Notifications { get; set; }
public DbSet<Account.Badge> Badges { get; set; } public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Account.ActionLog> ActionLogs { get; set; } public DbSet<Badge> Badges { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<Auth.Session> AuthSessions { get; set; } public DbSet<Session> AuthSessions { get; set; }
public DbSet<Auth.Challenge> AuthChallenges { get; set; } public DbSet<Challenge> AuthChallenges { get; set; }
public DbSet<Storage.CloudFile> Files { get; set; } public DbSet<CloudFile> Files { get; set; }
public DbSet<Storage.CloudFileReference> FileReferences { get; set; } public DbSet<CloudFileReference> FileReferences { get; set; }
public DbSet<Publisher.Publisher> Publishers { get; set; } public DbSet<Publisher.Publisher> Publishers { get; set; }
public DbSet<PublisherMember> PublisherMembers { get; set; } public DbSet<PublisherMember> PublisherMembers { get; set; }
@ -57,30 +67,30 @@ public class AppDatabase(
public DbSet<PublisherFeature> PublisherFeatures { get; set; } public DbSet<PublisherFeature> PublisherFeatures { get; set; }
public DbSet<Post.Post> Posts { get; set; } public DbSet<Post.Post> Posts { get; set; }
public DbSet<Post.PostReaction> PostReactions { get; set; } public DbSet<PostReaction> PostReactions { get; set; }
public DbSet<Post.PostTag> PostTags { get; set; } public DbSet<PostTag> PostTags { get; set; }
public DbSet<Post.PostCategory> PostCategories { get; set; } public DbSet<PostCategory> PostCategories { get; set; }
public DbSet<Post.PostCollection> PostCollections { get; set; } public DbSet<PostCollection> PostCollections { get; set; }
public DbSet<Realm.Realm> Realms { get; set; } public DbSet<Realm.Realm> Realms { get; set; }
public DbSet<Realm.RealmMember> RealmMembers { get; set; } public DbSet<RealmMember> RealmMembers { get; set; }
public DbSet<Chat.ChatRoom> ChatRooms { get; set; } public DbSet<ChatRoom> ChatRooms { get; set; }
public DbSet<Chat.ChatMember> ChatMembers { get; set; } public DbSet<ChatMember> ChatMembers { get; set; }
public DbSet<Chat.Message> ChatMessages { get; set; } public DbSet<Message> ChatMessages { get; set; }
public DbSet<Chat.RealtimeCall> ChatRealtimeCall { get; set; } public DbSet<RealtimeCall> ChatRealtimeCall { get; set; }
public DbSet<Chat.MessageReaction> ChatReactions { get; set; } public DbSet<MessageReaction> ChatReactions { get; set; }
public DbSet<Sticker.Sticker> Stickers { get; set; } public DbSet<Sticker.Sticker> Stickers { get; set; }
public DbSet<Sticker.StickerPack> StickerPacks { get; set; } public DbSet<StickerPack> StickerPacks { get; set; }
public DbSet<Wallet.Wallet> Wallets { get; set; } public DbSet<Wallet.Wallet> Wallets { get; set; }
public DbSet<Wallet.WalletPocket> WalletPockets { get; set; } public DbSet<WalletPocket> WalletPockets { get; set; }
public DbSet<Wallet.Order> PaymentOrders { get; set; } public DbSet<Order> PaymentOrders { get; set; }
public DbSet<Wallet.Transaction> PaymentTransactions { get; set; } public DbSet<Transaction> PaymentTransactions { get; set; }
public DbSet<Developer.CustomApp> CustomApps { get; set; } public DbSet<CustomApp> CustomApps { get; set; }
public DbSet<Developer.CustomAppSecret> CustomAppSecrets { get; set; } public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
public DbSet<Subscription> WalletSubscriptions { get; set; } public DbSet<Subscription> WalletSubscriptions { get; set; }
public DbSet<Coupon> WalletCoupons { get; set; } public DbSet<Coupon> WalletCoupons { get; set; }
@ -141,13 +151,13 @@ public class AppDatabase(
.HasForeignKey(pg => pg.GroupId) .HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Account.Relationship>() modelBuilder.Entity<Relationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId }); .HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Account.Relationship>() modelBuilder.Entity<Relationship>()
.HasOne(r => r.Account) .HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships) .WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.AccountId); .HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Account.Relationship>() modelBuilder.Entity<Relationship>()
.HasOne(r => r.Related) .HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships) .WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId); .HasForeignKey(r => r.RelatedId);
@ -202,49 +212,49 @@ public class AppDatabase(
.WithMany(c => c.Posts) .WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_collection_links")); .UsingEntity(j => j.ToTable("post_collection_links"));
modelBuilder.Entity<Realm.RealmMember>() modelBuilder.Entity<RealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId }); .HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<Realm.RealmMember>() modelBuilder.Entity<RealmMember>()
.HasOne(pm => pm.Realm) .HasOne(pm => pm.Realm)
.WithMany(p => p.Members) .WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId) .HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Realm.RealmMember>() modelBuilder.Entity<RealmMember>()
.HasOne(pm => pm.Account) .HasOne(pm => pm.Account)
.WithMany() .WithMany()
.HasForeignKey(pm => pm.AccountId) .HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chat.ChatMember>() modelBuilder.Entity<ChatMember>()
.HasKey(pm => new { pm.Id }); .HasKey(pm => new { pm.Id });
modelBuilder.Entity<Chat.ChatMember>() modelBuilder.Entity<ChatMember>()
.HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId }); .HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId });
modelBuilder.Entity<Chat.ChatMember>() modelBuilder.Entity<ChatMember>()
.HasOne(pm => pm.ChatRoom) .HasOne(pm => pm.ChatRoom)
.WithMany(p => p.Members) .WithMany(p => p.Members)
.HasForeignKey(pm => pm.ChatRoomId) .HasForeignKey(pm => pm.ChatRoomId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chat.ChatMember>() modelBuilder.Entity<ChatMember>()
.HasOne(pm => pm.Account) .HasOne(pm => pm.Account)
.WithMany() .WithMany()
.HasForeignKey(pm => pm.AccountId) .HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chat.Message>() modelBuilder.Entity<Message>()
.HasOne(m => m.ForwardedMessage) .HasOne(m => m.ForwardedMessage)
.WithMany() .WithMany()
.HasForeignKey(m => m.ForwardedMessageId) .HasForeignKey(m => m.ForwardedMessageId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Chat.Message>() modelBuilder.Entity<Message>()
.HasOne(m => m.RepliedMessage) .HasOne(m => m.RepliedMessage)
.WithMany() .WithMany()
.HasForeignKey(m => m.RepliedMessageId) .HasForeignKey(m => m.RepliedMessageId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Chat.RealtimeCall>() modelBuilder.Entity<RealtimeCall>()
.HasOne(m => m.Room) .HasOne(m => m.Room)
.WithMany() .WithMany()
.HasForeignKey(m => m.RoomId) .HasForeignKey(m => m.RoomId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chat.RealtimeCall>() modelBuilder.Entity<RealtimeCall>()
.HasOne(m => m.Sender) .HasOne(m => m.Sender)
.WithMany() .WithMany()
.HasForeignKey(m => m.SenderId) .HasForeignKey(m => m.SenderId)
@ -256,7 +266,7 @@ public class AppDatabase(
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue; if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
var method = typeof(AppDatabase) var method = typeof(AppDatabase)
.GetMethod(nameof(SetSoftDeleteFilter), .GetMethod(nameof(SetSoftDeleteFilter),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType); .MakeGenericMethod(entityType.ClrType);
method.Invoke(null, [modelBuilder]); method.Invoke(null, [modelBuilder]);

View File

@ -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;
/// <summary>
/// This controller is designed to handle the OAuth callback.
/// </summary>
[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<ActionResult> AppleCallbackPost(
[FromForm] string code,
[FromForm(Name = "id_token")] string idToken,
[FromForm] string? state = null,
[FromForm] string? user = null)
{
return await ProcessOidcCallback("apple", new OidcCallbackData
{
Code = code,
IdToken = idToken,
State = state,
RawData = user
});
}
private async Task<ActionResult> ProcessOidcCallback(string provider, OidcCallbackData callbackData)
{
try
{
// Get the appropriate provider service
var oidcService = GetOidcService(provider);
// Process the callback
var userInfo = await oidcService.ProcessCallbackAsync(callbackData);
if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId))
{
return BadRequest($"Email or user ID is missing from {provider}'s response");
}
// First, check if we already have a connection with this provider ID
var existingConnection = await db.AccountConnections
.Include(c => c.Account)
.FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId);
if (existingConnection is not null)
return await CreateSessionAndRedirect(
oidcService,
userInfo,
existingConnection.Account,
callbackData.State
);
// If no existing connection, try to find an account by email
var account = await accounts.LookupAccount(userInfo.Email);
if (account == null)
{
// 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<AccountContact>
{
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<string, object>
{
{ "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<AppleOidcService>(),
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
// Add more providers as needed
_ => throw new ArgumentException($"Unsupported provider: {provider}")
};
}
/// <summary>
/// Creates a session and redirects the user with a token
/// </summary>
private async Task<ActionResult> CreateSessionAndRedirect(OidcService oidcService, OidcUserInfo userInfo,
Account.Account account, string? state)
{
// Create a session for the user
var session = await oidcService.CreateSessionForUserAsync(userInfo, account);
// Generate token
var token = auth.CreateToken(session);
// Determine where to redirect
var redirectUrl = "/";
if (!string.IsNullOrEmpty(state))
redirectUrl = state;
// Set the token as a cookie
Response.Cookies.Append(AuthConstants.TokenQueryParamName, token, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddDays(30)
});
return Redirect(redirectUrl);
}
}

View File

@ -0,0 +1,267 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Implementation of OpenID Connect service for Apple Sign In
/// </summary>
public class AppleOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db
)
: OidcService(configuration, httpClientFactory, db)
{
private readonly IConfiguration _configuration = configuration;
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
public override string ProviderName => "apple";
protected override string DiscoveryEndpoint => "https://appleid.apple.com/.well-known/openid-configuration";
protected override string ConfigSectionName => "Apple";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code id_token" },
{ "scope", "name email" },
{ "response_mode", "form_post" },
{ "state", state },
{ "nonce", nonce }
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://appleid.apple.com/auth/authorize?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
// Verify and decode the id_token
var userInfo = await ValidateTokenAsync(callbackData.IdToken);
// If user data is provided in first login, parse it
if (!string.IsNullOrEmpty(callbackData.RawData))
{
var userData = JsonSerializer.Deserialize<AppleUserData>(callbackData.RawData);
if (userData?.Name != null)
{
userInfo.FirstName = userData.Name.FirstName ?? "";
userInfo.LastName = userData.Name.LastName ?? "";
userInfo.DisplayName = $"{userInfo.FirstName} {userInfo.LastName}".Trim();
}
}
// Exchange authorization code for access token (optional, if you need the access token)
if (string.IsNullOrEmpty(callbackData.Code)) return userInfo;
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse == null) return userInfo;
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
private async Task<OidcUserInfo> ValidateTokenAsync(string idToken)
{
// Get Apple's public keys
var jwksJson = await GetAppleJwksAsync();
var jwks = JsonSerializer.Deserialize<AppleJwks>(jwksJson) ?? new AppleJwks { Keys = new List<AppleKey>() };
// Parse the JWT header to get the key ID
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(idToken);
var kid = jwtToken.Header.Kid;
// Find the matching key
var key = jwks.Keys.FirstOrDefault(k => k.Kid == kid);
if (key == null)
{
throw new SecurityTokenValidationException("Unable to find matching key in Apple's JWKS");
}
// Create the validation parameters
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://appleid.apple.com",
ValidateAudience = true,
ValidAudience = GetProviderConfig().ClientId,
ValidateLifetime = true,
IssuerSigningKey = key.ToSecurityKey()
};
return ValidateAndExtractIdToken(idToken, validationParameters);
}
protected override Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config,
string? codeVerifier)
{
var parameters = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", GenerateClientSecret() },
{ "code", code },
{ "grant_type", "authorization_code" },
{ "redirect_uri", config.RedirectUri }
};
return parameters;
}
private async Task<string> GetAppleJwksAsync()
{
var client = _httpClientFactory.CreateClient();
var response = await client.GetAsync("https://appleid.apple.com/auth/keys");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
/// <summary>
/// Generates a client secret for Apple Sign In using JWT
/// </summary>
private string GenerateClientSecret()
{
var now = DateTime.UtcNow;
var teamId = _configuration["Oidc:Apple:TeamId"];
var clientId = _configuration["Oidc:Apple:ClientId"];
var keyId = _configuration["Oidc:Apple:KeyId"];
var privateKeyPath = _configuration["Oidc:Apple:PrivateKeyPath"];
// Read the private key
var privateKey = File.ReadAllText(privateKeyPath!);
// Create the JWT header
var header = new Dictionary<string, object>
{
{ "alg", "ES256" },
{ "kid", keyId }
};
// Create the JWT payload
var payload = new Dictionary<string, object>
{
{ "iss", teamId },
{ "iat", ToUnixTimeSeconds(now) },
{ "exp", ToUnixTimeSeconds(now.AddMinutes(5)) },
{ "aud", "https://appleid.apple.com" },
{ "sub", clientId }
};
// Convert header and payload to Base64Url
var headerJson = JsonSerializer.Serialize(header);
var payloadJson = JsonSerializer.Serialize(payload);
var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
// Create the signature
var dataToSign = $"{headerBase64}.{payloadBase64}";
var signature = SignWithECDsa(dataToSign, privateKey);
// Combine all parts
return $"{headerBase64}.{payloadBase64}.{signature}";
}
private long ToUnixTimeSeconds(DateTime dateTime)
{
return new DateTimeOffset(dateTime).ToUnixTimeSeconds();
}
private string SignWithECDsa(string dataToSign, string privateKey)
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(privateKey);
var bytes = Encoding.UTF8.GetBytes(dataToSign);
var signature = ecdsa.SignData(bytes, HashAlgorithmName.SHA256);
return Base64UrlEncode(signature);
}
private string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
}
public class AppleUserData
{
[JsonPropertyName("name")] public AppleNameData? Name { get; set; }
[JsonPropertyName("email")] public string? Email { get; set; }
}
public class AppleNameData
{
[JsonPropertyName("firstName")] public string? FirstName { get; set; }
[JsonPropertyName("lastName")] public string? LastName { get; set; }
}
public class AppleJwks
{
[JsonPropertyName("keys")] public List<AppleKey> Keys { get; set; } = new List<AppleKey>();
}
public class AppleKey
{
[JsonPropertyName("kty")] public string? Kty { get; set; }
[JsonPropertyName("kid")] public string? Kid { get; set; }
[JsonPropertyName("use")] public string? Use { get; set; }
[JsonPropertyName("alg")] public string? Alg { get; set; }
[JsonPropertyName("n")] public string? N { get; set; }
[JsonPropertyName("e")] public string? E { get; set; }
public SecurityKey ToSecurityKey()
{
if (Kty != "RSA" || string.IsNullOrEmpty(N) || string.IsNullOrEmpty(E))
{
throw new InvalidOperationException("Invalid key data");
}
var parameters = new RSAParameters
{
Modulus = Base64UrlDecode(N),
Exponent = Base64UrlDecode(E)
};
var rsa = RSA.Create();
rsa.ImportParameters(parameters);
return new RsaSecurityKey(rsa);
}
private byte[] Base64UrlDecode(string input)
{
var output = input
.Replace('-', '+')
.Replace('_', '/');
switch (output.Length % 4)
{
case 0: break;
case 2: output += "=="; break;
case 3: output += "="; break;
default: throw new InvalidOperationException("Invalid base64url string");
}
return Convert.FromBase64String(output);
}
}

View File

@ -0,0 +1,43 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Auth.OpenId;
[ApiController]
[Route("/api/connections")]
[Authorize]
public class ConnectionController(AppDatabase db) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<AccountConnection>>> GetConnections()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var connections = await db.AccountConnections
.Where(c => c.AccountId == currentUser.Id)
.Select(c => new { c.Id, c.AccountId, c.Provider, c.ProvidedIdentifier })
.ToListAsync();
return Ok(connections);
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult> RemoveConnection(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var connection = await db.AccountConnections
.Where(c => c.Id == id && c.AccountId == currentUser.Id)
.FirstOrDefaultAsync();
if (connection == null)
return NotFound();
db.AccountConnections.Remove(connection);
await db.SaveChangesAsync();
return Ok();
}
}

View File

@ -0,0 +1,184 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Implementation of OpenID Connect service for Google Sign In
/// </summary>
public class GoogleOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db
)
: OidcService(configuration, httpClientFactory, db)
{
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
public override string ProviderName => "google";
protected override string DiscoveryEndpoint => "https://accounts.google.com/.well-known/openid-configuration";
protected override string ConfigSectionName => "Google";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var discoveryDocument = GetDiscoveryDocumentAsync().GetAwaiter().GetResult();
if (discoveryDocument?.AuthorizationEndpoint == null)
{
throw new InvalidOperationException("Authorization endpoint not found in discovery document");
}
// Generate code verifier and challenge for PKCE
var codeVerifier = GenerateCodeVerifier();
var codeChallenge = GenerateCodeChallenge(codeVerifier);
// Store code verifier in session or cache for later use
// For simplicity, we'll append it to the state parameter in this example
var combinedState = $"{state}|{codeVerifier}";
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code" },
{ "scope", "openid email profile" },
{ "state", combinedState },
{ "nonce", nonce },
{ "code_challenge", codeChallenge },
{ "code_challenge_method", "S256" }
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
// Extract code verifier from state
string? codeVerifier = null;
var state = callbackData.State ?? "";
if (state.Contains('|'))
{
var parts = state.Split('|');
state = parts[0];
codeVerifier = parts.Length > 1 ? parts[1] : null;
callbackData.State = state; // Set the clean state back
}
// Exchange the code for tokens
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, codeVerifier);
if (tokenResponse?.IdToken == null)
{
throw new InvalidOperationException("Failed to obtain ID token from Google");
}
// Validate the ID token
var userInfo = await ValidateTokenAsync(tokenResponse.IdToken);
// Set tokens on the user info
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
// Try to fetch additional profile data if userinfo endpoint is available
try
{
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.UserinfoEndpoint != null && !string.IsNullOrEmpty(tokenResponse.AccessToken))
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
var userInfoResponse = await client.GetFromJsonAsync<Dictionary<string, object>>(discoveryDocument.UserinfoEndpoint);
if (userInfoResponse != null)
{
// Extract any additional fields that might be available
if (userInfoResponse.TryGetValue("picture", out var picture) && picture != null)
{
userInfo.ProfilePictureUrl = picture.ToString();
}
}
}
}
catch (Exception)
{
// Ignore errors when fetching additional profile data
}
return userInfo;
}
private async Task<OidcUserInfo> ValidateTokenAsync(string idToken)
{
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.JwksUri == null)
{
throw new InvalidOperationException("JWKS URI not found in discovery document");
}
// Get Google's signing keys
var client = _httpClientFactory.CreateClient();
var jwksResponse = await client.GetFromJsonAsync<JsonWebKeySet>(discoveryDocument.JwksUri);
if (jwksResponse == null)
{
throw new InvalidOperationException("Failed to retrieve JWKS from Google");
}
// Parse the JWT to get the key ID
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(idToken);
var kid = jwtToken.Header.Kid;
// Find the matching key
var signingKey = jwksResponse.Keys.FirstOrDefault(k => k.Kid == kid);
if (signingKey == null)
{
throw new SecurityTokenValidationException("Unable to find matching key in Google's JWKS");
}
// Create validation parameters
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://accounts.google.com",
ValidateAudience = true,
ValidAudience = GetProviderConfig().ClientId,
ValidateLifetime = true,
IssuerSigningKey = signingKey
};
return ValidateAndExtractIdToken(idToken, validationParameters);
}
#region PKCE Support
public string GenerateCodeVerifier()
{
var randomBytes = new byte[32]; // 256 bits
using (var rng = RandomNumberGenerator.Create())
rng.GetBytes(randomBytes);
return Convert.ToBase64String(randomBytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
public string GenerateCodeChallenge(string codeVerifier)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
return Convert.ToBase64String(challengeBytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
#endregion
}

View File

@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
namespace DysonNetwork.Sphere.Auth.OpenId;
[ApiController]
[Route("/auth/login")]
public class OidcController(
IServiceProvider serviceProvider,
AppDatabase db,
Account.AccountService accountService,
AuthService authService
)
: ControllerBase
{
[HttpGet("{provider}")]
public ActionResult SignIn([FromRoute] string provider, [FromQuery] string? returnUrl = "/")
{
try
{
// Get the appropriate provider service
var oidcService = GetOidcService(provider);
// Generate state (containing return URL) and nonce
var state = returnUrl;
var nonce = Guid.NewGuid().ToString();
// Get the authorization URL and redirect the user
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Redirect(authUrl);
}
catch (Exception ex)
{
return BadRequest($"Error initiating OpenID Connect flow: {ex.Message}");
}
}
private OidcService GetOidcService(string provider)
{
return provider.ToLower() switch
{
"apple" => serviceProvider.GetRequiredService<AppleOidcService>(),
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
// Add more providers as needed
_ => throw new ArgumentException($"Unsupported provider: {provider}")
};
}
}

View File

@ -0,0 +1,268 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Base service for OpenID Connect authentication providers
/// </summary>
public abstract class OidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db)
{
/// <summary>
/// Gets the unique identifier for this provider
/// </summary>
public abstract string ProviderName { get; }
/// <summary>
/// Gets the OIDC discovery document endpoint
/// </summary>
protected abstract string DiscoveryEndpoint { get; }
/// <summary>
/// Gets configuration section name for this provider
/// </summary>
protected abstract string ConfigSectionName { get; }
/// <summary>
/// Gets the authorization URL for initiating the authentication flow
/// </summary>
public abstract string GetAuthorizationUrl(string state, string nonce);
/// <summary>
/// Process the callback from the OIDC provider
/// </summary>
public abstract Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData);
/// <summary>
/// Gets the provider configuration
/// </summary>
protected ProviderConfiguration GetProviderConfig()
{
return new ProviderConfiguration
{
ClientId = configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
ClientSecret = configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
RedirectUri = configuration[$"Oidc:{ConfigSectionName}:RedirectUri"] ?? ""
};
}
/// <summary>
/// Retrieves the OpenID Connect discovery document
/// </summary>
protected async Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{
var client = httpClientFactory.CreateClient();
var response = await client.GetAsync(DiscoveryEndpoint);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcDiscoveryDocument>();
}
/// <summary>
/// Exchange the authorization code for tokens
/// </summary>
protected async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, string? codeVerifier = null)
{
var config = GetProviderConfig();
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.TokenEndpoint == null)
{
throw new InvalidOperationException("Token endpoint not found in discovery document");
}
var client = httpClientFactory.CreateClient();
var content = new FormUrlEncodedContent(BuildTokenRequestParameters(code, config, codeVerifier));
var response = await client.PostAsync(discoveryDocument.TokenEndpoint, content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
/// <summary>
/// Build the token request parameters
/// </summary>
protected virtual Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config,
string? codeVerifier)
{
var parameters = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "code", code },
{ "grant_type", "authorization_code" },
{ "redirect_uri", config.RedirectUri }
};
if (!string.IsNullOrEmpty(config.ClientSecret))
{
parameters.Add("client_secret", config.ClientSecret);
}
if (!string.IsNullOrEmpty(codeVerifier))
{
parameters.Add("code_verifier", codeVerifier);
}
return parameters;
}
/// <summary>
/// Validates and extracts information from an ID token
/// </summary>
protected virtual OidcUserInfo ValidateAndExtractIdToken(string idToken,
TokenValidationParameters validationParameters)
{
var handler = new JwtSecurityTokenHandler();
handler.ValidateToken(idToken, validationParameters, out _);
var jwtToken = handler.ReadJwtToken(idToken);
// Extract standard claims
var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
var email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
var emailVerified = jwtToken.Claims.FirstOrDefault(c => c.Type == "email_verified")?.Value == "true";
var name = jwtToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
var givenName = jwtToken.Claims.FirstOrDefault(c => c.Type == "given_name")?.Value;
var familyName = jwtToken.Claims.FirstOrDefault(c => c.Type == "family_name")?.Value;
var preferredUsername = jwtToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
var picture = jwtToken.Claims.FirstOrDefault(c => c.Type == "picture")?.Value;
// Determine preferred username - try different options
var username = preferredUsername;
if (string.IsNullOrEmpty(username))
{
// Fall back to email local part if no preferred username
username = !string.IsNullOrEmpty(email) ? email.Split('@')[0] : null;
}
return new OidcUserInfo
{
UserId = userId,
Email = email,
EmailVerified = emailVerified,
FirstName = givenName ?? "",
LastName = familyName ?? "",
DisplayName = name ?? $"{givenName} {familyName}".Trim(),
PreferredUsername = username ?? "",
ProfilePictureUrl = picture,
Provider = ProviderName
};
}
/// <summary>
/// Creates a challenge and session for an authenticated user
/// Also creates or updates the account connection
/// </summary>
public async Task<Session> CreateSessionForUserAsync(OidcUserInfo userInfo, Account.Account account)
{
// Create or update the account connection
var connection = await db.AccountConnections
.FirstOrDefaultAsync(c => c.Provider == ProviderName &&
c.ProvidedIdentifier == userInfo.UserId &&
c.AccountId == account.Id
);
if (connection is null)
{
connection = new AccountConnection
{
Provider = ProviderName,
ProvidedIdentifier = userInfo.UserId ?? "",
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(),
AccountId = account.Id
};
await db.AccountConnections.AddAsync(connection);
}
// Create a challenge that's already completed
var now = SystemClock.Instance.GetCurrentInstant();
var challenge = new Challenge
{
ExpiredAt = now.Plus(Duration.FromHours(1)),
StepTotal = 1,
StepRemain = 0, // Already verified by provider
Platform = ChallengePlatform.Unidentified,
Audiences = [ProviderName],
Scopes = ["*"],
AccountId = account.Id
};
await db.AuthChallenges.AddAsync(challenge);
// Create a session
var session = new Session
{
LastGrantedAt = now,
Account = account,
Challenge = challenge,
};
await db.AuthSessions.AddAsync(session);
await db.SaveChangesAsync();
return session;
}
}
/// <summary>
/// Provider configuration from app settings
/// </summary>
public class ProviderConfiguration
{
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string RedirectUri { get; set; } = "";
}
/// <summary>
/// OIDC Discovery Document
/// </summary>
public class OidcDiscoveryDocument
{
[JsonPropertyName("authorization_endpoint")]
public string? AuthorizationEndpoint { get; set; }
[JsonPropertyName("token_endpoint")] public string? TokenEndpoint { get; set; }
[JsonPropertyName("userinfo_endpoint")]
public string? UserinfoEndpoint { get; set; }
[JsonPropertyName("jwks_uri")] public string? JwksUri { get; set; }
}
/// <summary>
/// Response from the token endpoint
/// </summary>
public class OidcTokenResponse
{
[JsonPropertyName("access_token")] public string? AccessToken { get; set; }
[JsonPropertyName("token_type")] public string? TokenType { get; set; }
[JsonPropertyName("expires_in")] public int ExpiresIn { get; set; }
[JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; }
[JsonPropertyName("id_token")] public string? IdToken { get; set; }
}
/// <summary>
/// Data received in the callback from an OIDC provider
/// </summary>
public class OidcCallbackData
{
public string Code { get; set; } = "";
public string IdToken { get; set; } = "";
public string? State { get; set; }
public string? CodeVerifier { get; set; }
public string? RawData { get; set; }
}

View File

@ -0,0 +1,19 @@
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Represents the user information from an OIDC provider
/// </summary>
public class OidcUserInfo
{
public string? UserId { get; set; }
public string? Email { get; set; }
public bool EmailVerified { get; set; }
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string PreferredUsername { get; set; } = "";
public string? ProfilePictureUrl { get; set; }
public string Provider { get; set; } = "";
public string? RefreshToken { get; set; }
public string? AccessToken { get; set; }
}

View File

@ -147,6 +147,7 @@
<AutoGen>True</AutoGen> <AutoGen>True</AutoGen>
<DependentUpon>NotificationResource.resx</DependentUpon> <DependentUpon>NotificationResource.resx</DependentUpon>
</Compile> </Compile>
<Compile Remove="Auth\AppleAuthController.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -6,6 +6,7 @@ using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Email; using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Activity; using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Chat; using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Chat.Realtime; using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Connection; using DysonNetwork.Sphere.Connection;
@ -200,6 +201,9 @@ builder.Services.AddScoped<RelationshipService>();
builder.Services.AddScoped<MagicSpellService>(); builder.Services.AddScoped<MagicSpellService>();
builder.Services.AddScoped<NotificationService>(); builder.Services.AddScoped<NotificationService>();
builder.Services.AddScoped<AuthService>(); builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<AppleOidcService>();
builder.Services.AddScoped<GoogleOidcService>();
builder.Services.AddScoped<AccountUsernameService>();
builder.Services.AddScoped<FileService>(); builder.Services.AddScoped<FileService>();
builder.Services.AddScoped<FileReferenceService>(); builder.Services.AddScoped<FileReferenceService>();
builder.Services.AddScoped<FileReferenceMigrationService>(); builder.Services.AddScoped<FileReferenceMigrationService>();

View File

@ -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.

View File

@ -83,5 +83,19 @@
}, },
"GeoIp": { "GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb" "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"
}
} }
} }