Let an OIDC-only account delete itself via step-up re-authentication #82
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/step-up-reauthentication"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes Vikunja task #152. Recorded as ADR-0072 (next free number, checked against
mainand the one other open PR, #81, which adds 0071).DELETE /api/users/meaccepts a step-up proof in place of a password. A newPlaceMark.Api.Authentication.StepUpfeature reuses ADR-0032's existing OIDC challenge/callback endpoints under a thirdOidcFlowMode, and addsPOST /api/auth/step-up/begin(authenticated, recordsuser_id+ purpose behind a 60s handle) andPOST /api/auth/step-up/redeem(turns the resulting handback code into a two-minute, single-useStepUpProof, requiring both that the redeeming caller is authenticated as the account the handle recorded and that the freshly verified provider identity resolves — throughExternalIdentityRepository— to that same account, which is what proves a live re-authentication with the account's own already-linked identity rather than merely "holds a valid token"). No grace window, unlike the refresh token: a step-up proof has no legitimate multi-tab replay case, so reuse is refused outright every time (see ADR-0072's discussion of why ADR-0042's reasoning doesn't carry over).DeleteAccountRequestgains an optionalStepUpToken, mutually exclusive withPasswordviaIValidatableObject.POST /api/auth/step-up/beginalso runs ADR-0019's sole-owner-of-a-shared-group check (advisory, via a new read-onlyAccountDeletionRepository.FindSoleOwnedSharedGroupsAsync) before minting a handle, so a caller who's already ineligible isn't sent through a whole provider round trip only to be told so at the end — the authoritative check remainsDeleteAsync's own locked transaction, unchanged.No WebUI surface: there's no account-deletion button in
PlaceMark.WebUIyet, so nothing consumes the two-phase flow client-side today.Full reasoning, alternatives considered and what's deliberately left open (step-up before linking/unlinking, a WebUI surface) are in ADR-0072.
Verdict: changes needed
Test gap on the crux binding check. In
StepUpEndpoints.PostRedeem, disabling thecallerUserId != challenge.UserIdcheck (the "redeeming caller is authenticated as the account the handle recorded" comparison — ADR-0072's CSRF closure) does not fail any test: I mutated it locally and all fiveStepUpEndpointsTestsstill pass.PostRedeem_StepUpHandleWasMintedForADifferentAccount_RefusesTheProofis the test that should catch this, but it passes for an unrelated reason — the provider identity driven through the callback in that test (subject-victim-again) was never linked to any account, so the laterprovenUserId is nullcheck refuses the request regardless of whether the caller-binding check ran. The two checks aren't independently exercised: only theprovenUserIdcheck is proven by the suite (confirmed separately — disabling that one does failPostRedeem_VerifiedIdentityResolvesToADifferentAccountThanTheChallenge_RefusesTheProof). Since this is named as the crux of the ticket, the caller-binding check needs its own test where the driven identity does resolve to an existing account (e.g. the victim's own already-linked identity), so removing that specific check is what causes the failure.Branch doesn't merge cleanly.
mainhas moved (PR #81 landed ADR-0071 after this branch was cut) and merging now conflicts indocs/adr/README.md— both PRs add adjacent rows to the index table. No real numbering collision (ADR-0072 is still free), just the table entry; needs a rebase before merge.Everything else held up under review: token binding to account and purpose is enforced at redemption (not just recorded at issue),
SingleUseStore.TryConsume'sTryRemove-then-check is race-free for concurrent redeems/deletes, handles are 32 bytes fromRandomNumberGenerator, expiry is checked server-side and fails closed, the in-process-store/single-instance tradeoff is stated in ADR-0072 rather than assumed.DeleteAccountRequest's mutual exclusivity correctly rejects neither/both via one XOR check, and a local-only account can't get a valid step-up token in the first place. ADR-0019's authoritative check still runs unchanged insideDeleteAsync's locked transaction regardless ofPostBegin's advisory pre-check. Rate limiting is inherited correctly via the route-prefix match. The stub OIDC provider is a genuine second Kestrel host doing a real code exchange with PKCE verification, not a substitute. No schema changes, contracts stay off the wire, build and full test suite (including Testcontainers) are clean on SDK 10.0.100, CI is green on the head commit.Both addressed at
3c35148.PostRedeem_StepUpHandleWasMintedForADifferentAccount_RefusesTheProofrenamed toPostRedeem_CallerIsNotTheAccountTheStepUpHandleWasMintedFor_RefusesTheProofand rebuilt: the attacker now completes an entirely legitimate step-up round trip for their own account (soprovenUserIdgenuinely equalschallenge.UserId), and only the redeem call's bearer token is the victim's — isolating the caller-binding check. Verified by mutation: deletingcallerUserId != challenge.UserIdturned it red (200 instead of 401); restoring it turned it green again, with no diff left behind.Audited the rest of the file for the same shadowing risk.
PostRedeem_VerifiedIdentityResolvesToADifferentAccountThanTheChallenge_RefusesTheProofpasses the caller-binding check on purpose (same account throughout) so onlyprovenUserIdis left to refuse it — confirmed by the same mutation exercise, red on removal, green on restore, with the fixed test above staying green throughout so there's no new cross-masking between the two.Merged
origin/main; the only conflict was the ADR index row, resolved keeping both — 0071 then 0072.Full CI sequence (build, test — 1412 tests across all six projects,
dotnet format --verify-no-changes) clean under the pinned SDK.Verdict: mergeable
Both follow-ups from review 134 check out:
callerUserId != challenge.UserIdcheck turnsPostRedeem_CallerIsNotTheAccountTheStepUpHandleWasMintedFor_RefusesTheProofred (200 instead of 401) while the other four tests in the file stay green. Traced the guard order inPostRedeemfor this scenario by hand: handback consumption and hash checks pass,IsAuthenticatedis trivially true, and the attacker's own identity is genuinely linked to their own account, soprovenUserId == challenge.UserIdwould also pass — the caller-binding check is the only thing that can fail the request. The isolation is real.PostBegin_CallerIsSoleOwnerOfASharedGroup_RefusesWithoutMintingAHandlehas only the one guard it's testing;PostRedeem_OidcRedeemGivenAStepUpHandbackCode_Refuseshits a deterministic switch arm with no earlier check that could produce the same 401 for a different reason. No masking found elsewhere.docs/adr/README.mdmerge resolution is clean: 0071 and 0072 both present in numeric order, nothing else dropped (diffed the merge commit against its main-side parent).Build and full test suite (542 + 284 including Testcontainers) clean on SDK 10.0.100. CI green on
3c35148(run #392).