OBS/Kostenpflichtige Module/RESTServer/Beispiel5: Unterschied zwischen den Versionen

Aus OBS Wiki
Zur Navigation springen Zur Suche springen
Keine Bearbeitungszusammenfassung
Keine Bearbeitungszusammenfassung
 
(Eine dazwischenliegende Version desselben Benutzers wird nicht angezeigt)
Zeile 11: Zeile 11:
** API-Key: zufällig generiert
** API-Key: zufällig generiert
** JWT aktiv, JWT-Endpunkt ''auth'', JWT-Key zufällig, JWT-Exp 60 (Minuten)
** JWT aktiv, JWT-Endpunkt ''auth'', JWT-Key zufällig, JWT-Exp 60 (Minuten)
* '''Endpunkte''' (alle dem Profil ''Public-API'' zugeordnet):
* '''Endpunkte''' (beide dem Profil ''Public-API'' zugeordnet):
** ''orders/{uid}'' - Auftrag lesen/Ändern
** ''orders/{uid}'' - Auftrag lesen/Ändern
** ''orders/{uid}/material'' - Material erfassen
** ''orders/{uid}/material'' - Material erfassen
** ''auth/logout'' - Abmelden
* '''Berechtigung:''' Zugang ''Mobile-App'' für beide Endpunkte freigeschaltet
* '''Berechtigung:''' Zugang ''Mobile-App'' für alle drei Endpunkte freigeschaltet
 
{{Hinweis|Für das Abmelden gibt es '''keinen eigenen Endpunkt'''. Es läuft über
ein ''DELETE'' auf den JWT-Endpunkt ''auth'' und braucht daher weder eine Zeile
in ''RESTSRV_ENDPOINTS'' noch eine Berechtigung.}}


==JWT-Authentifizierungs-Skript (Zugang)==
==JWT-Authentifizierungs-Skript (Zugang)==
Zeile 42: Zeile 45:


         if (PasswortPasst(cUser, cPass)) then begin
         if (PasswortPasst(cUser, cPass)) then begin
             oRes.AddPair('status'              , 1);
             oRes.AddPair('status'              , 1);
             oRes.AddPair('_OBS_JWT_ID'          , cUser);
             oRes.AddPair('_OBS_JWT_ID'          , cUser);
Zeile 70: Zeile 70:
     oRes := TJSONObject.Create();
     oRes := TJSONObject.Create();
     try
     try
         cUser := oParams.Values['_OBS_JWT_SUBJECT'];
         cUser := oParams.Values['_OBS_JWT_SUBJECT'];


Zeile 85: Zeile 84:
         // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -
         // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -
         // sonst wirkt ein Rechteentzug erst beim naechsten Login.
         // sonst wirkt ein Rechteentzug erst beim naechsten Login.
         oRes.AddPair('status'              , 1);
         oRes.AddPair('status'              , 1);
         oRes.AddPair('_OBS_JWT_ID'          , cUser);
         oRes.AddPair('_OBS_JWT_ID'          , cUser);
Zeile 230: Zeile 228:
</syntaxhighlight>
</syntaxhighlight>


==Endpunkt ''auth/logout'' - Abmelden==
==Abmelden - ohne Endpunkt und ohne Skript==
 
Beim Abmelden soll das Gerät seine Sitzung wirklich verlieren, nicht erst mit dem
Ablauf des Tokens. Dafür ist '''nichts einzurichten''': ein ''DELETE'' auf den
JWT-Endpunkt mit dem Access-Token genügt, der Server sperrt die Sitzung in
''RESTSRV_TOKEN'' und antwortet 204.
 
Gesperrt wird die ganze Sitzung, also auch die Access-Token vorheriger
Erneuerungen. Ein zweiter Aufruf ist unschädlich und bleibt 204. Kann der Server
die Sitzung nicht sperren, antwortet er '''503''' mit ''Retry-After'' - die App
wiederholt den Abmeldevorgang dann.


Beim Abmelden soll das Gerät seine Sitzung wirklich verlieren - nicht erst mit dem
Nur wenn beim Abmelden zusätzlich etwas passieren soll - hier: die
Ablauf des Tokens. Dafür genügt ein Antwortfeld; die Sperre führt der Server in
Geräteregistrierung für Push löschen -, braucht es einen eigenen Endpunkt. Er
''RESTSRV_TOKEN'':
erledigt seine Fachlogik und setzt ''_OBS_JWT_REVOKE'':


