ASP.NET Core steg för steg

Den här sidan förutsätter att du gjort steg 1 och 2 på Kom igång: du har ett CLIENT_ID, en CLIENT_SECRET och har bestämt en callback-adress. I exemplen nedan är callback-adressen /callback, alltså https://din-sajt.se/callback registrerad hos oss.

Det finns tre utgångslägen, och de skiljer sig mer än man tror:

  • A. Sajten har ingen inloggning alls. Aegis blir det enda sättet att logga in. Tio rader i Program.cs, klart.
  • B. Sajten har redan ASP.NET Core Identity — en AspNetUsers-tabell, lösenordsinloggning, kanske roller. Då ska Aegis läggas bredvid Identity som en extern inloggning, och en sida ska koppla Aegis-kontot till ett konto i din databas. Det är fallet där de flesta fastnar.
  • C. Sajten har en egen kakinloggning utan Identity — en egen användartabell och AddCookie(). Då tar du över i OnTicketReceived och loggar in med din egen kod, så att Aegis ger exakt samma session som lösenordet.

Börja med det som är gemensamt.

Gemensamt: paket, konfiguration, hemlighet

Installera paketet:

dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

Lägg en sektion i appsettings.json. Bara klient-id och callback står här — hemligheten kommer i nästa steg:

"Aegis": {
  "Authority": "https://login.aegiscentralid.se/",
  "ClientId": "DITT-CLIENT-ID",
  "CallbackPath": "/callback",
  "Scopes": [ "openid", "profile", "email", "tenant.role" ]
}

Lägg hemligheten i User Secrets under utveckling:

dotnet user-secrets init
dotnet user-secrets set "Aegis:ClientSecret" "DIN-CLIENT-SECRET"

På servern läggs den som miljövariabel Aegis__ClientSecret (två understreck), eller i en appsettings.Production.json som inte checkas in. Hemligheten ska aldrig stå i appsettings.json eller i kod.

A. Sajt utan inloggning

I Program.cs, före builder.Build():

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;

var aegis = builder.Configuration.GetSection("Aegis");

builder.Services.AddAuthentication(options =>
{
    // Sajten har sin egen kaka; Aegis används bara för att skapa den.
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
    options.Authority = aegis["Authority"];
    options.ClientId = aegis["ClientId"];
    options.ClientSecret = aegis["ClientSecret"];
    options.CallbackPath = aegis["CallbackPath"] ?? "/callback";

    options.ResponseType = OpenIdConnectResponseType.Code;
    options.ResponseMode = OpenIdConnectResponseMode.Query;
    options.UsePkce = true;

    options.Scope.Clear();
    foreach (var scope in aegis.GetSection("Scopes").Get<string[]>() ?? ["openid", "profile", "email"])
        options.Scope.Add(scope);

    // Allt står i id-token. Ingen extra runda till userinfo.
    options.GetClaimsFromUserInfoEndpoint = false;

    // Inga tokens i kakan — se avsnittet om proxy längst ned.
    options.SaveTokens = false;

    options.TokenValidationParameters.NameClaimType = "name";
    options.TokenValidationParameters.RoleClaimType = "tenant_role";
});

builder.Services.AddAuthorization();

Efter var app = builder.Build();, i den här ordningen:

app.UseAuthentication();
app.UseAuthorization();

Det var allt. Sätt [Authorize] på en sida eller controller, och första besöket skickar användaren till Aegis. Callback-sidan /callback finns inte i din kod — OIDC-handlern fångar den själv.

Läs anspråken som vanligt:

var namn  = User.Identity?.Name;                        // "Anna Lindqvist"
var sub   = User.FindFirst("sub")?.Value;               // permanent id — nyckeln i din databas
var epost = User.FindFirst("email")?.Value;
var roll  = User.FindFirst("tenant_role")?.Value;       // Owner, Admin eller Member

Utloggning:

return SignOut(CookieAuthenticationDefaults.AuthenticationScheme,
               OpenIdConnectDefaults.AuthenticationScheme);

B. Sajt som redan har ASP.NET Core Identity

Här ska Identity fortsätta äga användarna. Aegis blir en extern inloggning, på samma sätt som "Logga in med Google" brukar kopplas: användaren loggar in hos oss, Identity tar emot resultatet i sin externa kaka, och en sida i din sajt bestämmer vilket konto i AspNetUsers det motsvarar.

