Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .claude/rules/security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Security Rules

## No hardcoded secrets — ever

Client IDs, client secrets, API keys, connection strings, and any other credential **must never appear in source code or committed files**. This includes placeholder GUIDs such as `00000000-0000-0000-0000-000000000000` — a placeholder is still a hardcoded value and must not be committed.

### Configuration pattern for this project

Secrets are loaded via `IConfiguration` in `App.axaml.cs` at startup. The chain is:

1. `appsettings.json` — committed to source control; contains only non-secret defaults and placeholder values
2. User secrets (`secrets.json`) — machine-local, never committed; overrides `appsettings.json` at development time

```csharp
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddUserSecrets<App>()
.Build();
```

Read secrets from configuration, never from a literal:

```csharp
// ❌ banned
.Create("a1b2c3d4-real-client-id")

// ✅ correct
var clientId = configuration["MicrosoftIdentity:ClientId"]
?? throw new InvalidOperationException("MicrosoftIdentity:ClientId is not configured.");
.Create(clientId)
```

### Setting a real value locally

```bash
dotnet user-secrets set "MicrosoftIdentity:ClientId" "<your-real-client-id>" \
--project src/AStar.Dev.CloudSyncFunctional
```

User secrets are stored at `~/.microsoft/usersecrets/astar-dev-cloudsync-functional/secrets.json` and are never committed to git.

### Configuration keys

| Key | Where set | Purpose |
|---|---|---|
| `MicrosoftIdentity:ClientId` | User secrets | Entra App Registration client ID |

### What is allowed in `appsettings.json`

- Non-secret configuration (authority URL, redirect URI, log level)
- Placeholder values (`"00000000-0000-0000-0000-000000000000"`) that make the shape of config obvious — **as long as the real value is never committed**

### Code review checklist

Before approving any PR that touches DI registration or configuration:

- [ ] No real GUIDs, tokens, or keys in any `.cs`, `.json`, or `.axaml` file
- [ ] Any new secret has a corresponding `configuration["Key"]` read with a `?? throw`
- [ ] `appsettings.json` contains only placeholder or non-secret values
- [ ] New configuration keys are documented in the table above
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Ten rules files in `.claude/rules/` cover the implementation patterns for this p
| DI lifetime guidelines, AppDbContext factory, unhandled exceptions | `@.claude/rules/onedrive-di.md` |
| Account onboarding — wizard completion, default sync path, sync rule seeding | `@.claude/rules/onedrive-onboarding.md` |
| In-app log viewer — Serilog sink, ring buffer, PII scrubbing, reactive stream | `@.claude/rules/onedrive-logviewer.md` |
| **Secrets and configuration — no hardcoded credentials, appsettings + user secrets pattern** | `@.claude/rules/security.md` |

## Code Exploration

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<NoWarn>NU1903</NoWarn>
<UserSecretsId>astar-dev-cloudsync-functional</UserSecretsId>
</PropertyGroup>

<ItemGroup>
Expand All @@ -13,14 +16,35 @@
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.3" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.3" />
<PackageReference Include="Lucide.Avalonia" Version="0.2.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.1" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Identity.Client" Version="4.67.2" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.67.2" />
<PackageReference Include="Microsoft.Graph" Version="5.77.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
</ItemGroup>

<ItemGroup>
<AvaloniaResource Include="Assets/Fonts/**" />
</ItemGroup>

<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../AStar.Dev.FunctionalParadigm/AStar.Dev.FunctionalParadigm.csproj" />
</ItemGroup>

</Project>
58 changes: 50 additions & 8 deletions src/AStar.Dev.CloudSyncFunctional/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,65 @@
using AStar.Dev.CloudSyncFunctional.Auth;
using AStar.Dev.CloudSyncFunctional.Graph;
using AStar.Dev.CloudSyncFunctional.Onboarding;
using AStar.Dev.CloudSyncFunctional.Wizard;
using AStar.Dev.CloudSyncFunctional.Workspace;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using MELogLevel = Microsoft.Extensions.Logging.LogLevel;

namespace AStar.Dev.CloudSyncFunctional;

/// <summary>Application entry point and DI composition root.</summary>
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
private IServiceProvider? _serviceProvider;

/// <inheritdoc/>
public override void Initialize() => AvaloniaXamlLoader.Load(this);

