// Advantech SSO + ASP.NET Core (.NET 8, minimal API) // // Environment variables: SSO_CLIENT_ID, SSO_CLIENT_SECRET, APP_URL (e.g. https://myapp.advantech.world) // dotnet run (login.html / index.html in wwwroot/) // Register APP_URL/auth/callback as the Redirect URI of the service. using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; const string SSO = "https://ae-sso.advantech.com"; var builder = WebApplication.CreateBuilder(args); var clientId = builder.Configuration["SSO_CLIENT_ID"]!; var clientSecret = builder.Configuration["SSO_CLIENT_SECRET"]!; var appUrl = builder.Configuration["APP_URL"]!.TrimEnd('/'); var redirectUri = appUrl + "/auth/callback"; builder.Services.AddHttpClient(); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(o => { o.LoginPath = "/login.html"; // ?ReturnUrl=... is appended o.ExpireTimeSpan = TimeSpan.FromHours(8); o.Cookie.SameSite = SameSiteMode.Lax; o.Events.OnRedirectToLogin = ctx => // APIs get 401 instead of a redirect { if (ctx.Request.Path.StartsWithSegments("/api") || ctx.Request.Path.StartsWithSegments("/auth/me")) ctx.Response.StatusCode = 401; else // try single sign-on first; login.html is shown only if that fails ctx.Response.Redirect("/auth/login?silent=1&next=" + Uri.EscapeDataString(ctx.Request.Path + ctx.Request.QueryString)); return Task.CompletedTask; }; }); builder.Services.AddAuthorization(); var app = builder.Build(); app.UseStaticFiles(); app.UseAuthentication(); app.UseAuthorization(); static string SafeNext(string? v) => v is { Length: > 0 } && v.StartsWith('/') && !v.StartsWith("//") ? v : "/"; // ---------- 1. sign in ---------- // /auth/login?silent=1 adds prompt=none: if the user already signed in to ANY service, // the SSO returns straight away (no page, no click); otherwise it comes back with // error=login_required and we show login.html. app.MapGet("/auth/login", (HttpContext ctx, string? next, string? silent) => { var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); // one short-lived cookie per pending sign-in, so two tabs don't overwrite each other var opts = new CookieOptions { HttpOnly = true, Secure = ctx.Request.IsHttps, SameSite = SameSiteMode.Lax, MaxAge = TimeSpan.FromMinutes(10) }; ctx.Response.Cookies.Append("sso_st_" + state, SafeNext(next), opts); var p = new Dictionary { ["client_id"] = clientId, ["redirect_uri"] = redirectUri, ["response_type"] = "code", ["state"] = state }; if (silent == "1") p["prompt"] = "none"; var q = QueryString.Create(p); return Results.Redirect($"{SSO}/authorize{q}"); }); app.MapGet("/auth/callback", async (HttpContext ctx, IHttpClientFactory http, string? code, string? state, string? error) => { var key = "sso_st_" + (state ?? ""); var saved = string.IsNullOrEmpty(state) || state.Length != 32 ? null : ctx.Request.Cookies[key]; if (saved is null) return Results.Redirect("/login.html?error=state"); // unknown / reused state ctx.Response.Cookies.Delete(key); var next = SafeNext(saved); if (!string.IsNullOrEmpty(error)) // login_required (silent try) / access_denied return Results.Redirect("/login.html?next=" + Uri.EscapeDataString(next) + (error == "login_required" ? "" : "&error=" + Uri.EscapeDataString(error))); var res = await http.CreateClient().PostAsync($"{SSO}/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "authorization_code", ["code"] = code ?? "", ["redirect_uri"] = redirectUri, ["client_id"] = clientId, ["client_secret"] = clientSecret })); if (!res.IsSuccessStatusCode) return Results.Redirect("/login.html?error=token"); var user = JsonNode.Parse(await res.Content.ReadAsStringAsync())!["user"]!; var claims = new List { new("sub", (string)user["sub"]!), new(ClaimTypes.Name, (string?)user["name"] ?? ""), new(ClaimTypes.Email, (string?)user["email"] ?? ""), new("sso_user", user.ToJsonString()), // the whole user JSON (department, picture ...) }; await ctx.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme))); return Results.Redirect(next); }); app.MapGet("/auth/logout", async (HttpContext ctx) => { await ctx.SignOutAsync(); return Results.Redirect($"{SSO}/logout?redirect_uri={Uri.EscapeDataString(appUrl + "/")}"); }); // ---------- 2. is the user signed in? ctx.User.Identity.IsAuthenticated / .RequireAuthorization() ---------- static JsonNode? SsoUser(ClaimsPrincipal p) => p.Identity?.IsAuthenticated == true ? JsonNode.Parse(p.FindFirst("sso_user")?.Value ?? "{}") : null; // ---------- 3. user data (used by advantech-sso.js) ---------- app.MapGet("/auth/me", (ClaimsPrincipal p) => Results.Content(SsoUser(p)!.ToJsonString(), "application/json")) .RequireAuthorization(); // ---------- your app ---------- app.MapGet("/", () => Results.File(Path.Combine(app.Environment.WebRootPath, "index.html"), "text/html")) .RequireAuthorization(); app.MapGet("/api/hello", (ClaimsPrincipal p) => { var u = SsoUser(p)!; return Results.Json(new { message = $"Hello {u["name"]}", department = (string?)u["department"] }); }).RequireAuthorization(); app.Run();