Skip to content

Self-host: one organization, closed sign-up, admin-required two-factor sign-in - #314

Merged
nambok merged 2 commits into
mainfrom
feat/selfhost-single-org-2fa
Oct 26, 2026
Merged

nambok merged 2 commits into
mainfrom
feat/selfhost-single-org-2fa

Conversation

@nambok

@nambok nambok commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A self-hosted install is now one organization, closed to sign-ups, and admins can require two-factor sign-in.

  • One organization. The first admin's first sign-in makes it, and nobody can make another one. People join only by an admin's invite, or with an email at a domain the admins allow. Every way in is refused otherwise: email sign-up, email sign-in, Google, GitHub, and a direct Cognito SignUp call (a new pre-sign-up trigger checks the same rules).
  • Runtime settings. Allowed domains and the two-factor rule live in the settings table and are changed in Settings, Sign-in. The install config's signup list is the first-boot default: its addresses become invites and its domains the allowed domains.
  • Required two-factor sign-in. Password accounts use Cognito's authenticator-app codes. Google and GitHub sign-ins never pass Cognito MFA, so CoderHelm asks for its own authenticator-app code right after them (the secret is sealed with the install's KMS key, and a code works once). The session token records whether its sign-in passed a second factor. With the rule on, the middleware lets a session without one reach only the setup screens, on every request, so older sessions are held at their next request. API keys are not affected.
  • Admins see who has it set up and can reset a person's second factor after checking who is asking. The reason is required and logged before anything changes.
  • Wrong codes are capped per person, by email, across every way in: the code after Google or GitHub, Cognito's code after a password, and turning the app code off.
    • Ten wrong codes in 15 minutes pause code sign-in for 15 minutes. Each later pause doubles, up to a day, and the count resets after a quiet day.
    • A pending Google or GitHub code step dies after five wrong codes.
    • The person gets a plain 429 sentence with Retry-After.
    • Admins get an email and a line in the log under Settings, Roles.
  • Sessions end when an admin resets someone's two-factor, removes them or changes their role, or when the person turns their own two-factor off (the session they used keeps working). Each member record carries sessions_valid_after. The auth middleware reads the record on every session request and refuses a session issued before that time, or one whose record is gone. This applies on hosted too, for removals and role changes.
  • Hosted is otherwise unchanged. A team-level switch for hosted is left for later.

Dashboard and install guide: CoderHelm/coderhelm229

Changes

  • routes/sign_in.rs (new):
    • the organization record and its adoption on older installs;
    • decide_join, the one join decision;
    • the first-admin, invite and domain joins;
    • domain checks, which refuse public email services;
    • the request gate and RFC 6238 codes;
    • the second step after Google or GitHub;
    • GET/PUT /api/sign-in, GET /api/users/2fa, the /api/users/2fa/app setup, verify and disable routes, POST /api/users/:id/2fa/reset and POST /auth/2fa/verify.
  • auth/jwt.rs: a SignInProof (second factor passed, and the way in) carried in the session token beside the claims, and a five-minute second-step token. Every token re-issue keeps it.
  • middleware/auth.rs: on a self-hosted install, it checks the rule on each session request before the role check.
  • routes/auth.rs:
    • sign-up is checked first, and a refusal from the pre-sign-up trigger maps to not_invited;
    • confirming an email makes no team;
    • email, Google and GitHub sign-ins land through selfhost_landing, never a new team;
    • Google and GitHub sign-ins then go to the code step.
  • routes/users.rs:
    • Cognito setup now records the factor;
    • turning on Cognito MFA gives the session its second factor;
    • Cognito MFA can't be turned off while the rule is on.
  • permissions.rs: a new sign_in capability (admins, never in a custom role). The setup routes go under own_account.
  • lambda/pre-signup (new, Node): the Cognito trigger. It reads only, fails closed, and is tested against cases.json, the same cases the Rust test reads.
  • Infra:
    • the pre-sign-up trigger on self-host (single-origin) only;
    • Cognito MFA stays OPTIONAL with TOTP, because the "required" switch is CoderHelm's and changes at runtime;
    • the config list is seeded with kind (admin, invite, domain);
    • new signup.requireTwoFactor setting.
  • Launch Stack:
    • "Let people from your company join" (empty means only people you invite);
    • a new RequireTwoFactor parameter.
  • CLI wizard: its sign-up domain now defaults to empty (only people you invite) instead of the admin's own domain.
  • Docs:
    • self-host/sign-in.md rewritten: one organization, invites, domains, two-factor, reset, and why authenticator apps rather than passkeys;
    • config reference, options and quick start updated.
  • CI: the trigger's tests run in the Lambda job.

