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).
This commit is contained in:
EugeneTes
2026-09-26 20:03:09 +02:00
parent 199ad47f64
commit ccfa3c1ade
4 changed files with 421 additions and 8 deletions

View File

@@ -110,16 +110,22 @@ and GOOGLE_CLIENT_SECRET`). If only one of the two is set, it logs a `Warning` i
### 3. OAuth client — `GoogleOAuthClient`
- `StartDeviceFlowAsync(ct) → DeviceCode(DeviceCode, UserCode, VerificationUrl, ExpiresAt, Interval)`. A non-2xx
response throws `GoogleCalendarException` with Google's `error`/`error_description`.
- `PollAsync(deviceCode, ct) → PollResult`: `Success(refreshToken, scope)`, `Pending`, `SlowDown`, `Denied`, `Expired`,
`Failed(message)`. **The mapping reads the JSON `error` field whatever the status code.** Google documents 428 for
`authorization_pending` and 403 for `slow_down`/`access_denied`; the reference only looked at 400.
- `StartDeviceFlowAsync(ct) → DeviceAuthorization(DeviceCode, UserCode, VerificationUrl, ExpiresAt, Interval)`
(`verification_url`, or `verification_uri` when Google sends that; the interval defaults to 5 seconds). A non-2xx
response throws `GoogleCalendarException` with Google's `error`/`error_description`, never the client secret.
- `PollAsync(deviceCode, ct) → PollResult`, one record with a `PollStatus`: `Success` (refresh token, scope, access
token and its expiry; the connect flow uses the access token for `calendars/primary`), `Pending`, `SlowDown`,
`Denied`, `Expired`, `Failed(message)`. **The mapping reads the JSON `error` field whatever the status code.** Google
documents 428 for `authorization_pending` and 403 for `slow_down`/`access_denied`; the reference only looked at 400.
A success without `refresh_token` is `Failed("Google returned no refresh token. Remove the app at
https://myaccount.google.com/permissions and run /calendar_connect again.")`.
- `RefreshAsync(refreshToken, ct) → (accessToken, expiresAt)`. `invalid_grant` throws
https://myaccount.google.com/permissions and run /calendar_connect again.")`. A 5xx response without an `error`
field throws `HttpRequestException` and counts as a transport error in the flow (§5). `PollResult.ToString()` never
shows tokens.
- `RefreshAsync(refreshToken, ct) → AccessToken(Token, ExpiresAt)`. `invalid_grant` throws
`GoogleCalendarException(NotConnected: true, "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.")`.
- `RevokeAsync(token, ct)`: best effort; failures are reported but don't stop a disconnect.
Other errors throw `GoogleCalendarException("Google token refresh failed: <error>[: <description>]")`.
- `RevokeAsync(token, ct) → string?`: best effort; `null` when Google revoked the token, else the failure reason
(Google's error, `HTTP <status>` or the transport error). Failures are reported but don't stop a disconnect.
### 4. Connections — `GoogleCalendarConnections` (process-wide) and `GoogleCalendarConnection` (per chat)

View File

@@ -0,0 +1,144 @@
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,
};
}

View File

