Skip to content

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. returnTo is a relative path inside the web app, such as /manage/security.
  • Validation (AppReturnPath.Normalize in Identity, mirrored by BffAccountPortal.NormalizeReturnTo in the API and by the web's AccountPortalService). 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/password and /manage/social-sign-in redirect 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-ended and 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 _AppReturn partial 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 returnTo through 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/Manage index 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.