stela ops usage fixes roles propagation and timoeut, one account to support multi tenants, migrations consolidation, search to support documentation, doctor and open api vector db search
This commit is contained in:
@@ -597,6 +597,16 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string AnalyticsRead = "analytics.read";
|
||||
|
||||
// Platform context scopes
|
||||
public const string PlatformContextRead = "platform.context.read";
|
||||
public const string PlatformContextWrite = "platform.context.write";
|
||||
|
||||
// Doctor scopes
|
||||
public const string DoctorRun = "doctor:run";
|
||||
public const string DoctorRunFull = "doctor:run:full";
|
||||
public const string DoctorExport = "doctor:export";
|
||||
public const string DoctorAdmin = "doctor:admin";
|
||||
|
||||
private static readonly IReadOnlyList<string> AllScopes = BuildAllScopes();
|
||||
private static readonly HashSet<string> KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
@@ -203,157 +203,52 @@ internal static class AuthorizeEndpointExtensions
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static readonly string LoginTemplate = LoadLoginTemplate();
|
||||
|
||||
private static string LoadLoginTemplate()
|
||||
{
|
||||
var assembly = typeof(AuthorizeEndpointExtensions).Assembly;
|
||||
var resourceName = "StellaOps.Authority.Pages.login.html";
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
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>");
|
||||
// Build error block
|
||||
var errorBlock = string.IsNullOrWhiteSpace(error)
|
||||
? string.Empty
|
||||
: $"<div class=\"error\">{enc.Encode(error)}</div>";
|
||||
|
||||
// Reset
|
||||
sb.AppendLine("*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}");
|
||||
// Build username value attribute
|
||||
var usernameValue = string.IsNullOrWhiteSpace(username)
|
||||
? string.Empty
|
||||
: $" value=\"{enc.Encode(username)}\"";
|
||||
|
||||
// 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);
|
||||
// Build OIDC hidden fields
|
||||
var hiddenSb = new StringBuilder();
|
||||
AppendHidden(hiddenSb, "response_type", request.ResponseType);
|
||||
AppendHidden(hiddenSb, "client_id", request.ClientId);
|
||||
AppendHidden(hiddenSb, "redirect_uri", request.RedirectUri);
|
||||
AppendHidden(hiddenSb, "scope", request.Scope);
|
||||
AppendHidden(hiddenSb, "state", request.State);
|
||||
AppendHidden(hiddenSb, "nonce", request.Nonce);
|
||||
AppendHidden(hiddenSb, "code_challenge", request.CodeChallenge);
|
||||
AppendHidden(hiddenSb, "code_challenge_method", request.CodeChallengeMethod);
|
||||
if (!string.IsNullOrWhiteSpace(request.GetParameter("audience")?.ToString()))
|
||||
{
|
||||
AppendHidden(sb, "audience", request.GetParameter("audience")?.ToString());
|
||||
AppendHidden(hiddenSb, "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();
|
||||
return LoginTemplate
|
||||
.Replace("{{error_block}}", errorBlock)
|
||||
.Replace("{{username_value}}", usernameValue)
|
||||
.Replace("{{oidc_hidden_fields}}", hiddenSb.ToString());
|
||||
}
|
||||
|
||||
private static void AppendHidden(StringBuilder sb, string name, string? value)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal static class OpenIddictGatewayBridgeEndpointExtensions
|
||||
{
|
||||
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"TE",
|
||||
"Trailers",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> IgnoredRequestHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
HeaderNames.Host,
|
||||
HeaderNames.ContentLength
|
||||
};
|
||||
|
||||
public static void MapOpenIddictGatewayBridgeEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapMethods("/connect/authorize", [HttpMethods.Get, HttpMethods.Post], (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProxyToAuthorityAsync(context, httpClientFactory, "/authorize", cancellationToken))
|
||||
.AllowAnonymous()
|
||||
.WithName("GatewayBridgeAuthorize")
|
||||
.WithSummary("OpenID Connect authorization endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/connect/authorize` requests to Authority `/authorize`.");
|
||||
|
||||
endpoints.MapPost("/connect/token", (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProxyToAuthorityAsync(context, httpClientFactory, "/token", cancellationToken))
|
||||
.AllowAnonymous()
|
||||
.WithName("GatewayBridgeToken")
|
||||
.WithSummary("OAuth2 token endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/connect/token` requests to Authority `/token`.");
|
||||
|
||||
endpoints.MapPost("/connect/introspect", (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProxyToAuthorityAsync(context, httpClientFactory, "/introspect", cancellationToken))
|
||||
.AllowAnonymous()
|
||||
.WithName("GatewayBridgeIntrospect")
|
||||
.WithSummary("OAuth2 introspection endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/connect/introspect` requests to Authority `/introspect`.");
|
||||
|
||||
endpoints.MapPost("/connect/revoke", (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProxyToAuthorityAsync(context, httpClientFactory, "/revoke", cancellationToken))
|
||||
.AllowAnonymous()
|
||||
.WithName("GatewayBridgeRevoke")
|
||||
.WithSummary("OAuth2 revocation endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/connect/revoke` requests to Authority `/revoke`.");
|
||||
|
||||
endpoints.MapGet("/well-known/openid-configuration", (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProxyToAuthorityAsync(context, httpClientFactory, "/.well-known/openid-configuration", cancellationToken))
|
||||
.AllowAnonymous()
|
||||
.WithName("GatewayBridgeOpenIdConfiguration")
|
||||
.WithSummary("OpenID Provider configuration endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/.well-known/openid-configuration` requests to Authority OIDC discovery.");
|
||||
}
|
||||
|
||||
private static async Task ProxyToAuthorityAsync(
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
string authorityPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var loopbackPort = await ResolveLoopbackPortAsync(cancellationToken).ConfigureAwait(false);
|
||||
var query = context.Request.QueryString.HasValue
|
||||
? context.Request.QueryString.Value
|
||||
: string.Empty;
|
||||
var upstreamUri = new Uri($"http://127.0.0.1:{loopbackPort}{authorityPath}{query}", UriKind.Absolute);
|
||||
|
||||
using var upstreamRequest = new HttpRequestMessage(
|
||||
new HttpMethod(context.Request.Method),
|
||||
upstreamUri);
|
||||
|
||||
if (context.Request.ContentLength is > 0 ||
|
||||
context.Request.Headers.ContainsKey(HeaderNames.TransferEncoding))
|
||||
{
|
||||
var body = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(body, cancellationToken).ConfigureAwait(false);
|
||||
body.Position = 0;
|
||||
upstreamRequest.Content = new StreamContent(body);
|
||||
}
|
||||
|
||||
foreach (var header in context.Request.Headers)
|
||||
{
|
||||
if (IgnoredRequestHeaders.Contains(header.Key) || HopByHopHeaders.Contains(header.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var values = header.Value.ToArray();
|
||||
if (!upstreamRequest.Headers.TryAddWithoutValidation(header.Key, values))
|
||||
{
|
||||
upstreamRequest.Content?.Headers.TryAddWithoutValidation(header.Key, values);
|
||||
}
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("AuthorityBridge");
|
||||
HttpResponseMessage upstreamResponse;
|
||||
try
|
||||
{
|
||||
upstreamResponse = await client.SendAsync(
|
||||
upstreamRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "authority_upstream_unavailable",
|
||||
message = "Authority upstream endpoint could not be reached."
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using (upstreamResponse)
|
||||
{
|
||||
context.Response.StatusCode = (int)upstreamResponse.StatusCode;
|
||||
|
||||
foreach (var header in upstreamResponse.Headers)
|
||||
{
|
||||
if (!HopByHopHeaders.Contains(header.Key))
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var header in upstreamResponse.Content.Headers)
|
||||
{
|
||||
if (!HopByHopHeaders.Contains(header.Key) &&
|
||||
!header.Key.Equals(HeaderNames.ContentLength, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
await upstreamResponse.Content.CopyToAsync(context.Response.Body, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> ResolveLoopbackPortAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var port in GetCandidateLoopbackPorts())
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(IPAddress.Loopback, port, cancellationToken).ConfigureAwait(false);
|
||||
return port;
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// Probe next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return 80;
|
||||
}
|
||||
|
||||
private static IEnumerable<int> GetCandidateLoopbackPorts()
|
||||
{
|
||||
var seen = new HashSet<int>();
|
||||
|
||||
var rawUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
|
||||
if (!string.IsNullOrWhiteSpace(rawUrls))
|
||||
{
|
||||
foreach (var rawUrl in rawUrls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var uri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (uri.Port > 0 && seen.Add(uri.Port))
|
||||
{
|
||||
yield return uri.Port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.Add(80))
|
||||
{
|
||||
yield return 80;
|
||||
}
|
||||
|
||||
if (seen.Add(8440))
|
||||
{
|
||||
yield return 8440;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign In — StellaOps</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
background:#080A12;
|
||||
color:#F0EDE4;display:flex;align-items:center;justify-content:center;min-height:100vh;
|
||||
-webkit-font-smoothing:antialiased;position:relative;overflow:hidden}
|
||||
body::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;
|
||||
background:radial-gradient(ellipse 55% 45% at 50% 38%,rgba(224,154,24,0.07) 0%,transparent 70%),
|
||||
radial-gradient(ellipse 40% 30% at 20% 80%,rgba(8,10,18,0.5) 0%,transparent 50%);
|
||||
pointer-events:none;z-index:0}
|
||||
.card{position:relative;z-index:1;background:rgba(18,21,31,0.85);
|
||||
backdrop-filter:blur(24px) saturate(1.4);-webkit-backdrop-filter:blur(24px) saturate(1.4);
|
||||
border-radius:24px;padding:2.5rem 2rem 2rem;width:100%;max-width:400px;
|
||||
border:1px solid rgba(224,154,24,0.12);
|
||||
box-shadow:0 0 60px rgba(224,154,24,0.04),0 20px 60px rgba(0,0,0,0.3),
|
||||
0 8px 24px rgba(0,0,0,0.2),inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
animation:card-entrance 600ms cubic-bezier(0.18,0.89,0.32,1) both}
|
||||
.logo-wrap{text-align:center;margin-bottom:0.25rem}
|
||||
.logo-wrap img{max-width:248px;border-radius:32px;object-fit:contain;
|
||||
filter:drop-shadow(0 4px 12px rgba(224,154,24,0.35));
|
||||
animation:logo-pop 650ms cubic-bezier(0.34,1.56,0.64,1) 100ms both}
|
||||
h1{font-size:1.5rem;text-align:center;margin-bottom:0.25rem;color:#F0EDE4;font-weight:700;
|
||||
letter-spacing:-0.03em;animation:slide-up 500ms ease 200ms both}
|
||||
.subtitle{text-align:center;color:#7A7568;font-size:.8125rem;margin-bottom:1.5rem;
|
||||
font-weight:400;animation:fade-in 400ms ease 350ms both}
|
||||
.error{background:rgba(239,68,68,0.12);border:1px solid rgba(239,68,68,0.25);color:#f87171;
|
||||
padding:.75rem;border-radius:12px;margin-bottom:1rem;font-size:.8125rem;font-weight:500;
|
||||
display:flex;align-items:center;gap:.5rem}
|
||||
.error::before{content:'';width:6px;height:6px;border-radius:50%;background:#ef4444;flex-shrink:0}
|
||||
label{display:block;font-size:.75rem;font-weight:600;color:#7A7568;margin-bottom:.375rem;
|
||||
letter-spacing:0.03em;text-transform:uppercase}
|
||||
input[type=text],input[type=password]{width:100%;padding:.75rem .875rem;
|
||||
background:rgba(255,255,255,0.05);border:1px solid rgba(224,154,24,0.15);border-radius:12px;
|
||||
color:#F0EDE4;font-size:.9375rem;margin-bottom:1rem;outline:none;font-family:inherit;
|
||||
transition:border-color .2s,box-shadow .2s}
|
||||
input[type=text]:focus,input[type=password]:focus{border-color:#CC8810;
|
||||
box-shadow:0 0 0 3px rgba(224,154,24,0.15)}
|
||||
input[type=text]::placeholder,input[type=password]::placeholder{color:#7A7568}
|
||||
button{width:100%;padding:.875rem;margin-top:0.25rem;
|
||||
background:linear-gradient(135deg,#D4920A 0%,#B8800A 100%);
|
||||
color:#080A12;border:none;border-radius:14px;font-size:1rem;font-weight:700;
|
||||
cursor:pointer;font-family:inherit;letter-spacing:0.01em;position:relative;overflow:hidden;
|
||||
transition:transform .22s cubic-bezier(0.18,0.89,0.32,1),box-shadow .22s;
|
||||
box-shadow:0 2px 16px rgba(212,146,10,0.30),0 1px 3px rgba(0,0,0,0.25)}
|
||||
button:hover{transform:translateY(-2px);
|
||||
box-shadow:0 6px 28px rgba(212,146,10,0.40),0 2px 8px rgba(0,0,0,0.20)}
|
||||
button:active{transform:translateY(0);
|
||||
box-shadow:0 1px 8px rgba(212,146,10,0.20),0 1px 2px rgba(0,0,0,0.15)}
|
||||
button:focus-visible{outline:2px solid rgba(212,146,10,0.5);outline-offset:3px}
|
||||
button::after{content:'';position:absolute;inset:0;
|
||||
background:linear-gradient(105deg,transparent 38%,rgba(255,255,255,0.20) 50%,transparent 62%);
|
||||
background-size:250% 100%;animation:shimmer 2.2s ease 1.2s}
|
||||
@keyframes card-entrance{from{opacity:0;transform:translateY(24px) scale(0.97)}to{opacity:1;transform:translateY(0) scale(1)}}
|
||||
@keyframes logo-pop{from{opacity:0;transform:scale(0.6)}to{opacity:1;transform:scale(1)}}
|
||||
@keyframes slide-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes fade-in{from{opacity:0}to{opacity:1}}
|
||||
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-100% 0}}
|
||||
@media(prefers-reduced-motion:reduce){.card,h1,.subtitle,.logo-wrap img,button::after{animation:none!important}
|
||||
.card,h1,.subtitle,.logo-wrap img{opacity:1}button{transition:none}}
|
||||
@media(max-width:480px){.card{margin:0 1rem;padding:2rem 1.5rem 1.75rem;border-radius:20px}
|
||||
.logo-wrap img{max-width:177px;border-radius:24px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="card" method="post" action="">
|
||||
<div class="logo-wrap"><img src="/assets/img/site.png" alt="" /></div>
|
||||
<h1>StellaOps</h1>
|
||||
<p class="subtitle">Sign in to continue</p>
|
||||
{{error_block}}
|
||||
{{oidc_hidden_fields}}
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" placeholder="Enter username" required{{username_value}} />
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" placeholder="Enter password" required />
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -19,6 +19,7 @@ using Microsoft.Net.Http.Headers;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using StellaOps.Router.AspNet;
|
||||
// Using PostgreSQL storage with in-memory compatibility shim
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
@@ -302,6 +303,15 @@ builder.Services.AddHttpClient("StellaOps.Auth.ServerIntegration.Metadata")
|
||||
ServerCertificateCustomValidationCallback = System.Net.Http.HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
});
|
||||
|
||||
// The gateway bridge proxies OIDC requests to the Authority's own loopback.
|
||||
// Disable auto-redirect so 302 responses (authorization code grants) are passed
|
||||
// back to the caller instead of followed inside the container.
|
||||
builder.Services.AddHttpClient("AuthorityBridge")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
builder.Services.TryAddSingleton<StellaOpsBypassEvaluator>();
|
||||
|
||||
builder.Services.AddOptions<StellaOpsResourceServerOptions>()
|
||||
@@ -445,6 +455,12 @@ builder.Services.Configure<OpenIddictServerOptions>(options =>
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration, configurationSection: null);
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "authority",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
builder.TryAddStellaOpsLocalBinding("authority");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("authority");
|
||||
@@ -1752,6 +1768,7 @@ app.UseRateLimiter();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapGet("/health", async (IAuthorityIdentityProviderRegistry registry, CancellationToken cancellationToken) =>
|
||||
{
|
||||
@@ -1784,6 +1801,18 @@ app.MapGet("/ready", (IAuthorityIdentityProviderRegistry registry) =>
|
||||
}))
|
||||
.WithName("ReadinessCheck");
|
||||
|
||||
app.MapGet("/api/v1/claims/overrides", (IConfiguration configuration) =>
|
||||
{
|
||||
var configured = configuration
|
||||
.GetSection("Authority:Router:ClaimsOverrides")
|
||||
.Get<RouterClaimsOverridesResponse>() ?? new RouterClaimsOverridesResponse();
|
||||
|
||||
return Results.Ok(NormalizeRouterClaimsOverrides(configured));
|
||||
})
|
||||
.WithName("GetRouterClaimsOverrides")
|
||||
.WithSummary("Get router claims overrides")
|
||||
.WithDescription("Returns Authority-managed endpoint claim overrides consumed by router-gateway authorization refresh.");
|
||||
|
||||
app.MapPost("/permalinks/vuln", async (
|
||||
VulnPermalinkRequest request,
|
||||
VulnPermalinkService service,
|
||||
@@ -3148,6 +3177,7 @@ app.MapConsoleEndpoints();
|
||||
app.MapConsoleAdminEndpoints();
|
||||
app.MapConsoleBrandingEndpoints();
|
||||
app.MapAuthorizeEndpoint();
|
||||
app.MapOpenIddictGatewayBridgeEndpoints();
|
||||
|
||||
|
||||
|
||||
@@ -3177,6 +3207,7 @@ app.MapGet("/jwks", (AuthorityJwksService jwksService, HttpContext context) =>
|
||||
app.Services.GetRequiredService<AuthorityAckTokenKeyManager>();
|
||||
app.Services.GetRequiredService<AuthoritySigningKeyManager>();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
app.Run();
|
||||
|
||||
static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions options, string basePath)
|
||||
@@ -3237,3 +3268,89 @@ static bool TryParseUris(IReadOnlyCollection<string>? values, out IReadOnlyColle
|
||||
uris = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
static RouterClaimsOverridesResponse NormalizeRouterClaimsOverrides(RouterClaimsOverridesResponse response)
|
||||
{
|
||||
var overrides = response.Overrides
|
||||
.Where(entry =>
|
||||
!string.IsNullOrWhiteSpace(entry.ServiceName) &&
|
||||
!string.IsNullOrWhiteSpace(entry.Method) &&
|
||||
!string.IsNullOrWhiteSpace(entry.Path))
|
||||
.Select(entry => new RouterClaimsOverrideEntry
|
||||
{
|
||||
ServiceName = entry.ServiceName.Trim(),
|
||||
Method = entry.Method.Trim().ToUpperInvariant(),
|
||||
Path = NormalizeOverridePath(entry.Path),
|
||||
RequiringClaims = entry.RequiringClaims
|
||||
.Where(claim => !string.IsNullOrWhiteSpace(claim.Type))
|
||||
.Select(claim => new RouterClaimRequirementEntry
|
||||
{
|
||||
Type = claim.Type.Trim(),
|
||||
Value = string.IsNullOrWhiteSpace(claim.Value) ? null : claim.Value.Trim()
|
||||
})
|
||||
.Distinct()
|
||||
.OrderBy(claim => claim.Type, StringComparer.Ordinal)
|
||||
.ThenBy(claim => claim.Value, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
})
|
||||
.GroupBy(
|
||||
entry => $"{entry.ServiceName.ToLowerInvariant()}|{entry.Method}|{entry.Path}",
|
||||
StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group.First();
|
||||
return new RouterClaimsOverrideEntry
|
||||
{
|
||||
ServiceName = first.ServiceName,
|
||||
Method = first.Method,
|
||||
Path = first.Path,
|
||||
RequiringClaims = group
|
||||
.SelectMany(entry => entry.RequiringClaims)
|
||||
.Distinct()
|
||||
.OrderBy(claim => claim.Type, StringComparer.Ordinal)
|
||||
.ThenBy(claim => claim.Value, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
})
|
||||
.OrderBy(entry => entry.ServiceName, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(entry => entry.Method, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new RouterClaimsOverridesResponse
|
||||
{
|
||||
Overrides = overrides
|
||||
};
|
||||
}
|
||||
|
||||
static string NormalizeOverridePath(string rawPath)
|
||||
{
|
||||
var path = rawPath.Trim();
|
||||
if (!path.StartsWith('/'))
|
||||
{
|
||||
path = "/" + path;
|
||||
}
|
||||
|
||||
path = path.TrimEnd('/');
|
||||
return string.IsNullOrEmpty(path) ? "/" : path;
|
||||
}
|
||||
|
||||
sealed class RouterClaimsOverridesResponse
|
||||
{
|
||||
public List<RouterClaimsOverrideEntry> Overrides { get; init; } = [];
|
||||
}
|
||||
|
||||
sealed class RouterClaimsOverrideEntry
|
||||
{
|
||||
public string ServiceName { get; init; } = string.Empty;
|
||||
public string Method { get; init; } = "GET";
|
||||
public string Path { get; init; } = "/";
|
||||
public List<RouterClaimRequirementEntry> RequiringClaims { get; init; } = [];
|
||||
}
|
||||
|
||||
sealed record RouterClaimRequirementEntry
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,16 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Pages\*.html" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\StellaOps.Api.OpenApi\authority\openapi.yaml" Link="OpenApi\authority.yaml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -78,9 +78,24 @@ ON CONFLICT (tenant_id, name) DO NOTHING;
|
||||
|
||||
INSERT INTO authority.clients (id, client_id, display_name, description, enabled, redirect_uris, allowed_scopes, allowed_grant_types, require_client_secret, require_pkce)
|
||||
VALUES
|
||||
('demo-client-ui', 'stellaops-console', 'Stella Ops Console', 'Web UI application', true,
|
||||
ARRAY['https://stella-ops.local/callback', 'https://stella-ops.local/silent-renew'],
|
||||
ARRAY['openid', 'profile', 'email', 'stellaops.api'],
|
||||
('demo-client-ui', 'stella-ops-ui', 'Stella Ops Console', 'Web UI application', true,
|
||||
ARRAY['https://stella-ops.local/auth/callback', 'https://stella-ops.local/auth/silent-refresh'],
|
||||
ARRAY['openid', 'profile', 'email', 'offline_access',
|
||||
'ui.read', 'ui.admin',
|
||||
'authority:tenants.read', 'authority:users.read', 'authority:roles.read',
|
||||
'authority:clients.read', 'authority:tokens.read', 'authority:branding.read',
|
||||
'authority.audit.read',
|
||||
'graph:read', 'sbom:read', 'scanner:read',
|
||||
'policy:read', 'policy:simulate', 'policy:author', 'policy:review', 'policy:approve',
|
||||
'orch:read', 'analytics.read', 'advisory:read', 'vex:read',
|
||||
'exceptions:read', 'exceptions:approve', 'aoc:verify', 'findings:read',
|
||||
'release:read', 'scheduler:read', 'scheduler:operate',
|
||||
'notify.viewer', 'notify.operator', 'notify.admin', 'notify.escalate',
|
||||
'evidence:read',
|
||||
'export.viewer', 'export.operator', 'export.admin',
|
||||
'vuln:view', 'vuln:investigate', 'vuln:operate', 'vuln:audit',
|
||||
'platform.context.read', 'platform.context.write',
|
||||
'doctor:run', 'doctor:admin'],
|
||||
ARRAY['authorization_code', 'refresh_token'],
|
||||
false, true),
|
||||
('demo-client-cli', 'stellaops-cli', 'Stella Ops CLI', 'Command-line client', true,
|
||||
|
||||
Reference in New Issue
Block a user