<syntaxhighlight lang="pascal" line>
<syntaxhighlight lang="pascal" line>
function Post(oParams: TStrings; oBody: TJSONObject): string;
function Post(oParams: TStrings; oBody: TJSONObject): string;
var oRes: TJSONObject;
var oRes : TJSONObject;
     oVal: TJSONValue;
     oVal : TJSONValue;
     cAlle: string;
     cAlle: string;
begin
begin
     oRes := TJSONObject.Create();
     oRes := TJSONObject.Create();
     try
     try
         // Optional: {"alleGeraete": true} meldet den Techniker ueberall ab
        PushRegistrierungLoeschen(oParams.Values['_OBS_JWT_CLAIM_technikerUid']);
 
         // Optional: {"alleGeraete": true} meldet den Techniker ueberall ab.
        // Diese Entscheidung gehoert bewusst in ein Skript - sie braucht eine
        // Berechtigungspruefung, die der JWT-Endpunkt nicht leisten kann.
         cAlle := 'session';
         cAlle := 'session';
         if (Assigned(oBody)) then begin
         if (Assigned(oBody)) then begin
Zeile 261: Zeile 273:
end;
end;
</syntaxhighlight>
</syntaxhighlight>
Gesperrt wird die ganze Sitzung, also auch die Access-Token vorheriger
Erneuerungen. Ein zweiter Aufruf ist unschädlich. Kann der Server die Sitzung
nicht sperren, antwortet er mit '''503''' statt mit dem 204 des Skripts - die App
darf den Abmeldevorgang dann wiederholen.


==Test mit curl==
==Test mit curl==
Zeile 316: Zeile 323:
Techniker muss sich neu anmelden, und der Vorfall steht mit IP im Protokoll.
Techniker muss sich neu anmelden, und der Vorfall steht mit IP im Protokoll.


Abmelden (Access-Token im Authorization-Header):
Abmelden - DELETE auf denselben JWT-Endpunkt, Access-Token im Header:


  curl -i -X POST -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
  curl -i -X DELETE -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
       https://api.meinserver.de/auth/logout
       https://api.meinserver.de/auth/


  HTTP/1.1 204 No Content
  HTTP/1.1 204 No Content
Zeile 348: Zeile 355:
* Rollenprüfung aus dem Token-Claim ''_OBS_JWT_CLAIM_roles'', nicht aus dem Body.
* Rollenprüfung aus dem Token-Claim ''_OBS_JWT_CLAIM_roles'', nicht aus dem Body.
* JWT mit Custom-Claims (''tenant''/''roles'') und Refresh-Token mit Rotation - '''vom Server erledigt''', das Skript führt keine Sperrtabelle.
* JWT mit Custom-Claims (''tenant''/''roles'') und Refresh-Token mit Rotation - '''vom Server erledigt''', das Skript führt keine Sperrtabelle.
* Abmelden über ''_OBS_JWT_REVOKE'', wahlweise für diese Sitzung oder alle Geräte des Technikers.
* Anmelden, Erneuern und Abmelden über '''einen''' Endpunkt - Abmelden als ''DELETE'', ohne eigene Endpunkt-Zeile und ohne Skript.
* ''_OBS_JWT_REVOKE'' für den Fall, dass beim Abmelden zusätzlich Fachlogik laufen soll oder alle Geräte gemeint sind.
* ''traceId'' im Fehler-Body für die Support-Nachverfolgung (auch als Header ''X-Trace-Id'').
* ''traceId'' im Fehler-Body für die Support-Nachverfolgung (auch als Header ''X-Trace-Id'').

Aktuelle Version vom 19. August 2026, 13:01 Uhr

Kostenpflichtige Module

Internet-Shop
UPS
IMS Professional
SMS
Mehrlager-Verwaltung
Mehrsprachen Modul
Multilanguage Modul
EVA Marketing Tool
Termin-Projekte
Edifact-Schnittstelle
Backup Überwachung Email
OBS Geo Daten
DeliSprint / DPD
Filialen
Cashback
Moebelschnittstelle
Dokumenten Manager
DocuWare-Schnittstelle
OFML-Kalkulation
Versicherungsschaden
Gutschriftsanzeigen
Kameraverwaltung
DataInOut
OpenMasterData / IDS
Sammelpositionen



Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz

