Initial commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Graph;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
using SharepointToolbox.Web.Services;
|
||||
using SharepointToolbox.Web.Services.Session;
|
||||
|
||||
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||
|
||||
/// <summary>Delegated Graph client using OAuth2 refresh-token flow via ISessionManager.</summary>
|
||||
public class GraphClientFactory
|
||||
{
|
||||
private readonly ISessionCredentialStore _credentialStore;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
|
||||
public GraphClientFactory(ISessionCredentialStore credentialStore, ISessionManager sessionManager)
|
||||
{
|
||||
_credentialStore = credentialStore;
|
||||
_sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
public async Task<GraphServiceClient> CreateClientAsync(TenantProfile profile)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(profile.TenantId);
|
||||
|
||||
var hasTokens = await _credentialStore.HasCredentialsAsync();
|
||||
if (!hasTokens)
|
||||
throw new InvalidOperationException(
|
||||
"No session tokens found. Please authenticate via Microsoft first.");
|
||||
|
||||
var credential = new SessionTokenCredential(_sessionManager);
|
||||
return new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Microsoft.SharePoint.Client;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
using SharepointToolbox.Web.Services;
|
||||
using SharepointToolbox.Web.Services.Auth;
|
||||
using SharepointToolbox.Web.Services.Session;
|
||||
|
||||
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Delegated session manager using OAuth2 refresh tokens.
|
||||
/// Tokens come from ISessionCredentialStore (ProtectedSessionStorage — browser-side only).
|
||||
/// Caches access tokens in-memory per scope for the duration of the Blazor circuit.
|
||||
/// Scoped per Blazor circuit.
|
||||
/// </summary>
|
||||
public class SessionManager : ISessionManager
|
||||
{
|
||||
private readonly ISessionCredentialStore _credentialStore;
|
||||
private readonly ITokenRefreshService _tokenRefresh;
|
||||
private readonly Dictionary<string, ClientContext> _contexts = new();
|
||||
private readonly Dictionary<string, (string Token, DateTimeOffset ExpiresAt)> _accessTokenCache = new();
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public SessionManager(ISessionCredentialStore credentialStore, ITokenRefreshService tokenRefresh)
|
||||
{
|
||||
_credentialStore = credentialStore;
|
||||
_tokenRefresh = tokenRefresh;
|
||||
}
|
||||
|
||||
public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl));
|
||||
|
||||
public async Task<(string Token, DateTimeOffset ExpiresAt)> GetAccessTokenWithExpiryAsync(
|
||||
string scope, CancellationToken ct = default)
|
||||
{
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_accessTokenCache.TryGetValue(scope, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
return cached;
|
||||
|
||||
var tokens = await _credentialStore.GetAsync()
|
||||
?? throw new InvalidOperationException(
|
||||
"No session tokens found. Please authenticate via Microsoft first.");
|
||||
|
||||
var result = await _tokenRefresh.RefreshAsync(tokens.RefreshToken, tokens.TenantId, tokens.ClientId, scope);
|
||||
|
||||
// Persist rotated refresh token back to browser storage
|
||||
if (result.RefreshToken != tokens.RefreshToken)
|
||||
await _credentialStore.UpdateRefreshTokenAsync(result.RefreshToken);
|
||||
|
||||
var entry = (result.AccessToken, result.ExpiresAt);
|
||||
_accessTokenCache[scope] = entry;
|
||||
return entry;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
public async Task<ClientContext> GetOrCreateContextAsync(TenantProfile profile, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
|
||||
ArgumentException.ThrowIfNullOrEmpty(profile.TenantId);
|
||||
|
||||
var key = NormalizeUrl(profile.TenantUrl);
|
||||
var spScope = NormalizeScopeUrl(profile.TenantUrl) + "/.default";
|
||||
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_contexts.TryGetValue(key, out var existing))
|
||||
return existing;
|
||||
|
||||
// Validate tokens are present before creating context
|
||||
var tokens = await _credentialStore.GetAsync()
|
||||
?? throw new InvalidOperationException(
|
||||
"No session tokens found. Please authenticate via Microsoft first.");
|
||||
|
||||
_ = tokens; // validated; actual token acquired per-request below
|
||||
|
||||
var ctx = new ClientContext(profile.TenantUrl);
|
||||
ctx.ExecutingWebRequest += async (_, e) =>
|
||||
{
|
||||
var (token, _) = await GetAccessTokenWithExpiryAsyncInternal(spScope, tokens.TenantId);
|
||||
e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + token;
|
||||
};
|
||||
|
||||
_contexts[key] = ctx;
|
||||
return ctx;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
public async Task<ClientContext> GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default)
|
||||
{
|
||||
var profileForSite = new TenantProfile
|
||||
{
|
||||
Id = profile.Id,
|
||||
Name = profile.Name,
|
||||
TenantUrl = siteUrl,
|
||||
TenantId = profile.TenantId,
|
||||
ClientId = profile.ClientId,
|
||||
ClientLogo = profile.ClientLogo,
|
||||
};
|
||||
return await GetOrCreateContextAsync(profileForSite, ct);
|
||||
}
|
||||
|
||||
public async Task ClearSessionAsync(string tenantUrl)
|
||||
{
|
||||
var key = NormalizeUrl(tenantUrl);
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_contexts.TryGetValue(key, out var ctx))
|
||||
{
|
||||
ctx.Dispose();
|
||||
_contexts.Remove(key);
|
||||
}
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
public async Task ClearAllAsync()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
foreach (var ctx in _contexts.Values) ctx.Dispose();
|
||||
_contexts.Clear();
|
||||
_accessTokenCache.Clear();
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
// Internal version that bypasses the outer lock (called from ExecutingWebRequest which may run concurrently)
|
||||
private async Task<(string Token, DateTimeOffset ExpiresAt)> GetAccessTokenWithExpiryAsyncInternal(
|
||||
string scope, string tenantId)
|
||||
{
|
||||
if (_accessTokenCache.TryGetValue(scope, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
return cached;
|
||||
|
||||
var tokens = await _credentialStore.GetAsync()
|
||||
?? throw new InvalidOperationException("No session tokens in store.");
|
||||
|
||||
var result = await _tokenRefresh.RefreshAsync(tokens.RefreshToken, tenantId, tokens.ClientId, scope);
|
||||
|
||||
if (result.RefreshToken != tokens.RefreshToken)
|
||||
await _credentialStore.UpdateRefreshTokenAsync(result.RefreshToken);
|
||||
|
||||
var entry = (result.AccessToken, result.ExpiresAt);
|
||||
_accessTokenCache[scope] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static string NormalizeUrl(string url) => url.TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static string NormalizeScopeUrl(string siteUrl)
|
||||
{
|
||||
if (Uri.TryCreate(siteUrl, UriKind.Absolute, out var uri))
|
||||
return $"{uri.Scheme}://{uri.Host}";
|
||||
return siteUrl.TrimEnd('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Azure.Core;
|
||||
using SharepointToolbox.Web.Services;
|
||||
|
||||
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// TokenCredential backed by the circuit's ISessionManager.
|
||||
/// Lets GraphServiceClient call GetTokenAsync transparently using our refresh-token flow.
|
||||
/// </summary>
|
||||
public class SessionTokenCredential : TokenCredential
|
||||
{
|
||||
private readonly ISessionManager _sessionManager;
|
||||
|
||||
public SessionTokenCredential(ISessionManager sessionManager) { _sessionManager = sessionManager; }
|
||||
|
||||
public override async ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken ct)
|
||||
{
|
||||
var scope = requestContext.Scopes.FirstOrDefault() ?? "https://graph.microsoft.com/.default";
|
||||
var (token, expiresAt) = await _sessionManager.GetAccessTokenWithExpiryAsync(scope, ct);
|
||||
return new AccessToken(token, expiresAt);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken ct)
|
||||
=> GetTokenAsync(requestContext, ct).AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SharepointToolbox.Web.Core.Config;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||
using SharepointToolbox.Web.Services.Auth;
|
||||
using SharepointToolbox.Web.Services.OAuth;
|
||||
|
||||
namespace SharepointToolbox.Web.Infrastructure.OAuth;
|
||||
|
||||
public static class OAuthEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapOAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// ── Connect: initiate PKCE flow for client tenant access ──────────────────
|
||||
app.MapGet("/connect/initiate", async (
|
||||
HttpContext ctx,
|
||||
string profileId,
|
||||
string? returnUrl,
|
||||
ProfileRepository profiles,
|
||||
IOAuthFlowCache flowCache,
|
||||
IOptions<ClientConnectOptions> opts) =>
|
||||
{
|
||||
if (!ctx.User.Identity?.IsAuthenticated ?? true)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var allProfiles = await profiles.LoadAsync();
|
||||
var profile = allProfiles.FirstOrDefault(p => p.Id == profileId);
|
||||
if (profile is null)
|
||||
return Results.NotFound($"Profile '{profileId}' not found.");
|
||||
|
||||
if (string.IsNullOrEmpty(profile.ClientId))
|
||||
return Results.Problem($"Profile '{profile.Name}' has no ClientId configured.");
|
||||
|
||||
var o = opts.Value;
|
||||
if (string.IsNullOrEmpty(o.RedirectUri))
|
||||
return Results.Problem("ClientConnect:RedirectUri is not configured on this server.");
|
||||
|
||||
var (state, authUrl) = BuildAuthUrl(
|
||||
tenantId: profile.TenantId,
|
||||
clientId: profile.ClientId,
|
||||
redirectUri: o.RedirectUri,
|
||||
scope: "openid offline_access",
|
||||
flowCache: flowCache,
|
||||
flowState: new OAuthFlowState
|
||||
{
|
||||
ProfileId = profileId,
|
||||
TenantId = profile.TenantId,
|
||||
ClientId = profile.ClientId,
|
||||
SpHost = ExtractHost(profile.TenantUrl),
|
||||
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl,
|
||||
IsRegistration = false,
|
||||
});
|
||||
|
||||
return Results.Redirect(authUrl);
|
||||
});
|
||||
|
||||
// ── Register: initiate admin auth to create app registration in client tenant
|
||||
app.MapGet("/connect/register-initiate", (
|
||||
HttpContext ctx,
|
||||
string tenantId,
|
||||
string tenantName,
|
||||
string tenantUrl,
|
||||
string? returnUrl,
|
||||
IOAuthFlowCache flowCache,
|
||||
IOptions<ClientConnectOptions> opts,
|
||||
IConfiguration config) =>
|
||||
{
|
||||
if (!ctx.User.Identity?.IsAuthenticated ?? true)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var o = opts.Value;
|
||||
if (string.IsNullOrEmpty(o.RedirectUri))
|
||||
return Results.Problem("ClientConnect:RedirectUri is not configured on this server.");
|
||||
|
||||
// Use our OIDC app (confidential client) to authenticate against the client tenant
|
||||
var oidcClientId = config["Oidc:ClientId"];
|
||||
if (string.IsNullOrEmpty(oidcClientId))
|
||||
return Results.Problem("Oidc:ClientId is not configured.");
|
||||
|
||||
// Need admin consent for Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All
|
||||
var (_, authUrl) = BuildAuthUrl(
|
||||
tenantId: tenantId,
|
||||
clientId: oidcClientId,
|
||||
redirectUri: o.RedirectUri,
|
||||
scope: "https://graph.microsoft.com/Application.ReadWrite.All " +
|
||||
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
||||
"openid offline_access",
|
||||
flowCache: flowCache,
|
||||
flowState: new OAuthFlowState
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TenantName = tenantName,
|
||||
TenantUrl = tenantUrl,
|
||||
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/profiles" : returnUrl,
|
||||
IsRegistration = true,
|
||||
},
|
||||
promptConsent: true);
|
||||
|
||||
return Results.Redirect(authUrl);
|
||||
});
|
||||
|
||||
// ── Shared callback for both connect and register flows ────────────────────
|
||||
app.MapGet("/connect/callback", async (
|
||||
string? code,
|
||||
string? state,
|
||||
string? error,
|
||||
string? error_description,
|
||||
IOAuthFlowCache flowCache,
|
||||
IOptions<ClientConnectOptions> opts,
|
||||
IConfiguration config,
|
||||
IAppRegistrationService appRegService,
|
||||
IHttpClientFactory httpClientFactory) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
var errMsg = Uri.EscapeDataString(error_description ?? error);
|
||||
return Results.Redirect($"/?connect_error={errMsg}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||
return Results.BadRequest("Missing code or state.");
|
||||
|
||||
var flowState = flowCache.GetAndRemoveFlowState(state);
|
||||
if (flowState is null)
|
||||
return Results.BadRequest("Invalid or expired state. Please try connecting again.");
|
||||
|
||||
var o = opts.Value;
|
||||
var http = httpClientFactory.CreateClient("oauth");
|
||||
|
||||
if (flowState.IsRegistration)
|
||||
{
|
||||
// ── Registration flow: confidential client exchange (OIDC app + secret) ──
|
||||
var oidcClientId = config["Oidc:ClientId"]!;
|
||||
var oidcClientSecret = config["Oidc:ClientSecret"]!;
|
||||
|
||||
var body = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "authorization_code",
|
||||
["client_id"] = oidcClientId,
|
||||
["client_secret"] = oidcClientSecret,
|
||||
["code"] = code,
|
||||
["redirect_uri"] = o.RedirectUri,
|
||||
["code_verifier"] = flowState.CodeVerifier,
|
||||
["scope"] = "https://graph.microsoft.com/Application.ReadWrite.All " +
|
||||
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
||||
"openid offline_access",
|
||||
};
|
||||
|
||||
var tokenUrl = $"https://login.microsoftonline.com/{flowState.TenantId}/oauth2/v2.0/token";
|
||||
var resp = await http.PostAsync(tokenUrl, new FormUrlEncodedContent(body));
|
||||
var json = await resp.Content.ReadAsStringAsync();
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var msg = Uri.EscapeDataString($"Admin token exchange failed: {json}");
|
||||
return Results.Redirect($"/profiles?connect_error={msg}");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var accessToken = doc.RootElement.GetProperty("access_token").GetString()!;
|
||||
|
||||
string clientId;
|
||||
try
|
||||
{
|
||||
clientId = await appRegService.CreateAsync(
|
||||
adminAccessToken: accessToken,
|
||||
tenantName: flowState.TenantName,
|
||||
redirectUri: o.RedirectUri);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var msg = Uri.EscapeDataString($"App registration failed: {ex.Message}");
|
||||
return Results.Redirect($"/profiles?connect_error={msg}");
|
||||
}
|
||||
|
||||
var regKey = Guid.NewGuid().ToString("N");
|
||||
flowCache.StoreRegistrationResult(regKey, new AppRegistrationResult
|
||||
{
|
||||
ClientId = clientId,
|
||||
TenantId = flowState.TenantId,
|
||||
TenantUrl = flowState.TenantUrl,
|
||||
TenantName = flowState.TenantName,
|
||||
DisplayName = $"SP Toolbox — {flowState.TenantName}",
|
||||
});
|
||||
|
||||
var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "reg_result_key", regKey);
|
||||
return Results.Redirect(returnTo);
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── Connect flow: public client exchange (profile ClientId, no secret) ──
|
||||
var body = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "authorization_code",
|
||||
["client_id"] = flowState.ClientId,
|
||||
["code"] = code,
|
||||
["redirect_uri"] = o.RedirectUri,
|
||||
["code_verifier"] = flowState.CodeVerifier,
|
||||
["scope"] = "openid offline_access",
|
||||
};
|
||||
|
||||
var tokenUrl = $"https://login.microsoftonline.com/{flowState.TenantId}/oauth2/v2.0/token";
|
||||
var resp = await http.PostAsync(tokenUrl, new FormUrlEncodedContent(body));
|
||||
var json = await resp.Content.ReadAsStringAsync();
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var msg = Uri.EscapeDataString($"Token exchange failed: {json}");
|
||||
return Results.Redirect($"/?connect_error={msg}");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var upn = ExtractUpnFromIdToken(root);
|
||||
var refreshToken = root.GetProperty("refresh_token").GetString()!;
|
||||
|
||||
var tokens = new SessionTokens
|
||||
{
|
||||
RefreshToken = refreshToken,
|
||||
TenantId = flowState.TenantId,
|
||||
ClientId = flowState.ClientId,
|
||||
SpHost = flowState.SpHost,
|
||||
UserPrincipalName = upn,
|
||||
};
|
||||
|
||||
var tokenKey = Guid.NewGuid().ToString("N");
|
||||
flowCache.StoreTokens(tokenKey, tokens);
|
||||
|
||||
var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "token_key", tokenKey);
|
||||
return Results.Redirect(returnTo);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private static (string State, string AuthUrl) BuildAuthUrl(
|
||||
string tenantId,
|
||||
string clientId,
|
||||
string redirectUri,
|
||||
string scope,
|
||||
IOAuthFlowCache flowCache,
|
||||
OAuthFlowState flowState,
|
||||
bool promptConsent = false)
|
||||
{
|
||||
var codeVerifier = GenerateCodeVerifier();
|
||||
var codeChallenge = GenerateCodeChallenge(codeVerifier);
|
||||
var state = Guid.NewGuid().ToString("N");
|
||||
|
||||
flowState.CodeVerifier = codeVerifier;
|
||||
flowCache.StoreFlowState(state, flowState);
|
||||
|
||||
var @params = new Dictionary<string, string?>
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["response_type"] = "code",
|
||||
["redirect_uri"] = redirectUri,
|
||||
["scope"] = scope,
|
||||
["state"] = state,
|
||||
["code_challenge"] = codeChallenge,
|
||||
["code_challenge_method"] = "S256",
|
||||
["prompt"] = promptConsent ? "consent" : "select_account",
|
||||
};
|
||||
|
||||
var authUrl = QueryHelpers.AddQueryString(
|
||||
$"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize",
|
||||
@params);
|
||||
|
||||
return (state, authUrl);
|
||||
}
|
||||
|
||||
private static string GenerateCodeVerifier()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
private static string GenerateCodeChallenge(string codeVerifier)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier));
|
||||
return Base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] bytes) =>
|
||||
Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
private static string ExtractHost(string url)
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return uri.Host;
|
||||
return url.TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string ExtractUpnFromIdToken(JsonElement tokenResponse)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!tokenResponse.TryGetProperty("id_token", out var idTokenEl))
|
||||
return string.Empty;
|
||||
|
||||
var parts = idTokenEl.GetString()!.Split('.');
|
||||
if (parts.Length < 2) return string.Empty;
|
||||
|
||||
var payload = parts[1];
|
||||
var padded = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=')
|
||||
.Replace('-', '+').Replace('_', '/');
|
||||
var bytes = Convert.FromBase64String(padded);
|
||||
using var doc = JsonDocument.Parse(bytes);
|
||||
var root = doc.RootElement;
|
||||
|
||||
foreach (var claim in new[] { "preferred_username", "upn", "email", "unique_name" })
|
||||
if (root.TryGetProperty(claim, out var val) && val.ValueKind == JsonValueKind.String)
|
||||
return val.GetString()!;
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>Append-only JSONL audit log. Each line is one AuditEntry JSON object.</summary>
|
||||
public class AuditRepository
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public AuditRepository(string filePath) { _filePath = filePath; }
|
||||
|
||||
public async Task AppendAsync(AuditEntry entry)
|
||||
{
|
||||
await _writeLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
var line = JsonSerializer.Serialize(entry, _opts) + "\n";
|
||||
await File.AppendAllTextAsync(_filePath, line, Encoding.UTF8);
|
||||
}
|
||||
finally { _writeLock.Release(); }
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntry>> LoadAllAsync()
|
||||
{
|
||||
if (!File.Exists(_filePath)) return Array.Empty<AuditEntry>();
|
||||
var lines = await File.ReadAllLinesAsync(_filePath, Encoding.UTF8);
|
||||
var result = new List<AuditEntry>(lines.Length);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
try
|
||||
{
|
||||
var entry = JsonSerializer.Deserialize<AuditEntry>(line, _opts);
|
||||
if (entry != null) result.Add(entry);
|
||||
}
|
||||
catch { /* skip corrupt lines */ }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||
|
||||
public class ProfileRepository
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
|
||||
public ProfileRepository(string filePath) { _filePath = filePath; }
|
||||
|
||||
public async Task<IReadOnlyList<TenantProfile>> LoadAsync()
|
||||
{
|
||||
if (!File.Exists(_filePath)) return Array.Empty<TenantProfile>();
|
||||
string json;
|
||||
try { json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); }
|
||||
catch (IOException ex) { throw new InvalidDataException($"Failed to read profiles: {_filePath}", ex); }
|
||||
|
||||
ProfilesRoot? root;
|
||||
try
|
||||
{
|
||||
root = JsonSerializer.Deserialize<ProfilesRoot>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in profiles: {_filePath}", ex); }
|
||||
|
||||
return (IReadOnlyList<TenantProfile>?)root?.Profiles ?? Array.Empty<TenantProfile>();
|
||||
}
|
||||
|
||||
public async Task SaveAsync(IReadOnlyList<TenantProfile> profiles)
|
||||
{
|
||||
await _writeLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var root = new ProfilesRoot { Profiles = profiles.ToList() };
|
||||
var json = JsonSerializer.Serialize(root, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
var tmpPath = _filePath + ".tmp";
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
|
||||
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose();
|
||||
File.Move(tmpPath, _filePath, overwrite: true);
|
||||
}
|
||||
finally { _writeLock.Release(); }
|
||||
}
|
||||
|
||||
private sealed class ProfilesRoot { public List<TenantProfile> Profiles { get; set; } = new(); }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||
|
||||
public class SettingsRepository
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
|
||||
public SettingsRepository(string filePath) { _filePath = filePath; }
|
||||
|
||||
public async Task<AppSettings> LoadAsync()
|
||||
{
|
||||
if (!File.Exists(_filePath)) return new AppSettings();
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<AppSettings>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new AppSettings();
|
||||
}
|
||||
catch { return new AppSettings(); }
|
||||
}
|
||||
|
||||
public async Task SaveAsync(AppSettings settings)
|
||||
{
|
||||
await _writeLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
var tmp = _filePath + ".tmp";
|
||||
await File.WriteAllTextAsync(tmp, json, Encoding.UTF8);
|
||||
File.Move(tmp, _filePath, overwrite: true);
|
||||
}
|
||||
finally { _writeLock.Release(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||
|
||||
public class TemplateRepository
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public TemplateRepository(string directory) { _directory = directory; }
|
||||
|
||||
public async Task<IReadOnlyList<SiteTemplate>> GetAllAsync()
|
||||
{
|
||||
if (!Directory.Exists(_directory)) return Array.Empty<SiteTemplate>();
|
||||
var files = Directory.GetFiles(_directory, "*.json");
|
||||
var templates = new List<SiteTemplate>();
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file, Encoding.UTF8);
|
||||
var t = JsonSerializer.Deserialize<SiteTemplate>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (t is not null) templates.Add(t);
|
||||
}
|
||||
catch { /* skip corrupt files */ }
|
||||
}
|
||||
return templates.OrderByDescending(t => t.CapturedAt).ToList();
|
||||
}
|
||||
|
||||
public async Task<SiteTemplate?> GetByIdAsync(string id)
|
||||
{
|
||||
var file = Path.Combine(_directory, $"{id}.json");
|
||||
if (!File.Exists(file)) return null;
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file, Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<SiteTemplate>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public async Task SaveAsync(SiteTemplate template)
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
var json = JsonSerializer.Serialize(template, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
var path = Path.Combine(_directory, $"{template.Id}.json");
|
||||
var tmp = path + ".tmp";
|
||||
await File.WriteAllTextAsync(tmp, json, Encoding.UTF8);
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string id)
|
||||
{
|
||||
var file = Path.Combine(_directory, $"{id}.json");
|
||||
if (File.Exists(file)) File.Delete(file);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task RenameAsync(string id, string newName)
|
||||
{
|
||||
var t = await GetByIdAsync(id);
|
||||
if (t is null) return;
|
||||
t.Name = newName;
|
||||
await SaveAsync(t);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||
|
||||
public class UserRepository
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public UserRepository(string filePath) { _filePath = filePath; }
|
||||
|
||||
public async Task<IReadOnlyList<AppUser>> LoadAsync()
|
||||
{
|
||||
if (!File.Exists(_filePath)) return Array.Empty<AppUser>();
|
||||
var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8);
|
||||
var root = JsonSerializer.Deserialize<UsersRoot>(json, _opts);
|
||||
return (IReadOnlyList<AppUser>?)root?.Users ?? Array.Empty<AppUser>();
|
||||
}
|
||||
|
||||
public async Task<AppUser?> FindByEmailAsync(string email)
|
||||
{
|
||||
var users = await LoadAsync();
|
||||
return users.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task SaveAsync(IReadOnlyList<AppUser> users)
|
||||
{
|
||||
await _writeLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var root = new UsersRoot { Users = users.ToList() };
|
||||
var json = JsonSerializer.Serialize(root, _opts);
|
||||
var tmpPath = _filePath + ".tmp";
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
|
||||
File.Move(tmpPath, _filePath, overwrite: true);
|
||||
}
|
||||
finally { _writeLock.Release(); }
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(AppUser user)
|
||||
{
|
||||
await _writeLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var users = (await LoadInternal()).ToList();
|
||||
var idx = users.FindIndex(u => u.Id == user.Id);
|
||||
if (idx >= 0) users[idx] = user;
|
||||
else users.Add(user);
|
||||
await SaveInternal(users);
|
||||
}
|
||||
finally { _writeLock.Release(); }
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string userId)
|
||||
{
|
||||
await _writeLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var users = (await LoadInternal()).ToList();
|
||||
users.RemoveAll(u => u.Id == userId);
|
||||
await SaveInternal(users);
|
||||
}
|
||||
finally { _writeLock.Release(); }
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AppUser>> LoadInternal()
|
||||
{
|
||||
if (!File.Exists(_filePath)) return Array.Empty<AppUser>();
|
||||
var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8);
|
||||
var root = JsonSerializer.Deserialize<UsersRoot>(json, _opts);
|
||||
return (IReadOnlyList<AppUser>?)root?.Users ?? Array.Empty<AppUser>();
|
||||
}
|
||||
|
||||
private async Task SaveInternal(List<AppUser> users)
|
||||
{
|
||||
var root = new UsersRoot { Users = users };
|
||||
var json = JsonSerializer.Serialize(root, _opts);
|
||||
var tmpPath = _filePath + ".tmp";
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
|
||||
File.Move(tmpPath, _filePath, overwrite: true);
|
||||
}
|
||||
|
||||
private sealed class UsersRoot { public List<AppUser> Users { get; set; } = new(); }
|
||||
}
|
||||
Reference in New Issue
Block a user