Repository navigation
feat(web): password reset, session revocation and admin reset links - #295
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, aPasswordResetTokentable that stores only the SHA-256 hash of each token, and anAuthRateLimittable. All three are additive.Email sending (shared package).
sendEmailhas two new optional settings.requireTransportrefuses 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.nodemaileris 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-resetandsystem/password-changed. They live intemplates/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
sessionVersionin 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 differentsessionVersion. 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 profilePUTbumps 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'ssessionVersionwhen it rebuilds the admin row.Public paths. The public path list moved to
lib/public-paths.tsand gained/forgot-password,/reset-passwordand/api/password-reset/. Matching now stops at a path-segment boundary, so/logincovers/login/xbut no longer/loginx. The two reset pages render without the app sidebar.Revoked session on an open page.
SessionGuardsends the browser to/loginwhen 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.
issuePasswordResetand 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 isapplication/json.requestalways answers200 { ok: true }and does the lookup and send inafter(), so neither the response body nor its timing shows whether an account exists.Pages. The login form has a "Forgot password?" link.
/forgot-passwordshows the same confirmation for every request./reset-passwordreads the token from the URL fragment, removes it from the address bar and history, checks it withverifybefore showing the form, and sets the password withconfirm. 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-resetworks 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/teamreturnshasPassword, 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
*-dbtests now run in CI withOUTPOST_REQUIRE_DB_TESTS=1. A follow-up step fails the job unless every*-dbfile 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
randomBytes(32), hex). Onlysha256(token)is stored, so a database leak gives no working links./reset-password#token=…), which the browser never sends to the server, so it stays out of request logs andReferer. The page removes it withhistory.replaceStateand sends it in a POST body. The reset page sendsReferrer-Policy: no-referrerand loads no third-party assets.NEXTAUTH_URLonly, neverHostorX-Forwarded-Host, which blocks reset-link poisoning. Production has nolocalhostfallback.sessionVersionthe token was issued at, so any password change kills every outstanding link.sessionVersionbumps on reset, on profile password change and on disable. The per-request check also refreshes the role. Thejwtcallback ignores clientupdate()payloads, so a client cannot set its own version.request, and the work runs after the response.verifyandconfirmgive the same answer for every kind of bad token.requireTransportmeans 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.Content-Type: application/json, which forces a CORS preflight on any cross-origin post. The admin route stays behind the existing CSRF check.Testing
*-dbfiles (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*-dbfiles passed 5 runs in a row.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:
joinedAtback to INVITED, which locked them out. Now either a password or ajoinedAtcounts as joined./reset-password-eviland similar paths matched the public list. Matching is now segment-bounded.application/json-patch+jsonandapplication/json.evil. The match is now anchored to the media type./api/auth/sessionsent valid sessions to/login. Now only a confirmed empty session redirects.Deployment notes
migrate deploy) and is additive.ADD COLUMN … DEFAULT 0changes only the catalog, but it takes a brief ACCESS EXCLUSIVE lock on"TeamMember"that waits behind any open transaction. Deploy at low traffic.[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 withOUTPOST_ALLOW_SCHEMA_DRIFT=1, or roll back web and worker together.sessionVersionon a profile password change, so that change revokes no sessions. This window closes when the rollout completes. Old replicas serve no reset routes.outpost-webneeds a working transport, eitherPOSTMARK_SERVER_TOKEN(with sender-domain DNS, Email go-live: configure Postmark DNS for support.copilotkit.ai #215) orSMTP_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-messageTrackLinks: 'None'is the control either way.NEXTAUTH_URL: it must be an absolutehttps://host[/path]with no query or fragment. If it is unset in production, no reset link is sent, and the log saysNEXTAUTH_URL is not set.TimeZoneis UTC (SHOW timezone). A non-UTC session time zone shifts the rate-limit windows.X-Real-IPheader 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
/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.memberIdis the GitHub account id, not aTeamMemberrow.callbackUrlignored:/loginignorescallbackUrland always lands on/dashboard. This was already the case before this PR.Deviations from the spec
note:frontmatter key instead of a comment at the top of the file, because a leading comment breaks frontmatter parsing.postmarkpackage instead of the HTTP boundary. The built-image e2e covers the real send path over SMTP.sessionVersion.Follow-ups
These items are out of scope and tracked in the internal follow-up backlog:
requireTransportortrackLinks, and invite accept does not guard its body parse.email_verifiedis not checked.res.ok, and a 401 leaves a dead page.profile.logincan 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.