Skip to content

feat(auth): read how many MFA recovery codes the signed-in user has left - #610

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

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

Conversation

@Tr00d

@Tr00d Tr00d commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Summary

A signed-in user can read how many of their MFA recovery codes remain unused, or learn that they have none.

Capability: auth.mfa_recovery_codes.get_status (MFA Recovery Codes Get Status). There is no capability spec for it; the contract is the auth OpenAPI GET /factors/recovery-codes.

Public API

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

// Supabase.Gotrue.GetRecoveryCodesStatus
public sealed record RecoveryCodesStatus
{
    public string FactorId { get; init; }   // id of the recovery_code factor, as in User.Factors
    public int Total { get; init; }         // codes in the current set
    public int Remaining { get; init; }     // codes not used yet
}

// IGotrueApi<TUser, TSession> / Api (wire call, same pattern as the custom-provider operations)
Task<RecoveryCodesStatus?> GetRecoveryCodesStatusAsync(string jwt, CancellationToken cancellationToken = default);
var status = await client.GetRecoveryCodesStatusAsync();
if (status == null)
{
    // offer to generate recovery codes
}
else if (status.Remaining == 0)
{
    // every code is used: prompt the user to regenerate them
}

Behavior

  • Operation: GET /factors/recovery-codes with the session's access token as Authorization: Bearer, plus the API key header. Works at AAL1 and AAL2, and 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:

    • 200 { id, type, total, remaining } maps to RecoveryCodesStatus { FactorId, Total, Remaining }. type is always recovery_code, so it is not surfaced.
    • 404 mfa_factor_not_found (the user never generated codes) returns null.
    • A 200 without a body throws GotrueException.
  • Failures: any other rejection throws GotrueException with the server's ErrorCode:

    • another 404 code throws;
    • 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.

  • State: nothing stored, cleared or emitted. The operation runs under a new gotrue.get_recovery_codes_status domain span that 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.
  • "Not enrolled" is null. auth-js returns the mfa_factor_not_found error. Not having codes is a status the caller renders, not a failure.
  • No session refresh before the call. auth-js's _useSession refreshes an expired session first. This SDK uses the current access token, like GetUserIdentitiesAsync, so an expired token is rejected with bad_jwt.

Tests

Tier Class Pins
E2E RecoveryCodesStatusTests A freshly signed-up user gets null. An anonymous user is rejected with no_authorization.
Contract RecoveryCodesStatusContractTests Request (method, path, bearer, apikey); mapping of the status body; null on mfa_factor_not_found; throws on another 404 and on a 200 without a body; bad_jwt; Offline and NoSessionFound before the wire; cancellation; no context capture.
Contract ObservabilityContractTests The domain span parents the HTTP span.

No approval test, because a GET has no request body.

Changed-line coverage: 100% (40 lines). Mutation: 14/14 on changed lines (10 killed, 4 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 the enrolled status is pinned at the contract tier. Add the E2E once the CLI exposes the recovery-codes enroll setting.
  • Misleading reason for anonymous users. no_authorization maps to Reason.AdminTokenRequired through the existing table, which is misleading for this case. It is unchanged here; ErrorCode carries the precise code.

@Tr00d
Tr00d force-pushed the feat/auth.mfa_recovery_codes.get_status branch from cb36efc to 05e4bb1 Compare October 30, 2026 13:32
@Tr00d
Tr00d marked this pull request as ready for review October 30, 2026 13:37
@Tr00d
Tr00d merged commit ab86bc3 into master Oct 30, 2026
6 checks passed
@Tr00d
Tr00d deleted the feat/auth.mfa_recovery_codes.get_status branch October 30, 2026 13:42
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