Skip to content

fix: harden auth recovery and request context - #36

Open
leoafarias wants to merge 5 commits into
agent/rockets-server-compositionfrom
agent/rockets-auth-hardening
Open

fix: harden auth recovery and request context#36
leoafarias wants to merge 5 commits into
agent/rockets-server-compositionfrom
agent/rockets-auth-hardening

Conversation

@leoafarias

@leoafarias leoafarias commented Aug 8, 2026

Copy link
Copy Markdown
Member

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.setPassword could never succeed, and the @Throttle decorators 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.


⛔ Do not merge before #34

This PR is blocked by #34 and must land after it. Two independent reasons:

  1. Topology — this PR's base is feat: define server composition APIs #34's head (agent/rockets-auth-hardeningagent/rockets-server-composition). Merging it lands the commits on feat: define server composition APIs #34's branch, not on main.
  2. Compile-time dependency — the core change here populates AuthBootstrap.contributes, and AuthBootstrapContributions / contributes / resolveRocketsComposition do not exist on main; they are introduced by feat: define server composition APIs #34. Rebased onto main alone, defineRocketsAuth() would not type-check.

Order: #34#36 (this)#37#35.

After #34 merges and agent/rockets-server-composition is deleted, GitHub retargets this PR to main automatically. If #34 is squash-merged, the squashed commit will not match the history this stack was built on, so this branch (and #37/#35 above it) will need a rebase instead of a clean retarget — a merge or rebase merge on #34 avoids that.

Review this diff against agent/rockets-server-composition, not main.


Contents

Section
What changed and why The six themes in this diff
Breaking changes Migration steps for existing hosts
Review focus Where to spend reviewer attention
Verification Commands run and results
Deferred to other PRs Known gaps that are not this PR's to close

What changed and why

1. Password reset was broken, not just unpolished

PasswordPort.setPassword dispatched upstream UpdateUserPasswordCommand with no passwordCurrent. Rockets defaults user.settings.password.requireCurrent: true, so UserCredentialsService.updatePassword threw UserPasswordCurrentInvalidException on every call — recovery-driven resets could not complete.

RocketsAuthSetPasswordPortHandler now performs the rotation directly: history check against the configured reuseAfterDays window, strength validation via passwordPort.create, deactivate-then-create inside one transaction, with commit/rollback event hooks preserved.

2. Public account-recovery HTTP surface

New RocketsAuthRecoveryController mounts POST /recovery/login, POST /recovery/password, POST /recovery/passcode, PATCH /recovery/password.

RocketsRecoveryService replaces the upstream implementation under the same DI token for two reasons:

  • Upstream fires notifications detached (void commandBus.execute(...).catch(throw)), so a failed mail surfaces as an unhandled rejection after the response is already sent. Ours awaits.
  • Upstream notifies before revoking the OTP. Ours revokes first, then notifies best-effort — a failing mail provider can no longer leave a live passcode behind.

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 AppContextHost from getAppContext(req) instead of {} or a synthesized { entity: KEY }. createRepositoryContext() is deleted — the shape it produced is rejected by AppContextHost.from(), so it could only ever have worked by accident.

4. Throttling is now actually enforced

@Throttle decorators already existed on /token/password (10/min), /token/refresh (20/min), and /otp (3/min), but nothing registered a ThrottlerGuard, so they did nothing. This PR registers ThrottlerModule + a global ThrottlerGuard and adds recovery limits (5/min initiation, 10/min passcode validation). Hosts that own their own throttler pass throttling: false.

5. One owner per CQRS handler, one composition entry point

  • SignupHandler was registered both in module.providers and via CrudModule.forFeature; RocketsValidateCurrentPasswordHandler shadowed upstream's handler through a @Global() override module. Both now have exactly one owner, the latter via its own RocketsValidateCurrentPasswordCommand.
  • defineRocketsAuth() returns a complete AuthBootstrap — persistence rows, root repository, user-metadata contract, and guard preference all travel with the integration. Explicit options on RocketsModule still win.
  • Admin role handler overrides now apply to list/read/create/update/delete instead of only create/update.

6. Surface cleanup

Removed the parked OAuth domain, the non-functional rockets-auth-swagger CLI and SWAGGER.md, the unsupported useHooks controller extra, and the unused RocketsAuthRouteHandlerOverride alias. Generated controller factories share one typed applyControllerExtras path instead of four near-copies. Minimum runtime is now Node 20.


Breaking changes

