@mriekki/project-forge 4.26.1 → 4.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/assets/authentication/distribution.json +90 -0
  2. package/assets/components.json +17 -3
  3. package/assets/core/distribution.json +12 -0
  4. package/assets/deployment/distribution.json +292 -68
  5. package/assets/generated/ASSEMBLY.json +150 -4
  6. package/assets/generated/authentication/docs/AUTH_RECOVERY_RETENTION.md +101 -0
  7. package/assets/generated/authentication/docs/HOUSEHOLD_INFRASTRUCTURE_ADOPTION.md +55 -0
  8. package/assets/generated/authentication/docs/HOUSEHOLD_REGISTRY.md +246 -0
  9. package/assets/generated/authentication/docs/PROJECT_AUTH_REFERENCE.md +878 -0
  10. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/Program.cs +40 -0
  11. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/ProjectForgeAuth.Api.csproj +13 -0
  12. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/auth.js +65 -0
  13. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/index.html +44 -0
  14. package/assets/generated/authentication/src/ProjectForge.Auth/AdmissionPolicy.cs +167 -0
  15. package/assets/generated/authentication/src/ProjectForge.Auth/AuthenticationDiagnostics.cs +159 -0
  16. package/assets/generated/authentication/src/ProjectForge.Auth/ProjectForge.Auth.csproj +9 -0
  17. package/assets/generated/authentication/src/ProjectForge.Auth/PwaExistingProjectCompatibility.cs +64 -0
  18. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectAuthStore.cs +471 -0
  19. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForge.Auth.Runtime.csproj +15 -0
  20. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForgeAuthRuntimeExtensions.cs +472 -0
  21. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ResendProjectInvitationDelivery.cs +105 -0
  22. package/assets/generated/authentication/src/ProjectForge.Transport/ProjectForge.Transport.csproj +11 -0
  23. package/assets/generated/authentication/src/ProjectForge.Transport/StrictPublicOriginTransport.cs +95 -0
  24. package/assets/generated/authentication/tools/household_connect.py +145 -0
  25. package/assets/generated/authentication/tools/project_auth_recovery.py +716 -0
  26. package/assets/generated/core/core/process/ENGINEERING_BACKLOG.md +150 -0
  27. package/assets/generated/core/core/skills/manage-backlog/SKILL.md +115 -0
  28. package/assets/generated/core/core/skills/review-user-feedback/SKILL.md +50 -0
  29. package/assets/generated/deployment/docs/RECOVERY_READINESS.md +103 -0
  30. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_capture.py +164 -0
  31. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_guard.py +225 -0
  32. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_lifecycle.py +455 -0
  33. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_retention.py +220 -0
  34. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_schedule.py +489 -0
  35. package/package.json +1 -1
  36. package/src/cli.mjs +32 -3
  37. package/src/engine.mjs +87 -5
