Login with Apple

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

View File

@ -25,6 +25,7 @@ public class Account : ModelBase
public ICollection<Badge> Badges { get; set; } = new List<Badge>(); public ICollection<Badge> Badges { get; set; } = new List<Badge>();
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>(); [JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>(); [JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>();
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>(); [JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>();

View File

@ -82,51 +82,21 @@ public class AccountController(
{ {
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token."); if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
var dupeNameCount = await db.Accounts.Where(a => a.Name == request.Name).CountAsync(); try
if (dupeNameCount > 0)
return BadRequest("The name is already taken.");
var account = new Account
{ {
Name = request.Name, var account = await accounts.CreateAccount(
Nick = request.Nick, request.Name,
Language = request.Language, request.Nick,
Contacts = new List<AccountContact> request.Email,
{ request.Password,
new() request.Language
{
Type = AccountContactType.Email,
Content = request.Email,
IsPrimary = true
}
},
AuthFactors = new List<AccountAuthFactor>
{
new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Secret = request.Password,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
}.HashSecret()
},
Profile = new Profile()
};
await db.Accounts.AddAsync(account);
await db.SaveChangesAsync();
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); return Ok(account);
}
return account; catch (Exception ex)
{
return BadRequest(ex.Message);
}
} }
public class RecoveryPasswordRequest public class RecoveryPasswordRequest

View File

@ -1,8 +1,10 @@
using System.Globalization; using System.Globalization;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Email; using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Pages.Emails; using DysonNetwork.Sphere.Pages.Emails;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using EFCore.BulkExtensions; using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -16,8 +18,9 @@ namespace DysonNetwork.Sphere.Account;
public class AccountService( public class AccountService(
AppDatabase db, AppDatabase db,
MagicSpellService spells, MagicSpellService spells,
AccountUsernameService uname,
NotificationService nty, NotificationService nty,
EmailService email, EmailService mailer,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
ICacheService cache, ICacheService cache,
ILogger<AccountService> logger ILogger<AccountService> logger
@ -62,6 +65,114 @@ public class AccountService(
return profile?.Level; return profile?.Level;
} }
public async Task<Account> CreateAccount(
string name,
string nick,
string email,
string? password,
string language = "en-US",
bool isEmailVerified = false,
bool isActivated = false
)
{
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync();
if (dupeNameCount > 0)
throw new InvalidOperationException("Account name has already been taken.");
var account = new Account
{
Name = name,
Nick = nick,
Language = language,
Contacts = new List<AccountContact>
{
new()
{
Type = AccountContactType.Email,
Content = email,
VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
IsPrimary = true
}
},
AuthFactors = password is not null
? new List<AccountAuthFactor>
{
new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Secret = password,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
}.HashSecret()
}
: [],
Profile = new Profile()
};
if (isActivated)
{
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
if (defaultGroup is not null)
{
db.PermissionGroupMembers.Add(new PermissionGroupMember
{
Actor = $"user:{account.Id}",
Group = defaultGroup
});
}
}
else
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountActivation,
new Dictionary<string, object>
{
{ "contact_method", account.Contacts.First().Content }
}
);
await spells.NotifyMagicSpell(spell, true);
}
db.Accounts.Add(account);
await db.SaveChangesAsync();
await transaction.CommitAsync();
return account;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task<Account> CreateAccount(OidcUserInfo userInfo)
{
if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation");
var displayName = !string.IsNullOrEmpty(userInfo.DisplayName)
? userInfo.DisplayName
: $"{userInfo.FirstName} {userInfo.LastName}".Trim();
// Generate username from email
var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
return await CreateAccount(
username,
displayName,
userInfo.Email,
null,
"en-US",
userInfo.EmailVerified,
userInfo.EmailVerified
);
}
public async Task RequestAccountDeletion(Account account) public async Task RequestAccountDeletion(Account account)
{ {
var spell = await spells.CreateMagicSpell( var spell = await spells.CreateMagicSpell(
@ -265,7 +376,7 @@ public class AccountService(
return; return;
} }
await email.SendTemplatedEmailAsync<VerificationEmail, VerificationEmailModel>( await mailer.SendTemplatedEmailAsync<VerificationEmail, VerificationEmailModel>(
account.Nick, account.Nick,
contact.Content, contact.Content,
localizer["VerificationEmail"], localizer["VerificationEmail"],

View File

@ -8,7 +8,7 @@ namespace DysonNetwork.Sphere.Account;
/// </summary> /// </summary>
public class AccountUsernameService(AppDatabase db) public class AccountUsernameService(AppDatabase db)
{ {
private readonly Random _random = new Random(); private readonly Random _random = new();
/// <summary> /// <summary>
/// Generates a unique username based on the provided base name /// Generates a unique username based on the provided base name
@ -49,31 +49,6 @@ public class AccountUsernameService(AppDatabase db)
return $"{sanitized}{timestamp}"; 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> /// <summary>
/// Sanitizes a username by removing invalid characters and converting to lowercase /// Sanitizes a username by removing invalid characters and converting to lowercase
/// </summary> /// </summary>
@ -127,26 +102,4 @@ public class AccountUsernameService(AppDatabase db)
// Use the local part as the base for username generation // Use the local part as the base for username generation
return await GenerateUniqueUsernameAsync(localPart); 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

@ -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); return ValidateAndExtractIdToken(idToken, validationParameters);
} }
protected override Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config, protected override Dictionary<string, string> BuildTokenRequestParameters(
string? codeVerifier) string code,
ProviderConfiguration config,
string? codeVerifier
)
{ {
var parameters = new Dictionary<string, string> var parameters = new Dictionary<string, string>
{ {

View File

@ -1,10 +1,9 @@
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary> /// <summary>
/// This controller is designed to handle the OAuth callback. /// This controller is designed to handle the OAuth callback.
@ -69,43 +68,8 @@ public class AuthCallbackController(
var account = await accounts.LookupAccount(userInfo.Email); var account = await accounts.LookupAccount(userInfo.Email);
if (account == null) if (account == null)
{ {
// Generate username and display name from email // Create a new account using the AccountService
var username = await accountUsernameService.GenerateUsernameFromEmailAsync(userInfo.Email); account = await accounts.CreateAccount(userInfo);
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 // Create a session for the user

View File

@ -1,5 +1,8 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Sphere.Auth.OpenId;
@ -8,7 +11,7 @@ namespace DysonNetwork.Sphere.Auth.OpenId;
public class OidcController( public class OidcController(
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
AppDatabase db, AppDatabase db,
Account.AccountService accountService, AccountService accounts,
AuthService authService AuthService authService
) )
: ControllerBase : 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) private OidcService GetOidcService(string provider)
{ {
return provider.ToLower() switch return provider.ToLower() switch
@ -45,4 +93,51 @@ public class OidcController(
_ => throw new ArgumentException($"Unsupported provider: {provider}") _ => 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"] ?? "", ClientId = configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
ClientSecret = configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "", 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 ?? "", ProvidedIdentifier = userInfo.UserId ?? "",
AccessToken = userInfo.AccessToken, AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken, RefreshToken = userInfo.RefreshToken,
LastUsedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(), LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
AccountId = account.Id AccountId = account.Id
}; };
await db.AccountConnections.AddAsync(connection); await db.AccountConnections.AddAsync(connection);
@ -190,6 +190,7 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto
ExpiredAt = now.Plus(Duration.FromHours(1)), ExpiredAt = now.Plus(Duration.FromHours(1)),
StepTotal = 1, StepTotal = 1,
StepRemain = 0, // Already verified by provider StepRemain = 0, // Already verified by provider
Type = ChallengeType.Oidc,
Platform = ChallengePlatform.Unidentified, Platform = ChallengePlatform.Unidentified,
Audiences = [ProviderName], Audiences = [ProviderName],
Scopes = ["*"], Scopes = ["*"],
@ -202,8 +203,8 @@ public abstract class OidcService(IConfiguration configuration, IHttpClientFacto
var session = new Session var session = new Session
{ {
LastGrantedAt = now, LastGrantedAt = now,
Account = account, AccountId = account.Id,
Challenge = challenge, ChallengeId = challenge.Id,
}; };
await db.AuthSessions.AddAsync(session); await db.AuthSessions.AddAsync(session);

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddAccountConnection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "account_connections",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
provider = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
provided_identifier = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
access_token = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
refresh_token = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
last_used_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_account_connections", x => x.id);
table.ForeignKey(
name: "fk_account_connections_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_account_connections_account_id",
table: "account_connections",
column: "account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "account_connections");
}
}
}

View File

@ -144,6 +144,64 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("account_auth_factors", (string)null); b.ToTable("account_auth_factors", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountConnection", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("AccessToken")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("access_token");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("LastUsedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_used_at");
b.Property<string>("ProvidedIdentifier")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("provided_identifier");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("provider");
b.Property<string>("RefreshToken")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("refresh_token");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_account_connections");
b.HasIndex("AccountId")
.HasDatabaseName("ix_account_connections_account_id");
b.ToTable("account_connections", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -2788,6 +2846,18 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Account"); b.Navigation("Account");
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountConnection", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany("Connections")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_account_connections_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
@ -3479,6 +3549,8 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Challenges"); b.Navigation("Challenges");
b.Navigation("Connections");
b.Navigation("Contacts"); b.Navigation("Contacts");
b.Navigation("IncomingRelationships"); b.Navigation("IncomingRelationships");

View File

@ -87,15 +87,13 @@
"Oidc": { "Oidc": {
"Google": { "Google": {
"ClientId": "YOUR_GOOGLE_CLIENT_ID", "ClientId": "YOUR_GOOGLE_CLIENT_ID",
"ClientSecret": "YOUR_GOOGLE_CLIENT_SECRET", "ClientSecret": "YOUR_GOOGLE_CLIENT_SECRET"
"RedirectUri": "https://your-app.com/auth/callback/google"
}, },
"Apple": { "Apple": {
"ClientId": "YOUR_APPLE_CLIENT_ID", "ClientId": "dev.solsynth.solian",
"TeamId": "YOUR_APPLE_TEAM_ID", "TeamId": "W7HPZ53V6B",
"KeyId": "YOUR_APPLE_KEY_ID", "KeyId": "B668YP4KBG",
"PrivateKeyPath": "./apple_auth_key.p8", "PrivateKeyPath": "./Keys/Solarpass.p8"
"RedirectUri": "https://your-app.com/auth/callback/apple"
} }
} }
} }