Change Migration
defineRocketsAuth() contributes its own persistence rows Stop passing buildRocketsAuthResources(...) on RocketsModule.forRoot({ resources }). The planner rejects a twice-registered entity class, so keeping it fails at boot with entity `<Name>` registered twice. repository and userMetadata can be dropped too.
Rockets global guard defaults off for built-in auth AuthenticationModule's JwtGuard owns the requests. To make Rockets own an ordered adapter chain instead: rocketsDefaults: { enableGlobalGuard: true } and auth: { appGuard: false }.
Passcode validation moved GET /recovery/passcode/{passcode}POST /recovery/passcode with { passcode }; the passcode is no longer in the URL.
Global throttling on by default 1000 req/min per IP app-wide, with stricter per-route limits. Opt out with throttling: false.
Local auth CQRS messages take ctx first Any consumer constructing these commands/queries directly must pass the app context.
Removed APIs Parked OAuth exports, rockets-auth-swagger bin, useHooks controller extra, RocketsAuthRouteHandlerOverride, createRepositoryContext.

Review focus

Ranked by blast radius. The first two are intentional trade-offs worth a second opinion rather than defects.

1. enableGlobalGuard now defaults to falsepackages/rockets-server-auth/src/define-rockets-auth.ts

Correct for the single-integration case (two global auth guards would otherwise stack). But no other bootstrap contributes this flag, so auth: [defineRocketsAuth(...), defineFirebaseAuth(...)] resolves to false, AuthServerGuard never 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 ThrottlerGuardcreateRocketsAuthProviders

APP_GUARD is application-wide, so the 1000/min default applies to the host's own routes, not just Rockets'. Behind a load balancer without trust 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.ts

This inlines what UserCredentialsService.updatePassword does, minus the requireCurrent gate. Worth confirming the transaction and event-lifecycle hooks (onCommit/onRollbackcommit()/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. RecoveryService token is bound to a non-subclass — rockets-recovery.service.ts

RocketsRecoveryService is registered under upstream's RecoveryService token through a useFactory, which Nest does not type-check. It is constrained by implements Pick<RecoveryService, keyof RecoveryService> so an upstream method addition fails at compile time. Related: two RecoveryPolicy instances now exist (upstream's provider and ours) because upstream does not export RecoveryPolicy — the shared default settings constant keeps them in agreement.

5. Enumeration-safe handlers swallow every error

POST /recovery/login and POST /recovery/password return 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.json is stale

It still documents /oauth/* and the removed GET /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

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Refactoring
  • Dependency update

Verification

Full monorepo, not a focused subset:

Command Result
yarn build pass
yarn typecheck:spec pass
yarn lint:all pass
vitest run (all projects) 114 files passed, 1 skipped — 1001 tests passed, 1 skipped

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.

  • Build succeeds (yarn build)
  • Unit tests pass (yarn test)
  • E2E tests pass (yarn test:e2e)
  • Lint passes (yarn lint)

New coverage: auth-readiness.e2e-spec.ts walks the full recovery flow against a booted app, asserts unknown emails are indistinguishable from known ones, asserts the login limit returns 429, and asserts zero already registered CQRS warnings at boot. Unit specs cover the recovery service's await/revoke ordering, the password-history guard, defineRocketsAuth guard defaults, applyControllerExtras, and admin role handler forwarding.

Checklist

  • My code follows the existing patterns in the codebase
  • I have updated relevant documentation (package README + CHANGELOG, example README)
  • I have added tests for new functionality

Deferred to other PRs in the stack

Deliberately not fixed here, to keep this diff inside its own scope:

@leoafarias
leoafarias force-pushed the agent/rockets-auth-hardening branch 2 times, most recently from a080064 to f2f18fc Compare August 8, 2026 23:56
@leoafarias
leoafarias marked this pull request as ready for review August 9, 2026 16:09
@leoafarias

Copy link
Copy Markdown
Member Author

Blocked by #34 — do not merge first.

Two independent reasons this cannot land ahead of #34:

  1. Topology — this PR's base is feat: define server composition APIs #34's head (agent/rockets-auth-hardeningagent/rockets-server-composition), so merging it lands the commits on feat: define server composition APIs #34's branch, not on main.
  2. Compile-time dependency — the core change populates AuthBootstrap.contributes. Verified against origin/main:
Symbol main #34 head
AuthBootstrapContributions absent present
AuthBootstrap.contributes absent present
resolveRocketsComposition absent present

Rebased onto main alone, defineRocketsAuth() would not type-check.

Merge order: #34#36 (this)#37#35.

Once #34 merges and agent/rockets-server-composition is deleted, GitHub retargets this PR to main automatically. One caveat: if #34 is squash-merged, the squashed commit will not match the history this stack was built on, so this branch and #37/#35 will need a rebase rather than a clean retarget. A merge commit or rebase merge on #34 avoids that.

Also worth closing on #34 before it lands: resolveRocketsComposition concatenates contributed and explicit resources with no dedup. That is the root cause of the boot failure fixed here in sample-server-auth — once it is on main, any host still following the previous README pattern hits entity <Name> registered twice at boot, and this PR's fix only covers the in-repo example.

`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`.
@leoafarias
leoafarias force-pushed the agent/rockets-auth-hardening branch from 22887b2 to 06769e3 Compare August 9, 2026 16:14
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