ADR-022: My Account Security hub and the return-to-app contract¶
Status¶
In-Review, 2026-10-07. It records the architecture behind account portal slices 1 and 2:
The plan and Chris's decisions D1–D16 are in
the account portal plan. The prototype he reviewed is in
prototype/. The plan is temporary; this ADR is
the permanent record.
Context¶
Under OpenIddict, a signed-in person needs one place to see and change how they sign in:
- password;
- linked Google sign-in;
- passkeys;
- two-step verification (2FA).
Three constraints shape where that place can live:
- Passkeys. The WebAuthn relying-party ID is the Identity host
(
IdentityPasskeyOptions.ServerDomain = issuer.Host), so passkey ceremonies must run on Identity. - Google. Linking redirects through Identity's external-login handlers.
- Step-up. Link, unlink and (since #4116) passkey removal and the two-step changes require an operation-bound step-up proof minted by Identity's Razor pages from a server-issued request (identity-email-step-up.md).
The web app in BFF mode has no Identity URL and no identity SDK of its own.
Decision¶
1. A read-only hub in the app, changes on Identity pages¶
My Account gains Security (/manage/security). It shows status and offers actions. Every change
happens on an existing Identity Razor page:
ExternalLogins;Passkeys;TwoFactor;StepUp.
Those pages keep their antiforgery-protected POST handlers and step-up rules. The app never posts to Identity. No new credential-mutating endpoint exists.
The one in-app action is the existing Send reset email
(POST api/account/profile/tickets/password). The API forwards it to Identity's admin
POST api/admin/users/password-reset. The emailed link opens Identity's /Account/ResetPassword
beneath OpenIddict:Issuer. UserManager.ResetPasswordAsync sets a hash even when the account has
none, so the same action lets a Google-only account set a first password ("Email me a link to set
one").
The admin link previously pointed at {UiUrl}/reset-password, which the web app does not route.
2. Status is server-to-server and masked at the source¶
The browser calls the API's GET api/account/account-management (BFF cookie). The API calls
Identity's admin GET api/admin/users/{id}/account-management (client credentials, admin:users
scope). CORS on Identity is not widened.
The projection is additive:
| Field | Meaning |
|---|---|
linkedIdentities[] |
As before, plus maskedEmail |
security.hasPassword |
Whether a password is set |
security.twoFactorEnabled |
Whether 2FA is on |
security.passkeys[{name, createdAt}] |
Newest first |
The API wraps the security fields as security. It is null for Auth0, the no-op service, and any
Identity build without the fields. The page shows null as "status unavailable", never as "not set".
Masking happens inside Identity. ProviderEmailMask keeps at most two local-part characters,
always hides at least one, and keeps the domain, for example al•••@gmail.com. The unmasked provider
email never leaves Identity.
Credential material is never projected: no credential ID, public key, sign count, AAGUID, transport, security stamp or raw email. Nothing in the projection is logged.
3. One closed-set hand-off endpoint¶
The app navigates the top-level window to GET /api/auth/account/{page}?returnTo=…, with {page}
one of:
external-logins;passkeys;two-factor.
The API redirects to {BffAuth:Authority}/Account/Manage/{Page}.
- Closed set.
{page}comes from a fixed map (BffAccountPortal.Pages). Anything else is 404, so a request can never name an Identity path. - Provider gate. The endpoint is 404 unless the BFF provider is OpenIddict.
- Origin. The destination origin is always configuration.
- No change. It is a GET that changes nothing.
4. The return-to-app contract¶
- Parameter.
returnTois a relative path inside the web app, such as/manage/security. - Validation (
AppReturnPath.Normalizein Identity, mirrored byBffAccountPortal.NormalizeReturnToin the API and by the web'sAccountPortalService). A value is accepted only if: - it has exactly one leading
/; - it has no backslash, whitespace or control character;
- it is at most 512 characters.
Anything else is dropped, not repaired. The page then behaves as if no returnTo was given.
- Resolution. AppReturnPath.ToAppUrl joins the path to the configured origin of
AppSettingsConfig:UiUrl. That origin must be HTTPS (HTTP only on loopback) with no user-info. The
origin therefore never comes from the request. A route-prefix allowlist would add nothing, because
the worst a crafted value can do is name another SyRF page.
- Rendered, never followed. The resolved URL is used only as an href, through one shared
partial, Pages/Shared/_AppReturn.cshtml:
- the standing Back to SyRF link;
- Back to My Account under a success notice, when the destination is a /manage… page.
No Identity handler redirects to it, so it cannot become an open redirect.
- Carried in requests, never stored. returnTo travels in query strings and form fields:
- every Manage page's own forms and post-redirect URLs;
- inside step-up's Identity-local returnUrl, from which StepUp reads it with
AppReturnPath.FromLocalUrl;
- on the external-login callback route values;
- through the sign-in that follows an unlink.
There is no cookie or server-side state. - D3: no automatic redirect. After an action, the page shows its success notice and a primary Back to My Account button, so the person sees the confirmation where the change happened. Turning 2FA on shows the button after the recovery codes.
5. Provider-aware web app¶
The web app learns the BFF provider from provider on /api/auth/me
(BffAuthProvider.identityProviderName()). The hub appears only when both hold:
- the web app runs in BFF mode;
- the BFF provider is OpenIddict.
When it appears:
- the side nav becomes Profile · Security · Deactivate account;
/manage/passwordand/manage/social-sign-inredirect to/manage/security(D2).
Every other mode keeps today's nav and pages, and /manage/security sends the person to
/manage/password. Production stays on Auth0 until M005 S13.
No runtime flag (D5). The provider switch is the gate. All changes are additive or confined to OpenIddict.
6. Silent sign-in stays intact¶
The hub's status request is an ordinary API request, so a 401 goes through the interceptor's refresh and silent restore (#4097, #4104):
- Restored. A hidden-frame restore retries the request.
- Still 401. The page records
session-endedand shows a calm "Your session has ended" with a Sign in button the person chooses. It never shows "status unavailable" for this, and never starts an interactive sign-in by itself. - Other failures show "status unavailable" with Try again.
The hub re-reads status on route entry and when the tab becomes visible, so a return from Identity never shows a stale row.
The hub adds no cookies and leaves the prompt=none responses unchanged.
Consequences¶
- Security-sensitive work stays in one place. Antiforgery, step-up, lockout, passkey ceremonies and their tests stay on Identity, and the app gains only a read model and links.
- Two origins. The person moves between two sites. Slice 3 (visual unification) makes Identity
look like the app, and adds a "Signed in as / Not you?" header for account-mismatch protection
(D4). The
_AppReturnpartial is the seam slice 3 restyles and moves into that header. - Step-up coverage. The hub does not change it. When this ADR was written it covered link and
unlink only; #4116 (a separate decision) extended it to passkey removal, turning two-step
verification off and resetting the authenticator, and carries
returnTothrough those step-ups. - Anonymous API reset. The anonymous API password reset (
RecoveryEmailKind.PasswordResetApi) still links to the web app's non-existent/reset-password. It is not used by My Account and is tracked separately.
Alternatives considered¶
- JSON APIs on Identity, called from the app. These would mean new credential-mutating endpoints, wider CORS, and passkey ceremonies off the relying-party host. Rejected (plan cross-cutting rule 1).
- An
/Account/Manageindex page on Identity. It would add a second hub and duplicate status. The app hub with deep links is simpler. - Auto-redirect back to the app after an action. Rejected on safety grounds (D3): the confirmation must be seen where the change happened.
- A runtime flag for the hub. Rejected (D5). The provider switch already gates it, and a flag would add a cleanup obligation without extra protection.