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