Tre delar behövs.

B1. Registrera Aegis som externt schema

I Program.cs, efter AddIdentity/AddDefaultIdentity:

using Microsoft.AspNetCore.Authentication.OpenIdConnect;

var aegis = builder.Configuration.GetSection("Aegis");

builder.Services.AddAuthentication()
    .AddOpenIdConnect("Aegis", "Aegis Central ID", options =>
    {
        // Det här är raden som skiljer B från A: resultatet läggs i Identitys
        // externa kaka, inte i sajtens vanliga inloggningskaka.
        options.SignInScheme = IdentityConstants.ExternalScheme;

        options.Authority = aegis["Authority"];
        options.ClientId = aegis["ClientId"];
        options.ClientSecret = aegis["ClientSecret"];
        options.CallbackPath = aegis["CallbackPath"] ?? "/callback";

        options.ResponseType = OpenIdConnectResponseType.Code;
        options.ResponseMode = OpenIdConnectResponseMode.Query;
        options.UsePkce = true;

        options.Scope.Clear();
        foreach (var scope in aegis.GetSection("Scopes").Get<string[]>() ?? ["openid", "profile", "email"])
            options.Scope.Add(scope);

        options.GetClaimsFromUserInfoEndpoint = false;
        options.SaveTokens = false;

        options.TokenValidationParameters.NameClaimType = "name";
        options.TokenValidationParameters.RoleClaimType = "tenant_role";

        // Visa felet på inloggningssidan i stället för en generisk felsida.
        options.Events.OnRemoteFailure = context =>
        {
            var fel = context.Failure?.GetBaseException().Message ?? "okänt fel";
            context.Response.Redirect("/Identity/Account/Login?remoteError=" + Uri.EscapeDataString(fel));
            context.HandleResponse();
            return Task.CompletedTask;
        };
    });

Anropa inte AddAuthentication med förvalda scheman här — Identity har redan satt dem, och att skriva över dem bryter lösenordsinloggningen.

Notera "Aegis" som schemanamn: det är det provider-värdet som knappen och callback-sidan i B2 och B3 använder.

B2. Knappen på inloggningssidan

Identitys inloggningssida listar redan externa leverantörer i Model.ExternalLogins om du scaffoldat den (dotnet aspnet-codegenerator identity -fi Account.Login). Har du inte scaffoldat visas knappen automatiskt i Identitys inbyggda UI. Vill du styra utseendet själv:

<form asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post">
    <button type="submit" name="provider" value="Aegis">
        Logga in med Aegis Central ID
    </button>
</form>

value="Aegis" måste vara samma sträng som schemanamnet i B1.

B3. Callback-sidan som kopplar kontot

Scaffolda Identitys ExternalLogin-sida:

dotnet aspnet-codegenerator identity -fi Account.ExternalLogin

Den scaffoldade sidan visar ett formulär där användaren får skriva in sin e-postadress. Med Aegis behövs det inte — adressen kommer verifierad i id-token — så ersätt OnGetCallbackAsync med den här logiken:

public async Task<IActionResult> OnGetCallbackAsync(string? returnUrl = null, string? remoteError = null)
{
    returnUrl ??= Url.Content("~/");
    if (remoteError != null)
        return Misslyckad($"Fel från Aegis Central ID: {remoteError}");

    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
        return Misslyckad("Kunde inte läsa inloggningsuppgifterna från Aegis Central ID.");

    // 1. Aegis-kontot är redan kopplat till ett konto hos oss → logga in.
    var result = await _signInManager.ExternalLoginSignInAsync(
        info.LoginProvider, info.ProviderKey, isPersistent: true, bypassTwoFactor: true);
    if (result.Succeeded)
        return LocalRedirect(returnUrl);
    if (result.IsLockedOut)
        return Misslyckad("Kontot är låst.");

    // 2. Ingen koppling. Hitta eller skapa kontot via den verifierade e-posten.
    var email = info.Principal.FindFirstValue(ClaimTypes.Email)
             ?? info.Principal.FindFirstValue("email");
    var verifierad = string.Equals(info.Principal.FindFirstValue("email_verified"), "true",
                                   StringComparison.OrdinalIgnoreCase);
    if (string.IsNullOrWhiteSpace(email) || !verifierad)
        return Misslyckad("Aegis Central ID lämnade ingen verifierad e-postadress.");

    var user = await _userManager.FindByEmailAsync(email);
    if (user == null)
    {
        user = new IdentityUser { UserName = email, Email = email, EmailConfirmed = true };
        var create = await _userManager.CreateAsync(user);
        if (!create.Succeeded)
            return Misslyckad(string.Join(" ", create.Errors.Select(e => e.Description)));
    }

    // 3. Spara kopplingen (sub → konto) i AspNetUserLogins, och logga in.
    var link = await _userManager.AddLoginAsync(user, info);
    if (!link.Succeeded)
        return Misslyckad(string.Join(" ", link.Errors.Select(e => e.Description)));

    await _signInManager.SignInAsync(user, isPersistent: true, info.LoginProvider);
    return LocalRedirect(returnUrl);
}

