Imports Newtonsoft.Json Imports System.IO Imports System.Text Public Class cRelayHubToken ' === Token-Datenmodell === Private Class TokenState Public AccessToken As String Public RefreshToken As String Public AccessExpiryUtc As DateTime Public RefreshExpiryUtc As DateTime End Class ' === Keycloak-Config === Private Shared ReadOnly KC_BASE As String = "https://dev-kc.singlewindow.io" Private Shared ReadOnly KC_TOKEN_PATH As String = "/auth/realms/agsw/protocol/openid-connect/token" Private Shared ReadOnly KC_CLIENT_ID As String = "agsw-admin" Private Shared ReadOnly KC_USERNAME As String = "andreas.test@test.com" Private Shared ReadOnly KC_PASSWORD As String = "Password.123" Private Shared ReadOnly SKEW As TimeSpan = TimeSpan.FromSeconds(30) ' === Cache/Persistenz === Private Shared _ts As TokenState = Nothing Private Shared ReadOnly TOKEN_FILE As String = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RelayHub", "token.cache" ) Private Shared ReadOnly _lockObj As New Object() ' -------------- DPAPI via Reflection (keine Compile-Abhängigkeit!) -------------- Private Shared Function TryProtect(plain As Byte()) As Byte() Try ' Versuche: Typen aus Assembly "System.Security" oder aus aktuellen Laufzeit-Assemblys laden Dim dpType As Type = Type.GetType("System.Security.Cryptography.ProtectedData, System.Security", throwOnError:=False) If dpType Is Nothing Then dpType = Type.GetType("System.Security.Cryptography.ProtectedData", throwOnError:=False) End If Dim scopeType As Type = Type.GetType("System.Security.Cryptography.DataProtectionScope, System.Security", throwOnError:=False) If dpType Is Nothing OrElse scopeType Is Nothing Then Return Nothing Dim scopeObj As Object = [Enum].Parse(scopeType, "CurrentUser") Dim mi = dpType.GetMethod("Protect", New Type() {GetType(Byte()), GetType(Byte()), scopeType}) If mi Is Nothing Then Return Nothing Dim res = mi.Invoke(Nothing, New Object() {plain, Nothing, scopeObj}) Return TryCast(res, Byte()) Catch Return Nothing End Try End Function Private Shared Function TryUnprotect(protectedBytes As Byte()) As Byte() Try Dim dpType As Type = Type.GetType("System.Security.Cryptography.ProtectedData, System.Security", throwOnError:=False) If dpType Is Nothing Then dpType = Type.GetType("System.Security.Cryptography.ProtectedData", throwOnError:=False) End If Dim scopeType As Type = Type.GetType("System.Security.Cryptography.DataProtectionScope, System.Security", throwOnError:=False) If dpType Is Nothing OrElse scopeType Is Nothing Then Return Nothing Dim scopeObj As Object = [Enum].Parse(scopeType, "CurrentUser") Dim mi = dpType.GetMethod("Unprotect", New Type() {GetType(Byte()), GetType(Byte()), scopeType}) If mi Is Nothing Then Return Nothing Dim res = mi.Invoke(Nothing, New Object() {protectedBytes, Nothing, scopeObj}) Return TryCast(res, Byte()) Catch Return Nothing End Try End Function ' -------------- Persistenz: bevorzugt DPAPI, Fallback Plain-File -------------- Private Shared Sub SaveTokenSecure(ts As TokenState) Try Dim dir = Path.GetDirectoryName(TOKEN_FILE) If Not Directory.Exists(dir) Then Directory.CreateDirectory(dir) Dim payload As String = String.Join(vbLf, { ts.AccessToken, ts.RefreshToken, ts.AccessExpiryUtc.Ticks.ToString(), ts.RefreshExpiryUtc.Ticks.ToString() }) Dim plain = Encoding.UTF8.GetBytes(payload) Dim protectedBytes = TryProtect(plain) If protectedBytes IsNot Nothing Then File.WriteAllBytes(TOKEN_FILE, protectedBytes) Else ' Fallback (nur zu Testzwecken!) File.WriteAllText(TOKEN_FILE, payload, Encoding.UTF8) End If Catch ' optional loggen End Try End Sub Private Shared Function LoadTokenSecure() As TokenState Try If Not File.Exists(TOKEN_FILE) Then Return Nothing ' Zuerst versuchen wir, als DPAPI-Bytes zu lesen und zu entschlüsseln Dim raw = File.ReadAllBytes(TOKEN_FILE) Dim plain = TryUnprotect(raw) Dim content As String If plain Is Nothing Then ' Fallback: als Text lesen (falls zuvor ohne DPAPI gespeichert) content = File.ReadAllText(TOKEN_FILE, Encoding.UTF8) Else content = Encoding.UTF8.GetString(plain) End If Dim s = content.Split({vbLf}, StringSplitOptions.None) If s.Length < 4 Then Return Nothing Return New TokenState With { .AccessToken = s(0), .RefreshToken = s(1), .AccessExpiryUtc = New DateTime(Long.Parse(s(2)), DateTimeKind.Utc), .RefreshExpiryUtc = New DateTime(Long.Parse(s(3)), DateTimeKind.Utc) } Catch Return Nothing End Try End Function Private Shared Sub ClearToken() SyncLock _lockObj _ts = Nothing Try If File.Exists(TOKEN_FILE) Then File.Delete(TOKEN_FILE) Catch End Try End SyncLock End Sub ' -------------- Utilities -------------- Private Shared Function UtcNow() As DateTime Return DateTime.UtcNow End Function Private Shared Function IsAccessValid(ts As TokenState) As Boolean Return ts IsNot Nothing AndAlso Not String.IsNullOrEmpty(ts.AccessToken) AndAlso UtcNow() < ts.AccessExpiryUtc - SKEW End Function Private Shared Function IsRefreshValid(ts As TokenState) As Boolean Return ts IsNot Nothing AndAlso Not String.IsNullOrEmpty(ts.RefreshToken) AndAlso UtcNow() < ts.RefreshExpiryUtc - SKEW End Function ' -------------- OAuth Flows -------------- Private Shared Function PasswordLogin() As TokenState Dim http As New Chilkat.Http Dim req As New Chilkat.HttpRequest req.HttpVerb = "POST" req.Path = KC_TOKEN_PATH req.AddParam("grant_type", "password") req.AddParam("username", KC_USERNAME) req.AddParam("password", KC_PASSWORD) req.AddParam("client_id", KC_CLIENT_ID) req.AddParam("scope", "openid offline_access") req.AddHeader("Content-Type", "application/x-www-form-urlencoded") Dim resp = http.PostUrlEncoded(KC_BASE, req) If resp Is Nothing Then Throw New Exception("Token-Request fehlgeschlagen: " & http.LastErrorText) If resp.StatusCode <> 200 Then Throw New Exception("Password-Grant fehlgeschlagen: " & resp.StatusCode & " - " & resp.BodyStr) Dim json As New Chilkat.JsonObject : json.Load(resp.BodyStr) Dim access = json.StringOf("access_token") Dim refresh = json.StringOf("refresh_token") Dim exp = Math.Max(60, json.IntOf("expires_in")) Dim rexp = Math.Max(300, json.IntOf("refresh_expires_in")) Dim ts = New TokenState With { .AccessToken = access, .RefreshToken = refresh, .AccessExpiryUtc = UtcNow().AddSeconds(exp), .RefreshExpiryUtc = UtcNow().AddSeconds(rexp) } SaveTokenSecure(ts) Return ts End Function Private Shared Function RefreshLogin(oldTs As TokenState) As TokenState If oldTs Is Nothing OrElse String.IsNullOrEmpty(oldTs.RefreshToken) Then Throw New Exception("Kein gültiger Refresh-Token vorhanden.") End If Dim http As New Chilkat.Http Dim req As New Chilkat.HttpRequest req.HttpVerb = "POST" req.Path = KC_TOKEN_PATH req.AddParam("grant_type", "refresh_token") req.AddParam("refresh_token", oldTs.RefreshToken) req.AddParam("client_id", KC_CLIENT_ID) req.AddHeader("Content-Type", "application/x-www-form-urlencoded") Dim resp = http.PostUrlEncoded(KC_BASE, req) If resp Is Nothing Then Throw New Exception("Refresh-Request fehlgeschlagen: " & http.LastErrorText) If resp.StatusCode <> 200 Then Throw New Exception("Refresh fehlgeschlagen: " & resp.StatusCode & " - " & resp.BodyStr) Dim json As New Chilkat.JsonObject : json.Load(resp.BodyStr) Dim access = json.StringOf("access_token") Dim refresh = json.StringOf("refresh_token") ' Rotation beachten Dim exp = Math.Max(60, json.IntOf("expires_in")) Dim rexp = Math.Max(300, json.IntOf("refresh_expires_in")) Dim ts = New TokenState With { .AccessToken = access, .RefreshToken = refresh, .AccessExpiryUtc = UtcNow().AddSeconds(exp), .RefreshExpiryUtc = UtcNow().AddSeconds(rexp) } SaveTokenSecure(ts) Return ts End Function ' -------------- Public API -------------- Public Shared Function GetValidAccessToken() As String SyncLock _lockObj If _ts Is Nothing Then _ts = LoadTokenSecure() If IsAccessValid(_ts) Then Return _ts.AccessToken End If If IsRefreshValid(_ts) Then Try _ts = RefreshLogin(_ts) Return _ts.AccessToken Catch ' fällt durch auf PasswordLogin End Try End If _ts = PasswordLogin() Return _ts.AccessToken End SyncLock End Function Public Shared Sub ResetTokenCache() ClearToken() End Sub End Class