Files
EugeneTes ccfa3c1ade feat(google-calendar): OAuth device flow, refresh and revoke client
Spec ยง3: DeviceAuthorization/PollStatus shapes, Success carries the access token, 5xx without error is a transport error, RefreshAsync returns AccessToken, RevokeAsync returns the failure reason (null on success).
2026-09-26 20:03:09 +02:00

145 lines
7.6 KiB
C#

using System.Globalization;
using System.Net;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
internal sealed record DeviceAuthorization(string DeviceCode, string UserCode, string VerificationUrl, DateTimeOffset ExpiresAt, TimeSpan Interval);
internal enum PollStatus { Success, Pending, SlowDown, Denied, Expired, Failed }
/// <summary>One poll of the token endpoint. <see cref="ToString"/> never shows tokens.</summary>
internal sealed record PollResult(PollStatus Status, string? RefreshToken = null, string? Scope = null, string? AccessToken = null,
DateTimeOffset AccessTokenExpiresAt = default, string? Message = null)
{
public static readonly PollResult Pending = new(PollStatus.Pending);
public static readonly PollResult SlowDown = new(PollStatus.SlowDown);
public static readonly PollResult Denied = new(PollStatus.Denied);
public static readonly PollResult Expired = new(PollStatus.Expired);
public static PollResult Succeeded(string refreshToken, string? scope, string accessToken, DateTimeOffset expiresAt) =>
new(PollStatus.Success, refreshToken, scope, accessToken, expiresAt);
public static PollResult Failed(string message) => new(PollStatus.Failed, Message: message);
public override string ToString() => Message is null ? Status.ToString() : $"{Status}: {Message}";
}
internal sealed record AccessToken(string Token, DateTimeOffset ExpiresAt)
{
public override string ToString() => $"AccessToken {{ ExpiresAt = {ExpiresAt:O} }}";
}
/// <summary>
/// Google's OAuth 2.0 device flow for limited-input devices, refresh and revoke, over raw HTTP. Error mapping reads
/// the JSON <c>error</c> field whatever the status code (Google documents 428 and 403 besides 400). Messages never
/// contain tokens or the client secret.
/// </summary>
internal sealed class GoogleOAuthClient(HttpClient http, string clientId, string clientSecret, TimeProvider time)
{
public const string DeviceCodeUrl = "https://oauth2.googleapis.com/device/code";
public const string TokenUrl = "https://oauth2.googleapis.com/token";
public const string RevokeUrl = "https://oauth2.googleapis.com/revoke";
public const string CalendarScope = "https://www.googleapis.com/auth/calendar";
public const string DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code";
public async Task<DeviceAuthorization> StartDeviceFlowAsync(CancellationToken cancellationToken)
{
var (status, json) = await PostAsync(DeviceCodeUrl, [new("client_id", clientId), new("scope", CalendarScope)], cancellationToken).ConfigureAwait(false);
if (!IsSuccess(status) || json is null || Str(json, "device_code") is not { } deviceCode || Str(json, "user_code") is not { } userCode)
throw new GoogleCalendarException($"Google refused the device code request: {Describe(json, status)}");
var url = Str(json, "verification_url") ?? Str(json, "verification_uri") ?? "https://www.google.com/device";
var expiresIn = Int(json, "expires_in") ?? 1800;
var interval = Math.Max(1, Int(json, "interval") ?? 5);
return new DeviceAuthorization(deviceCode, userCode, url, time.GetUtcNow().AddSeconds(expiresIn), TimeSpan.FromSeconds(interval));
}
public async Task<PollResult> PollAsync(string deviceCode, CancellationToken cancellationToken)
{
var (status, json) = await PostAsync(TokenUrl,
[new("client_id", clientId), new("client_secret", clientSecret), new("device_code", deviceCode), new("grant_type", DeviceGrantType)],
cancellationToken).ConfigureAwait(false);
if (json is not null && Str(json, "error") is { } error)
{
return error switch
{
"authorization_pending" => PollResult.Pending,
"slow_down" => PollResult.SlowDown,
"access_denied" => PollResult.Denied,
"expired_token" => PollResult.Expired,
_ => PollResult.Failed(Describe(json, status)),
};
}
if ((int)status >= 500) throw new HttpRequestException($"Google returned HTTP {(int)status}.");
if (!IsSuccess(status) || json is null) return PollResult.Failed(Describe(json, status));
if (Str(json, "refresh_token") is not { Length: > 0 } refreshToken) return PollResult.Failed(GoogleCalendarMessages.NoRefreshToken);
return PollResult.Succeeded(refreshToken, Str(json, "scope"), Str(json, "access_token") ?? "",
time.GetUtcNow().AddSeconds(Int(json, "expires_in") ?? 3600));
}
public async Task<AccessToken> RefreshAsync(string refreshToken, CancellationToken cancellationToken)
{
var (status, json) = await PostAsync(TokenUrl,
[new("client_id", clientId), new("client_secret", clientSecret), new("refresh_token", refreshToken), new("grant_type", "refresh_token")],
cancellationToken).ConfigureAwait(false);
if (json is not null && Str(json, "error") == "invalid_grant")
throw new GoogleCalendarException(GoogleCalendarMessages.TokenRejected, notConnected: true);
if (!IsSuccess(status) || json is null || Str(json, "access_token") is not { Length: > 0 } accessToken)
throw new GoogleCalendarException($"Google token refresh failed: {Describe(json, status)}");
return new AccessToken(accessToken, time.GetUtcNow().AddSeconds(Int(json, "expires_in") ?? 3600));
}
/// <summary>Best effort. Null when Google revoked the token, else the reason.</summary>
public async Task<string?> RevokeAsync(string token, CancellationToken cancellationToken)
{
try
{
var (status, json) = await PostAsync(RevokeUrl, [new("token", token)], cancellationToken).ConfigureAwait(false);
return IsSuccess(status) ? null : Describe(json, status);
}
catch (HttpRequestException ex)
{
return ex.Message;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
return ex.Message; // HttpClient timeout
}
}
private async Task<(HttpStatusCode Status, JsonObject? Json)> PostAsync(string url, KeyValuePair<string, string>[] form, CancellationToken cancellationToken)
{
using var content = new FormUrlEncodedContent(form);
using var response = await http.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
JsonObject? json = null;
try { json = JsonNode.Parse(text) as JsonObject; }
catch (System.Text.Json.JsonException) { }
return (response.StatusCode, json);
}
private static bool IsSuccess(HttpStatusCode status) => (int)status is >= 200 and < 300;
private static string Describe(JsonObject? json, HttpStatusCode status)
{
var error = json is null ? null : Str(json, "error");
var description = json is null ? null : Str(json, "error_description");
return (error, description) switch
{
({ } e, { } d) => $"{e}: {d}",
({ } e, null) => e,
_ => $"HTTP {(int)status}",
};
}
private static string? Str(JsonObject json, string name) =>
json[name] is JsonValue value && value.TryGetValue<string>(out var s) ? s : null;
private static int? Int(JsonObject json, string name) => json[name] switch
{
JsonValue v when v.TryGetValue<int>(out var i) => i,
JsonValue v when v.TryGetValue<string>(out var s) && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) => p,
_ => null,
};
}