Skip to content

Auth0 Behaviour → Native Identity/OpenIddict Parity Gate

Decision and scope

This is a required pre-rehearsal workstream. The objective is not to reproduce Auth0 Actions line by line. ASP.NET Core Identity must own confirmation, reset, external-login and claims behaviour natively, while OpenIddict owns OAuth/OIDC. The objective is to preserve the underlying SyRF domain invariants and safe user-facing behaviour.

No preview or staging rehearsal may begin until the required items below are implemented, reviewed and covered by automated acceptance tests. This document does not authorise deployment, real-user migration, Auth0 changes, production traffic, live email, or live secret/configuration changes.

The checked-in Action files are behavioural evidence, not an authoritative copy of the live Auth0 tenant. PR #2639 records known source drift after incident patches and remains intentionally held. No checked-in Auth0 Rules or Hooks were found, so absence in this repository is not proof that the live tenant has none. A read-only tenant inventory remains an operator gate before any real migration.

Identity invariants

  1. InvestigatorId/SyrfUserId is the enduring SyRF person identifier. It must not change during authentication migration.
  2. The OAuth/OIDC sub is the immutable Identity account identifier. The namespaced user_id claim is the verified InvestigatorId. These identifiers serve different purposes and must not be substituted for one another.
  3. Only a verified, immutable Identity-to-Investigator mapping may emit namespaced user_id. Email and other provider claims are discovery hints only; they may never create or change the mapping.
  4. Missing, duplicate, or contradictory mappings fail closed before normal application access or token issuance. Operators receive restricted, privacy-preserving diagnostics; users receive non-enumerating errors.
  5. A new InvestigatorId may be allocated only as part of an authorised, transactional new-person registration flow. It must not be generated to resolve an existing-person conflict.
  6. External identities are linked only after proof of control and recent reauthentication. Matching email alone never links or merges accounts.
  7. At least one permitted recovery or alternative sign-in method remains after linking or unlinking.
  8. Return URLs are local/allowlisted. Forwarded scheme/host values are trusted only from configured proxies because they determine OAuth callbacks and email links behind ingress.

Invariants 3, 5 and 6 have one bounded exception: a credential-less Investigator may be claimed after confirmed mailbox proof. The predicate for credential-less is mechanical: no Identity account exists whose SyrfUserId equals the Investigator's Id, and the persisted mapping history contains no record that the Investigator was previously mapped. PR A's mapping service records that history as a tombstone that survives Identity account deletion. All credentials — password, external logins, passkeys, MFA — live on the Identity account, so the current-account check is exhaustive for active credentials; the tombstone makes the one-time exception enforceable. No stronger proof can exist for such a record, and confirmed mailbox control is the same assurance accepted by password reset. The exception lapses permanently when the first Identity account takes the Investigator mapping. If that account is later deleted, the returning user must use support recovery rather than make a fresh mailbox claim.

A further account-level invariant, applying to every external provider (Google is simply the only configured provider at launch): a provider account links to at most one SyRF account, and the email address of a linked provider account may not serve as the sign-in email of any other SyRF account. Conflicts fail with non-enumerating helpdesk guidance (PR C enforces this for all providers).

For an already-linked user, the provider key is authoritative at sign-in and no email match participates in authentication. A changed provider email or changed local sign-in email therefore neither breaks nor grants access. Reservations are checked against current data at registration, at link time and at any local sign-in email change — changing an account's sign-in email to a reserved address is refused the same way registration is. Only a verified provider email may create or update a reservation: an unverified provider assertion reserves nothing, so a provider returning unverified addresses cannot block someone else's future registration. The observed provider email is refreshed at every provider sign-in. If a refreshed provider email collides with another account's sign-in email, the linked sign-in still succeeds; the collision blocks new registrations or links using that email and is surfaced as a helpdesk/operator flag rather than an authentication failure.

Current evidence

Primary code evidence:

