Skip to content

feat(auth): remove the signed-in user's MFA recovery codes - #611

Merged
Tr00d merged 1 commit into
masterfrom
feat/auth.mfa_recovery_codes.unenroll
Oct 30, 2026
Merged

Tr00d merged 1 commit into
masterfrom
feat/auth.mfa_recovery_codes.unenroll

Conversation

@Tr00d

@Tr00d Tr00d commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Summary

A signed-in user at aal2 can remove their MFA recovery codes factor, which invalidates every remaining code.

Capability: auth.mfa_recovery_codes.unenroll (MFA Recovery Codes Unenroll). There is no capability spec for it; the contract is the auth OpenAPI DELETE /factors/recovery-codes.

Public API

// IGotrueClient<TUser, TSession>, MFA region, after GetAuthenticatorAssuranceLevel
Task UnenrollRecoveryCodesAsync(CancellationToken cancellationToken = default);

// IGotrueApi<TUser, TSession> / Api (wire call)
Task UnenrollRecoveryCodesAsync(string jwt, CancellationToken cancellationToken = default);
await client.ChallengeAndVerify(new MfaChallengeAndVerifyParams { FactorId = totpFactorId, Code = code });
await client.UnenrollRecoveryCodesAsync();

No new types.

Behavior

  • Operation: DELETE /factors/recovery-codes with the session's access token as Authorization: Bearer, plus the API key header.

    • Requires an aal2 session.
    • Works whether or not the auth server enables recovery-code enroll or verify.
  • Checks before the wire: an offline client throws GotrueException (Reason Offline). No session, or a session without an access token, throws GotrueException (Reason NoSessionFound). Neither sends a request.

  • Output: the method completes on 200. The server's { id } body (the removed factor id) is not surfaced.

  • Failures: each throws GotrueException carrying the server's ErrorCode:

    • 403 insufficient_aal when the session is not at aal2;
    • 404 mfa_factor_not_found when the user has no recovery codes;
    • 403 no_authorization for an anonymous user;
    • 403 bad_jwt for an expired token.

    Reason follows the existing error_code and status mapping. No FailureHint.Reason member is added.

  • Cancellation: the token reaches the HTTP call.

  • Server-side effects:

    • deletes the factor and its codes;
    • writes the recovery_codes_deleted audit entry;
    • lowers sessions that reached aal2 with a recovery code to aal1;
    • sends the factor-unenrolled email when that notification is enabled.
  • SDK state:

    • no session refresh and no auth event;
    • CurrentSession.User.Factors keeps the removed factor until the user is next fetched or the session refreshes.

    Both match auth-js and the existing Unenroll.

  • Span: a new gotrue.unenroll_recovery_codes domain span parents the HTTP span.

Deviations from supabase-js

  • No experimental opt-in. auth-js throws unless auth.experimental.recoveryCodes is set. Here the XML docs say the feature is experimental on the auth server.
  • Returns Task rather than { id }. A user has a single recovery-code factor, so the id adds nothing for the caller.
  • No session refresh before the call. auth-js's _useSession refreshes an expired session first. This SDK uses the current access token, like its neighbours, so an expired token is rejected with bad_jwt.

Tests

Tier Class Pins
E2E UnenrollRecoveryCodesTests An aal1 session is rejected with insufficient_aal. An aal2 session (after TOTP verify) without recovery codes is rejected with mfa_factor_not_found.
Contract UnenrollRecoveryCodesContractTests Request (method, path, bearer, apikey); completion on 200; insufficient_aal and mfa_factor_not_found rejections; Offline and NoSessionFound before the wire; cancellation; no context capture.
Contract ObservabilityContractTests The domain span parents the HTTP span.

No approval test, because there is no request body.

Changed-line coverage: 100% (9 lines). Mutation: 7/7 on changed lines (6 killed, 1 timeout), 0 survivors.

Notes

  • Happy-path E2E waits on the CLI. The local stack runs auth v2.197.0, which serves this endpoint. But the auth server's GOTRUE_MFA_RECOVERY_CODES_ENROLL_ENABLED defaults to false, and the Supabase CLI (2.120 and 2.122) exposes no config.toml setting for it. So the stack cannot create recovery codes, and a successful removal is pinned at the contract tier. Add the E2E once the CLI exposes the recovery-codes enroll setting.
  • Merge order. This PR is independent of auth.mfa_recovery_codes.get_status (feat(auth): read how many MFA recovery codes the signed-in user has left #610). Whichever merges second needs a rebase over adjacent lines in IGotrueClient, IGotrueApi, Api, Client, GotrueInstrumentation.Spans, PublicAPI.Unshipped.txt, the test csproj and ObservabilityContractTests.

@Tr00d
Tr00d force-pushed the feat/auth.mfa_recovery_codes.unenroll branch from ba27c60 to 9028dd9 Compare October 30, 2026 13:44
@Tr00d
Tr00d marked this pull request as ready for review October 30, 2026 13:47
@Tr00d
Tr00d merged commit d4ea5eb into master Oct 30, 2026
6 checks passed
@Tr00d
Tr00d deleted the feat/auth.mfa_recovery_codes.unenroll branch October 30, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant