@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.
- package/assets/authentication/distribution.json +90 -0
- package/assets/components.json +17 -3
- package/assets/core/distribution.json +12 -0
- package/assets/deployment/distribution.json +292 -68
- package/assets/generated/ASSEMBLY.json +150 -4
- 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/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,40 @@
|
|
|
1
|
+
using ProjectForge.Auth.Runtime;
|
|
2
|
+
|
|
3
|
+
var builder = WebApplication.CreateBuilder(args);
|
|
4
|
+
|
|
5
|
+
// The first administrator must be configured explicitly using the Google
|
|
6
|
+
// issuer/subject recorded through trusted administration. Nothing automatically
|
|
7
|
+
// promotes the first visitor or a member of the shared Household Registry.
|
|
8
|
+
if (args is ["admin-init"])
|
|
9
|
+
{
|
|
10
|
+
var path = builder.Configuration["ProjectForge:Authentication:DbPath"]
|
|
11
|
+
?? throw new InvalidOperationException("App-owned authentication DB path required.");
|
|
12
|
+
var subject = Environment.GetEnvironmentVariable("PF_INITIAL_APP_ADMIN_SUBJECT");
|
|
13
|
+
var email = Environment.GetEnvironmentVariable("PF_INITIAL_APP_ADMIN_EMAIL");
|
|
14
|
+
if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(email))
|
|
15
|
+
throw new InvalidOperationException("Preapproved administrator Google subject and email required.");
|
|
16
|
+
var store = new ProjectAuthStore(path);
|
|
17
|
+
store.Initialize();
|
|
18
|
+
store.BootstrapAdministrator(
|
|
19
|
+
"https://accounts.google.com", subject, email, DateTimeOffset.UtcNow);
|
|
20
|
+
Console.WriteLine("Explicit per-Project administrator initialized; no public enrollment opened.");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (args.Length != 0)
|
|
24
|
+
throw new ArgumentException("Unknown command; supported host action: admin-init.");
|
|
25
|
+
|
|
26
|
+
builder.Services.AddProjectForgeProjectAuth(builder.Configuration);
|
|
27
|
+
var app = builder.Build();
|
|
28
|
+
|
|
29
|
+
// Before any static files, OIDC middleware or endpoints: verify the physical
|
|
30
|
+
// Caddy peer, exact host, HTTPS protocol and canonical forwarding evidence.
|
|
31
|
+
app.UseProjectForgeAuthPublicOrigin(app.Configuration);
|
|
32
|
+
app.UseRateLimiter();
|
|
33
|
+
app.UseAuthentication();
|
|
34
|
+
app.UseAuthorization();
|
|
35
|
+
app.UseDefaultFiles();
|
|
36
|
+
app.UseStaticFiles();
|
|
37
|
+
app.MapProjectForgeAuth();
|
|
38
|
+
app.Run();
|
|
39
|
+
|
|
40
|
+
public partial class Program { }
|
package/assets/generated/authentication/samples/ProjectForgeAuth.Api/ProjectForgeAuth.Api.csproj
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
2
|
+
<PropertyGroup>
|
|
3
|
+
<TargetFramework>net10.0</TargetFramework>
|
|
4
|
+
<Nullable>enable</Nullable>
|
|
5
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
6
|
+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
7
|
+
</PropertyGroup>
|
|
8
|
+
<ItemGroup>
|
|
9
|
+
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.14" />
|
|
10
|
+
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.14" />
|
|
11
|
+
<ProjectReference Include="../../src/ProjectForge.Auth.Runtime/ProjectForge.Auth.Runtime.csproj" />
|
|
12
|
+
</ItemGroup>
|
|
13
|
+
</Project>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Example Project login UI. API is the identity/authorization authority.
|
|
2
|
+
const el = id => document.getElementById(id);
|
|
3
|
+
let csrf = null;
|
|
4
|
+
async function get(path) {
|
|
5
|
+
const response = await fetch(path, {credentials:"same-origin",cache:"no-store"});
|
|
6
|
+
if (response.status === 401) return null;
|
|
7
|
+
if (!response.ok) throw new Error("Authentication service unavailable");
|
|
8
|
+
return response.json();
|
|
9
|
+
}
|
|
10
|
+
async function initialize() {
|
|
11
|
+
const [person, proof] = await Promise.all([get("/api/auth/me"), get("/api/auth/csrf")]);
|
|
12
|
+
csrf = proof?.requestToken ?? null;
|
|
13
|
+
el("anti").value = csrf ?? "";
|
|
14
|
+
if (!person) {
|
|
15
|
+
el("status").textContent = "Sign in or accept an administrator-issued Project invitation.";
|
|
16
|
+
el("login").hidden = false;
|
|
17
|
+
el("join").hidden = false;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
el("status").textContent = "Signed in to this Project.";
|
|
21
|
+
el("approved").hidden = false;
|
|
22
|
+
el("admin").hidden = person.isAdministrator !== true;
|
|
23
|
+
}
|
|
24
|
+
async function post(path, body) {
|
|
25
|
+
if (!csrf) throw new Error("CSRF protection unavailable");
|
|
26
|
+
return fetch(path, {
|
|
27
|
+
method:"POST",credentials:"same-origin",cache:"no-store",
|
|
28
|
+
headers:{"Content-Type":"application/json","X-PF-CSRF":csrf},
|
|
29
|
+
body:JSON.stringify(body)
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
el("logout").addEventListener("click", async () => {
|
|
33
|
+
try {
|
|
34
|
+
const response = await post("/api/auth/logout", {});
|
|
35
|
+
if (response.ok) location.reload();
|
|
36
|
+
else el("status").textContent = "Sign-out was not completed.";
|
|
37
|
+
} catch {el("status").textContent = "Unable to sign out."; }
|
|
38
|
+
});
|
|
39
|
+
el("create-invite").addEventListener("submit", async event => {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
const field = event.currentTarget.elements.namedItem("recipientEmail");
|
|
42
|
+
try {
|
|
43
|
+
const response = await post("/api/auth/invitations",
|
|
44
|
+
{recipientEmail:field.value});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
el("invite-result").textContent = "Invitation not created.";
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (response.status === 204) {
|
|
50
|
+
el("invite-result").textContent =
|
|
51
|
+
"Invitation submitted to the Project's configured email provider.";
|
|
52
|
+
field.value = "";
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const ticket = await response.json();
|
|
56
|
+
el("invite-result").textContent =
|
|
57
|
+
"Share this one-time token privately (never in a URL): " + ticket.token;
|
|
58
|
+
field.value = "";
|
|
59
|
+
} catch {el("invite-result").textContent = "Invitation not created."; }
|
|
60
|
+
});
|
|
61
|
+
initialize().catch(() => {
|
|
62
|
+
el("status").textContent = "Unable to verify Project authentication status.";
|
|
63
|
+
el("login").hidden = true;
|
|
64
|
+
el("join").hidden = true;
|
|
65
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><head>
|
|
3
|
+
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
4
|
+
<meta name="referrer" content="no-referrer"/>
|
|
5
|
+
<title>Project Forge | Project Authentication</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;color-scheme:light dark}
|
|
8
|
+
body{max-width:690px;margin:40px auto;padding:0 20px;line-height:1.5}
|
|
9
|
+
main{padding:26px;border:1px solid #9aabb878;border-radius:16px}
|
|
10
|
+
h1{font-size:1.75rem;margin:.1em 0}
|
|
11
|
+
button,a.action{font:inherit;padding:10px 18px;background:#17688e;color:white;border:0;border-radius:8px;cursor:pointer;text-decoration:none}
|
|
12
|
+
form{display:flex;flex-wrap:wrap;gap:12px;align-items:end;margin-top:18px}
|
|
13
|
+
label{display:flex;flex-direction:column;flex:1;gap:5px}
|
|
14
|
+
input{font:inherit;padding:8px;border:1px solid #b1bcc7;border-radius:8px}
|
|
15
|
+
section{margin-top:24px}
|
|
16
|
+
small{color:#667d8c}
|
|
17
|
+
</style></head>
|
|
18
|
+
<body><main>
|
|
19
|
+
<small>PROJECT FORGE</small>
|
|
20
|
+
<h1>Project authentication</h1>
|
|
21
|
+
<p>Sign in using this Project's own Google identity and invitation approval. Household Registry membership does not automatically grant access.</p>
|
|
22
|
+
<p id="status" role="status" aria-live="polite">Checking account status…</p>
|
|
23
|
+
<div id="login" hidden><a class="action" href="/api/auth/google">Sign in with Google</a></div>
|
|
24
|
+
<section id="join" hidden>
|
|
25
|
+
<h2>Accept your Project invitation</h2>
|
|
26
|
+
<form method="post" action="/api/auth/google/invite" id="invite-form">
|
|
27
|
+
<label>One-time invitation token
|
|
28
|
+
<input name="invitationToken" type="password" autocomplete="off" maxlength="64" required/></label>
|
|
29
|
+
<input id="anti" name="__RequestVerificationToken" type="hidden"/>
|
|
30
|
+
<button type="submit">Sign in and accept</button>
|
|
31
|
+
</form></section>
|
|
32
|
+
<section id="approved" hidden>
|
|
33
|
+
<p>You're approved for this Project. Your account identity belongs to this Project, not the Household Registry.</p>
|
|
34
|
+
<button id="logout" type="button">Sign out</button>
|
|
35
|
+
</section>
|
|
36
|
+
<section id="admin" hidden>
|
|
37
|
+
<h2>Invite another Project user</h2>
|
|
38
|
+
<form id="create-invite">
|
|
39
|
+
<label>Google email
|
|
40
|
+
<input name="recipientEmail" type="email" maxlength="254" autocomplete="off" required/></label>
|
|
41
|
+
<button>Create invitation</button></form>
|
|
42
|
+
<p id="invite-result" aria-live="polite"></p>
|
|
43
|
+
</section>
|
|
44
|
+
</main><script src="/auth.js" defer></script></body></html>
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Generic;
|
|
3
|
+
|
|
4
|
+
namespace ProjectForge.Auth;
|
|
5
|
+
|
|
6
|
+
/// <summary>
|
|
7
|
+
/// Immutable OIDC identity key. Provider token validation is the responsibility
|
|
8
|
+
/// of a trusted adapter: never construct one from unsigned browser parameters.
|
|
9
|
+
/// Comparison is deliberately ordinal; provider subjects are not email aliases.
|
|
10
|
+
/// </summary>
|
|
11
|
+
public sealed record ExternalIdentityKey
|
|
12
|
+
{
|
|
13
|
+
public string Issuer { get; }
|
|
14
|
+
public string Subject { get; }
|
|
15
|
+
|
|
16
|
+
public ExternalIdentityKey(string issuer, string subject)
|
|
17
|
+
{
|
|
18
|
+
if (string.IsNullOrWhiteSpace(issuer) ||
|
|
19
|
+
!Uri.TryCreate(issuer, UriKind.Absolute, out var address) ||
|
|
20
|
+
address.Scheme != Uri.UriSchemeHttps ||
|
|
21
|
+
!string.IsNullOrEmpty(address.UserInfo) ||
|
|
22
|
+
!string.IsNullOrEmpty(address.Query) ||
|
|
23
|
+
!string.IsNullOrEmpty(address.Fragment))
|
|
24
|
+
{
|
|
25
|
+
throw new ArgumentException("An absolute HTTPS issuer is required.", nameof(issuer));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (string.IsNullOrWhiteSpace(subject) || subject != subject.Trim())
|
|
29
|
+
{
|
|
30
|
+
throw new ArgumentException("A stable, nonblank subject is required.", nameof(subject));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Issuer = issuer;
|
|
34
|
+
Subject = subject;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// <summary>
|
|
39
|
+
/// An assertion already authenticated by the host's OIDC middleware.
|
|
40
|
+
/// This type does not verify tokens, issuer allowlists, nonce, audience or PKCE.
|
|
41
|
+
/// </summary>
|
|
42
|
+
public sealed record VerifiedExternalPrincipal(
|
|
43
|
+
ExternalIdentityKey Identity,
|
|
44
|
+
string? Email,
|
|
45
|
+
bool EmailVerified);
|
|
46
|
+
|
|
47
|
+
public enum ApplicationAccountState
|
|
48
|
+
{
|
|
49
|
+
PendingApproval,
|
|
50
|
+
Approved,
|
|
51
|
+
Disabled
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// <summary>
|
|
55
|
+
/// Snapshot read from the owning application's trusted account store.
|
|
56
|
+
/// Public-facing responses must not distinguish unknown, disabled or unlinked users.
|
|
57
|
+
/// </summary>
|
|
58
|
+
public sealed record AccountSnapshot(
|
|
59
|
+
Guid AccountId,
|
|
60
|
+
ApplicationAccountState State,
|
|
61
|
+
IReadOnlySet<ExternalIdentityKey> LinkedIdentities);
|
|
62
|
+
|
|
63
|
+
public enum SignInDecision
|
|
64
|
+
{
|
|
65
|
+
Denied,
|
|
66
|
+
Allowed
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// <summary>
|
|
70
|
+
/// An application-store-validated invitation proof. The caller must validate
|
|
71
|
+
/// a one-time token hash and redeem it transactionally with the account write.
|
|
72
|
+
/// A matching email alone never constitutes an invitation.
|
|
73
|
+
/// </summary>
|
|
74
|
+
public sealed record ValidatedInvitation(
|
|
75
|
+
Guid InvitationId,
|
|
76
|
+
string IntendedEmail,
|
|
77
|
+
ExternalIdentityKey? BoundIdentity,
|
|
78
|
+
DateTimeOffset ExpiresAt,
|
|
79
|
+
bool TokenProofVerified,
|
|
80
|
+
bool Consumed,
|
|
81
|
+
bool Revoked);
|
|
82
|
+
|
|
83
|
+
/// <summary>
|
|
84
|
+
/// Explicitly reviewed, time-bounded, stable identity for initial administrator
|
|
85
|
+
/// creation. No "first user to sign in becomes admin" behavior.
|
|
86
|
+
/// </summary>
|
|
87
|
+
public sealed record PreapprovedAdministratorSeed(
|
|
88
|
+
ExternalIdentityKey Identity,
|
|
89
|
+
DateTimeOffset ExpiresAt,
|
|
90
|
+
bool Enabled);
|
|
91
|
+
|
|
92
|
+
/// <summary>
|
|
93
|
+
/// Fail-closed, side-effect-free policy primitives for the initial Google OIDC
|
|
94
|
+
/// household preset. These do not create users, issue sessions, send email,
|
|
95
|
+
/// mutate storage, or replace proper OIDC validation or transaction boundaries.
|
|
96
|
+
/// </summary>
|
|
97
|
+
public sealed class InviteOnlyAdmissionPolicy
|
|
98
|
+
{
|
|
99
|
+
public SignInDecision EvaluateSignIn(
|
|
100
|
+
VerifiedExternalPrincipal principal,
|
|
101
|
+
AccountSnapshot? account)
|
|
102
|
+
{
|
|
103
|
+
ArgumentNullException.ThrowIfNull(principal);
|
|
104
|
+
|
|
105
|
+
if (!HasVerifiedEmail(principal) ||
|
|
106
|
+
account is null ||
|
|
107
|
+
account.AccountId == Guid.Empty ||
|
|
108
|
+
account.State != ApplicationAccountState.Approved ||
|
|
109
|
+
account.LinkedIdentities is null ||
|
|
110
|
+
!account.LinkedIdentities.Contains(principal.Identity))
|
|
111
|
+
{
|
|
112
|
+
return SignInDecision.Denied;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return SignInDecision.Allowed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public bool MayAcceptInvitation(
|
|
119
|
+
VerifiedExternalPrincipal principal,
|
|
120
|
+
ValidatedInvitation? invitation,
|
|
121
|
+
DateTimeOffset now)
|
|
122
|
+
{
|
|
123
|
+
ArgumentNullException.ThrowIfNull(principal);
|
|
124
|
+
|
|
125
|
+
if (!HasVerifiedEmail(principal) ||
|
|
126
|
+
invitation is null ||
|
|
127
|
+
invitation.InvitationId == Guid.Empty ||
|
|
128
|
+
!invitation.TokenProofVerified ||
|
|
129
|
+
invitation.Consumed ||
|
|
130
|
+
invitation.Revoked ||
|
|
131
|
+
now >= invitation.ExpiresAt ||
|
|
132
|
+
!SameNormalizedEmail(principal.Email!, invitation.IntendedEmail))
|
|
133
|
+
{
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return invitation.BoundIdentity is null ||
|
|
138
|
+
invitation.BoundIdentity == principal.Identity;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public bool MayInitializeFirstAdministrator(
|
|
142
|
+
VerifiedExternalPrincipal principal,
|
|
143
|
+
PreapprovedAdministratorSeed? seed,
|
|
144
|
+
int existingAdministratorCount,
|
|
145
|
+
DateTimeOffset now)
|
|
146
|
+
{
|
|
147
|
+
ArgumentNullException.ThrowIfNull(principal);
|
|
148
|
+
|
|
149
|
+
return HasVerifiedEmail(principal) &&
|
|
150
|
+
existingAdministratorCount == 0 &&
|
|
151
|
+
seed is { Enabled: true } &&
|
|
152
|
+
now < seed.ExpiresAt &&
|
|
153
|
+
seed.Identity == principal.Identity;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private static bool HasVerifiedEmail(VerifiedExternalPrincipal principal) =>
|
|
157
|
+
principal.Identity is not null &&
|
|
158
|
+
principal.EmailVerified &&
|
|
159
|
+
!string.IsNullOrWhiteSpace(principal.Email);
|
|
160
|
+
|
|
161
|
+
private static bool SameNormalizedEmail(string asserted, string intended) =>
|
|
162
|
+
!string.IsNullOrWhiteSpace(intended) &&
|
|
163
|
+
string.Equals(
|
|
164
|
+
asserted.Trim(),
|
|
165
|
+
intended.Trim(),
|
|
166
|
+
StringComparison.OrdinalIgnoreCase);
|
|
167
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Generic;
|
|
3
|
+
using System.Linq;
|
|
4
|
+
|
|
5
|
+
namespace ProjectForge.Auth;
|
|
6
|
+
|
|
7
|
+
// Fixed internal diagnostics. These states must be populated only by trusted
|
|
8
|
+
// application middleware and must never include raw Google tokens, provider
|
|
9
|
+
// response bodies, account emails, usernames, session IDs or exception text.
|
|
10
|
+
public enum AuthenticationStage
|
|
11
|
+
{
|
|
12
|
+
PublicOrigin,
|
|
13
|
+
OidcAssertion,
|
|
14
|
+
AccountAdmission,
|
|
15
|
+
Session,
|
|
16
|
+
ApplicationAuthorization,
|
|
17
|
+
InvitationEmail
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public enum AuthenticationStageState
|
|
21
|
+
{
|
|
22
|
+
NotChecked,
|
|
23
|
+
Passed,
|
|
24
|
+
Failed,
|
|
25
|
+
Blocked
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public enum AuthenticationFailureCode
|
|
29
|
+
{
|
|
30
|
+
None,
|
|
31
|
+
PublicOriginRejected,
|
|
32
|
+
OidcAssertionRejected,
|
|
33
|
+
AccountNotAdmitted,
|
|
34
|
+
SessionRejected,
|
|
35
|
+
ApplicationAccessDenied,
|
|
36
|
+
InvitationDeliveryUnavailable,
|
|
37
|
+
Unclassified
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public sealed record AuthenticationStageEvidence(
|
|
41
|
+
AuthenticationStage Stage,
|
|
42
|
+
AuthenticationStageState State,
|
|
43
|
+
AuthenticationFailureCode FailureCode);
|
|
44
|
+
|
|
45
|
+
public sealed record AuthenticationDiagnosticSummary(
|
|
46
|
+
int SchemaVersion,
|
|
47
|
+
string Status,
|
|
48
|
+
AuthenticationFailureCode ReasonCode,
|
|
49
|
+
IReadOnlyList<AuthenticationStage> Proved,
|
|
50
|
+
IReadOnlyList<AuthenticationStage> Failed,
|
|
51
|
+
IReadOnlyList<AuthenticationStage> Blocked,
|
|
52
|
+
IReadOnlyList<AuthenticationStage> NotChecked,
|
|
53
|
+
DateTimeOffset ObservedAt,
|
|
54
|
+
bool Mutated)
|
|
55
|
+
{
|
|
56
|
+
// Public callers receive a uniform response, regardless of known,
|
|
57
|
+
// uninvited, disabled or unlinked account state.
|
|
58
|
+
public string PublicFailureMessage => "Unable to complete sign-in.";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// <summary>
|
|
62
|
+
/// Deterministic, bounded, side-effect-free diagnostic projection.
|
|
63
|
+
/// This does not perform Google authentication, grant access or issue sessions.
|
|
64
|
+
/// </summary>
|
|
65
|
+
public static class AuthenticationDiagnostics
|
|
66
|
+
{
|
|
67
|
+
private static readonly AuthenticationStage[] Stages =
|
|
68
|
+
[
|
|
69
|
+
AuthenticationStage.PublicOrigin,
|
|
70
|
+
AuthenticationStage.OidcAssertion,
|
|
71
|
+
AuthenticationStage.AccountAdmission,
|
|
72
|
+
AuthenticationStage.Session,
|
|
73
|
+
AuthenticationStage.ApplicationAuthorization,
|
|
74
|
+
AuthenticationStage.InvitationEmail
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
private static readonly IReadOnlyDictionary<AuthenticationStage, AuthenticationFailureCode> ExpectedFailures =
|
|
78
|
+
new Dictionary<AuthenticationStage, AuthenticationFailureCode>
|
|
79
|
+
{
|
|
80
|
+
[AuthenticationStage.PublicOrigin] = AuthenticationFailureCode.PublicOriginRejected,
|
|
81
|
+
[AuthenticationStage.OidcAssertion] = AuthenticationFailureCode.OidcAssertionRejected,
|
|
82
|
+
[AuthenticationStage.AccountAdmission] = AuthenticationFailureCode.AccountNotAdmitted,
|
|
83
|
+
[AuthenticationStage.Session] = AuthenticationFailureCode.SessionRejected,
|
|
84
|
+
[AuthenticationStage.ApplicationAuthorization] = AuthenticationFailureCode.ApplicationAccessDenied,
|
|
85
|
+
[AuthenticationStage.InvitationEmail] = AuthenticationFailureCode.InvitationDeliveryUnavailable
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
public static AuthenticationDiagnosticSummary Summarize(
|
|
89
|
+
IEnumerable<AuthenticationStageEvidence> observations,
|
|
90
|
+
DateTimeOffset observedAt)
|
|
91
|
+
{
|
|
92
|
+
ArgumentNullException.ThrowIfNull(observations);
|
|
93
|
+
var byStage = new Dictionary<AuthenticationStage, AuthenticationStageEvidence>();
|
|
94
|
+
foreach (var entry in observations)
|
|
95
|
+
{
|
|
96
|
+
if (entry is null || !ExpectedFailures.ContainsKey(entry.Stage) ||
|
|
97
|
+
!Enum.IsDefined(entry.State) || !Enum.IsDefined(entry.FailureCode) ||
|
|
98
|
+
!byStage.TryAdd(entry.Stage, entry))
|
|
99
|
+
{
|
|
100
|
+
throw new ArgumentException("Invalid or duplicate authentication stage observation.", nameof(observations));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (entry.State == AuthenticationStageState.Failed &&
|
|
104
|
+
entry.FailureCode != ExpectedFailures[entry.Stage] &&
|
|
105
|
+
entry.FailureCode != AuthenticationFailureCode.Unclassified)
|
|
106
|
+
{
|
|
107
|
+
throw new ArgumentException("Failure code conflicts with stage.", nameof(observations));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (entry.State != AuthenticationStageState.Failed &&
|
|
111
|
+
entry.FailureCode != AuthenticationFailureCode.None)
|
|
112
|
+
{
|
|
113
|
+
throw new ArgumentException("A nonfailed stage cannot report a failure code.", nameof(observations));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
var proved = new List<AuthenticationStage>(6);
|
|
118
|
+
var failed = new List<AuthenticationStage>(6);
|
|
119
|
+
var blocked = new List<AuthenticationStage>(6);
|
|
120
|
+
var notChecked = new List<AuthenticationStage>(6);
|
|
121
|
+
var failure = AuthenticationFailureCode.None;
|
|
122
|
+
foreach (var stage in Stages)
|
|
123
|
+
{
|
|
124
|
+
var state = byStage.TryGetValue(stage, out var evidence)
|
|
125
|
+
? evidence.State
|
|
126
|
+
: AuthenticationStageState.NotChecked;
|
|
127
|
+
switch (state)
|
|
128
|
+
{
|
|
129
|
+
case AuthenticationStageState.Passed:
|
|
130
|
+
proved.Add(stage);
|
|
131
|
+
break;
|
|
132
|
+
case AuthenticationStageState.Failed:
|
|
133
|
+
failed.Add(stage);
|
|
134
|
+
if (failure == AuthenticationFailureCode.None)
|
|
135
|
+
{
|
|
136
|
+
failure = evidence!.FailureCode;
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
case AuthenticationStageState.Blocked:
|
|
140
|
+
blocked.Add(stage);
|
|
141
|
+
break;
|
|
142
|
+
default:
|
|
143
|
+
notChecked.Add(stage);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Email invitation delivery is not a prerequisite for an already
|
|
149
|
+
// approved user's interactive sign-in.
|
|
150
|
+
var required = Stages.Take(5).ToArray();
|
|
151
|
+
var allRequiredProved = required.All(proved.Contains);
|
|
152
|
+
var status = required.Any(failed.Contains) ? "failed"
|
|
153
|
+
: required.Any(blocked.Contains) ? "blocked"
|
|
154
|
+
: allRequiredProved ? "proved" : "incomplete";
|
|
155
|
+
return new AuthenticationDiagnosticSummary(
|
|
156
|
+
1, status, failure, proved, failed, blocked, notChecked,
|
|
157
|
+
observedAt.ToUniversalTime(), Mutated: false);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk">
|
|
2
|
+
<PropertyGroup>
|
|
3
|
+
<TargetFramework>net10.0</TargetFramework>
|
|
4
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
5
|
+
<Nullable>enable</Nullable>
|
|
6
|
+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
7
|
+
<IsPackable>false</IsPackable>
|
|
8
|
+
</PropertyGroup>
|
|
9
|
+
</Project>
|
package/assets/generated/authentication/src/ProjectForge.Auth/PwaExistingProjectCompatibility.cs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
|
|
3
|
+
namespace ProjectForge.Auth;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// Compatibility boundary for existing Family Meal Planner FB-001 accounts.
|
|
7
|
+
/// This adapts the already-linked Google identity to the reusable approval
|
|
8
|
+
/// policy WITHOUT changing its on-disk 32-hex string GUID, password hash,
|
|
9
|
+
/// email-recovery flow, session credentials, data or membership.
|
|
10
|
+
////
|
|
11
|
+
//// PWA still owns password sign-in, session issuance, OIDC code flow and
|
|
12
|
+
//// initial enrollment behavior until a separately approved runtime cutover.
|
|
13
|
+
/// The adapter must be called only after the PWA's trusted Google middleware
|
|
14
|
+
/// validated the OIDC assertion, issuer and subject. An unverified email
|
|
15
|
+
/// remains unverified under the PWA-compatible mode; it never links by email.
|
|
16
|
+
/// </summary>
|
|
17
|
+
public sealed record ExistingPwaGoogleAccount(
|
|
18
|
+
string AccountIdN,
|
|
19
|
+
string LinkedIssuer,
|
|
20
|
+
string LinkedSubject,
|
|
21
|
+
string Email,
|
|
22
|
+
bool EmailVerifiedByGoogle);
|
|
23
|
+
|
|
24
|
+
public sealed record ExistingPwaCompatibilityResult(
|
|
25
|
+
SignInDecision Decision,
|
|
26
|
+
string? PreservedAccountId,
|
|
27
|
+
string ReasonCode,
|
|
28
|
+
bool PreserveUnverifiedEmailState);
|
|
29
|
+
|
|
30
|
+
public sealed class PwaExistingProjectCompatibility
|
|
31
|
+
{
|
|
32
|
+
// Existing PWA FB-001 deliberately allows a verified OIDC assertion with
|
|
33
|
+
// email_verified=false to create a non-email-verified account/session.
|
|
34
|
+
// Its service tests document that behavior. New Projects default to the
|
|
35
|
+
// stricter invite-only policy; migration must not silently tighten PWA.
|
|
36
|
+
public ExistingPwaCompatibilityResult EvaluateExistingGoogleLink(
|
|
37
|
+
ExistingPwaGoogleAccount? account,
|
|
38
|
+
string authenticatedIssuer,
|
|
39
|
+
string authenticatedSubject)
|
|
40
|
+
{
|
|
41
|
+
if (account is null ||
|
|
42
|
+
!Guid.TryParseExact(account.AccountIdN, "N", out var id) ||
|
|
43
|
+
id == Guid.Empty ||
|
|
44
|
+
account.AccountIdN != id.ToString("N") ||
|
|
45
|
+
authenticatedIssuer != "https://accounts.google.com" ||
|
|
46
|
+
account.LinkedIssuer != authenticatedIssuer ||
|
|
47
|
+
string.IsNullOrWhiteSpace(account.LinkedSubject) ||
|
|
48
|
+
account.LinkedSubject != authenticatedSubject ||
|
|
49
|
+
string.IsNullOrWhiteSpace(account.Email))
|
|
50
|
+
{
|
|
51
|
+
return new ExistingPwaCompatibilityResult(
|
|
52
|
+
SignInDecision.Denied, null, "existing-link-not-proven", false);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// OIDC token validation and application-session issuance remain PWA
|
|
56
|
+
// responsibilities. This adapter is a non-destructive exact-link
|
|
57
|
+
// check, not a household membership or email verification grant.
|
|
58
|
+
return new ExistingPwaCompatibilityResult(
|
|
59
|
+
SignInDecision.Allowed,
|
|
60
|
+
account.AccountIdN,
|
|
61
|
+
"existing-link-verified-pwa-compatibility",
|
|
62
|
+
!account.EmailVerifiedByGoogle);
|
|
63
|
+
}
|
|
64
|
+
}
|