Area Current implementation evidence State
Native account UI Account Razor Pages implement password login, logout, registration, forgot/reset/change password, email verification, TOTP/recovery codes and passkey management. Implemented foundations
Token claims UserClaimsService and AuthorizationController emit namespaced user_id, groups/roles, names, picture and email when present. AuthorizationController uses the Identity account ID for sub. Partial
Registration Register and AdminSignUp allocate a new SyrfUserId unconditionally. Identity is configured with RequireUniqueEmail. Unsafe until new-person allocation is transactional and confirmed-email claiming of credential-less Investigators is implemented
Token issuance AuthorizationController omits namespaced user_id when SyrfUserId is null. Gap: must fail closed
Userinfo/BFF resolution Userinfo independently rebuilds claims; BffAuthController passes user_id and sub to ApplicationService.ResolveInvestigatorIdAsync, whose GUID-sub fallback can create/select an Investigator from the Identity account ID. Gap: remove the fallback and make userinfo and the application boundary share the same fail-closed mapping invariant
Mapping administration AdminApiController prevents replacing one user's different existing SyrfUserId. Gap: no proven global one-to-one constraint or ambiguity check
External login ExternalLogin looks up only provider plus provider key, rejects unknown/unmapped users, and validates local return URLs. Safe default, incomplete lifecycle
Confirmation Register, VerifyEmail, ForgotPassword and ResetPassword use native Identity tokens and SES integration. RequireConfirmedEmail is false. Mechanism implemented; middle-step admission and staged migration policy remain to implement
Profile completion Legacy Angular complete-profile-info is reachable only through the deployed Auth0 Action redirect; no application code navigates to it. No native pre-token profile-completion gate exists. Cutover regression unless PR B supplies the critical-path completion page and gate
Migration Export parsing, reconciliation, dry-run/import/verify/readiness and durable campaign controls exist. Tooling implemented; preservation defects/gaps remain; no live run
Forwarded headers IdentityForwardedHeadersTests and startup/chart tests cover configured forwarding. Foundation implemented; ingress acceptance still required

Specific migration evidence requiring correction before rehearsal:

  • InvestigatorReconciler groups duplicate Investigator Auth0 IDs and takes the first record, rather than rejecting the ambiguous mapping.
  • InvestigatorReconciler also breaks on the first Auth0 identity in an email group that matches any Investigator. Contradictory mappings inside one merged account are therefore resolved by iteration order.
  • Unmatched manifest entries can retain a null SyrfUserId and still be imported.
  • UserImporter hard-codes EmailConfirmed=true instead of preserving the manifest verification state.
  • MigrationManifestEntry carries one aggregate EmailVerified boolean and InvestigatorReconciler derives it with Any across grouped identities. It does not preserve which password/provider identity asserted verification.
  • UserImporter intentionally creates no password hash, requires password reset for password users, and sets TwoFactorEnabled=false. Forced reset is the approved password approach; MFA and passkeys require explicit re-enrolment.
  • The export model preserves core Auth0 identities, but does not currently model MFA enrolment/use, the full login-identities metadata or app_metadata.syrf_id. MigrationManifestEntry and UserImporter drop Auth0 nickname, and UserImporter does not write PreferredName.

