fix: TypeError when listing non-TOTP user auth factors#1665
Conversation
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Original prompt from Devin Bot
|
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Greptile SummaryThis PR fixes a
Confidence Score: 5/5Safe to merge — the change is a targeted, well-tested fix to a confirmed runtime crash with no side effects on adjacent code paths. The fix is minimal and correct: conditional spreading for Files Needing Attention: No files require special attention. Important Files Changed
|
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Addressed in 6cf80fd: added |
jeffnv
left a comment
There was a problem hiding this comment.
Reviewed the full branch with the PR checked out locally.
Verdict: correct fix, ship it after one type decision
The core change is right, and the test genuinely pins the bug — I verified that rather than trusting it. Reverting just the serializer hunk to the pre-fix form and re-running the spec reproduces the exact production failure:
TypeError: Cannot read properties of undefined (reading 'issuer')
> 10 | issuer: totp.issuer,
at src/multi-factor-auth/serializers/totp.serializer.ts:10:18
Tests: 1 failed, 13 passed
Restored, it passes. Also confirmed locally: tsc --noEmit clean, 902 tests pass across 48 suites, all 7 CI checks green.
One thing the diff obscures and that's worth stating for future readers: listUserAuthFactors lives in the MFA module but imports the user-management deserializer (multi-factor-auth.ts:36, aliased deserializeUMFactor). So the test added to multi-factor-auth.spec.ts really does cover the fixed code path — it isn't exercising the sibling by mistake.
One finding I'd act on
The WithSecrets widening is a consumer break with no correctness benefit. Details inline, but in short: EnrollAuthFactorOptions.type is 'totp' and nothing else (enroll-auth-factor.interface.ts:3), so createUserAuthFactor cannot return a non-TOTP factor. Making totp optional there forces every enroll consumer to null-check an impossible case.
Compiling a representative consumer against the new types:
probe.ts(3,69): error TS18048: 'f.totp' is possibly 'undefined'. // enroll: f.totp.qrCode
probe.ts(5,60): error TS18048: 'f.totp' is possibly 'undefined'. // list: f.totp.issuer
The list break is justified — that code is already crashing at runtime for SMS users, so the compile error surfaces a real bug. The enroll break is pure cost, and it hits the consumers reading .totp.secret / .qrCode / .uri, which is most of them.
The PR description draws this distinction correctly ("true of the enroll endpoint, false of list") and then widens both anyway for consistency — trading an accurate type for a uniform one.
Two nits
Both inline: a duplicated 'sms' | 'totp' | 'generic_otp' union, and a dead fixture file.
Operational note (not a defect — the PR discloses it)
This doesn't reach the affected customer. Production is on SDK 8.10.0 via userManagement.listAuthFactors(), there is no 8.x maintenance branch, and the method has since moved to multiFactorAuth.listUserAuthFactors(). The dashboard only picks this up on a v9/v10 major upgrade.
So the workos/workos-side change is still the one that actually unblocks them, and the three unguarded call sites in user-authentication-factors.resolver.ts (:87 list, :272 enroll, :337 delete) remain live regardless of what happens here.
| type: AuthenticationFactorType; | ||
| sms?: Sms; | ||
| totp?: TotpWithSecrets; |
There was a problem hiding this comment.
I'd keep the WithSecrets pair narrow — type: 'totp' with totp: TotpWithSecrets required — and only widen the non-secrets pair.
EnrollAuthFactorOptions.type is 'totp' and nothing else (enroll-auth-factor.interface.ts:3), so createUserAuthFactor provably cannot return a non-TOTP factor. Widening here doesn't fix a bug; it just makes every enroll consumer null-check an impossible case before reading .totp.secret / .qrCode / .uri. Verified it's a real compile break: error TS18048: 'f.totp' is possibly 'undefined'.
The fair counter-argument: with the guard added to deserializeFactorWithSecrets, that function genuinely can now return an object without totp, so the optional type is honest about it. If you want the narrow type back, either drop the guard on the WithSecrets variant only — an enroll response missing its TOTP payload is a contract violation you'd rather throw on than silently return undefined for — or keep the guard and throw explicitly.
Same applies to AuthenticationFactorWithSecretsResponse at lines 52-54.
There was a problem hiding this comment.
Done in fc2d3da — reverted the WithSecrets pair to type: 'totp' with required totp/TotpWithSecrets(Response), and dropped the guard in deserializeFactorWithSecrets so an enroll response missing its TOTP payload fails loudly (contract violation) rather than silently returning undefined. Only the non-secrets list-path pair stays widened.
| TotpWithSecretsResponse, | ||
| } from '../../multi-factor-auth/interfaces/totp.interface'; | ||
|
|
||
| export type AuthenticationFactorType = 'sms' | 'totp' | 'generic_otp'; |
There was a problem hiding this comment.
This union already exists as FactorType in multi-factor-auth/interfaces/factor.interface.ts:9 with identical members. Two independently-maintained copies of one union, across these exact two modules, is the divergence that caused this incident in the first place.
Worth exporting FactorType and reusing it here rather than declaring a second copy that can drift when a fifth factor type shows up.
There was a problem hiding this comment.
Done in fc2d3da — exported FactorType from multi-factor-auth/interfaces/factor.interface.ts and made AuthenticationFactorType an alias of it, so there's a single union to update when a new factor type ships.
| "user": "some_user" | ||
| } | ||
| }, | ||
| "user_id": "user_01H5JQDV7R7ATEYZDEG0W5PRYS" |
There was a problem hiding this comment.
This fixture is unreferenced — the only list-factors import in the tree is multi-factor-auth.spec.ts:16 pulling ./fixtures/list-factors.json, which resolves to the MFA copy.
Updating it reads as added coverage but contributes none. Worth deleting the file, or leaving a note on why it's kept.
There was a problem hiding this comment.
As of 6cf80fd the fixture is no longer unreferenced — the new src/user-management/serializers/authentication-factor.serializer.spec.ts imports it and exercises deserializeFactor against all three factor types, so it now contributes coverage of the fixed deserializer directly.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Description
Fixes DAAP-3034 (Sentry API-2FR):
multiFactorAuth.listUserAuthFactors()threwTypeError: Cannot read properties of undefined (reading 'issuer')for any user with a non-TOTP factor, failing the entire list call.GET /user_management/users/:id/auth_factorsreturns factors of typetotp,sms, orgeneric_otp, but only TOTP factors include atotpobject (SMS factors carrysms: { phone_number }instead). The user-managementdeserializeFactorcalleddeserializeTotp(factor.totp)unconditionally:This matches the existing (correct) sibling deserializer in
src/multi-factor-auth/serializers/factor.serializer.ts; the divergence between the two was the whole bug.Type fix (why the compiler never caught this)
The
AuthenticationFactor/AuthenticationFactorResponseinterfaces hard-codedtype: 'totp'and a requiredtotpobject — true of the enroll endpoint, false of list. They now use the sharedFactorTypeunion ('sms' | 'totp' | 'generic_otp', exported from the MFA module so there's a single source of truth) with optionalsms?/totp?.Judgment calls:
typeand makingtotpoptional on the list path is technically breaking for TypeScript consumers who destructurefactor.totp— but the type was lying; that code already crashes at runtime for exactly the users this affects. Shipping as afix(patch) since it corrects the types to the API's actual, long-standing response contract.WithSecrets(enroll path) stays narrow:EnrollAuthFactorOptions.typeis'totp'only, socreateUserAuthFactorcannot return a non-TOTP factor;deserializeFactorWithSecretskeeps the unguarded call so a contract-violating response fails loudly rather than silently returningundefined(per review).smsfield: now surfaced on the returned list object (previously dropped), matching the MFA sibling. Additive.Test coverage: both
list-factors.jsonfixtures now includesmsandgeneric_otpfactors, exercised through thelistUserAuthFactorsspec and a newauthentication-factor.serializer.spec.ts— either would have caught the bug.Note: the production consumer (WorkOS dashboard) is on SDK v8.10.0 (
userManagement.listAuthFactors()); there is no 8.x maintenance branch, so it will pick this up on upgrade to v9+/v10 where the method ismultiFactorAuth.listUserAuthFactors().Documentation
Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.
Link to Devin session: https://app.devin.ai/sessions/7fa3a604bb6d415ab2989a8e59fcf403
Requested by: @jeffnv