Files
EugeneTes d2fd7ffa02 feat(google-calendar): encrypt chat tokens and give watchers a short-lived access token
token.json is sealed with AES-256-GCM (key derived from the OAuth client secret,
bound to the chat), so the agent's processes can read the file but not the
refresh token. Plain files, including VolcanicSecretary's refresh_token format,
are rewritten sealed on first read.

The scheduler gains IScriptEnvironment: extra per-run variables set after the
process environment filter and redacted from the run's output. The Telegram
host uses it to pass GOOGLE_ACCESS_TOKEN, refreshed to cover the job's timeout;
the refresh token and client secret never reach the script.
2026-10-02 08:31:24 +00:00

157 lines
7.8 KiB
C#

using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// Explicit process-wide state (like <c>ClaudeLoginCoordinator.Shared</c>): extensions are per session, but a chat's
/// Google account, access-token cache and pending connect flow outlive its sessions. One
/// <see cref="GoogleCalendarConnection"/> per chat token directory, created on first use.
/// </summary>
public sealed class GoogleCalendarConnections
{
/// <summary>The process-wide instance the extension uses unless its options pass another.</summary>
public static GoogleCalendarConnections Shared { get; } = new();
/// <summary>Where a chat's token lives inside its workspace when no token root is configured.</summary>
public static readonly string WorkspaceTokenDirectory = Path.Combine(".volcanic", "google-calendar");
private readonly ConcurrentDictionary<string, GoogleCalendarConnection> _connections = new(StringComparer.Ordinal);
/// <summary>
/// The connection for a chat, keyed by its token directory: <c>&lt;workspace&gt;/.volcanic/google-calendar</c>, or
/// <c>&lt;root&gt;/&lt;chat key&gt;</c> when the options set a token root. The first caller's settings create it.
/// </summary>
internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope, string workspace)
{
ArgumentNullException.ThrowIfNull(settings);
var directory = Path.GetFullPath(settings.TokenRoot is { } root
? Path.Combine(root, ChatKeys.For(scope))
: Path.Combine(workspace, WorkspaceTokenDirectory));
return _connections.GetOrAdd(directory, d => new GoogleCalendarConnection(settings,
new GoogleTokenStore(Path.Combine(d, GoogleTokenStore.FileName), new TokenCipher(settings.ClientSecret, ChatKeys.For(scope)))));
}
/// <summary>
/// For the host (e.g. a scheduled script's environment): the chat's access token, valid for at least
/// <paramref name="validFor"/> as far as Google's token lifetime allows, or null when the calendar isn't configured or
/// the chat isn't connected (or Google rejected its token). The refresh token and the client secret never leave this
/// process. Other failures (network, Google errors) throw.
/// </summary>
public async Task<string?> TryGetAccessTokenAsync(GoogleCalendarOptions options, string scope, string workspace, TimeSpan validFor,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
if (options.Resolve() is not { } settings) return null;
try
{
return await For(settings, scope, workspace).GetAccessTokenAsync(cancellationToken, validFor).ConfigureAwait(false);
}
catch (GoogleCalendarException ex) when (ex.NotConnected)
{
return null;
}
}
}
/// <summary>One chat's Google account: token file, access-token cache (single-flight refresh), pending connect flow.</summary>
internal sealed class GoogleCalendarConnection
{
private static readonly TimeSpan RefreshMargin = TimeSpan.FromSeconds(60);
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private CachedToken? _cached;
private ConnectFlow? _pending;
private int _unknownZoneLogged;
public GoogleCalendarConnection(GoogleCalendarSettings settings, GoogleTokenStore store)
{
Settings = settings;
Store = store;
OAuth = new GoogleOAuthClient(settings.Http, settings.ClientId, settings.ClientSecret, settings.Time);
}
public GoogleCalendarSettings Settings { get; }
public GoogleTokenStore Store { get; }
public GoogleOAuthClient OAuth { get; }
/// <summary>
/// Serialises "is a flow pending? else start one" so two /calendar_connect in one chat can't both start, and the
/// flow's final token write with /calendar_disconnect so a disconnected chat can't end up connected.
/// </summary>
public SemaphoreSlim FlowGate { get; } = new(1, 1);
/// <summary>This chat's in-flight device flow, if any; a cancelled or expired one is cancelled, cleared and not returned.</summary>
public ConnectFlow? Pending
{
get
{
var flow = Volatile.Read(ref _pending);
if (flow is null || flow.IsLive(Settings.Time)) return flow;
flow.Cancel(); // an expired flow still parked in its delay ends silently instead of reporting later
End(flow);
return null;
}
}
/// <summary>Registers <paramref name="flow"/> as pending. Callers hold <see cref="FlowGate"/>.</summary>
public void Begin(ConnectFlow flow) => Volatile.Write(ref _pending, flow);
/// <summary>Clears the pending flow if it is still <paramref name="flow"/>.</summary>
public void End(ConnectFlow flow) => Interlocked.CompareExchange(ref _pending, null, flow);
/// <summary>Cancels the pending flow silently (used by /calendar_disconnect).</summary>
public void CancelPending() => Volatile.Read(ref _pending)?.Cancel();
/// <summary>
/// A valid access token: the cached one while it has more than 60 s (or <paramref name="validFor"/>, if longer) left
/// and belongs to the stored refresh token, else one refresh shared by all concurrent callers. A fresh token is
/// returned even when Google's lifetime is shorter than <paramref name="validFor"/>.
/// </summary>
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken, TimeSpan? validFor = null)
{
var margin = validFor is { } wanted && wanted > RefreshMargin ? wanted : RefreshMargin;
var stored = Store.Read() ?? throw new GoogleCalendarException(GoogleCalendarMessages.NotConnected, notConnected: true);
if (Fresh(stored, margin) is { } token) return token;
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
stored = Store.Read() ?? throw new GoogleCalendarException(GoogleCalendarMessages.NotConnected, notConnected: true);
if (Fresh(stored, margin) is { } again) return again;
var access = await OAuth.RefreshAsync(stored.RefreshToken, cancellationToken).ConfigureAwait(false);
Volatile.Write(ref _cached, new CachedToken(stored.RefreshToken, access.Token, access.ExpiresAt));
return access.Token;
}
finally
{
_refreshLock.Release();
}
}
/// <summary>Drops the cached access token (after a 401, a reconnect or a disconnect).</summary>
public void Invalidate() => Volatile.Write(ref _cached, null);
/// <summary>The calendar's own zone (stored at connect), else the chat's zone.</summary>
public TimeZoneInfo ResolveTimeZone(TimeZoneInfo chatZone, ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(chatZone);
var id = Store.Read()?.TimeZone;
if (string.IsNullOrWhiteSpace(id)) return chatZone;
if (ZoneIds.TryFind(id, out var zone)) return zone;
// Claim the log-once flag only when there is a logger: a logger-less call (e.g. the tool description) must not use it up.
if (logger is not null && Interlocked.Exchange(ref _unknownZoneLogged, 1) == 0)
logger.LogWarning("google-calendar: the calendar's time zone {Zone} is unknown on this machine; using the chat's zone {ChatZone}", id, ZoneIds.Name(chatZone));
return chatZone;
}
private string? Fresh(StoredToken stored, TimeSpan margin) =>
Volatile.Read(ref _cached) is { } cached && cached.RefreshToken == stored.RefreshToken
&& cached.ExpiresAt - Settings.Time.GetUtcNow() > margin
? cached.AccessToken
: null;
private sealed record CachedToken(string RefreshToken, string AccessToken, DateTimeOffset ExpiresAt)
{
public override string ToString() => $"CachedToken {{ ExpiresAt = {ExpiresAt:O} }}";
}
}