Additional cutover evidence:

  • ApplicationService.ResolveInvestigatorIdAsync acquired its GUID-sub fallback on 2026-04-27 as part of the BFF work. It was unreachable under Auth0: all 2,239 stored production subjects are auth0|-prefixed, and the deployed transform-token Action denies non-auth0| logins. Under OpenIddict it would misread an ApplicationUser ID as an InvestigatorId, so the approved closure is removal and fail-closed resolution.
  • No step-up plumbing exists: no auth_time or amr claim and no freshness check is present. PR C must add the evidence and validation.
  • The anonymous POST /api/account/email-lookup endpoint returns raw Investigator GUIDs and is consumed only by the deployed Auth0 Actions. It is a priority removal, but must remain while Auth0 is the actionable rollback because that fallback login flow calls it. It is the first item in the first cleanup wave after the four-review fallback window closes. The count-only sibling (its literal route name is email-lookup-p) remains because the SPA depends on it.
  • Two incidental defects are ticketed outside this parity scope: Project.RetrieveInvitationByToken ignores its token argument, so any token resolves when exactly one invitation is pending (#2729); and the email-lookup endpoints do not lowercase input although stored emails are always lowercased (#2730).

Auth0 behaviour mapping

Auth0 artefact Underlying behaviour Native owner Current state Required closure
transform-token.js (deployed) Stable SyRF ID, conflict checks and namespaced claims Verified mapping service plus UserClaimsService/OpenIddict issuance Claims mostly implemented; identities claim absent; missing mapping does not block issuance Enforce one-to-one mapping, fail closed, cover conflicts, and migrate Angular to the native account-management endpoint without an identities claim
check-email-verification.js (deployed) Verification delivery and user routing without account enumeration ASP.NET Core Identity confirmation/resend plus middle-step admission Confirmation/reset mechanics exist; global confirmation is not required Preserve imported state and implement the approved admission policy
upgrade-social-to-auth0-user.js (deployed) External identity lifecycle, account choice, link notification and metadata ASP.NET Core Identity external logins and account management Existing provider-key sign-in only Implement safe create/link/unlink/recovery/outage/notification flows
request-extra-profile-info.js (deployed) Require names before ordinary application use Native Identity profile-completion page/gate Cutover regression: the Angular route is reached only by the Auth0 Action redirect Add the critical-path completion gate before normal token/application use
link-accounts.js (empty, undeployed) None None No behaviour No replacement is required

Lifecycle and conflict matrix

All failure responses in this table must be non-enumerating. Detailed conflict information is restricted to privacy-safe operator evidence.

Entry point Existing safe behaviour Required behaviour before rehearsal
New password registration Checks Identity email and creates a local user with a new SyrfUserId With confirmed mailbox proof, claim an exact-email Investigator only when it has no Identity account of any kind; otherwise atomically create the new Investigator and immutable mapping. Reject duplicate email, duplicate Investigator, mapping collision and concurrent registration
Password login Native password/lockout/2FA/passkey path Resolve exactly one immutable mapping before normal access; block missing or ambiguous mappings; hold unconfirmed or profile-incomplete users in the Identity middle step without revealing whether another account exists
Known external login Provider-key lookup signs in an already-linked mapped user Preserve this provider-key-authoritative path without any email match; re-check mapping, confirmation/profile state, provider status and recovery-method invariant, refresh the observed provider email, and flag rather than fail sign-in on a newly observed email collision
New external login Unknown provider-key is rejected Keep signup open-access, but require a confirmation page that states a new account is being created, collects missing required profile fields and offers authenticated linking instead. Apply the same claiming/new-Investigator rules as password registration
Same-email local account Email is not currently used to link Keep email-only auto-link forbidden; require sign-in plus recent reauthentication/proof of control before AddLogin
Authenticated linking No account-management flow Reauthenticate by password, passkey or short-lived emailed single-use sign-in link; require the configured second factor for MFA-enabled accounts; validate provider callback/state, reservation and mapping consistency; link atomically, record audit metadata and notify the user
Unlinking/recovery No flow Require the same recent step-up; refuse removal of the last permitted recovery/sign-in method; notify the user; offer support recovery without account enumeration
Provider outage/error Remote error is shown inline Preserve local password/passkey/recovery alternatives for an OAuth error callback, upstream HTTP failure or callback timeout; show a safe retry path and avoid exposing provider or account-match details
Pre-existing loginless Investigator The Auth0 Action pair looks up an Investigator by email and pins the selected Id into app_metadata.syrf_id; native signup instead mints a new SyrfUserId Preserve historical app_metadata.syrf_id claims during migration. For remaining credential-less records, claim only on exact confirmed-email proof and notify project owners when the claimed record holds live membership or ownership
Migration import Auth0 rows are grouped by email; Investigator is reconciled by Auth0 ID Fail closed on duplicate email groups, duplicate Auth0 IDs, duplicate Investigator IDs, contradictory mappings/provider keys, missing stable mappings and unknown providers; preserve app_metadata.syrf_id rather than inferring a fresh email link
Token issuance/userinfo/BFF callback Claims are emitted from ApplicationUser; missing SyrfUserId is omitted; email_verified is added only by Userinfo; the BFF resolver can fall back from user_id to GUID-shaped sub Require one verified, immutable mapping and every admission gate; reject ambiguous/missing mapping before authorization code, token, userinfo or BFF account resolution; never treat Identity sub as InvestigatorId

Claims contract

The current native claim mapping covers:

  • https://claims.syrf.org.uk/user_id from the verified SyrfUserId;
  • https://claims.syrf.org.uk/syrf_groups and role;
  • names, preferred name, picture and email;
  • standard sub from the Identity account ID; and
  • standard email_verified from Identity confirmation state in userinfo only.

The authorization and refresh principals do not currently add email_verified, and the claim destination logic has no email_verified case. Token-path parity is therefore a gap, not an implemented capability.

Before rehearsal:

  1. Claims must be produced by one tested service for authorization code, refresh, ID token, access token and /connect/userinfo paths.
  2. A normal end-user token without a verified SyrfUserId is forbidden.
  3. Group/role and name claims must reflect the preserved authoritative records, not untrusted external claims.
  4. The current Angular consumer of https://claims.syrf.org.uk/identities must migrate to a native authenticated account-management endpoint. No transitional identities claim is permitted.
  5. Tests must prove that an unverified email or altered provider claim cannot create, replace, or select an Investigator mapping.
  6. BffAuthController and ApplicationService.ResolveInvestigatorIdAsync must require the verified user_id mapping. Missing/invalid user_id must not fall back to GUID-shaped sub or create an Investigator implicitly.
  7. Token-destination tests must prove email_verified is present with the correct boolean wherever the approved client contract requires it, as well as in userinfo.

Email confirmation and resend matrix

RequireConfirmedEmail remains disabled permanently: SignInManager would reject an unconfirmed user before the middle-step session could exist, hard-locking exactly the dormant and non-completing cohorts the middle step admits. Confirmation is enforced solely at the authorization/admission gate.

User cohort State to preserve Required launch behaviour
New password user Starts unconfirmed Hold in the Identity middle step until confirmation and profile completion
Migrated confirmed user Auth0 email_verified=true Import as confirmed after provenance validation
Migrated unconfirmed user Auth0 email_verified=false Preserve as unconfirmed and hold in the Identity middle step. The observed population is 523 Auth0 users as of the 2026-08-10 management API count
Password-reset user Existing confirmation state A reset completed through the emailed token flow confirms the email; an in-session password change never does. The campaign's emailed links therefore confirm password users who complete them; non-completers and dormant users remain the main middle-step cohort
External-provider user Provider email and verification claim are provider assertions SyRF confirms the address itself. A provider's verified-email assertion never satisfies the local confirmation gate, so a Google user still receives one confirmation email; the UI must say why. Provider assurance never links accounts

Resend must be non-enumerating, rate-limited, idempotent, auditable without email addresses in public logs, and use a local/allowlisted return path. Preview and staging tests use an email sink and synthetic addresses only.

Middle-step admission model

An unconfirmed or profile-incomplete user may authenticate at the Identity service by password, external provider or passkey. Identity establishes an Identity-service session only. That session permits confirm-email/resend, complete-profile, sign-out and support pages. Identity issues no authorization code or tokens, serves no /connect/userinfo response and creates no BFF or application session until every admission gate passes.

AuthorizationController.Authorize already applies this pre-issuance pattern to RequiresPasswordReset. Confirmation and profile completion extend that proven gate. The Angular application never handles a partially authenticated user. Project-invitation acceptance is confirmation-gated automatically because an unconfirmed registrant cannot enter the application to accept an invitation.

RequireConfirmedEmail remains disabled permanently — the admission gate is the enforcement point. Enabling the global option would block unconfirmed users at SignInManager before the middle-step session could be established.

After issuance, any account mutation that invalidates an admission gate — an email change that makes the address unconfirmed or any change to required profile state — rotates the Identity security stamp. Existing Identity and BFF sessions and refresh paths must re-evaluate the gates at their next validation and return the user to the Identity middle step. The staleness bound is explicit: PR B pins the Identity security-stamp validation interval to at most five minutes (the ASP.NET Core Identity default of thirty is too stale for a gate), and the API-side bound is one access-token lifetime, since gates are re-checked at every refresh. A gate-invalidating mutation therefore takes effect within five minutes at Identity and one token lifetime at the API. For immediate per-user effect at the API, a gate-invalidating mutation also triggers the same per-user BFF session revocation that PR C requires after link, unlink or support recovery — the environment-wide session generation is not a per-user lever and is reserved for provider switches and rollbacks.

Profile completion

All password and external-provider users missing first name, last name or preferred name must complete a native Identity page before normal token issuance/application use. This deliberately extends the deployed Auth0 Action, which exempted non-auth0| subjects and therefore allowed Google-only users to bypass profile completion. The same component supplies the external-signup confirmation page. It must:

  • state the required fields without exposing migration/account-match details;
  • validate and store canonical given, family and preferred names;
  • use a short-lived, one-account continuation bound to the original local return URL;
  • be safe to retry and resume; and
  • prevent external claims from overwriting user-approved profile values on later sign-ins.

Migration populates PreferredName with a clear precedence: a matched Investigator's canonical preferred name wins, the Auth0 nickname is the fallback, and the first name is the default when both are absent — the SyRF record is authoritative and an IdP nickname must not overwrite it. MigrationManifestEntry and UserImporter currently drop the nickname entirely, which would otherwise trip the gate for every migrated user. The legacy Angular route is not an alternative after cutover: it has no application navigation path and is reached only by the Auth0 Action redirect.

Account claiming for pre-existing loginless Investigators

The 2026-08-10 read-only production aggregation found 4,863 pmInvestigator records. Of these, 2,239 carry an Auth0Id and 2,624 do not: 2,615 have no field and 9 have a null field. Within the 2,624, 2,533 have real sign-in history from 2017–2023, 91 never signed in and 785 hold live project membership or ownership. No duplicate-email Investigators were found.

Invitations are a separate, smaller population: 1,615 total, with 266 pending; 151 pending invitations are already linked to an Investigator and 115 are unlinked.

The deployed Auth0 Action pair is the only mechanism that reattaches a login to a pre-existing Investigator: it performs an email lookup through the anonymous endpoint and pins the answer into app_metadata.syrf_id. That mechanism ends at cutover. Current native code then hard-locks the affected user: signup always mints a fresh SyrfUserId, after which the first API call either throws the email guard in ApplicationService or reaches the unique-index duplicate-key failure in UserHasRegisteredHandler.

The approved replacement has two layers:

  1. PR D migration uses app_metadata.syrf_id from the Auth0 export. These are the Action's recorded historical claim decisions and are authoritative migration evidence, not a fresh email inference. The export parser must be verified and extended to read them.
  2. PR A registration or first sign-in may attach an exact-email Investigator only after confirmed-email proof and only while the credential-less predicate holds: no Identity account maps to the record and PR A's persisted mapping history contains no tombstone showing that it was ever mapped. The tombstone survives Identity account deletion, so a deleted account's returning user enters support recovery rather than claiming the record again. The accepted proof is the same on every path: completion of SyRF's own email confirmation. An external provider's verified-email assertion does not qualify (decision 2, superseded 2026-08-18), so a Google first sign-in confirms locally before the claim applies — the claim is irreversible and must not bind on weaker proof than ordinary app access already demands. It uses that record's Id as SyrfUserId instead of minting another. When the claimed dormant record has live membership or ownership, notify the affected project owners. A deactivated Investigator is never claimable: Deactivated is an authoritative operator lockout, so a claim attempt against a deactivated credential-less record fails closed with non-enumerating helpdesk guidance rather than resurrecting the account.

The claimable-Investigator lookup compares the canonical trimmed, lowercased email form on both sides; stored Investigator emails are always lowercased, and the related input-normalisation defect class is tracked in #2730.

The general rule remains that email never attaches an existing Investigator. The sole exception is the credential-less record described above: confirmed mailbox proof is the strongest available proof and matches the assurance already accepted by password reset. The exception lapses permanently as soon as an Identity account first holds the mapping because the persisted tombstone remains after that account is deleted.

Inviting creates no Investigator record. Invitations are email-keyed entries in the Project aggregate. The native InvestigatorCreatedEvent claiming flow remains unchanged for genuinely new users, and the middle-step admission model prevents an unconfirmed user from reaching invitation acceptance.

Migration preservation matrix

Datum Required handling Current state
SyrfUserId/InvestigatorId Preserve exactly from Investigator Auth0 ID or historical app_metadata.syrf_id; one-to-one verified mapping; no fresh email inference Reconciled from Investigator Auth0 ID, but app_metadata.syrf_id, ambiguity and null import are not fail-closed
OIDC sub New immutable Identity account ID; never replace InvestigatorId Implemented architecture
External provider links Preserve provider plus immutable provider key; validate uniqueness Google link import exists; full lifecycle/conflict verification incomplete
Roles/groups Preserve from the authoritative Investigator record Imported as SyrfGroups
Email verification Preserve each source identity's provider, immutable provider subject, asserted email_verified value and export observation time; derive local confirmation only under the approved assurance policy Manifest currently collapses grouped identities to one Any-derived boolean; importer then incorrectly forces true
Blocked/deactivated state Preserve Auth0 blocked and Investigator Deactivated as indefinite native lockout until an authorised operator changes the authoritative source state Reconciler/importer carry both states into LockoutEnd, but the parity fixtures do not yet make this an explicit gate
Profile metadata Preserve canonical names; PreferredName precedence is the matched Investigator's canonical value, then Auth0 nickname, then first name; provider pictures remain non-authoritative Partial; MigrationManifestEntry/UserImporter drop nickname and PreferredName
Password Do not export Auth0 hashes; forced reset Implemented migration intent
MFA Do not claim portability; aggregate read-only inventory and target optional re-enrolment/recovery communications Import disables 2FA; inventory/enrolment-use evidence is external
Passkeys/WebAuthn Biometrics remain on device; no credential import; require secure re-enrolment and alternative recovery Native capability exists; migration state/communication remains
Password-reset state Mark password cohorts for forced reset; campaign is durable and idempotent Implemented foundations; no live campaign

No real export may be taken until the user approves the proposed restricted retention/access schedule. Public evidence remains aggregate and redacted. The user is the sole human operator/approver; no additional access is inferred.

Focused implementation PRs

These are vertical, independently reviewable PR boundaries. Each PR must include its automated tests and must merge before the rehearsal PR.

PR A — Identity mapping and issuance invariant

  • Introduce one authoritative verified Identity-to-Investigator mapping service.
  • Persist mapping-history tombstones in the mapping service's store so the credential-less claiming exception remains spent after Identity account deletion and returning users follow support recovery.
  • Enforce global one-to-one and provider-key uniqueness, including concurrency.
  • Claim a confirmed exact-email Investigator only while it has no Identity account of any kind and has never previously been mapped; make all other new-person allocation transactional with Investigator creation.
  • Notify project owners when a claimed dormant Investigator holds live project membership or ownership.
  • Make password/external login, import and token issuance fail closed on missing or contradictory mappings.
  • Apply the same fail-closed rule to /connect/userinfo, BffAuthController and ApplicationService.ResolveInvestigatorIdAsync; remove the GUID-sub-to-Investigator fallback.
  • Preserve Identity account ID as sub and stable InvestigatorId as namespaced user_id.
  • Emit email_verified consistently under the approved token/userinfo contract.

Acceptance: tests cover the bounded credential-less claiming exception, permanent lapse after mapping, including deletion of the mapped Identity account followed by refusal of a new claim, project-owner notification, the deactivated credential-less record refusing every claim, the untrusted-provider path refusing to claim before local confirmation, duplicate email, duplicate Investigator, duplicate provider key, concurrent registration, and the claiming-specific race: concurrent claim attempts against one Investigator yield exactly one winner and a fail-closed loser, including a claim racing a plain registration on the same email. Also covered: unlinked/null mapping, attempted email fallback outside the exception, altered external claims, authorization code issuance, refresh, /connect/userinfo and the BFF callback/application resolver. Tests prove a GUID-shaped Identity sub can never create or select an Investigator.

PR A2 — Native account-management endpoint and Angular migration

This companion is separate from PR A's mapping invariant. It adds an authenticated native account-management endpoint and migrates the Angular account UI to it. It must not expose provider credentials or use an email/provider claim to create a link. The rejected transitional identities claim path must not exist.

Acceptance: account-management UI tests cover the native contract, linked and unlinked states, multiple providers, missing metadata and non-enumerating failure. Tests prove no transitional identities claim is emitted.

PR B — Confirmation and profile-completion gates

  • Preserve imported verification state.
  • Implement the approved new/migrated/password/external admission matrix and Identity-only middle step before authorization-code issuance.
  • Add non-enumerating, throttled resend and middle-step verification UX.
  • Confirm the local email on password resets completed through the emailed token flow only; an in-session password change never confirms.
  • SyRF confirms every email address locally regardless of external provider. A provider's verified-email assertion never satisfies the local confirmation gate. A Google user therefore receives a confirmation email that looks redundant; the checkpoint must carry the reason (decision 2, superseded 2026-08-18).
  • Add native required-profile completion for first name, last name and preferred name before normal access, including external-provider users.
  • Use one component for profile completion and external-signup confirmation.
  • Rotate the Identity security stamp when an email or required-profile mutation invalidates an admission gate so issued sessions and refresh paths re-enter the middle step on their next validation, and trigger the same per-user BFF session revocation PR C provides for link/unlink so the API-side effect is immediate rather than waiting out a token lifetime.
  • Keep return URLs local/allowlisted and provider claims non-authoritative.

Acceptance: tests cover every cohort in the confirmation matrix, resend enumeration/throttling/idempotency, reset-confirms behavior, all external-provider users confirming locally (including Google, checkpoint must carry the reason), incomplete password/external profiles, nickname migration/defaulting, absence of code/token/userinfo/BFF issuance from the middle step, an email change after issuance dropping the existing session back to the middle step, continuation replay and forwarded scheme/host handling.

PR C — External identity lifecycle

  • Keep external signup open-access and add an explicit confirmation page that states a new account is being created, collects missing required details and offers sign-in-to-link instead.
  • Require step-up reauthentication no more than five minutes before linking or unlinking. A long-lived BFF session alone is insufficient. Password, passkey or a short-lived emailed single-use sign-in link proves the primary factor; an MFA-enabled account must also complete its configured second factor. Expired evidence returns to a local allowlisted continuation after a fresh step-up.
  • Add the missing native step-up plumbing, including auth_time, amr and the freshness check.
  • Enforce provider-key uniqueness across accounts.
  • Reserve each external provider account for at most one SyRF account. The email address of a linked external provider account may not be the sign-in email of any other SyRF account.
  • Refuse linking an external provider account to S1 when its email is the sign-in email of a different account S2. Refuse new password registration when its email belongs to an external provider account already linked elsewhere. For example, these rules apply to Google at launch. Use non-enumerating messaging with a helpdesk pointer.
  • Serialize every operation that takes or contests an email — password registration, provider linking and local sign-in email changes — through one uniquely constrained reservation record in the same store owned by PR A's mapping service. Exactly one operation wins; the loser fails closed without account enumeration.
  • Refresh the observed provider email at every external sign-in, and surface a refreshed-email collision with another account's sign-in email as a helpdesk/operator flag that blocks new registrations/links without failing the linked user's authentication.
  • Canonicalize every reservation key to the trimmed, lowercased email form on both write and lookup, the same normalisation the claiming lookup uses.
  • Implement safe unlinking and last-recovery-method protection, releasing the provider-email reservation when its link is removed (unless the address is the account's own sign-in email) and when the owning account is deleted.
  • Send link and unlink security notifications using wording ported from the existing SES/Auth0 transactional templates and helpdesk@syrf.org.uk as the support contact.
  • Preserve local password/passkey/recovery paths during provider outage.
  • Rotate the Identity security stamp and the per-user session version after link, unlink, support recovery or account deletion (revocation runs before or atomically with the Identity record deletion) — never the environment-wide BFF session generation, which is reserved for provider switches and rollbacks; revoke refresh tokens and all BFF sessions, and reject outstanding access tokens through the chosen revocation/version mechanism.

Acceptance: integration/E2E tests cover new external user and its confirmation, known linked user, both reservation refusal cases, and the registration-versus- provider-link race in both directions with exactly one winner and a fail-closed, non-enumerating loser. Also covered: same-email local account, authenticated linking, unauthenticated/email-only linking rejection, five-minute step-up expiry, password/passkey/emailed-link factors, MFA completion, unlink/recovery, OAuth error callback, upstream HTTP failure, callback timeout, session/refresh/access-token invalidation, provider-email refresh at sign-in, the drift-collision flag that blocks new registrations/links without failing the linked user's authentication, notifications in both directions and safe return URLs.

PR D — Migration parity and readiness

  • Reject ambiguous/unmapped rows and unknown provider states. Cover both the GroupBy(...).First() ambiguity and the first-match break at InvestigatorReconciler.cs:167-175; contradictory merged-account mappings must fail closed rather than depend on iteration order.
  • Preserve SyrfUserId from Investigator Auth0Id or authoritative historical app_metadata.syrf_id, provider links, roles/groups, names, per-source verification provenance, Auth0 blocked state and Investigator Deactivated state exactly. Verify or extend the export parser for app_metadata.syrf_id, and require the operator export request to include app_metadata explicitly — Auth0 bulk exports return it only when the field list asks for it, so an export taken without it silently empties Layer 1 of the claiming design.
  • Preserve each imported link's observed provider email in the manifest and backfill the reservation store for every imported provider link, so the provider/email reservation invariant is true from the first post-import sign-in rather than only for links created natively.
  • Populate PreferredName through MigrationManifestEntry/UserImporter with the agreed precedence: matched Investigator's canonical preferred name, then Auth0 nickname, then first name.
  • Retain forced password reset.
  • Represent MFA/passkey re-enrolment explicitly without pretending credentials were migrated.
  • Extend verify/readiness evidence for all preserved fields and conflict cases.

Acceptance: fixture-based import/verify/readiness tests cover every row in the migration matrix, including app_metadata.syrf_id, both first-match defects, mixed-verification merged accounts, nickname/defaulted PreferredName and independently blocked/deactivated accounts. Reruns are idempotent, no secret or user identity appears in argv/GitOps/public evidence, and rollback leaves the source/Auth0 path unchanged.

Dependencies: PR A establishes the mapping contract first, and it delivers the mapping store's primitives that later PRs consume — the mapping history tombstone and the uniquely constrained reservation record. PRs B and C can then proceed in parallel; PR C's reservation-race enforcement and tests explicitly depend on PR A's reservation primitive being merged. PR A2 implements the resolved native-endpoint decision. PR D may proceed in parallel after A, but its confirmation and external-state schema must follow B and C. The rehearsal is serial after A–D and A2 are merged and all acceptance suites are green.

Isolated synthetic rehearsal

The BFF synthetic rehearsal is a separate SyRF plus cluster-gitops change after PRs A–D, A2 and the Identity-only S08 dark launch:

  1. Pin immutable, validated API and Identity artifacts.
  2. Add an isolated preview/staging namespace using established SyRF chart/package ownership and cluster-gitops Argo/Helm/Kustomize/ExternalSecret/operator patterns only. Do not introduce Terraform or manual cloud configuration.
  3. Use a dedicated Identity database, Redis namespace, secret references, encryption keys, OAuth clients and allowlisted callbacks.
  4. Use synthetic identities and an email sink. Do not send real-user email or copy production/Auth0 user data.
  5. Keep Auth0 live and the default browser/API authentication path. Expose the rehearsal only through an explicit restricted test route/client.
  6. Run the full matrix for password, confirmation/resend, forced reset, passkeys, optional MFA, external login/link/unlink, profile completion, claims, API/BFF/Swagger authorization, sessions, SignalR and rollback.
  7. Test trusted and untrusted X-Forwarded-Proto/X-Forwarded-Host cases through the actual ingress; assert generated callback and email URLs.
  8. Roll back by removing only the isolated Argo application/revision and its synthetic resources. Auth0, production traffic, live users and live data remain unchanged.

Real Google-provider validation requires a later, explicit sole-operator action to provision/approve a non-production client and exact callback. Secret values must never be committed or included in public evidence.

Resolved product decisions (2026-08-10)

  1. Migration preserves source email verification state and never forces EmailConfirmed=true. The 523 observed unverified Auth0 users enter the Identity middle step. They do not enter a constrained Angular experience.
  2. SUPERSEDED 2026-08-18 (design review): SyRF confirms every address itself, social logins included. A provider's verified-email assertion never satisfies the local confirmation gate. One gate and one code path for every account, and the once-only claim can no longer bind on weaker proof than ordinary app access already demands. A Google user therefore receives a confirmation email that looks redundant, so the checkpoint must carry the reason. Confirmation never links accounts. (Originally: a verified email from an explicitly trusted provider, initially Google, could confirm the local address.)
  3. A password reset completed through the emailed token flow confirms email — that flow proves mailbox control. An in-session password change (including the forced-reset change-password path) proves nothing about the mailbox and never confirms. The forced-reset campaign delivers emailed reset links, so completing it confirms password users; non-completers and dormant users remain the main middle-step population.
  4. First name, last name and preferred name are required before ordinary access. The gate includes external-provider users. Migration populates PreferredName by precedence: the matched Investigator's canonical value, then Auth0 nickname, then first name. Profile completion and external-signup confirmation use one component.
  5. PR A2 supplies a native authenticated account-management endpoint and Angular migration. No transitional identities claim is emitted.
  6. External signup remains open-access through an explicit create-new confirmation step with an authenticated link-existing alternative. Password, passkey or a short-lived emailed single-use link may authenticate the existing account; MFA-enabled accounts still complete their second factor. Google account/provider-email reservations fail with non-enumerating helpdesk guidance when they conflict.
  7. Link and unlink both send security notifications. Wording is ported from the existing SES/Auth0 transactional templates and directs support requests to helpdesk@syrf.org.uk.
  8. ApplicationService.ResolveInvestigatorIdAsync loses its GUID-sub fallback and fails closed. The fallback was BFF-era code unreachable under Auth0 and would confuse an OpenIddict ApplicationUser ID with an InvestigatorId.
  9. Account claiming for pre-existing loginless Investigators is approved. PR D preserves historical app_metadata.syrf_id claims, while PR A permits the bounded confirmed-mailbox claim for a still-credential-less record.

The earlier settled decisions remain unchanged: forced password reset; optional non-mandatory MFA; passkeys at launch with recovery; preservation of social providers/links; stable InvestigatorId; OpenIddict-compatible Swagger OAuth; Auth0 Actions PR #2639 held; GitOps/operator delivery only; and no replacement for the empty undeployed Link Accounts Action.