private IActionResult Misslyckad(string meddelande)
{
    ErrorMessage = meddelande;      // [TempData]-egenskap som Login-sidan visar
    return RedirectToPage("./Login");
}

Byt IdentityUser mot din egen användarklass om du har en. Har du extra obligatoriska fält — förnamn, efternamn — fyll dem från name-anspråket:

var namn = info.Principal.FindFirstValue("name") ?? "";

Så här hänger det ihop, om du vill förstå i stället för att kopiera:

Adress Vem svarar Vad händer
POST /Identity/Account/ExternalLogin?provider=Aegis Din ExternalLogin-sida Skickar användaren till Aegis
GET /callback?code=…&state=… OIDC-handlern, ingen kod hos dig Löser in koden, verifierar id-token, lägger anspråken i Identitys externa kaka
GET /Identity/Account/ExternalLogin?handler=Callback Din ExternalLogin-sida (koden ovan) Läser den externa kakan, hittar eller skapar kontot, loggar in

Två saker som brukar förvåna: callback-adressen (/callback) och ExternalLogin-sidan är två olika adresser, och det är bara den första som registreras hos Aegis. Och sub lagras i AspNetUserLogins, inte i AspNetUsers — det är så Identity kommer ihåg att ett Aegis-konto hör till ett lokalt konto.

C. Sajt med egen kakinloggning, utan Identity

Många mindre sajter har en egen adminmodell: en tabell med användare, ett lösenord hashat med BCrypt, AddCookie() och en egen SignInAsync som sätter kakan. Ingen ASP.NET Core Identity. Här ska Aegis inte utfärda någon egen session — resultatet ska bli exakt samma kaka som lösenordsinloggningen ger, så att allt sajten byggt runt kakan (säkerhetsstämpel, sessionskontroll, händelselogg) fungerar orört.

Nyckeln är händelsen OnTicketReceived: OIDC-handlern har då verifierat id-token, och du tar över innan den hinner logga in någon.

C1. Registrera schemat bredvid kakan

var auth = builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options => { /* din befintliga konfiguration */ });

var aegis = builder.Configuration.GetSection("Aegis");

// Bara när klient-id och hemlighet finns — annars syns ingen knapp och inget ändras.
if (!string.IsNullOrEmpty(aegis["ClientId"]) && !string.IsNullOrEmpty(aegis["ClientSecret"]))
{
    auth.AddOpenIdConnect("Aegis", "Aegis Central ID", options =>
    {
        options.Authority = aegis["Authority"];
        options.ClientId = aegis["ClientId"];
        options.ClientSecret = aegis["ClientSecret"];
        options.CallbackPath = aegis["CallbackPath"] ?? "/callback";

        options.ResponseType = OpenIdConnectResponseType.Code;
        options.ResponseMode = OpenIdConnectResponseMode.Query;
        options.UsePkce = true;

        options.Scope.Clear();
        options.Scope.Add("openid");
        options.Scope.Add("profile");
        options.Scope.Add("email");

        options.GetClaimsFromUserInfoEndpoint = false;
        options.SaveTokens = false;

        // Behåll OIDC-namnen (sub, email, email_verified) i stället för .NET:s URI-namn.
        options.MapInboundClaims = false;

        options.Events.OnTicketReceived = OnTicketReceivedAsync;
        options.Events.OnRemoteFailure = context =>
        {
            context.HandleResponse();
            context.Response.Redirect("/login?aegis=fel");
            return Task.CompletedTask;
        };
    });
}

SignInScheme behöver inte sättas: den ärver kakschemat från AddAuthentication, och eftersom handlern aldrig kommer så långt som till att logga in spelar det ingen roll.

