Plan ID: PLAN-0001
Linked Spec: SPEC-0001 (COMPLETED)
Constitution Ref: FortressAPI Constitution v2.0.0
Status: COMPLETED
| Field | Value |
|---|---|
| Plan ID | PLAN-0001 |
| Linked Spec | SPEC-0001 |
| Linked Tasks | TASK-0001 |
| Tech Lead | Senior IAM Engineer |
| Security Reviewer | Security Working Group |
| Created | 2026-03-13 |
| Target Release | v1.0.0 |
| Estimated Effort | 8 dev-days |
| Gate | Status |
|---|---|
| SPEC-0001 in APPROVED state | ✅ |
| STRIDE/DREAD complete, all HIGH threats mitigated | ✅ |
| Security reviewer approval | ✅ |
| FIPS 140-3 algorithm set verified | ✅ |
| Keycloak config design reviewed | ✅ |
| All new NuGet packages scanned (zero CRITICAL CVEs) | ✅ |
| Feature flag key defined | ✅ feature.auth.dpop-flow |
┌─────────────────────────────────────────────────────────────────────┐
│ Government Employee Client │
│ Browser / Desktop App │
│ - Generates ephemeral DPoP key pair (EC P-256 in FIPS mode) │
│ - Holds DPoP private key in memory only — never persisted │
└────────────────────────┬────────────────────────────────────────────┘
│ HTTPS TLS 1.3
▼
┌─────────────────────────────────────────────────────────────────────┐
│ API Gateway │
│ - TLS termination (TLS 1.3 only) │
│ - Rate limiting (PAR: 10/min/IP, Token: 20/min/IP) │
│ - mTLS to backend services │
│ - Forwards: Authorization, DPoP headers unchanged │
└──────────────┬──────────────────────────────┬───────────────────────┘
│ mTLS │ mTLS
▼ ▼
┌──────────────────────────┐ ┌─────────────────────────────────────┐
│ Keycloak 26+ │ │ .NET 9 Web API │
│ - FIPS 140-3 mode │ │ Middleware pipeline (ordered): │
│ - PAR endpoint │ │ 1. TLS enforcement │
│ - WebAuthn AAL3 flow │ │ 2. Security headers │
│ - Token endpoint │ │ 3. Rate limiting │
│ - JWKS endpoint │ │ 4. DpopValidationMiddleware │
│ - Back-channel logout │ │ 5. TokenReplayMiddleware (Redis) │
│ - UMA 2.0 authz │ │ 6. UseAuthentication (JWT Bearer) │
│ │ │ 7. AcrValidationMiddleware │
│ ┌──────────────────┐ │ │ 8. UseAuthorization │
│ │ Infinispan cluster│ │ │ │
│ │ (session store) │ │ │ Controllers: │
│ └──────────────────┘ │ │ - ProfileController │
│ │ │ - TokenIntrospectionController │
│ ┌──────────────────┐ │ └─────────────────┬───────────────────┘
│ │ FIPS HSM │ │ │ mTLS
│ │ (signing keys) │ │ ▼
│ └──────────────────┘ │ ┌─────────────────────────┐
└──────────────────────────┘ │ Redis (jti cache) │
│ JWKS (mTLS) │ - TLS + auth │
└─────────────────────│ - Key: replay:jti:* │
│ - TTL = token lifetime │
└─────────────────────────┘
│
┌──────────────────┼───────────────┐
▼ ▼ ▼
OTel Collector SIEM (Splunk) Prometheus
src/
├── FortressApi.Api/ ← Presentation layer
│ ├── Controllers/
│ │ ├── ProfileController.cs
│ │ └── HealthController.cs
│ ├── Middleware/
│ │ ├── DpopValidationMiddleware.cs ← DPoP proof verification
│ │ ├── TokenReplayMiddleware.cs ← jti Redis cache
│ │ ├── AcrValidationMiddleware.cs ← ACR claim enforcement
│ │ └── SecurityHeadersMiddleware.cs ← Response security headers
│ ├── Authorization/
│ │ ├── Requirements/
│ │ │ ├── AcrRequirement.cs
│ │ │ └── ScopeRequirement.cs
│ │ └── Handlers/
│ │ ├── AcrAuthorizationHandler.cs
│ │ └── ScopeAuthorizationHandler.cs
│ ├── Filters/
│ │ └── ProblemDetailsFactory.cs ← RFC 7807 uniform errors
│ ├── OpenApi/
│ │ └── SecuritySchemeDocumentFilter.cs
│ └── Program.cs
│
├── FortressApi.Application/ ← Use cases
│ ├── Auth/
│ │ ├── Queries/
│ │ │ └── GetCurrentUserQuery.cs
│ │ └── Models/
│ │ ├── TokenClaims.cs ← Parsed, validated claims model
│ │ └── DpopProof.cs ← Parsed DPoP proof model
│
├── FortressApi.Infrastructure/ ← External integrations
│ ├── Auth/
│ │ ├── DpopProofValidator.cs ← Core DPoP proof validation logic
│ │ ├── JtiReplayCache.cs ← Redis jti store
│ │ ├── KeycloakJwksProvider.cs ← JWKS caching + rotation
│ │ └── AcrClaimsTransformer.cs ← ACR claim normalization
│ ├── Cache/
│ │ └── RedisConnectionFactory.cs
│ └── Telemetry/
│ ├── AuthTelemetry.cs ← Named metrics + spans
│ └── SecurityEventEmitter.cs ← SIEM event publisher
│
└── FortressApi.Tests/
├── Unit/
│ ├── DpopProofValidatorTests.cs
│ ├── JtiReplayCacheTests.cs
│ ├── AcrAuthorizationHandlerTests.cs
│ └── TokenClaimsTests.cs
└── Integration/
├── AuthFlowIntegrationTests.cs ← Full PAR→Token→API flow
├── SecurityScenarioTests.cs ← All S-XX scenarios
└── Fixtures/
├── KeycloakFixture.cs ← Testcontainers Keycloak
└── RedisFixture.cs ← Testcontainers Redis
{
"realm": "fortress-gov",
"enabled": true,
"displayName": "FortressAPI Government",
"sslRequired": "all",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"maxDeltaTimeSeconds": 600,
"failureFactor": 5,
"accessTokenLifespan": 300,
"accessTokenLifespanForImplicitFlow": 0,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 28800,
"offlineSessionIdleTimeout": 28800,
"offlineSessionMaxLifespan": 28800,
"revokeRefreshToken": true,
"refreshTokenMaxReuse": 0
}{
"name": "fapi2-government-policy",
"description": "Enforces FAPI 2.0 + DPoP for all government clients",
"enabled": true,
"conditions": [
{
"condition": "any-client",
"configuration": {}
}
],
"profiles": ["fapi2-government-profile"]
}{
"name": "fapi2-government-profile",
"executors": [
{ "executor": "pkce-enforcer", "configuration": { "allow-method": ["S256"] } },
{ "executor": "dpop-enforcer", "configuration": { "dpop-bound-access-tokens": "true" } },
{ "executor": "par-enforcer", "configuration": { "par-required": "true" } },
{ "executor": "secure-signing-algorithm", "configuration": { "algorithm": ["PS256", "ES256"] } },
{ "executor": "secure-session", "configuration": { "max-sessions": "3" } },
{ "executor": "hold-of-key-enforcer", "configuration": { "every-endpoint": "true" } }
]
}{
"webAuthnPolicyRpEntityName": "FortressAPI Government",
"webAuthnPolicyRpId": "agency.gov",
"webAuthnPolicyAttestationConveyancePreference": "direct",
"webAuthnPolicyAuthenticatorAttachment": "cross-platform",
"webAuthnPolicyRequireResidentKey": "Yes",
"webAuthnPolicyUserVerificationRequirement": "required",
"webAuthnPolicySignatureAlgorithms": ["ES256", "RS256"],
"webAuthnPolicyAvoidSameAuthenticatorRegister": false,
"webAuthnPolicyAcceptableAaguids": [],
"webAuthnPolicyExtraOrigins": []
}Flow: government-aal3-browser
REQUIRED - Auth Note Checker
REQUIRED - Cookie
ALTERNATIVE:
REQUIRED - Username Password Form
REQUIRED - WebAuthn Authenticator
[Configuration]
User Verification: REQUIRED
Attestation: DIRECT
Timeout: 300 seconds
MDS3 validation: ENABLED
CONDITIONAL (WebAuthn failures ≥ 3 in session):
REQUIRED - OTP Form (TOTP recovery only)
Post-login:
REQUIRED - ACR Loa Condition Check
→ Maps this flow to acr3
// ── Authentication ──────────────────────────────────────────────────
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = config["Keycloak:Authority"];
options.Audience = config["Keycloak:Audience"];
options.RequireHttpsMetadata = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero, // ZERO tolerance
ValidAlgorithms = ["PS256", "ES256"],
RequireSignedTokens = true,
RequireExpirationTime = true,
NameClaimType = "sub",
RoleClaimType = "realm_access.roles",
};
// Automatic JWKS refresh
options.RefreshOnIssuerKeyNotFound = true;
});
// ── Authorization ────────────────────────────────────────────────────
builder.Services.AddAuthorization(options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("acr")
.Build();
options.AddPolicy(Policies.ReadProfile,
p => p.RequireAuthenticatedUser()
.AddRequirements(
new ScopeRequirement("profile"),
new AcrRequirement(AcrLevel.Acr2)));
options.AddPolicy(Policies.AdminWrite,
p => p.RequireAuthenticatedUser()
.AddRequirements(
new ScopeRequirement("admin:write"),
new AcrRequirement(AcrLevel.Acr3)));
});
builder.Services.AddSingleton<IAuthorizationHandler, AcrAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, ScopeAuthorizationHandler>();
// ── Infrastructure ───────────────────────────────────────────────────
builder.Services.AddStackExchangeRedisCache(o =>
o.ConfigurationOptions = RedisConnectionFactory.Build(config));
builder.Services.AddSingleton<IJtiReplayCache, JtiReplayCache>();
builder.Services.AddSingleton<IDpopProofValidator, DpopProofValidator>();
builder.Services.AddHttpClient<IKeycloakJwksProvider, KeycloakJwksProvider>()
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
// mTLS client certificate loaded from vault
ClientCertificates = { CertificateLoader.LoadFromVault("keycloak-mtls-cert") }
});
// ── OpenTelemetry ────────────────────────────────────────────────────
builder.Services
.AddOpenTelemetry()
.WithTracing(t => t.AddAspNetCoreInstrumentation().AddSource(AuthTelemetry.SourceName))
.WithMetrics(m => m.AddMeter(AuthTelemetry.MeterName).AddPrometheusExporter());
// ── Middleware pipeline (ORDER IS CRITICAL) ──────────────────────────
app.UseMiddleware<SecurityHeadersMiddleware>(); // 1. Headers on all responses
app.UseRateLimiter(); // 2. Protect auth endpoints
app.UseMiddleware<DpopValidationMiddleware>(); // 3. DPoP before auth resolves user
app.UseMiddleware<TokenReplayMiddleware>(); // 4. jti check before auth resolves user
app.UseAuthentication(); // 5. JWT Bearer validation
app.UseMiddleware<AcrValidationMiddleware>(); // 6. ACR after identity established
app.UseAuthorization(); // 7. Policy enforcementValidation steps (all must pass):
- Extract
Authorizationheader — must beDPoP <token>, notBearer <token> - Extract
DPoPheader — must be present - Parse DPoP proof JWT header — get
alg(must be PS256/ES256) andjwk(public key) - Verify DPoP proof JWT signature using the embedded
jwk - Parse DPoP proof JWT payload:
jti— must be present (unique proof ID)htm— must matchHttpContext.Request.Methodhtu— must matchHttpContext.Request.Scheme + "://" + Host + Path(no query string)iat— must be within[now - 60s, now + 5s]nonce— must match the server-issued nonce (from Redis keydpop:nonce:{client-hash})
- Verify the
cnf.jktclaim in the access token matches the JWK thumbprint in the DPoP proof - On any failure: return 401 with
WWW-Authenticate: DPoP error="invalid_dpop_proof", algs="PS256 ES256" - After validation: issue a new nonce in the response
DPoP-Nonceheader
Algorithm:
1. Wait for JWT Bearer authentication to complete (runs after UseAuthentication but jti check
can run before user is resolved by hooking into OnTokenValidated event instead)
PREFERRED APPROACH: Hook into JwtBearer OnTokenValidated event:
options.Events = new JwtBearerEvents
{
OnTokenValidated = async ctx =>
{
var jti = ctx.Principal!.FindFirst("jti")?.Value;
var exp = ctx.Principal!.FindFirst("exp")?.Value;
var cache = ctx.HttpContext.RequestServices.GetRequiredService<IJtiReplayCache>();
if (string.IsNullOrEmpty(jti))
{
ctx.Fail("Missing jti claim");
return;
}
var alreadySeen = await cache.ExistsAsync(jti, ctx.HttpContext.RequestAborted);
if (alreadySeen)
{
// Emit security event BEFORE failing
var telemetry = ctx.HttpContext.RequestServices
.GetRequiredService<ISecurityEventEmitter>();
await telemetry.EmitAsync(SecurityEvent.TokenReplay, new { jti });
ctx.Fail("Token replay detected");
return;
}
// Store jti — TTL = remaining token lifetime
var remainingTtl = DateTimeOffset.FromUnixTimeSeconds(long.Parse(exp!))
- DateTimeOffset.UtcNow;
await cache.StoreAsync(jti, remainingTtl, ctx.HttpContext.RequestAborted);
}
};
public sealed class AcrRequirement(string minimumAcr) : IAuthorizationRequirement
{
public string MinimumAcr { get; } = minimumAcr;
}
public sealed class AcrAuthorizationHandler
: AuthorizationHandler<AcrRequirement>
{
private static readonly Dictionary<string, int> AcrRank = new()
{
["acr1"] = 1,
["acr2"] = 2,
["acr3"] = 3,
};
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AcrRequirement requirement)
{
var tokenAcr = context.User.FindFirst("acr")?.Value;
if (tokenAcr is null
|| !AcrRank.TryGetValue(tokenAcr, out var tokenRank)
|| !AcrRank.TryGetValue(requirement.MinimumAcr, out var requiredRank)
|| tokenRank < requiredRank)
{
// Return 401 with step-up hint — NOT 403
// The httpContext is accessed via resource if needed for the header
context.Fail(new AuthorizationFailureReason(this,
$"Insufficient ACR. Required: {requirement.MinimumAcr}, Got: {tokenAcr}"));
return Task.CompletedTask;
}
context.Succeed(requirement);
return Task.CompletedTask;
}
}// Applied on ALL responses — no exceptions
app.Use(async (ctx, next) =>
{
ctx.Response.Headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload";
ctx.Response.Headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'";
ctx.Response.Headers["X-Content-Type-Options"] = "nosniff";
ctx.Response.Headers["X-Frame-Options"] = "DENY";
ctx.Response.Headers["Referrer-Policy"] = "no-referrer";
ctx.Response.Headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()";
ctx.Response.Headers["Cache-Control"] = "no-store";
ctx.Response.Headers["Pragma"] = "no-cache";
// Remove server identification
ctx.Response.Headers.Remove("Server");
ctx.Response.Headers.Remove("X-Powered-By");
await next();
});Key pattern : replay:jti:{jti-value}
Value : "" (empty — presence is the signal)
TTL : Remaining token lifetime in seconds (min 1s)
Eviction policy : allkeys-lru (safety net — TTL is primary expiry)
Max memory policy : Redis must be sized for peak concurrent users × 5 min token window
Key pattern : dpop:nonce:{sha256-of-client-ip-and-clientId}
Value : {nonce-value} (32 bytes, base64url)
TTL : 600 seconds (nonce must be used within 10 minutes)
{
"timestamp" : "2026-03-13T10:00:00.000Z",
"eventType" : "AUTH_SUCCESS | AUTH_FAILURE | TOKEN_REPLAY | TOKEN_ISSUED | LOGOUT",
"correlationId" : "uuid-v4",
"sub" : "opaque-uuid",
"clientId" : "fortressapi-gov-client",
"sessionId" : "keycloak-session-id",
"acr" : "acr3",
"ipHash" : "sha256-hex",
"userAgentHash" : "sha256-hex",
"outcome" : "success | failure",
"failureReason" : "string | null",
"dpopJkt" : "jwk-thumbprint | null"
}| Dependency | Version | Purpose | CVE Status |
|---|---|---|---|
Microsoft.AspNetCore.Authentication.JwtBearer |
9.0.x | JWT Bearer auth | Clean |
Microsoft.AspNetCore.Authorization |
9.0.x | Policy-based authz | Clean |
StackExchange.Redis |
2.8.x | Redis jti cache | Clean |
Microsoft.Extensions.Caching.StackExchangeRedis |
9.0.x | Redis integration | Clean |
OpenTelemetry.Extensions.Hosting |
1.9.x | OTel SDK | Clean |
OpenTelemetry.Instrumentation.AspNetCore |
1.9.x | HTTP tracing | Clean |
Microsoft.IdentityModel.JsonWebTokens |
8.x | JWT parsing | Clean |
System.IdentityModel.Tokens.Jwt |
8.x | JWT validation | Clean |
| Keycloak | 26.1.x | IdP / AS | FIPS verified |
| Redis | 7.4.x | Cache | Clean |
No BouncyCastle (non-FIPS) in .NET — using only System.Security.Cryptography FIPS-approved APIs.
| Failure | HTTP Status | type URI |
Log Level | SIEM? |
|---|---|---|---|---|
Missing Authorization header |
401 | /errors/unauthorized |
Information | No |
| Invalid JWT signature | 401 | /errors/unauthorized |
Warning | Yes (count spike) |
| Expired JWT | 401 | /errors/token-expired |
Information | No |
| Invalid algorithm (non-PS256/ES256) | 401 | /errors/unauthorized |
Warning | Yes |
| Missing/invalid DPoP header | 401 | /errors/invalid-dpop-proof |
Warning | Yes (count spike) |
DPoP htm/htu mismatch |
401 | /errors/invalid-dpop-proof |
Warning | Yes |
jti replay detected |
401 | /errors/unauthorized |
Critical | Yes (immediate alert) |
| Insufficient ACR | 401 | /errors/insufficient-auth |
Information | No |
| Insufficient scope | 403 | /errors/forbidden |
Information | No |
| Redis unavailable | 503 | /errors/service-unavailable |
Critical | Yes (immediate alert) |
{
"type" : "/errors/{slug}",
"title" : "Human-readable title",
"status" : 401,
"correlationId" : "uuid-v4",
"traceId" : "w3c-traceparent"
}Never include: stack traces, exception messages, inner exception details, Keycloak URLs, service names, file paths, or database query fragments.
// JtiReplayCache — fail-closed design
public async ValueTask<bool> ExistsAsync(string jti, CancellationToken ct)
{
try
{
var db = _connection.GetDatabase();
return await db.KeyExistsAsync(GetKey(jti));
}
catch (RedisException ex)
{
// Log critical — Redis is unavailable
_logger.LogCritical(ex, "Redis unavailable during jti replay check. Failing closed.");
_telemetry.RecordRedisFailure();
// FAIL CLOSED: treat as if token has been seen (block the request)
throw new ReplayCacheUnavailableException("jti replay cache unavailable", ex);
}
}
// The middleware catches ReplayCacheUnavailableException and returns 503.Unit Tests (xUnit + Moq + FluentAssertions)
├── DpopProofValidatorTests — all proof validation paths in isolation
├── JtiReplayCacheTests — Redis interaction, fail-closed behavior
├── AcrAuthorizationHandlerTests — all ACR level combinations
├── TokenClaimsTests — claim extraction, null handling
└── SecurityHeadersTests — header presence verification
Integration Tests (xUnit + Testcontainers)
├── Fixture: KeycloakFixture — starts Keycloak 26 container, imports realm
├── Fixture: RedisFixture — starts Redis 7 container
├── AuthFlowIntegrationTests — full PAR → Auth Code → Token → API flow
└── SecurityScenarioTests — all 14 security scenarios from threat model
| Layer | Coverage Target |
|---|---|
| DPoP validation logic | 100% |
| jti replay cache | 100% |
| ACR handler | 100% |
| Security headers | 100% |
| Overall project | ≥ 85% |
Use Testcontainers for .NET to spin up a real Keycloak 26 instance with the imported realm config for integration tests. This avoids mocking the authorization server — security tests must run against real Keycloak behavior, not a simulated mock.
// KeycloakFixture.cs
public sealed class KeycloakFixture : IAsyncLifetime
{
private readonly KeycloakContainer _container = new KeycloakBuilder()
.WithImage("quay.io/keycloak/keycloak:26.1")
.WithEnvironment("KC_FEATURES", "fips:preview,dpop,par")
.WithImportRealm("./TestData/fortress-gov-realm.json")
.Build();
public string AuthorityUrl => _container.GetAuthServerAddress() + "/realms/fortress-gov";
public async Task InitializeAsync() => await _container.StartAsync();
public async Task DisposeAsync() => await _container.StopAsync();
}| Phase | Scope | Duration | Rollback Trigger |
|---|---|---|---|
| 0 — Dark Launch | Internal platform team, feature.auth.dpop-flow = false |
2 days | Any P0 |
| 1 — Internal Canary | Internal users only, flag true |
3 days | Auth error rate > 1% |
| 2 — Staged | 25% → 100% government employees | 7 days | Auth error rate > 0.5% |
| 3 — GA | Feature flag removed from code | After 14 days stable |
- All SPEC-0001 functional requirements (FR-01 to FR-34) implemented and tested
- All 14 threat model mitigations verified by integration tests
- SAST: zero HIGH/CRITICAL findings
- Dependency scan: zero CRITICAL CVEs
- DPoP fail-closed Redis behavior verified
- Algorithm rejection (RS256) verified
-
ClockSkew = Zeroverified (expired tokens rejected immediately) - All security headers present on every response
- OTel traces visible end-to-end in staging
- SIEM alert rules deployed and tested with synthetic events
- Keycloak config committed as IaC and applied via CI pipeline
- OpenAPI 3.1 spec updated
- Security reviewer PR approval on all security-tagged tasks