Actions & Sandbox-Ausführung
Das Problem: Erweiterbarkeit ohne Forking
Jede Identitätsplattform begegnet letztendlich der gleichen Spannung: Kunden benötigen benutzerdefinierte Logik in ihren Authentifizierungs-Flows, aber die Plattform kann nicht jede Anforderung antizipieren. Ohne einen Erweiterungsmechanismus sind die Optionen:
| Ansatz | Problem |
|---|---|
| Feature-Requests | Langsam, blockiert Kunden, jeder Anwendungsfall ist anders |
| Webhooks zu externen Diensten | Fügt Latenz hinzu, erfordert, dass der Kunde einen Webhook-Server hostet, kann den Flow nicht synchron blockieren |
| Plattform forken | Wartungsalptraum, blockiert Upgrades |
| Nur-Konfigurations-Regeln | Zu begrenzt für reale Logik |
Was Kunden tatsächlich brauchen, ist die Möglichkeit, benutzerdefinierten Code an bestimmten Punkten im Authentifizierungs-Flow auszuführen — vor der Anmeldung (um verdächtige Versuche zu blockieren), nach der Anmeldung (um Tokens mit benutzerdefinierten Claims anzureichern), nach der Registrierung (um Onboarding-Workflows auszulösen) und mehr.
Die Herausforderung besteht darin, dies sicher zu tun. Kundencode läuft innerhalb des Autorisierungsservers, dem sicherheitskritischsten Komponenten der gesamten Infrastruktur. Ein Fehler in benutzerdefiniertem Code darf den Server nicht zum Absturz bringen, Daten von anderen Tenants nicht preisgeben oder Authentifizierungskontrollen umgehen.
Branchenansätze
Verschiedene Identitätsanbieter lösen dieses Problem auf grundlegend unterschiedliche Weisen:
| Anbieter | Mechanismus | Sprache | Ausführungsmodell | Latenzauswirkung |
|---|---|---|---|---|
| Auth0 | Actions | JavaScript/TypeScript | Isolierte VM (Deno-ähnliche Sandbox) | Mittel (VM-Start) |
| Okta | Hooks (Inline/Event) | K. A. (Webhook) | Externer HTTP-Aufruf an Kundendienst | Hoch (Netzwerk-Roundtrip) |
| Firebase | Extensions | TypeScript | Cloud Functions (separater Prozess) | Hoch (Cold Start + Ausführung) |
| Keycloak | SPIs (Service Provider Interfaces) | Java | Classpath-Plugin, beim Start geladen | Niedrig (gleicher Prozess) |
| Auris | Actions | JavaScript | Sandgeboxte Function-Constructor | Niedrig (gleicher Prozess, keine VM) |
Jeder Ansatz macht einen anderen Kompromiss zwischen Isolation, Leistung und Entwicklererfahrung:
- V8-Isolates (Auth0): Starke Isolation, aber VM-Start fügt Latenz und Infrastrukturkomplexität hinzu.
- Webhooks (Okta): Vollständige Isolation (läuft auf der Infrastruktur des Kunden), aber fügt Netzwerklatenz hinzu und erfordert, dass der Kunde einen Server betreibt.
- Classpath-Plugins (Keycloak): Null Overhead, erfordert aber Java-Expertise, komplexes Deployment und Neustart für Updates.
- Function constructor (Auris): Sehr niedriger Overhead, läuft im gleichen Node.js-Prozess, mit Isolation auf Code-Analyse-Ebene statt Runtime-Ebene.
Funktionsweise der Auris-Sandbox
Auris Actions führen benutzerdefiniertes JavaScript in einer kontrollierten Umgebung mithilfe des JavaScript-Function-Constructors aus. Dieser Mechanismus erstellt eine Funktion aus einem Code-String mit einem expliziten Scope — der Action-Code sieht nur Variablen, die Auris explizit bereitstellt.
Die Ausführungspipeline
Schritt 1: Statische Analyse
Vor der Ausführung von Code scannt Auris die Quelle der Action auf blockierte Muster:
const BLOCKED_PATTERNS = [
'require', // Kein Modul-Laden
'import', // Keine ES-Modul-Imports
'process', // Kein Zugriff auf process.env, process.exit usw.
'global', // Kein Zugriff auf das globale Objekt
'child_process', // Kein Subprocess-Spawning
'fs', // Kein Dateisystemzugriff
]Wenn ein blockiertes Muster im Quellcode gefunden wird, wird die Action vor der Ausführung abgelehnt. Dies ist eine Defense-in-Depth-Maßnahme.
Statische Analyse ist allein keine Sicherheitsgrenze. Ein entschlossener Angreifer könnte blockierte Schlüsselwörter verschleiern. Die statische Analyse existiert, um versehentlichen Missbrauch zu erkennen und klare Fehlermeldungen bereitzustellen. Die echte Sicherheit kommt vom eingeschränkten Scope-Objekt, das der konstruierten Funktion bereitgestellt wird.
Schritt 2: Funktionskonstruktion
Der Action-Code-String wird verwendet, um eine Funktion mit context als einzigem Parameter zu erstellen:
// Konzeptionell äquivalent zu:
function anonymous(context) {
// actionCode läuft hier
// Es kann NUR zugreifen auf:
// - 'context' (das Argument)
// - JavaScript-Built-ins (Array, Object, String, Math, Date, JSON usw.)
// - fetch (explizit für ausgehende HTTP bereitgestellt)
// - console (erfasst, nicht echtes stdout)
}Die kritische Eigenschaft: Die Funktion hat keinen Closure über die Variablen des Servers. Sie kann nicht auf require, process, __dirname, Datenbankverbindungen oder andere serverseitige Zustände zugreifen.
Schritt 3: Context-Objekt
Der context-Parameter stellt der Action Informationen über das aktuelle Authentifizierungsereignis und begrenzte Fähigkeiten bereit:
interface ActionContext {
// Identitätsinformationen
user: {
id: string
email: string
username: string
roles: string[]
metadata: Record<string, unknown>
}
// Anfrageinformationen
request: {
ip: string
userAgent: string
geoip?: { country: string; city: string }
}
// Tenant-Informationen
tenant: {
id: string
name: string
}
// Anwendungsinformationen
application: {
id: string
clientId: string
name: string
}
// Trigger-spezifische Daten
trigger: string // 'pre_login' | 'post_login' | 'pre_signup' | 'post_signup' | ...
// Erlaubte Utilities
fetch: typeof fetch // Ausgehende HTTP (für Webhook-artige Integrationen)
console: { // Erfasste Protokollierung (geht zu ActionLog, nicht stdout)
log: (...args: unknown[]) => void
warn: (...args: unknown[]) => void
error: (...args: unknown[]) => void
}
}Schritt 4: Timeout-Durchsetzung
Jede Action-Ausführung ist in ein Promise.race() mit einem konfigurierbaren Timeout (Standard 5 Sekunden) eingewickelt:
const result = await Promise.race([
fn(context),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Action timed out')), action.timeout)
),
])Schritt 5: Ergebnis-Verarbeitung
Der Rückgabewert der Action bestimmt, was als nächstes passiert:
interface ActionResult {
// Pre-Trigger können den Flow blockieren
allow?: boolean // false = Anmeldung/Registrierung blockieren
// Post-Trigger können dem JWT benutzerdefinierte Claims hinzufügen
claims?: Record<string, unknown>
// Actions können Metadaten zum Benutzerdatensatz hinzufügen
metadata?: Record<string, unknown>
// Menschenlesbarer Ablehnungsgrund (dem Benutzer angezeigt, wenn allow=false)
reason?: string
}Die Trigger-Pipeline
Actions sind nach Trigger organisiert — dem Punkt im Authentifizierungs-Flow, an dem sie ausgeführt werden:
| Trigger | Wann er feuert | Kann blockieren? | Kann Token anreichern? |
|---|---|---|---|
pre_login | Nach Anmeldedaten-Validierung, vor Sitzungserstellung | Ja | Nein |
post_login | Nach erfolgreicher Anmeldung, vor Token-Ausstellung | Nein | Ja (Claims hinzufügen) |
pre_signup | Nach Registrierungsformular-Validierung, vor Benutzererstellung | Ja | Nein |
post_signup | Nach Benutzererstellung in der Datenbank | Nein | Ja (Metadaten hinzufügen) |
post_change_password | Nach Passwortänderung | Nein | Nein |
pre_m2m_token | Vor Ausstellung eines M2M-Tokens via client_credentials | Ja | Ja (Claims hinzufügen) |
Sequentielle Ausführung
Mehrere Actions können für denselben Trigger registriert werden. Sie werden sequentiell in der durch das order-Feld definierten Reihenfolge ausgeführt. Wenn Action 1 { allow: false } zurückgibt, werden Actions 2 und 3 übersprungen.
Wenn Action 2 eine unbehandelte Ausnahme wirft, wird der Flow standardmäßig blockiert (Fail-Secure). Dies verhindert, dass eine defekte Action versehentlich unbefugten Zugriff ermöglicht.
Wenn du möchtest, dass eine bestimmte Action nicht blockierend (nur beratend) ist, umhülle deinen Action-Code mit einem try-catch und gib immer { allow: true } zurück. So werden Ausnahmen innerhalb der Action abgefangen und der Flow wird fortgesetzt.
Das Sicherheitsmodell im Detail
Was Actions TUN KÖNNEN
| Fähigkeit | Wie |
|---|---|
| Ausgehende HTTP-Aufrufe tätigen | await context.fetch('https://api.example.com/check', { ... }) |
| Aktuelles Benutzerprofil lesen | context.user.email, context.user.roles |
| Anfragemetadaten lesen | context.request.ip, context.request.userAgent |
| Authentifizierung blockieren | return { allow: false, reason: 'Blocked by policy' } |
| JWT-Claims hinzufügen | return { claims: { department: 'engineering' } } |
| Benutzermetadaten hinzufügen | return { metadata: { onboardingStep: 3 } } |
| Nachrichten protokollieren | context.console.log('Checked IP:', context.request.ip) |
| JavaScript-Built-ins verwenden | JSON.parse(), Date.now(), Math.random(), Array.from() usw. |
Was Actions NICHT KÖNNEN
| Einschränkung | Warum |
|---|---|
Module laden (require, import) | Verhindert Zugriff auf Node.js-APIs (fs, child_process, net usw.) |
Auf process zugreifen | Verhindert Lesen von Env-Vars, Beenden des Servers |
Auf global/globalThis zugreifen | Verhindert das Entkommen aus dem Sandbox-Scope |
| Auf die Datenbank zugreifen | Kein Prisma-Client oder Datenbankverbindung im Scope |
| Auf Daten anderer Tenants zugreifen | context enthält nur Daten für den aktuellen Tenant und Benutzer |
| Unbegrenzt laufen | Promise.race()-Timeout erzwungen (Standard 5 Sekunden) |
Leistungsmerkmale
| Operation | Typische Latenz | Hinweise |
|---|---|---|
| Statische Analyse | <0,1ms | Einfache String-Suche |
| Funktionskonstruktion | ~0,5ms | Einmalig pro Ausführung |
| Einfache Logik (kein HTTP) | 1-5ms | Bedingte Anweisungen, String-Operationen |
| Mit einem ausgehenden HTTP-Aufruf | 50-500ms | Dominiert durch externe Dienstlatenz |
| Timeout | 5.000ms (Standard) | Pro Action konfigurierbar (1s-30s-Bereich) |
Für ein typisches Setup mit einer Pre-Login-Action (IP-Prüfung, kein HTTP) und einer Post-Login-Action (benutzerdefinierte Claims hinzufügen, kein HTTP):
Anmeldung ohne Actions: ~150ms
Anmeldung mit 2 Actions: ~155ms (+5ms)
Anmeldung mit HTTP-Action: ~350ms (+200ms, dominiert durch HTTP-Aufruf)Der Blueprint Visual Editor
Für Teams, die visuelle Programmierung gegenüber JavaScript-Schreiben bevorzugen, bietet Auris einen Blueprint-Editor — einen knotenbasierten visuellen Editor, inspiriert von Unreal Engines Blueprint-System.
Knotentypen
| Knotentyp | Visuelles Erscheinungsbild | JavaScript-Äquivalent |
|---|---|---|
| Trigger | Orangener Knoten | Funktionseinstiegspunkt |
| Bedingung | Cyanfarbener Knoten | if (field operator value) |
| Logikgatter | Violetter Knoten | && (UND) oder ` |
| Deny-Action | Pinkfarbener Knoten | return { allow: false, reason: '...' } |
| Claims setzen | Blauer Knoten | return { claims: { key: value } } |
| Metadaten setzen | Smaragdgrüner Knoten | return { metadata: { key: value } } |
| Log | Grüner Knoten | context.console.log('...') |
Der Blueprint-Editor pflegt eine bidirektionale Zuordnung zwischen dem visuellen Graphen und JavaScript-Code. Du kannst Actions visuell erstellen und dann zum Code-Editor wechseln, um das generierte JavaScript zu sehen (und zu ändern), und zurückwechseln, um deine Code-Änderungen visuell widergespiegelt zu sehen.
Codebeispiele: Häufige Action-Muster
Wegwerf-E-Mail-Anbieter blockieren
// Trigger: pre_signup
// Registrierungen von Wegwerf-E-Mail-Anbietern blockieren
const disposableDomains = [
'tempmail.com', 'throwaway.email', 'guerrillamail.com',
'mailinator.com', '10minutemail.com', 'yopmail.com'
]
const domain = context.user.email.split('@')[1]
if (disposableDomains.includes(domain)) {
return {
allow: false,
reason: 'Wegwerf-E-Mail-Adressen sind nicht erlaubt.'
}
}
return { allow: true }Token mit externen Daten anreichern
// Trigger: post_login
// Abteilung und Manager aus HR-System hinzufügen
try {
const response = await context.fetch(
'https://hr.internal/api/employee?email=' +
encodeURIComponent(context.user.email),
{ headers: { 'X-Api-Key': 'hr-api-key' } }
)
if (response.ok) {
const employee = await response.json()
return {
claims: {
department: employee.department,
manager_id: employee.managerId,
cost_center: employee.costCenter
}
}
}
} catch (err) {
context.console.error('HR-API-Aufruf fehlgeschlagen:', err.message)
}
// Wenn die HR-API nicht verfügbar ist, Anmeldung nicht blockieren — Anreicherung überspringen
return {}Geo-Einschränkung für Anmeldung
// Trigger: pre_login
// Anmeldung nur aus bestimmten Ländern erlauben
const allowedCountries = ['DE', 'AT', 'CH', 'US', 'GB', 'FR', 'IT']
const country = context.request.geoip?.country
if (country && !allowedCountries.includes(country)) {
context.console.warn(
'Anmeldung blockiert aus', country, 'für Benutzer', context.user.email
)
return {
allow: false,
reason: 'Anmeldung ist von deinem aktuellen Standort nicht verfügbar.'
}
}
return { allow: true }Verwandte Konzepte
- OAuth 2.0 & OIDC — Die Authentifizierungs-Flows, in die Actions eingehängt werden
- Tokens erklärt — Wie benutzerdefinierte Claims aus Actions in JWTs erscheinen
- Adaptives MFA & Risikobewertung — Risikobewertung kann Step-Up-MFA auslösen, ergänzt Actions
- Multi-Tenancy — Wie Tenant-Isolation auf Actions angewendet wird