diff --git a/.claude/rules/backend/feature-flags.md b/.claude/rules/backend/feature-flags.md new file mode 100644 index 000000000..0baea03de --- /dev/null +++ b/.claude/rules/backend/feature-flags.md @@ -0,0 +1,65 @@ +--- +description: Non-obvious behaviour of the feature flag system on the backend (declaration, evaluation, lifecycle, ownership) +--- + +# Feature Flags (backend) + +Load this when adding, removing, or changing how a feature flag is evaluated or stored. The obvious parts (declare a field, read `executionContext.UserInfo.FeatureFlags`) are not repeated. + +## Subtype Is the Contract + +The `FeatureFlagDefinition` subtype on a field is not just a permissions tag — it rewires database-row ownership and validator behaviour at runtime. The subtype hierarchy replaced what used to be runtime checks (see the comment above the registry in `application/shared-kernel/SharedKernel/FeatureFlags/FeatureFlags.cs`). + +- `PlanGatedTenantFlag` makes `PlanBasedFeatureFlagEvaluator` the exclusive writer of tenant overrides on every JWT refresh; the reconciler stamps `Source=Plan` on the base row and the Set/Remove validators block manual edits. +- `SystemFeatureFlag` skips the database entirely — config + frontend env var only. There is no per-tenant override. +- `TenantAbTestFlag` / `UserAbTestFlag` enable rollout buckets; `IsAbTestEligible` and `ConfigurableBy*` are mutually exclusive by construction (the disjoint subtype branches), and `BucketStart/End` are ignored if you pick a non-AB subtype. +- `TenantOwnerConfigurableFlag` requires `AdminLevel.TenantOwner` and `Scope.Tenant`; handlers check both. If those ever drift you get a silent 403. + +Switching subtypes on an existing flag changes the row owner on the next reconcile — confirm that's what you want before flipping a `TenantAbTestFlag` to `PlanGatedTenantFlag`. + +## `IsKillSwitchEnabled` Defaults the Row to Inactive + +`isKillSwitchEnabled: true` causes the reconciler to create the base row with `EnabledAt = null` — an admin must click Activate before anyone sees the flag. `ActivateFeatureFlag` / `DeactivateFeatureFlag` only operate on kill-switch flags; default-false flags (e.g. `TenantOwnerConfigurableFlag`) are globally un-killable by design — only per-tenant overrides can turn them off. + +## Soft-Delete Burns the Key + +The startup reconciler marks orphaned rows (`OrphanedAt`), and re-adding the same key restores them — including any orphaned tenant/user overrides. **But** once a row is `DeletedAt`-stamped (back-office hard-delete), re-adding the same key in C# throws on startup and aborts deployment. Don't re-use a name; pick a new one. + +## The Four BackOffice Query Mirrors Drift Silently + +`FeatureFlagEvaluator` is the canonical runtime path. The four BackOffice query handlers (`GetUserFeatureFlags`, `GetTenantFeatureFlags`, `GetFeatureFlagUsers`, `GetFeatureFlagTenants`) each carry their own copy of `EvaluateAbRollout`, `ComputeInclusionThresholdPercentage`, and `ComputeDefaultEnabled`. They agree today by construction, not by tests — if you change evaluation math, update all five and rely on `FeatureFlagEvaluatorTests` for the canonical contract. + +Known divergences worth being aware of: the mirrors do NOT consult parent-dependency, and `EvaluateOverride` in the bulk-list mirrors returns `IsEnabled=true` without checking `baseRow.IsActive`. BackOffice display can therefore report a flag as enabled while runtime evaluation excludes it. + +## Disable Semantics Are Asymmetric + +The four "disable this flag for this entity" paths look symmetric but aren't: + +- `SetTenantFeatureFlagInternal(Enabled=false)` (admin) creates a new override row with `EnabledAt == DisabledAt` if none exists. This is required: without it, the entity would stay enabled-by-rollout. Same applies to `SetUserFeatureFlagInternal`. +- `SetTenantFeatureFlagOwner(Enabled=false)` (tenant owner) no-ops if no override exists — owners can't yank themselves out of a rollout they were never explicitly in. +- `RemoveTenantFeatureFlagOverride` (admin only) is a hard `Remove`; the row is dropped. +- `SetTenantFeatureFlagInternal(Enabled=false)` is a soft `Deactivate`; the row is kept with `DisabledAt` set. + +Both removal paths emit `FeatureFlagTenantOverrideRemoved`, but they produce different `OverrideCount` results in bulk admin lists because the list counts `Source=Manual` rows — `Remove` drops them, `Deactivate` keeps them. + +`SetTenantFeatureFlagOwner` is NOT a back-office mutation. It lives under `/api/account/feature-flags/{key}/tenant-override` and self-validates `Role == Owner`. Plan-gated flags are explicitly blocked at the validator level for both manual paths. + +## Rollout Math Is Deterministic by Flag + +`RolloutBucketHasher` is a van der Corput low-discrepancy sequence offset by a per-flag FNV-1a hash of the key. Two flags at the same percentage do NOT cover the same tenants, and ramping up never reshuffles existing members — a tenant included at 10% stays included at 20%. Don't replace this with a random or modulo strategy. + +`ComputeInclusionThresholdPercentage` returns the percentage at which an entity would join the rollout, but it's special-cased for pins (`AlwaysOn` → 0, `NeverOn` → null). After the pin-trumps-rollout change, pins are unconditional, so the "joins at N%" column in BackOffice lists is meaningless for pinned rows. + +## Reading vs Writing + +- Handlers: read `executionContext.UserInfo.FeatureFlags` (already populated from the JWT claim). Re-querying the DB at request time means you've stepped outside the JWT-claim contract. +- The `FeatureFlagEvaluator` runs at JWT refresh, not per request. Flag changes take up to the 5-minute access-token TTL to propagate. +- Mutations that change the actor's own claim must chain `AddRefreshAuthenticationTokens()` so the gateway refreshes the JWT in-flight; without that the user waits up to 5 minutes. Plain mutations of other users' flags don't need this — they'll see it on their next refresh. + +## Architecture Test Guards + +Every new back-office mutation belongs in `EndpointMetadataTests.AdminPolicyBackOfficeRoutes` if admin-only, and gets a paired `_WhenNonAdminBackOfficeIdentity_ShouldReturnForbidden` test. The architecture test fails if a route's declared `RequireAuthorization` policy doesn't match the allowlist. + +## Telemetry + +Override events (`FeatureFlagTenantOverrideSet/Removed`, `FeatureFlagUserOverrideSet/Removed`) carry a `FeatureFlagOverrideTrigger` axis: `Internal` (back-office staff), `Owner` (tenant owner), `Self` (end-user preference). Plan-source transitions emit their own `FeatureFlagPlanOverrideActivated/Deactivated` events — they're not in the trigger enum. Per-flag telemetry tags are emitted as a single comma-separated `user.feature_flags` dimension; `feature_flag.*` is reserved by the OTel semantic-conventions registry, do not reintroduce it. diff --git a/.claude/rules/frontend/feature-flags.md b/.claude/rules/frontend/feature-flags.md new file mode 100644 index 000000000..7ec9e78ba --- /dev/null +++ b/.claude/rules/frontend/feature-flags.md @@ -0,0 +1,37 @@ +--- +description: Non-obvious behaviour of the feature flag system on the frontend (hook, codegen, propagation timing) +--- + +# Feature Flags (frontend) + +Load this when gating UI on a feature flag, displaying a flag, or wondering why a toggle isn't reflecting in the SPA. The obvious parts (`useFeatureFlag()` returns `{ enabled }`) are not repeated. + +## `FeatureFlagKey` Is a Type-Level Contract + +`` is typed against `FeatureFlagKey`, a codegen string-literal union built from `SharedKernel.FeatureFlags.FeatureFlags.cs`. Never cast a string to `FeatureFlagKey`; never accept a `string` parameter where `FeatureFlagKey` would do. Removing or renaming a flag in C# turns every stale callsite into a TS compile error after the next backend build — that compile error is the safety net. + +The codegen also enforces backend-before-frontend deploy order: the frontend build cannot reference a flag the backend hasn't shipped because the union is regenerated from the C# manifest. + +## The Hook Doesn't Subscribe — `AuthenticationProvider` Does + +`useFeatureFlag` reads `useUserInfo().featureFlags`. The bridge that turns the `x-user-feature-flags` response header into a re-render lives in `AuthenticationProvider` and short-circuits on identical flag sets, so re-renders are cheap. **Every authenticated response is the eventing channel** — there is no push, no SSE, no polling, and there is no flag-specific TanStack query to invalidate. Don't call `queryClient.invalidateQueries` for flag changes. + +## System Flags Bypass the User Path + +For `Scope: "system"` flags the hook reads `import.meta.runtime_env[envVar]` and ignores `userInfo` entirely. The hook handles this transparently — just call `useFeatureFlag()` regardless of scope. There is no per-tenant or per-user override for a system flag; if you need that, the flag is the wrong subtype on the backend. + +## Propagation Has a 5-Minute Floor + +Flag state lags behind a back-office or self-service toggle by up to the 5-minute access-token TTL. The mutation response carries `x-user-feature-flags` only when the mutating endpoint chains `AddRefreshAuthenticationTokens()` AND the gateway's endpoint-triggered refresh succeeds. Don't optimistically update for flag-driven UI; the response is the source of truth. If the backend was transiently unavailable during the refresh, the gateway suppresses `x-user-feature-flags` rather than emit the stale claim — so a "no change visible after toggle" outcome is a possible (rare) UI state. + +## Labels Are Codegen Too + +Display copy lives in `@repo/ui/featureFlags/labels` (`labels.generated.ts`), sourced from each `FeatureFlagDefinition.Label` / `Description`. Don't write parallel Lingui strings for flag names in components — call `getFeatureFlagLabel(key)`. The labels participate in the shared Lingui catalog under `shared-webapp/ui/translations/locale/`; translate there, not in the per-system catalog. + +## Where the Surfaces Live + +- Owner self-service: `/account/settings` (Features section) — `TenantOwnerConfigurableFlag` only. +- User preferences: same area — `UserConfigurableFlag` only. +- Back-office admin: `/feature-flags/{key}` — every scope. + +Orphaned and soft-deleted flags surface read-only in the back-office. They never reach `useFeatureFlag` (the codegen union drops them on the next backend build), so call sites don't need a "deleted flag" branch. diff --git a/.claude/skills/db-query/SKILL.md b/.claude/skills/db-query/SKILL.md index 4c20fb527..19575bddf 100644 --- a/.claude/skills/db-query/SKILL.md +++ b/.claude/skills/db-query/SKILL.md @@ -5,6 +5,8 @@ description: Query the local Postgres database of the active Aspire worktree via # Database Query +**Port = `.workspace/port.txt` base + 2. Never trust Aspire MCP for the port — a common critical failure that silently runs SQL on another worktree's database.** + **Read-only. Every write needs explicit user approval for that exact statement, every time — prior approvals never carry over.** **For destructive operations (DROP, TRUNCATE, DELETE, ALTER, or anything that loses data), take extreme care. If anything in the request is even slightly unclear about the scope, target, or intent, stop and ask for clarification before executing. Always assume the most conservative interpretation.** diff --git a/.gitignore b/.gitignore index cf072a5ae..b3b424ed7 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,7 @@ $tf/ # Visual Studio Code .vscode +*.lscache # ReSharper is a .NET coding add-in _ReSharper*/ @@ -392,6 +393,7 @@ FodyWeavers.xsd **/package.g.props **/*.generated.d.ts **/*.generated.ts +**/*.generated.json **/Api/swagger.json **/lib/api/*.Api.json **/lib/api/*.Api_*.json diff --git a/AGENTS.md b/AGENTS.md index 23850f3a0..5a28de9b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Run `build` first, then `format`, `lint`, `test` in parallel with `--no-build`. **Slow:** Aspire restart, backend format, backend lint, end-to-end tests. **Fast:** frontend format/lint, backend test. -**Aspire**: The `aspire-restart` skill manages the AppHost - always use it; never `aspire run`, `aspire restart`, or the developer CLI's `run` command. Use the Aspire MCP `list_resources` tool to look up service URLs (or read `.workspace/port.txt` if you only need the base port). In the agentic workflow, only the Guardian agent restarts Aspire. All other agents must notify the Guardian if they need it restarted. +**Aspire**: The `aspire-restart` skill manages the AppHost - always use it; never `aspire run`, `aspire restart`, or the developer CLI's `run` command. Port = `.workspace/port.txt` base + 2. Never trust Aspire MCP for the port — a common critical failure that silently runs SQL on another worktree's database. If you need other Aspire MCP data, call `mcp__aspire__select_apphost` with the cwd path first. In the agentic workflow, only the Guardian agent restarts Aspire. All other agents must notify the Guardian if they need it restarted. Never commit, amend, or revert without explicit user instruction each time. Commit messages: one descriptive line in imperative form, no description body. diff --git a/README.md b/README.md index 806a59ede..41ce6c577 100644 --- a/README.md +++ b/README.md @@ -22,37 +22,56 @@ Kick-start building top-tier B2B & B2C cloud SaaS products with sleek design, fully localized and accessible, vertical slice architecture, automated and fast DevOps, and top-notch security. +Ships with signup and login via Google or email one-time password, Stripe-powered subscription and payment management with plan upgrades, downgrades, and invoicing, feature flags with A/B-rollout, plan-gating, and per-user/tenant overrides, and a back-office dashboard with MRR and revenue trends, plan distribution, and tenant growth. + Built to demonstrate seamless flow: backend contracts feed a fully-typed React UI, pipelines make fully automated deployments to Azure, and a multi-agent workflow built on Claude Code's native [Agent Teams](https://code.claude.com/docs/en/agent-teams) where PlatformPlatform-expert agents collaborate to deliver complete features following the opinionated architecture. Think of it as a ready-made blueprint, not a pile of parts to assemble. ## What's inside -* **Backend** - .NET 10 and C# 14 adhering to the principles of vertical slice architecture, DDD, CQRS, and clean code +* **Backend** - .NET 10 and C# 14 following the principles of vertical slice architecture, DDD, CQRS, and clean code * **Frontend** - React 19, TypeScript, TanStack Router & Query, ShadCN 2.0 with Base UI for accessible UI * **CI/CD** - GitHub actions for fast passwordless deployments of docker containers and infrastructure (Bicep) -* **Infrastructure** - Cost efficient and scalable Azure PaaS services like Azure Container Apps, Azure PostgreSQL, etc. +* **Infrastructure** - Cost efficient and scalable Azure PaaS like Azure Container Apps and PostgreSQL * **Developer CLI** - Extendable .NET CLI for DevEx - set up CI/CD is one command and a couple of questions -* **AI rules** - 30+ rules & workflows for Claude Code - sync to other editors can be enabled via `.gitignore` +* **AI rules** - 30+ rules & skills, refined and battle-tested over a year of daily use, capturing our opinionated patterns * **Multi-agent development** - Agent Teams workflow where specialized Claude Code agents with deep PlatformPlatform expertise collaborate end-to-end -Follow our [up-to-date roadmap](https://github.com/orgs/PlatformPlatform/projects/2/views/2) with core SaaS features like SSO, monitoring, alerts, multi-region, feature flags, back office for support, etc. +Follow our [up-to-date roadmap](https://github.com/orgs/PlatformPlatform/projects/2/views/2). Show your support for our project - give us a star on GitHub! It truly means a lot! ⭐ ### Back office -Operate the platform: manage account signups, users, and logins, and monitor revenue, MRR, churn, invoices, and Stripe drift. +Operate the platform from a dedicated SPA on its own hostname, secured by Entra ID Easy Auth: + +* **Dashboard** - KPI tiles for total accounts, blended MRR, all-time revenue, active users, and live sessions; trend cards for MRR, revenue, tenant growth, plan distribution, user logins; activity feeds for recent signups, payments, logins, and Stripe webhook events +* **Accounts** - Search and filter every tenant; drill into detail with owner, plan, and signup activity +* **Users** - Cross-tenant user list with role and last-seen filters; drill into per-user profile and tenant +* **Invoices** - Paginated invoice ledger across every account with Stripe drift detection so finance can reconcile what's in Stripe vs. what landed in the database +* **Billing events** - Authoritative event log of subscription, payment, and billing transitions, filterable by event type and account, with deep-link from dashboard cards +* **Feature flags** - Four levers (System / Subscription plan / Account / User) plus A/B-rollout with rich telemetry; declared in C# and surfaced as a strongly-typed React hook; back-office UI for rollouts and overrides PlatformPlatform Back Office -### Product demo +### User-facing SaaS product -End-user flows: tenant signup, account settings, Google login, welcome flow, accessibility and localization, and Stripe-powered subscription signup and management. +Production-ready end-user surfaces — fully localized, accessible, and ready to brand as your own product: + +* **Signup** - Tenant signup with email one-time password or Google OAuth (OpenID Connect with PKCE) +* **Login** - Same OTP and Google sign-in flows, with `UNLOCK` shortcut on localhost so dev mail is optional +* **Welcome** - First-run guided flow for naming the account, uploading a logo, and inviting colleagues +* **Account settings** - Owner-editable account name, logo, and danger-zone account deletion +* **User management** - Invite users, change roles (Owner/Admin/Member), bulk delete, and recycle-bin restore +* **Subscription & billing** - Embedded Stripe Checkout & Payment Element, prorated upgrades/downgrades, billing-info editing, scheduled-downgrade banner, dunning, and payment history with invoices and credit notes +* **User profile** - Personal profile with avatar upload (Gravatar fallback), first/last name, email, and job title +* **User preferences** - Theme (light/dark/system) and zoom per device, language per profile +* **Sessions** - Active session list with device type, browser, and OS, plus one-click revocation of any session PlatformPlatform Demo # Getting Started -TL;DR: Open the [PlatformPlatform](./application/PlatformPlatform.slnx) solution in Rider or Visual Studio and run the [Aspire AppHost](./application/AppHost/AppHost.csproj) project. +TL;DR: Requires .NET 10, Node, and Docker. Clone the repo and `dotnet run` from `developer-cli/` to start Aspire. ### Prerequisites @@ -283,8 +302,6 @@ cd application/AppHost dotnet run ``` -Alternatively, open the [PlatformPlatform](./application/PlatformPlatform.slnx) solution in Rider or Visual Studio and run the [Aspire AppHost](./application/AppHost/AppHost.csproj) project. - On first startup, Aspire will prompt for `stripe-enabled` -- enter `true` to configure Stripe integration (see the optional Stripe setup section below) or `false` to skip. Once the Aspire dashboard fully loads, click to the WebApp and sign up for a new account (https://app.dev.localhost:9000/signup). A one-time password (OTP) will be sent to the development mail server, but for local development, you can always use the code `UNLOCK` instead of checking the mail server. diff --git a/application/AppGateway.Tests/AuthenticationCookiePathTests.cs b/application/AppGateway.Tests/AuthenticationCookiePathTests.cs index bd1cba95e..cff37ab5f 100644 --- a/application/AppGateway.Tests/AuthenticationCookiePathTests.cs +++ b/application/AppGateway.Tests/AuthenticationCookiePathTests.cs @@ -1,7 +1,13 @@ +using System.Net; +using System.Security.Claims; using AppGateway.Middleware; using FluentAssertions; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using SharedKernel.Authentication; @@ -13,31 +19,25 @@ namespace AppGateway.Tests; public sealed class AuthenticationCookiePathTests(AppGatewayApplicationFactory factory) : IClassFixture { [Fact] - public async Task ReplaceAuthenticationHeaderWithCookie_WhenInnerHandlerSetsTokenHeaders_ShouldIssueHostCookiesWithPathSlash() + public async Task InvokeAsync_WhenUpstreamResponseCarriesTokenHeaders_ShouldIssueHostCookiesWithPathSlash() { // Arrange await using var scope = factory.Services.CreateAsyncScope(); var middleware = scope.ServiceProvider.GetRequiredService(); var signingClient = scope.ServiceProvider.GetRequiredService(); - - var refreshToken = CreateSignedToken(signingClient, 60); - var accessToken = CreateSignedToken(signingClient, 5); - - var context = new DefaultHttpContext - { - Request = { Path = "/api/account/authentication/switch-tenant" }, - Response = { Body = new MemoryStream() } - }; - - Task Next(HttpContext ctx) - { - ctx.Response.Headers[AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey] = refreshToken; - ctx.Response.Headers[AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey] = accessToken; - return Task.CompletedTask; - } + var refreshToken = CreateSignedToken(signingClient, 60, []); + var accessToken = CreateSignedToken(signingClient, 5, []); + var context = CreateHttpContext("/api/account/authentication/switch-tenant"); // Act - await middleware.InvokeAsync(context, Next); + await middleware.InvokeAsync(context, downstream => + { + downstream.Response.Headers[AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey] = refreshToken; + downstream.Response.Headers[AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey] = accessToken; + return Task.CompletedTask; + } + ); + await TriggerOnStartingAsync(context); // Assert var setCookieHeaders = context.Response.Headers.SetCookie.ToArray(); @@ -50,28 +50,20 @@ Task Next(HttpContext ctx) } [Fact] - public async Task ExpiredRefreshToken_WhenMiddlewareDeletesCookies_ShouldIssueDeletionWithPathSlash() + public async Task InvokeAsync_WhenRefreshTokenCookieHasExpired_ShouldDeleteCookiesWithPathSlash() { // Arrange await using var scope = factory.Services.CreateAsyncScope(); var middleware = scope.ServiceProvider.GetRequiredService(); var signingClient = scope.ServiceProvider.GetRequiredService(); - - var expiredRefreshToken = CreateSignedToken(signingClient, -10); - var expiredAccessToken = CreateSignedToken(signingClient, -10); - - var context = new DefaultHttpContext - { - Request = - { - Path = "/some-spa-path", - Headers = { Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={expiredRefreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={expiredAccessToken}" } - }, - Response = { Body = new MemoryStream() } - }; + var expiredRefreshToken = CreateSignedToken(signingClient, -10, []); + var expiredAccessToken = CreateSignedToken(signingClient, -10, []); + var context = CreateHttpContext("/some-spa-path"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={expiredRefreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={expiredAccessToken}"; // Act await middleware.InvokeAsync(context, _ => Task.CompletedTask); + await TriggerOnStartingAsync(context); // Assert var setCookieHeaders = context.Response.Headers.SetCookie.ToArray(); @@ -82,7 +74,212 @@ public async Task ExpiredRefreshToken_WhenMiddlewareDeletesCookies_ShouldIssueDe } } - private static string CreateSignedToken(ITokenSigningClient signingClient, int validForMinutes) + [Fact] + public async Task InvokeAsync_WhenAuthenticatedRequestHasFeatureFlagsClaim_ShouldEmitUserFeatureFlagsHeader() + { + // Arrange + await using var scope = factory.Services.CreateAsyncScope(); + var middleware = scope.ServiceProvider.GetRequiredService(); + var signingClient = scope.ServiceProvider.GetRequiredService(); + var refreshToken = CreateSignedToken(signingClient, 60, []); + var accessToken = CreateSignedToken(signingClient, 5, [new Claim(AuthenticationTokenHttpKeys.FeatureFlagsClaimName, "account-overview,compact-view")]); + var context = CreateHttpContext("/api/account/me"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={refreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={accessToken}"; + + // Act + await middleware.InvokeAsync(context, _ => Task.CompletedTask); + await TriggerOnStartingAsync(context); + + // Assert + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be("account-overview,compact-view"); + } + + [Fact] + public async Task InvokeAsync_WhenAccessTokenHasNoFeatureFlagsClaim_ShouldOmitUserFeatureFlagsHeader() + { + // Arrange + await using var scope = factory.Services.CreateAsyncScope(); + var middleware = scope.ServiceProvider.GetRequiredService(); + var signingClient = scope.ServiceProvider.GetRequiredService(); + var refreshToken = CreateSignedToken(signingClient, 60, []); + var accessToken = CreateSignedToken(signingClient, 5, []); + var context = CreateHttpContext("/api/account/me"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={refreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={accessToken}"; + + // Act + await middleware.InvokeAsync(context, _ => Task.CompletedTask); + await TriggerOnStartingAsync(context); + + // Assert + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey); + } + + [Fact] + public async Task InvokeAsync_WhenAccessTokenHasEmptyFeatureFlagsClaim_ShouldEmitEmptyUserFeatureFlagsHeader() + { + // Arrange + await using var scope = factory.Services.CreateAsyncScope(); + var middleware = scope.ServiceProvider.GetRequiredService(); + var signingClient = scope.ServiceProvider.GetRequiredService(); + var refreshToken = CreateSignedToken(signingClient, 60, []); + var accessToken = CreateSignedToken(signingClient, 5, [new Claim(AuthenticationTokenHttpKeys.FeatureFlagsClaimName, string.Empty)]); + var context = CreateHttpContext("/api/account/me"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={refreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={accessToken}"; + + // Act + await middleware.InvokeAsync(context, _ => Task.CompletedTask); + await TriggerOnStartingAsync(context); + + // Assert + context.Response.Headers.Should().ContainKey(AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey); + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().BeEmpty(); + } + + [Fact] + public async Task InvokeAsync_WhenUpstreamSetsRefreshAuthenticationTokensHeader_ShouldEmitHeaderReflectingPostRefreshJwt() + { + // Arrange + await using var stubFactory = new RefreshStubAppGatewayApplicationFactory(); + var middleware = stubFactory.Services.GetRequiredService(); + var signingClient = stubFactory.Services.GetRequiredService(); + var inboundRefreshToken = CreateSignedToken(signingClient, 60, []); + var preRefreshAccessToken = CreateSignedToken(signingClient, 5, [new Claim(AuthenticationTokenHttpKeys.FeatureFlagsClaimName, "stale-flag")]); + var postRefreshAccessToken = CreateSignedToken(signingClient, 5, [new Claim(AuthenticationTokenHttpKeys.FeatureFlagsClaimName, "account-overview")]); + var postRefreshRefreshToken = CreateSignedToken(signingClient, 60, []); + RefreshStubAppGatewayApplicationFactory.SetStubResponse(postRefreshRefreshToken, postRefreshAccessToken); + var context = CreateHttpContext("/api/account/feature-flags/account-overview/tenant-override"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={inboundRefreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={preRefreshAccessToken}"; + + // Act + await middleware.InvokeAsync(context, downstream => + { + downstream.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; + return Task.CompletedTask; + } + ); + await TriggerOnStartingAsync(context); + + // Assert + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be("account-overview"); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + var setCookieHeaders = context.Response.Headers.SetCookie.ToArray(); + setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.RefreshTokenCookieName)); + setCookieHeaders.Should().Contain(h => h!.Contains(AuthenticationTokenHttpKeys.AccessTokenCookieName)); + } + + [Fact] + public async Task InvokeAsync_WhenInlineRefreshPrecedesEndpointTriggeredRefresh_ShouldUseRotatedRefreshTokenForSecondCall() + { + // Arrange - expired access token forces inline refresh on the inbound path. The downstream + // endpoint then signals x-refresh-authentication-tokens-required, triggering a second refresh. + // Without M8 the second call would reuse the v=1 cookie value and fall back on the 30-second + // grace window; with M8 the second call uses the v=2 token returned from the first refresh. + await using var stubFactory = new RefreshStubAppGatewayApplicationFactory(); + var middleware = stubFactory.Services.GetRequiredService(); + var signingClient = stubFactory.Services.GetRequiredService(); + var inboundRefreshTokenV1 = CreateSignedToken(signingClient, 60, []); + var expiredAccessToken = CreateSignedToken(signingClient, -1, []); + var rotatedRefreshTokenV2 = CreateSignedToken(signingClient, 60, []); + var inlineRefreshedAccessToken = CreateSignedToken(signingClient, 5, []); + var finalRefreshToken = CreateSignedToken(signingClient, 60, []); + var finalAccessToken = CreateSignedToken(signingClient, 5, [new Claim(AuthenticationTokenHttpKeys.FeatureFlagsClaimName, "account-overview")]); + RefreshStubAppGatewayApplicationFactory.SetStubResponse(rotatedRefreshTokenV2, inlineRefreshedAccessToken); + RefreshStubAppGatewayApplicationFactory.EnqueueStubResponse(finalRefreshToken, finalAccessToken); + var context = CreateHttpContext("/api/account/me/change-locale"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={inboundRefreshTokenV1}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={expiredAccessToken}"; + + // Act + await middleware.InvokeAsync(context, downstream => + { + downstream.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; + return Task.CompletedTask; + } + ); + await TriggerOnStartingAsync(context); + + // Assert + var bearerTokens = RefreshStubAppGatewayApplicationFactory.ReceivedBearerTokens; + bearerTokens.Should().HaveCount(2, "both an inline refresh and an endpoint-triggered refresh fired"); + bearerTokens[0].Should().Be(inboundRefreshTokenV1, "the inline refresh uses the cookie's v=1 token"); + bearerTokens[1].Should().Be(rotatedRefreshTokenV2, "the endpoint-triggered refresh must use the v=2 token returned from the inline refresh, not the stale v=1"); + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey].ToString().Should().Be("account-overview"); + } + + [Fact] + public async Task InvokeAsync_WhenEndpointTriggeredRefreshHitsBackendOutage_ShouldOmitUserFeatureFlagsHeader() + { + // Arrange - downstream PUT succeeds and signals endpoint-triggered refresh. The refresh call to + // the account backend throws HttpRequestException (simulating a transient outage). The middleware + // logs and lets the mutation response through, but must NOT emit x-user-feature-flags from the + // pre-refresh access token — that would mislead the SPA into "your toggle didn't take effect". + await using var stubFactory = new RefreshStubAppGatewayApplicationFactory(); + var middleware = stubFactory.Services.GetRequiredService(); + var signingClient = stubFactory.Services.GetRequiredService(); + var inboundRefreshToken = CreateSignedToken(signingClient, 60, []); + var preRefreshAccessToken = CreateSignedToken(signingClient, 5, [new Claim(AuthenticationTokenHttpKeys.FeatureFlagsClaimName, "stale-flag")]); + RefreshStubAppGatewayApplicationFactory.SetStubBackendUnavailable(); + var context = CreateHttpContext("/api/account/feature-flags/stale-flag/tenant-override"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={inboundRefreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={preRefreshAccessToken}"; + + // Act + await middleware.InvokeAsync(context, downstream => + { + downstream.Response.StatusCode = StatusCodes.Status204NoContent; + downstream.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; + return Task.CompletedTask; + } + ); + await TriggerOnStartingAsync(context); + + // Assert + context.Response.StatusCode.Should().Be(StatusCodes.Status204NoContent, "the mutation response must pass through unchanged"); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey, "emitting the pre-refresh claim would tell the SPA the mutation had no effect"); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + } + + [Fact] + public async Task InvokeAsync_WhenRefreshEndpointSignalsSessionRevoked_ShouldOverwriteResponseWith401AndClearCookies() + { + // Arrange + await using var stubFactory = new RefreshStubAppGatewayApplicationFactory(); + var middleware = stubFactory.Services.GetRequiredService(); + var signingClient = stubFactory.Services.GetRequiredService(); + var inboundRefreshToken = CreateSignedToken(signingClient, 60, []); + var preRefreshAccessToken = CreateSignedToken(signingClient, 5, [new Claim(AuthenticationTokenHttpKeys.FeatureFlagsClaimName, "stale-flag")]); + RefreshStubAppGatewayApplicationFactory.SetStubRevoked("ReplayAttackDetected"); + var context = CreateHttpContext("/api/account/feature-flags/account-overview/tenant-override"); + context.Request.Headers.Cookie = $"{AuthenticationTokenHttpKeys.RefreshTokenCookieName}={inboundRefreshToken}; {AuthenticationTokenHttpKeys.AccessTokenCookieName}={preRefreshAccessToken}"; + + // Act + await middleware.InvokeAsync(context, downstream => + { + downstream.Response.Headers[AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey] = "true"; + return Task.CompletedTask; + } + ); + await TriggerOnStartingAsync(context); + + // Assert + context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey].ToString().Should().Be("ReplayAttackDetected"); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey); + context.Response.Headers.Should().NotContainKey(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + } + + private static DefaultHttpContext CreateHttpContext(string path) + { + var context = new DefaultHttpContext { Request = { Path = path }, Response = { Body = new MemoryStream() } }; + context.Features.Set(new CapturingResponseFeature()); + return context; + } + + private static Task TriggerOnStartingAsync(HttpContext context) + { + var feature = (CapturingResponseFeature)context.Features.GetRequiredFeature(); + return feature.TriggerOnStartingAsync(); + } + + private static string CreateSignedToken(ITokenSigningClient signingClient, int validForMinutes, Claim[] claims) { var now = DateTimeOffset.UtcNow; var notBefore = validForMinutes >= 0 ? now.UtcDateTime : now.AddMinutes(validForMinutes - 1).UtcDateTime; @@ -93,8 +290,111 @@ private static string CreateSignedToken(ITokenSigningClient signingClient, int v Expires = now.AddMinutes(validForMinutes).UtcDateTime, Issuer = signingClient.Issuer, Audience = signingClient.Audience, - SigningCredentials = signingClient.GetSigningCredentials() + SigningCredentials = signingClient.GetSigningCredentials(), + Subject = claims.Length == 0 ? null : new ClaimsIdentity(claims) }; return new JsonWebTokenHandler().CreateToken(descriptor); } + + /// + /// DefaultHttpContext's stock HttpResponseFeature treats OnStarting callbacks as no-ops because + /// the response is never actually started in a unit test. This replacement captures the + /// callbacks so the test can flush them after the downstream pipeline has set its response. + /// + private sealed class CapturingResponseFeature : HttpResponseFeature + { + private readonly List<(Func Callback, object State)> _onStartingCallbacks = []; + + public override void OnStarting(Func callback, object state) + { + _onStartingCallbacks.Add((callback, state)); + } + + public override void OnCompleted(Func callback, object state) + { + } + + public async Task TriggerOnStartingAsync() + { + foreach (var (callback, state) in _onStartingCallbacks) + { + await callback(state); + } + } + } +} + +internal sealed class RefreshStubAppGatewayApplicationFactory : WebApplicationFactory +{ + private static readonly Queue<(string Refresh, string Access)> ResponseQueue = new(); + private static readonly List ReceivedBearerTokensField = []; + private static string? _revokedReason; + private static bool _backendUnavailable; + + public static IReadOnlyList ReceivedBearerTokens => ReceivedBearerTokensField; + + public static void SetStubResponse(string refreshToken, string accessToken) + { + ResetStub(); + ResponseQueue.Enqueue((refreshToken, accessToken)); + } + + public static void EnqueueStubResponse(string refreshToken, string accessToken) + { + ResponseQueue.Enqueue((refreshToken, accessToken)); + } + + public static void SetStubRevoked(string revokedReason) + { + ResetStub(); + _revokedReason = revokedReason; + } + + public static void SetStubBackendUnavailable() + { + ResetStub(); + _backendUnavailable = true; + } + + private static void ResetStub() + { + ResponseQueue.Clear(); + ReceivedBearerTokensField.Clear(); + _revokedReason = null; + _backendUnavailable = false; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureLogging(logging => logging.AddFilter(_ => false)); + builder.ConfigureServices(services => { services.AddHttpClient("Account").ConfigurePrimaryHttpMessageHandler(() => new StubRefreshHandler()); } + ); + } + + private sealed class StubRefreshHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + ReceivedBearerTokensField.Add(request.Headers.Authorization?.Parameter); + + if (_backendUnavailable) + { + throw new HttpRequestException("Stub backend unavailable."); + } + + if (_revokedReason is not null) + { + var revoked = new HttpResponseMessage(HttpStatusCode.Unauthorized); + revoked.Headers.Add(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey, _revokedReason); + return Task.FromResult(revoked); + } + + var (refreshToken, accessToken) = ResponseQueue.Dequeue(); + var response = new HttpResponseMessage(HttpStatusCode.OK); + response.Headers.Add(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, refreshToken); + response.Headers.Add(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, accessToken); + return Task.FromResult(response); + } + } } diff --git a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs index ef6d72a23..70e2762ad 100644 --- a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs +++ b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs @@ -7,7 +7,7 @@ namespace AppGateway.Middleware; -public class AuthenticationCookieMiddleware( +public sealed class AuthenticationCookieMiddleware( ITokenSigningClient tokenSigningClient, IHttpClientFactory httpClientFactory, TimeProvider timeProvider, @@ -21,13 +21,18 @@ ILogger logger public async Task InvokeAsync(HttpContext context, RequestDelegate next) { - if (context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenCookieName, out var refreshTokenCookieValue)) + var tokenState = new TokenState(); + + if (context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenCookieName, out var refreshTokenFromCookie)) { + tokenState.InboundRefreshToken = refreshTokenFromCookie; context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenCookieName, out var accessTokenCookieValue); - await ValidateAuthenticationCookieAndConvertToHttpBearerHeader(context, refreshTokenCookieValue, accessTokenCookieValue); + tokenState.CurrentAccessToken = await ValidateAuthenticationCookieAndConvertToHttpBearerHeader(context, tokenState, accessTokenCookieValue); } - // If session was revoked during refresh, handle based on request type + // If session was revoked during the inbound cookie validation, short-circuit before reaching + // downstream. The OnStarting callback registered below will still emit x-user-feature-flags if + // a current token exists; in this revoked path we return early without a token, so no header. if (context.Items.TryGetValue(UnauthorizedReasonItemKey, out var reason) && reason is string unauthorizedReason) { if (context.Request.Path.StartsWithSegments("/api")) @@ -45,24 +50,87 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); } - await next(context); + // Single OnStarting hook for everything driven by the downstream response: header-mode + // cookie swap (login / signup / switch-tenant), endpoint-triggered refresh + // (x-refresh-authentication-tokens-required), session-revoked 401 override, and + // x-user-feature-flags emission from the current (possibly just-refreshed) access token. + // Sequential execution in one hook eliminates the race the split YARP-transform design had. + context.Response.OnStarting(async state => + { + var (httpContext, currentState) = ((HttpContext, TokenState))state; + await HandleOutgoingResponseAsync(httpContext, currentState); + }, (context, tokenState) + ); + await next(context); + } + private async Task HandleOutgoingResponseAsync(HttpContext context, TokenState tokenState) + { if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _)) { - logger.LogDebug("Refreshing authentication tokens as requested by endpoint"); - var (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshTokenCookieValue!); - await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken, accessToken); + // Endpoint-triggered refresh: the downstream signaled the actor's claims have changed + // (e.g. PUT /me, PUT /me/change-locale, PUT /api/account/feature-flags/{key}/tenant-override). + // Refresh the JWT now so the same response carries fresh cookies AND a fresh x-user-feature-flags. + if (tokenState.InboundRefreshToken is { } refreshToken) + { + try + { + logger.LogDebug("Refreshing authentication tokens as requested by endpoint"); + var (newRefreshToken, newAccessToken) = await RefreshAuthenticationTokensAsync(refreshToken); + await ReplaceAuthenticationHeaderWithCookieAsync(context, newRefreshToken, newAccessToken); + tokenState.CurrentAccessToken = newAccessToken; + } + catch (SessionRevokedException ex) + { + OverwriteWithUnauthorized(context, ex.RevokedReason); + logger.LogWarning(ex, "Session revoked during endpoint-triggered refresh. Reason: {Reason}", ex.RevokedReason); + return; + } + catch (SecurityTokenException ex) + { + OverwriteWithUnauthorized(context, nameof(UnauthorizedReason.SessionNotFound)); + logger.LogWarning(ex, "Endpoint-triggered token refresh failed validation. Path: {Path}", context.Request.Path); + return; + } + catch (HttpRequestException ex) + { + // Backend temporarily unreachable: the upstream mutation already succeeded, so + // let the response through. The SPA picks up new claims on the next refresh. The + // degraded flag below suppresses x-user-feature-flags emission so the SPA isn't + // told the pre-mutation flag set is current — that would look like the mutation + // didn't take effect. + logger.LogWarning(ex, "Backend unavailable during endpoint-triggered refresh. Path: {Path}", context.Request.Path); + tokenState.EndpointTriggeredRefreshFailedDegraded = true; + } + catch (TaskCanceledException ex) when (!context.RequestAborted.IsCancellationRequested) + { + logger.LogWarning(ex, "Backend timed out during endpoint-triggered refresh. Path: {Path}", context.Request.Path); + tokenState.EndpointTriggeredRefreshFailedDegraded = true; + } + } + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); } - else if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshToken) && - context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessToken)) + else if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshTokenHeader) && + context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessTokenHeader)) + { + // Login / signup / switch-tenant flows return the new tokens in response headers + // for AppGateway to convert into cookies before egress. + var newRefreshToken = refreshTokenHeader.Single()!; + var newAccessToken = accessTokenHeader.Single()!; + await ReplaceAuthenticationHeaderWithCookieAsync(context, newRefreshToken, newAccessToken); + tokenState.CurrentAccessToken = newAccessToken; + } + + if (tokenState is { EndpointTriggeredRefreshFailedDegraded: false, CurrentAccessToken: { } currentAccessToken } && + ExtractFeatureFlagsClaim(currentAccessToken) is { } featureFlagsClaim) { - await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken.Single()!, accessToken.Single()!); + context.Response.Headers[AuthenticationTokenHttpKeys.UserFeatureFlagsHeaderKey] = featureFlagsClaim; } } - private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(HttpContext context, string refreshToken, string? accessToken) + private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(HttpContext context, TokenState tokenState, string? accessToken) { if (context.Request.Headers.ContainsKey(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey) || context.Request.Headers.ContainsKey(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey)) @@ -73,6 +141,8 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http try { + var refreshToken = tokenState.InboundRefreshToken!; + if (accessToken is null || await ExtractExpirationFromTokenAsync(accessToken) < timeProvider.GetUtcNow()) { if (await ExtractExpirationFromTokenAsync(refreshToken) < timeProvider.GetUtcNow()) @@ -81,18 +151,25 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, expiredCookieOptions); context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, expiredCookieOptions); logger.LogDebug("The refresh-token has expired; authentication token cookies are removed"); - return; + return null; } logger.LogDebug("The access-token has expired, attempting to refresh"); (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshToken); + // Mirror the rotated refresh token onto tokenState so a downstream-triggered refresh in + // HandleOutgoingResponseAsync uses the v=2 jti, not the stale v=1 cookie value. Without + // this the second refresh fell back to the 30-second grace window in Session.IsRefreshTokenValid + // and would emit a spurious 401 once the gap exceeded the grace window. + tokenState.InboundRefreshToken = refreshToken; + // Update the authentication token cookies with the new tokens await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken, accessToken); } context.Request.Headers.Authorization = $"Bearer {accessToken}"; + return accessToken; } catch (SessionRevokedException ex) { @@ -124,6 +201,8 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http context.Items[UnauthorizedReasonItemKey] = nameof(UnauthorizedReason.SessionNotFound); logger.LogError(ex, "Unexpected exception during authentication token validation. Path: {Path}", context.Request.Path); } + + return null; } private async Task<(string newRefreshToken, string newAccessToken)> RefreshAuthenticationTokensAsync(string refreshToken) @@ -234,6 +313,43 @@ private async Task ExtractExpirationFromTokenAsync(string token) return DateTimeOffset.FromUnixTimeSeconds(long.Parse(expires)); } + + private static string? ExtractFeatureFlagsClaim(string accessToken) + { + if (!TokenHandler.CanReadToken(accessToken)) return null; + var jwt = TokenHandler.ReadJsonWebToken(accessToken); + return jwt.TryGetClaim(AuthenticationTokenHttpKeys.FeatureFlagsClaimName, out var claim) ? claim.Value : null; + } + + /// + /// Convert the upstream success response into a 401 with x-unauthorized-reason so the + /// SPA's authentication middleware can react (redirect to login, surface revocation reason). + /// + private static void OverwriteWithUnauthorized(HttpContext context, string unauthorizedReason) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey] = unauthorizedReason; + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey); + context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); + + var hostCookieOptions = new CookieOptions { Secure = true, Path = "/" }; + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, hostCookieOptions); + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); + } + + private sealed class TokenState + { + public string? CurrentAccessToken { get; set; } + + public string? InboundRefreshToken { get; set; } + + // Set when an endpoint-triggered refresh swallows a transient backend failure so the response + // does NOT emit x-user-feature-flags from the now-stale pre-refresh access token. Without this, + // the SPA would interpret a successful mutation + stale claim header as "the toggle didn't + // apply", causing flag-toggle UX confusion. + public bool EndpointTriggeredRefreshFailedDegraded { get; set; } + } } public sealed class SessionRevokedException(string revokedReason) : SecurityTokenException($"Session has been revoked. Reason: {revokedReason}") diff --git a/application/AppGateway/Transformations/BlockInternalApiTransform.cs b/application/AppGateway/Transformations/BlockInternalApiTransform.cs index e2157170f..79a1e097c 100644 --- a/application/AppGateway/Transformations/BlockInternalApiTransform.cs +++ b/application/AppGateway/Transformations/BlockInternalApiTransform.cs @@ -11,6 +11,9 @@ public override async ValueTask ApplyAsync(RequestTransformContext context) context.HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden; context.HttpContext.Response.ContentType = "text/plain"; await context.HttpContext.Response.WriteAsync("Access to internal API is forbidden."); + // Finalize the response so the YARP forwarder cannot pick up the request and forward it + // upstream after we've already written the 403 body. + await context.HttpContext.Response.CompleteAsync(); } } } diff --git a/application/account/Api/Account.Api.csproj b/application/account/Api/Account.Api.csproj index cc9eb042e..35b4cdf0d 100644 --- a/application/account/Api/Account.Api.csproj +++ b/application/account/Api/Account.Api.csproj @@ -41,6 +41,30 @@ + + + <_FeatureFlagsManifestPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-webapp\ui\featureFlags\featureFlags.generated.json')) + <_FeatureFlagsLabelsPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-webapp\ui\featureFlags\labels.generated.ts')) + <_FeatureFlagsRegistryPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-webapp\ui\featureFlags\registry.generated.ts')) + <_FeatureFlagsSourcePath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-kernel\SharedKernel\FeatureFlags\FeatureFlags.cs')) + <_FeatureFlagsCodegenScriptPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\shared-webapp\ui\scripts\generateFeatureFlagArtifacts.mjs')) + + + + + + + +