Login with Apple

This commit is contained in:
2025-06-15 17:29:30 +08:00
parent bf013a108b
commit 16ff5588b9
14 changed files with 4007 additions and 149 deletions

View File

@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class AppleMobileSignInRequest
{
[Required]
public required string IdentityToken { get; set; }
[Required]
public required string AuthorizationCode { get; set; }
}

View File

@ -102,8 +102,11 @@ public class AppleOidcService(
return ValidateAndExtractIdToken(idToken, validationParameters);
}
protected override Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config,
string? codeVerifier)
protected override Dictionary<string, string> BuildTokenRequestParameters(
string code,
ProviderConfiguration config,
string? codeVerifier
)
{
var parameters = new Dictionary<string, string>
{

View File

@ -1,10 +1,9 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Mvc;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Auth;
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// This controller is designed to handle the OAuth callback.
@ -69,43 +68,8 @@ public class AuthCallbackController(
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 new account using the AccountService
account = await accounts.CreateAccount(userInfo);
}
// Create a session for the user

View File

@ -1,5 +1,8 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId;
@ -8,7 +11,7 @@ namespace DysonNetwork.Sphere.Auth.OpenId;
public class OidcController(
IServiceProvider serviceProvider,
AppDatabase db,
Account.AccountService accountService,
AccountService accounts,
AuthService authService
)
: ControllerBase
@ -35,6 +38,51 @@ public class OidcController(
}
}
/// <summary>
/// Mobile Apple Sign In endpoint
/// Handles Apple authentication directly from mobile apps
/// </summary>
[HttpPost("apple/mobile")]
public async Task<ActionResult<AuthController.TokenExchangeResponse>> AppleMobileSignIn([FromBody] AppleMobileSignInRequest request)
{
try
{
// Get Apple OIDC service
if (GetOidcService("apple") is not AppleOidcService appleService)
return StatusCode(503, "Apple OIDC service not available");
// Prepare callback data for processing
var callbackData = new OidcCallbackData
{
IdToken = request.IdentityToken,
Code = request.AuthorizationCode,
};
// Process the authentication
var userInfo = await appleService.ProcessCallbackAsync(callbackData);
// Find or create user account using existing logic
var account = await FindOrCreateAccount(userInfo, "apple");
// Create session using the OIDC service
var session = await appleService.CreateSessionForUserAsync(userInfo, account);
// Generate token using existing auth service
var token = authService.CreateToken(session);
return Ok(new AuthController.TokenExchangeResponse { Token = token });
}
catch (SecurityTokenValidationException ex)
{
return Unauthorized($"Invalid identity token: {ex.Message}");
}
catch (Exception ex)
{
// Log the error
return StatusCode(500, $"Authentication failed: {ex.Message}");
}
}
private OidcService GetOidcService(string provider)
{
return provider.ToLower() switch
@ -45,4 +93,51 @@ public class OidcController(
_ => throw new ArgumentException($"Unsupported provider: {provider}")
};
}
private async Task<Account.Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider)
{
if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation");
// Check if account exists by email
var existingAccount = await accounts.LookupAccount(userInfo.Email);
if (existingAccount != null)
{
// Check if this provider connection already exists
var existingConnection = await db.AccountConnections
.FirstOrDefaultAsync(c => c.AccountId == existingAccount.Id &&
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId);
// If no connection exists, create one
if (existingConnection != null) return existingAccount;
var connection = new AccountConnection
{
AccountId = existingAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
};
db.AccountConnections.Add(connection);
await db.SaveChangesAsync();
return existingAccount;
}
// Create new account using the AccountService
var newAccount = await accounts.CreateAccount(userInfo);
// Create the provider connection
var newConnection = new AccountConnection
{
AccountId = newAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
};
db.AccountConnections.Add(newConnection);
await db.SaveChangesAsync();
return newAccount;
}
}

View File

@ -49,7 +49,7 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto
{
ClientId = configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
ClientSecret = configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
RedirectUri = configuration[$"Oidc:{ConfigSectionName}:RedirectUri"] ?? ""
RedirectUri = configuration["BaseUrl"] + "/auth/callback/" + ProviderName
};
}
@ -177,7 +177,7 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto
ProvidedIdentifier = userInfo.UserId ?? "",
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(),
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
AccountId = account.Id
};
await db.AccountConnections.AddAsync(connection);
@ -190,6 +190,7 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto
ExpiredAt = now.Plus(Duration.FromHours(1)),
StepTotal = 1,
StepRemain = 0, // Already verified by provider
Type = ChallengeType.Oidc,
Platform = ChallengePlatform.Unidentified,
Audiences = [ProviderName],
Scopes = ["*"],
@ -202,8 +203,8 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto
var session = new Session
{
LastGrantedAt = now,
Account = account,
Challenge = challenge,
AccountId = account.Id,
ChallengeId = challenge.Id,
};
await db.AuthSessions.AddAsync(session);

View File

@ -22,7 +22,8 @@ public class Session : ModelBase
public enum ChallengeType
{
Login,
OAuth
OAuth, // Trying to authorize other platforms
Oidc // Trying to connect other platforms
}
public enum ChallengePlatform