Initial commit

This commit is contained in:
2026-06-02 10:51:14 +02:00
committed by kawa
commit d19092c84e
182 changed files with 13757 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace SharepointToolbox.Web.Services.Auth;
public class AppRegistrationService : IAppRegistrationService
{
private const string GraphAppId = "00000003-0000-0000-c000-000000000000";
private const string SharePointAppId = "00000003-0000-0ff1-ce00-000000000000";
// Graph delegated scopes to request + consent
private static readonly string[] GraphScopes =
[
"User.Read", // signed-in user basic profile
"User.Read.All", // look up users by email/UPN (GraphUserDirectoryService, BulkMemberService)
"Group.ReadWrite.All", // read group members + add members/owners (BulkMemberService, SharePointGroupResolver)
"Sites.Read.All", // resolve site groupId from siteId (BulkMemberService)
];
// SharePoint delegated scopes to request + consent
private static readonly string[] SpScopes =
[
"AllSites.FullControl", // CSOM — site permissions, content, admin operations
];
private readonly HttpClient _http;
public AppRegistrationService(HttpClient http) { _http = http; }
public async Task<string> CreateAsync(
string adminAccessToken,
string tenantName,
string redirectUri,
CancellationToken ct = default)
{
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", adminAccessToken);
// 1. Resolve Graph + SharePoint service principals in the target tenant
var (graphSpId, graphPermIds) = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, ct);
var (spSpId, spPermIds) = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, ct);
// 2. Create app registration
var appBody = new
{
displayName = $"SP Toolbox — {tenantName}",
signInAudience = "AzureADMyOrg",
isFallbackPublicClient = true,
web = new { redirectUris = new[] { redirectUri } },
requiredResourceAccess = new[]
{
new
{
resourceAppId = GraphAppId,
resourceAccess = graphPermIds.Select(id => new { id, type = "Scope" }).ToArray(),
},
new
{
resourceAppId = SharePointAppId,
resourceAccess = spPermIds.Select(id => new { id, type = "Scope" }).ToArray(),
},
},
};
var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications",
appBody, ct);
var clientId = appJson.GetProperty("appId").GetString()!;
// 3. Create service principal for the new app
var spJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/servicePrincipals",
new { appId = clientId }, ct);
var newSpId = spJson.GetProperty("id").GetString()!;
// 4. Grant org-wide admin consent for Graph
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = newSpId,
consentType = "AllPrincipals",
resourceId = graphSpId,
scope = string.Join(" ", GraphScopes),
}, ct);
// 5. Grant org-wide admin consent for SharePoint
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = newSpId,
consentType = "AllPrincipals",
resourceId = spSpId,
scope = string.Join(" ", SpScopes),
}, ct);
return clientId;
}
// Returns (servicePrincipalObjectId, [permissionIds matching requested scopes])
private async Task<(string SpObjectId, string[] PermissionIds)> ResolveServicePrincipalAsync(
string appId, string[] scopeNames, CancellationToken ct)
{
var url = $"https://graph.microsoft.com/v1.0/servicePrincipals" +
$"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes";
var resp = await _http.GetAsync(url, ct);
var json = await resp.Content.ReadAsStringAsync(ct);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(json);
var values = doc.RootElement.GetProperty("value");
var sp = values.EnumerateArray().First();
var spId = sp.GetProperty("id").GetString()!;
var allScopes = sp.GetProperty("oauth2PermissionScopes");
var ids = new List<string>();
foreach (var scope in allScopes.EnumerateArray())
{
var value = scope.GetProperty("value").GetString();
if (scopeNames.Contains(value, StringComparer.OrdinalIgnoreCase))
ids.Add(scope.GetProperty("id").GetString()!);
}
return (spId, ids.ToArray());
}
private async Task<JsonElement> PostGraphAsync(string url, object body, CancellationToken ct)
{
var content = new StringContent(
JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }),
Encoding.UTF8,
"application/json");
var resp = await _http.PostAsync(url, content, ct);
var json = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException(
$"Graph API error {resp.StatusCode} calling {url}: {json}");
return JsonDocument.Parse(json).RootElement.Clone();
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace SharepointToolbox.Web.Services.Auth;
public interface IAppRegistrationService
{
/// <summary>
/// Creates an Entra ID app registration in the target tenant using a delegated admin token
/// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All scope).
/// Grants org-wide admin consent for SharePoint + Graph delegated permissions.
/// Returns the new app's client ID (appId).
/// </summary>
Task<string> CreateAsync(
string adminAccessToken,
string tenantName,
string redirectUri,
CancellationToken ct = default);
}
+17
View File
@@ -0,0 +1,17 @@
namespace SharepointToolbox.Web.Services.Auth;
public interface ITokenRefreshService
{
/// <summary>
/// Exchanges a refresh token for a new access token using the public-client flow (no secret).
/// ClientId is per-tenant (from TenantProfile) — no global secret required.
/// </summary>
Task<TokenRefreshResult> RefreshAsync(string refreshToken, string tenantId, string clientId, string scope);
}
public class TokenRefreshResult
{
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
using System.Security.Claims;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Auth;
public interface IUserService
{
/// <summary>Auto-provision on first OIDC login; update LastLogin on subsequent logins.
/// First user ever becomes Admin automatically.</summary>
Task<AppUser> ProvisionAsync(ClaimsPrincipal principal);
Task<AppUser?> GetByEmailAsync(string email);
Task<IReadOnlyList<AppUser>> GetAllAsync();
Task UpdateRoleAsync(string userId, UserRole role);
Task DeleteAsync(string userId);
}
+42
View File
@@ -0,0 +1,42 @@
using System.Text.Json;
namespace SharepointToolbox.Web.Services.Auth;
public class TokenRefreshService : ITokenRefreshService
{
private readonly HttpClient _http;
public TokenRefreshService(HttpClient http) { _http = http; }
public async Task<TokenRefreshResult> RefreshAsync(
string refreshToken, string tenantId, string clientId, string scope)
{
var body = new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["client_id"] = clientId,
["refresh_token"] = refreshToken,
["scope"] = scope,
};
var url = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
var resp = await _http.PostAsync(url, new FormUrlEncodedContent(body));
var json = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Token refresh failed ({resp.StatusCode}): {json}");
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var expiresIn = root.GetProperty("expires_in").GetInt32();
return new TokenRefreshResult
{
AccessToken = root.GetProperty("access_token").GetString()!,
RefreshToken = root.TryGetProperty("refresh_token", out var rt)
? rt.GetString()!
: refreshToken,
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresIn - 30),
};
}
}
+62
View File
@@ -0,0 +1,62 @@
using System.Security.Claims;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
namespace SharepointToolbox.Web.Services.Auth;
public class UserService : IUserService
{
private readonly UserRepository _repo;
public UserService(UserRepository repo) { _repo = repo; }
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
{
var email = principal.FindFirstValue(ClaimTypes.Email)
?? principal.FindFirstValue("preferred_username")
?? throw new InvalidOperationException("OIDC token has no email claim.");
var display = principal.FindFirstValue("name")
?? principal.FindFirstValue(ClaimTypes.Name)
?? email;
var existing = await _repo.FindByEmailAsync(email);
if (existing is not null)
{
existing.LastLogin = DateTimeOffset.UtcNow;
existing.DisplayName = display;
await _repo.UpsertAsync(existing);
return existing;
}
// First user ever → Admin; subsequent → TechN0
var all = await _repo.LoadAsync();
var role = all.Count == 0 ? UserRole.Admin : UserRole.TechN0;
var user = new AppUser
{
Email = email,
DisplayName = display,
Role = role,
CreatedAt = DateTimeOffset.UtcNow,
LastLogin = DateTimeOffset.UtcNow
};
await _repo.UpsertAsync(user);
return user;
}
public Task<AppUser?> GetByEmailAsync(string email) => _repo.FindByEmailAsync(email);
public Task<IReadOnlyList<AppUser>> GetAllAsync() => _repo.LoadAsync();
public async Task UpdateRoleAsync(string userId, UserRole role)
{
var users = (await _repo.LoadAsync()).ToList();
var user = users.FirstOrDefault(u => u.Id == userId)
?? throw new KeyNotFoundException($"User {userId} not found.");
user.Role = role;
await _repo.UpsertAsync(user);
}
public Task DeleteAsync(string userId) => _repo.DeleteAsync(userId);
}