Skip to content

feat(web): password reset, session revocation and admin reset links - #295

Merged
jpr5 merged 16 commits into
mainfrom
blitz/outpost-pw-reset/integration
Oct 29, 2026
Merged

jpr5 merged 16 commits into
mainfrom
blitz/outpost-pw-reset/integration

Conversation

@jpr5

@jpr5 jpr5 commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Outpost members who sign in with email and password can now reset a forgotten password themselves. The login page has a "Forgot password?" link. It emails a single-use link that expires in 30 minutes, and the link opens a page where the member sets a new password. Using the link signs the account out everywhere and sends a "your password was changed" email.

Admins can email a member the same reset link from Settings → Team. Sending the link changes nothing until the member uses it, and the admin never sees the link.

Sessions can now be revoked. A password reset signs out every session. A password change on the profile page signs out the member's other sessions and keeps the current one. Disabling a member signs them out at once. A role change takes effect on the member's next request, so a demoted admin loses admin access without having to sign in again.

The flow exists only when AUTH_PROVIDER=credentials. With GitHub or OIDC, the reset link is hidden and the routes return 404.

Closes #206

What changed

Database. A migration adds TeamMember.sessionVersion, a PasswordResetToken table that stores only the SHA-256 hash of each token, and an AuthRateLimit table. All three are additive.

Email sending (shared package). sendEmail has two new optional settings. requireTransport refuses to fall back to printing the email to the console, and its error names a configured transport that failed to load. trackLinks: 'None' turns off Postmark link tracking for the message. nodemailer is now a real dependency, and the web server bundle no longer replaces it with an empty module, so SMTP works in the production web image.

Email templates. Two fixed system templates, system/password-reset and system/password-changed. They live in templates/system/, so Settings → Templates does not list them and the override API cannot edit them.

Dev stack. The dev compose stack has a Mailpit SMTP sink, so local email goes through the real SMTP path.

Password policy. A shared validateNewPassword() enforces the minimum length and rejects passwords longer than 72 UTF-8 bytes, which bcrypt would silently truncate. Invite accept uses it, and it now refuses unless the member is still INVITED, so an old invite link cannot overwrite the password of an ACTIVE or DISABLED member.

Session revocation. Sign-in stores the member's sessionVersion in the JWT. Each server-side session read loads the member again and revokes the session if the member is missing, not ACTIVE, or on a different sessionVersion. A database error during this check also revokes the session (fail closed). The same read refreshes the role from the database. Credentials sign-in refuses non-ACTIVE members. OIDC sign-in now creates an ACTIVE member, promotes an INVITED one, or refuses. The profile PUT bumps the version with a compare-and-set and re-issues the caller's own cookie (chunked cookies included), so only the caller's other sessions end. Disable bumps the version. An admin cannot disable themselves. Re-enabling a member who never joined returns them to INVITED. The admin data-reset route keeps the admin's sessionVersion when it rebuilds the admin row.

Public paths. The public path list moved to lib/public-paths.ts and gained /forgot-password, /reset-password and /api/password-reset/. Matching now stops at a path-segment boundary, so /login covers /login/x but no longer /loginx. The two reset pages render without the app sidebar.

Revoked session on an open page. SessionGuard sends the browser to /login when the server confirms the session is gone (200 with no user). A failed read, such as offline, a 502 or a 500, does not count as proof, so the page stays and checks again when the tab is next visible.

Dashboard. When a data request fails, or returns 401 after revocation, the dashboard widgets show an error instead of crashing or showing "all caught up" copy. The stats panel clears the previous month's numbers and offers Try again.

Rate limiter. A Postgres fixed-window limiter does one atomic upsert per hit, in epoch-aligned windows that every replica shares. It keys on X-Real-IP, which Railway's edge sets, and fails closed on a database error.

Reset token service. issuePasswordReset and the verify and consume functions. A failed send keeps the previous link working. Only a delivered link replaces older ones.

Public API. POST /api/password-reset/{request,verify,confirm}. They return 404 unless the provider is credentials and 415 unless the body is application/json. request always answers 200 { ok: true } and does the lookup and send in after(), so neither the response body nor its timing shows whether an account exists.

Pages. The login form has a "Forgot password?" link. /forgot-password shows the same confirmation for every request. /reset-password reads the token from the URL fragment, removes it from the address bar and history, checks it with verify before showing the form, and sets the password with confirm. Only an expired or used link (410) shows the expired state. A 429, a server error or a network error offers Try again.

