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)
- Cross-cutting rules
- Return-to-app contract
- Slice 1: social sign-in works under OpenIddict (in progress)
- Slice 2: security hub in My Account
- Slice 3: visual unification of Identity pages
- Slice 4: after slices 1–3
- Proposed FEAT-023 scope change
- Sequencing and dependencies
- Decisions (Chris, 2026-10-05)
- Decisions after the prototype review (Chris, 2026-10-06)
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.
- 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.
- 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.
- Return-to-app is a validated relative path only. See the contract. An absolute URL from a request is never followed.
- 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 inbffmode 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. - 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. - 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. - Docs ship with behaviour. Each slice updates
/docs/and/user-guide/in the same PR ("stale docs block merges"). - Do not break BFF silent sign-in. #4097
(
prompt=nonesilent sign-in) and #4104 (sliding sessions and the hidden-iframe variant) are merged. The hub and the restyled Identity pages must keep them working: - 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.
-
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-outandsyrf-silent-authguards 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-author the BFF session and PKCE cookies. - Identity layout, theme and header changes must not alter theprompt=noneauthorize response or itsframe-ancestorsexemption (identitySilentSignInFrame). Every other page keepsX-Frame-Options: SAMEORIGIN.SilentAuthorizationFramingTestsstay 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/securityor/manage/social-sign-in. - Validation: Identity
Services/AppReturnPath.cs(Normalize,ToAppUrl), mirrored in the API'sAuth/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.tsand the social sign-in component (inbffmode, 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.tsandapp-settings.interface.ts: correct or delete the stalemanageAccountUrl(the hand-off endpoint makes it unnecessary);auth.effects.spec.ts.- Identity:
Pages/Account/Manage/ExternalLogins.cshtml{,.cs}(acceptreturnTo, render "Back to SyRF");Services/AppReturnPath.csand its tests.- API:
Auth/BffAccountPortal.csandAuth/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 thereturnTocontract.docs/how-to/enable-bff-auth.md: config key change, ifmanageAccountUrlis 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 carriesreturnToin 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:
- Identity admin response. Extend
NativeAccountManagementResponseadditively withHasPassword(UserManager.HasPasswordAsync),TwoFactorEnabled(GetTwoFactorEnabledAsync) andPasskeys: [{ Name, CreatedAt }]. Exclude credential IDs, public keys, sign counts, AAGUIDs and transports. Same admin-scope gate. EachLinkedIdentities[]entry gainsMaskedEmail, the provider account's email masked inside Identity: the first two characters of the local part,•••, and the domain, for exampleal•••@gmail.com. It is null when the provider supplied no email. The unmasked provider email never leaves Identity and is never logged. - API. Extend
AccountManagementDtowith a nullableSecurityobject carrying the same fields, and passmaskedEmailthrough on each linked identity, unchanged.IdentityService(OpenIddict) maps it.Auth0ServiceandNoOpIdentityServicereturnnull, which tells the UI "status unavailable on this provider". Regenerate NSwag (Release build first; run generators from the repo root). - Hand-off and Identity Manage pages.
- Generalise the API hand-off:
GET /api/auth/account/external-loginsbecomes a small, closed set of targets (ExternalLogins, Passkeys, TwoFactor, and the Identity security landing page if one is added), for exampleGET /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 theBffAccountPortalnormalisation. Passkeys,TwoFactorandStepUpadoptAppReturnPathexactly asExternalLoginsdid in slice 1, carryingreturnTothrough 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.- Web.
- Add a
securityroute and component (signals;selectSignal; M3 list items and buttons). - 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.
- Under OpenIddict, Password and Social sign-in are folded into the one Security page. The
side nav becomes Profile · Security · Deactivate, and
/manage/passwordand/manage/social-sign-in(slice 1's interim page) redirect to/manage/security(decision D2). - Under Auth0, the side nav and routes are unchanged (Profile · Password · Social sign-in · Deactivate).
- 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.csandAuth/BffAccountPortal.cs(the generalised hand-off endpoint) andBffAccountPortalRedirectTests;- tests (
AccountControllerTests,OpenIddictIdentityServiceTests,ApiSensitiveLogTests). - Web:
manage/security/*(new) andmanage/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 adduser-guide/account/index.mdnavigation 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
visibilitychangeback to visible. - Step-up bypass.
returnTois 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):
- One source:
src/services/web/src/global-styles/_m3-palettes.scss(generated by@angular/material:theme-color) plus the typography the app passes tomat.theme(). - One generator: a small Sass entry,
src/services/web/tools/identity-tokens/identity-tokens.scss. - It calls the documented
mat.theme()mixin with the same palettes and typography twice: in:root(light) and in the dark selectors. - A Node script compiles it with the repo's
sassand rewrites--mat-sys-*to--syrf-sys-*. The rename means Identity owns no Angular runtime role names (FEAT-023 principle 3). - 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. - Identity's build therefore stays .NET-only. It needs no Node at image build time.
- Consumption:
syrf-auth.cssdrops 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. - Drift test:
node .../generate-identity-tokens.mjs --checkregenerates and fails if the output differs. It runs in the web CI lane so a palette change cannot merge without regenerated Identity tokens.- A .NET test in
SyRF.Identity.Endpoint.Testsasserts thatsyrf-auth.csscontains no hex orrgb()literal outside an explicit, commented allowlist, and that everyvar(--syrf-sys-*)it uses is defined in the generated file. - Dark mode: Identity keeps
prefers-color-schemeplusdata-theme. The shared theme preference (below) decidesdata-themeserver-side. The allowlistedthemehint 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:NameandThemeCookie: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 fromenvironment.name,environment.domainand the preview'ssyrf.prNumberby thesyrf-common.themeCookie.*helpers), so no per-environment cluster-gitops change is needed. - Startup validation:
Domainmust domain-match bothUiUrland the Identity issuer host. Outside production it must not be the registrable domainsyrf.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) andPages/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.tsand theindex.htmlpre-paint script (shared cookie);shared/information-box(the newsuccessmode);src/charts/syrf-common/env-mapping.yaml(a narrowidentityThemeFlagssection carryingthemeToggletoidentity, and thethemeCookiesection 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 indocs/how-to/manage-feature-flags.mdforthemeToggleon 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, likesyrf-auth.css. - Theme cookie scope. The production cookie reaches every
*.syrf.org.ukhost. 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=noneframing 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:
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.
technical-plan.md:55-56(principle 3): add "through the generatedsyrf-tokens.generated.css, whose drift check runs in the web lane".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.- 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
.cshtmlfiles, so whichever merges second rebases. The_AppReturnpartial 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-inremains as the interim OpenIddict page and becomes a redirect to/manage/securityin 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 greensuccessmode, 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
infostyle (3.18–3.21). - D15. 2FA Off is neutral, not a warning.
- D16. With
themeToggleoff, 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.