Add Jarvis Google Calendar OAuth broker

This commit is contained in:
zsolt
2026-08-13 13:22:40 +02:00
parent 94f09a3328
commit 7e433ead88
10 changed files with 567 additions and 236 deletions
+375 -2
View File
@@ -1,9 +1,12 @@
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@@ -151,6 +154,214 @@ app.MapGet("/api/app-config", async (HttpContext context, OtthonDbContext db) =>
!string.IsNullOrWhiteSpace(googleClientId)));
});
app.MapGet("/api/google/oauth/start", async (
HttpContext context,
OtthonDbContext db) =>
{
if (!HasApiToken(context))
{
return Results.Unauthorized();
}
var workspaceToken = GetWorkspaceToken(context);
if (string.IsNullOrWhiteSpace(workspaceToken))
{
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
}
var clientId = await GetGoogleClientId(db);
var clientSecret = await GetGoogleClientSecret(db);
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
{
return Results.BadRequest(new { message = "Google OAuth client is not configured." });
}
var stateValue = RandomToken(32);
db.GoogleOAuthStates.Add(new GoogleOAuthState
{
State = stateValue,
WorkspaceToken = workspaceToken,
CreatedUtc = DateTimeOffset.UtcNow,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(15)
});
await db.SaveChangesAsync();
var redirectUri = GoogleOAuthRedirectUri();
var authUri = QueryHelpers.AddQueryString("https://accounts.google.com/o/oauth2/v2/auth", new Dictionary<string, string?>
{
["client_id"] = clientId,
["redirect_uri"] = redirectUri,
["response_type"] = "code",
["scope"] = "openid email https://www.googleapis.com/auth/calendar.readonly",
["access_type"] = "offline",
["prompt"] = "consent",
["state"] = stateValue
});
return Results.Ok(new GoogleOAuthStartResponse(authUri));
});
app.MapGet("/api/google/oauth/callback", async (
string? code,
string? state,
string? error,
OtthonDbContext db,
IHttpClientFactory httpClientFactory) =>
{
if (!string.IsNullOrWhiteSpace(error))
{
return Results.Content(GoogleOAuthResultPage(false, $"Google rejected the request: {Html(error)}"), "text/html; charset=utf-8");
}
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
{
return Results.Content(GoogleOAuthResultPage(false, "Missing OAuth code or state."), "text/html; charset=utf-8");
}
var savedState = await db.GoogleOAuthStates.FindAsync(state);
if (savedState is null || savedState.ExpiresUtc < DateTimeOffset.UtcNow)
{
return Results.Content(GoogleOAuthResultPage(false, "OAuth session expired. Start Google connection again from the tablet."), "text/html; charset=utf-8");
}
var clientId = await GetGoogleClientId(db);
var clientSecret = await GetGoogleClientSecret(db);
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
{
return Results.Content(GoogleOAuthResultPage(false, "Google OAuth client is not configured in JARVIS."), "text/html; charset=utf-8");
}
var http = httpClientFactory.CreateClient();
var tokenResponse = await http.PostAsync(
"https://oauth2.googleapis.com/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["code"] = code,
["client_id"] = clientId,
["client_secret"] = clientSecret,
["redirect_uri"] = GoogleOAuthRedirectUri(),
["grant_type"] = "authorization_code"
}));
var tokenBody = await tokenResponse.Content.ReadAsStringAsync();
if (!tokenResponse.IsSuccessStatusCode)
{
return Results.Content(GoogleOAuthResultPage(false, $"Token exchange failed: {Html(tokenBody)}"), "text/html; charset=utf-8");
}
using var tokenJson = JsonDocument.Parse(tokenBody);
var accessToken = GetString(tokenJson.RootElement, "access_token");
var refreshToken = GetString(tokenJson.RootElement, "refresh_token");
var expiresIn = tokenJson.RootElement.TryGetProperty("expires_in", out var expiresElement)
? expiresElement.GetInt32()
: 3600;
if (string.IsNullOrWhiteSpace(accessToken))
{
return Results.Content(GoogleOAuthResultPage(false, "Google did not return an access token."), "text/html; charset=utf-8");
}
var email = await LoadGoogleEmail(http, accessToken);
var auth = await db.GoogleCalendarAuths.FindAsync(savedState.WorkspaceToken);
if (auth is null)
{
auth = new GoogleCalendarAuth
{
WorkspaceToken = savedState.WorkspaceToken,
AccessToken = accessToken,
RefreshToken = refreshToken ?? "",
Email = email,
ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60)),
UpdatedUtc = DateTimeOffset.UtcNow
};
db.GoogleCalendarAuths.Add(auth);
}
else
{
auth.AccessToken = accessToken;
if (!string.IsNullOrWhiteSpace(refreshToken))
{
auth.RefreshToken = refreshToken;
}
auth.Email = email ?? auth.Email;
auth.ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60));
auth.UpdatedUtc = DateTimeOffset.UtcNow;
}
db.GoogleOAuthStates.Remove(savedState);
await db.SaveChangesAsync();
return Results.Content(GoogleOAuthResultPage(true, $"Google Calendar connected for {Html(auth.Email ?? "the selected account")}."), "text/html; charset=utf-8");
});
app.MapGet("/api/google/oauth/status", async (HttpContext context, OtthonDbContext db) =>
{
if (!HasApiToken(context)) return Results.Unauthorized();
var workspaceToken = GetWorkspaceToken(context);
if (string.IsNullOrWhiteSpace(workspaceToken))
{
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
}
var auth = await db.GoogleCalendarAuths.FindAsync(workspaceToken);
return Results.Ok(new GoogleOAuthStatusResponse(auth is not null, auth?.Email, auth?.UpdatedUtc));
});
app.MapGet("/api/google/calendars", async (
HttpContext context,
OtthonDbContext db,
IHttpClientFactory httpClientFactory) =>
{
if (!HasApiToken(context)) return Results.Unauthorized();
var workspaceToken = GetWorkspaceToken(context);
if (string.IsNullOrWhiteSpace(workspaceToken))
{
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
}
var token = await GetGoogleAccessToken(db, httpClientFactory, workspaceToken);
if (token is null) return Results.BadRequest(new { message = "Google Calendar is not connected." });
var http = httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader&showHidden=true");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
return Results.Content(body, "application/json; charset=utf-8", statusCode: (int)response.StatusCode);
});
app.MapGet("/api/google/events", async (
HttpContext context,
string calendarId,
string? timeMin,
string? timeMax,
OtthonDbContext db,
IHttpClientFactory httpClientFactory) =>
{
if (!HasApiToken(context)) return Results.Unauthorized();
var workspaceToken = GetWorkspaceToken(context);
if (string.IsNullOrWhiteSpace(workspaceToken))
{
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
}
var token = await GetGoogleAccessToken(db, httpClientFactory, workspaceToken);
if (token is null) return Results.BadRequest(new { message = "Google Calendar is not connected." });
var query = new Dictionary<string, string?>
{
["singleEvents"] = "true",
["orderBy"] = "startTime",
["timeMin"] = timeMin,
["timeMax"] = timeMax,
["maxResults"] = "2500"
};
var uri = QueryHelpers.AddQueryString(
$"https://www.googleapis.com/calendar/v3/calendars/{Uri.EscapeDataString(calendarId)}/events",
query);
var http = httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
return Results.Content(body, "application/json; charset=utf-8", statusCode: (int)response.StatusCode);
});
app.MapPost("/api/devices/register", async (
DeviceRegistrationRequest request,
HttpContext context,
@@ -661,6 +872,88 @@ static async Task SetSetting(OtthonDbContext db, string key, string value)
await db.SaveChangesAsync();
}
static string RandomToken(int byteCount)
{
var bytes = RandomNumberGenerator.GetBytes(byteCount);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
static string GoogleOAuthRedirectUri() =>
"https://api-test.a-web.hu/api/google/oauth/callback";
static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId";
static string GoogleCalendarClientSecretSettingKey() => "GoogleCalendarClientSecret";
static async Task<string?> GetGoogleClientId(OtthonDbContext db) =>
await GetSetting(db, GoogleCalendarServerClientIdSettingKey());
static async Task<string?> GetGoogleClientSecret(OtthonDbContext db) =>
await GetSetting(db, GoogleCalendarClientSecretSettingKey());
static async Task<string?> LoadGoogleEmail(HttpClient http, string accessToken)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, "https://www.googleapis.com/oauth2/v2/userinfo");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await http.SendAsync(request);
if (!response.IsSuccessStatusCode) return null;
var body = await response.Content.ReadAsStringAsync();
using var json = JsonDocument.Parse(body);
return GetString(json.RootElement, "email");
}
catch
{
return null;
}
}
static async Task<string?> GetGoogleAccessToken(
OtthonDbContext db,
IHttpClientFactory httpClientFactory,
string workspaceToken)
{
var auth = await db.GoogleCalendarAuths.FindAsync(workspaceToken);
if (auth is null) return null;
if (auth.ExpiresUtc > DateTimeOffset.UtcNow.AddMinutes(2))
{
return auth.AccessToken;
}
if (string.IsNullOrWhiteSpace(auth.RefreshToken)) return null;
var clientId = await GetGoogleClientId(db);
var clientSecret = await GetGoogleClientSecret(db);
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) return null;
var http = httpClientFactory.CreateClient();
var response = await http.PostAsync(
"https://oauth2.googleapis.com/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = clientId,
["client_secret"] = clientSecret,
["refresh_token"] = auth.RefreshToken,
["grant_type"] = "refresh_token"
}));
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) return null;
using var json = JsonDocument.Parse(body);
var accessToken = GetString(json.RootElement, "access_token");
if (string.IsNullOrWhiteSpace(accessToken)) return null;
var expiresIn = json.RootElement.TryGetProperty("expires_in", out var expiresElement)
? expiresElement.GetInt32()
: 3600;
auth.AccessToken = accessToken;
auth.ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60));
auth.UpdatedUtc = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return auth.AccessToken;
}
static void ApplyProductLookup(Product target, Product lookup, string source)
{
target.Name = lookup.Name;
@@ -747,6 +1040,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
await EnsureFoodLearningSchemaAsync(db);
await EnsureWorkspaceSchemaAsync(db);
await EnsureAppSettingsSchemaAsync(db);
await EnsureGoogleCalendarSchemaAsync(db);
return;
}
catch (Exception ex) when (attempt < maxAttempts)
@@ -765,6 +1059,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
await EnsureFoodLearningSchemaAsync(db);
await EnsureWorkspaceSchemaAsync(db);
await EnsureAppSettingsSchemaAsync(db);
await EnsureGoogleCalendarSchemaAsync(db);
}
static async Task EnsureProductSchemaAsync(OtthonDbContext db)
@@ -815,6 +1110,33 @@ END
await db.Database.ExecuteSqlRawAsync(sql);
}
static async Task EnsureGoogleCalendarSchemaAsync(OtthonDbContext db)
{
var sql = """
IF OBJECT_ID('GoogleCalendarAuths', 'U') IS NULL
BEGIN
CREATE TABLE GoogleCalendarAuths (
WorkspaceToken nvarchar(160) NOT NULL CONSTRAINT PK_GoogleCalendarAuths PRIMARY KEY,
Email nvarchar(320) NULL,
AccessToken nvarchar(max) NOT NULL,
RefreshToken nvarchar(max) NOT NULL,
ExpiresUtc datetimeoffset NOT NULL,
UpdatedUtc datetimeoffset NOT NULL
);
END
IF OBJECT_ID('GoogleOAuthStates', 'U') IS NULL
BEGIN
CREATE TABLE GoogleOAuthStates (
[State] nvarchar(160) NOT NULL CONSTRAINT PK_GoogleOAuthStates PRIMARY KEY,
WorkspaceToken nvarchar(160) NOT NULL,
CreatedUtc datetimeoffset NOT NULL,
ExpiresUtc datetimeoffset NOT NULL
);
END
""";
await db.Database.ExecuteSqlRawAsync(sql);
}
static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db)
{
var sql = """
@@ -1047,9 +1369,27 @@ static string SettingsPage(string googleClientId) => $$"""
</html>
""";
static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? "");
static string GoogleOAuthResultPage(bool success, string message) => $$"""
<!doctype html>
<html lang="hu">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Google Calendar connection</title>
<style>{{AdminCss()}}</style>
</head>
<body>
<main class="card narrow">
<p class="eyebrow">JARVIS API</p>
<h1>{{(success ? "Google Calendar connected" : "Google Calendar error")}}</h1>
<p class="{{(success ? "hint" : "error")}}">{{message}}</p>
<p>You can close this browser window and return to Közös Tér.</p>
</main>
</body>
</html>
""";
static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId";
static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? "");
static string AdminCss() => """
:root { color-scheme: light; font-family: Inter, Segoe UI, Arial, sans-serif; }
@@ -1091,11 +1431,19 @@ public sealed class OtthonDbContext(DbContextOptions<OtthonDbContext> options)
public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>();
public DbSet<WorkspaceState> Workspaces => Set<WorkspaceState>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<GoogleCalendarAuth> GoogleCalendarAuths => Set<GoogleCalendarAuth>();
public DbSet<GoogleOAuthState> GoogleOAuthStates => Set<GoogleOAuthState>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<AppSetting>().HasKey(setting => setting.Key);
modelBuilder.Entity<AppSetting>().Property(setting => setting.Key).HasMaxLength(160);
modelBuilder.Entity<GoogleCalendarAuth>().HasKey(auth => auth.WorkspaceToken);
modelBuilder.Entity<GoogleCalendarAuth>().Property(auth => auth.WorkspaceToken).HasMaxLength(160);
modelBuilder.Entity<GoogleCalendarAuth>().Property(auth => auth.Email).HasMaxLength(320);
modelBuilder.Entity<GoogleOAuthState>().HasKey(state => state.State);
modelBuilder.Entity<GoogleOAuthState>().Property(state => state.State).HasMaxLength(160);
modelBuilder.Entity<GoogleOAuthState>().Property(state => state.WorkspaceToken).HasMaxLength(160);
modelBuilder.Entity<WorkspaceState>().HasKey(workspace => workspace.Token);
modelBuilder.Entity<WorkspaceState>().Property(workspace => workspace.Token).HasMaxLength(160);
modelBuilder.Entity<Product>().HasKey(product => product.Barcode);
@@ -1177,6 +1525,24 @@ public sealed class AppSetting
public DateTimeOffset UpdatedUtc { get; set; }
}
public sealed class GoogleCalendarAuth
{
public required string WorkspaceToken { get; set; }
public string? Email { get; set; }
public required string AccessToken { get; set; }
public required string RefreshToken { get; set; }
public DateTimeOffset ExpiresUtc { get; set; }
public DateTimeOffset UpdatedUtc { get; set; }
}
public sealed class GoogleOAuthState
{
public required string State { get; set; }
public required string WorkspaceToken { get; set; }
public DateTimeOffset CreatedUtc { get; set; }
public DateTimeOffset ExpiresUtc { get; set; }
}
public sealed record LoginForm(string Password);
public sealed record GoogleCalendarSettingsForm(string? ServerClientId);
public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform);
@@ -1252,6 +1618,13 @@ public sealed record AppConfigResponse(
string? GoogleCalendarServerClientId,
bool GoogleCalendarConfigured);
public sealed record GoogleOAuthStartResponse(string AuthorizationUrl);
public sealed record GoogleOAuthStatusResponse(
bool Connected,
string? Email,
DateTimeOffset? UpdatedUtc);
public sealed record ProductResponse(
bool Found,
string Barcode,