Skip to content

feat(auth): update a user's MFA factor as an admin - #609

Merged
Tr00d merged 2 commits into
masterfrom
feat/auth.admin.update_mfa_factor
Oct 30, 2026
Merged

Tr00d merged 2 commits into
masterfrom
feat/auth.admin.update_mfa_factor

Conversation

@Tr00d

@Tr00d Tr00d commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A service-role caller can now update a user's MFA factor: rename it, or change the phone number of a phone factor.

Capability: auth.admin.update_mfa_factor — Admin Update MFA Factor. Spec: specs/auth/admin/update_mfa_factor.md.

Public API

// IGotrueAdminClient<TUser>, next to ListFactors / DeleteFactor
Task<Factor> UpdateFactorAsync(string userId, string factorId, FactorChanges changes, CancellationToken cancellationToken = default);

// IGotrueApi<TUser, TSession>, as for every admin neighbour
Task<Factor> UpdateFactorAsync(string jwt, string userId, string factorId, FactorChanges changes, CancellationToken cancellationToken = default);

New types:

  • Supabase.Gotrue.UpdateFactor.FactorChanges (sealed record, init-only): string? FriendlyName, string? Phone.
  • Supabase.Gotrue.Mfa.Factor.Phone (string?, phone): additive member on the existing factor, so callers see the number they set; ListFactors returns it too.
using Supabase.Gotrue.UpdateFactor;

var admin = new AdminClient(serviceRoleKey, new ClientOptions { Url = authUrl });
var factor = await admin.UpdateFactorAsync(userId, factorId, new FactorChanges { FriendlyName = "Work phone" });

Adding a member to IGotrueAdminClient<TUser> and IGotrueApi<TUser, TSession> (both public, all members abstract, no default implementations) breaks third-party implementers of those interfaces, the same as SignOutAsync and the custom-provider members did; callers are unaffected (CONVENTIONS §7).

Behavior

  • Operation: PUT /admin/users/{userId}/factors/{factorId} with the service-role key (Authorization: Bearer <serviceKey>).
  • Inputs: userId and factorId must be canonical hyphenated UUIDs (either case), checked before the wire: null throws ArgumentNullException, anything else ArgumentException naming the parameter. The ids are sent as given. changes null throws ArgumentNullException. Only the members set are sent (friendly_name, phone); none set sends {}, which the server answers with the factor unchanged. An empty value cannot clear a setting. The server ignores phone on a factor that is not a phone factor. The SDK does not check E.164; the server does.
  • Output: the updated Factor. A 200 with no factor throws GotrueException("The auth server returned no factor.").
  • Failures (GotrueException):
Server answer Status ErrorCode Reason
Unknown user 404 user_not_found Unknown
Factor not owned by the user 404 mfa_factor_not_found Unknown
Phone not E.164 (phone factor) 400 validation_failed Unknown
Key without the service_role claim 403 not_admin AdminTokenRequired
Friendly name another factor of the user has 500 Unknown
  • State: none in the SDK. The server updates the factor's friendly_name / phone and updated_at, and writes an audit-log entry.

Deviations from supabase-js

None: supabase-js has no counterpart. Against the spec, only the canonical UUID form is accepted (the spec allows braced, bare and URN forms too).

Deliberately different from the neighbouring ListFactors / DeleteFactor (legacy shape kept as is): Async suffix and CancellationToken, positional ids instead of a mutable params class, an immutable record for the changes, a non-nullable return, and the spec's UUID check before the wire.

Tests

Tier Class Pins
E2E UpdateFactorTests rename, phone ignored on a TOTP factor, mfa_factor_not_found, user_not_found, duplicate friendly name, AdminTokenRequired against the live stack
Contract UpdateFactorContractTests method, path, credentials, ids sent as given, phone factor response mapping, mfa_factor_not_found and validation_failed error codes, empty body, null changes, ConfigureAwait(false), cancellation
Contract UpdateFactorApprovalTests exact request bodies: name only, every member, none
Unit UpdateFactorIdTests non-canonical and null ids rejected before I/O, with the parameter name

Changed-line coverage: 100% (26 lines). Mutation: 9/9 tested (2 block-removal mutants ignored by the Stryker config) on changed lines, 0 survivors.

Notes

  • The phone path (changing a phone factor's number, validation_failed on a non-E.164 number) is pinned by contract and approval tests only. The local stack has phone MFA off, and the CLI refuses [auth.mfa.phone] enroll_enabled = true without verify_enabled = true (Invalid MFA config: auth.mfa.phone.enroll_enabled requires verify_enabled), so supabase/config.toml is unchanged.
  • The OpenAPI entry still documents an empty request body (openapi.yaml seems to drift from the admin handlers in several places auth#2859); the fields come from adminUserUpdateFactorParams in internal/api/admin.go.
  • The auth server checks friendly-name uniqueness on enroll (mfa_factor_name_conflict) but not on this update, so a duplicate hits the (friendly_name, user_id) unique index instead.
  • Follow-up: ListFactors / DeleteFactor lack the spec's UUID check; adding it changes their error contract, so it is left for a separate change.

@Tr00d
Tr00d marked this pull request as ready for review October 30, 2026 07:26
@Tr00d
Tr00d merged commit 37bfcf9 into master Oct 30, 2026
3 checks passed
@Tr00d
Tr00d deleted the feat/auth.admin.update_mfa_factor branch October 30, 2026 07:49
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