/// <inheritdoc/>
public override void OnFrameworkInitializationCompleted()
{
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddUserSecrets<App>()
.Build();

var services = new ServiceCollection();
ConfigureServices(services, configuration);
_serviceProvider = services.BuildServiceProvider();

if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
desktop.MainWindow = new MainWindow(_serviceProvider.GetRequiredService<WorkspaceViewModel>());

base.OnFrameworkInitializationCompleted();
}
}

private static void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddLogging(builder => builder.AddConsole().SetMinimumLevel(MELogLevel.Debug));

var clientId = configuration["MicrosoftIdentity:ClientId"]
?? throw new InvalidOperationException("MicrosoftIdentity:ClientId is not configured. Set it in appsettings.json or user secrets.");

services.AddSingleton<IPublicClientApplication>(_ =>
PublicClientApplicationBuilder
.Create(clientId)
.WithAuthority("https://login.microsoftonline.com/consumers")
.WithRedirectUri("http://localhost")
.Build());

services.AddSingleton<ITokenCacheService, TokenCacheService>();
services.AddSingleton<IAuthService, AuthService>();
services.AddSingleton<IGraphClientFactory, GraphClientFactory>();
services.AddSingleton<IGraphService, GraphService>();
services.AddSingleton<IAccountOnboardingService, AccountOnboardingService>();
services.AddTransient<AddAccountWizardViewModel>();
services.AddTransient<WorkspaceViewModel>();
}
}
6 changes: 6 additions & 0 deletions src/AStar.Dev.CloudSyncFunctional/Auth/AccountProfile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace AStar.Dev.CloudSyncFunctional.Auth;

/// <summary>Profile information extracted from a successful authentication result.</summary>
/// <param name="DisplayName">The user's display name.</param>
/// <param name="Email">The user's email address.</param>
public sealed record AccountProfile(string DisplayName, string Email);
40 changes: 40 additions & 0 deletions src/AStar.Dev.CloudSyncFunctional/Auth/AuthError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace AStar.Dev.CloudSyncFunctional.Auth;

/// <summary>Base type for authentication errors.</summary>
public abstract record AuthError
{
/// <summary>Gets the human-readable error message.</summary>
public abstract string Message { get; }
}

/// <summary>Represents a cancelled authentication attempt.</summary>
public sealed record AuthCancelledError : AuthError
{
/// <inheritdoc/>
public override string Message => "Authentication was cancelled.";
}

/// <summary>Represents a failed authentication attempt with a specific reason.</summary>
public sealed record AuthFailedError : AuthError
{
/// <inheritdoc/>
public override string Message { get; }

/// <summary>Initialises a new <see cref="AuthFailedError"/> with the given message.</summary>
/// <param name="message">The error message describing the failure.</param>
public AuthFailedError(string message) => Message = message;
}

/// <summary>Creates <see cref="AuthError"/> instances.</summary>
public static class AuthErrorFactory
{
/// <summary>Creates an <see cref="AuthCancelledError"/>.</summary>
/// <returns>An error representing a cancelled authentication.</returns>
public static AuthError Cancelled() => new AuthCancelledError();

/// <summary>Creates an <see cref="AuthFailedError"/> with the given message.</summary>
/// <param name="message">The error message; falls back to a default if null or whitespace.</param>
/// <returns>An error representing a failed authentication.</returns>
public static AuthError Failed(string? message) => new AuthFailedError(
string.IsNullOrWhiteSpace(message) ? "Authentication failed: unknown error." : message);
}
29 changes: 29 additions & 0 deletions src/AStar.Dev.CloudSyncFunctional/Auth/AuthResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace AStar.Dev.CloudSyncFunctional.Auth;

/// <summary>The result of a successful authentication with Microsoft.</summary>
/// <param name="AccessToken">The OAuth2 access token for Graph API calls.</param>
/// <param name="AccountId">The MSAL HomeAccountId identifier.</param>
/// <param name="Profile">The authenticated user's profile.</param>
/// <param name="ExpiresOn">When the access token expires.</param>
public sealed record AuthResult(string AccessToken, string AccountId, AccountProfile Profile, DateTimeOffset ExpiresOn);

