@blackforestsentinel/seed-web-auth
v0.8.0
Published
Anmeldung per MSAL für Frontends in Sentinel-Seed-Projekten: Login mit stiller Erneuerung, Access-Tokens für die Function-API und Capabilities der angemeldeten Person für das UI; dazu ein Einstieg für Browser-Erweiterungen.
Maintainers
Readme
@blackforestsentinel/seed-web-auth
Anmeldung per MSAL für Frontends in Projekten aus Sentinel Seed. Die Werte kommen aus der Laufzeitkonfiguration, die das Terraform-Modul sso über die Pipeline in config.json schreibt.
import { createHttpClient, loadRuntimeConfig } from '@blackforestsentinel/seed-web-core';
import { createSeedAuth, isSeedAuthConfig } from '@blackforestsentinel/seed-web-auth';
const config = await loadRuntimeConfig<{ apiBaseUrl: string; auth?: unknown }>();
if (isSeedAuthConfig(config.auth)) {
const auth = await createSeedAuth(config.auth);
if (!auth.account) await auth.login();
const api = createHttpClient({ baseUrl: config.apiBaseUrl, getAccessToken: auth.getAccessToken });
}createSeedAuth()schließt eine laufende Anmeldung per Redirect ab; danach istaccountgesetzt.getAccessToken()holt Tokens still und leitet nur um, wenn Entra ID eine Interaktion verlangt. Dann wirft esSeedAuthRedirectError.getAccessToken(scopes)holt ein Token für genau diese Berechtigungen, etwa für Microsoft Graph (unten).- Redirect-Ziel ist der Origin der Seite mit abschließendem Schrägstrich (
https://app.example.org/). Das Terraform-Modulssoträgt genau diese Form für die Static Web App und indevfürhttp://localhost:5173/ein. - Browser-Erweiterungen nutzen statt
createSeedAuth()den Einstieg/extensionmit demselben Interface (unten, „Browser-Erweiterungen“).
Stille Sitzungserneuerung
Entra ID begrenzt das Refresh-Token einer SPA auf 24 Stunden. Danach erneuert seed-web-auth die Sitzung in dieser Reihenfolge und geht erst zum nächsten Schritt, wenn der vorige scheitert:
- Access-Token aus dem Cache oder per Refresh-Token, ohne iframe.
ssoSilentmit dem Benutzernamen alsloginHintin einem unsichtbaren iframe. Das nutzt die Sitzung bei Entra ID statt des abgelaufenen Refresh-Tokens, braucht aber die Bridge-Seite (unten) und Cookies von Drittanbietern.- Umleitung zu Entra ID mit
loginHintundprompt=none: Mit Sitzung kommt die Seite sofort zurück, ohne Sitzung mit einem Fehler statt eines Anmeldedialogs. Erst der nächste Versuch im selben Tab ist interaktiv.
Netzwerkfehler leiten nicht um, alle anderen Fehler beim stillen Erneuern schon; auch timed_out und redirect_bridge_timeout, die MSAL statt eines InteractionRequiredAuthError meldet. Parallele Aufrufe teilen sich eine Erneuerung. Der Benutzername liegt im localStorage, damit auch ein neuer Tab still anmelden kann; logout() löscht ihn. Wer noch nie angemeldet war, wird direkt interaktiv umgeleitet.
Bridge-Seite
MSAL 5 lädt die Redirect-URI der stillen Anmeldung im iframe und erwartet dort eine eigene Seite, die die Antwort per broadcastResponseToMainFrame an die App weiterreicht. Die Startseite der App kann das nicht. Ohne Bridge-Seite überspringt seed-web-auth den Schritt ssoSilent und leitet direkt um.
<!-- frontend/redirect.html, in vite.config.ts als zweiter Einstieg -->
<script type="module" src="/src/redirect.ts"></script>// frontend/src/redirect.ts
import { handleSeedAuthRedirect } from '@blackforestsentinel/seed-web-auth/bridge';
void handleSeedAuthRedirect();Die Redirect-URI <origin>/redirect.html trägt das Modul sso mit spa_redirect_bridge_path = "/redirect.html" ein und schreibt redirectBridgePath in config.json.
Tokens für Microsoft Graph
getAccessToken() ohne Argument liefert das Token für die API. Mit einer Liste von Scopes liefert es eines für genau diese, etwa für Graph:
const token = await auth.getAccessToken(['https://graph.microsoft.com/User.ReadBasic.All']);- Dieselbe Reihenfolge wie für die API: Cache und Refresh-Token, dann
ssoSilent, dann Umleitung. - Parallele Aufrufe je Scope-Satz teilen sich eine Anfrage; die Reihenfolge der Scopes spielt keine Rolle.
- Fehlt die Einwilligung, leitet es je Tab und Scope-Satz höchstens zweimal um: erst ohne Dialog, dann mit, damit die Person zustimmen kann. Danach wirft es
SeedAuthScopeErrorstatt erneut umzuleiten, und die App läuft ohne diese Berechtigungen weiter. Kommt die Umleitung mitaccess_deniedzurück (Einwilligung abgelehnt oder nicht erlaubt), startet die App trotzdem. - Ein Token für die API setzt den Merker für Graph nicht zurück,
logout()schon.
@blackforestsentinel/seed-web-people holt so Fotos und Profile. Welche Graph-Berechtigungen das Frontend anfragt, steht in config.json unter auth.directory (Typ SeedDirectoryConfig); das Terraform-Modul sso schreibt den Teil, wenn project.yaml das Verzeichnis einschaltet:
"auth": {
"clientId": "…", "tenantId": "…", "apiScope": "…", "redirectBridgePath": "/redirect.html",
"directory": {
"scopes": ["https://graph.microsoft.com/User.Read", "https://graph.microsoft.com/User.ReadBasic.All", "https://graph.microsoft.com/People.Read", "https://graph.microsoft.com/Presence.Read.All"],
"presence": true,
"roleFilter": false,
"apiClientId": "<Client-ID der API-Registrierung>"
}
}Im lokalen Modus gibt es keinen Graph-Zugriff; auth.directory.people enthält dort erfundene Personen (Typ SeedLocalDirectoryConfig).
Browser-Erweiterungen
Eine Erweiterung (Edge, Chrome, Manifest V3) läuft unter chrome-extension://<id> statt auf einer Web-Origin. Umleitung der ganzen Seite, Token-Cache je Tab und Bridge-Seite passen dort nicht. Dafür gibt es den Einstieg /extension: createSeedExtensionAuth() liefert dasselbe Interface SeedAuth wie createSeedAuth(), damit laufen createHttpClient, SeedUserProvider und die Komponenten aus @blackforestsentinel/seed-web-people unverändert. Er meldet per Autorisierungscode mit PKCE über chrome.identity.launchWebAuthFlow an und lädt MSAL nicht (rund 3 KB gzip im Bundle).
import { createHttpClient, loadRuntimeConfig } from '@blackforestsentinel/seed-web-core';
import { createSeedExtensionAuth, isSeedAuthConfig, SeedAuthInteractionRequiredError } from '@blackforestsentinel/seed-web-auth/extension';
const config = await loadRuntimeConfig<{ apiBaseUrl: string; auth?: unknown }>({ url: chrome.runtime.getURL('config.json') });
if (isSeedAuthConfig(config.auth)) {
const auth = await createSeedExtensionAuth(config.auth, { domainHint: 'example.org' });
const api = createHttpClient({ baseUrl: config.apiBaseUrl, getAccessToken: auth.getAccessToken });
try {
render(await api.get('/api/items'));
} catch (error) {
if (error instanceof SeedAuthInteractionRequiredError) {
// „Anmeldung erneuern“ anzeigen und erst nach dem Klick auth.login() aufrufen.
}
}
}getAccessToken(scopes?) geht diese Stufen durch und nimmt die nächste nur, wenn die vorige scheitert:
- Access-Token aus dem Speicher oder per Refresh-Token.
launchWebAuthFlowohne Fenster (interactive: false) mitprompt=noneund dem Benutzernamen alslogin_hint. Das nutzt die Sitzung bei Entra ID im Browser-Profil, etwa nach den 24 Stunden, die ein Refresh-Token einer SPA gilt, oder nach einem Browser-Neustart.- Kein Fenster:
SeedAuthInteractionRequiredError. Für zusätzliche Berechtigungen (etwa Graph) gilt wie im WebSeedAuthScopeError, wenn nur die Einwilligung fehlt.
- Nur
login()öffnet ein Fenster, mitdomain_hintund, falls bekannt,login_hint. Die App ruft es nur nach einem Klick auf. Nirgends leitet der Einstieg die Seite um. accountkommt beim Start sofort auschrome.storage.local, ohne Netz und ohne Token; der neue Tab kann also gleich den Namen zeigen. Dort liegt auch der Benutzername fürlogin_hint, damit der Service Worker ihn kennt.- Die Tokens gelten für alle Seiten und den Service Worker der Erweiterung gemeinsam.
tokenStorage: 'session'(Default) legt sie inchrome.storage.session: nur im Speicher, nach einem Neustart meldet Stufe 2 still neu an.tokenStorage: 'local'legt sie inchrome.storage.local, wo sie Neustarts überdauern, solange das Refresh-Token gilt (Warnung unten). Beim Start löschtcreateSeedExtensionAuth()die Tokens aus dem jeweils anderen Bereich; nach einem Wechsel zurück auf'session'bleibt kein Refresh-Token liegen. - Parallele Aufrufe teilen sich eine Erneuerung, auch über Tabs hinweg (
navigator.locks). - Scheitert Stufe 2, versucht der Einstieg sie für denselben Scope-Satz in dieser Browser-Sitzung nicht noch einmal, sondern wirft sofort;
login()setzt das zurück. Netzwerk- und Serverfehler des Token-Endpunkts lösen Stufe 2 nicht aus, sondern gehen weiter. logout()löscht Tokens, Konto und Merker. Die Sitzung bei Entra ID gehört dem Browser-Profil und bleibt.redirectBridgePathaus der Konfiguration gilt hier nicht; sonst ist sie dieselbe wie im Web, auchdirectoryfür Graph.
Warnung zu
tokenStorage: 'local': Das Refresh-Token liegt dann unverschlüsselt im Browser-Profil auf der Platte und ist für die Content Scripts der Erweiterung lesbar, die in fremden Webseiten laufen; eine Lücke dort reicht, um es abzugreifen. Damit lassen sich bis zu 24 Stunden lang Tokens für die API holen, mit Einwilligung auch für Graph. Beim Default'session'bleiben, Stufe 2 überbrückt Neustarts ohne Fenster. Den Zugriff aufchrome.storage.sessionnie perchrome.storage.session.setAccessLevel()für Content Scripts öffnen (TRUSTED_AND_UNTRUSTED_CONTEXTS); dann gälte dort dasselbe.
Einrichtung:
manifest.jsonbraucht"permissions": ["identity", "storage"].host_permissionsfür Entra ID oder die API sind nicht nötig. Mitkeyim Manifest behält eine entpackte Erweiterung ihre ID; die Store-Version bekommt ihre ID vom Store.- Die Redirect-URI ist
chrome.identity.getRedirectURL(), alsohttps://<erweiterungs-id>.chromiumapp.org/. Sie gehört an die SPA-Registrierung, damit der Token-Endpunkt die Einlösung mitOrigin: chrome-extension://<id>annimmt; im Template je Umgebung überauth.extraRedirectUrisinproject.yaml. Die API muss die Originchrome-extension://<id>per CORS zulassen (hosting.corsAllowedOrigins). Beides beschreibt die README von seed-template, Abschnitt „Browser-Erweiterungen“. config.jsonliegt im Paket der Erweiterung; die Pipeline aus seed-pipelines schreibt sie je Umgebung (Parameterextension).
Capabilities im UI
Die API liefert unter GET /api/me die angemeldete Person samt Capabilities, aufgelöst aus ihren App-Rollen (SeedUserInfo aus Bfs.Seed.Auth). Das Frontend kennt die Zuordnung nicht, es blendet nur ein oder aus. Durchgesetzt werden die Rechte in der API per [RequireCapability].
import { IfCapability, SeedUserProvider, loadSeedUser, useCapabilities } from '@blackforestsentinel/seed-web-auth/react';
<SeedUserProvider load={() => loadSeedUser(http)}>
<IfCapability capability="invoices.write" fallback={<p>Nur lesen</p>}>
<button>Rechnung anlegen</button>
</IfCapability>
</SeedUserProvider>
const canWrite = useCapabilities().has('invoices.write');SeedUserProviderlädt die Person einmal beim Einhängen; solange sie lädt, ohne Anmeldung oder bei einem Fehler sind keine Capabilities gesetzt und geschützte Elemente bleiben aus.useSeedUser()liefert{ status, user }mitstatusloading,readyodererror.user.roleDetailsnennt die Anzeigenamen der Rollen ausproject.yaml({ value: 'Admin', displayName: 'Administrator' }), wenn die API sie liefert;roleDisplayNames(user)gibt sie in der Reihenfolge vonroleszurück, ohne Anzeigenamen den Wert.- Ohne React:
loadSeedUser(http)undhasCapability(user, 'invoices.write')aus dem Haupteinstieg.
Der Einstieg /react braucht React ab Version 18 und lädt MSAL nicht.
Lokaler Modus
Steht in config.json "auth": { "mode": "local" }, startet das Frontend ohne MSAL (isSeedLocalAuthConfig()); die API läuft dann mit Auth:Mode = Local und liefert unter /api/me den Entwicklungsnutzer mit seinen Capabilities.
