forked from admin/VolcanicAgenticCore
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).
145 lines
7.6 KiB
C#
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,
|
|
};
|
|
}
|