@@ -0,0 +1,173 @@
using System.Net;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleOAuthClientTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _time = new(Now);
private readonly GoogleFakeHttp _http;
private readonly GoogleOAuthClient _client;
public GoogleOAuthClientTests()
{
_http = new GoogleFakeHttp(_time);
_client = new GoogleOAuthClient(_http.CreateClient(), "cid", "csecret", _time);
}
[Fact]
public async Task Start_posts_the_client_id_and_the_calendar_scope()
{
_http.Json("POST /device/code", """{"device_code":"dev-1","user_code":"ABCD-EFGH","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}""");
var device = await _client.StartDeviceFlowAsync(CancellationToken.None);
var request = Assert.Single(_http.Requests);
Assert.Equal("https://oauth2.googleapis.com/device/code", request.Uri.ToString());
Assert.Equal(new Dictionary<string, string> { ["client_id"] = "cid", ["scope"] = "https://www.googleapis.com/auth/calendar" }, request.Form);
Assert.Equal(new DeviceAuthorization("dev-1", "ABCD-EFGH", "https://www.google.com/device", Now.AddSeconds(1800), TimeSpan.FromSeconds(5)), device);
}
[Fact]
public async Task Start_accepts_verification_uri_and_defaults_the_interval_to_5_seconds()
{
_http.Json("POST /device/code", """{"device_code":"d","user_code":"U","verification_uri":"https://example.test/device","expires_in":600}""");
var device = await _client.StartDeviceFlowAsync(CancellationToken.None);
Assert.Equal("https://example.test/device", device.VerificationUrl);
Assert.Equal(TimeSpan.FromSeconds(5), device.Interval);
}
[Fact]
public async Task Start_error_throws_with_googles_error_and_description()
{
_http.Json("POST /device/code", (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"The OAuth client was not found."}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.StartDeviceFlowAsync(CancellationToken.None));
Assert.Contains("invalid_client: The OAuth client was not found.", ex.Message);
Assert.DoesNotContain("csecret", ex.Message);
}
[Fact]
public async Task Poll_posts_the_device_code_grant()
{
_http.Json("POST /token", (HttpStatusCode.PreconditionRequired, """{"error":"authorization_pending"}"""));
await _client.PollAsync("dev-1", CancellationToken.None);
var form = Assert.Single(_http.Requests).Form;
Assert.Equal("cid", form["client_id"]);
Assert.Equal("csecret", form["client_secret"]);
Assert.Equal("dev-1", form["device_code"]);
Assert.Equal("urn:ietf:params:oauth:grant-type:device_code", form["grant_type"]);
}
[Theory]
[InlineData(428)]
[InlineData(400)]
public async Task Poll_maps_authorization_pending_whatever_the_status(int status)
{
_http.Json("POST /token", ((HttpStatusCode)status, """{"error":"authorization_pending","error_description":"Precondition Required"}"""));
Assert.Equal(PollStatus.Pending, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_slow_down_at_403()
{
_http.Json("POST /token", (HttpStatusCode.Forbidden, """{"error":"slow_down"}"""));
Assert.Equal(PollStatus.SlowDown, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_access_denied()
{
_http.Json("POST /token", (HttpStatusCode.Forbidden, """{"error":"access_denied","error_description":"Forbidden"}"""));
Assert.Equal(PollStatus.Denied, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_expired_token()
{
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"expired_token"}"""));
Assert.Equal(PollStatus.Expired, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_an_unknown_error_to_failed_with_its_text()
{
_http.Json("POST /token", (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"Unauthorized"}"""));
var result = await _client.PollAsync("dev-1", CancellationToken.None);
Assert.Equal(PollStatus.Failed, result.Status);
Assert.Equal("invalid_client: Unauthorized", result.Message);
}
[Fact]
public async Task Poll_500_without_an_error_field_is_a_transport_error()
{
_http.Json("POST /token", (HttpStatusCode.InternalServerError, "<html>oops</html>"));
await Assert.ThrowsAsync<HttpRequestException>(() => _client.PollAsync("dev-1", CancellationToken.None));
}
[Fact]
public async Task Poll_success_returns_the_refresh_and_access_tokens()
{
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3599,"refresh_token":"rt-1","scope":"https://www.googleapis.com/auth/calendar","token_type":"Bearer"}""");
var result = await _client.PollAsync("dev-1", CancellationToken.None);
Assert.Equal(PollStatus.Success, result.Status);
Assert.Equal("rt-1", result.RefreshToken);
Assert.Equal("at-1", result.AccessToken);
Assert.Equal("https://www.googleapis.com/auth/calendar", result.Scope);
Assert.Equal(Now.AddSeconds(3599), result.AccessTokenExpiresAt);
Assert.DoesNotContain("rt-1", result.ToString());
Assert.DoesNotContain("at-1", result.ToString());
}
[Fact]
public async Task Poll_success_without_a_refresh_token_fails_with_the_permissions_hint()
{
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3599}""");
var result = await _client.PollAsync("dev-1", CancellationToken.None);
Assert.Equal(PollStatus.Failed, result.Status);
Assert.Equal("Google returned no refresh token. Remove the app at https://myaccount.google.com/permissions and run /calendar_connect again.", result.Message);
}
[Fact]
public async Task Refresh_returns_the_access_token_and_its_expiry()
{
_http.Json("POST /token", """{"access_token":"at-2","expires_in":3600}""");
var token = await _client.RefreshAsync("rt-1", CancellationToken.None);
Assert.Equal(new AccessToken("at-2", Now.AddSeconds(3600)), token);
var form = Assert.Single(_http.Requests).Form;
Assert.Equal("refresh_token", form["grant_type"]);
Assert.Equal("rt-1", form["refresh_token"]);
Assert.Equal("csecret", form["client_secret"]);
}
[Fact]
public async Task Refresh_invalid_grant_throws_not_connected_without_the_token()
{
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant","error_description":"Token has been expired or revoked."}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.RefreshAsync("rt-secret-1", CancellationToken.None));
Assert.True(ex.NotConnected);
Assert.Equal("Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.", ex.Message);
Assert.DoesNotContain("rt-secret-1", ex.ToString());
}
[Fact]
public async Task Refresh_other_errors_throw_with_googles_text()
{
_http.Json("POST /token", (HttpStatusCode.Unauthorized, """{"error":"invalid_client"}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.RefreshAsync("rt-1", CancellationToken.None));
Assert.False(ex.NotConnected);
Assert.Equal("Google token refresh failed: invalid_client", ex.Message);
}
[Fact]
public async Task Revoke_posts_the_token_and_reports_failures()
{
_http.Json("POST /revoke", (HttpStatusCode.OK, "{}"), (HttpStatusCode.BadRequest, """{"error":"invalid_token"}"""));
Assert.Null(await _client.RevokeAsync("rt-1", CancellationToken.None));
Assert.Equal("rt-1", _http.To("POST /revoke")[0].Form["token"]);
Assert.Equal("invalid_token", await _client.RevokeAsync("rt-1", CancellationToken.None));
_http.Fail("POST /revoke", "no route to host");
Assert.Equal("no route to host", await _client.RevokeAsync("rt-1", CancellationToken.None));
}
}

View File

@@ -0,0 +1,90 @@
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
/// <summary>One request as Google would have seen it, with the fake clock's time.</summary>
internal sealed record RecordedRequest(HttpMethod Method, Uri Uri, string? Body, string? Authorization, DateTimeOffset At)
{
public string Route => $"{Method.Method} {Uri.AbsolutePath}";
public IReadOnlyDictionary<string, string> Form => Parse(Body);
public IReadOnlyDictionary<string, string> Query => Parse(Uri.Query.TrimStart('?'));
public JsonObject Json => JsonNode.Parse(Body ?? "{}")!.AsObject();
private static Dictionary<string, string> Parse(string? encoded) =>
(encoded ?? "").Split('&', StringSplitOptions.RemoveEmptyEntries)
.Select(pair => pair.Split('=', 2))
.ToDictionary(p => Decode(p[0]), p => p.Length > 1 ? Decode(p[1]) : "");
private static string Decode(string s) => Uri.UnescapeDataString(s.Replace('+', ' '));
}
/// <summary>
/// Scripted Google endpoints keyed by <c>"METHOD /path"</c> (host ignored). A route serves its responders in order and
/// repeats the last one. Unrouted requests get a 501 so a missing script is loud.
/// </summary>
internal sealed class GoogleFakeHttp(TimeProvider? time = null) : HttpMessageHandler
{
private readonly Lock _lock = new();
private readonly Dictionary<string, (Func<RecordedRequest, Task<HttpResponseMessage>>[] Responders, int Served)> _routes = new();
private readonly List<RecordedRequest> _requests = [];
public IReadOnlyList<RecordedRequest> Requests { get { lock (_lock) return _requests.ToList(); } }
public IReadOnlyList<RecordedRequest> To(string route) { lock (_lock) return _requests.Where(r => r.Route == route).ToList(); }
/// <summary>Replaces the route's script.</summary>
public GoogleFakeHttp On(string route, params Func<RecordedRequest, Task<HttpResponseMessage>>[] responders)
{
lock (_lock) _routes[route] = (responders, 0);
return this;
}
public GoogleFakeHttp Json(string route, params (HttpStatusCode Status, string Body)[] responses) =>
On(route, responses.Select(r => (Func<RecordedRequest, Task<HttpResponseMessage>>)(_ => Task.FromResult(Response(r.Status, r.Body)))).ToArray());
public GoogleFakeHttp Json(string route, string body) => Json(route, (HttpStatusCode.OK, body));
public GoogleFakeHttp Reply(string route, Func<RecordedRequest, (HttpStatusCode Status, string Body)> respond) =>
On(route, r =>
{
var (status, body) = respond(r);
return Task.FromResult(Response(status, body));
});
public static Func<RecordedRequest, Task<HttpResponseMessage>> Throws(string message = "network down") =>
_ => Task.FromException<HttpResponseMessage>(new HttpRequestException(message));
public static Func<RecordedRequest, Task<HttpResponseMessage>> Returns(HttpStatusCode status, string body) =>
_ => Task.FromResult(Response(status, body));
public GoogleFakeHttp Fail(string route, string message = "network down") => On(route, Throws(message));
public static HttpResponseMessage Response(HttpStatusCode status, string body) =>
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
public HttpClient CreateClient() => new(this, disposeHandler: false);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
var recorded = new RecordedRequest(request.Method, request.RequestUri!, body, request.Headers.Authorization?.ToString(),
(time ?? TimeProvider.System).GetUtcNow());
Func<RecordedRequest, Task<HttpResponseMessage>>? responder = null;
lock (_lock)
{
_requests.Add(recorded);
if (_routes.TryGetValue(recorded.Route, out var route))
{
responder = route.Responders[Math.Min(route.Served, route.Responders.Length - 1)];
_routes[recorded.Route] = (route.Responders, route.Served + 1);
}
}
if (responder is null)
return Response(HttpStatusCode.NotImplemented, $$$"""{"error":{"code":501,"message":"no fake route for {{{recorded.Route}}}"}}""");
var response = await responder(recorded);
response.RequestMessage = request;
return response;
}
}