Initial commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user