Skip to content

Account portal and unified sign-in look

What this is: the delivery plan for making the Angular app's My Account the single place where a signed-in person sees and manages how they sign in, and for making the Identity service's Razor pages look like part of the same application. It is a plan only. It changes no product code.

Decisions this plan implements (Chris, 2026-10-05):

  • Approach: a hub plus matching pages. My Account (src/services/web/src/app/manage/) shows status and actions for the password, linked social sign-ins (Google), passkeys, two-step verification (2FA) and, later, sessions. Security-sensitive operations stay on the Identity service's Razor pages (src/services/identity/SyRF.Identity.Endpoint/Pages/Account/Manage/*): passkey add/remove, 2FA enable/disable/reset, Google link/unlink and emailed step-up. Those pages are restyled to look like the app and return to My Account afterwards. The reasons:
  • the passkey relying-party ID is the Identity host (Program.cs:117-124, IdentityPasskeyOptions.ServerDomain = issuer.Host), so passkey ceremonies must run there;
  • Google linking must redirect through Identity;
  • this route adds no new security-sensitive JSON APIs.
  • Timing: slices 1–3 land before M005 S13, the production user switch to OpenIddict. M005 S10–S12 proceed in parallel. Slice 4 follows slices 1–3 and is not an S13 prerequisite. See Decisions, D1.
  • Four slices, as set out below.

Contents

Current state (verified 2026-10-05)

All paths and line numbers below were read at main af2d8e960.