/// <summary>Creates <see cref="AuthResult"/> instances with validated inputs.</summary>
public static class AuthResultFactory
{
/// <summary>Creates a validated <see cref="AuthResult"/>.</summary>
/// <param name="accessToken">The OAuth2 access token.</param>
/// <param name="accountId">The MSAL account identifier.</param>
/// <param name="profile">The user profile.</param>
/// <param name="expiresOn">The token expiry time.</param>
/// <returns>A new <see cref="AuthResult"/>.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="accessToken"/> or <paramref name="accountId"/> is null or whitespace.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="profile"/> is null.</exception>
public static AuthResult Create(string accessToken, string accountId, AccountProfile profile, DateTimeOffset expiresOn)
{
ArgumentException.ThrowIfNullOrWhiteSpace(accessToken);
ArgumentException.ThrowIfNullOrWhiteSpace(accountId);
ArgumentNullException.ThrowIfNull(profile);

return new AuthResult(accessToken, accountId, profile, expiresOn);
}
}
105 changes: 105 additions & 0 deletions src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using AStar.Dev.FunctionalParadigm;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using MELogLevel = Microsoft.Extensions.Logging.LogLevel;

namespace AStar.Dev.CloudSyncFunctional.Auth;

/// <inheritdoc />
public sealed partial class AuthService(IPublicClientApplication app, ILogger<AuthService> logger) : IAuthService
{
private static readonly string[] Scopes = ["Files.ReadWrite", "offline_access", "User.Read"];

/// <inheritdoc />
public async Task<Result<AuthResult, AuthError>> SignInInteractiveAsync(CancellationToken ct = default)
{
try
{
var msalResult = await app
.AcquireTokenInteractive(Scopes)
.WithPrompt(Prompt.SelectAccount)
.WithUseEmbeddedWebView(false)
.ExecuteAsync(ct)
.ConfigureAwait(false);

return new Ok<AuthResult, AuthError>(BuildAuthResult(msalResult));
}
catch (MsalClientException ex) when (ex.ErrorCode is "authentication_canceled" or "user_canceled")
{
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Cancelled());
}
catch (OperationCanceledException)
{
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Cancelled());
}
catch (MsalException ex)
{
LogAuthFailed(logger, ex.Message);
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Failed(ex.Message));
}
catch (Exception ex)
{
LogAuthFailed(logger, ex.Message);
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Failed(ex.Message));
}
}

/// <inheritdoc />
public async Task<Result<AuthResult, AuthError>> AcquireTokenSilentAsync(string accountId, CancellationToken ct = default)
{
try
{
var accounts = await app.GetAccountsAsync().ConfigureAwait(false);
var account = accounts.FirstOrDefault(a => a.HomeAccountId?.Identifier == accountId);
if (account is null)
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Failed("Account not found in token cache."));

var msalResult = await app.AcquireTokenSilent(Scopes, account).ExecuteAsync(ct).ConfigureAwait(false);

return new Ok<AuthResult, AuthError>(BuildAuthResult(msalResult));
}
catch (MsalUiRequiredException)
{
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Failed("Re-authentication required."));
}
catch (Exception ex)
{
LogAuthFailed(logger, ex.Message);
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Failed(ex.Message));
}
}

/// <inheritdoc />
public async Task SignOutAsync(string accountId, CancellationToken ct = default)
{
var accounts = await app.GetAccountsAsync().ConfigureAwait(false);
var account = accounts.FirstOrDefault(a => a.HomeAccountId?.Identifier == accountId);
if (account is not null)
await app.RemoveAsync(account).ConfigureAwait(false);
}

/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetCachedAccountIdsAsync()
{
var accounts = await app.GetAccountsAsync().ConfigureAwait(false);

return accounts.Select(a => a.HomeAccountId.Identifier).ToList();
}

private static AuthResult BuildAuthResult(AuthenticationResult result)
{
var displayName = result.ClaimsPrincipal?.FindFirst("name")?.Value ?? result.Account.Username;
var email = result.ClaimsPrincipal?.FindFirst("preferred_username")?.Value
?? result.ClaimsPrincipal?.FindFirst("email")?.Value
?? result.Account.Username;

return AuthResultFactory.Create(
result.AccessToken,
result.Account.HomeAccountId.Identifier,
new AccountProfile(displayName, email),
result.ExpiresOn);
}

[LoggerMessage(Level = MELogLevel.Error, Message = "Authentication failed: {ErrorMessage}")]
private static partial void LogAuthFailed(ILogger logger, string errorMessage);
}
Loading
Loading