Validate reconfigure before mutating the credential store - #167
Merged
Conversation
`reconfigure` wrote or cleared the OS credential store before it had validated anything, and only wrote settings.json on success. A failed reconfigure could therefore delete a working client secret while leaving settings.json still configured to use one. Since Entra only reveals a client secret at creation time, recovery meant minting a new one. Reordering alone doesn't work: WriteCredential had to stay ahead of validation because StoreAPIFactory called ReadCredential to build the client it validates with. Deferring only the ClearCredentials branch is also wrong - it would leave the previous secret in the store during validation, where the certificate-file path would silently use it as the PKCS#12 password. Instead, make the secret an explicit input to validation. IStoreAPIFactory gains CreateWithSecretAsync(config, secret, ct), where a null secret unambiguously means "this configuration has no secret" and never falls back to the credential store. CreateAsync now delegates to it after reading the stored credential, so every other call site is unchanged. ConfigureAsync validates the candidate configuration with the secret it was given, and only once that succeeds does it write the new credential, save settings.json, and finally clear a credential the new configuration no longer needs. Clearing is the one irreversible step, so it happens last. This covers all three credential modes: client secret, certificate (thumbprint and file), and client assertion. Also makes the validation retry back-off overridable, mirroring StorePackagedAPI.DefaultSubmissionPollDelay, so failure-path tests don't sit through 20s of real retries. Fixes #166 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Alexandre Zollinger Chohfi (azchohfi)
requested a balanced review from Copilot
August 29, 2026 04:17
Copilot started reviewing on behalf of
Alexandre Zollinger Chohfi (azchohfi)
August 29, 2026 04:18
View session
There was a problem hiding this comment.
Pull request overview
Validates reconfiguration candidates without first mutating stored credentials.
Changes:
- Adds explicit-secret Store API creation.
- Defers credential updates until after validation.
- Adds credential-safety tests and configurable retry delay.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
MSStore.CLI/Services/StoreAPIFactory.cs |
Adds explicit-secret API creation. |
MSStore.CLI/Services/IStoreAPIFactory.cs |
Defines the new factory contract. |
MSStore.CLI/Services/CLIConfigurator.cs |
Reorders validation and credential persistence. |
MSStore.CLI.UnitTests/StoreAPIFactoryUnitTests.cs |
Tests factory credential-store behavior. |
MSStore.CLI.UnitTests/ReconfigureCredentialSafetyUnitTests.cs |
Tests reconfiguration credential safety. |
MSStore.CLI.UnitTests/BaseCommandLineTest.cs |
Configures new mocks and retry seam. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
WriteCredential overwrites whatever is stored for the client ID, so it is destructive too, not just ClearCredentials. With the previous ordering a SaveAsync failure (settings.json locked, disk full, or cancellation - it takes the CancellationToken) left the old secret already overwritten while settings.json still described the old configuration. A client-secret to certificate-password reconfigure would then pair the old configuration with the certificate password, and the original secret is unrecoverable. I had the tiebreaker backwards: a saved configuration whose credential is missing is recoverable, because the user still has the secret they just supplied, whereas an overwritten secret cannot be read back from Entra. So persistence now happens first and both credential mutations happen after it, giving one crisp invariant - a reconfigure that does not succeed leaves the credential store exactly as it found it. Adds coverage for the SaveAsync failure path on both the write branch and the clear branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Alexandre Zollinger Chohfi (azchohfi)
requested a balanced review from Copilot
August 29, 2026 04:25
Copilot started reviewing on behalf of
Alexandre Zollinger Chohfi (azchohfi)
August 29, 2026 04:26
View session
ClearCredentials cannot report failure on any platform: Windows wraps the delete in a bare catch, and the Linux and macOS implementations discard the native status. A credential surviving the clear is therefore silent, and for a password-less certificate file it is harmful - validation runs with a null PKCS#12 password, but every later command reads the leftover secret and hands it to LoadCertificate as the password, so a configuration reported as working is not. Rather than change the ICredentialManager contract and the error handling of three native code paths, confirm the outcome where it matters: read the credential back after clearing and fail with an actionable message if anything remains. This is platform-agnostic and asserts the property that actually matters - the saved configuration behaves the way the validated one did. The write branch already surfaces failures as exceptions on all three platforms, so it needs no read-back. Also fixes the shared ReadCredential test double, which threw on UserNames.Last() once ClearCredentials emptied the lists. Every real implementation returns an empty string for a missing credential, so the fake now does too. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Alexandre Zollinger Chohfi (azchohfi)
requested a balanced review from Copilot
August 29, 2026 04:35
Copilot started reviewing on behalf of
Alexandre Zollinger Chohfi (azchohfi)
August 29, 2026 04:36
View session
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
MSStore.CLI/Services/CLIConfigurator.cs:383
- The suggested
--resetrecovery is unreliable in the exact failure case detected here.ResetAsynccalls the same best-effortClearCredentialsoperation without verifying its result (CLIConfigurator.cs:675-680), then clears the saved settings and can report success while the obsolete credential remains. Remove this recommendation, or make reset verify deletion before advertising it as a remedy.
ctx.ErrorStatus(ansiConsole, $"The configuration was saved, but the obsolete credential for '{clientIdString}' could not be removed from the credential store. Remove it manually, or run 'msstore reconfigure --reset', before using the CLI.");
The recovery advice added in the previous commit pointed at `reconfigure --reset`, but ResetAsync called the same unverified ClearCredentials, then wiped settings.json and reported success even when the credential survived - so it could not actually deliver the remedy it was being advertised for. Reset now clears the credential first and bails before discarding the settings if anything remains, since wiping the configuration while an unremovable credential lingers leaves the machine worse off than it started. The verified clear is shared with ConfigureAsync as TryClearCredentials, and the reconfigure error message no longer suggests --reset, because a credential the OS refuses to delete needs manual removal either way. Adds the first coverage for the reset path: one test for the surviving credential, one for the normal case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Alexandre Zollinger Chohfi (azchohfi)
requested a balanced review from Copilot
August 29, 2026 04:43
Copilot started reviewing on behalf of
Alexandre Zollinger Chohfi (azchohfi)
August 29, 2026 04:43
View session
isourabh
approved these changes
Sep 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #166
The problem
CLIConfigurator.ConfigureAsyncmutated the OS credential store before it validated the new configuration, and only wrotesettings.jsonon success:_storeAPIFactory.CreateAsync(config, ct)— the first real validation_configurationManager.SaveAsync(config, ct)— written only on successThe
elsebranch in step 1 calledClearCredentialswhenever neitherclientSecretnorcertificatePasswordwas supplied. So starting from a working client-secret configuration:deleted the working secret, then failed validation, then never saved — leaving
settings.jsonstill in client-secret mode with the secret gone. Entra only reveals a client secret at creation time, so recovery meant minting a new one.--certificateThumbprintreached the same branch, so this was never client-assertion specific.Why reordering alone doesn't work
Two constraints ruled out simply moving the block:
WriteCredentialhad to stay ahead of validation, becauseStoreAPIFactorycalledReadCredential(clientId)to construct the client it validates with.ClearCredentialsbranch leaves the previous secret in the store during validation. On the certificate-file path that value is passed straight through as the PKCS#12 password inStoreAPIFactory.LoadCertificate, so a password-less.pfxwould silently be opened with the stale client secret — changing behaviour for a mode unrelated to the trigger.Snapshot-and-restore was also rejected: it's a best-effort undo, and the window it has to cover is the retry loop, which sleeps 10s between attempts and prints "Retrying..." — exactly when a user is most likely to hit Ctrl+C.
The fix
Make the secret an explicit input to validation instead of something the factory goes and fetches.
IStoreAPIFactorygainsCreateWithSecretAsync(config, secret, ct), where anullsecret unambiguously means "this configuration has no secret" and never falls back to the credential store.CreateAsyncnow delegates to it after reading the stored credential, so all other call sites are untouched.ConfigureAsyncthen:clientSecret ?? certificatePassword) without touching the store,settings.json, then writes the new credential or removes the obsolete one.Persistence goes first because both credential operations are irreversible —
WriteCredentialoverwrites whatever was stored for that client ID, and a client secret cannot be read back from Entra. A saved configuration whose credential is missing is recoverable (the user still has the secret they just supplied); an overwritten secret is not. That yields one invariant: a reconfigure that does not succeed leaves the credential store exactly as it found it.How this covers all three modes
nullnull— not the stale secretnullnullnullnull(assertion comes from env)Verified credential removal
ClearCredentialscannot report failure on any platform — Windows wraps the delete in a barecatch { }, and the Linux and macOS implementations discard the native delete status. A credential surviving the clear is therefore silent, and for a password-less certificate file it's harmful: validation runs with anullPKCS#12 password, but every later command reads the leftover secret and hands it toLoadCertificateas the password. Reconfigure would report success for a configuration that doesn't work.Rather than change the
ICredentialManagercontract and the error handling of three native code paths,TryClearCredentialsreads the credential back after clearing and reports failure with an actionable message if anything remains. The write branch needs no read-back —WriteCredentialalready throws on failure on all three platforms.reconfigure --resethad the same defect: it cleared without verifying, then wipedsettings.jsonand reported success regardless. It now clears first and bails before discarding the settings if anything remains, since wiping the configuration while an unremovable credential lingers leaves the machine worse off than it started.Tests
20 new tests across two files.
ReconfigureCredentialSafetyUnitTestsswaps in a dictionary-backed in-memory credential store seeded with a working secret, so assertions look at real store state rather than a call log:--clientAssertion,--certificateThumbprint,--clientSecret, and--certificateFilePatheach leave the existing secret intact, withWriteCredential,ClearCredentials, andSaveAsyncall never called.SaveAsyncthrowing on the write branch and on the clear branch both leave the store untouched.ClearCredentialsmust not report success; a successful one is confirmed by read-back rather than assumed.ReconfigureShouldValidateBeforeMutatingTheCredentialStoresnapshots the store during validation and asserts it still held the old secret, and thatReadCredentialis never called.PasswordLessCertificateFileReconfigureShouldNotValidateWithTheStaleSecretpins constraint 2 above.StoreAPIFactoryUnitTestscovers the factory split directly (previously untested):CreateAsyncreads the store,CreateWithSecretAsyncnever does.Each fix was verified to actually catch its bug by temporarily restoring the old behaviour and confirming the relevant tests fail.
main: 170 total, 160 succeeded, 10 skipped, 0 failednet10.0-windows10.0.17763.0: 190 total, 182 succeeded, 8 skipped, 0 failedTwo incidental changes
CLIConfigurator.ValidationRetryDelaymakes the retry back-off overridable, mirroring the existingStorePackagedAPI.DefaultSubmissionPollDelayseam, so failure-path tests don't sit through 3 attempts × 10s of real sleeps. It also fixes the retry message, which now prints the delay actually in use.The shared
ReadCredentialtest double inBaseCommandLineTestthrew onUserNames.Last()onceClearCredentialsemptied the lists. Every real implementation returns an empty string for a missing credential, so the fake now matches that contract.