Stabilzie modules
This commit is contained in:
@@ -577,6 +577,11 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string GraphAdmin = "graph:admin";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to analytics data.
|
||||
/// </summary>
|
||||
public const string AnalyticsRead = "analytics.read";
|
||||
|
||||
private static readonly IReadOnlyList<string> AllScopes = BuildAllScopes();
|
||||
private static readonly HashSet<string> KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
@@ -86,6 +86,25 @@ public static class ServiceCollectionExtensions
|
||||
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
|
||||
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
|
||||
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
|
||||
|
||||
// Accept both "Bearer" and "DPoP" authorization schemes.
|
||||
// The StellaOps UI sends DPoP-bound access tokens with "Authorization: DPoP <token>".
|
||||
jwt.Events ??= new JwtBearerEvents();
|
||||
jwt.Events.OnMessageReceived = context =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(context.Token))
|
||||
{
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
var authorization = context.Request.Headers.Authorization.ToString();
|
||||
if (authorization.StartsWith("DPoP ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Token = authorization["DPoP ".Length..].Trim();
|
||||
}
|
||||
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the /authorize endpoint for the OpenIddict authorization code flow.
|
||||
/// Renders a minimal login form on GET, validates credentials on POST,
|
||||
/// and issues an authorization code via OpenIddict SignIn.
|
||||
/// </summary>
|
||||
internal static class AuthorizeEndpointExtensions
|
||||
{
|
||||
public static void MapAuthorizeEndpoint(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/authorize", HandleAuthorize);
|
||||
app.MapPost("/authorize", HandleAuthorize);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAuthorize(
|
||||
HttpContext httpContext,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
IAuthorityClientStore clientStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var request = httpContext.GetOpenIddictServerRequest();
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Invalid authorization request." });
|
||||
}
|
||||
|
||||
// prompt=none: silent refresh — no interactive login allowed.
|
||||
if (string.Equals(request.Prompt, "none", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var redirectUri = request.RedirectUri;
|
||||
if (string.IsNullOrWhiteSpace(redirectUri))
|
||||
{
|
||||
return Results.BadRequest(new { error = "login_required", message = "User interaction is required." });
|
||||
}
|
||||
|
||||
return Results.Redirect(BuildErrorRedirect(redirectUri, "login_required", "User interaction is required.", request.State));
|
||||
}
|
||||
|
||||
// POST: extract and validate credentials from the form body.
|
||||
if (HttpMethods.IsPost(httpContext.Request.Method))
|
||||
{
|
||||
var form = await httpContext.Request.ReadFormAsync(httpContext.RequestAborted).ConfigureAwait(false);
|
||||
var username = form["username"].FirstOrDefault();
|
||||
var password = form["password"].FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrEmpty(password))
|
||||
{
|
||||
return await TryAuthenticateAndSignIn(
|
||||
httpContext, request, registry, clientStore, timeProvider,
|
||||
username!, password!).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Results.Content(
|
||||
BuildLoginHtml(request, "Username and password are required."),
|
||||
"text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
// GET: render the login form.
|
||||
return Results.Content(BuildLoginHtml(request), "text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static async Task<IResult> TryAuthenticateAndSignIn(
|
||||
HttpContext httpContext,
|
||||
OpenIddictRequest request,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
IAuthorityClientStore clientStore,
|
||||
TimeProvider timeProvider,
|
||||
string username,
|
||||
string password)
|
||||
{
|
||||
// Find a password-capable provider.
|
||||
var providerMeta = registry.Providers.FirstOrDefault(
|
||||
static p => p.Capabilities.SupportsPassword);
|
||||
|
||||
if (providerMeta is null)
|
||||
{
|
||||
return Results.Content(
|
||||
BuildLoginHtml(request, "No identity provider is configured."),
|
||||
"text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
await using var handle = await registry.AcquireAsync(
|
||||
providerMeta.Name, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
var provider = handle.Provider;
|
||||
|
||||
var verification = await provider.Credentials.VerifyPasswordAsync(
|
||||
username, password, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
if (!verification.Succeeded || verification.User is null)
|
||||
{
|
||||
return Results.Content(
|
||||
BuildLoginHtml(request, verification.Message ?? "Invalid username or password.", username),
|
||||
"text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
// Build ClaimsPrincipal (mirrors HandlePasswordGrantHandler pattern).
|
||||
var identity = new ClaimsIdentity(
|
||||
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
|
||||
OpenIddictConstants.Claims.Name,
|
||||
OpenIddictConstants.Claims.Role);
|
||||
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, verification.User.SubjectId));
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.PreferredUsername, verification.User.Username));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verification.User.DisplayName))
|
||||
{
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Name, verification.User.DisplayName!));
|
||||
}
|
||||
|
||||
foreach (var role in verification.User.Roles)
|
||||
{
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Role, role));
|
||||
}
|
||||
|
||||
// Resolve tenant from the client document.
|
||||
var clientId = request.ClientId;
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
var client = await clientStore.FindByClientIdAsync(
|
||||
clientId!, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
if (client?.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenant) == true
|
||||
&& !string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
identity.SetClaim(StellaOpsClaimTypes.Tenant, tenant.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
identity.AddClaim(new Claim(
|
||||
OpenIddictConstants.Claims.AuthenticationTime,
|
||||
EpochTime.GetIntDate(issuedAt.UtcDateTime).ToString(CultureInfo.InvariantCulture),
|
||||
ClaimValueTypes.Integer64));
|
||||
|
||||
identity.SetDestinations(static claim => claim.Type switch
|
||||
{
|
||||
OpenIddictConstants.Claims.Subject => new[]
|
||||
{
|
||||
OpenIddictConstants.Destinations.AccessToken,
|
||||
OpenIddictConstants.Destinations.IdentityToken
|
||||
},
|
||||
OpenIddictConstants.Claims.Name => new[]
|
||||
{
|
||||
OpenIddictConstants.Destinations.AccessToken,
|
||||
OpenIddictConstants.Destinations.IdentityToken
|
||||
},
|
||||
OpenIddictConstants.Claims.PreferredUsername => new[]
|
||||
{
|
||||
OpenIddictConstants.Destinations.AccessToken
|
||||
},
|
||||
OpenIddictConstants.Claims.Role => new[]
|
||||
{
|
||||
OpenIddictConstants.Destinations.AccessToken
|
||||
},
|
||||
_ => new[] { OpenIddictConstants.Destinations.AccessToken }
|
||||
});
|
||||
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
principal.SetScopes(request.GetScopes());
|
||||
|
||||
// Enrich claims via the identity provider plugin.
|
||||
var enrichmentContext = new AuthorityClaimsEnrichmentContext(
|
||||
provider.Context, verification.User, null);
|
||||
await provider.ClaimsEnricher.EnrichAsync(
|
||||
identity, enrichmentContext, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
// SignIn via OpenIddict — generates the authorization code and
|
||||
// redirects the browser back to the client's redirect_uri.
|
||||
return Results.SignIn(
|
||||
principal,
|
||||
properties: null,
|
||||
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
private static string BuildErrorRedirect(
|
||||
string redirectUri, string error, string description, string? state)
|
||||
{
|
||||
var separator = redirectUri.Contains('?') ? '&' : '?';
|
||||
var sb = new StringBuilder(redirectUri);
|
||||
sb.Append(separator);
|
||||
sb.Append("error=").Append(Uri.EscapeDataString(error));
|
||||
sb.Append("&error_description=").Append(Uri.EscapeDataString(description));
|
||||
if (!string.IsNullOrWhiteSpace(state))
|
||||
{
|
||||
sb.Append("&state=").Append(Uri.EscapeDataString(state));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildLoginHtml(
|
||||
OpenIddictRequest request, string? error = null, string? username = null)
|
||||
{
|
||||
var enc = HtmlEncoder.Default;
|
||||
|
||||
var sb = new StringBuilder(8192);
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"utf-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
|
||||
sb.AppendLine("<title>Sign In — StellaOps</title>");
|
||||
sb.AppendLine("<style>");
|
||||
|
||||
// Reset
|
||||
sb.AppendLine("*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}");
|
||||
|
||||
// Body — warm amber light theme matching the Angular app
|
||||
sb.AppendLine("body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;");
|
||||
sb.AppendLine("background:linear-gradient(175deg,#FFFCF5 0%,#FFF9ED 40%,#FFFFFF 100%);");
|
||||
sb.AppendLine("color:#3D2E0A;display:flex;align-items:center;justify-content:center;min-height:100vh;");
|
||||
sb.AppendLine("-webkit-font-smoothing:antialiased;position:relative;overflow:hidden}");
|
||||
|
||||
// Animated background radials
|
||||
sb.AppendLine("body::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;");
|
||||
sb.AppendLine("background:radial-gradient(ellipse 70% 50% at 50% 0%,rgba(245,166,35,0.08) 0%,transparent 60%),");
|
||||
sb.AppendLine("radial-gradient(ellipse 60% 50% at 0% 100%,rgba(245,166,35,0.04) 0%,transparent 50%),");
|
||||
sb.AppendLine("radial-gradient(ellipse 50% 40% at 100% 80%,rgba(212,146,10,0.03) 0%,transparent 50%);");
|
||||
sb.AppendLine("pointer-events:none;z-index:0}");
|
||||
|
||||
// Card — frosted glass on warm light
|
||||
sb.AppendLine(".card{position:relative;z-index:1;background:rgba(255,255,255,0.8);");
|
||||
sb.AppendLine("backdrop-filter:blur(24px) saturate(1.4);-webkit-backdrop-filter:blur(24px) saturate(1.4);");
|
||||
sb.AppendLine("border-radius:24px;padding:2.5rem 2rem 2rem;width:100%;max-width:400px;");
|
||||
sb.AppendLine("border:1px solid rgba(212,201,168,0.25);");
|
||||
sb.AppendLine("box-shadow:0 0 60px rgba(245,166,35,0.06),0 20px 60px rgba(28,18,0,0.06),");
|
||||
sb.AppendLine("0 8px 24px rgba(28,18,0,0.04),inset 0 1px 0 rgba(255,255,255,0.8);");
|
||||
sb.AppendLine("animation:card-entrance 600ms cubic-bezier(0.18,0.89,0.32,1) both}");
|
||||
|
||||
// Logo container
|
||||
sb.AppendLine(".logo-wrap{text-align:center;margin-bottom:0.25rem}");
|
||||
sb.AppendLine(".logo-wrap img{width:56px;height:56px;border-radius:14px;");
|
||||
sb.AppendLine("filter:drop-shadow(0 4px 12px rgba(245,166,35,0.2));");
|
||||
sb.AppendLine("animation:logo-pop 650ms cubic-bezier(0.34,1.56,0.64,1) 100ms both}");
|
||||
|
||||
// Title
|
||||
sb.AppendLine("h1{font-size:1.5rem;text-align:center;margin-bottom:0.25rem;color:#1C1200;font-weight:700;");
|
||||
sb.AppendLine("letter-spacing:-0.03em;animation:slide-up 500ms ease 200ms both}");
|
||||
|
||||
// Subtitle
|
||||
sb.AppendLine(".subtitle{text-align:center;color:#6B5A2E;font-size:.8125rem;margin-bottom:1.5rem;");
|
||||
sb.AppendLine("font-weight:400;animation:fade-in 400ms ease 350ms both}");
|
||||
|
||||
// Error
|
||||
sb.AppendLine(".error{background:#fef2f2;border:1px solid rgba(239,68,68,0.2);color:#991b1b;");
|
||||
sb.AppendLine("padding:.75rem;border-radius:12px;margin-bottom:1rem;font-size:.8125rem;font-weight:500;");
|
||||
sb.AppendLine("display:flex;align-items:center;gap:.5rem}");
|
||||
sb.AppendLine(".error::before{content:'';width:6px;height:6px;border-radius:50%;background:#ef4444;flex-shrink:0}");
|
||||
|
||||
// Labels
|
||||
sb.AppendLine("label{display:block;font-size:.75rem;font-weight:600;color:#6B5A2E;margin-bottom:.375rem;");
|
||||
sb.AppendLine("letter-spacing:0.03em;text-transform:uppercase}");
|
||||
|
||||
// Inputs
|
||||
sb.AppendLine("input[type=text],input[type=password]{width:100%;padding:.75rem .875rem;");
|
||||
sb.AppendLine("background:#FFFCF5;border:1px solid rgba(212,201,168,0.4);border-radius:12px;");
|
||||
sb.AppendLine("color:#3D2E0A;font-size:.9375rem;margin-bottom:1rem;outline:none;font-family:inherit;");
|
||||
sb.AppendLine("transition:border-color .2s,box-shadow .2s}");
|
||||
sb.AppendLine("input[type=text]:focus,input[type=password]:focus{border-color:#F5A623;");
|
||||
sb.AppendLine("box-shadow:0 0 0 3px rgba(245,166,35,0.15)}");
|
||||
sb.AppendLine("input[type=text]::placeholder,input[type=password]::placeholder{color:#9A8F78}");
|
||||
|
||||
// Button — amber gradient CTA
|
||||
sb.AppendLine("button{width:100%;padding:.875rem;margin-top:0.25rem;");
|
||||
sb.AppendLine("background:linear-gradient(135deg,#F5A623 0%,#D4920A 100%);");
|
||||
sb.AppendLine("color:#fff;border:none;border-radius:14px;font-size:1rem;font-weight:600;");
|
||||
sb.AppendLine("cursor:pointer;font-family:inherit;letter-spacing:0.01em;position:relative;overflow:hidden;");
|
||||
sb.AppendLine("transition:transform .22s cubic-bezier(0.18,0.89,0.32,1),box-shadow .22s;");
|
||||
sb.AppendLine("box-shadow:0 2px 12px rgba(245,166,35,0.3),0 1px 3px rgba(28,18,0,0.08)}");
|
||||
sb.AppendLine("button:hover{transform:translateY(-2px);");
|
||||
sb.AppendLine("box-shadow:0 6px 24px rgba(245,166,35,0.4),0 2px 8px rgba(28,18,0,0.08)}");
|
||||
sb.AppendLine("button:active{transform:translateY(0);");
|
||||
sb.AppendLine("box-shadow:0 1px 6px rgba(245,166,35,0.2),0 1px 2px rgba(28,18,0,0.06)}");
|
||||
sb.AppendLine("button:focus-visible{outline:2px solid rgba(245,166,35,0.5);outline-offset:3px}");
|
||||
|
||||
// Shimmer effect on button
|
||||
sb.AppendLine("button::after{content:'';position:absolute;inset:0;");
|
||||
sb.AppendLine("background:linear-gradient(105deg,transparent 38%,rgba(255,255,255,0.3) 50%,transparent 62%);");
|
||||
sb.AppendLine("background-size:250% 100%;animation:shimmer 2.2s ease 1.2s}");
|
||||
|
||||
// Keyframes
|
||||
sb.AppendLine("@keyframes card-entrance{from{opacity:0;transform:translateY(24px) scale(0.97)}to{opacity:1;transform:translateY(0) scale(1)}}");
|
||||
sb.AppendLine("@keyframes logo-pop{from{opacity:0;transform:scale(0.6)}to{opacity:1;transform:scale(1)}}");
|
||||
sb.AppendLine("@keyframes slide-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}");
|
||||
sb.AppendLine("@keyframes fade-in{from{opacity:0}to{opacity:1}}");
|
||||
sb.AppendLine("@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-100% 0}}");
|
||||
|
||||
// Reduced motion
|
||||
sb.AppendLine("@media(prefers-reduced-motion:reduce){.card,h1,.subtitle,.logo-wrap img,button::after{animation:none!important}");
|
||||
sb.AppendLine(".card,h1,.subtitle,.logo-wrap img{opacity:1}button{transition:none}}");
|
||||
|
||||
// Responsive
|
||||
sb.AppendLine("@media(max-width:480px){.card{margin:0 1rem;padding:2rem 1.5rem 1.75rem;border-radius:20px}}");
|
||||
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
sb.AppendLine("<body>");
|
||||
sb.AppendLine("<form class=\"card\" method=\"post\" action=\"\">");
|
||||
|
||||
// Logo
|
||||
sb.AppendLine("<div class=\"logo-wrap\"><img src=\"/assets/img/site.png\" alt=\"\" width=\"56\" height=\"56\" /></div>");
|
||||
|
||||
sb.AppendLine("<h1>StellaOps</h1>");
|
||||
sb.AppendLine("<p class=\"subtitle\">Sign in to continue</p>");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
sb.Append("<div class=\"error\">").Append(enc.Encode(error)).AppendLine("</div>");
|
||||
}
|
||||
|
||||
// Hidden fields for OIDC parameters
|
||||
AppendHidden(sb, "response_type", request.ResponseType);
|
||||
AppendHidden(sb, "client_id", request.ClientId);
|
||||
AppendHidden(sb, "redirect_uri", request.RedirectUri);
|
||||
AppendHidden(sb, "scope", request.Scope);
|
||||
AppendHidden(sb, "state", request.State);
|
||||
AppendHidden(sb, "nonce", request.Nonce);
|
||||
AppendHidden(sb, "code_challenge", request.CodeChallenge);
|
||||
AppendHidden(sb, "code_challenge_method", request.CodeChallengeMethod);
|
||||
if (!string.IsNullOrWhiteSpace(request.GetParameter("audience")?.ToString()))
|
||||
{
|
||||
AppendHidden(sb, "audience", request.GetParameter("audience")?.ToString());
|
||||
}
|
||||
|
||||
sb.AppendLine("<label for=\"username\">Username</label>");
|
||||
sb.Append("<input type=\"text\" id=\"username\" name=\"username\" autocomplete=\"username\" placeholder=\"Enter username\" required");
|
||||
if (!string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
sb.Append(" value=\"").Append(enc.Encode(username)).Append('"');
|
||||
}
|
||||
sb.AppendLine(" />");
|
||||
|
||||
sb.AppendLine("<label for=\"password\">Password</label>");
|
||||
sb.AppendLine("<input type=\"password\" id=\"password\" name=\"password\" autocomplete=\"current-password\" placeholder=\"Enter password\" required />");
|
||||
|
||||
sb.AppendLine("<button type=\"submit\">Sign In</button>");
|
||||
sb.AppendLine("</form>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendHidden(StringBuilder sb, string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enc = HtmlEncoder.Default;
|
||||
sb.Append("<input type=\"hidden\" name=\"")
|
||||
.Append(enc.Encode(name))
|
||||
.Append("\" value=\"")
|
||||
.Append(enc.Encode(value))
|
||||
.AppendLine("\" />");
|
||||
}
|
||||
}
|
||||
@@ -79,15 +79,24 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
// Placeholder: load from storage
|
||||
var branding = GetDefaultBranding(tenantId);
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.branding.read",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("tenant.id", tenantId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.branding.read",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("tenant.id", tenantId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Best-effort audit for public branding endpoint.
|
||||
// Do not fail the request if the audit sink is unavailable
|
||||
// (e.g. DB schema not yet initialized).
|
||||
}
|
||||
|
||||
return Results.Ok(branding);
|
||||
}
|
||||
|
||||
@@ -24,30 +24,40 @@ internal sealed class TenantHeaderFilter : IEndpointFilter
|
||||
}
|
||||
|
||||
var tenantHeader = httpContext.Request.Headers[AuthorityHttpHeaders.Tenant];
|
||||
if (IsMissing(tenantHeader))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.BadRequest(new
|
||||
{
|
||||
error = "tenant_header_missing",
|
||||
message = $"Header '{AuthorityHttpHeaders.Tenant}' is required."
|
||||
}));
|
||||
}
|
||||
|
||||
var normalizedHeader = tenantHeader.ToString().Trim().ToLowerInvariant();
|
||||
var claimTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(claimTenant))
|
||||
// Determine effective tenant:
|
||||
// 1. If both header and claim present: they must match
|
||||
// 2. If header present but no claim: use header value (bootstrapped users have no tenant claim)
|
||||
// 3. If no header but claim present: use claim value
|
||||
// 4. If neither present: default to "default"
|
||||
string effectiveTenant;
|
||||
|
||||
if (!IsMissing(tenantHeader))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
var normalizedHeader = tenantHeader.ToString().Trim().ToLowerInvariant();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
var normalizedClaim = claimTenant.Trim().ToLowerInvariant();
|
||||
if (!string.Equals(normalizedClaim, normalizedHeader, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
}
|
||||
}
|
||||
|
||||
effectiveTenant = normalizedHeader;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
effectiveTenant = claimTenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
else
|
||||
{
|
||||
effectiveTenant = "default";
|
||||
}
|
||||
|
||||
var normalizedClaim = claimTenant.Trim().ToLowerInvariant();
|
||||
if (!string.Equals(normalizedClaim, normalizedHeader, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
}
|
||||
|
||||
httpContext.Items[TenantItemKey] = normalizedHeader;
|
||||
httpContext.Items[TenantItemKey] = effectiveTenant;
|
||||
return next(context);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the token request for the authorization_code grant type.
|
||||
/// OpenIddict (in degraded mode) validates the authorization code and
|
||||
/// populates context.Principal before this handler runs. We simply
|
||||
/// sign in with the already-validated principal to issue tokens.
|
||||
/// </summary>
|
||||
internal sealed class HandleAuthorizationCodeGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext>
|
||||
{
|
||||
private readonly ILogger<HandleAuthorizationCodeGrantHandler> logger;
|
||||
|
||||
public HandleAuthorizationCodeGrantHandler(ILogger<HandleAuthorizationCodeGrantHandler> logger)
|
||||
{
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!context.Request.IsAuthorizationCodeGrantType())
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
// The principal was built by AuthorizeEndpoint and embedded in the
|
||||
// self-contained authorization code. OpenIddict already validated
|
||||
// the code (PKCE, redirect_uri, expiry) and deserialized the
|
||||
// principal into context.Principal.
|
||||
var principal = context.Principal;
|
||||
if (principal is null)
|
||||
{
|
||||
logger.LogError("Authorization code grant failed: no principal found in the validated authorization code.");
|
||||
context.Reject(
|
||||
OpenIddictConstants.Errors.InvalidGrant,
|
||||
"The authorization code is no longer valid.");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Authorization code grant succeeded for subject {Subject}.",
|
||||
principal.FindFirst(OpenIddictConstants.Claims.Subject)?.Value ?? "<unknown>");
|
||||
|
||||
context.Principal = principal;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1830,7 +1830,6 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
await PersistTokenAsync(context, document, tokenId, grantedScopes, session!, activity).ConfigureAwait(false);
|
||||
|
||||
context.Principal = principal;
|
||||
context.HandleRequest();
|
||||
logger.LogInformation("Issued client credentials access token for {ClientId} with scopes {Scopes}.", document.ClientId, grantedScopes);
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -1307,7 +1307,10 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
}
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
identity.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
identity.AddClaim(new Claim(
|
||||
OpenIddictConstants.Claims.AuthenticationTime,
|
||||
issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
|
||||
ClaimValueTypes.Integer64));
|
||||
|
||||
AuthoritySenderConstraintHelper.ApplySenderConstraintClaims(context.Transaction, identity);
|
||||
|
||||
@@ -1433,7 +1436,6 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
}
|
||||
|
||||
context.Principal = principal;
|
||||
context.HandleRequest();
|
||||
activity?.SetTag("authority.subject_id", verification.User.SubjectId);
|
||||
logger.LogInformation("Password grant issued for {Username} with subject {SubjectId}.", verification.User.Username, verification.User.SubjectId);
|
||||
}
|
||||
|
||||
@@ -293,10 +293,16 @@ builder.Services.AddSingleton(pluginRegistrationSummary);
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration, configurationSection: null);
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
// The Authority validates its own tokens for admin endpoints. Configure the JWKS
|
||||
// backchannel to accept the Authority's self-signed certificate (self-referential).
|
||||
builder.Services.AddHttpClient("StellaOps.Auth.ServerIntegration.Metadata")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = System.Net.Http.HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
});
|
||||
|
||||
builder.Services.TryAddSingleton<StellaOpsBypassEvaluator>();
|
||||
|
||||
@@ -321,6 +327,8 @@ builder.Services.AddOpenIddict()
|
||||
options.AllowPasswordFlow();
|
||||
options.AllowClientCredentialsFlow();
|
||||
options.AllowRefreshTokenFlow();
|
||||
options.AllowAuthorizationCodeFlow();
|
||||
options.RequireProofKeyForCodeExchange();
|
||||
|
||||
options.SetAccessTokenLifetime(authorityOptions.AccessTokenLifetime);
|
||||
options.SetRefreshTokenLifetime(authorityOptions.RefreshTokenLifetime);
|
||||
@@ -328,9 +336,8 @@ builder.Services.AddOpenIddict()
|
||||
options.SetAuthorizationCodeLifetime(authorityOptions.AuthorizationCodeLifetime);
|
||||
options.SetDeviceCodeLifetime(authorityOptions.DeviceCodeLifetime);
|
||||
|
||||
options.EnableDegradedMode();
|
||||
options.DisableAccessTokenEncryption();
|
||||
options.DisableTokenStorage();
|
||||
options.DisableAuthorizationStorage();
|
||||
|
||||
options.RegisterScopes(
|
||||
new[]
|
||||
@@ -348,8 +355,7 @@ builder.Services.AddOpenIddict()
|
||||
.AddEphemeralSigningKey();
|
||||
|
||||
var aspNetCoreBuilder = options.UseAspNetCore()
|
||||
.EnableAuthorizationEndpointPassthrough()
|
||||
.EnableTokenEndpointPassthrough();
|
||||
.EnableAuthorizationEndpointPassthrough();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
@@ -363,6 +369,11 @@ builder.Services.AddOpenIddict()
|
||||
});
|
||||
#endif
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateAuthorizationRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidateAuthorizationRequestHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateTokenRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidatePasswordGrantHandler>();
|
||||
@@ -388,6 +399,11 @@ builder.Services.AddOpenIddict()
|
||||
descriptor.UseScopedHandler<HandleClientCredentialsHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.HandleTokenRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<HandleAuthorizationCodeGrantHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateTokenContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidateAccessTokenHandler>();
|
||||
@@ -398,6 +414,16 @@ builder.Services.AddOpenIddict()
|
||||
descriptor.UseScopedHandler<PersistTokensHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateIntrospectionRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidateIntrospectionRequestHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateRevocationRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidateRevocationRequestHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.HandleRevocationRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<HandleRevocationRequestHandler>();
|
||||
@@ -3117,6 +3143,7 @@ app.MapAuthorityOpenApiDiscovery();
|
||||
app.MapConsoleEndpoints();
|
||||
app.MapConsoleAdminEndpoints();
|
||||
app.MapConsoleBrandingEndpoints();
|
||||
app.MapAuthorizeEndpoint();
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user