Admin reset link. POST /api/team/[id]/password-reset works only in credentials mode, only for ACTIVE members who have a password, and never for the admin's own account. It is rate limited per target member, and its response never contains the token or the URL. GET /api/team returns hasPassword, and the hash never leaves the server. Settings → Team has a "Send password reset link" button.

E2E. A Playwright suite runs against a locally built web image with its own Postgres and Mailpit.

CI. The real-Postgres *-db tests now run in CI with OUTPOST_REQUIRE_DB_TESTS=1. A follow-up step fails the job unless every *-db file ran at least one test and all of them passed, so a lost env passthrough fails the job instead of skipping those tests without notice.

Security model

  • Token: 256 bits of randomness (randomBytes(32), hex). Only sha256(token) is stored, so a database leak gives no working links.
  • Delivery: the token is in the URL fragment (/reset-password#token=…), which the browser never sends to the server, so it stays out of request logs and Referer. The page removes it with history.replaceState and sends it in a POST body. The reset page sends Referrer-Policy: no-referrer and loads no third-party assets.
  • Link base: the link uses NEXTAUTH_URL only, never Host or X-Forwarded-Host, which blocks reset-link poisoning. Production has no localhost fallback.
  • Lifetime: 30-minute TTL and single use. The consume step is a conditional update in a transaction that locks the member row first. A token is valid only while the member is ACTIVE and on the sessionVersion the token was issued at, so any password change kills every outstanding link.
  • Revocation: sessionVersion bumps on reset, on profile password change and on disable. The per-request check also refreshes the role. The jwt callback ignores client update() payloads, so a client cannot set its own version.
  • No enumeration: identical responses from request, and the work runs after the response. verify and confirm give the same answer for every kind of bad token.
  • Rate limits: per IP (10 / 15 min) and per hashed email (3 / hour) on request. Per IP (20 / 15 min, shared) on verify and confirm. Per target member (3 / hour) on the admin send. The limits fail closed on a database error.
  • Email: requireTransport means a reset email is never printed to the console, so no live link lands in Railway logs. trackLinks: 'None' keeps Postmark from rewriting the link through its click tracker.
  • CSRF: the public routes require Content-Type: application/json, which forces a CORS preflight on any cross-origin post. The admin route stays behind the existing CSRF check.
  • Logging: the token, its hash, the URL and the password are never logged. An unknown email is never logged either.

Testing

  • Unit and route tests: 6878 passed, 0 failed (1 skipped, pre-existing) across all packages. Lint and typecheck are clean, and the uncached build passed. Web and worker Docker images build.
  • Real-Postgres tests: seven *-db files (26 tests) run against a real database and cover consume, issue, verify, request, the rate limiter, invite accept and team status. CI now enforces them. The two reset *-db files passed 5 runs in a row.
  • Playwright e2e: 6/6 against a freshly built image of the final head. The suite covers SMTP delivery from the built image, the reset pages being public, single-use self-service reset revoking every session, the expired-link state, a profile change that keeps the current session and revokes the others, and an admin-sent link that revokes nothing until it is used. CI does not run this suite yet. It needs a hand-built image (see the e2e support file).

Each review fix was proven red then green on the real surface (live route handlers, real Postgres, real next-auth callbacks, or Playwright Chromium), with a mutation check:

  • Deadlock: two live links for one member hit a Postgres deadlock (40P01) on confirm. Now consume locks the member row first.
  • Deadlock: a link being issued while an older link was confirmed hit a deadlock. Issue cleanup now takes the same member lock. Same-millisecond issues no longer leave two live links.
  • Lost link on a failed send: a failed send deleted the link already in the inbox. Now only a delivered link replaces older ones.
  • Self-disable lockout: an admin could disable themselves and get locked out. That PATCH is now refused.
  • Demotion kept admin: a demoted admin kept admin access until signing in again. The role now refreshes on every session read.
  • Disabled invitee: a disabled member with an unused invite link could reactivate themselves. Accept now requires INVITED.
  • Re-enable regression: enabling a member who never joined made them ACTIVE with no password, which was fixed. The first fix then sent seeded or script-created admins with no joinedAt back to INVITED, which locked them out. Now either a password or a joinedAt counts as joined.
  • Chunked cookie: a profile password change with a large session (chunked cookie) left Chromium with no session cookie. The re-issue now writes chunked cookies correctly.
  • Path prefix: /reset-password-evil and similar paths matched the public list. Matching is now segment-bounded.
  • Content-Type guard: the guard accepted application/json-patch+json and application/json.evil. The match is now anchored to the media type.
  • Transient session read: a 5xx or network error on /api/auth/session sent valid sessions to /login. Now only a confirmed empty session redirects.
  • Reset page errors: a 5xx or a 429 on verify showed "link expired" for a valid link. Now those cases offer Try again.
  • Dashboard crash: a revoked session's 401s crashed the dashboard widgets. They now show an error state, checked by screenshots at 375, 430 and 1440 px.
  • Reset pages in the app shell: the anonymous reset pages rendered inside the app shell. They now render bare.
  • Skipped DB tests: CI passed while the real-Postgres tests were skipped. A verify step now fails the job.

Deployment notes

  • Migration: runs at container start (migrate deploy) and is additive. ADD COLUMN … DEFAULT 0 changes only the catalog, but it takes a brief ACCESS EXCLUSIVE lock on "TeamMember" that waits behind any open transaction. Deploy at low traffic.
  • Deploy web and worker together. After the migration applies, an old worker that restarts fails the existing schema drift guard ([worker] FATAL: the database does not match schema.prisma). This can happen through a crash loop, a scale event, a restart during the rollout, or a rollback. Old web replicas are unaffected. For a rollback, keep the migration in place and boot the old worker with OUTPOST_ALLOW_SCHEMA_DRIFT=1, or roll back web and worker together.
  • During the rollout: an old web replica still serving traffic does not bump sessionVersion on a profile password change, so that change revokes no sessions. This window closes when the rollout completes. Old replicas serve no reset routes.
  • Existing sessions: sessions from before the deploy carry no version, which counts as 0, so they stay signed in. The one exception is legacy OIDC sessions whose memberId fell back to the OIDC subject. Those are signed out once.
  • Email transport: outpost-web needs a working transport, either POSTMARK_SERVER_TOKEN (with sender-domain DNS, Email go-live: configure Postmark DNS for support.copilotkit.ai #215) or SMTP_HOST. Without one, reset emails are refused, never printed. Members see the same confirmation, and admins see "could not be emailed". The template and Postmark bundle prerequisites landed in fix(web): ship email templates and load postmark in the production web image #294. The Postmark server-level link tracking setting is unverified. The per-message TrackLinks: 'None' is the control either way.
  • NEXTAUTH_URL: it must be an absolute https://host[/path] with no query or fragment. If it is unset in production, no reset link is sent, and the log says NEXTAUTH_URL is not set.
  • Database TimeZone: check that the production database TimeZone is UTC (SHOW timezone). A non-UTC session time zone shifts the rate-limit windows.
  • Client IP: on staging, confirm that a forged X-Real-IP header is overwritten by the Railway edge. If it is not, the per-IP limits are advisory only, and the per-email and per-member limits still hold.

Known limitations

  • Page shell after revocation: a revoked session can still render a protected page's shell briefly, under a second, before the client redirects to /login. Every data API returns 401 in that window. The edge middleware can only decode the JWT, so closing this gap needs a server or edge version check, which is a design change.
  • Unexplained e2e timeout: one early e2e run hit a 60-second timeout in the profile-change test. The cause was not found, and the trace was lost. The timeout did not reproduce in a targeted attempt, and every full e2e run since has passed 6/6.
  • Not covered in GitHub mode: session revocation does not apply in GitHub auth mode, where memberId is the GitHub account id, not a TeamMember row.
  • callbackUrl ignored: /login ignores callbackUrl and always lands on /dashboard. This was already the case before this PR.

Deviations from the spec

  • Template comment: the reset templates mark themselves as fixed with a note: frontmatter key instead of a comment at the top of the file, because a leading comment breaks frontmatter parsing.
  • Sender-options test: this test mocks the postmark package instead of the HTTP boundary. The built-image e2e covers the real send path over SMTP.
  • Role refresh: the spec put role refresh out of scope, but review found that a demoted admin kept admin access. The per-request session check now sets the role from the database. A role-only change still does not bump sessionVersion.
  • Replacing old links: the spec had issue delete the member's other unused links in the same transaction as the insert. A failed send then destroyed the link already in the inbox, so older links are now replaced only after the new one is delivered. As a result, two links can be live for a moment, and consume handles that safely.
  • Deadlock interleaving: when a new link is issued while an older one is being confirmed, the issue wins the member lock and the confirm gets 410. That replaces the deadlock and 500 from before.
  • Admin changes on Settings → Team: an admin cannot change their own status. Re-enabling a member who never joined returns them to pending invites.

Follow-ups

These items are out of scope and tracked in the internal follow-up backlog:

  • Invite flow credential hardening: invite and resend emails do not set requireTransport or trackLinks, and invite accept does not guard its body parse.
  • OIDC identity hardening: email_verified is not checked.
  • Email header and HTML escaping: org name and email go unescaped into the From header and body, and a member email that contains a comma can be read as two recipients.
  • Team settings error handling: mutation handlers ignore res.ok, and a 401 leaves a dead page.
  • GitHub auth mode correctness: the profile lookup uses the GitHub id, the org domain check is case-sensitive, a catch block is empty, and profile.login can be undefined.

Note on #289

The GitHub Contents fix from #289 has not yet been exercised by a live support ticket. This PR does not touch that path. Watch the first live support ticket after this deploy to confirm it.

jpr5 added 16 commits October 29, 2026 08:27
TeamMember.sessionVersion lets the web app revoke every JWT session a
member holds by bumping one integer. PasswordResetToken stores only the
SHA-256 hash of each reset link token, with expiry, consumption and
supersession timestamps and the member it belongs to. AuthRateLimit
backs a fixed-window rate limiter that works across web replicas.
…hip SMTP

sendEmail gains two options. requireTransport makes it refuse, rather
than fall back to logging to the console, when neither Postmark nor
SMTP is configured, and the refusal names a transport that failed to
load. trackLinks controls Postmark link tracking so security emails can
turn it off and keep their one-time links unrewritten.

nodemailer becomes a real dependency of @copilotkit/outpost, and the
web server bundle stops aliasing it to an empty module, so SMTP sends
work in the production web image instead of failing at load time.
Two system templates: password-reset carries the one-time reset link,
password-changed tells the member their password was changed and their
other sessions were signed out. They are fixed system templates, not
editable from the templates UI, because their wording is part of the
security flow.
The web service sends through Mailpit (SMTP on 1025, UI and API on
8025), so local email, including the password reset e2e suite, takes
the real sendEmail-to-SMTP path instead of the console fallback.
validateNewPassword enforces the minimum length and rejects passwords
longer than 72 UTF-8 bytes, which bcrypt would silently truncate. The
invite-accept route uses it, and now refuses unless the member is still
INVITED, so an accept link cannot overwrite the password of an ACTIVE
or DISABLED member.
Sign-in stamps the member's sessionVersion into the JWT, and every
later request re-reads the member: a missing member, a status other
than ACTIVE, or a different sessionVersion revokes the session. The
same check refreshes the role from the database, so a role change takes
effect on the next request without signing the member out. A database
error during the check revokes too (fail closed). The check applies
to credentials and OIDC sessions; GitHub mode stores the GitHub account
id as memberId, so it has no TeamMember row to check.

Credentials sign-in refuses non-ACTIVE members. OIDC sign-in leaves an
ACTIVE member behind (creating one, or activating an INVITED one) or
refuses; it no longer falls back to the provider subject as memberId.

What bumps sessionVersion:
- Disabling a member signs out all of their sessions. Re-enabling
  restores ACTIVE when the member has joined or has a password, and
  INVITED otherwise. Admins cannot disable themselves.
- A profile password change signs out the member's other sessions and
  re-issues the current session cookie (chunked like NextAuth's) with
  the new version, so the browser that made the change stays signed in.
  Settings > Profile says so.
- The admin data reset carries the admin's sessionVersion over, so the
  admin is not signed out by their own reset.
Move the middleware's public path list into lib/public-paths.ts so the
client can share it, and add /forgot-password, /reset-password and
/api/password-reset/. Matching now stops at a path-segment boundary:
'/login' covers '/login/x' but no longer '/loginx'. The reset API routes
skip the middleware CSRF check like the other public routes, and carry
their own JSON Content-Type check instead.

The app shell renders the two reset pages bare, without the sidebar,
using the same segment-bounded matching.
The middleware runs on the edge and only decodes the JWT, so a revoked
session still loads protected pages; the sessionVersion check runs when
the client reads /api/auth/session. SessionGuard watches useSession()
and, when it reports unauthenticated on a non-public path, reads the
session once more itself and redirects to /login only if the server
answers 200 with no user. A failed read (offline, a proxy 502, a 500)
is not proof the session ended, so the guard stays put and checks again
when the tab next becomes visible.
…ata fails

When a session is revoked, the dashboard's data requests return 401
before SessionGuard sends the page to /login. MyTasks and FaqSection
treated the 401 error body as their list and crashed the page to the
error screen (.map and .length on a non-array). The redirect to /login
still completed; the crash was a broken screen on the way there.

Each widget now treats any non-OK response, or a failed request, as a
load failure: MyTasks, FaqSection and the stats panel show an error
instead of crashing or showing success copy such as "No FAQs available
yet". The stats panel clears the previous month's numbers on failure
and offers Try again.
hit() counts requests per key in AuthRateLimit with one atomic upsert
per call, in epoch-aligned windows so every replica computes the same
bucket. Postgres is the only store shared across replicas and deploys.
A database error is rethrown so callers fail closed. Old windows are
pruned opportunistically.

clientIp() keys limits on X-Real-IP, which Railway's edge sets to the
remote address. X-Forwarded-For is not read. A missing header shares
one 'unknown' bucket, which fails closed.
Reset links carry a random token in the URL fragment, so it never
reaches a server log or a Referer; only its SHA-256 hash is stored. The
link base is NEXTAUTH_URL only, never a request header, and there is no
localhost fallback in production. Links expire after 30 minutes.

issuePasswordReset emails a new link with link tracking off and the
console fallback refused. A failed send keeps the previous link
working; only a delivered link supersedes older ones. Issuing changes
no session.

isResetTokenValid accepts a token only while it is unused, unexpired,
its member is ACTIVE, and no password change has happened since issue.
Unknown, used, expired and stale tokens look the same to the caller.

consumePasswordReset sets the new password and bumps sessionVersion,
revoking every session. Only one request can claim a token, and the
member write is a compare-and-set on the version the token was issued
against. Issue and consume both lock the member row before any token
row, so concurrent links for one member cannot deadlock (40P01).
Three public routes under /api/password-reset/, all behind one guard:
404 unless AUTH_PROVIDER is credentials, and 415 unless the body is
exactly application/json, which forces a CORS preflight on a
cross-origin post now that these paths skip the middleware CSRF check.

- request answers 200 for any well-formed email, whether or not a
  member exists or the per-email limit is hit, and emails a link only
  to an ACTIVE member with a password. The lookup and send run after
  the response, so its timing reveals nothing. Limited per IP (429) and
  per hashed email.
- verify reports whether a link is still usable, so the reset page can
  say so before the user types a password. It never consumes the link.
- confirm validates the new password, consumes the link, revokes every
  session, and sends the password-changed email after responding.

verify and confirm share one per-IP limit. Server errors return 500
without echoing details, and a limiter error fails closed.
The login form links to /forgot-password, carrying the typed email.
/forgot-password posts the email and shows the same "check your email"
text for every accepted request, so it never reveals whether an
account exists.

/reset-password reads the token from the URL fragment, removes it from
the address bar and history, checks it with the verify endpoint before
showing the form, and sets the new password through the confirm
endpoint. Only a 410 from verify marks the link expired; a 429, a 5xx
or a network error offers Try again with the token kept in component
state.

/reset-password is served with Referrer-Policy: no-referrer as defense
in depth; the token is in the fragment and never reaches Referer anyway.
POST /api/team/[id]/password-reset sends the member the same link they
could request themselves. The response never contains the token or the
URL, because an admin holding the link could set the member's password
and sign in as them. Sending revokes nothing; sessions end only when the
link is used. Only credentials mode, only ACTIVE members with a
password, and never the admin's own account. Limited per target member,
counted before the send, so the 429 copy never claims a link was sent;
a limiter error fails closed.

GET /api/team reports hasPassword for each member (the hash never
leaves the server), and Settings > Team shows a send-reset-link action
for members who have one.
The suite runs a locally built web image (OUTPOST_E2E_IMAGE) against
Postgres and Mailpit from its own compose project and ports, and drives
the real flows end to end: the image sends email over SMTP, the reset
pages are public, self-service reset through the emailed link is single
use and revokes every session, an expired link shows the expired state,
a profile password change keeps the current session and revokes the
others, and an admin-sent link revokes nothing until it is used.

Build the image, then run `OUTPOST_E2E_IMAGE=<tag> pnpm e2e` in apps/web
(see e2e/support.ts).
The *-db test files run only when OUTPOST_TEST_DATABASE_URL is set.
The Test step now sets it, pointing at the CI database that the
SystemConfig repair check has just migrated, and sets
OUTPOST_REQUIRE_DB_TESTS=1 so a missing URL fails those files instead
of skipping them. turbo.json passes both variables through strict env
mode.

A follow-up step reruns the *-db files through the same turbo task with
a JSON reporter and fails unless every file ran at least one test and
all passed, so losing the env passthrough cannot turn into a silent
green run.
@jpr5
jpr5 merged commit 012d904 into main Oct 29, 2026
3 checks passed
@jpr5
jpr5 deleted the blitz/outpost-pw-reset/integration branch October 29, 2026 16:28
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.

auth: no forgot-password / reset flow exists

1 participant