SPA + ASP.NET Core Web API
Gleich vorweg, es geht um Berechtigungen – mein Lieblingsthema (Sarkasmus aus…). Und zwar geht es im Konkreten darum, dass eine Single-Page Application, die auf der Domäne A liegt, auf Endpunkte eines ASP.NET Core Web API Projektes zugreifen soll, die auf der Domäne B liegen. Der Fall ist mMn durchaus realistisch, da bspw. die SPA in einer Azure Static Web App liegt, die Web-API in einer Container App.
Ist die SPA und die Web-API in der gleichen Domäne, so könnte man dies einfach alles in der Web-API implementieren und über Auth-Cookies lösen. Da wir aber die Domäne durchbrechen, klappt dies nicht mehr, da dieses Cookie nicht an eine andere Domäne weitergegeben werden kann/darf.
Daher kommt jetzt Plan B ins Spiel: OAuth 2.0 Authorization Code Flow gepaart mit Proof Key for Code Exchange (PKCE) 😊. Der Ablauf dazu ist auf der Microsoft-Seite ziemlich „detailliert“ beschrieben 🙈.
Ablauf
Stark vereinfacht sieht der geplante Ablauf wie folgt aus:
- Die SPA initiiert den Login in Azure AD und erhält die Tokens (Access-Token, Refresh-Token und anderem Geraffel)
- Bei Requests der SPA an die Web-API senden wir den Access-Token als Authorization-Header mit, wodurch die Web-API prüfen kann, ob wir wirklich der sind, den wir vorgeben zu sein.
Einrichtung
Azure Application Registration
Als ersten Schritt müssen wir eine App registrieren. Dies wird im Azure Portal unter „Azure Active Directory“ > „App registrations“ gemacht.
Wichtig ist, dass SPA ausgewählt wird. Bei Redirect-URL wird im ersten Schritt die URL angegeben, die wir beim Test der SPA verwenden. Wir werden im Folgeschritt auch noch die Domäne ergänzen, auf der die App dann produktiv läuft.
Dann erstellen wir einen Scope, den wir für die Web API benötigen.
Den Scope hinterlegen wir in „API permissions“.
SPA
Als nächstes passen wir unsere SPA an, indem wir das Paket @azure/msal-browser installieren.
npm i @azure/msal-browser
Dann erzeugen wir eine Datei mit der Konfiguration und einigen Hilfsmethoden.
Teil 1 ist die Konfiguration
import * as msal from "@azure/msal-browser";
const msalConfig = {
auth: {
clientId: "<Client ID>",
authority: "https://login.microsoftonline.com/<ID des Tenants>",
redirectUri: location.port
? "http://localhost:3000"
: "https://aca2209282front.jollyisland",
},
cache: {
cacheLocation: "localStorage",
storeAuthStateInCookie: false
}
};
Die benötigte Client ID und Tenant ID findet man in der App Registrierung auf der Seite Overview.
Als Nächstes definieren wir die Scopes, die wir benötigen. Beim ersten Login aus unserer App muss der Benutzer nach dem Login in Azure AD zustimmen (= Consent), dass unsere App diese Berechtigungen erhält.
const loginRequest: RedirectRequest = {
scopes: [
"User.Read"
],
extraScopesToConsent: [
"api://d95fd485-5d00-484c-9edf-2dfbc3aa9093/access_as_user"
]
};
Hier sind zwei Punkte anzuführen:
- Die Scopes dürfen nicht aus unterschiedlichen Ressourcen stammen (bspw. hier kommt „User.Read“ aus Microsoft Graph und „api://…“ aus unserer App). Weitere Informationen dazu unter https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/resources-and-scopes.md#working-with-multiple-resources.
- api://d95fd485-5d00-484c-9edf-2dfbc3aa9093/access_as_user ist der Scope, denn wir bei der App Registrierung im Punkt „Expose an API“ erstellt haben.
Jetzt wird es spannend 😊 Wir können das MSAL-Objekt erstellen, mit dem wir u. a. den Login durchführen können.
const msalObj = new msal.PublicClientApplication(msalConfig);
msalObj.handleRedirectPromise()
.then(handleResponse)
.catch((error) => {
console.error(error);
});
function handleResponse(response: msal.AuthenticationResult) {
if (response) {
setAccount(response.account);
} else {
selectAccount();
}
}
function setAccount(account: msal.AccountInfo) {
//mit dem Account können wir dann weiterarbeiten;
}
function selectAccount () {
const currentAccounts = msalObj.getAllAccounts();
if (currentAccounts.length === 0) {
return;
} else if (currentAccounts.length > 1) {
console.warn("Es wurde mehr als ein Account gefunden?!");
} else if (currentAccounts.length === 1) {
setAccount(currentAccounts[0]);
}
}
Sowie unsere Methode für den Login. Wenn diese aufgerufen wird, werden wir zur Microsoft Seite weitergeleitet, bei der sich der Benutzer anmelden kann.
export function signIn() {
msalObj.loginRedirect(loginRequest);
}
Wenn wir angemeldet sind, dann können wir mit dem Account den AccessToken abfragen, den wir dann als Authorization-Header an die Web-API weitergeben.
export async function getToken(account: msal.AccountInfo) {
const request: SilentRequest = {
account,
scopes: [
"api://d95fd485-5d00-484c-9edf-2dfbc3aa9093/access_as_user"
]
};
try {
return await msalObj.acquireTokenSilent(request);
} catch (ex) {
if (ex instanceof msal.InteractionRequiredAuthError) {
return await msalObj.acquireTokenRedirect(request);
}
console.warn(ex);
}
}
Den Account hierfür haben wir ja schon in der Methode „setAccount“ bekommen. Das Ergebnis ist ein Objekt, das u. a. eine Eigenschaft „accessToken“ enthält. Damit erstellen wir den Bearer-Header.
const token = await getToken(account);
const r = await fetch("http://meineapi.at/api", {
credentials: "include",
headers: {
Authorization: `Bearer ${token.accessToken}`
}
});
Kleine, aber wichtige Bemerkung am Rande. Wenn die Web-API in einer anderen Domäne ist, dann müssen wir beim Fetch die credentials hinzufügen.
Web API
Im ASP.NET Core Web API Projekt benötigen wir das NuGet-Paket „Microsoft.Identity.Web“.
Dann werden die benötigen Services konfiguriert:
Wichtig ist in diesem Zusammenhang die korrekte Konfiguration von CORS. Die Origins müssen explizit benannt sein (any geht nicht!), AllowCredentials() muss aktiviert sein und zumindest der Header „Authorization“ (oder eben alle) müssen erlaubt sein.
Dann noch die Middlewares aktivieren.
Und zu guter Letzt noch ein Blick in die appsettings.json 😜.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Information"
}
},
"AllowedHosts": "*",
"AzureAd": {
"Instance": "https://login.microsoftonline.com",
"ClientId": "<Client ID>",
"TenantId": "<Tenant ID>",
"Audience": "api://d95fd485-5d00-484c-9edf-2dfbc3aa9093"
}
}
Client ID + Tenant ID ist ident wie zuvor. Wichtig hier ist „Audience“. Das ist der Wert aus der App Registrierung unter „Expose an API“. Hier allerdings ohne den Scope, sondern nur den vorderen Teil!
Good Luck 💚