Dieses Beispiel zeigt einen schreibenden Endpunkt für eine mobile App: Aufträge werden mit echten HTTP-Statuscodes aktualisiert, konkurrierende Änderungen über ETag/If-Match abgesichert (Optimistic Concurrency) und doppelte Sendungen über einen Idempotency-Key abgefangen. Die Anmeldung nutzt JWT mit Custom-Claims (Mandant, Rollen) und einem Refresh-Token.

Einrichtung in OBS

  • Server-Profil: Standard-TLS-Profil Public-API (Port 443)
  • Zugang: Mobile-App
    • API-Key: zufällig generiert
    • JWT aktiv, JWT-Endpunkt auth, JWT-Key zufällig, JWT-Exp 60 (Minuten)
  • Endpunkte (beide dem Profil Public-API zugeordnet):
    • orders/{uid} - Auftrag lesen/Ändern
    • orders/{uid}/material - Material erfassen
  • Berechtigung: Zugang Mobile-App für beide Endpunkte freigeschaltet
HINWEIS: Für das Abmelden gibt es keinen eigenen Endpunkt. Es läuft über

ein DELETE auf den JWT-Endpunkt auth und braucht daher weder eine Zeile

in RESTSRV_ENDPOINTS noch eine Berechtigung.

JWT-Authentifizierungs-Skript (Zugang)

Stellt Mandant und Rollen als Custom-Claims aus und löst über _OBS_JWT_REFRESH_ID ein Refresh-Token aus. Bei einem Refresh ruft der Server im selben Skript die Methode Refresh auf. Die Hilfsfunktionen (PasswortPasst, TechnikerMandant, TechnikerRollen) sind illustrativ und projektabhängig.

HINWEIS: Eine eigene Sperrtabelle für Refresh-jti ist nicht nötig.

Einmalgebrauch, Rotation und Widerruf führt der Server in RESTSRV_TOKEN - das Skript entscheidet nur, ob und mit welchen Rechten die Sitzung fortgesetzt wird. Frühere Fassungen dieses Beispiels zeigten eine Skript-Tabelle;

wer sie übernommen hat, kann sie entfernen.
function Authenticate(oParams: TStrings; oBody: TJSONObject): string;
var oRes : TJSONObject;
    oVal : TJSONValue;
    cUser, cPass: string;