Area Fact Where
My Account routes profile, password, social-sign-in, deactivate-account, all inside ManageComponent's side nav web/src/app/manage/manage.routes.ts
Social sign-in under OpenIddict Link and unlink effects are filtered to authProvider === 'auth0', and the component hides the buttons for any other provider. On OpenIddict environments the page can show status but cannot act. core/auth/auth.effects.ts:385-460, manage/social-sign-in/social-sign-in.component.ts (canManageConnections)
Stale config manageAccountUrl is ${stsUrl}/Identity/Account/Manage, a legacy path that does not exist on the Identity service. Nothing in the web app outside config reads it. core/services/config/app-config.service.ts:169, app-settings.interface.ts:86
Read-only status API Web → API GET api/account/account-management → Identity admin GET {userId}/account-management (admin scope, server-to-server) returns NativeAccountManagementResponse { LinkedIdentities[] } only api/.../Controllers/AccountController.cs:82-120, identity/.../Controllers/AdminApiController.cs:123-145,1030-1037, api/.../Models/Account/AccountManagementDto.cs
Password page Only "send reset email" through the API. No change-password action. manage/password/password.component.*
Identity change-password page Pages/Account/ChangePassword exists but is only the forced Auth0-migration reset: it sets a password without the current one and refuses (redirects) any user without RequiresPasswordReset. It is not a general change-password page and must not be linked from My Account. ChangePassword.cshtml.cs:40-72
Identity Manage pages ExternalLogins, Passkeys, TwoFactor, StepUp. All [Authorize], all Cache-Control: no-store. Mutations are antiforgery-protected POSTs. Step-up return URLs are Identity-local only (AdmissionContinuationService.IsLocalReturnUrl). There is no Manage index page and no way back to SyRF (#4025). Pages/Account/Manage/*.cshtml.cs
Identity root A signed-in visitor to / is redirected to AppSettings:UiUrl, which is fixed config validated as an absolute HTTPS origin. This is the existing safe pattern to reuse. Program.cs:441-462, Configuration/IdentityHostOptions.cs:204
Identity look One hand-rolled stylesheet, wwwroot/css/syrf-auth.css (1,163 lines). Tokens are "ported verbatim" from a design-system file, so they are copies, not derived from the app palette. Two shells: split (pre-auth, navy brand panel) and plain (signed-in card). Dark mode follows prefers-color-scheme, or data-theme on <html>. syrf-auth.css:1-120, Pages/Shared/_Layout.cshtml, AuthLayoutModels.cs
App palette Generated by ng generate @angular/material:theme-color from primary #203457 and secondary #c6cad2 web/src/global-styles/_m3-palettes.scss
App theme mode light/dark/system lives in app-origin localStorage (syrf-theme-mode) behind the themeToggle flag. Identity, on another origin, cannot read it. core/services/theme/theme.service.ts
FEAT-023 scope Visual parity for the Identity Razor app is out of scope (README). Principle 3: Identity may share reviewed source colour values but must not own or override Angular runtime roles. Wave 6 already asks to "reconcile shared source values with Identity without coupling Angular to Identity CSS". docs/features/material-3-migration/README.md:55-57, technical-plan.md:55-56,254

Cross-cutting rules

These apply to every slice.

  1. No new credential-mutating endpoints. Password, passkey, 2FA, linked-identity and step-up changes happen only through the existing antiforgery-protected Identity Razor POST handlers. The app never POSTs to Identity. Deep links are top-level GET navigations. A GET may preselect or highlight an action on the page but must never perform one.
  2. The read-only status API stays server-to-server. Browser → API (BFF cookie) → Identity admin API (client credentials, admin scope). The browser never calls the Identity admin API. CORS on Identity is not widened.
  3. Return-to-app is a validated relative path only. See the contract. An absolute URL from a request is never followed.
  4. Provider-aware UI. In the web app the native provider runs in authProvider === 'bff' mode; there is no 'openiddict' mode. The BFF's own provider (BffAuth:Provider) decides whether the backing IdP is OpenIddict. The hub's Identity hand-offs and Identity-backed status appear only in bff mode with the OpenIddict BFF provider: the API hand-off endpoint returns 404 for any other provider. Auth0 environments (production until M005 S13) keep today's behaviour unchanged. Elsewhere in this plan, "under OpenIddict" means exactly this combination.
  5. Material 3 under FEAT-023's contract. Every new or changed screen uses --mat-sys-* roles (Angular) or the shared generated tokens (Identity), works in light and dark, and is accepted by Chris on a preview environment before merge.
  6. No PII in logs or URLs. Never log or put in a query string: email addresses, passkey names, credential IDs, external account IDs or user IDs. Identity logging keeps using BeginPiiScope/Pii* properties (M005-R06/S29). The API logs outcome categories only.
  7. Docs ship with behaviour. Each slice updates /docs/ and /user-guide/ in the same PR ("stale docs block merges").
  8. Do not break BFF silent sign-in. #4097 (prompt=none silent sign-in) and #4104 (sliding sessions and the hidden-iframe variant) are merged. The hub and the restyled Identity pages must keep them working:
  9. The Security page treats an API 401 as "session ending", not as "status unavailable". It lets the interceptor's silent restore run and retry. It shows the error state only for a non-401 failure or after the restore has failed.
  10. Linking Google can revoke the BFF refresh token, and removing a sign-in ends the Identity session. Coming back to the hub after either must end in one of two states:

    • a silently restored session;
    • the normal signed-out state, with the syrf-signed-out and syrf-silent-auth guards intact.

    It must never loop or end in an interactive sign-in the user did not ask for. - New cookies use distinct names and never touch syrf-signed-out, syrf-silent-auth or the BFF session and PKCE cookies. - Identity layout, theme and header changes must not alter the prompt=none authorize response or its frame-ancestors exemption (identitySilentSignInFrame). Every other page keeps X-Frame-Options: SAMEORIGIN. SilentAuthorizationFramingTests stay green. - "Not you? Sign out" on Identity ends the Identity session only. When the BFF session later ends, silent sign-in must fall back to signed out. That is the existing behaviour, and slice 3 must keep it.

Return-to-app contract

Slice 1 (#4078) implemented this contract, and #4025 asks for it. Every later Identity page reuses that implementation rather than adding its own.

  • Parameter: returnTo, a relative path within the SyRF web app such as /manage/security or /manage/social-sign-in.
  • Validation: Identity Services/AppReturnPath.cs (Normalize, ToAppUrl), mirrored in the API's Auth/BffAccountPortal.cs. A value is accepted only if:
  • it starts with exactly one / (no // prefix);
  • it is at most 512 characters;
  • it contains no backslash anywhere (browsers treat \ as /), no whitespace and no control character.

Anything else is dropped, not repaired. A page given an unusable returnTo behaves exactly as it does without one, so it shows no "Back to SyRF" link. - Resolution: the path is joined to the configured web app origin AppSettingsConfig:UiUrl, which must be absolute HTTPS (HTTP only for a loopback development host) with no user-info. The destination origin therefore always comes from configuration, never from the request. Because the origin is fixed, a route-prefix allowlist such as /manage/ only would add no protection and is not used: the worst a crafted value can do is name another page inside SyRF. - Rendering only, never a redirect: Identity uses the resolved URL only as the href of a "Back to SyRF" link (and, from slice 2, the "Back to My Account" button). No Identity handler issues a redirect to it, so it cannot become an open redirect. - Carried in the request, not a cookie: the value travels as a query or form field. Through step-up it rides inside the Identity-local returnUrl (which stays Identity-local exactly as today); through the Google link round trip it rides on the callback route values. There is no server-side or cookie persistence. - App → Identity hand-off: the web app never builds Identity URLs itself. It navigates the top-level window to the API endpoint GET /api/auth/account/external-logins?returnTo=… (BffAuthController). That endpoint normalises returnTo and redirects to {BffAuth:Authority}/Account/Manage/ExternalLogins; the target host comes from configuration. It returns 404 unless the BFF provider is OpenIddict. Slice 2 generalises this endpoint to the Passkeys, TwoFactor and Security pages. - Use: the shared header renders "Back to SyRF" with the resolved URL. A page that completes a single action the user arrived for (link, unlink, add passkey, enable/disable 2FA) shows its success notice with a primary "Back to My Account" button. It does not auto-redirect: the user should see the confirmation on the page where the change happened (decision D3). - Theme hint (slice 3): optional theme=light|dark, allowlisted, passed through the same hand-off endpoint. Identity writes it to an Identity-origin preference cookie (display only, no security effect) and sets data-theme. Any other value is ignored.

Slice 1: social sign-in works under OpenIddict (in progress)

Status: being implemented by a separate worker in #4078 (draft; branch codex/openiddict-social-sign-in-management-shafq7). This section is the acceptance contract that PR should meet.

MVP boundary: on OpenIddict environments the existing /manage/social-sign-in page shows the real linked identities and its Connect/Disconnect buttons open the Identity ExternalLogins page, which can take the user back to the same My Account page. Auth0 behaviour is unchanged. No restyle, no new status fields. /manage/social-sign-in is the interim OpenIddict page: slice 2 folds it into /manage/security and turns the route into a redirect (decision D2).

Files touched (expected):

  • Web:
  • core/auth/auth.effects.ts and the social sign-in component (in bff mode, Connect and Disconnect navigate to the API hand-off endpoint; the Auth0 effects stay filtered to 'auth0');
  • manage/social-sign-in/social-sign-in.component.{ts,html,spec.ts};
  • core/services/config/app-config.service.ts and app-settings.interface.ts: correct or delete the stale manageAccountUrl (the hand-off endpoint makes it unnecessary);
  • auth.effects.spec.ts.
  • Identity:
  • Pages/Account/Manage/ExternalLogins.cshtml{,.cs} (accept returnTo, render "Back to SyRF");
  • Services/AppReturnPath.cs and its tests.
  • API:
  • Auth/BffAccountPortal.cs and Auth/BffAuthController.cs (GET api/auth/account/external-logins);
  • BffAccountPortalRedirectTests.

Feature flag: none new. The behaviour sits behind the existing authProvider configuration, which is itself the M005-controlled switch. Production stays on Auth0 until S13, so this is dark in production on merge.

Acceptance criteria:

# Criterion Verification
1.1 In bff mode with the OpenIddict BFF provider, /manage/social-sign-in lists Google as Connected or Not connected from api/account/account-management Web unit spec (effects + component); Playwright on the local E2E stack with Identity
1.2 In bff mode, Connect and Disconnect navigate the top-level window to /api/auth/account/external-logins?returnTo=%2Fmanage%2Fsocial-sign-in. That endpoint redirects to {BffAuth:Authority}/Account/Manage/ExternalLogins with the normalised returnTo, and returns 404 unless the BFF provider is OpenIddict. No Auth0 SDK call and no management-token request is made. Web unit spec (navigation target; Auth0 effects do not fire); API BffAccountPortalRedirectTests (redirect target, 404 for Auth0/disabled BFF)
1.3 Under Auth0, link/unlink behaviour and requests are byte-for-byte unchanged Existing Auth0 effect specs stay green, unmodified
1.4 ExternalLogins accepts a valid returnTo and renders a "Back to SyRF" link to UiUrl + path. The value is used only as that href, never as a redirect target. Identity page test (WebApplicationFactory)
1.5 Hostile returnTo values are dropped (no "Back to SyRF" link; the API hand-off omits them) and never reach a rendered href or a Location header: https://evil.example, //evil.example, /\evil.example, javascript:alert(1), a value with whitespace or a control character, and a 513-character path. A UiUrl that is not HTTPS (non-loopback) or carries user-info yields no link. Identity AppReturnPath unit tests and API BffAccountPortal tests over the same hostile corpus
1.6 returnTo survives the step-up and the Google round trip in query/form fields (inside step-up's Identity-local returnUrl; on the link callback's route values): after linking, "Back to SyRF" still targets /manage/social-sign-in Identity integration test with a fake external scheme; live staging check after S09/S27
1.7 manageAccountUrl no longer points at /Identity/Account/Manage Config unit spec; grep shows no reference to Identity/Account/Manage
1.8 Returning to the app re-fetches account-management (no stale "Not connected") Web unit spec: entering the route dispatches loadAccountManagement
1.9 No new log line contains the return path, email or provider key Identity sensitive-log test (existing *SensitiveLog* pattern)

Tests: Web pnpm exec ng test (not bare vitest) for effects, component and config. Identity xUnit for return-path validation and the page. Playwright on the local OpenIddict E2E stack for 1.1/1.2. A live check on staging once staging runs OpenIddict.

Docs:

  • user-guide/account/modify-account.md: new "Linking Google to your account" section explaining that SyRF opens a secure SyRF sign-in page and asks you to confirm who you are.
  • docs/architecture/identity-email-step-up.md: note the returnTo contract.
  • docs/how-to/enable-bff-auth.md: config key change, if manageAccountUrl is renamed.

Risks:

  • Open redirect through returnTo. Mitigated by the contract and test 1.5.
  • Account mismatch. The Identity cookie may belong to a different person than the app session in the same browser. Mitigation: the ExternalLogins page already shows the account. Slice 3's header adds "Signed in as" and "Not you? Sign out", which is the accepted guard (decision D4).
  • Cross-origin cookies. The Identity session and step-up proof cookies are SameSite=Lax, so they travel on the top-level GET navigation from the app. If the Identity session has expired, [Authorize] sends the user to sign in and back to the original page URL, which still carries returnTo in its query string.

Slice 2: security hub in My Account

MVP boundary: one new My Account page, Security (/manage/security). Under OpenIddict it shows:

  • Password: set or not set, with "Send reset email" (the existing action).
  • Google: linked or not, with the account email masked by the server (for example al•••@gmail.com), and Link or Remove → ExternalLogins. Remove is disabled, with an explanation, when Google is the only way to sign in (decision D10).
  • Passkeys: count, plus each passkey's name and creation date, with Manage → Passkeys.
  • Two-step verification: on or off, with Manage → TwoFactor.

Each action goes through the generalised API hand-off endpoint to the Identity page with returnTo=/manage/security. Read-only. No sessions (slice 4) and no in-app password change (slice 4: no general change-password page exists today).

Step 0: design prototype (gate). Before any implementation:

  • Build a static HTML prototype of the Security page and of one Identity Manage page carrying the "Back to SyRF" header (Passkeys), in light and dark.
  • Use FEAT-023 --mat-sys-* roles and the existing app side-nav shell.
  • Cover these states: all set; nothing set; status unavailable (API error); and the Auth0 environment (section hidden, existing pages shown).
  • Store it in-repo under docs/planning/account-portal/prototype/ with screenshots (decision D6).
  • Chris accepts it in writing on the PR before step 1 starts.

Step 0 status (2026-10-07): the prototype is in #4084 (prototype/). Chris reviewed it on 2026-10-06. His decisions are recorded under Decisions after the prototype review. Written acceptance of the revised prototype is still required (2.1).

Implementation status (2026-10-07): implemented in #4113, stacked on slice 1 (#4078). The architecture and the return-to-app contract are recorded in ADR-022.

Steps after acceptance:

  1. Identity admin response. Extend NativeAccountManagementResponse additively with HasPassword (UserManager.HasPasswordAsync), TwoFactorEnabled (GetTwoFactorEnabledAsync) and Passkeys: [{ Name, CreatedAt }]. Exclude credential IDs, public keys, sign counts, AAGUIDs and transports. Same admin-scope gate. Each LinkedIdentities[] entry gains MaskedEmail, the provider account's email masked inside Identity: the first two characters of the local part, •••, and the domain, for example al•••@gmail.com. It is null when the provider supplied no email. The unmasked provider email never leaves Identity and is never logged.
  2. API. Extend AccountManagementDto with a nullable Security object carrying the same fields, and pass maskedEmail through on each linked identity, unchanged. IdentityService (OpenIddict) maps it. Auth0Service and NoOpIdentityService return null, which tells the UI "status unavailable on this provider". Regenerate NSwag (Release build first; run generators from the repo root).
  3. Hand-off and Identity Manage pages.
  4. Generalise the API hand-off: GET /api/auth/account/external-logins becomes a small, closed set of targets (ExternalLogins, Passkeys, TwoFactor, and the Identity security landing page if one is added), for example GET /api/auth/account/{page}?returnTo=…. {page} comes from a fixed enum, so a request can never name an arbitrary Identity path. The endpoint keeps the 404-unless-OpenIddict rule and the BffAccountPortal normalisation.
  5. Passkeys, TwoFactor and StepUp adopt AppReturnPath exactly as ExternalLogins did in slice 1, carrying returnTo through their own handlers' query/form fields, and render "Back to SyRF" through one shared partial. This closes #4025, together with the root/landing behaviour that #4025 also reports.
  6. Web.
  7. Add a security route and component (signals; selectSignal; M3 list items and buttons).
  8. Load status so that a 401 goes through the existing interceptor's silent restore and retry (cross-cutting rule 8). Show "status unavailable" only for other failures.
  9. Under OpenIddict, Password and Social sign-in are folded into the one Security page. The side nav becomes Profile · Security · Deactivate, and /manage/password and /manage/social-sign-in (slice 1's interim page) redirect to /manage/security (decision D2).
  10. Under Auth0, the side nav and routes are unchanged (Profile · Password · Social sign-in · Deactivate).
  11. Docs and user guide (below).

Files touched:

  • Identity:
  • Controllers/AdminApiController.cs (response records and handler);
  • Pages/Account/Manage/{Passkeys,TwoFactor,ExternalLogins,StepUp}.cshtml{,.cs};
  • a new Pages/Shared/_AppReturn.cshtml;
  • tests in SyRF.Identity.Endpoint.Tests (AdminApiControllerTests, page tests).
  • API:
  • Models/Account/AccountManagementDto.cs;
  • Services/IdentityService.cs, Auth0Service.cs, NoOpIdentityService.cs;
  • Auth/BffAuthController.cs and Auth/BffAccountPortal.cs (the generalised hand-off endpoint) and BffAccountPortalRedirectTests;
  • tests (AccountControllerTests, OpenIddictIdentityServiceTests, ApiSensitiveLogTests).
  • Web:
  • manage/security/* (new) and manage/manage.routes.ts;
  • manage/manage.component.ts (nav links);
  • core/auth/* (state for the security summary, or a component store);
  • core/services/api-client.generated.ts (regenerated).

Feature flag: none new. The page and Identity-backed fields appear only when bff mode with the OpenIddict BFF provider, which production does not use until S13. All mutations reuse existing Identity pages. The API change is additive and nullable. A runtime flag would add a cleanup obligation without adding protection that the provider switch does not already give. Chris confirmed this: no runtime flag, the provider switch is the gate (decision D5).

Acceptance criteria:

# Criterion Verification
2.1 Chris accepts the static prototype (light and dark, every state in prototype/README.md) before implementation starts, and the implementation renders every one of those states: all set, password only, Google only, reset sent, loading, status unavailable, and the Auth0 environment (today's pages) Written acceptance on the PR; web component spec per state (security.component.spec.ts), plus the side-nav and route specs for Auth0
2.2 Identity admin {id}/account-management returns hasPassword, twoFactorEnabled and passkeys[{name, createdAt}]. It still requires admin scope (403 without it) and returns 404 for an unknown user. Identity controller tests
2.3 The response contains no credential ID, public key, sign count, AAGUID, transport, security stamp or unmasked provider email Identity test asserting the serialized JSON property set exactly
2.4 API GET api/account/account-management passes the security summary through for OpenIddict and returns security: null for Auth0 and NoOp. Existing linkedIdentities are unchanged. API controller and service tests
2.5 Neither the API nor Identity logs a passkey name, email or user ID in a message template ApiSensitiveLogTests and the Identity sensitive-log tests
2.6 Under OpenIddict, /manage/security shows all four rows with correct states for: password + Google + 2 passkeys + 2FA on; nothing set; API error Web component spec; Playwright on the local E2E stack with seeded users
2.7 Every Manage action goes through the hand-off endpoint to the matching Identity page with returnTo=%2Fmanage%2Fsecurity (an unknown {page} returns 404), and "Back to SyRF" on that page returns to /manage/security, which re-fetches status Web spec (targets); Playwright round trip on the local stack (passkey via the Chromium virtual authenticator; 2FA via a TOTP helper)
2.8 Under Auth0, My Account's side nav, routes and pages are unchanged and no Identity link is rendered Web spec with authProvider: auth0; existing E2E
2.9 Every Identity Manage page (Passkeys, TwoFactor, ExternalLogins, StepUp) renders "Back to SyRF" through AppReturnPath, keeps returnTo across its own POST/redirect cycle, and never redirects to it (the 1.5 corpus applied to each page) Identity page tests
2.10 Step-up is not bypassed: arriving from the hub with returnTo still requires step-up before any link, unlink, passkey removal, turning two-step verification off or resetting the authenticator (the last three since #4116), and completing step-up returns to the same page with returnTo intact Existing step-up tests stay green; tests arrive with returnTo and assert the step-up redirect for link and unlink (ManagePagesReturnToTests) and the passkey-removal and two-step round trips (AccountSecurityStepUpHttpTests)
2.11 The Security page passes axe (no serious or critical violations) in light and dark Playwright + axe in the E2E stack
2.12 Chris accepts the page and one Identity round trip on a preview environment, light and dark Preview screenshot acceptance
2.13 After merge and on staging under OpenIddict, a real account sees correct status and completes one Google link/unlink round trip back to the hub Live staging check (Chris or the operator, desktop browser)
2.14 Linked identities carry only the masked provider email. The raw email never appears in the Identity admin response, the API response or any log line. The masking rule holds for short local parts (1–2 characters) and for no email (null). Identity unit tests for the masker; an Identity and an API test asserting that the seeded raw email string is absent from the serialized JSON; sensitive-log tests (2.5)
2.15 With Google as the only sign-in method (no password, no passkeys), the Security page's Google "Remove" is disabled. A visible explanation is linked to it by aria-describedby. With a password or a passkey it is enabled. Web component spec over the three cases
2.17 A Google-only account (no password) can set a first password from the Password row's "Email me a link to set one". The button is the existing "Send reset email" (API → Identity admin password-reset), whose emailed link opens Identity's /Account/ResetPassword; UserManager.ResetPasswordAsync sets a hash when none exists. The row's copy depends on hasPassword. Identity HTTP-level test through the real host with a real UserManager (FirstPasswordResetFlowTests); web component spec for the copy
2.16 BFF silent sign-in is not broken (cross-cutting rule 8). A 401 while the Security page loads triggers the interceptor's silent restore, not the error state. After a Google link (refresh token revoked) or an unlink (Identity session ended), returning to /manage/security ends either signed in or in the normal signed-out state, with no loop. The existing silent-sign-in specs stay green. Web component and interceptor specs (401 → restore → retry; restore fails → signed out); existing bff-auth.provider/bff-silent-sign-in-frame specs and API BffSilentSignIn* tests; Playwright round trip on the local stack with bffSilentSignIn on

Docs:

  • user-guide/account/modify-account.md: a new "Your account security" section. It covers what each row means, that changes open a secure SyRF page that may ask you to confirm who you are (cross-reference the existing "Confirming a Sign-in Method Change"), how to get back, and what the Auth0-era password page did. Also add user-guide/account/index.md navigation if needed.
  • docs/architecture/identity-email-step-up.md: the hub entry points.
  • A docs page for the account-management API contract, or an update to the existing one in docs/features/auth0-to-openiddict-migration.md.

Risks:

  • Information disclosure. Passkey names are user-chosen free text. They are shown only to their owner through the authenticated API and never logged.
  • Stale status. Re-fetch on route entry and on visibilitychange back to visible.
  • Step-up bypass. returnTo is display and navigation only. It never mints or spends a proof (2.10).
  • CSRF. No new state-changing GET. Antiforgery on Identity POSTs is unchanged.

Slice 3: visual unification of Identity pages

MVP boundary: Identity's sign-in (split) shell and Manage (plain) shell take colours and typography from the same M3 source as the web app, through a generated token file with a drift test. Manage pages get an app-like header so that moving between app and Identity feels like one application. Light and dark. The person's light/dark choice is shared between the app and Identity, and every Identity page has the app's theme toggle. Notices match the app's notice component. The sign-in page is decluttered. There is no change to page flows or security behaviour.

Step 0: design prototype (gate).

  • Build static HTML/screenshots of Login (split shell) and Passkeys (plain shell with the new header), each next to the corresponding app screen (My Account Security), in light and dark.
  • Build them from the generated tokens (step 1 may be done first because it is invisible).
  • Chris accepts them before any Razor or CSS change ships.

Step 0 status (2026-10-07): prototyped in #4084, including the theme carry-over demo (prototype/compare.html). Reviewed by Chris on 2026-10-06; see Decisions after the prototype review. Written acceptance of the revision is still required (3.1).

Implementation (2026-10-07): in two PRs, each mergeable on its own, in order: #4112 (part 1: the token generator and its drift check, the app's shared theme cookie and the notice component's success mode, the Helm plumbing) and #4114 (part 2: the Identity restyle, header, toggle, notices and sign-in declutter; it contains part 1 until that merges). Screenshots of the implemented pages, rendered through the real Identity host, light and dark at 1280 and 360 px, are in slice-3-screenshots/ with the visual harness report (harness-report.json; see Identity theme tokens). The pipeline is documented in Identity theme tokens and the cookie in Shared theme preference.

Token sharing design (proposed):

  1. One source: src/services/web/src/global-styles/_m3-palettes.scss (generated by @angular/material:theme-color) plus the typography the app passes to mat.theme().
  2. One generator: a small Sass entry, src/services/web/tools/identity-tokens/identity-tokens.scss.
  3. It calls the documented mat.theme() mixin with the same palettes and typography twice: in :root (light) and in the dark selectors.
  4. A Node script compiles it with the repo's sass and rewrites --mat-sys-* to --syrf-sys-*. The rename means Identity owns no Angular runtime role names (FEAT-023 principle 3).
  5. The result is written to src/services/identity/SyRF.Identity.Endpoint/wwwroot/css/syrf-tokens.generated.css, which is checked in with a "generated, do not edit" header.
  6. Identity's build therefore stays .NET-only. It needs no Node at image build time.
  7. Consumption: syrf-auth.css drops its copied hex tokens and maps its existing semantic variables onto --syrf-sys-* (for example --syrf-accent: var(--syrf-sys-primary)). Brand-only values with no M3 role, such as the navy brand panel ink (which deliberately does not flip in dark mode), stay as named --syrf-brand-* variables taken from palette tones, never free hex.
  8. Drift test:
  9. node .../generate-identity-tokens.mjs --check regenerates and fails if the output differs. It runs in the web CI lane so a palette change cannot merge without regenerated Identity tokens.
  10. A .NET test in SyRF.Identity.Endpoint.Tests asserts that syrf-auth.css contains no hex or rgb() literal outside an explicit, commented allowlist, and that every var(--syrf-sys-*) it uses is defined in the generated file.
  11. Dark mode: Identity keeps prefers-color-scheme plus data-theme. The shared theme preference (below) decides data-theme server-side. The allowlisted theme hint from the contract remains a fallback. With neither, Identity follows the system preference.

Shared theme preference (decision D11):

A user who deliberately picks light or dark on one site sees the same mode on the other. Today the app keeps light | dark | system in app-origin localStorage (syrf-theme-mode, ThemeService, behind themeToggle). Identity, on another origin, cannot read it.

  • Cookie: a first-party cookie on the environment's parent domain, read and written by both sites.
Value
Name syrf-theme in production. Outside production the name carries a suffix: syrf-theme-staging, syrf-theme-pr-<n>, syrf-theme-local.
Values Exactly light, dark or system. Both sites drop anything else on read and refuse it on write.
Attributes Domain=<configured parent>; Path=/; Max-Age=31536000; SameSite=Lax; Secure. Not HttpOnly, because both sites' scripts write it. Display only; no security effect.
Content One word. No PII and no identifiers. Written only on an explicit choice.
  • Configuration: ThemeCookie:Name and ThemeCookie:Domain, set per environment and identically in Identity and the web runtime config. Never derived from the request. Both services render them from the same Helm values (themeCookie.name/themeCookie.domain, or, when unset, derived from environment.name, environment.domain and the preview's syrf.prNumber by the syrf-common.themeCookie.* helpers), so no per-environment cluster-gitops change is needed.
  • Startup validation: Domain must domain-match both UiUrl and the Identity issuer host. Outside production it must not be the registrable domain syrf.org.uk. Otherwise both sites fall back to a host-only cookie plus the hint.
  • Hosts (cluster-gitops, 2026-10-06):
Environment App host Identity host Domain
Production syrf.org.uk (app.syrf.org.uk redirects to it) identity.syrf.org.uk syrf.org.uk
Staging staging.syrf.org.uk identity.staging.syrf.org.uk staging.syrf.org.uk
Preview pr-<n>.syrf.org.uk identity.pr-<n>.syrf.org.uk pr-<n>.syrf.org.uk
Local local-syrf.org.uk local Identity local-syrf.org.uk

The production Identity host is an assumption until production Identity is deployed (M005 S10). - Leakage: the production cookie (Domain=syrf.org.uk) reaches every *.syrf.org.uk host, staging and previews included. Two rules contain it: - environment-suffixed names, so non-production never reads the production value; - the startup rule, so non-production never writes the production cookie.

Staging and preview cookies stay inside their own subdomain.

The production cookie also reaches signin.syrf.org.uk, Auth0's hosted custom domain (a third party). It is one display-only word with no identifier, so this is harmless; it is recorded so that nothing else is ever put in this cookie. The production web host is https://syrf.org.uk. - Identity: reads the cookie per request and renders <html data-theme> server-side, so there is no flash and no render-blocking script. The toggle writes the cookie and flips data-theme. - App: the existing pre-paint script in index.html reads the cookie first, then localStorage, then the system preference. It keeps the syrf-theme-pending no-flash mechanism. ThemeService.setMode writes both the cookie and localStorage. On start-up, a localStorage choice with no cookie is copied to the cookie, so existing choices carry over. - Fallback: the hand-off endpoint forwards the allowlisted theme hint. Identity applies it and writes its own cookie. - Flag: the existing themeToggle gates the toggle and the cookie on both sites. Identity receives the same Helm value through a narrow Identity-only section in env-mapping.yaml (identityThemeFlags, the pattern backendFeatureFlags uses for project management) that carries only themeToggle. identity is not added to the featureFlags section's services, which would ship every catalogued web flag to Identity. Identity binds it to IdentityFeatureFlags.ThemeToggle (FeatureFlags:ThemeToggle) at start-up and never reads the API's runtime override store, so its half is deployment-only. With the flag off, Identity shows no toggle, ignores the cookie and follows the system preference, as it does today (decision D16). - Privacy: a user-requested interface preference, set only on an explicit choice. No consent banner is needed.

Theme toggle on every Identity page (decision D12): the app's top-bar control, with the same sun/moon icon, accessible label ("Switch to light/dark theme") and behaviour (toggleTheme: switch to the opposite of the mode now showing). It sits in the navy header on the Manage pages and in the top-right corner of the split shell. It is rendered by the shared layout, so no page can omit it.

Notices (decision D13): Identity notices are a replica of the app's shared app-information-box component (shared/information-box). They use the same 12 px radius, 1 px outline, padding, gap, outlined 24 px icon, bold optional heading, and the same light and dark colours. They use its modes: info, alert and warning, plus a new green success mode that slice 3 adds to the app component too. The cutover notice ("SyRF's sign-in has moved") becomes info. Its text is unchanged.

Sign-in page (decision D14):

  • Remove the "Manage passkeys", "Manage two-step verification" and "Manage external sign-ins" links. People manage those from My Account after signing in. No other public page links to the Manage pages. ExternalLogin's "Sign in to an existing account" return path is functional and stays.
  • "Verify email and retry your password" shows after any failed password attempt, inside the failure notice, and never on a fresh page. A passkey failure or lockout shows no such link: the route is about password throttling (implementation note, 2026-10-07, #4114).
  • "Forgot your password?" stays a link. "Create an account" becomes an outlined button, matching the app's secondary actions (mat-stroked-button).

App-like header (Manage shell):

  • SyRF logo, linking to UiUrl.
  • "Signed in as {display name}" plus an initials avatar in the app's account-menu style. Email is shown only in the menu, not in the page title.
  • "Back to SyRF" (the resolved returnTo).
  • "Sign out" (the existing Identity sign-out).

The sign-in split shell keeps its brand panel but adopts the shared tokens and the M3 type scale.

Files touched:

  • Identity:
  • wwwroot/css/syrf-auth.css;
  • wwwroot/css/syrf-tokens.generated.css (new);
  • Pages/Shared/_Layout.cshtml (link the token CSS; header for the plain shell; theme attribute);
  • a new Pages/Shared/_AppHeader.cshtml;
  • AuthLayoutModels.cs (header model);
  • the theme cookie reader/writer, its options and startup validation, and the toggle script;
  • Pages/Account/Login.cshtml (declutter) and Pages/Shared/_Notice.cshtml (information-box replica);
  • possibly wwwroot/fonts (only if the type scale needs weights outside the current subset);
  • tests.
  • Web:
  • tools/identity-tokens/* (new) and a CI step in the existing web lane;
  • core/services/theme/theme.service.ts and the index.html pre-paint script (shared cookie);
  • shared/information-box (the new success mode);
  • src/charts/syrf-common/env-mapping.yaml (a narrow identityThemeFlags section carrying themeToggle to identity, and the themeCookie section for both services) plus the generated flag outputs.
  • Docs: FEAT-023 README/technical plan (see the proposed scope change).

Feature flag: no new flag. - Restyle: the restyle and the sign-in declutter have no behavioural branch. Identity is not yet serving production users (production is Auth0 until S13), and rollback is an image revert. A CSS flag would mean shipping both stylesheets, which is the drift this slice removes. - Theme: the shared theme cookie and the Identity toggle sit behind the existing themeToggle flag, carried to the identity service by its own narrow section.

Acceptance criteria:

# Criterion Verification
3.1 Chris accepts the static prototype (every state in prototype/README.md, light and dark) before implementation Written acceptance on the PR
3.2 syrf-tokens.generated.css is reproducible: --check passes on a clean tree and fails after a one-tone edit to _m3-palettes.scss CI step, plus a deliberate local mutation run recorded in the PR
3.3 syrf-auth.css contains no colour literal outside the commented allowlist, and every --syrf-sys-* it references is defined Identity .NET test
3.4 For the same mode, these computed values are identical in the app and on Identity pages: primary, on-primary, surface, on-surface, outline and error Playwright: read computed styles on /manage/security and /Account/Manage/Passkeys in light and dark, and assert equality
3.5 Body and heading text use the same family and the same M3 type-scale size and weight in the app and on Identity The same Playwright comparison for body-large and title-large equivalents
3.6 Every Manage page shows the header: logo → UiUrl, "Signed in as", "Back to SyRF" → validated return, "Sign out" Identity page tests; Playwright
3.7 ?theme=dark renders dark and writes the shared cookie. An unknown value is ignored. With no cookie and no hint, the system preference is followed. Identity page tests; Playwright with colorScheme emulation
3.8 Login, Register, ForgotPassword, ResetPassword, VerifyEmail, LoginWithTwoFactor and all Manage pages pass axe (no serious or critical violations) and WCAG 1.4.3/1.4.11 contrast in light and dark Playwright + axe
3.9 No third-party requests and no CDN are introduced. Fonts stay self-hosted. Playwright network assertion (all requests same-origin)
3.10 Existing Identity page tests and the live harness e2e/tests/auth-migration-live.spec.ts stay green: selectors and flows are unchanged CI; S09/S27 live run
3.11 Chris accepts the sign-in journey and a Manage round trip on a preview environment in light and dark, judging that it "feels like one application" Preview screenshot acceptance
3.12 Choosing light or dark on the app sets the environment's theme cookie with the configured Domain, Path=/, SameSite=Lax, Secure and no HttpOnly, and the next Identity page renders in that mode. Choosing on Identity carries to the app's next load in the same way. Playwright on the local stack with app and Identity on two hosts under one parent domain: assert the cookie attributes and the rendered mode on both hosts, in both directions
3.13 Only light, dark and system are honoured. Any other cookie or hint value is ignored on both sites and never written. Identity page tests and a web ThemeService spec over a hostile corpus (DARK, dark;x, <script>, an empty value, a 1 KB value)
3.14 No flash of the wrong theme. Identity's HTML already carries data-theme from the cookie. The app's pre-paint script applies the cookie before Angular boots. Identity page test asserting data-theme in the response HTML; Playwright first-paint screenshot (waitUntil: 'commit') with a dark cookie shows no light frame on either site
3.15 Startup validation rejects a theme cookie Domain that does not domain-match both hosts, and rejects syrf.org.uk outside production. Each environment uses its own cookie name. Identity and web configuration unit tests; a review of the cluster-gitops values for staging, preview and production
3.16 The theme toggle is present on every Identity page (the split shell and every Manage page), with the app's icon, label and behaviour. It is absent when themeToggle is off, in which case Identity follows the system preference. Identity page tests iterating all pages, flag on and off; Playwright click test
3.17 An existing syrf-theme-mode in app localStorage with no cookie is copied to the cookie on the next app load Web ThemeService spec
3.18 The sign-in page has no link to /Account/Manage/* Identity page test (no href containing /Account/Manage); repository grep in review
3.19 "Verify email and retry your password" is absent on a fresh sign-in page. It is present after a failed password attempt, with identical markup for a wrong password and an unknown email, and absent after a passkey failure. Identity page tests for the four cases
3.20 "Create an account" renders as an outlined button. "Forgot your password?" remains a link. Identity page test (element and classes); preview acceptance
3.21 The cutover notice renders as the info notice with its text unchanged Identity page test; preview acceptance
3.22 Every Identity notice kind (info, alert, warning, success) matches the app's app-information-box of the same kind: border radius, outline width, padding, gap, icon size, heading weight, and the computed container, text and outline colours in light and dark. The app component gains the success mode. Playwright: render each kind in the app and on Identity and compare computed styles; a pixel diff of a fixed-text notice within a small tolerance; web component spec for success
3.23 BFF silent sign-in still works (cross-cutting rule 8). prompt=none authorize keeps its frame-ancestors behaviour under identitySilentSignInFrame. Every other page, the sign-in page included, keeps X-Frame-Options: SAMEORIGIN. The theme cookie never collides with syrf-signed-out, syrf-silent-auth or the BFF cookies. Existing SilentAuthorizationFramingTests stay green; a new Identity test asserts that the layout changes add no UI or headers to the prompt=none response; a cookie-name collision unit test

Docs:

  • FEAT-023 edits (below).
  • docs/architecture/: a short reference on the token pipeline (source, generator, check, how to regenerate).
  • docs/architecture/: the shared theme cookie contract (names, domains per environment, leakage rules, configuration). The same note goes in docs/how-to/manage-feature-flags.md for themeToggle on Identity.
  • user-guide/account/modify-account.md: add one sentence to the security section. "Your light or dark choice applies to SyRF's sign-in pages too, and the sun/moon button there changes it everywhere."
  • user-guide/whats-new/ entry, if Chris wants the new look announced.

Risks:

  • Coupling. The web build must not depend on Identity CSS, and Identity must not depend on the web build at runtime. The checked-in generated file satisfies both.
  • Cache. The generated CSS uses asp-append-version, like syrf-auth.css.
  • Theme cookie scope. The production cookie reaches every *.syrf.org.uk host. It is a display preference with no security effect, contained by the environment-suffixed names and the startup rule (3.15). The hint is allowlisted.
  • Silent sign-in. Layout and header changes touch the same responses as the prompt=none framing middleware. Mitigated by 3.23.
  • Regression in the 1,163-line stylesheet. Mitigated by the visual comparison (3.4/3.5), axe (3.8) and preview acceptance.

Slice 4: after slices 1–3

Not an S13 prerequisite. Planned at outline level only. Each item gets its own plan with acceptance criteria before work starts. "Identity config flag" below means a flag in src/charts/syrf-common/env-mapping.yaml in an Identity-only section (such as identityFeatureFlags), never by adding identity to the web featureFlags section's services.

Item Where Notes Flag decision
Change password while signed in New Identity page Pages/Account/Manage/Password (current password + new; set-password for social-only accounts), linked from the hub Mutation stays on Identity. Revalidates the security stamp and signs out other sessions on change. The existing ChangePassword stays migration-only. Not flagged: a new page, reachable only from the OpenIddict hub
Sessions/devices list and "sign out other sessions" Identity Manage page plus a hub summary row Needs a session store view (Valkey BFF sessions plus Identity cookies). Revocation stays on Identity. Flag: Identity config flag until session inventory is proven
Post-sign-in passkey prompt (#4029) Identity, after sign-in Interrupts the sign-in flow, so roll out gradually Flag: Identity config flag, default off
Passkey autofill / conditional mediation (#4039) Identity Login Browser support varies Flag: Identity config flag, default off

Proposed FEAT-023 scope change

Slice 3 deliberately brings Identity visual parity into FEAT-023's contract. The doc edits are proposed here and should land in slice 3's PR, or earlier if Chris approves this plan:

  1. docs/features/material-3-migration/README.md:55-57: replace "or visual parity work for the separate Identity Razor application" with:

Identity Razor pages are brought into visual parity through the shared generated token file described in account portal slice 3 (added 2026-10-05). Identity consumes reviewed source values. It does not own or override Angular runtime roles.

  1. technical-plan.md:55-56 (principle 3): add "through the generated syrf-tokens.generated.css, whose drift check runs in the web lane".
  2. technical-plan.md:254 (Wave 6): mark "Reconcile shared source values with Identity" as delivered by account portal slice 3, and keep the "without coupling Angular to Identity CSS" constraint.
  3. Wave 7 audit list: add the Identity sign-in and Manage pages.

Sequencing and dependencies

Slice 1 (#4078) ──> Slice 2 [prototype → identity admin → API → Identity returnTo → web hub]
                         └─────────────┐
Slice 3 [prototype → token generator → Identity restyle + header]   (parallel with slice 2 after slice 1's returnTo service)
Slices 1–3 merged + preview-accepted ──> M005 S13 (prod OpenIddict user switch)
M005 S10 ─> S11/S12  (in parallel with slices 1–3; not gated by them)
Slices 1–3 ──> Slice 4 items
  • Slice 2 step 3 and slice 3's header both depend on slice 1's return-path service. Merge slice 1 first.
  • Slices 2 and 3 can run in parallel in separate worktrees once their prototypes are accepted. They touch the same Manage .cshtml files, so whichever merges second rebases. The _AppReturn partial from slice 2 is reused by slice 3's header.
  • Live verification of 1.6 and 2.13 needs staging on OpenIddict (M005 S09/S27). Preview environments with the local Identity stack cover the rest.
  • M005 gate: the cutover docs record slices 1–3 as S13 prerequisites (M005-ROADMAP).

Decisions (Chris, 2026-10-05)

These replace the open questions in the first draft of this plan.

  • D1. The hard gate is S13, not S10. M005's S10 is the production dark launch: production Identity runs, but users stay on Auth0. Users first meet Identity's pages at S13, the production OpenIddict user switch. Slices 1–3 are S13 prerequisites. S10–S12 proceed in parallel and are not gated by this plan.
  • D2. One Security page under OpenIddict. Password and Social sign-in are folded into /manage/security. The OpenIddict side nav is Profile · Security · Deactivate. The Auth0 side nav is unchanged. Slice 1's /manage/social-sign-in remains as the interim OpenIddict page and becomes a redirect to /manage/security in slice 2, as does /manage/password.
  • D3. No automatic redirect after an Identity action (decided on safety grounds). The Identity page stays put, shows a success notice and offers a "Back to My Account" button, so the user sees the confirmation where the change happened.
  • D4. Account-mismatch guard. "Signed in as … / Not you? Sign out" in the Identity header (slice 3) is sufficient. The app passes no expected-account hint, so no identifier goes into a URL.
  • D5. No runtime flag for the Security page. The provider switch is the gate: production stays on Auth0 until S13.
  • D6. Prototypes live in-repo under docs/planning/account-portal/prototype/.

Decisions after the prototype review (Chris, 2026-10-06)

Chris reviewed the prototypes in #4084.

  • D7. Navy header on Identity Manage pages. The app's top bar and section header replace the centred card. The sign-in page keeps its brand panel and card.
  • D8. M3 pill buttons in sentence case on Identity, matching the app.
  • D9. Masked Google email on the Security page. Slice 2 adds a server-masked provider email (maskedEmail) to each linked identity. The raw email never leaves Identity (2.3, 2.14).
  • D10. Google-only users cannot remove Google from the Security page. Remove is disabled with an explanation (2.15).
  • D11. Shared theme preference. A deliberate light or dark choice on either site applies on both, through the per-environment first-party cookie described in slice 3 (3.12–3.17).
  • D12. A theme toggle on every Identity page, identical to the app's top-bar toggle (3.16).
  • D13. Notices identical to the app's app-information-box. That includes a new green success mode, added to the app component as well (3.22).
  • D14. Sign-in page declutter. No Manage links. The retry link shows after any failed attempt. Clarification (Chris, 2026-10-07): "failed attempt" means a failed password attempt. The link is about password-attempt limits, so a passkey failure or lockout shows no retry link. "Create an account" is an outlined button. "Forgot your password?" stays a link. The cutover notice uses the calm info style (3.18–3.21).
  • D15. 2FA Off is neutral, not a warning.
  • D16. With themeToggle off, Identity follows the system preference, as today.

Resolved 2026-10-07 (slice 2): the Identity reset flow does set a first password for a Google-only account. ResetPasswordAsync sets a hash when none exists (criterion 2.17). The signed-in "Send reset email" path used to link to {UiUrl}/reset-password, which the web app does not route. Slice 2 points it at Identity's /Account/ResetPassword.