Add Google Calendar OAuth broker
This commit is contained in:
+375
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user