Verification

  • cargo fmt --check, cargo clippy --workspace --tests -D warnings: clean.
  • cargo test --workspace after rebasing on main (Self-host: switch the model provider at any time, and check a release's models before updating #318): 1009, 2, 506 (gateway) and 687 passed. New tests:
    • every shared join case;
    • a stranger refused once the organization exists;
    • domains and lookalikes;
    • an invite to another team;
    • no second organization;
    • domain cleaning and public mail;
    • the config seed;
    • the gate on every route but setup, for password and federated sessions;
    • every setup route is routed;
    • RFC 6238 vectors and single use;
    • a reset needs a reason;
    • every sign-up and sign-in path checks the rules before writing or issuing a session (signup, verify, email sign-in, Google, GitHub);
    • the middleware checks before the role check on every request, with no cache;
    • a reset is logged before Cognito or the record changes;
    • a session carries its proof, and an old token has none;
    • the lockout: ten wrong codes in a window pause, pauses double up to a day, the count resets after a quiet day, spread-out codes and a right code don't pause, a pending token dies at five, and one count by email across ways in;
    • session end: a removed record, an invite record, a revoked session, one issued at the cut, and the 401 clearing the cookie;
    • every code check refuses a paused person before checking and counts failures, and every reset, role change and two-factor-off ends sessions;
    • the middleware checks the member record before the role check, with no cache.
  • node --test lambda/pre-signup/handler.test.js: 6 passed. Covers the shared cases, a direct SignUp from a stranger, Google sign-ups, invites and the first admin, and failing closed.
  • Infra npm test: 66 passed. New tests:
    • self-host has the pre-sign-up trigger, read-only access, Cognito permission to invoke it, MFA OPTIONAL with SOFTWARE_TOKEN_MFA, and the seeded kinds and rule;
    • hosted has no trigger and keeps MFA OPTIONAL;
    • signup.requireTwoFactor is checked.
  • Installer: 90 passed. Launch bootstrap: 31 passed. cfn-lint on the templates: clean. Python Lambda tests: OK.
  • The diff has no customer names and no arrow glyphs.

@nambok
nambok force-pushed the feat/selfhost-single-org-2fa branch from 506931c to c885bed Compare October 26, 2026 18:46
@nambok
nambok force-pushed the feat/selfhost-single-org-2fa branch from c885bed to daafb88 Compare October 26, 2026 20:14
@nambok
nambok merged commit 7d7edd7 into main Oct 26, 2026
8 checks passed
@nambok
nambok deleted the feat/selfhost-single-org-2fa branch October 26, 2026 21:37
nambok added a commit that referenced this pull request Nov 4, 2026
Security fix. Since #464 forgot-password on a self-hosted install answered
with a different status per case (has an account, on the team without
one, invited, not on this install), so anyone could learn which emails
have accounts or are on the team.

Forgot password now answers the same status, body and headers for every
email on every install, and no sooner than 2.5 seconds after the ask, so
the case can't be timed either: the work (the Cognito reset code, or the
set-up email for a team member with no account here) runs while the
answer waits, and a slow send carries on after it. Send failures are
logged, never answered. The set-up email keeps its once-an-hour limit and
the 10/h per IP and 5/h per email limits stay.

Self-hosted sign-up answers the same way for invited, existing and
refused emails, with the password rules checked for every email first; an
email that signed up but never confirmed gets a fresh code. The app
client now has preventUserExistenceErrors on.

Pins the dashboard to d26225b (CoderHelm/coderhelm #314): the sign-in
page shows one message for every email and names both senders.
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