@mriekki/project-forge 4.26.0 → 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.
- package/assets/authentication/distribution.json +90 -0
- package/assets/components.json +18 -4
- package/assets/core/distribution.json +12 -0
- package/assets/deployment/distribution.json +292 -68
- package/assets/generated/ASSEMBLY.json +152 -6
- package/assets/generated/authentication/docs/AUTH_RECOVERY_RETENTION.md +101 -0
- package/assets/generated/authentication/docs/HOUSEHOLD_INFRASTRUCTURE_ADOPTION.md +55 -0
- package/assets/generated/authentication/docs/HOUSEHOLD_REGISTRY.md +246 -0
- package/assets/generated/authentication/docs/PROJECT_AUTH_REFERENCE.md +878 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/Program.cs +40 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/ProjectForgeAuth.Api.csproj +13 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/auth.js +65 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/index.html +44 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/AdmissionPolicy.cs +167 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/AuthenticationDiagnostics.cs +159 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/ProjectForge.Auth.csproj +9 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/PwaExistingProjectCompatibility.cs +64 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectAuthStore.cs +471 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForge.Auth.Runtime.csproj +15 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForgeAuthRuntimeExtensions.cs +472 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ResendProjectInvitationDelivery.cs +105 -0
- package/assets/generated/authentication/src/ProjectForge.Transport/ProjectForge.Transport.csproj +11 -0
- package/assets/generated/authentication/src/ProjectForge.Transport/StrictPublicOriginTransport.cs +95 -0
- package/assets/generated/authentication/tools/household_connect.py +145 -0
- package/assets/generated/authentication/tools/project_auth_recovery.py +716 -0
- package/assets/generated/core/core/process/ENGINEERING_BACKLOG.md +150 -0
- package/assets/generated/core/core/skills/manage-backlog/SKILL.md +115 -0
- package/assets/generated/core/core/skills/review-user-feedback/SKILL.md +50 -0
- package/assets/generated/core/tools/setup_maintenance/maintenance.py +162 -6
- package/assets/generated/deployment/docs/RECOVERY_READINESS.md +103 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_capture.py +164 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_guard.py +225 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_lifecycle.py +455 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_retention.py +220 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_schedule.py +489 -0
- package/package.json +1 -1
- package/src/cli.mjs +32 -3
- 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
|
+
}
|
package/assets/generated/authentication/src/ProjectForge.Transport/ProjectForge.Transport.csproj
ADDED
|
@@ -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>
|