begin
    oRes := TJSONObject.Create();
    try
        cUser := ''; cPass := '';
        if (Assigned(oBody)) then begin
            oVal := oBody.GetValue('username'); if (Assigned(oVal)) then cUser := oVal.Value;
            oVal := oBody.GetValue('password'); if (Assigned(oVal)) then cPass := oVal.Value;
        end;

        if (PasswortPasst(cUser, cPass)) then begin
            oRes.AddPair('status'               , 1);
            oRes.AddPair('_OBS_JWT_ID'          , cUser);
            oRes.AddPair('_OBS_JWT_SUBJECT'     , cUser);
            oRes.AddPair('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser));
            oRes.AddPair('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser));   // z.B. "tech,lead"
            oRes.AddPair('_OBS_JWT_REFRESH_ID'  , GlobalUID());   // loest das Refresh-Token aus
            // _OBS_JWT_REFRESH_EXP weggelassen -> Default 90 Tage
        end else begin
            oRes.AddPair('status', 9);
            oRes.AddPair('error' , 'Login fehlgeschlagen');
        end;
        result := oRes.ToJSON();
    finally
        MyFreeAndNil(oRes);
    end;
end;

//------------------------------------------------------------------------------

function Refresh(oParams: TStrings; oBody: TJSONObject): string;
var oRes : TJSONObject;
    cUser: string;
begin
    oRes := TJSONObject.Create();
    try
        cUser := oParams.Values['_OBS_JWT_SUBJECT'];

        // Das vorgelegte Refresh-Token hat der Server bereits geprueft und
        // entwertet. Hier wird nur entschieden, ob die Sitzung fortgesetzt
        // werden darf - und mit welchen Rechten.
        if (not TechnikerAktiv(cUser)) then begin
            oRes.AddPair('status', 9);
            oRes.AddPair('error' , 'Konto ist nicht mehr aktiv, bitte neu anmelden');
            result := oRes.ToJSON();
            exit;
        end;

        // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -
        // sonst wirkt ein Rechteentzug erst beim naechsten Login.
        oRes.AddPair('status'               , 1);
        oRes.AddPair('_OBS_JWT_ID'          , cUser);
        oRes.AddPair('_OBS_JWT_SUBJECT'     , cUser);
        oRes.AddPair('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser));
        oRes.AddPair('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser));
        oRes.AddPair('_OBS_JWT_REFRESH_ID'  , GlobalUID());
        result := oRes.ToJSON();
    finally
        MyFreeAndNil(oRes);
    end;
end;

Endpunkt orders/{uid} - ändern mit ETag / If-Match

GET liefert den Auftrag samt ETag (Version), PUT prüft Rolle und If-Match.

function Get(oParams: TStrings; oBody: TJSONObject): string;
var oRes, oHdr: TJSONObject;
    cUid: string;
    nVer: integer;
begin
    oRes := TJSONObject.Create();
    try
        cUid := oParams.Values['_OBS_PATH_uid'];
        if (not AuftragLesen(oParams.Values['_OBS_JWT_CLAIM_tenant'], cUid, oRes, nVer)) then begin
            oRes.AddPair('_OBS_HTTP_STATUS', 404);
            oRes.AddPair('error', TJSONObject.Create.AddPair('code', 'NOT_FOUND'));
            result := oRes.ToJSON();
            exit;
        end;
        oHdr := TJSONObject.Create();
        oHdr.AddPair('ETag', xStr(nVer));
        oRes.AddPair('_OBS_HEADERS', oHdr);
        result := oRes.ToJSON();
    finally
        MyFreeAndNil(oRes);
    end;
end;

//------------------------------------------------------------------------------

function Put(oParams: TStrings; oBody: TJSONObject): string;
var oRes, oHdr: TJSONObject;
    cUid: string;
    nAktuell, nIfMatch: integer;
begin
    oRes := TJSONObject.Create();
    try
        // nur Rolle "lead" darf ändern - Rolle kommt aus dem Token, nicht aus dem Body.
        // Mit Trennzeichen suchen: ein blosses Pos('lead', ...) wuerde auch in
        // "leadless" oder "teamlead" treffen.
        if (Pos(',lead,', ',' + oParams.Values['_OBS_JWT_CLAIM_roles'] + ',') = 0) then begin
            oRes.AddPair('_OBS_HTTP_STATUS', 403);
            oRes.AddPair('error', TJSONObject.Create.AddPair('code', 'FORBIDDEN_ROLE'));
            result := oRes.ToJSON();
            exit;
        end;

        cUid     := oParams.Values['_OBS_PATH_uid'];
        nAktuell := AuftragVersion(cUid);
        nIfMatch := iVal(oParams.Values['if-match']);

        if (nIfMatch <> nAktuell) then begin
            oHdr := TJSONObject.Create();
            oHdr.AddPair('ETag', xStr(nAktuell));
            oRes.AddPair('_OBS_HTTP_STATUS', 409);
            oRes.AddPair('_OBS_HEADERS', oHdr);
            oRes.AddPair('error', TJSONObject.Create.AddPair('code', 'VERSION_CONFLICT'));
            result := oRes.ToJSON();
            exit;
        end;

        AuftragSpeichern(cUid, oBody);   // setzt Version auf nAktuell + 1
        oHdr := TJSONObject.Create();
        oHdr.AddPair('ETag', xStr(nAktuell + 1));
        oRes.AddPair('_OBS_HEADERS', oHdr);
        result := oRes.ToJSON();
    finally
        MyFreeAndNil(oRes);
    end;
end;

Endpunkt orders/{uid}/material - idempotentes Anlegen

POST erfasst eine Materialposition: Validierungsfehler -> 422, erfolgreiches Anlegen -> 201.

Gegen doppelte Sendungen schickt der Client den Header Idempotency-Key. Das Skript muss dafür nichts tun - der Server erkennt den Header, merkt sich das Ergebnis und liefert bei einer Wiederholung mit demselben Schlüssel die gespeicherte Antwort zurück, ohne das Skript erneut zu starten (siehe Endpunkte, Abschnitt Idempotenz). Das Skript kümmert sich nur um seine Fachlogik:

function Post(oParams: TStrings; oBody: TJSONObject): string;
var oRes, oErr: TJSONObject;
    oVal: TJSONValue;
    cUid, cArtikel, cNeueUid: string;
    nMenge: integer;
begin
    oRes := TJSONObject.Create();
    try
        cUid := oParams.Values['_OBS_PATH_uid'];

        // 1) Validierung -> 422 mit traceId.
        //    Wichtig: fachliche Ablehnung als 4xx melden, nicht als 200 mit
        //    Fehlertext - nur dann gibt der Server den Idempotency-Key wieder
        //    frei und der Client darf ihn nach Korrektur erneut verwenden.
        cArtikel := '';
        nMenge   := 0;
        if (Assigned(oBody)) then begin
            oVal := oBody.GetValue('artikel'); if (Assigned(oVal)) then cArtikel := oVal.Value;
            oVal := oBody.GetValue('menge');   if (Assigned(oVal)) then nMenge   := iVal(oVal.Value);
        end;
        if ((Empty(cArtikel)) or (nMenge <= 0)) then begin
            oErr := TJSONObject.Create();
            oErr.AddPair('code'   , 'VALIDATION_FAILED');
            oErr.AddPair('message', 'artikel und menge sind Pflicht');
            oErr.AddPair('traceId', oParams.Values['_OBS_TRACE_ID']);
            oRes.AddPair('_OBS_HTTP_STATUS', 422);
            oRes.AddPair('error', oErr);
            result := oRes.ToJSON();
            exit;
        end;

        // 2) Buchen. Die Transaktionssteuerung bleibt beim Skript; der
        //    Idempotenz-Speicher des Servers läuft getrennt davon und
        //    umschliesst diesen Block nicht.
        cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge);

        // 3) Antwort vollständig aufbauen - genau sie wird eingefroren und bei
        //    einer Wiederholung erneut ausgeliefert.
        oRes.AddPair('_OBS_HTTP_STATUS', 201);
        oRes.AddPair('uid', cNeueUid);
        result := oRes.ToJSON();
    finally
        MyFreeAndNil(oRes);
    end;
end;

Abmelden - ohne Endpunkt und ohne Skript

Beim Abmelden soll das Gerät seine Sitzung wirklich verlieren, nicht erst mit dem Ablauf des Tokens. Dafür ist nichts einzurichten: ein DELETE auf den JWT-Endpunkt mit dem Access-Token genügt, der Server sperrt die Sitzung in RESTSRV_TOKEN und antwortet 204.

Gesperrt wird die ganze Sitzung, also auch die Access-Token vorheriger Erneuerungen. Ein zweiter Aufruf ist unschädlich und bleibt 204. Kann der Server die Sitzung nicht sperren, antwortet er 503 mit Retry-After - die App wiederholt den Abmeldevorgang dann.

Nur wenn beim Abmelden zusätzlich etwas passieren soll - hier: die Geräteregistrierung für Push löschen -, braucht es einen eigenen Endpunkt. Er erledigt seine Fachlogik und setzt _OBS_JWT_REVOKE:

function Post(oParams: TStrings; oBody: TJSONObject): string;
var oRes : TJSONObject;
    oVal : TJSONValue;
    cAlle: string;
begin
    oRes := TJSONObject.Create();
    try
        PushRegistrierungLoeschen(oParams.Values['_OBS_JWT_CLAIM_technikerUid']);

        // Optional: {"alleGeraete": true} meldet den Techniker ueberall ab.
        // Diese Entscheidung gehoert bewusst in ein Skript - sie braucht eine
        // Berechtigungspruefung, die der JWT-Endpunkt nicht leisten kann.
        cAlle := 'session';
        if (Assigned(oBody)) then begin
            oVal := oBody.GetValue('alleGeraete');
            if (Assigned(oVal)) and (Lower(oVal.Value) = 'true') then begin
                cAlle := 'all';
            end;
        end;

        oRes.AddPair('_OBS_JWT_REVOKE' , cAlle);
        oRes.AddPair('_OBS_HTTP_STATUS', 204);
        result := oRes.ToJSON();
    finally
        MyFreeAndNil(oRes);
    end;
end;

Test mit curl

Token holen:

curl -X POST -H "apikey: [API-KEY]" -H "Content-Type: application/json" ^
     -d "{\"username\":\"tech1\",\"password\":\"geheim\"}" ^
     https://api.meinserver.de/auth/
{"token":"eyJ...","accessToken":"eyJ...","refreshToken":"eyJ...",
 "expiresIn":3600,"serverTime":"2026-06-29T15:30:12+02:00"}

Bei falschen Zugangsdaten antwortet der Server mit 401 und dem Text aus dem Authenticate-Skript als error.message.

Auftrag lesen (liefert den ETag-Header):

curl -i -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
     https://api.meinserver.de/orders/4711
... ETag: 7

ändern mit korrektem If-Match -> 200, mit veraltetem If-Match -> 409:

curl -i -X PUT -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
     -H "If-Match: 7" -H "Content-Type: application/json" ^
     -d "{\"status\":\"erledigt\"}" ^
     https://api.meinserver.de/orders/4711

Material idempotent erfassen (zweiter Aufruf mit gleichem Key -> gleiche Antwort, keine Doppelbuchung):

curl -i -X POST -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
     -H "Idempotency-Key: 9c84-7f2a-..." -H "Content-Type: application/json" ^
     -d "{\"artikel\":\"A100\",\"menge\":3}" ^
     https://api.meinserver.de/orders/4711/material

Die Antwort des zweiten Aufrufs trägt zusätzlich den Header Idempotent-Replay: true - daran ist erkennbar, dass sie aus dem Speicher kam und nichts erneut gebucht wurde.

Token erneuern (Refresh-Token im Authorization-Header an denselben JWT-Endpunkt):

curl -X POST -H "apikey: [API-KEY]" -H "Authorization: Bearer <refreshToken>" ^
     https://api.meinserver.de/auth/

Die Antwort enthält ein neues Paar. Das alte Refresh-Token ist damit verbraucht: ein zweiter Aufruf mit demselben Token antwortet 401. Erfolgt er innerhalb von 60 Sekunden, bleibt die Sitzung bestehen (paralleler Refresh der App); später gilt er als Wiedervorlage und die ganze Sitzung wird gesperrt - der Techniker muss sich neu anmelden, und der Vorfall steht mit IP im Protokoll.

Abmelden - DELETE auf denselben JWT-Endpunkt, Access-Token im Header:

curl -i -X DELETE -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
     https://api.meinserver.de/auth/
HTTP/1.1 204 No Content

Derselbe Token danach noch einmal verwendet:

curl -i -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
     https://api.meinserver.de/orders/4711
HTTP/1.1 401 Unauthorized
{
  "error": {
    "code":    "AUTH_EXPIRED",
    "uid":     "Y00G9TOK09",
    "message": "Die Sitzung ist nicht mehr gültig, bitte neu anmelden",
    "traceId": "20260819T091233123-00001A"
  }
}

Was zeigt das Beispiel?

  • Echte HTTP-Statuscodes aus dem Skript: 201, 403, 404, 409, 422.
  • Optimistic Concurrency über ETag (GET) und If-Match (PUT) -> 409 VERSION_CONFLICT.
  • Idempotente Schreibzugriffe über den Idempotency-Key - vom Server erledigt, das Skript enthält dafür keine Zeile Code.
  • Rollenprüfung aus dem Token-Claim _OBS_JWT_CLAIM_roles, nicht aus dem Body.
  • JWT mit Custom-Claims (tenant/roles) und Refresh-Token mit Rotation - vom Server erledigt, das Skript führt keine Sperrtabelle.
  • Anmelden, Erneuern und Abmelden über einen Endpunkt - Abmelden als DELETE, ohne eigene Endpunkt-Zeile und ohne Skript.
  • _OBS_JWT_REVOKE für den Fall, dass beim Abmelden zusätzlich Fachlogik laufen soll oder alle Geräte gemeint sind.
  • traceId im Fehler-Body für die Support-Nachverfolgung (auch als Header X-Trace-Id).