Office365 AuthenticationRecord persistieren, Graph-Scope Fix, SIP Expiry 3600s

This commit is contained in:
dimedtec 2026-08-28 17:39:49 +02:00
parent 3b2f149d8f
commit ed18ffdd1d
3 changed files with 68 additions and 16 deletions

View file

@ -6,7 +6,7 @@
"Username": "",
"Password": "",
"DisplayName": "",
"RegisterExpirySeconds": 300,
"RegisterExpirySeconds": 3600,
"SendRingingProgress": true
},
"Contacts": {

View file

@ -1,3 +1,4 @@
using System.IO;
using Azure.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@ -8,7 +9,10 @@ namespace Anrufmonitor.Contacts;
/// <summary>
/// Liest die Outlook/Office365-Kontakte des angemeldeten Benutzers ueber Microsoft Graph.
/// Anmeldung per Device-Code-Flow (interaktiv, einmalig, Token wird vom MSAL-Cache verwaltet).
/// Anmeldung per Device-Code-Flow. Nur beim allerersten Mal interaktiv: der MSAL-Token-Cache
/// UND der <see cref="AuthenticationRecord"/> werden lokal persistiert (letzterer sagt MSAL,
/// welcher Account beim naechsten Start automatisch/silent verwendet werden soll - ohne ihn
/// wuerde trotz persistiertem Cache jedes Mal wieder interaktiv nachgefragt).
/// Benoetigte App-Registration (Azure/Entra Portal): Public client, Delegated Permission "Contacts.Read".
/// </summary>
public sealed class Office365ContactProvider : IContactProvider
@ -29,6 +33,12 @@ public sealed class Office365ContactProvider : IContactProvider
/// <summary>Feuert mit der fuer den Benutzer bestimmten Anmelde-Nachricht (Code + URL) des Device-Code-Flows.</summary>
public event Action<string>? DeviceCodeRequired;
private static string AuthRecordPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Anrufmonitor",
"office365-auth-record.json");
public async Task<IReadOnlyList<ContactInfo>> GetAllContactsAsync(CancellationToken cancellationToken)
{
if (!IsEnabled)
@ -36,19 +46,7 @@ public sealed class Office365ContactProvider : IContactProvider
return [];
}
var credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions
{
ClientId = _options.Office365.ClientId,
TenantId = _options.Office365.TenantId,
DeviceCodeCallback = (info, ct) =>
{
_logger.LogWarning(
"Office365-Anmeldung erforderlich: {Message}",
info.Message);
DeviceCodeRequired?.Invoke(info.Message);
return Task.CompletedTask;
},
});
var credential = await CreateCredentialAsync(cancellationToken);
var graphClient = new GraphServiceClient(credential, ["Contacts.Read"]);
@ -78,6 +76,60 @@ public sealed class Office365ContactProvider : IContactProvider
return result;
}
private async Task<DeviceCodeCredential> CreateCredentialAsync(CancellationToken cancellationToken)
{
AuthenticationRecord? authRecord = null;
if (File.Exists(AuthRecordPath))
{
try
{
await using var readStream = File.OpenRead(AuthRecordPath);
authRecord = await AuthenticationRecord.DeserializeAsync(readStream, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Gespeicherter Office365-AuthenticationRecord konnte nicht gelesen werden, melde erneut interaktiv an");
}
}
var credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions
{
ClientId = _options.Office365.ClientId,
TenantId = _options.Office365.TenantId,
TokenCachePersistenceOptions = new TokenCachePersistenceOptions
{
Name = "Anrufmonitor.Office365",
},
AuthenticationRecord = authRecord,
DeviceCodeCallback = (info, ct) =>
{
_logger.LogWarning(
"Office365-Anmeldung erforderlich: {Message}",
info.Message);
DeviceCodeRequired?.Invoke(info.Message);
return Task.CompletedTask;
},
});
if (authRecord is null)
{
// Erste Anmeldung ueberhaupt (oder Record fehlt/ist kaputt): einmal interaktiv
// authentifizieren und den Record fuer alle folgenden Starts wegschreiben.
var newRecord = await credential.AuthenticateAsync(
new global::Azure.Core.TokenRequestContext(["https://graph.microsoft.com/Contacts.Read"]),
cancellationToken);
Directory.CreateDirectory(Path.GetDirectoryName(AuthRecordPath)!);
await using var writeStream = File.Create(AuthRecordPath);
await newRecord.SerializeAsync(writeStream, cancellationToken);
_logger.LogInformation("Office365-Anmeldung gespeichert fuer {Account}", newRecord.Username);
}
return credential;
}
private static ContactInfo MapContact(Contact contact)
{
var numbers = new List<string>();

View file

@ -19,7 +19,7 @@ public sealed class SipOptions
public string? DisplayName { get; set; }
public int RegisterExpirySeconds { get; set; } = 300;
public int RegisterExpirySeconds { get; set; } = 3600;
/// <summary>
/// Passiv = nur mitlesen. Es wird nie geantwortet (200 OK), der Anruf wird also nie