fix: harden auth recovery and request context - #36
Conversation
a080064 to
f2f18fc
Compare
|
⛔ Blocked by #34 — do not merge first. Two independent reasons this cannot land ahead of #34:
Rebased onto Merge order: #34 → #36 (this) → #37 → #35. Once #34 merges and Also worth closing on #34 before it lands: |
`defineRocketsAuth` now contributes its persistence rows, but
`resolveRocketsComposition` concatenates contributed and explicit
`resources` without deduping. `sample-server-auth` still spread
`buildRocketsAuthResources(...)`, so the planner rejected every auth
entity as registered twice and both e2e suites failed at bootstrap.
Drop the spread and document the migration in the changelog.
Also from the review pass:
- Single-source the recovery OTP defaults; the settings factory and the
RecoveryService factory each carried their own copy, and a divergent
namespace or category silently invalidates issued passcodes.
- Constrain RocketsRecoveryService to the upstream RecoveryService public
surface. It is registered under that token through an unchecked
useFactory, so a new upstream method would otherwise surface as a
runtime TypeError.
- Normalize `ctx` once per recovery method. `AppContextLike` admits
null/undefined and the notification command call sites skipped the
`?? {}` guard the port calls used.
- Re-export upstream `AuthPublic` from rockets-core instead of shipping a
byte-identical clone; the sentinel must match the guard that reads it.
- Revert the provider reorder and its rationale: those providers are the
generated options provider, not a consumer override slot.
- Restore discrimination in the signup handler override test, which
passed vacuously once CrudModule took ownership.
- Correct the two AppContextHost comments that the rewrite inverted.
- Drop the `request.raw as object` cast, merge split imports, and replace
the deprecated `describe.sequential`.
22887b2 to
06769e3
Compare
Summary
Makes account recovery actually work end to end, and makes request context explicit on every local auth CQRS, port, guard, and repository hop.
Two of these were silent failures rather than rough edges: password reset through
PasswordPort.setPasswordcould never succeed, and the@Throttledecorators already on the login and OTP routes were inert because no throttler guard was registered. The rest tightens the supported surface — one owner per CQRS handler, one composition entry point, and no parked/placeholder APIs.Contents
What changed and why
1. Password reset was broken, not just unpolished
PasswordPort.setPassworddispatched upstreamUpdateUserPasswordCommandwith nopasswordCurrent. Rockets defaultsuser.settings.password.requireCurrent: true, soUserCredentialsService.updatePasswordthrewUserPasswordCurrentInvalidExceptionon every call — recovery-driven resets could not complete.RocketsAuthSetPasswordPortHandlernow performs the rotation directly: history check against the configuredreuseAfterDayswindow, strength validation viapasswordPort.create, deactivate-then-create inside one transaction, with commit/rollback event hooks preserved.2. Public account-recovery HTTP surface
New
RocketsAuthRecoveryControllermountsPOST /recovery/login,POST /recovery/password,POST /recovery/passcode,PATCH /recovery/password.RocketsRecoveryServicereplaces the upstream implementation under the same DI token for two reasons:void commandBus.execute(...).catch(throw)), so a failed mail surfaces as an unhandled rejection after the response is already sent. Ours awaits.Initiation routes answer 200 whether or not the address exists, so the endpoints do not confirm account existence.
3. Request context propagation
Local commands, queries, ports, guards, the invitation listener, and repository access now carry the canonical
AppContextHostfromgetAppContext(req)instead of{}or a synthesized{ entity: KEY }.createRepositoryContext()is deleted — the shape it produced is rejected byAppContextHost.from(), so it could only ever have worked by accident.4. Throttling is now actually enforced
@Throttledecorators already existed on/token/password(10/min),/token/refresh(20/min), and/otp(3/min), but nothing registered aThrottlerGuard, so they did nothing. This PR registersThrottlerModule+ a globalThrottlerGuardand adds recovery limits (5/min initiation, 10/min passcode validation). Hosts that own their own throttler passthrottling: false.5. One owner per CQRS handler, one composition entry point
SignupHandlerwas registered both inmodule.providersand viaCrudModule.forFeature;RocketsValidateCurrentPasswordHandlershadowed upstream's handler through a@Global()override module. Both now have exactly one owner, the latter via its ownRocketsValidateCurrentPasswordCommand.defineRocketsAuth()returns a completeAuthBootstrap— persistence rows, root repository, user-metadata contract, and guard preference all travel with the integration. Explicit options onRocketsModulestill win.6. Surface cleanup
Removed the parked OAuth domain, the non-functional
rockets-auth-swaggerCLI andSWAGGER.md, the unsupporteduseHookscontroller extra, and the unusedRocketsAuthRouteHandlerOverridealias. Generated controller factories share one typedapplyControllerExtraspath instead of four near-copies. Minimum runtime is now Node 20.Breaking changes
defineRocketsAuth()contributes its own persistence rowsbuildRocketsAuthResources(...)onRocketsModule.forRoot({ resources }). The planner rejects a twice-registered entity class, so keeping it fails at boot withentity `<Name>` registered twice.repositoryanduserMetadatacan be dropped too.AuthenticationModule'sJwtGuardowns the requests. To make Rockets own an ordered adapter chain instead:rocketsDefaults: { enableGlobalGuard: true }andauth: { appGuard: false }.GET /recovery/passcode/{passcode}→POST /recovery/passcodewith{ passcode }; the passcode is no longer in the URL.throttling: false.ctxfirstrockets-auth-swaggerbin,useHookscontroller extra,RocketsAuthRouteHandlerOverride,createRepositoryContext.Review focus
Ranked by blast radius. The first two are intentional trade-offs worth a second opinion rather than defects.
1.
enableGlobalGuardnow defaults tofalse—packages/rockets-server-auth/src/define-rockets-auth.tsCorrect for the single-integration case (two global auth guards would otherwise stack). But no other bootstrap contributes this flag, so
auth: [defineRocketsAuth(...), defineFirebaseAuth(...)]resolves tofalse,AuthServerGuardnever installs, and the ordered adapter chain is never consulted — Firebase-authenticated requests would 401. There is a documented escape hatch but no test covers the mixed-chain case. Worth deciding whether that shape should be supported now or explicitly declared unsupported.2. Global
ThrottlerGuard—createRocketsAuthProvidersAPP_GUARDis application-wide, so the 1000/min default applies to the host's own routes, not just Rockets'. Behind a load balancer withouttrust proxy, every client shares one source IP and therefore one bucket. Please sanity-check the default against expected production traffic.3. Reimplemented credential rotation —
rockets-auth-password-port.handlers.tsThis inlines what
UserCredentialsService.updatePassworddoes, minus therequireCurrentgate. Worth confirming the transaction and event-lifecycle hooks (onCommit/onRollback→commit()/uncommit()) match upstream exactly. It also injects two upstream DI tokens by string literal (USER_CREDENTIALS_REPOSITORY_TOKEN,USER_MODULE_SETTINGS_TOKEN) because upstream does not export them — an upstream rename would break this at runtime, not at compile time.4.
RecoveryServicetoken is bound to a non-subclass —rockets-recovery.service.tsRocketsRecoveryServiceis registered under upstream'sRecoveryServicetoken through auseFactory, which Nest does not type-check. It is constrained byimplements Pick<RecoveryService, keyof RecoveryService>so an upstream method addition fails at compile time. Related: twoRecoveryPolicyinstances now exist (upstream's provider and ours) because upstream does not exportRecoveryPolicy— the shared default settings constant keeps them in agreement.5. Enumeration-safe handlers swallow every error
POST /recovery/loginandPOST /recovery/passwordreturn 200 even when the mailer is down; only a log line records it. That is the point, but confirm the logging is sufficient for on-call to notice a fully broken mail path.6.
swagger/swagger.jsonis staleIt still documents
/oauth/*and the removedGET /recovery/passcode/{passcode}, and none of the four new recovery routes. That file belongs to #35 — flagged so it is not mistaken for this PR's output.Type of Change
Verification
Full monorepo, not a focused subset:
yarn buildyarn typecheck:specyarn lint:allvitest run(all projects)Re-run at the current tip after rebasing this branch onto the updated #34 head (
090cad2). The earlier figure in this table (113 files / 995 tests) predated the review-fix commits on both branches.yarn build)yarn test)yarn test:e2e)yarn lint)New coverage:
auth-readiness.e2e-spec.tswalks the full recovery flow against a booted app, asserts unknown emails are indistinguishable from known ones, asserts the login limit returns 429, and asserts zeroalready registeredCQRS warnings at boot. Unit specs cover the recovery service's await/revoke ordering, the password-history guard,defineRocketsAuthguard defaults,applyControllerExtras, and admin role handler forwarding.Checklist
Deferred to other PRs in the stack
Deliberately not fixed here, to keep this diff inside its own scope:
resolveRocketsComposition, which concatenates contributed and explicitresourceswith no dedup. That silent concat is the root cause of the boot failure fixed in this PR's example; a dedup or an error naming the contributing integration belongs there.enableGlobalGuard(review item 1) and could finish theAuthPubliccleanup by exporting straight from therockets-corebarrel — kept here as a one-line re-export so this PR does not touch files feat: define server composition APIs #34 already edits.swagger/swagger.json(review item 6) and the rootgenerate-swaggerscript and README row, which it already deletes.