@@ -0,0 +1,472 @@
1
+ using System;
2
+ using System.Net;
3
+ using System.Security.Claims;
4
+ using System.Threading.RateLimiting;
5
+ using Microsoft.AspNetCore.Antiforgery;
6
+ using Microsoft.AspNetCore.DataProtection;
7
+ using Microsoft.AspNetCore.Authentication;
8
+ using Microsoft.AspNetCore.Authentication.Cookies;
9
+ using Microsoft.AspNetCore.Authentication.OpenIdConnect;
10
+ using Microsoft.AspNetCore.Builder;
11
+ using Microsoft.AspNetCore.Http;
12
+ using Microsoft.AspNetCore.RateLimiting;
13
+ using Microsoft.AspNetCore.Routing;
14
+ using Microsoft.Extensions.Configuration;
15
+ using Microsoft.Extensions.DependencyInjection;
16
+ using Microsoft.Extensions.Options;
17
+ using Microsoft.IdentityModel.Tokens;
18
+ using ProjectForge.Transport;
19
+
20
+ namespace ProjectForge.Auth.Runtime;
21
+
22
+ /// <summary>
23
+ /// A separate per-Project account/session implementation. Does NOT query the
24
+ /// Household Registry in interactive sign-in; Registry membership is not an
25
+ /// automatic Project account grant.
26
+ /// </summary>
27
+ public static class ProjectForgeAuthRuntimeExtensions
28
+ {
29
+ public const string GoogleScheme = "PF-Google";
30
+ public const string ExternalScheme = "PF-External";
31
+ public const string CookieName = "__Host-pf-project-session";
32
+ private const string InviteStateKey = "pf-invite-token";
33
+ public const string CallbackPath = "/signin-pf-google";
34
+
35
+ // A Docker secret may be mounted as a file rather than placed in an
36
+ // environment variable (which would be visible in container inspection).
37
+ // Never silently choose between two credentials for the same setting.
38
+ private static string? ReadProtectedSecret(IConfiguration configuration, string key)
39
+ {
40
+ var inline = configuration[key];
41
+ var secretPath = configuration[key + "File"];
42
+ if (!string.IsNullOrWhiteSpace(inline) && !string.IsNullOrWhiteSpace(secretPath))
43
+ throw new InvalidOperationException(
44
+ "A provider credential cannot be configured both inline and through a secret file.");
45
+ if (string.IsNullOrWhiteSpace(secretPath))
46
+ return inline;
47
+ if (!Path.IsPathFullyQualified(secretPath))
48
+ throw new InvalidOperationException(
49
+ "Provider secret file must have an absolute protected path.");
50
+ var file = new FileInfo(secretPath);
51
+ if (!file.Exists || file.LinkTarget is not null || file.Length < 12 || file.Length > 8192)
52
+ throw new InvalidOperationException(
53
+ "Provider secret file is missing, linked, or outside supported size bounds.");
54
+ var value = File.ReadAllText(file.FullName).TrimEnd('\r', '\n');
55
+ if (value.Length < 12 || value.Length > 8192 ||
56
+ value.Contains('\r') || value.Contains('\n'))
57
+ throw new InvalidOperationException(
58
+ "Provider secret file must contain exactly one non-empty credential.");
59
+ return value;
60
+ }
61
+
62
+ public static IServiceCollection AddProjectForgeProjectAuth(
63
+ this IServiceCollection services,
64
+ IConfiguration configuration)
65
+ {
66
+ var path = configuration["ProjectForge:Authentication:DbPath"];
67
+ var clientId = configuration["ProjectForge:Authentication:Google:ClientId"];
68
+ var clientSecret = ReadProtectedSecret(configuration, "ProjectForge:Authentication:Google:ClientSecret");
69
+ var originText = configuration["ProjectForge:Authentication:PublicOrigin"];
70
+ var proxyText = configuration["ProjectForge:Authentication:TrustedProxyIp"];
71
+ if (string.IsNullOrWhiteSpace(path) ||
72
+ string.IsNullOrWhiteSpace(clientId) ||
73
+ string.IsNullOrWhiteSpace(clientSecret) ||
74
+ !Uri.TryCreate(originText, UriKind.Absolute, out var origin) ||
75
+ origin.Scheme != Uri.UriSchemeHttps ||
76
+ origin.AbsolutePath != "/" ||
77
+ !string.IsNullOrEmpty(origin.Query) ||
78
+ !string.IsNullOrEmpty(origin.Fragment) ||
79
+ !string.IsNullOrEmpty(origin.UserInfo) ||
80
+ !IPAddress.TryParse(proxyText, out var peer) ||
81
+ IPAddress.IsLoopback(peer) || peer.Equals(IPAddress.Any) ||
82
+ peer.Equals(IPAddress.IPv6Any))
83
+ throw new InvalidOperationException(
84
+ "Exact Project-owned SQLite path, OIDC credentials and reviewed public origin/proxy are required.");
85
+ var store = new ProjectAuthStore(path);
86
+ store.Initialize();
87
+ services.AddSingleton(store);
88
+
89
+ var keysPath = configuration["ProjectForge:Authentication:DataProtectionKeysPath"];
90
+ if (string.IsNullOrWhiteSpace(keysPath) || !Path.IsPathFullyQualified(keysPath))
91
+ throw new InvalidOperationException(
92
+ "A persistent private per-Project OIDC key directory is required.");
93
+ // A configured key directory must never silently redirect the OIDC
94
+ // correlation/state key ring through a symlink. In particular,
95
+ // chmod on a symlink target can change an unrelated directory.
96
+ var keysDirectory = new DirectoryInfo(keysPath);
97
+ if (keysDirectory.LinkTarget is not null)
98
+ throw new InvalidOperationException(
99
+ "The per-Project OIDC key directory cannot be a symbolic link.");
100
+ Directory.CreateDirectory(keysPath);
101
+ keysDirectory.Refresh();
102
+ if (keysDirectory.LinkTarget is not null)
103
+ throw new InvalidOperationException(
104
+ "The per-Project OIDC key directory cannot be a symbolic link.");
105
+ if (!OperatingSystem.IsWindows())
106
+ File.SetUnixFileMode(keysPath,
107
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
108
+ services.AddDataProtection()
109
+ .PersistKeysToFileSystem(new DirectoryInfo(keysPath))
110
+ .SetApplicationName("ProjectForge.Auth." +
111
+ Convert.ToHexString(
112
+ System.Security.Cryptography.SHA256.HashData(
113
+ System.Text.Encoding.UTF8.GetBytes(
114
+ origin.GetLeftPart(UriPartial.Authority)))));
115
+
116
+ // Resend is explicitly Project-scoped. Household infrastructure may
117
+ // provide a verified sending domain, but never a shared API secret.
118
+ var resendKey = ReadProtectedSecret(configuration, "ProjectForge:Authentication:Resend:ApiKey");
119
+ var resendFrom = configuration["ProjectForge:Authentication:Resend:From"];
120
+ if (string.IsNullOrWhiteSpace(resendKey) != string.IsNullOrWhiteSpace(resendFrom))
121
+ throw new InvalidOperationException(
122
+ "Per-Project Resend credential and verified sender address must be configured together.");
123
+ if (!string.IsNullOrWhiteSpace(resendKey))
124
+ {
125
+ services.AddSingleton<IProjectInvitationDelivery>(
126
+ new ResendProjectInvitationDelivery(
127
+ new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }),
128
+ resendKey, resendFrom!, origin.GetLeftPart(UriPartial.Authority)));
129
+ }
130
+
131
+ services.AddAntiforgery(options =>
132
+ {
133
+ options.HeaderName = "X-PF-CSRF";
134
+ options.Cookie.Name = "__Host-pf-project-csrf";
135
+ options.Cookie.Path = "/";
136
+ options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
137
+ options.Cookie.SameSite = SameSiteMode.Strict;
138
+ options.Cookie.HttpOnly = true;
139
+ });
140
+ services.AddRateLimiter(options =>
141
+ {
142
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
143
+ options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
144
+ http => RateLimitPartition.GetFixedWindowLimiter(
145
+ http.Connection.RemoteIpAddress?.ToString() ?? "unknown",
146
+ _ => new FixedWindowRateLimiterOptions
147
+ {
148
+ PermitLimit = 60,
149
+ Window = TimeSpan.FromMinutes(1),
150
+ AutoReplenishment = true,
151
+ QueueLimit = 0
152
+ }));
153
+ });
154
+
155
+ // Do not replace a consuming Project's default authentication scheme.
156
+ // The short-lived external cookie scheme is never used for persistent
157
+ // Project authorization; OIDC ticket handling creates an opaque
158
+ // server-authoritative session cookie and calls HandleResponse().
159
+ services.AddAuthentication()
160
+ .AddCookie(ExternalScheme, options =>
161
+ {
162
+ options.Cookie.Name = "__Host-pf-external";
163
+ options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
164
+ options.Cookie.HttpOnly = true;
165
+ options.Cookie.SameSite = SameSiteMode.Lax;
166
+ options.ExpireTimeSpan = TimeSpan.FromMinutes(10);
167
+ options.SlidingExpiration = false;
168
+ })
169
+ .AddOpenIdConnect(GoogleScheme, options =>
170
+ {
171
+ options.SignInScheme = ExternalScheme;
172
+ options.Authority = "https://accounts.google.com";
173
+ options.ClientId = clientId;
174
+ options.ClientSecret = clientSecret;
175
+ options.ResponseType = "code";
176
+ options.UsePkce = true;
177
+ options.SaveTokens = false;
178
+ options.MapInboundClaims = false;
179
+ options.GetClaimsFromUserInfoEndpoint = false;
180
+ options.CallbackPath = CallbackPath;
181
+ options.Scope.Clear();
182
+ options.Scope.Add("openid");
183
+ options.Scope.Add("email");
184
+ options.TokenValidationParameters = new TokenValidationParameters
185
+ {
186
+ ValidateIssuer = true,
187
+ ValidIssuer = "https://accounts.google.com",
188
+ ValidateAudience = true,
189
+ ValidAudience = clientId,
190
+ ValidateIssuerSigningKey = true,
191
+ NameClaimType = "email"
192
+ };
193
+ options.Events = new OpenIdConnectEvents
194
+ {
195
+ OnMessageReceived = context =>
196
+ {
197
+ if (!TrustedOrigin(context.HttpContext, origin))
198
+ context.Fail("Public origin is not trusted.");
199
+ return Task.CompletedTask;
200
+ },
201
+ OnTokenValidated = context =>
202
+ {
203
+ var issuer = context.SecurityToken?.Issuer;
204
+ var subject = ExactlyOne(context.Principal, "sub");
205
+ var email = ExactlyOne(context.Principal, "email");
206
+ var verified = ExactlyOne(context.Principal, "email_verified");
207
+ if (issuer != "https://accounts.google.com" ||
208
+ string.IsNullOrWhiteSpace(subject) ||
209
+ subject.Length > 512 ||
210
+ string.IsNullOrWhiteSpace(email) ||
211
+ !string.Equals(verified, "true", StringComparison.OrdinalIgnoreCase) ||
212
+ context.Principal?.Identity is not ClaimsIdentity identity)
213
+ {
214
+ context.Fail("Verified Google claims required.");
215
+ return Task.CompletedTask;
216
+ }
217
+ identity.AddClaim(new Claim("pf_verified_google_issuer", issuer));
218
+ return Task.CompletedTask;
219
+ },
220
+ OnTicketReceived = context =>
221
+ {
222
+ var store = context.HttpContext.RequestServices
223
+ .GetRequiredService<ProjectAuthStore>();
224
+ var claims = context.Principal;
225
+ var issuer = ExactlyOne(claims, "pf_verified_google_issuer");
226
+ var subject = ExactlyOne(claims, "sub");
227
+ var email = ExactlyOne(claims, "email");
228
+ var verified = ExactlyOne(claims, "email_verified");
229
+ if (!TrustedOrigin(context.HttpContext, origin) ||
230
+ issuer != "https://accounts.google.com" ||
231
+ string.IsNullOrWhiteSpace(subject) ||
232
+ string.IsNullOrWhiteSpace(email) ||
233
+ !string.Equals(verified, "true", StringComparison.OrdinalIgnoreCase))
234
+ {
235
+ context.HandleResponse();
236
+ context.Response.Redirect("/?auth=unavailable");
237
+ return Task.CompletedTask;
238
+ }
239
+ var google = new VerifiedGoogleIdentity(
240
+ issuer, subject, email, EmailVerified: true);
241
+ var time = DateTimeOffset.UtcNow;
242
+ string? invitation = null;
243
+ context.Properties?.Items.TryGetValue(InviteStateKey, out invitation);
244
+ var result = string.IsNullOrWhiteSpace(invitation)
245
+ ? store.SignIn(google, time)
246
+ : store.RedeemInvitation(google, invitation, time);
247
+
248
+ // Same generic response for no account, bad invitation,
249
+ // disabled account and other unapproved principals.
250
+ if (!result.Succeeded || result.Session is null)
251
+ {
252
+ context.HandleResponse();
253
+ context.Response.Redirect("/?auth=unavailable");
254
+ return Task.CompletedTask;
255
+ }
256
+ context.Response.Cookies.Append(
257
+ CookieName, result.Session.Token,
258
+ new CookieOptions
259
+ {
260
+ HttpOnly = true, Secure = true,
261
+ SameSite = SameSiteMode.Lax,
262
+ Path = "/", Expires = result.Session.ExpiresAt
263
+ });
264
+ context.HandleResponse();
265
+ context.Response.Redirect("/");
266
+ return Task.CompletedTask;
267
+ },
268
+ OnRemoteFailure = context =>
269
+ {
270
+ context.HandleResponse();
271
+ context.Response.Redirect("/?auth=unavailable");
272
+ return Task.CompletedTask;
273
+ },
274
+ OnRedirectToIdentityProvider = context =>
275
+ {
276
+ if (!TrustedOrigin(context.HttpContext, origin))
277
+ {
278
+ context.HandleResponse();
279
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
280
+ }
281
+ return Task.CompletedTask;
282
+ }
283
+ };
284
+ });
285
+
286
+ services.AddAuthorization();
287
+ return services;
288
+ }
289
+
290
+ public static IApplicationBuilder UseProjectForgeAuthPublicOrigin(
291
+ this IApplicationBuilder app, IConfiguration configuration)
292
+ {
293
+ var expectedText = configuration["ProjectForge:Authentication:PublicOrigin"];
294
+ if (!Uri.TryCreate(expectedText, UriKind.Absolute, out var expected) ||
295
+ expected.Scheme != Uri.UriSchemeHttps)
296
+ throw new InvalidOperationException("Exact configured HTTPS public origin required.");
297
+ var reviewedPeerText = configuration["ProjectForge:Authentication:TrustedProxyIp"];
298
+ if (!IPAddress.TryParse(reviewedPeerText, out var reviewedPeer) ||
299
+ IPAddress.IsLoopback(reviewedPeer) ||
300
+ reviewedPeer.Equals(IPAddress.Any) || reviewedPeer.Equals(IPAddress.IPv6Any))
301
+ throw new InvalidOperationException("An exact reviewed transport peer is required.");
302
+ // Validate raw Caddy transport and forwarded headers before any
303
+ // auth processing. Never pre-run generic ForwardedHeaders here.
304
+ app.Use(async (context, next) =>
305
+ {
306
+ if (!StrictPublicOriginTransport.TryApply(context, reviewedPeer, expected))
307
+ {
308
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
309
+ return;
310
+ }
311
+ context.Response.Headers.CacheControl = "no-store";
312
+ context.Response.Headers["X-Content-Type-Options"] = "nosniff";
313
+ context.Response.Headers["Referrer-Policy"] = "no-referrer";
314
+ context.Response.Headers["Strict-Transport-Security"] = "max-age=31536000";
315
+ await next();
316
+ });
317
+ return app;
318
+ }
319
+
320
+ public static IEndpointRouteBuilder MapProjectForgeAuth(
321
+ this IEndpointRouteBuilder endpoints)
322
+ {
323
+ // Liveness only. The application's strict origin/physical-peer guard
324
+ // runs BEFORE endpoint routing, so even health cannot be reached by
325
+ // a direct/forged host request. Do not use this as restore readiness.
326
+ endpoints.MapGet("/health", (ProjectAuthStore db) =>
327
+ db.IsReadableAndCurrent()
328
+ ? Results.Ok(new { status = "healthy" })
329
+ : Results.StatusCode(StatusCodes.Status503ServiceUnavailable));
330
+
331
+ endpoints.MapGet("/api/auth/google", (HttpContext http) =>
332
+ {
333
+ if (!http.Request.IsHttps) return Results.BadRequest();
334
+ return Results.Challenge(new AuthenticationProperties
335
+ {
336
+ RedirectUri = "/"
337
+ }, [GoogleScheme]);
338
+ });
339
+
340
+ endpoints.MapGet("/api/auth/csrf", (HttpContext http, IAntiforgery antiforgery) =>
341
+ {
342
+ var tokens = antiforgery.GetAndStoreTokens(http);
343
+ return Results.Ok(new { requestToken = tokens.RequestToken });
344
+ });
345
+
346
+ // Form POST is an actual browser navigation; invitation token lives
347
+ // inside DataProtection-protected OIDC state rather than a query URL.
348
+ endpoints.MapPost("/api/auth/google/invite", async Task<IResult> (
349
+ HttpContext http, IAntiforgery antiforgery) =>
350
+ {
351
+ if (!await CsrfOk(http, antiforgery) ||
352
+ !http.Request.HasFormContentType)
353
+ return Results.BadRequest();
354
+ var values = await http.Request.ReadFormAsync();
355
+ var token = values["invitationToken"].ToString();
356
+ if (token.Length != 64 ||
357
+ !System.Text.RegularExpressions.Regex.IsMatch(
358
+ token, "^[0-9a-f]{64}$"))
359
+ return Results.BadRequest();
360
+ var props = new AuthenticationProperties { RedirectUri = "/" };
361
+ props.Items[InviteStateKey] = token;
362
+ return Results.Challenge(props, [GoogleScheme]);
363
+ });
364
+
365
+ endpoints.MapGet("/api/auth/me", (HttpContext http, ProjectAuthStore db) =>
366
+ {
367
+ var account = AuthenticatedAccount(http, db);
368
+ return account is null
369
+ ? Results.Unauthorized()
370
+ : Results.Ok(new { accountId = account.Id, isAdministrator = account.IsAdministrator });
371
+ });
372
+
373
+ endpoints.MapPost("/api/auth/logout", async Task<IResult> (
374
+ HttpContext http, IAntiforgery antiforgery, ProjectAuthStore db) =>
375
+ {
376
+ if (!await CsrfOk(http, antiforgery)) return Results.BadRequest();
377
+ var token = http.Request.Cookies[CookieName];
378
+ if (token is not null) db.RevokeSession(token, DateTimeOffset.UtcNow);
379
+ http.Response.Cookies.Delete(CookieName, new CookieOptions
380
+ {
381
+ Path = "/", Secure = true, HttpOnly = true, SameSite = SameSiteMode.Lax
382
+ });
383
+ return Results.NoContent();
384
+ });
385
+
386
+ endpoints.MapPost("/api/auth/invitations", async Task<IResult> (
387
+ InvitationRequest input, HttpContext http, IAntiforgery antiforgery,
388
+ ProjectAuthStore db) =>
389
+ {
390
+ if (!await CsrfOk(http, antiforgery)) return Results.BadRequest();
391
+ var account = AuthenticatedAccount(http, db);
392
+ if (account is not { IsAdministrator: true }) return Results.StatusCode(StatusCodes.Status403Forbidden);
393
+ try
394
+ {
395
+ var invite = db.CreateInvitation(
396
+ account.Id, input.RecipientEmail, DateTimeOffset.UtcNow);
397
+ var delivery = http.RequestServices.GetService<IProjectInvitationDelivery>();
398
+ if (delivery is not null)
399
+ {
400
+ var delivered = await delivery.SendAsync(
401
+ input.RecipientEmail, invite.Token, invite.ExpiresAt,
402
+ http.RequestAborted);
403
+ // Never send the invitation token back to the browser
404
+ // when configured for secure Project-owned email delivery.
405
+ return delivered ? Results.NoContent() : Results.StatusCode(
406
+ StatusCodes.Status502BadGateway);
407
+ }
408
+ // Manual operator delivery remains available without a
409
+ // Resend credential; the token is shown only once to admin.
410
+ return Results.Ok(new { token = invite.Token, expiresAt = invite.ExpiresAt });
411
+ }
412
+ catch (ArgumentException) { return Results.BadRequest(); }
413
+ catch (InvalidOperationException)
414
+ {
415
+ return Results.StatusCode(StatusCodes.Status429TooManyRequests);
416
+ }
417
+ });
418
+
419
+ endpoints.MapPost("/api/auth/accounts/{accountId}/disable",
420
+ async Task<IResult> (string accountId, HttpContext http,
421
+ IAntiforgery antiforgery, ProjectAuthStore db) =>
422
+ {
423
+ if (!await CsrfOk(http, antiforgery)) return Results.BadRequest();
424
+ var token = http.Request.Cookies[CookieName];
425
+ if (token is null) return Results.Unauthorized();
426
+ try
427
+ {
428
+ return db.DisableAccount(token, accountId, DateTimeOffset.UtcNow)
429
+ ? Results.NoContent() : Results.NotFound();
430
+ }
431
+ // The Project Auth API deliberately has no default ASP.NET
432
+ // authentication scheme: application sessions are validated
433
+ // against SQLite rather than forwarded to cookie auth.
434
+ // Results.Forbid() would throw when no DefaultForbidScheme
435
+ // exists, turning a denied member into an HTTP 500.
436
+ catch (UnauthorizedAccessException)
437
+ {
438
+ return Results.StatusCode(StatusCodes.Status403Forbidden);
439
+ }
440
+ });
441
+ return endpoints;
442
+ }
443
+
444
+ public static bool IsReviewedTransportPeer(IPAddress? observed, IPAddress reviewed) =>
445
+ observed is not null && observed.Equals(reviewed);
446
+
447
+ private static string? ExactlyOne(ClaimsPrincipal? principal, string name)
448
+ {
449
+ var claims = principal?.FindAll(name).Take(2).ToArray();
450
+ return claims?.Length == 1 ? claims[0].Value : null;
451
+ }
452
+ private static bool TrustedOrigin(HttpContext http, Uri origin) =>
453
+ http.Request.IsHttps &&
454
+ string.Equals(http.Request.Host.Host, origin.Host, StringComparison.OrdinalIgnoreCase) &&
455
+ http.Request.Host.Port == (origin.IsDefaultPort ? null : origin.Port);
456
+
457
+ private static ProjectAccount? AuthenticatedAccount(HttpContext http, ProjectAuthStore db)
458
+ {
459
+ var token = http.Request.Cookies[CookieName];
460
+ return token is null ? null : db.GetSession(token, DateTimeOffset.UtcNow);
461
+ }
462
+ private static async Task<bool> CsrfOk(HttpContext http, IAntiforgery antiforgery)
463
+ {
464
+ try
465
+ {
466
+ await antiforgery.ValidateRequestAsync(http);
467
+ return true;
468
+ }
469
+ catch (AntiforgeryValidationException) { return false; }
470
+ }
471
+ public sealed record InvitationRequest(string RecipientEmail);
472
+ }
@@ -0,0 +1,105 @@
1
+ using System;
2
+ using System.Net;
3
+ using System.Net.Http.Headers;
4
+ using System.Net.Mail;
5
+ using System.Security.Cryptography;
6
+ using System.Text;
7
+ using System.Text.Json;
8
+
9
+ namespace ProjectForge.Auth.Runtime;
10
+
11
+ /// <summary>
12
+ /// Optional per-Project Resend invitation delivery. Never uses a household
13
+ /// Registry token or stores the Resend API key in SQLite. Invitation tokens
14
+ /// are sent only in TLS request bodies, never URLs, query params or logs.
15
+ /// </summary>
16
+ public interface IProjectInvitationDelivery
17
+ {
18
+ Task<bool> SendAsync(
19
+ string recipientEmail, string invitationToken,
20
+ DateTimeOffset expiresAt, CancellationToken cancellationToken);
21
+ }
22
+
23
+ public sealed class ResendProjectInvitationDelivery : IProjectInvitationDelivery, IDisposable
24
+ {
25
+ private readonly HttpClient _client;
26
+ private readonly string _apiKey;
27
+ private readonly string _from;
28
+ private readonly string _origin;
29
+
30
+ public ResendProjectInvitationDelivery(
31
+ HttpClient client, string apiKey, string fromEmail, string publicOrigin)
32
+ {
33
+ if (client is null || string.IsNullOrWhiteSpace(apiKey) || apiKey.Length < 12)
34
+ throw new ArgumentException("Project-scoped Resend credential required.");
35
+ _from = ValidateMail(fromEmail);
36
+ if (!Uri.TryCreate(publicOrigin, UriKind.Absolute, out var origin) ||
37
+ origin.Scheme != Uri.UriSchemeHttps ||
38
+ origin.AbsolutePath != "/" ||
39
+ !origin.IsDefaultPort ||
40
+ !string.IsNullOrEmpty(origin.UserInfo) ||
41
+ !string.IsNullOrEmpty(origin.Query) ||
42
+ !string.IsNullOrEmpty(origin.Fragment))
43
+ throw new ArgumentException("An exact HTTPS Project public origin is required.");
44
+ _origin = origin.GetLeftPart(UriPartial.Authority);
45
+ _apiKey = apiKey;
46
+ _client = client;
47
+ }
48
+
49
+ private static string ValidateMail(string value)
50
+ {
51
+ if (string.IsNullOrWhiteSpace(value) || value.Length > 254 ||
52
+ value.Any(char.IsWhiteSpace) || value.Contains('<') || value.Contains('>') ||
53
+ value.Count(c => c == '@') != 1 ||
54
+ !MailAddress.TryCreate(value, out var parsed) ||
55
+ !string.Equals(parsed.Address, value, StringComparison.Ordinal))
56
+ throw new ArgumentException("A single literal email address is required.");
57
+ return value;
58
+ }
59
+
60
+ public async Task<bool> SendAsync(
61
+ string recipientEmail, string invitationToken,
62
+ DateTimeOffset expiresAt, CancellationToken cancellationToken)
63
+ {
64
+ var recipient = ValidateMail(recipientEmail);
65
+ if (invitationToken is null || invitationToken.Length != 64 ||
66
+ invitationToken.Any(c => !Uri.IsHexDigit(c) || char.IsUpper(c)))
67
+ throw new ArgumentException("Invitation token must be the generated 32-byte lowercase value.");
68
+ if (expiresAt <= DateTimeOffset.UtcNow)
69
+ return false;
70
+
71
+ // Resend's Idempotency-Key is deterministic for this specific
72
+ // invitation without revealing the token. Never retry with a
73
+ // different request body using the same key.
74
+ var tokenFingerprint = Convert.ToHexString(
75
+ SHA256.HashData(Encoding.ASCII.GetBytes(invitationToken))).ToLowerInvariant();
76
+ var message = new
77
+ {
78
+ from = _from,
79
+ to = new[] { recipient },
80
+ subject = "Your Project invitation",
81
+ text = $"You've been invited to use a Project Forge application.\n\n" +
82
+ $"Open {_origin} in your browser, then enter this one-time token:\n\n" +
83
+ $"{invitationToken}\n\nExpires: {expiresAt:O}\n\n" +
84
+ "Use the Google account that received this email. " +
85
+ "Household Registry membership alone does not grant Project access."
86
+ };
87
+ using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.resend.com/emails");
88
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
89
+ request.Headers.TryAddWithoutValidation("Idempotency-Key", "project-invite-" + tokenFingerprint);
90
+ request.Content = new StringContent(
91
+ JsonSerializer.Serialize(message), Encoding.UTF8, "application/json");
92
+ try
93
+ {
94
+ using var response = await _client.SendAsync(
95
+ request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
96
+ // Do not read or log provider bodies/headers; 3xx is never a
97
+ // delivery success and must not redirect with our bearer token.
98
+ return response.StatusCode == HttpStatusCode.OK;
99
+ }
100
+ catch (HttpRequestException) { return false; }
101
+ catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { return false; }
102
+ }
103
+
104
+ public void Dispose() => _client.Dispose();
105
+ }
@@ -0,0 +1,11 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+ <PropertyGroup>
3
+ <TargetFramework>net10.0</TargetFramework>
4
+ <Nullable>enable</Nullable>
5
+ <ImplicitUsings>enable</ImplicitUsings>
6
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
7
+ </PropertyGroup>
8
+ <ItemGroup>
9
+ <FrameworkReference Include="Microsoft.AspNetCore.App" />
10
+ </ItemGroup>
11
+ </Project>