C2. Ta över i OnTicketReceived

static async Task OnTicketReceivedAsync(TicketReceivedContext context)
{
    var http = context.HttpContext;
    var db = http.RequestServices.GetRequiredService<MinDbContext>();

    // Handlern ska varken sätta en kaka eller omdirigera. Det gör vi.
    context.HandleResponse();

    var principal = context.Principal!;
    var sub = principal.FindFirstValue("sub");
    var email = (principal.FindFirstValue("email") ?? "").ToLowerInvariant();
    var emailVerified = principal.FindFirstValue("email_verified") == "true";

    // 1. Redan kopplat konto: matcha enbart på sub.
    var user = await db.AdminUsers.FirstOrDefaultAsync(u => u.AegisSub == sub);

    // 2. Ingen koppling: bind en inbjuden rad via verifierad e-post, en gång.
    if (user is null && emailVerified)
    {
        user = await db.AdminUsers.FirstOrDefaultAsync(u => u.AegisSub == null && u.Email == email);
        if (user is not null) user.AegisSub = sub;
    }

    // 3. Ingen rad alls: ingen åtkomst, hur giltigt Aegis-kontot än är.
    if (user is null || !user.IsActive)
    {
        http.Response.Redirect("/login?aegis=obehorig");
        return;
    }

    user.LastLoginAt = DateTime.UtcNow;
    await db.SaveChangesAsync();

    // Din befintliga inloggning — samma kaka som lösenordsvägen.
    await MinInloggning.SignInAsync(http, user);

    http.Response.Redirect(context.ReturnUri ?? "/admin");
}

C3. Knappen och inbjudan

Knappen är ett formulär som svarar med Challenge:

public IActionResult OnPostAegis(string? returnUrl) =>
    Challenge(new AuthenticationProperties { RedirectUri = returnUrl ?? "/admin" }, "Aegis");

Inbjudan är en rad i din användartabell med e-post, rättigheter, en flagga UsesAegis = true, tom lösenordshash och AegisSub = null. Lösenordsvägen ska avvisa sådana rader innan den jämför något, och sidor för lösenordsbyte, TOTP och passkeys ska visa en förklaring i stället för formulär — allt det sköts av Aegis, där tvåfaktor är obligatoriskt.

Tänk på att ha kvar ett lokalt konto med lösenord för den som äger sajten. Det är den sista vägen in om Aegis inte går att nå.

Om sajten står bakom en omvänd proxy

Avslutas TLS i Nginx, Nginx Proxy Manager, IIS ARR, Traefik eller Cloudflare framför din applikation behövs det här, annars fungerar inloggningen inte:

// Före builder.Build()
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor;
    options.KnownProxies.Add(IPAddress.Parse("192.168.1.10"));   // proxyns adress
});

// Först i kedjan efter builder.Build(), före allt annat
app.UseForwardedHeaders();

Utan KnownProxies ignoreras huvudena tyst: applikationen bygger redirect_uri som http://…, och Aegis svarar att adressen inte matchar den registrerade. Vanliga sidor fungerar, bara inloggningen går sönder — det är signalen.

SaveTokens = false i exemplen ovan är inte kosmetika. Med true hamnar id-token och access token i kakan som sätts i svaret från /callback, svaret blir över 4 kB huvuden, och de flesta proxyer svarar då 502 — efter att koden redan lösts in, så en omladdning ger invalid_grant. Behöver du tokens efteråt, höj proxy_buffer_size i proxyn i stället.

Kontrollista

  • Microsoft.AspNetCore.Authentication.OpenIdConnect installerat
  • Aegis:ClientId = CLIENT_ID från arbetsytan
  • Aegis:ClientSecret i User Secrets / miljövariabel, inte i appsettings.json
  • CallbackPath identisk med den registrerade callback-adressen, tecken för tecken
  • Varje scope i Aegis:Scopes ibockat på klienten hos oss
  • Vid Identity: SignInScheme = IdentityConstants.ExternalScheme och knappens value = schemanamnet
  • Bakom proxy: UseForwardedHeaders + KnownProxies, och SaveTokens = false
  • app.UseAuthentication() före app.UseAuthorization()
  • Har sajten en CSP med form-action: lägg till https://login.aegiscentralid.se, annars stannar klicket tyst i Chrome