Merge feat/oidc-sso: OpenID Connect SSO, per-organization providers, DNS-verified domains

Replaces an OAuth implementation that verified nothing that mattered. The Google path
asked tokeninfo whether an ACCESS token was valid and trusted the email in the reply;
the Microsoft path handed a bearer token to Graph /me and trusted that. Neither checked
who the token was issued FOR, so any site a user signed into that asked for `email` or
`User.Read` could replay that token here and be issued a session as them. Identity now
comes from an ID token: signature against the published JWKS, iss, aud, azp, exp, and a
nonce this server generated for that specific login.

  - one flow for every provider (Authorization Code + PKCE, server-side), so Google and
    Microsoft are ordinary entries rather than special cases; any OIDC provider works
  - per-organization providers configured by customers, with sign-in domains PROVED by
    a DNS TXT record — a claim reserves nothing until DNS says so, lapses after 8 hours
    if unproved, and releases rather than renewing
  - optional per-organization SSO-only, where removing the requirement needs a platform
    admin's approval; the operator queue lives under Admin
  - a boot-time dependency preflight, because this branch removes a dependency and a
    rollback would otherwise not start

Instance-wide configuration is the default and unchanged: with no SSO variables set,
the login page and every auth flow behave exactly as before.

Six review rounds, sixteen agent audits. Roughly half of all defects found were in
FIXES rather than in original code — including an account takeover, three separate
lockouts, a CSP block that meant per-organization SSO had never worked in a browser at
all, and a stored XSS where the first fix escaped one of two copies of the same table.
Each is documented at the code it touches, because the reasoning is the part worth
keeping.

1609 tests.
This commit is contained in:
ScreenTinker 2026-08-11 11:29:30 -05:00
commit 8361392ebd
24 changed files with 5221 additions and 464 deletions

224
README.md
View file

@ -318,31 +318,223 @@ advertising it — put the account on the hidden plan and it simply gets those l
above deliberately lists hidden plans too (marked as such), because the previous behaviour was that
a hidden plan was invisible to the operator as well as the customer.
#### Google OAuth
#### Single sign-on (OpenID Connect)
Let users sign in with Google.
Any OIDC provider works — Google, Microsoft/Entra, Okta, Auth0, Keycloak, Authentik, Zitadel — through
one flow: **Authorization Code with PKCE, run server-side**. The browser never talks to the provider
directly, so there is no SDK to load and no third-party script origin to allow in the CSP.
1. Create a project in [Google Cloud Console](https://console.cloud.google.com)
2. Enable the Google Identity API
3. Create OAuth 2.0 credentials (web application)
4. Add `https://yourdomain.com` as an authorized origin
Every login is verified as an **ID token**: signature against the provider's published JWKS,
`iss` exactly as discovered, `aud` (and `azp`) matching your client, `exp`, and a `nonce` this server
generated for that specific login. An access token is never accepted as proof of identity.
Set the redirect URI at your provider to:
```
https://yourdomain.com/api/auth/oidc/<slug>/callback
```
Set `APP_URL` so that origin is pinned — the redirect URI must match your provider's registration
exactly, and deriving it from the request `Host` would both break behind a second hostname and take
its value from the caller.
**Google** and **Microsoft** need only the variables this README has always documented; their issuer
is filled in for you and their slugs are `google` and `microsoft`:
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Your Google OAuth client ID |
| `GOOGLE_CLIENT_ID` | OAuth 2.0 client ID from [Google Cloud Console](https://console.cloud.google.com) |
| `MICROSOFT_CLIENT_ID` | Application (client) ID from the [Azure portal](https://portal.azure.com) |
| `MICROSOFT_TENANT_ID` | **Your tenant GUID — required.** `common`/`organizations` are refused |
#### Microsoft OAuth
⚠️ **Multi-tenant Microsoft (`common`) is deliberately refused, and Microsoft sign-in stays disabled
until you set a tenant GUID.** Two reasons that point the same way. It cannot work: Microsoft's
multi-tenant metadata advertises the literal template `https://login.microsoftonline.com/{tenantid}/v2.0`,
so the issuer never matches and every login fails anyway. And the obvious fix is dangerous — accepting
that template means accepting tokens from *every* Azure tenant, which is
[nOAuth](https://www.descope.com/blog/post/noauth): any tenant admin can set an arbitrary, unverified
`email` on one of their own users and be issued a session as that address. Safe multi-tenant support
needs per-tenant pinning (validate `tid` against an allowlist, key accounts on `oid`+`tid` rather than
email) and is not implemented.
Let users sign in with Microsoft/Azure AD.
**Any other provider** is added by slug:
1. Register an app in [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps)
2. Add a web redirect URI: `https://yourdomain.com`
3. Note the Application (client) ID
```bash
OIDC_PROVIDERS=okta,authentik
OIDC_OKTA_ISSUER=https://example.okta.com
OIDC_OKTA_CLIENT_ID=0oa...
OIDC_OKTA_NAME=Okta # optional button label
OIDC_OKTA_CLIENT_SECRET=... # optional — PKCE means a public client works
OIDC_OKTA_SCOPES=openid email profile # optional
```
| Variable | Description |
|----------|-------------|
| `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID |
| `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) |
The issuer is the base URL whose `/.well-known/openid-configuration` describes the provider; endpoints
and keys are discovered from it and cached.
**Account rules.** A provider must assert a verified email, because the whole account model keys on
it. An SSO login never takes over an existing account that has a password — the owner signs in
locally and links from Settings. If the provider's stable subject (`sub`) changes for an address, the
login is refused rather than handing an account to a recycled mailbox.
An account established by one provider is **not** adopted by another. A per-organization provider may
only claim an account that its own organization established, or a `local` account that has never set
a password (an invited user signing in for the first time); anything else is refused with
`account_exists_other_provider`. The earlier rule — "any account without a password may be re-pointed
at whichever provider spoke last" — was safe only while the operator chose every provider, and became
an account-takeover primitive the moment customers could add their own.
⚠️ **TOTP is not prompted on an SSO login.** Second-factor is the identity provider's job in this
flow, matching the long-standing behaviour of the SSO and API-token paths.
#### Per-organization SSO (customer-configured)
The providers above are **instance-wide** — they belong to whoever runs the server and appear as
buttons on the login page for everyone.
An organization can also bring **its own** identity provider, configured by an org owner or admin in
**Settings → Single sign-on**. No environment variable or restart is involved.
A per-org provider is **never listed publicly**. It appears only when someone types an email address
at one of that organization's **verified** domains, at which point the login page offers a generic
"Continue with single sign-on" button. The domain lookup answers only whether that domain uses SSO
and whether it is required — never a provider name or slug — so a guessed domain cannot confirm who
a customer is, and the mapping back to a provider happens server-side on submit. Both endpoints are
rate limited.
**Instance-wide is the default; an organization overrides only its own verified domains.** Type an
address whose domain no organization has verified and you get the local password form plus every
instance provider you configured. Type one that an organization has verified and its own button is
added — and if that organization requires SSO, it becomes the only option.
Each provider gets a randomly generated redirect URI, shown in Settings, which the admin registers
with their identity provider:
```
https://yourdomain.com/api/auth/oidc/<generated-slug>/callback
```
The slug is generated rather than chosen so two customers cannot collide on — or guess — each
other's. A domain may be claimed by only one organization; a second claim is refused.
⚠️ **A provider may only authenticate emails inside the domains it has VERIFIED.** An organization
supplies its own issuer and client ID, so it controls that identity provider completely and could
otherwise assert any address at all — including another company's, or an administrator's. Confining
assertions to verified domains is what makes customer-configurable SSO safe to offer.
⚠️ **Public email providers cannot be claimed.** `gmail.com`, `outlook.com`, `yahoo.com`, `icloud.com`
and the rest of the consumer mailboxes are refused (`server/lib/public-email-domains.js`). Claiming
one would offer every Gmail user a "sign in with your organization" button pointing at one tenant's
infrastructure — phishing launched from this product's own login page — and would let one account
deny a public domain to everyone else.
##### Proving a domain
A claimed domain **routes nobody and authenticates nobody until DNS proves the organization controls
it.** Typing a domain into a form reserves the name and nothing more.
Publish this record, then press **Verify**:
```
_screentinker-verify.example.com. IN TXT "st-verify=<token>"
```
The token is unique per domain, so publishing one proof cannot be replayed to claim a second. A
dedicated `_`-prefixed name is used rather than the apex, where a careless edit would sit alongside
SPF and DMARC and break mail — and where a wildcard `*.example.com` could not be confused for a
proof, since a wildcard answers with its own value and never with the token.
TXT is the only accepted form. A CNAME alternative would have to point at a wildcard zone this
project operates, answering for every token ever issued; documenting one without running it would
describe a check that can never pass.
⚠️ **The proof name itself must not be a CNAME.** A TXT lookup follows CNAMEs, and a wildcard
`*.example.com` covers `_screentinker-verify.example.com` too — so a wildcard CNAME would let
whoever controls its target prove the domain, turning an ordinary subdomain takeover into control of
every `@example.com` login. A delegated proof name is refused, which is stricter than ACME's dns-01.
**An unverified claim lapses after 8 hours, and lapsing RELEASES it.** Pressing Verify on an
expired claim does not reissue it in place — that renewed the clock, so one request per window held
a domain forever. The claim is released, the domain becomes free for anyone else, and re-adding it
is a new claim: new token, and the operator is notified again. A verified domain never expires;
re-proving on a timer would log a customer out over a DNS edit made months afterwards. Squatting is
not made impossible — it is made loud.
**Deleting a provider releases its domains and returns its accounts to local sign-in**, so the
organization can re-claim its own domain and its people can recover by password reset. Both used to
be stranded: a verified domain row outlived its provider and blocked that domain for everyone
permanently, and its users could neither sign in nor reset.
Platform admins are emailed whenever a domain is claimed. Verification is what makes an unowned
claim worthless; the notification is what makes an attempt visible. Nothing is ever sent to the
claimed domain itself — that would let any tenant make this product email third parties.
⚠️ **Instance-wide providers are exempt from all of the above.** `GOOGLE_CLIENT_ID`, `OIDC_*` and
friends are the operator's own configuration, are not domain-restricted, and require no verification.
Domain proof exists because per-organization providers are supplied by CUSTOMERS.
Signing in through an organization's provider makes the user a member of that organization
(`org_member`). Existing members keep whatever role they already have — logging in never promotes or
demotes anyone. Client secrets are optional (PKCE), and are stored AES-256-GCM encrypted and never
returned by the API.
##### Requiring single sign-on
An organization can turn off password sign-in for its verified domains, so its identity provider is
the only way in — which is the point of buying SSO: the IdP holds the MFA, the conditional access
and the instant removal of access, and a password box beside it is a way around all three.
Settings → Single sign-on → **Require single sign-on**. It needs at least one verified domain, so an
organization cannot leave its own people with no way to sign in, and cannot switch off passwords for
a domain it merely typed.
When it is on:
- the login page **hides** the password field for those domains rather than letting someone type a
password that is going to be refused and then send them to reset it;
- `POST /api/auth/login` refuses with `403 sso_required` — distinguishable from a wrong password,
because the page must not tell a user to fix a credential that is not the problem;
- **every other identity provider is refused too**, including the instance's own Google or
Microsoft. Those belong to the operator and are not domain-restricted, so leaving them available
would be a side door straight past the customer's MFA — blocking passwords while leaving
"Continue with Google" is not requiring single sign-on, it is renaming the bypass.
**Turning it off is a request, not a switch.** That direction re-opens password sign-in, so it is
the direction an attacker who has taken an org admin would take, and it is also what a customer will
demand at their worst moment — identity provider down, nobody can work — which is exactly when a
self-service toggle gets flipped without thinking. The org admin files a request; a **platform admin
approves it**, and nothing changes until they do.
The approval email deliberately carries **no action link**. A token that acts on its own would turn
every forwarded, archived or auto-previewed copy of that message into a way to switch off a
customer's single sign-on. The decision is made signed in, under Admin.
⚠️ **`platform_admin` is exempt from enforcement, and that exemption is load-bearing.** The operator
is who approves removal. If the operator's own address sat at an SSO-only domain and that identity
provider broke, nobody could sign in to approve anything and the instance would be bricked with no
way out. It is the break-glass — it applies to the people running the server, never to a customer's
own admins.
⚠️ **This makes the approval queue an availability dependency.** An organization whose IdP breaks is
locked out until an operator acts. That is the intended trade — deliberate friction on the dangerous
direction — but it should be a decision, not a surprise.
#### Dependency preflight on boot
Before anything else is loaded, the server checks that the packages this build declares are actually
installed and that the native database module loads under the running Node. If either is wrong it
repairs it (`npm install --omit=dev`, or `npm rebuild better-sqlite3`) and continues; if it cannot,
it exits saying what to run rather than dying on a `MODULE_NOT_FOUND` naming a file.
`scripts/upgrade.sh` already installs dependencies, so this is not for the normal path. It is for
the ways a box ends up with the wrong `node_modules`:
- **rolling back** to an older tag restores that tag's `package.json` but not its packages — and you
are rolling back because something is already wrong;
- **upgrading Node** leaves `better-sqlite3` compiled against the previous ABI, which fails in a way
that reads like database corruption and is not.
Set `ST_SKIP_DEP_PREFLIGHT=1` on an air-gapped host, or anywhere you manage `node_modules` yourself
and do not want a boot reaching for the registry.
#### Email (Microsoft Graph or SMTP)

View file

@ -248,7 +248,7 @@ async function refreshCurrentUser() {
// a redirect loop.
const hash = window.location.hash || '#/';
if (hasNoAccessibleWorkspace(fresh)
&& hash !== '#/no-workspace' && hash !== '#/login' && hash !== '#/change-password') {
&& hash !== '#/no-workspace' && !hash.startsWith('#/login') && hash !== '#/change-password') {
window.location.hash = '#/no-workspace';
}
} catch {}
@ -338,14 +338,29 @@ function route() {
// do nothing. The login view reads the token off the hash and shows the new-password form.
const isResetRoute = hash.startsWith('#/reset-password');
/*
* The SAME rule the comment above states, for the login route.
*
* The server finishes every single sign-on by redirecting to `#/login?sso=1` (claim the session)
* or `#/login?sso_error=<code>` (say what went wrong). Matching the hash EXACTLY meant neither
* survived: an unauthenticated browser the only kind that arrives here had the hash rewritten
* to a bare `#/login` and the query was gone before the login view ever ran. So a user who
* authenticated perfectly at their identity provider landed back on a clean login page, still
* signed out, with no message; and all sixteen error codes rendered SILENCE, which is worse than
* a wrong message because there is nothing to report or search for.
*
* It took the pre-existing `?verified=1` email-verification toast with it.
*/
const isLoginRoute = hash === '#/login' || hash.startsWith('#/login?');
// Auth check - redirect to login if not authenticated
if (!isAuthenticated() && hash !== '#/login' && !isResetRoute) {
if (!isAuthenticated() && !isLoginRoute && !isResetRoute) {
window.location.hash = '#/login';
return;
}
// If authenticated and on login page, redirect to dashboard or onboarding
if (isAuthenticated() && (hash === '#/login' || isResetRoute)) {
if (isAuthenticated() && (isLoginRoute || isResetRoute)) {
window.location.hash = localStorage.getItem('rd_onboarded') ? '#/' : '#/onboarding';
return;
}
@ -422,8 +437,10 @@ function route() {
return;
}
// Login page (and password-reset links from email) - hide sidebar
if (hash === '#/login' || isResetRoute) {
// Login page (and password-reset links from email) - hide sidebar.
// Matches `#/login?...` too: the single sign-on return carries `?sso=1` / `?sso_error=<code>`,
// and an exact comparison meant the login view was never rendered for either.
if (isLoginRoute || isResetRoute) {
sidebar.style.display = 'none';
app.style.marginLeft = '0';
const mb = document.getElementById('mobileMenuBtn');

View file

@ -1,3 +1,15 @@
/*
* Messages are ESCAPED. This builds innerHTML, and callers pass server error strings straight
* in including ones that reflect user input verbatim, such as the OIDC issuer in
* `not a URL: <value>`. A review typed `<img src=x onerror=alert(1)>` as an issuer and got script
* execution in the admin's own session. A toast is a place text goes, never markup.
*/
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
export function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
@ -10,7 +22,7 @@ export function showToast(message, type = 'info', duration = 4000) {
type === 'error' ? '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>' :
'<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>'}
</svg>
<span>${message}</span>
<span>${esc(message)}</span>
`;
container.appendChild(toast);
setTimeout(() => {

View file

@ -132,6 +132,93 @@ export default {
'auth.trial_notice': 'New accounts get a 14-day free Pro trial',
'auth.divider_or': 'OR',
'auth.signin_google': 'Sign in with Google',
'auth.signin_with': 'Continue with {provider}',
'auth.sso_failed': 'Single sign-on failed. Please try again.',
'auth.sso_org_hint': 'Your organization uses single sign-on.',
'auth.signin_sso': 'Continue with single sign-on',
'sso.title': 'Single sign-on',
'sso.blurb': 'Let your team sign in with your own identity provider. Anyone using an email address at one of your domains will be sent there instead of being asked for a password.',
'sso.add': 'Add a provider',
'sso.none': 'No provider configured yet.',
'sso.create': 'Add provider',
'sso.saved': 'Saved',
'sso.save_failed': 'Could not save that provider.',
'sso.load_failed': 'Could not load single sign-on settings.',
'sso.missing_fields': 'Name, issuer and client ID are required.',
'sso.confirm_delete': 'Remove this provider? Anyone who signs in with it will lose that route.',
'sso.delete': 'Remove',
'sso.enable': 'Enable',
'sso.disable': 'Disable',
'sso.disabled': 'disabled',
'sso.domains_label': 'Email domains',
'sso.domains_heading': 'Sign-in domains',
'sso.removed': 'Removed.',
'sso.only_stranded': 'Single sign-on is now required.\n\nThese members are not at a verified domain, so they can no longer sign in at all:\n\n{list}\n\nVerify their domain, or remove them from this organization.',
'sso.only_heading': 'Require single sign-on',
'sso.only_help': 'When required, people at your verified domains can only sign in through your identity provider — a password will not work. Your provider keeps control of MFA and of removing access.',
'sso.only_on': 'Single sign-on is required for your verified domains.',
'sso.only_off': 'Password sign-in is still allowed alongside single sign-on.',
'sso.only_enable': 'Require single sign-on',
'sso.only_confirm': 'Require single sign-on for everyone at your verified domains?\n\nPasswords will stop working for them at their next sign-in; sessions already open continue until they expire. Turning this back off needs approval from the people who run this server, so make sure your identity provider is working first.',
'sso.only_needs_domain': 'Verify a sign-in domain first — otherwise nobody would be able to sign in.',
'sso.only_remove_help': 'Turning this off re-opens password sign-in, so it needs approval from the people who run this server.',
'sso.only_request': 'Request to stop requiring single sign-on',
'sso.only_reason_prompt': 'Why do you need password sign-in re-opened? (optional, but it helps the reviewer)',
'sso.only_requested': 'Request sent. Single sign-on stays required until it is approved.',
'sso.only_pending': 'A request to stop requiring single sign-on is awaiting approval. Nothing changes until then.',
'sso.only_cancel': 'Withdraw request',
'sso.only_cancelled': 'Request withdrawn.',
'sso.only_failed': 'That did not work.',
'sso.domain_verified': 'verified',
'sso.domain_pending': 'not verified — routes nobody yet',
'sso.unverified_warning': 'Some domains are not verified yet, so nobody is routed to this provider by email address.',
'sso.verify_now': 'Verify',
'sso.verifying': 'Checking DNS…',
'sso.verify_failed': 'Could not verify that domain.',
'sso.domain_verified_toast': '{domain} is verified.',
'sso.dns_instructions': 'Publish this TXT record in this domain\u2019s DNS, then click Verify. Claims expire after 8 hours.',
'sso.callback_label': 'Redirect URI — add this to your provider',
'sso.f_name': 'Display name',
'sso.f_issuer': 'Issuer URL',
'sso.f_issuer_hint': 'The base URL whose /.well-known/openid-configuration describes your provider. We check it before saving.',
'sso.f_client_id': 'Client ID',
'sso.f_client_secret': 'Client secret (optional)',
'sso.f_client_secret_hint': 'Leave blank for a public client — we use PKCE, so a secret is not required. Stored encrypted and never shown again.',
'sso.f_domains': 'Email domains',
'sso.f_domains_hint': 'Comma separated. Anyone with an address at these domains is sent to this provider.',
'sso.edit': 'Edit',
'sso.save': 'Save changes',
'sso.cancel': 'Cancel',
'sso.secret_set': 'A secret is set — leave blank to keep it',
'sso.secret_none': 'No secret set (public client)',
'sso.secret_edit_hint': 'Leave blank to keep the current secret. Type a new one to replace it.',
'sso.secret_clear': 'Remove the stored secret (use a public client)',
'sso.test': 'Test',
'sso.testing': 'Checking the provider…',
'sso.test_failed': 'Could not reach that provider.',
'sso.check_discovery': 'OpenID configuration',
'sso.check_endpoints': 'Authorization and token endpoints',
'sso.check_signing_keys': 'Signing keys',
'sso.test_caveat': 'This confirms the provider is reachable and its tokens can be verified. It cannot check the client ID, the secret, or that the redirect URI is registered — only a real sign-in does that.',
'auth.sso_err_expired': 'That sign-in took too long. Please try again.',
'auth.sso_err_bad_state': 'Sign-in could not be verified. Please start again.',
'auth.sso_err_no_code': 'The provider did not return an authorization code.',
'auth.sso_err_no_email': 'Your provider did not share an email address.',
'auth.sso_err_email_unverified': 'Your provider has not verified that email address.',
'auth.sso_err_verification_failed': 'We could not verify the sign-in with your provider.',
'auth.sso_err_provider_refused': 'Your provider declined the sign-in.',
'auth.sso_err_provider_unavailable': 'That provider is not reachable right now.',
'auth.sso_err_unknown_provider': 'That sign-in provider is not configured.',
'auth.sso_err_registration_disabled': 'New accounts are disabled on this instance.',
'auth.sso_err_account_exists_local': 'An account with this email already exists and uses a password. Sign in with your password instead.',
'auth.sso_err_subject_mismatch': 'This email is already linked to a different account at your provider.',
'auth.sso_err_server_error': 'Something went wrong completing sign-in.',
// Both of these used to fall through to "please try again", which is advice that can never work:
// retrying is exactly what will not help, and the user needs to be told who to talk to instead.
'auth.sso_required': 'Your organization requires single sign-on. Use \u201cContinue with single sign-on\u201d above \u2014 your password will not work here.',
'auth.sso_err_sso_required': 'Your organization requires its own single sign-on. Use the single sign-on option for your organization.',
'auth.sso_err_domain_not_allowed': 'Your organization has not verified that email domain for sign-in. Ask your administrator to verify it in ScreenTinker.',
'auth.sso_err_account_exists_other_provider': 'An account with this email already exists and signs in through a different provider. Use that provider, or ask your administrator.',
'auth.signin_microsoft': 'Sign in with Microsoft',
'auth.back_to_signin': 'Back to Sign In',
// TOTP 2FA challenge (second login step)
@ -1328,6 +1415,16 @@ export default {
'admin.orgs.ws_deleted': 'Workspace "{name}" deleted',
'admin.access_denied': 'Access Denied',
'admin.access_denied_desc': 'Platform admin access required.',
'admin.sso_only.title': 'Single sign-on removal requests',
'admin.sso_only.desc': 'An organization has asked to stop requiring its identity provider. Until you approve, nothing changes for them.',
'admin.sso_only.requested_by': 'Requested by {who}',
'admin.sso_only.effect': 'Approving re-opens password sign-in for everyone at this organization\u2019s verified domains.',
'admin.sso_only.approve': 'Approve removal',
'admin.sso_only.reject': 'Reject',
'admin.sso_only.confirm': 'Re-open password sign-in for this organization?\n\nTheir identity provider will no longer be the only way in. Approve only if you are satisfied the request is genuine.',
'admin.sso_only.approved': 'Approved. Password sign-in is re-opened for that organization.',
'admin.sso_only.rejected': 'Rejected. Single sign-on is still required.',
'admin.sso_only.failed': 'That did not work.',
'admin.all_users': 'All Users',
'admin.plans': 'Subscription Plans',
'admin.col.accounts': 'Accounts',

View file

@ -79,6 +79,15 @@ export async function render(container) {
</div>
</div>
<!-- Single sign-on removal approvals. First, because it is the only screen on this page an
operator is DIRECTED to by an email, and because a tenant is locked out of their own
product while it sits here. -->
<div class="settings-section" id="ssoOnlySection" style="display:none">
<h3>${t('admin.sso_only.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.sso_only.desc')}</p>
<div id="ssoOnlyRequests"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div>
<div class="settings-section">
<h3>${t('admin.all_users')}</h3>
<div id="allUsersTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
@ -137,6 +146,7 @@ export async function render(container) {
loadUsers();
loadOrgs();
loadSsoOnlyRequests();
loadBranding();
loadPlans();
loadSystem();
@ -146,6 +156,70 @@ export async function render(container) {
// #36: list organizations with owner + resource counts; platform admin can
// cascade-delete an org or an individual workspace (type-the-name confirm).
/*
* Pending "stop requiring single sign-on" requests.
*
* The notification email tells the operator to review this under Admin, and for a while it did not
* exist the only way to approve was curl, while the customer sat locked out. The section hides
* itself when there is nothing pending so it is never noise.
*/
async function loadSsoOnlyRequests() {
const section = document.getElementById('ssoOnlySection');
const host = document.getElementById('ssoOnlyRequests');
if (!section || !host) return;
// NB: `api` is a map of named methods, not a generic client — there is no api.get(), and calling
// one silently hid this whole section behind the catch below.
const authed = (path, init = {}) => fetch(`/api${path}`, {
...init,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...(init.headers || {}) },
});
let requests = [];
try {
const res = await authed('/organizations/sso-only/removal-requests');
if (!res.ok) throw new Error(String(res.status));
requests = (await res.json()).requests || [];
} catch {
section.style.display = 'none';
return;
}
// Clear as well as hide: leaving the last decided request in the tree kept its live
// Approve/Reject listeners attached to a request that no longer exists.
if (!requests.length) { host.innerHTML = ''; section.style.display = 'none'; return; }
section.style.display = '';
host.innerHTML = requests.map((r) => `
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px">
<div><strong>${esc(r.organization_name || r.organization_id)}</strong></div>
<div style="font-size:12px;color:var(--text-muted);margin-top:2px">
${esc(t('admin.sso_only.requested_by', { who: r.requested_by_email || 'unknown' }))}
</div>
${r.reason ? `<div style="font-size:12px;margin-top:6px">${esc(r.reason)}</div>` : ''}
<div style="font-size:12px;color:var(--warning,#b45309);margin-top:8px">${esc(t('admin.sso_only.effect'))}</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">
<button class="btn btn-danger btn-sm" data-sso-approve="${esc(r.id)}">${esc(t('admin.sso_only.approve'))}</button>
<button class="btn btn-secondary btn-sm" data-sso-reject="${esc(r.id)}">${esc(t('admin.sso_only.reject'))}</button>
</div>
</div>`).join('');
const decide = async (id, decision) => {
try {
const res = await authed(`/organizations/sso-only/removal-requests/${id}/${decision}`, { method: 'POST', body: '{}' });
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))).error) || String(res.status));
showToast(t(decision === 'approve' ? 'admin.sso_only.approved' : 'admin.sso_only.rejected'), 'success');
await loadSsoOnlyRequests();
} catch (e) {
showToast((e && e.message) || t('admin.sso_only.failed'), 'error');
}
};
// Approving RE-OPENS password sign-in for a whole organization, so it is confirmed; rejecting
// only leaves the safe state in place and is not.
host.querySelectorAll('[data-sso-approve]').forEach((b) => b.addEventListener('click', () => {
if (window.confirm(t('admin.sso_only.confirm'))) decide(b.dataset.ssoApprove, 'approve');
}));
host.querySelectorAll('[data-sso-reject]').forEach((b) => b.addEventListener('click', () => decide(b.dataset.ssoReject, 'reject')));
}
async function loadOrgs() {
const el = document.getElementById('orgsTable');
if (!el) return;
@ -276,11 +350,17 @@ async function loadUsers() {
<tbody>
${users.map(u => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px"><div style="font-weight:500">${u.name || u.email}</div><div style="font-size:11px;color:var(--text-muted)">${u.email}</div></td>
<td style="padding:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span></td>
<!-- ESCAPED: these come from self-registration and from an identity provider's
email claim, so they are attacker-chosen. A reviewer registered an address whose
local part was an img tag with an onerror handler, anonymously, and got script
execution in the PLATFORM ADMIN's session on this page - the very page operators
are now emailed to. Note backticks are illegal here: this sits inside a template
literal. -->
<td style="padding:8px"><div style="font-weight:500">${esc(u.name || u.email)}</div><div style="font-size:11px;color:var(--text-muted)">${esc(u.email)}</div></td>
<td style="padding:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${esc(u.auth_provider)}</span></td>
<td style="padding:8px;font-size:11px;color:var(--text-muted)">${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')}</td>
<td style="padding:8px">
<select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}">
<select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${esc(u.id)}">
${PLATFORM_ROLE_OPTIONS.map(r => `<option value="${r}" ${u.role === r ? 'selected' : ''}>${t('admin.role.' + r)}</option>`).join('')}
</select>
</td>
@ -291,7 +371,7 @@ async function loadUsers() {
</td>
${workspaceCell(u)}
<td style="padding:8px;white-space:nowrap">
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${esc(u.id)}" data-user-email="${esc(u.email)}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
${!isPlatformAdmin(u) ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
</td>
</tr>

View file

@ -1,5 +1,33 @@
import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
import { esc } from '../utils.js';
/*
* A recognisable mark for the providers people expect to see, and an honest generic one for
* everything else. Inline SVG rather than a remote image: an <img> to a provider CDN would put a
* third-party origin back into the CSP, which is precisely what moving the flow server-side removed.
*/
const PROVIDER_ICONS = {
google: `<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>`,
microsoft: `<svg width="18" height="18" viewBox="0 0 21 21" aria-hidden="true">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>`,
};
const GENERIC_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>`;
const providerIcon = (slug) => PROVIDER_ICONS[slug] || GENERIC_ICON;
let authConfig = null;
@ -77,8 +105,19 @@ export async function render(container) {
<input type="email" id="loginEmail" class="input" placeholder="${t('auth.placeholder_email')}" autocomplete="email">
</div>
<div class="form-group">
<label>${t('auth.password')}</label>
<label id="loginPasswordLabel" for="loginPassword">${t('auth.password')}</label>
<input type="password" id="loginPassword" class="input" placeholder="${t('auth.placeholder_password')}" autocomplete="current-password">
<!-- Filled in only when the typed email belongs to an organization that has configured
its own identity provider. A customer's IdP is never listed to everyone: the button
appears for the people it belongs to and nobody else, which also keeps the customer
list off the login page.
BELOW the input, inside the same group. Above it, the button sat between the
"Password" label and its field so the label described the SSO button and the
password box had none at all. It has to stay INSIDE the group, because hiding the
group is how the password is hidden and the button must survive that... which is
exactly why setPasswordVisible() hides the FIELD, never the container. -->
<div id="orgSsoSlot" style="display:none;margin-top:12px"></div>
</div>
${isSetup ? `
<div class="form-group">
@ -148,39 +187,32 @@ export async function render(container) {
</div>
<div id="ssoBlock">
${config.googleEnabled || config.microsoftEnabled ? `
<div style="display:flex;align-items:center;gap:12px;margin:20px 0">
${(config.providers || []).length ? `
<div id="ssoDivider" style="display:flex;align-items:center;gap:12px;margin:20px 0">
<hr style="flex:1;border-color:var(--border)">
<span style="color:var(--text-muted);font-size:12px">${t('auth.divider_or')}</span>
<hr style="flex:1;border-color:var(--border)">
</div>
` : ''}
${config.googleEnabled ? `
<div id="googleSignInContainer">
<button class="btn btn-secondary" id="googleSignInBtn" style="width:100%;justify-content:center;padding:10px;gap:8px">
<svg width="18" height="18" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
${t('auth.signin_google')}
</button>
<!-- One button per configured provider, and each is a plain LINK to a server endpoint.
There is no provider SDK on this page: the browser never speaks to the identity
provider directly, so nothing here needs a client id and the CSP needs no
third-party script origin. Google and Microsoft are ordinary entries in this list.
The icon is chosen by slug where we have one and falls back to a generic mark, so a
self-hoster's Keycloak or Authentik still gets a real-looking button. -->
<!-- Wrapped so the whole set can be hidden at once: an organization that REQUIRES its own
identity provider must not be shown the operator's, which are not domain-confined. -->
<div id="instanceProviders">
${(config.providers || []).map((p) => `
<a class="btn btn-secondary" href="/api/auth/oidc/${encodeURIComponent(p.slug)}/start"
id="sso-${esc(p.slug)}"
style="width:100%;justify-content:center;padding:10px;gap:8px;margin-top:8px;text-decoration:none">
${providerIcon(p.slug)}
${esc(t('auth.signin_with', { provider: p.name }))}
</a>
`).join('')}
</div>
` : ''}
${config.microsoftEnabled ? `
<button class="btn btn-secondary" id="microsoftSignInBtn" style="width:100%;justify-content:center;padding:10px;gap:8px;margin-top:8px">
<svg width="18" height="18" viewBox="0 0 21 21">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
${t('auth.signin_microsoft')}
</button>
` : ''}
</div>
</div>
@ -284,6 +316,12 @@ function setupHandlers(config, isSetup) {
body: JSON.stringify({ email, password })
});
const data = await res.json();
/*
* The organization requires its identity provider, so this is not a credential failure and
* must not read like one "invalid password" sends the user to reset a password that will
* never work again. Point them at the control that does work.
*/
if (!res.ok && data.code === 'sso_required') { showError(t('auth.sso_required')); return; }
if (!res.ok) { showError(data.error); return; }
// Unverified account (hosted hard-gate): no session — prompt to check email.
if (data.verification_required) { showVerifyNotice(data.email || email); return; }
@ -451,66 +489,214 @@ function setupHandlers(config, isSetup) {
}
}
// Google Sign-In
if (config.googleEnabled) {
document.getElementById('googleSignInBtn')?.addEventListener('click', async () => {
try {
// Use Google's popup-based sign in
const client = google.accounts.oauth2.initTokenClient({
client_id: config.googleClientId,
scope: 'email profile',
callback: async (response) => {
if (response.access_token) {
// Get ID token via Google's tokeninfo
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${response.access_token}`);
const tokenData = await tokenRes.json();
// Send to our server
const res = await fetch('/api/auth/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: response.access_token, email: tokenData.email })
});
const data = await res.json();
if (res.ok) onAuthSuccess(data);
else showError(data.error);
/*
* SSO is a link, not a script.
*
* The buttons above are anchors to /api/auth/oidc/<slug>/start, so there is nothing to bind here
* and no SDK to wait for. What DOES need handling is the trip back: the callback redirects to
* #/login carrying either a session token or an error code.
*
* The token rides in the URL FRAGMENT, which browsers never send to servers and proxies never
* log and it is stripped from the address bar before anything else happens, so a shared screen
* or a copied URL does not carry a live session.
*/
/*
* Email-first SSO for organizations.
*
* Instance-wide providers are always on the page. An ORG provider is different it belongs to
* one customer so it is fetched by domain once the address looks complete, and only then.
*
* Debounced because this fires while someone types, and the endpoint is rate limited; asking on
* every keystroke would spend a user's whole budget before they finished their own address.
*/
let ssoLookupTimer = null;
let lastDomainAsked = '';
const orgSlot = () => document.getElementById('orgSsoSlot');
/*
* Show or hide the password half of the sign-in form.
*
* Presentation only the server refuses a password for these accounts regardless. Restoring it
* on every negative answer matters as much as hiding it: someone who types an SSO-only address,
* then corrects it to their own, must get the password box back.
*/
function setPasswordVisible(visible) {
/*
* Hide the password FIELD, never its .form-group the organization SSO slot lives inside
* that same group, so hiding the container took the single sign-on button down with it and left
* a login page whose only action was "Create Account". Found by looking at a screenshot.
*/
const show = visible ? '' : 'none';
for (const id of ['loginPassword', 'loginPasswordLabel', 'loginBtn']) {
const el = document.getElementById(id);
if (el) el.style.display = show;
}
/*
* The instance's own providers go too. They are the operator's, not this organization's, and
* they are not domain-confined so offering "Continue with Google" to someone whose company
* requires its own identity provider is offering them the bypass. The server refuses it either
* way; this stops the page inviting it.
*/
const instance = document.getElementById('instanceProviders');
if (instance) instance.style.display = show;
/*
* "Create Account" goes too. Registration at an SSO-only domain is refused by the server, and
* leaving the button was worse than useless: it was the ONLY action left on the card, so the
* page invited the one thing that cannot work.
*/
const reg = document.getElementById('showRegisterBtn');
if (reg) reg.style.display = show;
// The OR divider sits outside #instanceProviders, so hiding those alone left a dangling rule
// with nothing beneath it.
const divider = document.getElementById('ssoDivider');
if (divider) divider.style.display = show;
// "Forgot your password?" sits in its own <p>; hide the wrapper so no empty gap is left.
const forgot = document.getElementById('forgotLink');
if (forgot) {
const wrap = forgot.parentElement && forgot.parentElement.tagName === 'P' ? forgot.parentElement : forgot;
wrap.style.display = show;
}
});
client.requestAccessToken();
} catch (err) {
showError(t('auth.error_google_failed'));
}
});
}
// Microsoft Sign-In
if (config.microsoftEnabled) {
document.getElementById('microsoftSignInBtn')?.addEventListener('click', async () => {
async function lookupOrgSso(email) {
const at = String(email || '').lastIndexOf('@');
const domain = at === -1 ? '' : email.slice(at + 1).trim().toLowerCase();
const slot = orgSlot();
if (!slot) return;
// Nothing to ask about until there is a domain with a dot in it.
if (!domain || !domain.includes('.')) {
slot.style.display = 'none'; slot.innerHTML = ''; lastDomainAsked = ''; setPasswordVisible(true); return;
}
if (domain === lastDomainAsked) return;
try {
const msalConfig = {
auth: {
clientId: config.microsoftClientId,
authority: `https://login.microsoftonline.com/${config.microsoftTenantId}`,
redirectUri: window.location.origin
}
};
const msalInstance = new msal.PublicClientApplication(msalConfig);
await msalInstance.initialize();
const loginResponse = await msalInstance.loginPopup({ scopes: ['User.Read'] });
if (loginResponse.accessToken) {
const res = await fetch('/api/auth/microsoft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: loginResponse.accessToken })
});
const res = await fetch(`/api/auth/sso/discover?email=${encodeURIComponent(email)}`);
/*
* Check the STATUS, not just that a body parsed.
*
* The comment below has always said a tripped rate limit must not poison the domain and it
* did anyway, because a 429 body is perfectly valid JSON: res.json() resolved, `data.sso`
* came back undefined, so the single sign-on button was hidden, the password box restored,
* and `lastDomainAsked` recorded permanently, for the life of the page. On an SSO-only
* domain that is the worst possible outcome: the password box the user is then offered gets
* 403, and the button they are told to use is not on the screen. Discover is 10/min per IP,
* so a handful of colleagues behind one office address is enough to trigger it.
*/
if (!res.ok) throw new Error(`discover ${res.status}`);
const data = await res.json();
if (res.ok) onAuthSuccess(data);
else showError(data.error);
}
} catch (err) {
showError(t('auth.error_microsoft_failed'));
// Remembered only after a SUCCESSFUL answer.
lastDomainAsked = domain;
if (!data.sso) { slot.style.display = 'none'; slot.innerHTML = ''; setPasswordVisible(true); return; }
/*
* When the organization REQUIRES its identity provider, the password box is not merely going
* to fail it is the wrong thing to offer. Showing it invites someone to type a password,
* be refused, and go and reset a password that will never work again. Hidden, not disabled,
* so there is one obvious way forward.
*/
setPasswordVisible(!data.required);
/*
* A FORM, not a link, and a deliberately generic label.
*
* The lookup tells us only that this domain uses SSO never which provider or whose it is,
* because that would identify a customer to anyone who guessed a domain. The server does the
* mapping again on submit, so the slug is never published to the page. POST keeps the address
* out of the URL, browser history and any Referer the provider's page would send.
*/
/*
* A BUTTON that fetches and then navigates not a form that submits.
*
* The dashboard's CSP is `form-action 'self'`, and Chrome applies it across the whole
* redirect chain, so a form POST that 302s on to the customer's identity provider was
* ABORTED with nothing shown to the user at all. The provider origins cannot be allowlisted
* because customers supply them. A script-initiated navigation is not covered by
* form-action, so the page asks the server where to go and goes there.
*
* Styled secondary: "Sign In" is the primary action while a password still works, and two
* identical blue buttons stacked one above the other sent people to their IdP by muscle
* memory after typing a password.
*/
slot.innerHTML = `
<button type="button" id="orgSsoBtn" class="btn ${data.required ? 'btn-primary' : 'btn-secondary'}"
style="width:100%;justify-content:center;padding:10px">
${t('auth.signin_sso')}
</button>
<div style="font-size:11px;color:var(--text-muted);margin-top:6px;text-align:center">
${t('auth.sso_org_hint')}
</div>`;
slot.style.display = '';
const btn = slot.querySelector('#orgSsoBtn');
if (btn) btn.addEventListener('click', async () => {
btn.disabled = true;
try {
const r = await fetch('/api/auth/sso/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email }),
});
const body = await r.json().catch(() => ({}));
if (!r.ok || !body.start_url) throw new Error(body.error || `start ${r.status}`);
window.location.assign(body.start_url);
} catch {
btn.disabled = false;
showError(t('auth.sso_err_provider_unavailable'));
}
});
} catch {
// A failed lookup must never block a password login — the form still works, and the password
// box comes back rather than leaving someone staring at a form with no way to submit it.
slot.style.display = 'none';
slot.innerHTML = '';
setPasswordVisible(true);
}
}
document.getElementById('loginEmail')?.addEventListener('input', (e) => {
clearTimeout(ssoLookupTimer);
const value = e.target.value;
ssoLookupTimer = setTimeout(() => lookupOrgSso(value), 400);
});
/*
* Completing an SSO login.
*
* The callback no longer hands the session token back in the URL that was a login-CSRF hole,
* because a crafted link could install an ATTACKER'S token and quietly sign the victim into their
* account. The server now leaves it in a one-shot httpOnly cookie and we exchange it here, which
* a link cannot forge.
*
* Wrapped in an async IIFE because setupHandlers() is not async; `await` at this level is a
* SyntaxError that takes the whole module graph down with it, since app.js imports this file
* statically and there is no bundler to catch it first.
*/
const ssoParams = new URLSearchParams((window.location.hash.split('?')[1] || ''));
const ssoReturning = ssoParams.get('sso') === '1';
const ssoError = ssoParams.get('sso_error');
if (ssoReturning || ssoError) {
// Keep any real query string; only the hash carried the SSO markers.
history.replaceState(null, '', window.location.pathname + window.location.search + '#/login');
}
if (ssoReturning) {
(async () => {
try {
const res = await fetch('/api/auth/sso/claim', { method: 'POST' });
if (!res.ok) throw new Error('claim rejected');
const data = await res.json();
onAuthSuccess(data);
} catch {
showToast(t('auth.sso_failed'), 'error');
}
})();
} else if (ssoError) {
// Every code the callback can emit has a message; an unknown one still says something true
// rather than failing silently, which is how the previous implementation behaved on every click.
const known = ['expired', 'bad_state', 'no_code', 'no_email', 'email_unverified',
'verification_failed', 'provider_refused', 'provider_unavailable', 'unknown_provider',
'registration_disabled', 'account_exists_local', 'subject_mismatch', 'server_error',
'domain_not_allowed', 'account_exists_other_provider', 'sso_required'];
const key = known.includes(ssoError) ? `auth.sso_err_${ssoError}` : 'auth.sso_failed';
showToast(t(key), 'error');
}
}

View file

@ -66,6 +66,35 @@ export async function render(container) {
</div>
</div>
<!-- Per-organization SSO. Hidden unless the signed-in user administers an organization: this
is the most security-relevant setting a tenant has, so it is not shown to members who
cannot change it. Instance-wide providers are the operator's business and are configured
by environment, not here. -->
<div class="settings-section" id="ssoCard" style="display:none">
<h3>${t('sso.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('sso.blurb')}</p>
<div id="ssoList"></div>
<details id="ssoAddDetails" style="margin-top:12px">
<summary style="cursor:pointer;font-size:13px">${t('sso.add')}</summary>
<div style="margin-top:12px;display:grid;gap:10px;max-width:560px">
<div class="form-group"><label>${t('sso.f_name')}</label>
<input type="text" id="ssoName" class="input" placeholder="Acme SSO"></div>
<div class="form-group"><label>${t('sso.f_issuer')}</label>
<input type="url" id="ssoIssuer" class="input" placeholder="https://login.example.com">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_issuer_hint')}</div></div>
<div class="form-group"><label>${t('sso.f_client_id')}</label>
<input type="text" id="ssoClientId" class="input"></div>
<div class="form-group"><label>${t('sso.f_client_secret')}</label>
<input type="password" id="ssoClientSecret" class="input" autocomplete="new-password">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_client_secret_hint')}</div></div>
<div class="form-group"><label>${t('sso.f_domains')}</label>
<input type="text" id="ssoDomains" class="input" placeholder="acme.com, acme.co.uk">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_domains_hint')}</div></div>
<div><button class="btn btn-primary btn-sm" id="ssoCreateBtn">${t('sso.create')}</button></div>
</div>
</details>
</div>
<div class="settings-section">
<h3>${t('apitoken.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.desc')}</p>
@ -634,6 +663,388 @@ export async function render(container) {
}
});
/* Per-organization SSO
*
* Only an org owner/admin sees this. The server enforces the same rule (and answers 404, not
* 403, so an outsider learns nothing) this just avoids showing a card the user cannot use.
*/
const orgId = user.current_organization?.id;
const canManageSso = orgId && ['org_owner', 'org_admin'].includes(user.current_org_role);
async function loadSso() {
const card = document.getElementById('ssoCard');
if (!card || !canManageSso) return;
card.style.display = '';
const listEl = document.getElementById('ssoList');
let providers = [];
try {
const res = await fetch(`/api/organizations/${orgId}/sso`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
if (!res.ok) throw new Error('load failed');
providers = (await res.json()).providers || [];
} catch {
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${esc(t('sso.load_failed'))}</p>`;
return;
}
if (!providers.length) {
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${esc(t('sso.none'))}</p>`;
return;
}
// Requiring SSO is a separate decision from having it, so it gets its own block rather than
// hiding inside a provider — an organization may have several providers and one answer.
let onlyState = null;
try {
const r = await fetch(`/api/organizations/${orgId}/sso-only`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
if (r.ok) onlyState = await r.json();
} catch { /* the providers still render; the toggle simply does not appear */ }
const origin = `${window.location.protocol}//${window.location.host}`;
listEl.innerHTML = providers.map((p) => `
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
<div>
<strong>${esc(p.name)}</strong>
${p.enabled ? '' : `<span style="font-size:11px;color:var(--text-muted)"> — ${esc(t('sso.disabled'))}</span>`}
<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(p.issuer)}</div>
<div style="font-size:12px;color:var(--text-muted)">${esc(t('sso.domains_label'))}: ${esc(p.email_domains || '—')}</div>
${((p.domains || []).some((d) => !d.verified) || (p.domains || []).length === 0)
? `<div style="font-size:12px;color:var(--warning,#b45309);margin-top:2px">⚠️ ${esc(t('sso.unverified_warning'))}</div>`
: ''}
</div>
<!-- wrap, do not shrink-to-clip: at 375px this row ran to x=417 on a 375px viewport and
the page does not scroll horizontally, so "Remove" was simply unreachable. -->
<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">
<button class="btn btn-secondary btn-sm" data-sso-test="${esc(p.id)}">${esc(t('sso.test'))}</button>
<button class="btn btn-secondary btn-sm" data-sso-edit="${esc(p.id)}">${esc(t('sso.edit'))}</button>
<button class="btn btn-secondary btn-sm" data-sso-toggle="${esc(p.id)}" data-enabled="${p.enabled ? '1' : '0'}">
${esc(p.enabled ? t('sso.disable') : t('sso.enable'))}
</button>
<button class="btn btn-danger btn-sm" data-sso-delete="${esc(p.id)}">${esc(t('sso.delete'))}</button>
</div>
</div>
<!-- The admin has to paste this into their identity provider, and it must match character
for character, so it is shown rather than described. -->
<div style="margin-top:8px;font-size:12px">
<div style="color:var(--text-muted)">${esc(t('sso.callback_label'))}</div>
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px">${esc(origin + p.callback_url)}</code>
</div>
<!-- Editing is per provider, because an organization may have several (one per domain, or
one per identity provider after a merger) and they are configured independently. -->
<!-- Domain proof. A claimed domain routes NOBODY until DNS confirms the organization
controls it, so the state of each one is shown plainly rather than left to be inferred
from a login that silently does not work. -->
${(p.domains || []).length ? `
<div style="margin-top:10px;font-size:12px">
<div style="color:var(--text-muted);margin-bottom:4px">${esc(t('sso.domains_heading'))}</div>
${p.domains.map((d, di) => `
<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
<div><strong>${esc(d.domain)}</strong>
${d.verified
? `<span style="color:var(--success,#15803d)"> — ${esc(t('sso.domain_verified'))}</span>`
: `<span style="color:var(--warning,#b45309)"> — ${esc(t('sso.domain_pending'))}</span>`}
</div>
${d.verified ? '' : `<button class="btn btn-secondary btn-sm" data-sso-verify="${esc(p.id)}" data-domain="${esc(d.domain)}" data-di="${di}">${esc(t('sso.verify_now'))}</button>`}
</div>
${d.verified ? '' : `
<div style="margin-top:6px;color:var(--text-muted)">${esc(t('sso.dns_instructions'))}</div>
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px;margin-top:4px">${esc(d.record_name)} TXT ${esc(d.txt_value)}</code>
`}
<!-- ONE place for the outcome. The last failure is persisted server-side and was
rendered here, while the click handler wrote the live result into a second
element below it so retrying showed the identical sentence twice, in two
different colours. The handler replaces this element's text instead. -->
<div id="ssoVerify-${esc(p.id)}-${di}" style="margin-top:4px;color:var(--danger,#b91c1c)">${d.verified ? '' : esc(d.last_error || '')}</div>
</div>`).join('')}
</div>` : ''}
<div id="ssoTest-${esc(p.id)}" style="display:none;margin-top:8px;font-size:12px"></div>
<div id="ssoEdit-${esc(p.id)}" style="display:none;margin-top:12px;padding-top:12px;border-top:1px solid var(--border);display:none">
<div style="display:grid;gap:10px;max-width:560px">
<div class="form-group"><label>${esc(t('sso.f_name'))}</label>
<input type="text" class="input" data-f="name" value="${esc(p.name)}"></div>
<div class="form-group"><label>${esc(t('sso.f_issuer'))}</label>
<input type="url" class="input" data-f="issuer" value="${esc(p.issuer)}"></div>
<div class="form-group"><label>${esc(t('sso.f_client_id'))}</label>
<input type="text" class="input" data-f="client_id" value="${esc(p.client_id)}"></div>
<div class="form-group"><label>${esc(t('sso.f_client_secret'))}</label>
<input type="password" class="input" data-f="client_secret" autocomplete="new-password"
placeholder="${esc(p.has_client_secret ? t('sso.secret_set') : t('sso.secret_none'))}">
<!-- A secret can never be shown back: the API does not return it. Blank therefore means
"leave it alone" rather than "clear it", which is what stops a save from silently
wiping a working configuration. Clearing is a separate, explicit choice. -->
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${esc(t('sso.secret_edit_hint'))}</div>
${p.has_client_secret ? `
<label style="display:flex;align-items:center;gap:6px;font-size:12px;margin-top:6px">
<input type="checkbox" data-f="clear_secret"> ${esc(t('sso.secret_clear'))}
</label>` : ''}
</div>
<div class="form-group"><label>${esc(t('sso.f_domains'))}</label>
<input type="text" class="input" data-f="email_domains" value="${esc(p.email_domains)}"></div>
<div style="display:flex;gap:6px">
<button class="btn btn-primary btn-sm" data-sso-save="${esc(p.id)}">${esc(t('sso.save'))}</button>
<button class="btn btn-secondary btn-sm" data-sso-cancel="${esc(p.id)}">${esc(t('sso.cancel'))}</button>
</div>
</div>
</div>
</div>`).join('');
listEl.querySelectorAll('[data-sso-toggle]').forEach((btn) => {
btn.addEventListener('click', async () => {
await ssoRequest('PUT', `/${btn.dataset.ssoToggle}`, { enabled: btn.dataset.enabled !== '1' });
});
});
/*
* Ask the server to look for the DNS record now. Pull-based on purpose: the admin has just
* edited DNS and wants an answer, and a failure has to say WHICH failure not published yet,
* published wrong, or the claim expired and the record has changed underneath them.
*/
if (onlyState) {
const pend = onlyState.pending_removal_request;
const box = document.createElement('div');
box.style.cssText = 'border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-top:4px';
box.innerHTML = `
<div style="font-weight:600;margin-bottom:4px">${esc(t('sso.only_heading'))}</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">${esc(t('sso.only_help'))}</div>
${onlyState.sso_only ? `
<div style="font-size:13px;margin-bottom:8px"> ${esc(t('sso.only_on'))}</div>
${pend
? `<div style="font-size:12px;color:var(--warning,#b45309)">⏳ ${esc(t('sso.only_pending'))}</div>
<button class="btn btn-secondary btn-sm" id="ssoOnlyCancel" data-req="${esc(pend.id)}" style="margin-top:6px">${esc(t('sso.only_cancel'))}</button>`
: `<div style="font-size:12px;color:var(--text-muted);margin-bottom:6px">${esc(t('sso.only_remove_help'))}</div>
<button class="btn btn-secondary btn-sm" id="ssoOnlyRequest">${esc(t('sso.only_request'))}</button>`}
` : `
<div style="font-size:13px;margin-bottom:8px">${esc(t('sso.only_off'))}</div>
${onlyState.verified_domains
? `<button class="btn btn-secondary btn-sm" id="ssoOnlyEnable">${esc(t('sso.only_enable'))}</button>`
: `<div style="font-size:12px;color:var(--warning,#b45309)">⚠️ ${esc(t('sso.only_needs_domain'))}</div>`}
`}`;
listEl.appendChild(box);
const post = async (url, body, method = 'POST') => {
const r = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: body ? JSON.stringify(body) : undefined,
});
const j = await r.json().catch(() => ({}));
if (!r.ok) { showToast(j.error || t('sso.only_failed'), 'error'); return null; }
return j;
};
const enableBtn = box.querySelector('#ssoOnlyEnable');
if (enableBtn) enableBtn.addEventListener('click', async () => {
// Confirmed, because it removes the only way in for everyone at these domains, and the way
// back needs the operator rather than this button.
if (!window.confirm(t('sso.only_confirm'))) return;
const r = await post(`/api/organizations/${orgId}/sso-only`);
if (r) {
showToast(t('sso.only_on'), 'success');
/*
* Name the people who just lost their only way in. The server reports them precisely so
* the admin finds out HERE rather than from a support ticket and it was being thrown
* away, which made the whole warning pointless.
*/
const stranded = r.stranded_members || [];
if (stranded.length) {
window.alert(t('sso.only_stranded', { list: stranded.join('\n') }));
}
await loadSso();
}
});
const reqBtn = box.querySelector('#ssoOnlyRequest');
if (reqBtn) reqBtn.addEventListener('click', async () => {
const reason = window.prompt(t('sso.only_reason_prompt')) || '';
const r = await post(`/api/organizations/${orgId}/sso-only/removal-request`, { reason });
if (r) { showToast(t('sso.only_requested'), 'success'); await loadSso(); }
});
const cancelBtn = box.querySelector('#ssoOnlyCancel');
if (cancelBtn) cancelBtn.addEventListener('click', async () => {
const r = await post(`/api/organizations/${orgId}/sso-only/removal-request/${cancelBtn.dataset.req}`, null, 'DELETE');
if (r) { showToast(t('sso.only_cancelled'), 'success'); await loadSso(); }
});
}
listEl.querySelectorAll('[data-sso-verify]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.ssoVerify;
const domain = btn.dataset.domain;
// Indexed, not derived from the domain: `a.b.test` and `a-b.test` both slugify to
// `a-b-test`, and getElementById would put one domain's answer in the other's box.
const out = document.getElementById(`ssoVerify-${id}-${btn.dataset.di}`);
btn.disabled = true;
if (out) { out.style.color = 'var(--text-muted)'; out.textContent = t('sso.verifying'); }
try {
const res = await fetch(`/api/organizations/${orgId}/sso/${id}/domains/${encodeURIComponent(domain)}/verify`, {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
const body = await res.json().catch(() => ({}));
if (body.ok) {
showToast(t('sso.domain_verified_toast', { domain }), 'success');
await loadSso(); // re-render: the domain now routes, and the card must say so
return;
}
// An expired claim has already been reissued server-side, so the records on screen are
// stale — reload rather than leaving the admin publishing a value that no longer matches.
if (body.expired) {
showToast(body.error || t('sso.verify_failed'), 'error');
await loadSso();
return;
}
if (out) { out.style.color = 'var(--danger,#b91c1c)'; out.textContent = body.error || t('sso.verify_failed'); }
} catch {
if (out) { out.style.color = 'var(--danger,#b91c1c)'; out.textContent = t('sso.verify_failed'); }
} finally {
btn.disabled = false;
}
});
});
listEl.querySelectorAll('[data-sso-test]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.ssoTest;
const out = document.getElementById(`ssoTest-${id}`);
if (!out) return;
out.style.display = '';
out.textContent = t('sso.testing');
try {
const res = await fetch(`/api/organizations/${orgId}/sso/${id}/test`, {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
const data = await res.json();
if (!res.ok) { out.textContent = data.error || t('sso.test_failed'); return; }
/*
* Literal keys, never a key built by concatenating a check name. Doing that defeats the
* check in server/test/i18n-keys-exist.js that every key an operator can see is
* translated and a check name the UI does not know would render as raw key text. The
* fallback keeps an unknown one readable instead.
*/
const CHECK_LABELS = {
discovery: t('sso.check_discovery'),
endpoints: t('sso.check_endpoints'),
signing_keys: t('sso.check_signing_keys'),
};
const rows = (data.checks || []).map((c) => `
<div>${c.ok ? '✅' : '❌'} ${esc(CHECK_LABELS[c.name] || c.name)} <span style="color:var(--text-muted)">${esc(c.detail || '')}</span></div>`).join('');
/*
* The caveat is shown on SUCCESS, not tucked away. Discovery and keys prove the provider
* exists and that we could verify a token it signs they say nothing about whether the
* client id, the secret, or the redirect URI registration are right. A green tick that
* implied "SSO works" would send an admin away from the one thing still to check.
*/
out.innerHTML = rows + (data.ok
? `<div style="margin-top:6px;color:var(--text-muted)">${esc(t('sso.test_caveat'))}</div>`
: '');
} catch {
out.textContent = t('sso.test_failed');
}
});
});
listEl.querySelectorAll('[data-sso-edit]').forEach((btn) => {
btn.addEventListener('click', () => {
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoEdit}`);
if (panel) panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
});
});
listEl.querySelectorAll('[data-sso-cancel]').forEach((btn) => {
btn.addEventListener('click', () => {
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoCancel}`);
if (panel) panel.style.display = 'none';
});
});
listEl.querySelectorAll('[data-sso-save]').forEach((btn) => {
btn.addEventListener('click', async () => {
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoSave}`);
if (!panel) return;
const val = (f) => panel.querySelector(`[data-f="${f}"]`)?.value?.trim() ?? '';
const body = {
name: val('name'),
issuer: val('issuer'),
client_id: val('client_id'),
email_domains: val('email_domains'),
};
/*
* Three states, and only these three:
* typed a value -> replace the secret
* ticked "remove" -> send '' so the server clears it
* left blank, unticked -> send NOTHING, so the stored secret survives
* Sending '' on every save is the bug this shape exists to avoid.
*/
const typed = panel.querySelector('[data-f="client_secret"]')?.value || '';
const clearing = panel.querySelector('[data-f="clear_secret"]')?.checked;
if (typed) body.client_secret = typed;
else if (clearing) body.client_secret = '';
if (!body.name || !body.issuer || !body.client_id) {
showToast(t('sso.missing_fields'), 'error');
return;
}
await ssoRequest('PUT', `/${btn.dataset.ssoSave}`, body);
});
});
listEl.querySelectorAll('[data-sso-delete]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm(t('sso.confirm_delete'))) return;
await ssoRequest('DELETE', `/${btn.dataset.ssoDelete}`);
});
});
}
async function ssoRequest(method, path = '', body) {
try {
const res = await fetch(`/api/organizations/${orgId}/sso${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
// The server's message is the useful one here — a bad issuer or a domain already claimed by
// another organization both say exactly what went wrong, and a generic failure would not.
if (!res.ok) { showToast(data.error || t('sso.save_failed'), 'error'); return false; }
// "Saved" for a DELETE read as though nothing had been destroyed.
showToast(t(method === 'DELETE' ? 'sso.removed' : 'sso.saved'), 'success');
await loadSso();
return true;
} catch {
showToast(t('sso.save_failed'), 'error');
return false;
}
}
document.getElementById('ssoCreateBtn')?.addEventListener('click', async () => {
const payload = {
name: document.getElementById('ssoName').value.trim(),
issuer: document.getElementById('ssoIssuer').value.trim(),
client_id: document.getElementById('ssoClientId').value.trim(),
client_secret: document.getElementById('ssoClientSecret').value,
email_domains: document.getElementById('ssoDomains').value.trim(),
};
if (!payload.name || !payload.issuer || !payload.client_id) {
showToast(t('sso.missing_fields'), 'error');
return;
}
if (await ssoRequest('POST', '', payload)) {
['ssoName', 'ssoIssuer', 'ssoClientId', 'ssoClientSecret', 'ssoDomains']
.forEach((id) => { document.getElementById(id).value = ''; });
document.getElementById('ssoAddDetails').open = false;
}
});
loadSso();
document.getElementById('createTokenBtn')?.addEventListener('click', async () => {
const name = document.getElementById('tokName').value.trim();
const scope = document.getElementById('tokScope').value;
@ -830,25 +1241,30 @@ async function loadUsers() {
</thead>
<tbody>
${users.map(u => `
<tr style="border-bottom:1px solid var(--border)" data-user-id="${u.id}">
<!-- ESCAPED. A SECOND copy of the platform users table lives here, rendered from the
same endpoint as the one in views/admin.js. Escaping only that one left this whole
table wide open, including a raw text node for the email - and an org or workspace
admin can choose an email, so this executed in the platform admin's session. When
you touch one of these tables, touch both. -->
<tr style="border-bottom:1px solid var(--border)" data-user-id="${esc(u.id)}">
<td style="padding:10px 12px">
<div style="font-weight:500">${u.name || u.email}</div>
<div style="font-size:11px;color:var(--text-muted)">${u.email}</div>
<div style="font-weight:500">${esc(u.name || u.email)}</div>
<div style="font-size:11px;color:var(--text-muted)">${esc(u.email)}</div>
</td>
<td style="padding:10px 12px">
<span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span>
<span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${esc(u.auth_provider)}</span>
</td>
<td style="padding:10px 12px">
<span style="color:${isPlatformAdmin(u) ? 'var(--accent)' : 'var(--text-secondary)'}">${u.role}</span>
<span style="color:${isPlatformAdmin(u) ? 'var(--accent)' : 'var(--text-secondary)'}">${esc(u.role)}</span>
</td>
<td style="padding:10px 12px">
<select class="input plan-select" data-user-id="${u.id}" style="padding:4px 8px;font-size:12px;width:auto">
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
<select class="input plan-select" data-user-id="${esc(u.id)}" style="padding:4px 8px;font-size:12px;width:auto">
${plans.map(p => `<option value="${esc(p.id)}" ${u.plan_id === p.id ? 'selected' : ''}>${esc(p.display_name)}</option>`).join('')}
</select>
</td>
<td style="padding:10px 12px;white-space:nowrap">
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm reset-user-pw-btn" data-user-id="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('settings.user.reset_password')}</button>` : ''}
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${u.id}">${t('settings.user.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('settings.user.you')}</span>`}
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm reset-user-pw-btn" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}" style="margin-right:4px">${t('settings.user.reset_password')}</button>` : ''}
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${esc(u.id)}">${t('settings.user.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('settings.user.you')}</span>`}
</td>
</tr>
`).join('')}

View file

@ -84,11 +84,14 @@ module.exports = {
return secret;
})(),
jwtExpiry: '7d',
// Google OAuth - set these in env or here
googleClientId: process.env.GOOGLE_CLIENT_ID || '',
// Microsoft OAuth - set these in env or here
microsoftClientId: process.env.MICROSOFT_CLIENT_ID || '',
microsoftTenantId: process.env.MICROSOFT_TENANT_ID || 'common',
/*
* Google and Microsoft sign-in are configured through lib/oidc-providers.js, which reads
* process.env directly there is nothing here for it to read, so these fields were dead, and
* `microsoftTenantId` defaulting to 'common' actively contradicted the provider code, which now
* REFUSES 'common' (it advertises a template issuer that can never match, and accepting it means
* accepting tokens from every Azure tenant nOAuth). Removed rather than left as a trap for the
* next person who greps for where Microsoft SSO is configured.
*/
// Stripe (optional - for paid subscriptions)
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',

View file

@ -396,6 +396,105 @@ const migrations = [
// Per-telemetry-row rather than on `devices` because a display can be swapped, unplugged or
// renegotiated without the player re-registering, and because a dual-output player registers ONE
// ROW PER OUTPUT (see output_index) — each row must carry its own screen, not the box's first.
/*
* Per-organization SSO.
*
* Instance-wide providers come from the environment and belong to whoever runs the server. These
* belong to a CUSTOMER: an organization brings its own identity provider, and its people sign in
* with it without the operator touching a config file.
*
* `slug` is globally unique and randomly generated rather than chosen, because it is a URL path
* segment (/api/auth/oidc/<slug>/start) and two organizations both wanting "okta" must not be
* able to collide or to guess each other's. The admin only ever sees `name`.
*
* `client_secret_enc` is AES-256-GCM via lib/secretbox, the same at-rest treatment as TOTP
* secrets and BYOK AI keys. PKCE means a secret is optional, so a public client stores NULL.
*
* `email_domains` is the list an admin TYPED, kept for display and for the edit form. It does not
* drive routing org_sso_domains does, and only its verified rows (see the table below). The two
* are not interchangeable: reading this column to decide who may sign in would let a tenant route
* a domain it never proved.
*/
`CREATE TABLE IF NOT EXISTS org_sso_providers (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
issuer TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret_enc TEXT,
scopes TEXT NOT NULL DEFAULT 'openid email profile',
email_domains TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
)`,
"CREATE INDEX IF NOT EXISTS idx_org_sso_org ON org_sso_providers(organization_id)",
/*
* Claimed sign-in domains, and the proof that the claimant controls them.
*
* `org_sso_providers.email_domains` used to be the whole story, and first-claim-wins on a text
* field is not a claim it is a land grab. A tenant could type a domain it had nothing to do
* with and every person at that company typing their work address into the login page would be
* routed to the squatter's identity provider. It also let one account permanently deny a domain
* to its real owner, and strand accounts at addresses it never owned.
*
* So a domain is inert until DNS says otherwise. `verified_at` NULL means claimed but unproven:
* it routes nobody, and the login callback will not accept an assertion for it. The row still
* reserves the name, so two tenants cannot race the same domain, but reserving is all it does.
*
* `token` is what has to appear in DNS. It is per-domain rather than per-organization so that
* publishing one proof cannot be replayed to claim a second domain.
*/
`CREATE TABLE IF NOT EXISTS org_sso_domains (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
provider_id TEXT,
domain TEXT NOT NULL UNIQUE,
token TEXT NOT NULL,
-- When the current token was issued. An UNVERIFIED claim is only good for 8 hours from here:
-- past that the token is dead and the reservation lapses, so a domain nobody can prove cannot
-- be held indefinitely by whoever typed it first. Verified rows ignore this entirely.
token_issued_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
verified_at INTEGER,
last_checked_at INTEGER,
last_error TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
-- A verified row never expires and domain is globally UNIQUE, so a row that outlives its
-- provider blocks that domain for EVERYONE, forever, while being invisible in the API. The
-- delete handler clears these explicitly; this is the backstop for every other route out
-- (an organization cascade, a manual delete, a future caller that forgets).
FOREIGN KEY (provider_id) REFERENCES org_sso_providers(id) ON DELETE CASCADE
)`,
"CREATE INDEX IF NOT EXISTS idx_org_sso_domains_org ON org_sso_domains(organization_id)",
"CREATE INDEX IF NOT EXISTS idx_org_sso_domains_provider ON org_sso_domains(provider_id)",
/*
* SSO-ONLY: an organization may require its people to use its identity provider, so a password
* is no longer an alternative way in. That is the point of buying SSO the IdP holds the MFA,
* the conditional access and the instant deprovisioning, and a password box beside it is a way
* around all three.
*
* Asymmetric on purpose. Turning it ON is the safe direction and an org admin does it alone.
* Turning it OFF is how a compromised admin would re-open password login, and it is also what
* an org will demand at its worst moment IdP down, nobody can work which is exactly when a
* self-service switch gets flipped under pressure. So removal goes through the operator: the
* request is recorded here and a platform admin has to approve it.
*/
`CREATE TABLE IF NOT EXISTS org_sso_only_requests (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
requested_by TEXT,
reason TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | rejected | cancelled
decided_by TEXT,
decided_at INTEGER,
decision_note TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
)`,
"CREATE INDEX IF NOT EXISTS idx_sso_only_req_status ON org_sso_only_requests(status, organization_id)",
"ALTER TABLE device_telemetry ADD COLUMN attached_display TEXT",
"ALTER TABLE device_telemetry ADD COLUMN video_mode TEXT",
// Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because
@ -565,6 +664,40 @@ for (const sql of migrations) {
}
if (_migApplied > 0) console.log(`[migrate] applied ${_migApplied} new column migration(s)`);
/*
* Say something when per-org SSO domains predate the proof requirement.
*
* Domains used to be a comma list an admin typed, and that list routed logins. They now route only
* once DNS proves them, so on an instance upgraded from an earlier build of this feature every one
* of those domains silently stops working the provider still says "enabled", the typed list is
* still on screen, and every federated user in that organization is locked out with no self-service
* way back.
*
* They are deliberately NOT auto-claimed. A claim now notifies the operator, reserves the name
* against other tenants and starts an 8-hour clock; manufacturing all of that on an admin's behalf,
* for domains nobody ever proved, is not a migration's decision to make. So: name them, loudly,
* once per boot, and let an admin re-add the ones they still want.
*/
try {
const stranded = db.prepare(`
SELECT p.slug, p.name, p.organization_id, p.email_domains
FROM org_sso_providers p
WHERE p.email_domains != ''
AND NOT EXISTS (SELECT 1 FROM org_sso_domains d WHERE d.provider_id = p.id)
`).all();
if (stranded.length) {
console.warn(`[migrate] ⚠️ ${stranded.length} SSO provider(s) have typed domains that were never verified.`);
console.warn('[migrate] Domains now route only after a DNS TXT record proves them, so these route NOBODY:');
for (const r of stranded) {
console.warn(`[migrate] ${r.name} (${r.slug}, org ${r.organization_id}): ${r.email_domains}`);
}
console.warn('[migrate] Re-add each domain in Settings to get its record, then Verify. See README, "Proving a domain".');
}
} catch (e) {
// The table may not exist yet on a first boot; that is not a problem worth a stack trace.
if (!/no such table/i.test(e.message)) console.error('[migrate] SSO domain check failed:', e.message);
}
// #74/#75 per-item schedules: the playlist_item_schedules table is created
// idempotently by schema.sql (CREATE TABLE IF NOT EXISTS, run every boot, so it
// self-applies on upgrade). Record it in schema_migrations for observability.
@ -825,6 +958,27 @@ migrateGroupSchedules();
// updates workspace_id.
ensureMultitenancyMigration();
/*
* `organizations.sso_only` added HERE, not in the migrations array above.
*
* That array runs BEFORE ensureMultitenancyMigration(), which is what creates the organizations
* table, so on a fresh install the ALTER hit a table that did not exist yet: `[migrate] FAILED …
* no such table: organizations`, one console.error among ~85 migration lines. The instance then
* ran its entire first boot with the SSO settings screen 500ing and far worse
* ssoOnlyForEmail() catching `no such column` and returning "not SSO-only", which is password
* login proceeding for an organization that had switched it off. It self-healed on the second
* boot, which is exactly what makes it easy to miss.
*/
try {
const orgCols = db.prepare('PRAGMA table_info(organizations)').all().map((c) => c.name);
if (orgCols.length && !orgCols.includes('sso_only')) {
db.exec('ALTER TABLE organizations ADD COLUMN sso_only INTEGER NOT NULL DEFAULT 0');
console.log('[migrate] added organizations.sso_only');
}
} catch (e) {
console.error('[migrate] could not add organizations.sso_only:', e.message);
}
// Phase 2.2c migration: backfill content_folders.workspace_id from owner's
// default workspace. The ALTER lives in the migrations array above; this
// one-shot populates the column for any rows that pre-date it.

154
server/lib/domain-verify.js Normal file
View file

@ -0,0 +1,154 @@
'use strict';
/*
* Proving that a tenant controls a sign-in domain.
*
* Per-organization SSO routes everyone at a domain to that organization's identity provider. That
* is exactly right when the organization owns the domain and an account-takeover primitive when it
* does not and typing a domain into a form is not ownership. A review demonstrated the whole
* chain: claim a company's domain, sign in as a named address there, and the real owner is left
* unable to reach an account bearing their own address.
*
* DNS is the check, because control of a domain's DNS is what "owning a domain" means in the only
* sense that matters here. It is also the mechanism every other vendor uses, so the instructions
* are already familiar to the person who has to follow them.
*
* ONE RECORD FORM a TXT record at a dedicated name:
*
* _screentinker-verify.example.com. IN TXT "st-verify=<token>"
*
* A CNAME alternative was drafted and dropped. It would have pointed at
* `<token>.verify.screentinker.com`, which requires operating a wildcard DNS zone that answers for
* every token ever issued infrastructure this project does not have, so the instructions would
* have described a check that could never pass. TXT needs nothing but the customer's own zone.
*
* A dedicated `_`-prefixed name is used rather than the apex on purpose: an apex TXT record sits
* alongside SPF and DMARC, where a careless edit breaks mail, and it is the one record set an
* administrator is most reluctant to touch.
*
* THE PROOF NAME MUST NOT BE A CNAME. A TXT lookup follows CNAMEs transparently, and RFC 4592
* means a wildcard `*.example.com` synthesizes `_screentinker-verify.example.com` too so a
* wildcard CNAME pointing anywhere the attacker controls would let them prove a domain they do not
* own. That turns an ordinary subdomain takeover into an apex takeover, and from there into every
* `@example.com` login. ACME's dns-01 permits this delegation deliberately; here the thing being
* delegated is the whole company's sign-in, so it is refused instead.
*/
const dns = require('dns').promises;
const crypto = require('crypto');
const RECORD_PREFIX = '_screentinker-verify';
const TXT_PREFIX = 'st-verify=';
// A DNS answer that never arrives must not hold an HTTP request open. The resolver's own retries
// sit under this, so it is a ceiling on the whole lookup rather than on one query.
const LOOKUP_TIMEOUT_MS = 5000;
/*
* How long an UNVERIFIED claim is worth anything.
*
* A claim reserves the domain so two tenants cannot race it but a reservation that never lapses
* is squatting with extra steps: type a company's domain, prove nothing, and hold it against its
* real owner forever. Eight hours is comfortably longer than a DNS change takes to publish and
* propagate, and short enough that an unprovable claim is gone by the next working day.
*
* The token dies with the claim. Trying again mints a NEW token, so an old record left in DNS from
* a lapsed attempt proves nothing, and a domain that changed hands cannot be verified with the
* previous holder's value.
*
* A VERIFIED domain is not affected proof already happened, and re-proving on a timer would log
* out a customer over a DNS edit made months later.
*/
const CLAIM_TTL_S = 8 * 60 * 60;
/** True when an unverified claim has run out of time and no longer reserves anything. */
function isClaimExpired(row, nowS = Math.floor(Date.now() / 1000)) {
if (!row) return false;
// `verified_at` is compared to null, NOT tested for truthiness: SQL asks `IS NOT NULL` and a
// stored 0 would otherwise be "verified" to the router and "unverified" here — two definitions of
// the same word, which is how a domain ends up routing while the code believes it cannot.
if (row.verified_at !== null && row.verified_at !== undefined) return false;
return (Number(row.token_issued_at) || 0) + CLAIM_TTL_S <= nowS;
}
/** Tokens are compared, so they are random and long enough that guessing is not a strategy. */
const newToken = () => crypto.randomBytes(16).toString('hex');
const recordName = (domain) => `${RECORD_PREFIX}.${domain}`;
/** Exactly what the admin has to publish — shown in the UI, so it is built in one place. */
function instructions(domain, token) {
return {
record_name: recordName(domain),
txt_value: `${TXT_PREFIX}${token}`,
};
}
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('DNS lookup timed out')), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
/*
* Look for the proof.
*
* Both record types are queried together and either one is enough. NXDOMAIN and "no such record"
* are ordinary answers here the overwhelmingly common case is an admin checking before the record
* has propagated so they are reported as "not found yet", never as an error to be alarmed by.
*
* Resolution uses the system resolver, which is the same view of DNS the operator already
* trusts. A tenant that can poison that resolver can forge a proof, but a tenant that can do that
* has already won something larger.
*/
async function check(domain, token) {
const name = recordName(domain);
const wantTxt = `${TXT_PREFIX}${token}`;
/*
* Refuse before looking at the TXT at all if the name is delegated. Checking afterwards would
* still be safe, but doing it first means the answer never depends on what the delegation target
* happens to say.
*/
try {
const cnames = await withTimeout(dns.resolveCname(name), LOOKUP_TIMEOUT_MS);
if (cnames && cnames.length) {
return {
ok: false,
error: `${name} is a CNAME (to ${cnames[0]}). The record must be a TXT record in this `
+ 'domain\u2019s own zone — a delegated name would let whoever controls the target prove this domain.',
};
}
} catch { /* no CNAME is the normal and wanted case */ }
let records;
try {
records = await withTimeout(dns.resolveTxt(name), LOOKUP_TIMEOUT_MS);
} catch (e) {
// NXDOMAIN and "no such record" are the ORDINARY answers here — an admin checking before the
// record has propagated — so they are "not found yet", not an error to be alarmed by.
if (/timed out/i.test(e.message)) return { ok: false, error: 'the DNS lookup timed out — try again shortly' };
return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` };
}
// resolveTxt returns arrays of string chunks — a value over 255 bytes is split, so join first.
for (const chunks of records) {
if (chunks.join('').trim() === wantTxt) return { ok: true, via: 'TXT' };
}
// Present but wrong is a different problem from absent, and the fixes differ: one needs
// correcting, the other needs publishing. A wildcard TXT lands here, which is right — it answers
// with its own value, and that is not a proof of anything. (A wildcard CNAME is refused above.)
if (records.length) {
const found = records.map((c) => c.join('')).join('; ');
return { ok: false, error: `${name} exists but does not match. Found: ${found}` };
}
return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` };
}
module.exports = {
check, instructions, newToken, recordName, isClaimExpired,
CLAIM_TTL_S, RECORD_PREFIX, TXT_PREFIX,
};

View file

@ -0,0 +1,400 @@
'use strict';
/*
* Which identity providers this instance offers.
*
* Providers are resolved through ONE function on purpose. Instance-wide providers come from the
* environment today; per-organization SSO will come from the database later, and when it does it
* plugs in here rather than growing a second login path. The rest of the app only ever asks
* "give me the provider called X" and never learns where the answer came from.
*
* Configuration
*
* OIDC_PROVIDERS=okta,authentik comma-separated slugs to enable
* OIDC_OKTA_ISSUER=https://example.okta.com
* OIDC_OKTA_CLIENT_ID=...
* OIDC_OKTA_CLIENT_SECRET=... optional PKCE means a public client works
* OIDC_OKTA_NAME=Okta optional button label
* OIDC_OKTA_SCOPES=openid email profile optional
*
* Google and Microsoft are ordinary OIDC providers and are registered automatically from the
* variables the README has always documented (GOOGLE_CLIENT_ID, MICROSOFT_CLIENT_ID +
* MICROSOFT_TENANT_ID), so an existing deployment keeps working without editing anything. They get
* no special code path the only difference is that their issuer is filled in for you.
*/
const GOOGLE_ISSUER = 'https://accounts.google.com';
const DEFAULT_SCOPES = 'openid email profile';
/** A slug has to be safe in a URL path and in an env var name. */
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,30}$/;
/*
* `local` is what users.auth_provider says for a password account, so a provider by that name would
* make every federated login look like a password login to the linking rules and would put a NULL
* password_hash on rows that POST /login then feeds straight to bcrypt.compareSync. Reserved rather
* than merely discouraged.
*/
const RESERVED_SLUGS = new Set(['local', 'recovery']);
function envKey(slug, suffix) {
return `OIDC_${slug.toUpperCase().replace(/-/g, '_')}_${suffix}`;
}
function fromEnv(env, slug) {
const issuer = (env[envKey(slug, 'ISSUER')] || '').trim().replace(/\/+$/, '');
const clientId = (env[envKey(slug, 'CLIENT_ID')] || '').trim();
if (!issuer || !clientId) return null;
return {
slug,
name: (env[envKey(slug, 'NAME')] || '').trim() || slug.replace(/[-_]/g, ' '),
issuer,
clientId,
clientSecret: (env[envKey(slug, 'CLIENT_SECRET')] || '').trim() || null,
scopes: (env[envKey(slug, 'SCOPES')] || '').trim() || DEFAULT_SCOPES,
source: 'env',
};
}
/**
* Every provider this instance offers, in a stable order.
*
* Never returns clientSecret to a caller that only wants to draw buttons see publicList().
*/
function list(env = process.env) {
const out = [];
const seen = new Set();
// Back-compat: the two providers the README documented before generic OIDC existed.
const googleId = (env.GOOGLE_CLIENT_ID || '').trim();
if (googleId) {
out.push({
slug: 'google',
name: 'Google',
issuer: GOOGLE_ISSUER,
clientId: googleId,
clientSecret: (env.GOOGLE_CLIENT_SECRET || '').trim() || null,
scopes: DEFAULT_SCOPES,
source: 'env',
});
seen.add('google');
}
const msId = (env.MICROSOFT_CLIENT_ID || '').trim();
if (msId) {
/*
* A TENANT GUID IS REQUIRED. `common` and `organizations` are refused, for two reasons that
* point the same way.
*
* It does not work: Microsoft's multi-tenant metadata advertises
* `https://login.microsoftonline.com/{tenantid}/v2.0` a literal template so the issuer can
* never equal the configured URL and every login fails at /start regardless.
*
* And the obvious patch is dangerous: loosening the `iss` comparison to accept the template
* means accepting tokens from EVERY Azure tenant, which is nOAuth an admin of any tenant can
* set an arbitrary, unverified `email` on one of their own users and be issued a session as that
* address here. Doing multi-tenant Microsoft safely needs per-tenant pinning (validate `tid`
* against an allowlist and key the account on `oid`+`tid`, not on email), which is a feature,
* not a relaxed regex.
*
* So: refuse loudly at boot rather than ship a login that either never works or works too well.
*/
const rawTenant = (env.MICROSOFT_TENANT_ID || '').trim().toLowerCase();
if (!rawTenant || ['common', 'organizations', 'consumers'].includes(rawTenant)) {
if (!list._warned) {
console.warn('[sso] MICROSOFT_CLIENT_ID is set but MICROSOFT_TENANT_ID is missing or multi-tenant '
+ `(${rawTenant || 'unset'}). Microsoft sign-in is DISABLED: set your tenant GUID. See README.`);
list._warned = true;
}
seen.add('microsoft');
} else {
out.push({
slug: 'microsoft',
name: 'Microsoft',
// A tenant GUID narrows the issuer to that tenant, so a token from any other tenant fails
// the `iss` check instead of being quietly accepted.
issuer: `https://login.microsoftonline.com/${rawTenant}/v2.0`,
clientId: msId,
clientSecret: (env.MICROSOFT_CLIENT_SECRET || '').trim() || null,
scopes: DEFAULT_SCOPES,
source: 'env',
});
seen.add('microsoft');
}
}
for (const raw of String(env.OIDC_PROVIDERS || '').split(',')) {
const slug = raw.trim().toLowerCase();
if (!slug || seen.has(slug)) continue;
if (!SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug)) continue; // ignore rather than crash a boot over a typo
const p = fromEnv(env, slug);
if (p) { out.push(p); seen.add(slug); }
}
return out;
}
/** One provider by slug, or null. This is the seam per-org SSO will extend. */
function get(slug, env = process.env) {
if (!slug || !SLUG_RE.test(String(slug))) return null;
const fromEnvList = list(env).find((p) => p.slug === slug);
if (fromEnvList) return fromEnvList;
// Instance providers win a name clash, which cannot happen in practice (org slugs are random)
// but decides it deterministically if it ever did.
return getOrgProvider(slug);
}
/**
* What the login page is allowed to know: enough to draw a button and nothing else.
* No client ids, because the browser never talks to the provider directly any more the redirect
* is built server-side, so there is nothing for the page to do with one.
*/
function publicList(env = process.env) {
return list(env).map((p) => ({ slug: p.slug, name: p.name }));
}
/*
* Per-organization providers.
*
* Loaded lazily so this module stays usable (and testable) without a database the env-only paths
* above never touch it. An org provider is an ordinary provider once loaded: the login flow cannot
* tell the difference, which is the whole point of resolving everything through get().
*/
let _db = null;
function db() {
if (_db === null) {
try { _db = require('../db/database').db; } catch { _db = false; }
}
return _db || null;
}
function rowToProvider(row, secretbox) {
return {
slug: row.slug,
name: row.name,
issuer: String(row.issuer).replace(/\/+$/, ''),
clientId: row.client_id,
/*
* Fail CLOSED. secretbox.decrypt returns null when the key has rotated, which silently turned a
* confidential client into a public one the login then fails at the provider with an error
* nobody can act on, while the admin screen still says "a secret is set".
*/
clientSecret: row.client_secret_enc
? (secretbox.decrypt(row.client_secret_enc) ?? (() => { throw new Error('client secret could not be decrypted — re-enter it'); })())
: null,
scopes: row.scopes || DEFAULT_SCOPES,
source: 'org',
organizationId: row.organization_id,
/*
* VERIFIED domains only never org_sso_providers.email_domains.
*
* That column is what an admin typed. This is what they PROVED, by publishing a record in the
* domain's own DNS, and it is the only thing the login callback may confine an assertion to.
* Reading the typed column here would reduce the whole verification feature to a decoration:
* a tenant could type any company's domain and immediately assert addresses in it.
*/
emailDomains: verifiedDomainsFor(row.id).join(','),
};
}
/** The domains a provider has actually proved it controls. */
function verifiedDomainsFor(providerId) {
const conn = db();
if (!conn) return [];
try {
return conn.prepare('SELECT domain FROM org_sso_domains WHERE provider_id = ? AND verified_at IS NOT NULL')
.all(providerId).map((r) => r.domain);
} catch (e) {
if (/no such table/i.test(e.message)) return [];
throw e;
}
}
/** One org provider by its (globally unique) slug, or null. */
function getOrgProvider(slug) {
const conn = db();
if (!conn || !slug || !SLUG_RE.test(String(slug))) return null;
try {
const row = conn.prepare('SELECT * FROM org_sso_providers WHERE slug = ? AND enabled = 1').get(String(slug));
if (!row) return null;
return rowToProvider(row, require('./secretbox'));
} catch (e) {
/*
* Only "the table is not there yet" is a null. This catch used to swallow EVERYTHING, which
* turned a secret that could not be decrypted back into a silent success the exact failure the
* fail-closed check above exists to prevent. Anything else propagates so it is logged and the
* login fails loudly.
*/
if (/no such table/i.test(e.message)) return null;
throw e;
}
}
/**
* Who owns a provider slug without decrypting anything, and regardless of whether it is enabled.
*
* The linking rules need to know which ORGANIZATION established an account, not how to talk to its
* provider, and asking get() for that has two problems: it fails closed on an undecryptable secret
* (right for a login, wrong for an ownership question) and it hides disabled rows, which still own
* the accounts they created.
*
* null means "nothing here owns that slug" either it never existed or the provider has since been
* deleted, and those are deliberately the same answer.
*/
function ownerOf(slug) {
if (!slug || !SLUG_RE.test(String(slug))) return null;
if (list().some((p) => p.slug === slug)) return { source: 'env', organizationId: null };
const conn = db();
if (!conn) return null;
try {
const row = conn.prepare('SELECT organization_id FROM org_sso_providers WHERE slug = ?').get(String(slug));
return row ? { source: 'org', organizationId: row.organization_id } : null;
} catch (e) {
if (/no such table/i.test(e.message)) return null;
throw e;
}
}
/**
* Which provider, if any, owns an email address.
*
* Domain routing is what makes per-org SSO usable: a customer's staff type their work address and
* are sent to their own identity provider rather than being asked for a password they do not have.
*
* Matched on the domain ONLY, never on whether the address exists. Answering "yes, that domain
* uses SSO" tells an attacker nothing they could not learn from the customer's website; answering
* "yes, that USER exists" would be an account-enumeration oracle on the login page.
*/
function forEmail(email) {
const conn = db();
if (!conn) return null;
const at = String(email || '').lastIndexOf('@');
if (at === -1) return null;
const domain = String(email).slice(at + 1).toLowerCase().trim();
if (!domain) return null;
try {
/*
* Routing is driven by the VERIFIED domain table, not by the text an admin typed, and the JOIN
* is what enforces it an unverified claim cannot send anyone anywhere.
*
* No ORDER BY: `domain` is UNIQUE, so at most one row can match and there is no tie to break.
* An earlier version ordered here and the comment claimed it decided a race; it decided
* nothing, and saying so invited someone to rely on it.
*/
const row = conn.prepare(`
SELECT p.* FROM org_sso_domains d
JOIN org_sso_providers p ON p.id = d.provider_id
WHERE d.domain = ? AND d.verified_at IS NOT NULL AND p.enabled = 1
`).get(domain);
if (row) return rowToProvider(row, require('./secretbox'));
} catch (e) {
// Only a missing table is a null — anything else (a secret that will not decrypt, a schema
// drift) must surface rather than silently answering "this domain has no SSO", which is how a
// fail-closed guarantee turns back into a fail-open one.
if (!/no such table/i.test(e.message)) throw e;
}
return null;
}
/**
* Is this address inside an organization that REQUIRES its identity provider?
*
* Only a VERIFIED domain can compel anyone: an org must not be able to switch off password login
* for a domain it merely typed, which would be a denial-of-service against a company it has nothing
* to do with. Enabled providers only, for the same reason a disabled provider routes nobody.
*/
function ssoOnlyForEmail(email) {
const conn = db();
if (!conn) return null;
const at = String(email || '').lastIndexOf('@');
if (at === -1) return null;
// A trailing root dot is the same domain; `acme.test.` slipped the match and let someone
// register at an SSO-only domain (a distinct string, so no squat — but a hole in the gate).
const domain = String(email).slice(at + 1).toLowerCase().trim().replace(/\.+$/, '');
if (!domain) return null;
try {
return conn.prepare(`
SELECT o.id AS organization_id, o.name AS organization_name, p.slug
FROM org_sso_domains d
JOIN org_sso_providers p ON p.id = d.provider_id
JOIN organizations o ON o.id = d.organization_id
WHERE d.domain = ? AND d.verified_at IS NOT NULL AND p.enabled = 1 AND o.sso_only = 1
`).get(domain) || null;
} catch (e) {
/*
* FAIL CLOSED. This used to swallow `no such column` and return null and null means "not
* SSO-only", i.e. password login proceeds. It is the single control stopping a password from
* bypassing a customer's identity provider, so a schema problem must never be the thing that
* quietly switches it off. The sibling forEmail() carries the same warning for the same reason.
*
* `no such table` on the DOMAINS table is different and genuinely means "this instance has no
* per-org SSO at all", so it stays a null.
*/
/*
* "The feature is not installed" and "the schema drifted" are different answers.
*
* A missing per-org SSO table, or no organizations table at all, means this instance has no
* per-organization SSO nothing is being bypassed, so null is correct and a single-tenant
* install must keep working. A missing sso_only COLUMN on a table that does exist is drift, and
* that is the case that must never quietly answer "not required".
*/
if (/no such table: (org_sso_domains|org_sso_providers|organizations|organization_members)/i.test(e.message)) return null;
console.error('[sso] could not determine SSO-only status, refusing password login:', e.message);
throw e;
}
}
/**
* Must THIS USER use single sign-on?
*
* Membership, not just the address. ssoOnlyForEmail() answers about a DOMAIN, and a review used
* that gap to walk straight in: any account in the tenant whose address sits outside the verified
* domains kept password login a contractor, an MSP, the one address nobody remembered. Worse, it
* could be manufactured on demand, because an org admin can create a local password account at any
* address and bind it to their workspace. Enforcing on the domain alone protects the domain; it
* does not protect the ORGANIZATION, which is what the setting claims to do.
*
* So both are asked: the address's domain (which catches people who are not members yet) and every
* organization the user actually belongs to.
*/
function ssoOnlyForUser(user) {
if (!user) return null;
const byDomain = ssoOnlyForEmail(user.email);
if (byDomain) return byDomain;
const conn = db();
if (!conn) return null;
try {
/*
* WORKSPACE membership, not just organization_members.
*
* Almost nobody is in `organization_members`: only three places write it (creating an org,
* an org-SSO login, a platform admin creating an org) and nothing ever deletes a row. Every
* INVITED user, every admin-created account and every workspace assignment lands in
* `workspace_members` and nowhere else so an earlier version of this check covered org
* owners and people who had already used SSO, which is exactly the set the domain check
* already caught. A review invited an outside address into an SSO-only tenant and kept
* password login, then used it to invite more.
*/
return conn.prepare(`
SELECT o.id AS organization_id, o.name AS organization_name
FROM organizations o
WHERE o.sso_only = 1
AND (EXISTS (SELECT 1 FROM organization_members m WHERE m.organization_id = o.id AND m.user_id = ?)
OR EXISTS (SELECT 1 FROM workspace_members wm
JOIN workspaces w ON w.id = wm.workspace_id
WHERE w.organization_id = o.id AND wm.user_id = ?))
LIMIT 1
`).get(user.id, user.id) || null;
} catch (e) {
if (/no such table: (organization_members|organizations|workspace_members|workspaces)/i.test(e.message)) return null;
throw e; // drift on a table that exists — fail closed; the caller refuses the login
}
}
module.exports = {
list, get, publicList, getOrgProvider, ownerOf, forEmail,
ssoOnlyForEmail, ssoOnlyForUser, DEFAULT_SCOPES, SLUG_RE,
};

332
server/lib/oidc.js Normal file
View file

@ -0,0 +1,332 @@
'use strict';
/*
* OpenID Connect discovery, key handling and ID-token verification.
*
* This exists because the previous "OAuth" support verified nothing that mattered. The Google path
* asked Google's tokeninfo endpoint whether an ACCESS token was valid and then trusted the email in
* the reply; the Microsoft path handed a bearer token to Graph /me and trusted that. Neither ever
* checked WHO THE TOKEN WAS ISSUED FOR, and an access token is not a proof of identity it is a
* bearer credential for some resource, minted for some application, and Graph will happily describe
* the user behind a token issued to somebody else's app. Any site a user signs into that asks for
* `email` or `User.Read` could replay that token here and be issued a session as that user.
*
* So identity now comes from an ID TOKEN and nothing else, and the token has to survive:
*
* signature against the provider's published JWKS, restricted to asymmetric algorithms
* iss exactly the issuer discovery advertised
* aud contains our client_id (and azp === client_id when the token carries one)
* exp/nbf inside a small clock skew
* nonce equal to the one WE generated for this login, which is what stops a token
* obtained elsewhere even a correctly-audienced one being replayed here
*
* Deliberately dependency-free beyond `jsonwebtoken`: Node can import a JWK straight into a
* KeyObject, so there is no need for jwks-rsa and no second opinion about what a key is.
*/
const crypto = require('crypto');
const net = require('net');
const jwt = require('jsonwebtoken');
/*
* `alg: "none"` is the oldest JWT attack there is, and HMAC is nearly as bad here: an HS256 token is
* verified with a SHARED SECRET, and the only "key" we have for a provider is its PUBLIC one which
* an attacker also has, and could sign with. Only asymmetric families are ever acceptable.
*/
const ALLOWED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512'];
// Providers rotate keys and publish new ones ahead of use, so a short cache is safe and a miss is
// cheap. Discovery changes far less often but is cached the same way for one reason: a provider
// outage should not be able to stall every login for as long as it lasts.
const DISCOVERY_TTL_MS = 60 * 60 * 1000; // 1 hour
const JWKS_TTL_MS = 10 * 60 * 1000; // 10 minutes
const FETCH_TIMEOUT_MS = 8000;
const discoveryCache = new Map(); // issuer -> { at, doc }
const jwksCache = new Map(); // jwks_uri -> { at, keys }
/*
* Every URL this module fetches is ultimately chosen by whoever configured the provider and since
* per-org SSO, that is a CUSTOMER, not the operator. Discovery, JWKS and the token endpoint are
* therefore server-side request forgery primitives unless they are constrained.
*
* Two rules, both cheap:
* https only an http:// target is a plaintext credential leak as well as a way to reach
* services that never expected a request from inside the network.
* public hosts only loopback, RFC1918, CGNAT, link-local (169.254.169.254 is cloud metadata),
* multicast and reserved ranges, in BOTH address families, including the
* IPv4-mapped IPv6 forms that a prefix match misses.
*
* This is a literal-address check, not full SSRF protection: a hostname that RESOLVES to a
* private address still passes, because refusing that needs resolve-then-pin plumbing that Node's
* fetch does not expose. It raises the bar from "type an internal URL" to "control public DNS".
* README.md documents this limitation under per-organization SSO.
*/
/*
* Addresses are parsed as ADDRESSES and compared by range. This started life as a prefix regex,
* which was wrong in both directions: it missed `[::ffff:127.0.0.1]` the entire IPv4 space
* re-encoded, which WHATWG URL normalises to `[::ffff:7f00:1]` so no dotted-quad prefix can match,
* and a review reached a loopback service straight through it while also matching plain TEXT, so
* every hostname beginning "fc" or "fd" was refused (fcm.googleapis.com, fcps.edu).
*/
const BLOCKED_V4 = [
['0.0.0.0', 8], // "this network"
['10.0.0.0', 8], // RFC1918
['100.64.0.0', 10], // CGNAT / Tailscale
['127.0.0.0', 8], // loopback
['169.254.0.0', 16], // link-local — 169.254.169.254 is cloud metadata
['172.16.0.0', 12], // RFC1918
['192.0.0.0', 24], // IETF protocol assignments
['192.168.0.0', 16], // RFC1918
['198.18.0.0', 15], // benchmarking
['224.0.0.0', 4], // multicast
['240.0.0.0', 4], // reserved
];
const v4ToInt = (ip) => ip.split('.').reduce((acc, o) => (acc * 256) + Number(o), 0);
function isBlockedV4(ip) {
const addr = v4ToInt(ip);
return BLOCKED_V4.some(([base, bits]) => {
const mask = bits === 0 ? 0 : (-1 << (32 - bits)) >>> 0;
return (addr & mask) >>> 0 === (v4ToInt(base) & mask) >>> 0;
});
}
function isBlockedV6(ip) {
const low = ip.toLowerCase();
// An IPv4-mapped or IPv4-compatible address is an IPv4 address wearing a hat — judge the IPv4.
const mapped = low.match(/^::(ffff:)?(\d+\.\d+\.\d+\.\d+)$/)
|| low.match(/^::(ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (mapped) {
if (mapped[2] && mapped[2].includes('.')) return isBlockedV4(mapped[2]);
const hi = parseInt(mapped[2], 16), lo = parseInt(mapped[3], 16);
return isBlockedV4([hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.'));
}
if (low === '::' || low === '::1') return true; // unspecified (= loopback on Linux), loopback
if (/^f[cd]/.test(low)) return true; // fc00::/7 unique-local
if (/^fe[89ab]/.test(low)) return true; // fe80::/10 link-local
if (/^ff/.test(low)) return true; // multicast
return false;
}
function assertFetchable(url) {
let u;
try { u = new URL(url); } catch { throw new Error(`not a URL: ${url}`); }
if (u.protocol !== 'https:') throw new Error('provider URLs must use https');
/*
* A trailing root dot is a legal, fully-qualified spelling of the same name, and WHATWG URL keeps
* it so `https://localhost./` matched neither alternative below and was ALLOWED. The parser
* normalises the literal-IP forms itself (`127.0.0.1.` becomes `127.0.0.1`), so only the name
* form slipped, and on a resolver that synthesizes `localhost.` it resolves to loopback.
*/
const host = u.hostname.replace(/\.$/, '');
// URL keeps IPv6 literals in brackets; net.isIP does not want them.
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
const family = net.isIP(bare);
const blocked = family === 4 ? isBlockedV4(bare)
: family === 6 ? isBlockedV6(bare)
: /^(localhost|.*\.localhost)$/i.test(host);
if (blocked) throw new Error('provider host is not publicly routable');
return u;
}
/** fetch with a timeout, because a hanging IdP must not hang a login forever. */
async function getJson(url) {
assertFetchable(url);
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
try {
/*
* redirect: 'manual' following redirects would let an allowlisted host bounce us to a blocked
* one, which defeats the check above entirely. A provider that redirects its own well-known
* document is misconfigured, and saying so is more useful than quietly following it.
*/
const res = await fetch(url, { signal: ctl.signal, redirect: 'manual' });
if (res.status >= 300 && res.status < 400) throw new Error(`${url} redirected; provider URLs must be final`);
if (!res.ok) throw new Error(`${url} responded ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
/**
* The provider's own description of itself.
*
* The discovered `issuer` is checked against the configured one. Discovery is fetched over TLS
* from a URL derived from the issuer, so this is belt-and-braces but a provider whose document
* claims a DIFFERENT issuer is either misconfigured or hostile, and either way its tokens must not
* be accepted under a name it does not own.
*/
async function discover(issuer) {
const key = String(issuer).replace(/\/+$/, '');
const hit = discoveryCache.get(key);
if (hit && Date.now() - hit.at < DISCOVERY_TTL_MS) return hit.doc;
const url = `${key}/.well-known/openid-configuration`;
const doc = await getJson(url);
const advertised = String(doc.issuer || '').replace(/\/+$/, '');
if (advertised !== key) {
throw new Error(`discovery issuer mismatch: configured ${key}, document says ${doc.issuer}`);
}
for (const required of ['authorization_endpoint', 'token_endpoint', 'jwks_uri']) {
if (!doc[required]) throw new Error(`discovery for ${key} is missing ${required}`);
}
discoveryCache.set(key, { at: Date.now(), doc });
return doc;
}
/**
* The signing key for one token.
*
* An unknown `kid` forces ONE refresh: that is the normal shape of a key rotation, and refusing to
* refetch would fail every login until the cache expired. It is bounded to one refresh per call so
* a token quoting nonsense cannot be used to hammer the provider.
*/
async function keyForKid(jwksUri, kid) {
let entry = jwksCache.get(jwksUri);
const fresh = entry && Date.now() - entry.at < JWKS_TTL_MS;
if (!fresh || !entry.keys.some((k) => k.kid === kid)) {
const doc = await getJson(jwksUri);
entry = { at: Date.now(), keys: Array.isArray(doc.keys) ? doc.keys : [] };
jwksCache.set(jwksUri, entry);
}
const jwk = entry.keys.find((k) => k.kid === kid)
// A provider with exactly one key may omit kid entirely; anything ambiguous is refused rather
// than guessed, because "try each key until one verifies" is how you accept a key you did not mean to.
|| (!kid && entry.keys.length === 1 ? entry.keys[0] : null);
if (!jwk) throw new Error(`no signing key for kid ${kid || '(none)'}`);
return crypto.createPublicKey({ key: jwk, format: 'jwk' });
}
/**
* Verify an ID token and return its claims.
*
* `nonce` is REQUIRED by this function even though the spec makes it conditional. Every flow here
* is a browser login we initiated, so we always have one to compare and it is the single check
* that distinguishes "a token minted for us, now" from "a token minted for us at some point,
* captured, and replayed".
*/
async function verifyIdToken(idToken, { issuer, clientId, nonce }) {
if (!idToken || typeof idToken !== 'string') throw new Error('no id_token');
if (!nonce) throw new Error('no nonce to verify against');
const decoded = jwt.decode(idToken, { complete: true });
if (!decoded || !decoded.header) throw new Error('id_token is not a JWT');
if (!ALLOWED_ALGS.includes(decoded.header.alg)) {
throw new Error(`refusing id_token algorithm ${decoded.header.alg}`);
}
const doc = await discover(issuer);
const key = await keyForKid(doc.jwks_uri, decoded.header.kid);
// jsonwebtoken checks signature, exp, nbf, iss and aud. The algorithm allowlist is passed
// explicitly so the header cannot choose how it is verified.
const claims = jwt.verify(idToken, key, {
algorithms: ALLOWED_ALGS,
issuer: doc.issuer,
audience: clientId,
clockTolerance: 60,
});
if (claims.nonce !== nonce) throw new Error('id_token nonce does not match this login');
/*
* azp names the party the token was issued TO when it differs from the audience. If it is present
* it must be us: a token with our client_id merely in a multi-valued `aud`, issued to a different
* application, is exactly the confused-deputy case this whole file exists to prevent.
*/
if (claims.azp && claims.azp !== clientId) {
throw new Error('id_token was issued to a different application');
}
if (!claims.sub) throw new Error('id_token has no subject');
return claims;
}
/** PKCE S256. The verifier never leaves us; only its hash goes to the provider. */
function createPkce() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge, method: 'S256' };
}
const randomToken = () => crypto.randomBytes(32).toString('base64url');
/**
* Exchange the authorization code.
*
* PKCE means a public client needs no secret, which is what lets a self-hoster configure a provider
* without one. A secret is still sent when configured, because some providers (and some admins)
* require confidential clients.
*/
async function exchangeCode({ issuer, clientId, clientSecret, code, redirectUri, verifier }) {
const doc = await discover(issuer);
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: verifier,
});
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' };
if (clientSecret) {
// client_secret_basic is the form every provider accepts; client_secret_post is not universal.
headers.Authorization = 'Basic ' + Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString('base64');
}
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
let payload;
try {
assertFetchable(doc.token_endpoint);
const res = await fetch(doc.token_endpoint, { method: 'POST', headers, body, signal: ctl.signal, redirect: 'manual' });
payload = await res.json().catch(() => ({}));
if (!res.ok) {
// The provider's own error is far more useful than "exchange failed" — a wrong redirect_uri
// or an unregistered client is the overwhelmingly common setup mistake and it says so here.
throw new Error(payload.error_description || payload.error || `token endpoint responded ${res.status}`);
}
} finally {
clearTimeout(timer);
}
if (!payload.id_token) throw new Error('provider returned no id_token — is the openid scope requested?');
return payload;
}
/** Test seam: drop cached discovery/JWKS so a test can change what a provider claims. */
function _resetCaches() {
discoveryCache.clear();
jwksCache.clear();
}
/**
* The provider's published keys, straight from the document. Used by the configuration test so an
* admin learns at setup time that a provider publishes no signing keys, rather than at first login.
*/
async function fetchJwks(jwksUri) {
return getJson(jwksUri);
}
module.exports = {
discover,
assertFetchable,
fetchJwks,
verifyIdToken,
exchangeCode,
createPkce,
randomToken,
ALLOWED_ALGS,
_resetCaches,
};

View file

@ -0,0 +1,178 @@
'use strict';
/*
* Make sure the dependencies this build needs are actually installed and loadable BEFORE anything
* requires them.
*
* The normal upgrade path (scripts/upgrade.sh) runs `npm ci --omit=dev`, so this is not for the
* happy case. It is for the three ways a running box ends up with the wrong node_modules:
*
* ROLLBACK checking out an older tag to back out a bad release restores that tag's
* package.json but not its packages, so the server dies on a MODULE_NOT_FOUND for
* something the newer build had removed. That is a bad moment to be reading a
* stack trace: you are already rolling back because something else broke.
* NODE UPGRADE better-sqlite3 is a native module compiled against one ABI. Upgrading Node makes
* every boot fail with NODE_MODULE_VERSION mismatch, which reads like database
* corruption and is not.
* HAND EDITS a `git checkout`, a partly-copied tree, an interrupted install.
*
* All three present as a server that will not start, with an error that names a file rather than
* the action needed. Detecting and repairing is a few seconds; diagnosing is an outage.
*
* Deliberately dependency-free only Node builtins. Anything it required could be the very
* thing that is missing.
*
* Set ST_SKIP_DEP_PREFLIGHT=1 to turn it off (air-gapped hosts, or an operator who manages
* node_modules themselves and does not want a boot reaching for the network).
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const SERVER_DIR = path.join(__dirname, '..');
const NODE_MODULES = path.join(SERVER_DIR, 'node_modules');
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; // a cold install on a Pi is genuinely slow
/** Which declared dependencies are not on disk. */
function missingDeps() {
let pkg;
try {
pkg = JSON.parse(fs.readFileSync(path.join(SERVER_DIR, 'package.json'), 'utf8'));
} catch {
return []; // no package.json is not our problem to diagnose
}
const declared = Object.keys(pkg.dependencies || {});
return declared.filter((name) => {
// A scoped or nested name is still one directory below node_modules.
try { return !fs.existsSync(path.join(NODE_MODULES, name, 'package.json')); } catch { return true; }
});
}
/**
* Is the native module loadable by THIS Node?
*
* Checked by actually loading it, because the failure is an ABI mismatch that no version string
* comparison catches reliably a rebuild against the same major can still differ.
*/
function nativeModuleBroken() {
try {
/*
* CONSTRUCT one, do not merely require it.
*
* better-sqlite3's entry point is plain JavaScript and loads the compiled `.node` binding
* lazily, so `require()` alone SUCCEEDS under a Node whose ABI the binary was not built for
* the first version of this check did exactly that and reported a broken install as healthy,
* verified against a real Node 18 / Node 20 mismatch. Opening an in-memory database is what
* actually pulls the binding in, and it touches no file.
*/
const Database = require('better-sqlite3');
new Database(':memory:').close();
return null;
} catch (e) {
const msg = String((e && e.message) || '');
if (/NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|was compiled against a different/i.test(msg)) return msg;
if (/Cannot find module/i.test(msg)) return msg;
// Anything else is a real error in the module, not an installation problem — let it surface
// later with its own stack rather than being masked by an npm run.
return null;
}
}
function run(args, label) {
console.log(`[preflight] ${label}: npm ${args.join(' ')}`);
execFileSync('npm', args, { cwd: SERVER_DIR, stdio: 'inherit', timeout: INSTALL_TIMEOUT_MS });
}
function fail(reason, hint) {
console.error(`[preflight] ${reason}`);
console.error(`[preflight] ${hint}`);
console.error('[preflight] Set ST_SKIP_DEP_PREFLIGHT=1 to boot without this check.');
process.exit(1);
}
function preflight() {
// Same spellings as every other boolean the server accepts, so an operator who writes `true`
// does not silently get a boot that reaches for the registry anyway.
if (['1', 'true', 'yes'].includes(String(process.env.ST_SKIP_DEP_PREFLIGHT || '').toLowerCase())) return;
const missing = missingDeps();
const nodeModulesAbsent = !fs.existsSync(NODE_MODULES);
if (missing.length || nodeModulesAbsent) {
const what = nodeModulesAbsent
? 'node_modules is missing'
: `${missing.length} dependency/dependencies missing: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? '…' : ''}`;
console.warn(`[preflight] ${what} — installing before start.`);
try {
/*
* `npm ci` when there is a lockfile and nothing installed: it is reproducible and it is what
* upgrade.sh uses. Otherwise `npm install`, because `ci` DELETES node_modules first and would
* throw away a working tree to fix one missing package.
*/
const hasLock = fs.existsSync(path.join(SERVER_DIR, 'package-lock.json'));
if (hasLock && nodeModulesAbsent) {
/*
* Nothing installed, so `ci` has nothing to destroy and gives a reproducible tree.
*
* `--omit=dev` ONLY when this is plainly a production boot. Applying it unconditionally
* meant a cold start on a developer machine installed 307 packages and left `npm test`
* broken js-yaml, puppeteer-core and socket.io-client absent which is the same class of
* surprise as the prune this file already warns about, arriving through the other branch of
* the same `if`.
*/
const prod = process.env.NODE_ENV === 'production';
run(prod ? ['ci', '--omit=dev', '--no-audit', '--no-fund'] : ['ci', '--no-audit', '--no-fund'], 'installing');
} else {
/*
* Install ONLY what is missing, by name, and never `--omit=dev` on a populated tree.
*
* `npm install --omit=dev` reconciles the whole tree, which PRUNES devDependencies so
* merely starting the server deleted socket.io-client, puppeteer-core and js-yaml, and broke
* `npm test`. A review watched it happen. A boot-time repair that quietly removes packages
* is worse than the failure it fixes, so this touches nothing it was not asked to.
*
* `--no-save` because a server starting up has no business editing package.json.
*/
run(['install', '--no-save', '--no-audit', '--no-fund', ...missing], 'installing missing packages');
}
} catch (e) {
/*
* An install can fail because ANOTHER server started at the same moment and won the race
* observed as `ENOTEMPTY … rename node_modules/fs-extra`. The tree is complete by the time we
* see the error, so exiting here killed a process that had nothing wrong with it. Re-check
* before giving up; only a genuinely incomplete tree is fatal.
*/
const afterFailure = missingDeps();
if (afterFailure.length) {
fail(`could not install dependencies: ${e && e.message}`,
'Run `npm ci --omit=dev` in the server directory, or check network access to the npm registry.');
}
console.warn(`[preflight] install reported an error but the tree is complete (${e && e.message}) — continuing.`);
}
const still = missingDeps();
if (still.length) {
fail(`still missing after install: ${still.join(', ')}`, 'Check the npm output above.');
}
console.log('[preflight] dependencies installed.');
}
const nativeProblem = nativeModuleBroken();
if (nativeProblem) {
console.warn(`[preflight] better-sqlite3 will not load under Node ${process.version} — rebuilding.`);
console.warn(`[preflight] ${nativeProblem.split('\n')[0]}`);
try {
run(['rebuild', 'better-sqlite3'], 'rebuilding native module');
} catch (e) {
fail(`could not rebuild better-sqlite3: ${e && e.message}`,
`Run \`npm rebuild better-sqlite3\` in the server directory. This usually means Node changed version (now ${process.version}) and the module needs recompiling; a build toolchain (python3, make, g++) must be present.`);
}
if (nativeModuleBroken()) {
fail('better-sqlite3 still will not load after a rebuild.',
'Delete server/node_modules and run `npm ci --omit=dev`.');
}
console.log('[preflight] native module rebuilt.');
}
}
module.exports = { preflight, missingDeps, nativeModuleBroken };

View file

@ -0,0 +1,57 @@
'use strict';
/*
* Email domains nobody may claim for organization SSO.
*
* Per-org SSO routes everyone at a domain to that organization's identity provider. Applied to a
* company domain that is the point. Applied to a CONSUMER domain it is an attack: one tenant claims
* `gmail.com`, and from then on every Gmail user who types their address into this product's login
* page is offered a "sign in with your organization" button that sends them to infrastructure the
* tenant controls phishing launched from the vendor's own trusted login screen. First-claim-wins
* also lets one cheap account deny a public domain to everyone else, permanently.
*
* This is a floor, not a ceiling. It stops the mass-abuse case; it does NOT stop a tenant
* claiming a domain that belongs to some specific other company. Only proof of control a DNS TXT
* record, or a challenge to postmaster@ settles that, and until it exists a claimed domain means
* "nobody else had claimed it", not "they own it".
*
* Kept as data, in one file, because it is a list that will need adding to and that is the cheapest
* possible edit. Matching is exact on the registrable domain, so `mail.google.com` is not blocked by
* `gmail.com` subdomains of consumer providers are not a realistic sign-in domain anyway.
*/
const PUBLIC_EMAIL_DOMAINS = new Set([
// Google
'gmail.com', 'googlemail.com',
// Microsoft
'outlook.com', 'outlook.co.uk', 'hotmail.com', 'hotmail.co.uk', 'hotmail.fr', 'hotmail.it',
'live.com', 'live.co.uk', 'msn.com', 'passport.com',
// Yahoo and friends
'yahoo.com', 'yahoo.co.uk', 'yahoo.co.jp', 'yahoo.fr', 'yahoo.de', 'yahoo.ca', 'yahoo.com.au',
'ymail.com', 'rocketmail.com', 'aol.com', 'aim.com',
// Apple
'icloud.com', 'me.com', 'mac.com',
// Privacy-focused
'proton.me', 'protonmail.com', 'pm.me', 'tutanota.com', 'tutanota.de', 'tuta.io', 'tuta.com',
'duck.com', 'hey.com', 'fastmail.com', 'fastmail.fm',
// Other large consumer providers
'gmx.com', 'gmx.de', 'gmx.net', 'gmx.at', 'gmx.ch', 'web.de', 'mail.com', 'email.com',
'zoho.com', 'zohomail.com', 'yandex.com', 'yandex.ru', 'ya.ru', 'mail.ru', 'bk.ru', 'inbox.ru',
'list.ru', 'rambler.ru',
'qq.com', 'foxmail.com', '163.com', '126.com', 'sina.com', 'sina.cn', 'naver.com', 'daum.net',
'hanmail.net', 'rediffmail.com',
// ISP-style mailboxes, where the domain belongs to the ISP and not to any customer
'comcast.net', 'verizon.net', 'att.net', 'sbcglobal.net', 'bellsouth.net', 'cox.net',
'charter.net', 'earthlink.net', 'juno.com', 'optonline.net', 'roadrunner.com',
'btinternet.com', 'sky.com', 'virginmedia.com', 'talktalk.net', 'orange.fr', 'wanadoo.fr',
'free.fr', 'laposte.net', 'libero.it', 'virgilio.it', 'tiscali.it', 'terra.com.br', 'uol.com.br',
'bol.com.br', 'telus.net', 'shaw.ca', 'rogers.com', 'sympatico.ca', 'bigpond.com', 'optusnet.com.au',
't-online.de', 'freenet.de', 'arcor.de',
]);
/** True when this domain is a consumer mailbox provider rather than an organization's own domain. */
function isPublicEmailDomain(domain) {
return PUBLIC_EMAIL_DOMAINS.has(String(domain || '').trim().toLowerCase());
}
module.exports = { PUBLIC_EMAIL_DOMAINS, isPublicEmailDomain };

174
server/package-lock.json generated
View file

@ -15,7 +15,6 @@
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^8.3.1",
"google-auth-library": "^10.6.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
@ -931,6 +930,7 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@ -1314,15 +1314,6 @@
"prebuild-install": "^7.1.1"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@ -1845,15 +1836,6 @@
"node": ">= 8"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -2347,12 +2329,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@ -2415,29 +2391,6 @@
"pend": "~1.2.0"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@ -2491,18 +2444,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -2550,34 +2491,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gaxios": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -2717,32 +2630,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -2857,6 +2744,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@ -2870,6 +2758,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -2887,6 +2776,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/iconv-lite": {
@ -3022,15 +2912,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
@ -3371,44 +3252,6 @@
"node": ">=10"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -4845,15 +4688,6 @@
"node": ">= 0.8"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",

View file

@ -17,7 +17,6 @@
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^8.3.1",
"google-auth-library": "^10.6.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
@ -32,7 +31,7 @@
},
"devDependencies": {
"js-yaml": "^4.2.0",
"socket.io-client": "^4.8.3",
"puppeteer-core": "^24.43.1"
"puppeteer-core": "^24.43.1",
"socket.io-client": "^4.8.3"
}
}

View file

@ -3,6 +3,7 @@ const router = express.Router();
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const oidcProviders = require('../lib/oidc-providers');
const { canAdminWorkspace } = require('../lib/permissions');
const { requirePlatformAdmin, requireAdmin } = require('../middleware/auth');
const { logActivity, getClientIp } = require('../services/activity');
@ -20,7 +21,9 @@ const { platformDefaultRow, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID } = require(
// have no user/role-management power (#13).
// Same email shape the invite-create endpoint validates against (workspaces.js).
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Markup characters are not legal here. The looser form admitted < > " ' and an admin-
// chosen email became stored XSS in the platform admin's user list.
const EMAIL_RE = /^[^\s@<>"'`\\;,()\[\]]+@[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/;
const WORKSPACE_ROLES = ['workspace_admin', 'workspace_editor', 'workspace_viewer'];
// Mirror the server-side minimum enforced by PUT /api/auth/me and register.
const MIN_PASSWORD_LENGTH = 8;
@ -55,6 +58,53 @@ router.post('/users', (req, res) => {
if (!canAdminWorkspace(db, req.user, ws)) {
return res.status(403).json({ error: 'Admin access required' });
}
/*
* An SSO-only organization must not have password accounts minted into it.
*
* This route creates a LOCAL account with an admin-chosen password, and it accepts any address
* so on a tenant that requires single sign-on it was a one-call backdoor: create
* `contractor@somewhere-else.test` bound to the workspace, log in with the password, and every
* control the customer turned SSO-only on for is behind you. A review did exactly that, and the
* account it created could then mint another.
*
* platform_admin keeps the ability, because that is the operator break-glass the same
* exemption the login gate makes, for the same reason.
*/
if (req.user.role !== 'platform_admin' && ws.organization_id) {
// The table is absent on a single-tenant install; that simply means no organization requires
// single sign-on, so creation proceeds.
let org = null;
try { org = db.prepare('SELECT sso_only, name FROM organizations WHERE id = ?').get(ws.organization_id); }
catch { org = null; }
if (org && org.sso_only) {
return res.status(400).json({
error: `${org.name || 'This organization'} requires single sign-on, so password accounts cannot be created. Invite the person through your identity provider instead.`,
code: 'sso_only_org',
});
}
}
/*
* And the ADDRESS's own domain, wherever it is being created.
*
* Gating only on the target workspace left the squat open through a different door: create your
* own organization, then mint `cfo@theircompany.test` into YOUR workspace. Login is refused, so
* it is not access but the row now has a password_hash, and an SSO login will not adopt a row
* that has one. The real CFO can then never sign in through their own identity provider, and a
* password reset they CAN complete lands them at a login that refuses them. Permanent, with no
* self-service way out, for any address at any SSO-only customer.
*/
if (req.user.role !== 'platform_admin') {
let ownedBy = null;
try { ownedBy = oidcProviders.ssoOnlyForEmail(email); } catch { ownedBy = { unavailable: true }; }
if (ownedBy) {
return res.status(400).json({
error: 'That email domain uses single sign-on, so a password account cannot be created for it.',
code: 'sso_only_domain',
});
}
}
// Stamp the target workspace so the activityLogger middleware (and our
// explicit audit row) attribute to the right tenant.
req.workspaceId = ws.id;

File diff suppressed because it is too large Load diff

939
server/routes/org-sso.js Normal file
View file

@ -0,0 +1,939 @@
'use strict';
/*
* Per-organization SSO the customer-facing half of single sign-on.
*
* Instance-wide providers live in the environment and belong to whoever runs the server. These
* belong to a CUSTOMER: an organization points ScreenTinker at its own identity provider, and its
* people sign in with it without the operator editing a config file.
*
* The login flow is unchanged. A provider configured here is resolved by exactly the same
* oidc-providers.get(slug) the environment ones go through, so there is one authorization request
* builder, one token exchange and one verifier not a second, less-tested path for tenants.
*/
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const { db } = require('../db/database');
const { requireAuth } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const secretbox = require('../lib/secretbox');
const oidc = require('../lib/oidc');
const { logActivity, getClientIp } = require('../services/activity');
const { isPublicEmailDomain } = require('../lib/public-email-domains');
const domainVerify = require('../lib/domain-verify');
const emailSvc = require('../services/email');
/*
* Only an org owner/admin may configure how their people sign in it is the most security-relevant
* setting a tenant has. Platform staff are deliberately NOT given a bypass here: this is customer
* configuration, and an operator who needs to change it can do so as a member of that organization.
*/
function requireOrgAdmin(req, res, next) {
const orgId = req.params.orgId;
if (!orgId) return res.status(400).json({ error: 'organization required' });
const row = db.prepare(
'SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?'
).get(orgId, req.user.id);
if (!row || (row.role !== 'org_owner' && row.role !== 'org_admin')) {
// 404 rather than 403: an outsider should not learn that an organization id exists.
return res.status(404).json({ error: 'Not found' });
}
req.orgId = orgId;
next();
}
/*
* Configuring SSO requires a VERIFIED email address, on top of being an org admin.
*
* Everything else here rests on the identity of the person doing it: they claim domains, they point
* the organization at an identity provider, and they are who the operator's claim notification
* names. An unverified address is an assertion nobody has checked, so without this the entire
* feature including domain claims is reachable by anyone who can type an address into the
* signup form and never open the mail.
*
* Reads are deliberately NOT gated: seeing your own organization's configuration changes nothing,
* and locking an admin out of the screen that explains why sign-in is broken helps no one.
*/
function requireVerifiedAdmin(req, res, next) {
const row = db.prepare('SELECT email_verified FROM users WHERE id = ?').get(req.user.id);
if (!row || !row.email_verified) {
return res.status(403).json({
error: 'Verify your email address before configuring single sign-on.',
code: 'email_unverified',
});
}
next();
}
/*
* The slug is a URL path segment and is generated, never chosen.
*
* Two customers both wanting "okta" must not collide, and one must not be able to guess or squat
* another's. It is random and globally unique; the admin only ever sees the display name.
*/
const newSlug = () => `org${crypto.randomBytes(6).toString('hex')}`;
/** Never let a secret out of the API, in either direction of a round trip. */
function toPublic(row) {
return {
id: row.id,
slug: row.slug,
name: row.name,
issuer: row.issuer,
client_id: row.client_id,
has_client_secret: !!row.client_secret_enc,
scopes: row.scopes,
email_domains: row.email_domains,
enabled: !!row.enabled,
login_url: `/api/auth/oidc/${row.slug}/start`,
callback_url: `/api/auth/oidc/${row.slug}/callback`,
// Domains and their proof state. A provider whose domains are all unverified can be saved and
// looks configured, but routes nobody — the UI needs this to say so rather than imply success.
domains: domainsFor(row.id),
};
}
/*
* Domains are the routing key, so they are normalised hard: lowercased, de-duplicated, stripped of
* a leading @ or scheme someone pasted, and validated as something that can actually be the right
* hand side of an address. A wildcard is refused "*" would route every unrecognised address at
* one customer's IdP.
*/
const MAX_DOMAINS = 50;
function normaliseDomains(raw) {
const seen = new Set();
for (const part of String(raw || '').split(/[,\s]+/)) {
/*
* Capped. Uncapped, one verified org admin could POST 20,000 domains inside the 12 MB body
* limit: 20,000 rows inserted under a single write lock (stalling every other query on the
* instance) and 20,000 notification emails per platform admin. No real organization signs in
* from fifty domains, and an org that does can create a second provider.
*/
if (seen.size >= MAX_DOMAINS) {
const e = new Error(`at most ${MAX_DOMAINS} sign-in domains per provider`);
e.status = 400;
throw e;
}
let d = part.trim().toLowerCase().replace(/^@/, '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
if (!d) continue;
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(d)) {
throw new Error(`"${part.trim()}" is not a valid email domain`);
}
/*
* A consumer mailbox provider is never an organization's sign-in domain, and claiming one is an
* attack rather than a mistake: every Gmail or Outlook user typing their address into this
* product's login page would be offered a "sign in with your organization" button pointing at
* one tenant's infrastructure. It also lets one cheap account deny a public domain to everyone.
*/
if (isPublicEmailDomain(d)) {
const e = new Error(`${d} is a public email provider and cannot be used as a sign-in domain. `
+ 'Use a domain your organization owns.');
e.status = 400;
throw e;
}
seen.add(d);
}
return [...seen].join(',');
}
/**
* A domain may belong to ONE organization.
*
* Without this, a second tenant could claim a domain already routed elsewhere and quietly capture
* that company's logins the worst failure this feature could have. First claim wins; the loser is
* told which domain clashed and nothing about who holds it.
*/
function assertDomainsFree(domains, orgId, excludeProviderId) {
if (!domains) return;
for (const d of domains.split(',')) {
const row = db.prepare('SELECT * FROM org_sso_domains WHERE domain = ?').get(d);
if (!row) continue;
if (row.provider_id && row.provider_id === excludeProviderId) continue;
/*
* A lapsed unverified claim reserves nothing. Clearing it here rather than on a timer means the
* domain frees itself the moment someone else asks for it, and there is no sweeper to forget to
* run a squatter's unprovable claim simply stops being an obstacle.
*/
if (domainVerify.isClaimExpired(row)) {
db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(row.id);
continue;
}
// Same-org duplicates were allowed and should not have been: two providers claiming one
// domain makes routing depend on table-scan order, so half a company's staff get sent to an
// identity provider that has never heard of them.
const e = new Error(row.organization_id === orgId
? `the domain ${d} is already used by another of your providers`
: `the domain ${d} is already used for sign-in by another organization`);
e.status = 409;
throw e;
}
}
/*
* Bring the claimed-domain rows in line with what the admin typed.
*
* A newly claimed domain arrives UNVERIFIED and stays inert until DNS proves the claim it routes
* nobody and the login callback refuses assertions for it. Re-typing an existing domain must not
* reset that proof, which is why this diffs rather than deleting and re-inserting: a save on the
* name field would otherwise silently un-verify every domain the customer had already proved, and
* log their whole company out.
*
* Runs inside the caller's transaction so a domain cannot be reserved by two organizations at once.
*/
function syncDomains(providerId, orgId, domains) {
const wanted = domains ? domains.split(',').filter(Boolean) : [];
const existing = db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ?').all(providerId);
const stale = new Map(existing.map((r) => [r.domain, r]));
const claimed = []; // newly claimed, for the operator notification — sent AFTER the transaction
for (const d of wanted) {
const mine = stale.get(d);
if (mine && !domainVerify.isClaimExpired(mine)) {
stale.delete(d); // already ours and still live — keep any proof that happened
continue;
}
if (mine) {
/*
* A LAPSED claim is not renewed in place. Renewing silently is what made the 8-hour limit
* meaningless: a review held a domain indefinitely at one request per window, and because a
* renewal was not a new claim, the operator was told exactly once, on day zero.
*
* So the row is dropped and re-created: the token changes (a record left over from the
* abandoned attempt cannot satisfy the new one), and it counts as a fresh claim, which means
* it is notified again. Squatting is not made impossible it is made loud.
*/
stale.delete(d);
db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(mine.id);
}
assertDomainsFree(d, orgId, providerId);
db.prepare(`INSERT INTO org_sso_domains (id, organization_id, provider_id, domain, token)
VALUES (?, ?, ?, ?, ?)`)
.run(crypto.randomUUID(), orgId, providerId, d, domainVerify.newToken());
claimed.push(d);
}
for (const row of stale.values()) {
db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(row.id);
}
return claimed;
}
/*
* Tell the operator that a tenant has claimed a domain.
*
* DNS verification makes a claim worthless without control of the domain, so this is not what stops
* abuse it is what makes abuse VISIBLE. A tenant claiming `microsoft.com` will never verify it,
* but an operator still wants to know somebody tried, and the notification is the difference
* between finding that out now and finding it out from the company involved.
*
* Deliberately NOT sent to postmaster@ the claimed domain. That would mean this product emails
* third parties who never signed up for it, on input any tenant can supply a spam cannon with a
* ScreenTinker return address. The operator can contact a domain owner; the server should not do it
* unprompted.
*
* Failure to send is logged and swallowed: a mail outage must not stop a customer configuring SSO.
*/
function notifyOperatorOfClaim(req, { domains, orgId, providerName }) {
try {
if (!domains || !domains.length) return;
// Always log, even with no mail transport — otherwise an instance without email has no record
// of a claim at all, and those are exactly the instances least likely to notice.
console.log(`[org-sso] domain(s) claimed by org ${orgId} (${providerName}): ${domains.join(', ')}`);
if (!emailSvc.isConfigured()) return;
// COALESCE, because email_alerts is nullable in practice on older rows and `= 1` silently
// excludes NULL — the activation-nudge query already defends this way.
const admins = db.prepare("SELECT email FROM users WHERE role = 'platform_admin' AND COALESCE(email_alerts, 1) = 1").all();
if (!admins.length) return;
const org = db.prepare('SELECT name FROM organizations WHERE id = ?').get(orgId);
const who = req.user && req.user.email ? req.user.email : 'an administrator';
// ONE message per save listing every domain, not one per domain per admin — fifty admins
// claiming ten domains was five hundred messages from a single request.
const body = [
`${who} claimed ${domains.length} sign-in domain(s):`,
'',
...domains.map((d) => ` ${d}`),
'',
`Organization: ${org ? org.name : orgId} (${orgId})`,
`Provider: ${providerName}`,
'',
'A claimed domain routes nobody until a DNS TXT record proves it, and the claim lapses after',
'8 hours if it is not proved. No action is needed unless this looks wrong.',
].join('\n');
const subject = domains.length === 1
? `SSO domain claimed: ${domains[0]}`
: `${domains.length} SSO domains claimed`; // services/email.js adds the [ScreenTinker] prefix
for (const a of admins) {
Promise.resolve(emailSvc.sendEmail({ to: a.email, subject, text: body }))
.catch((e) => console.error('[org-sso] claim notification failed:', e && e.message));
}
} catch (e) {
console.error('[org-sso] claim notification failed:', e && e.message);
}
}
/*
* Tell the operator that a customer wants password login re-opened.
*
* The mail deliberately carries NO action link. A token that acts on its own turns every forwarded,
* archived or auto-previewed copy of this message into a way to switch off a customer's single
* sign-on; the decision belongs to a signed-in platform admin, so the mail only says where to make
* it. Logged unconditionally, because an instance with no mail transport still needs a record that
* somebody asked.
*/
function notifyOperatorOfRemovalRequest(req, { id, orgId, orgName, reason }) {
try {
const who = req.user && req.user.email ? req.user.email : 'an administrator';
console.warn(`[org-sso] SSO-ONLY REMOVAL REQUESTED for org ${orgName || orgId} (${orgId}) by ${who} — request ${id}`);
if (!emailSvc.isConfigured()) return;
const admins = db.prepare("SELECT email FROM users WHERE role = 'platform_admin' AND COALESCE(email_alerts, 1) = 1").all();
if (!admins.length) return;
const body = [
`${who} has asked to stop requiring single sign-on for ${orgName || orgId}.`,
'',
'Approving this RE-OPENS password sign-in for everyone at that organization\u2019s verified',
'domains. Until it is approved, nothing changes.',
'',
reason ? `Reason given: ${reason}` : 'No reason was given.',
'',
`Organization: ${orgName || ''} (${orgId})`,
`Request: ${id}`,
'',
'Review it in ScreenTinker under Admin. There is no link in this email on purpose — the',
'decision has to be made while signed in as a platform admin, so a forwarded copy of this',
'message cannot turn off a customer\u2019s single sign-on.',
].join('\n');
for (const a of admins) {
Promise.resolve(emailSvc.sendEmail({
to: a.email,
subject: `Approval needed: stop requiring SSO for ${orgName || orgId}`,
text: body,
})).catch((e) => console.error('[org-sso] removal notification failed:', e && e.message));
}
} catch (e) {
console.error('[org-sso] removal notification failed:', e && e.message);
}
}
/*
* An SSO-only organization may not dismantle its own enforcement sideways.
*
* `sso_only` is honoured only while a provider is enabled AND a domain is verified, so disabling
* the provider, clearing its domains, or deleting it all switch enforcement off with `sso_only`
* still reading `true`, no request filed and the operator never told. A review used each of the
* three, and the delete variant additionally rewrites every federated account to `local`, after
* which a password reset takes over accounts the identity provider was supposed to own.
*
* That made the approval workflow decorative: anyone who could file a request could instead just
* turn the provider off. So the same interlock guards every route that would leave the tenant with
* nothing enforcing, and points at the request as the way through.
*/
/** The domains that currently ENFORCE for an organization: verified, on an enabled provider. */
function enforcingDomains(orgId, excludeProviderId = null) {
return db.prepare(`
SELECT d.domain FROM org_sso_domains d
JOIN org_sso_providers p ON p.id = d.provider_id
WHERE d.organization_id = ? AND d.verified_at IS NOT NULL AND p.enabled = 1
AND (? IS NULL OR p.id != ?)
`).all(orgId, excludeProviderId, excludeProviderId).map((r) => r.domain);
}
/*
* An SSO-only organization may not shrink the set of domains that enforce.
*
* The first version of this asked "would ANY provider still enforce?", which was the wrong
* question twice over, and a review defeated it both ways:
*
* SWAP it only fired when the resulting domain list was EMPTY, so replacing
* `acme.test` with `decoy.test` removed every proof and sailed through two PUTs
* and the customer's domain no longer required anything.
* SIBLING it counted PROVIDERS, so with two configured you could disable the one that owns
* your staff's domain while the other, covering a domain nobody signs in at, kept the
* answer "yes, something still enforces".
*
* The question that matters is per-DOMAIN: after this change, is every domain that enforces today
* still enforcing? Losing one is exactly what needs the operator, whichever route gets you there.
*/
function assertEnforcementNotReduced(orgId, providerId, nextDomainsFor, what) {
const org = db.prepare('SELECT sso_only FROM organizations WHERE id = ?').get(orgId);
if (!org || !org.sso_only) return;
const before = new Set(enforcingDomains(orgId));
const after = new Set(enforcingDomains(orgId, providerId));
// Whatever this provider will still contribute afterwards, as VERIFIED domains only — a domain
// being re-added is unverified, so it does not count as still enforcing.
for (const d of nextDomainsFor) after.add(d);
const lost = [...before].filter((d) => !after.has(d));
if (!lost.length) return;
const e = new Error(`Your organization requires single sign-on, so ${what} would stop `
+ `${lost.join(', ')} from being covered and leave those people unable to sign in. `
+ 'Ask the people who run this server to approve stopping the requirement first.');
e.status = 409;
e.code = 'sso_only_locked';
throw e;
}
/** A provider's domains, with the DNS record each unverified one still needs. *//** A provider's domains, with the DNS record each unverified one still needs. */
function domainsFor(providerId) {
return db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ? ORDER BY domain').all(providerId)
.map((r) => ({
domain: r.domain,
verified: !!r.verified_at,
verified_at: r.verified_at,
last_checked_at: r.last_checked_at,
last_error: r.verified_at ? null : r.last_error,
// The token is not a secret — it only means anything published in that domain's own DNS.
...domainVerify.instructions(r.domain, r.token),
}));
}
/*
* What an admin is told when discovery fails.
*
* The temptation is to hand back the underlying message, because it is genuinely the most useful
* thing for a real misconfiguration. But the issuer is caller-supplied and fetched server-side, so
* that message is an SSRF read primitive: `https://internal-host:8080 responded 403` and
* `discovery issuer mismatch: … document says <X>` both report on services the caller cannot reach
* directly. The jwks branch of the /test endpoint was already genericised for exactly this reason;
* these paths were not, which left the scanner intact one line above the comment saying not to.
*
* So: the shape of the failure, never the upstream's answer. The full message goes to the log.
*/
function discoveryErrorMessage(e, issuer) {
const raw = String((e && e.message) || '');
console.warn(`[org-sso] discovery failed for ${issuer}: ${raw}`);
if (/must use https|not publicly routable|not a URL/i.test(raw)) return raw; // our own guard, no upstream data
if (/issuer mismatch/i.test(raw)) return 'that URL is not the OpenID issuer it claims to be';
if (/is missing /i.test(raw)) return 'that issuer published an incomplete OpenID configuration';
if (/redirected/i.test(raw)) return 'that issuer redirected; the URL must be the final one';
if (/abort|timeout/i.test(raw)) return 'that issuer did not respond in time';
return 'no OpenID configuration could be read from that URL';
}
/*
* Wrap an async handler so a rejection is a 500 rather than a dead server. Express 4 does not await
* handlers and server.js turns an unhandled rejection into process.exit see the longer note on
* asyncRoute in routes/auth.js, which is the same guard for the same reason.
*/
function asyncRoute(handler) {
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch((err) => {
console.error(`[org-sso] unhandled error in ${req.method} ${req.path}:`, err && err.message);
// Wrapped for the same reason as in routes/auth.js: a throw while REPORTING an error would
// become an unhandled rejection and take the process down.
try {
if (!res.headersSent) res.status(500).json({ error: 'Something went wrong' });
} catch (e2) {
console.error('[org-sso] failed to report an error:', e2 && e2.message);
}
});
}
router.use(requireAuth, resolveTenancy);
// List an organization's providers.
router.get('/:orgId/sso', requireOrgAdmin, (req, res) => {
const rows = db.prepare('SELECT * FROM org_sso_providers WHERE organization_id = ? ORDER BY created_at').all(req.orgId);
res.json({ providers: rows.map(toPublic) });
});
router.post('/:orgId/sso', requireOrgAdmin, requireVerifiedAdmin, asyncRoute(async (req, res) => {
const { name, issuer, client_id: clientId, client_secret: clientSecret, scopes, email_domains: domains } = req.body || {};
if (!name || !issuer || !clientId) {
return res.status(400).json({ error: 'name, issuer and client_id are required' });
}
let cleanDomains;
try {
cleanDomains = normaliseDomains(domains);
assertDomainsFree(cleanDomains, req.orgId, null);
} catch (e) {
return res.status(e.status || 400).json({ error: e.message });
}
/*
* The issuer is checked against the live provider BEFORE anything is stored. A typo here would
* otherwise be discovered by a user staring at a failed login, and the error they would see says
* nothing useful. Discovery also proves the URL is an OIDC issuer at all rather than a company
* home page someone pasted.
*/
try {
await oidc.discover(String(issuer).trim().replace(/\/+$/, ''));
} catch (e) {
return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${discoveryErrorMessage(e, issuer)}` });
}
const id = crypto.randomUUID();
const slug = newSlug();
let newlyClaimed = [];
/*
* Re-check the domains INSIDE the transaction. The first check happened before `await
* oidc.discover()`, which yields the event loop for a network round trip the caller's own IdP
* controls the length of two admins racing that window both passed and both got the domain,
* after which routing became whichever row the scan reached first.
*/
try {
db.transaction(() => {
assertDomainsFree(cleanDomains, req.orgId, null);
db.prepare(`
INSERT INTO org_sso_providers (id, organization_id, slug, name, issuer, client_id, client_secret_enc, scopes, email_domains, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`).run(id, req.orgId, slug, String(name).trim(), String(issuer).trim().replace(/\/+$/, ''), String(clientId).trim(),
clientSecret ? secretbox.encrypt(String(clientSecret)) : null,
String(scopes || 'openid email profile').trim(), cleanDomains);
newlyClaimed = syncDomains(id, req.orgId, cleanDomains);
})();
} catch (e) {
if (e.status) return res.status(e.status).json({ error: e.message });
console.error('[org-sso] create failed:', e.message);
return res.status(500).json({ error: 'Could not save that provider' });
}
// Notified after the transaction commits, so an operator is never told about a claim that rolled
// back — and never inside it, where a slow mail path would hold a write lock.
notifyOperatorOfClaim(req, { domains: newlyClaimed, orgId: req.orgId, providerName: String(name).trim() });
// (userId, action, details, deviceId, ipAddress, workspaceId) — the org id is NOT the 4th arg.
logActivity(req.user.id, 'org_sso_created', `${name} (${slug}) org=${req.orgId}`, null, getClientIp(req));
res.status(201).json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(id)));
}));
router.put('/:orgId/sso/:id', requireOrgAdmin, requireVerifiedAdmin, asyncRoute(async (req, res) => {
const existing = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId);
if (!existing) return res.status(404).json({ error: 'Not found' });
const { name, issuer, client_id: clientId, client_secret: clientSecret, scopes, email_domains: domains, enabled } = req.body || {};
let cleanDomains = existing.email_domains;
// `!== undefined` let a null through, and null took the destructive branch: normaliseDomains(null)
// is '', which deleted every claimed domain and every DNS proof with it. A client that sends the
// field as null on an unrelated save must not log a customer's whole company out.
const domainsSupplied = domains !== undefined && domains !== null;
if (domainsSupplied) {
try {
cleanDomains = normaliseDomains(domains);
assertDomainsFree(cleanDomains, req.orgId, existing.id);
} catch (e) {
return res.status(e.status || 400).json({ error: e.message });
}
}
const nextIssuer = issuer !== undefined ? String(issuer).trim().replace(/\/+$/, '') : existing.issuer;
if (nextIssuer !== existing.issuer) {
try { await oidc.discover(nextIssuer); }
catch (e) { return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${discoveryErrorMessage(e, nextIssuer)}` }); }
}
/*
* An absent client_secret LEAVES THE STORED ONE ALONE; an empty string clears it. The API never
* returns the secret, so a UI that round-trips a form would otherwise blank it on every save
* the classic way a settings page silently breaks the thing it is editing.
*/
// Disabling this provider, or removing the domains it enforces through, is the same act as
// turning the requirement off — and that needs the operator.
try {
const willBeEnabled = enabled === undefined ? !!existing.enabled : !!enabled;
/*
* What this provider still covers afterwards: nothing if it is being disabled, otherwise the
* domains it keeps that are ALREADY verified. A domain typed back in arrives unverified and
* enforces nobody, which is precisely how the swap bypass worked.
*/
let keeps = [];
if (willBeEnabled) {
const kept = domainsSupplied ? new Set(cleanDomains.split(',').filter(Boolean)) : null;
keeps = db.prepare("SELECT domain FROM org_sso_domains WHERE provider_id = ? AND verified_at IS NOT NULL")
.all(existing.id).map((r) => r.domain)
.filter((d) => (kept ? kept.has(d) : true));
}
const what = !willBeEnabled ? 'disabling this provider' : 'changing its sign-in domains';
assertEnforcementNotReduced(req.orgId, existing.id, keeps, what);
} catch (e) {
return res.status(e.status || 400).json({ error: e.message, code: e.code });
}
let newlyClaimed = [];
const secretEnc = clientSecret === undefined ? existing.client_secret_enc
: (clientSecret === '' ? null : secretbox.encrypt(String(clientSecret)));
/*
* Same transaction, same re-check, and for the same reason as the create path above this one was
* missed when that was fixed, which left the race fully open on the route an attacker would
* actually pick: `await oidc.discover()` on a CHANGED issuer is a round trip whose length the
* caller's own IdP decides, so it can be held open for the full fetch timeout while a victim
* organization claims the domain legitimately. The UNIQUE constraint on `domain` is the hard
* backstop now; this keeps the failure a clean 409 rather than a constraint error.
*/
try {
db.transaction(() => {
if (domainsSupplied) assertDomainsFree(cleanDomains, req.orgId, existing.id);
db.prepare(`
UPDATE org_sso_providers
SET name = ?, issuer = ?, client_id = ?, client_secret_enc = ?, scopes = ?, email_domains = ?, enabled = ?,
updated_at = strftime('%s','now')
WHERE id = ?
`).run(
name !== undefined ? String(name).trim() : existing.name,
nextIssuer,
clientId !== undefined ? String(clientId).trim() : existing.client_id,
secretEnc,
scopes !== undefined ? String(scopes).trim() : existing.scopes,
cleanDomains,
enabled === undefined ? existing.enabled : (enabled ? 1 : 0),
existing.id,
);
if (domainsSupplied) newlyClaimed = syncDomains(existing.id, req.orgId, cleanDomains);
})();
} catch (e) {
// A thrown assertDomainsFree carries its own status; anything else is ours and stays generic
// rather than returning a raw SQLite message to the caller.
if (e.status) return res.status(e.status).json({ error: e.message });
console.error('[org-sso] update failed:', e.message);
return res.status(500).json({ error: 'Could not save that provider' });
}
notifyOperatorOfClaim(req, { domains: newlyClaimed, orgId: req.orgId, providerName: existing.name });
logActivity(req.user.id, 'org_sso_updated', `${existing.name} (${existing.slug}) org=${req.orgId}`, null, getClientIp(req));
res.json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(existing.id)));
}));
/*
* Check a provider without making anyone log in.
*
* The overwhelmingly common failure is a configuration one an issuer that is a company home page
* rather than an OIDC issuer, a provider that is unreachable from the server, a JWKS with no signing
* keys and every one of those currently surfaces as a user staring at a failed login with an
* error that says nothing useful. This turns that into an answer at configuration time.
*
* It is deliberately honest about its limits. Discovery and JWKS prove the provider EXISTS and
* that we could verify a token it signed. They cannot prove the client id is right, that the secret
* matches, or that the redirect URI is registered only a real authorization round trip does that,
* and the response says so rather than implying a green tick means "SSO works".
*/
router.post('/:orgId/sso/:id/test', requireOrgAdmin, requireVerifiedAdmin, asyncRoute(async (req, res) => {
const row = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId);
if (!row) return res.status(404).json({ error: 'Not found' });
const checks = [];
let doc = null;
try {
doc = await oidc.discover(row.issuer);
checks.push({ name: 'discovery', ok: true, detail: doc.issuer });
} catch (e) {
checks.push({ name: 'discovery', ok: false, detail: discoveryErrorMessage(e, row.issuer) });
return res.json({ ok: false, checks });
}
checks.push({
name: 'endpoints',
ok: !!(doc.authorization_endpoint && doc.token_endpoint),
detail: doc.authorization_endpoint || 'missing authorization_endpoint',
});
try {
const jwks = await oidc.fetchJwks(doc.jwks_uri);
const signing = (jwks.keys || []).filter((k) => !k.use || k.use === 'sig');
checks.push({
name: 'signing_keys',
ok: signing.length > 0,
detail: signing.length ? `${signing.length} key(s)` : 'the provider published no signing keys',
});
} catch (e) {
// Deliberately generic. `jwks_uri` comes from the CALLER'S OWN discovery document, so echoing
// the upstream status here turned this endpoint into a readable internal port scanner.
checks.push({ name: 'signing_keys', ok: false, detail: 'could not read the provider keys' });
}
// What the admin must have registered at the provider — the single most common thing to get
// wrong, and something we can state exactly rather than ask them to guess.
const origin = (process.env.APP_URL || '').trim().replace(/\/+$/, '') || `${req.protocol}://${req.get('host')}`;
res.json({
ok: checks.every((c) => c.ok),
checks,
redirect_uri: `${origin}/api/auth/oidc/${row.slug}/callback`,
// Said plainly so a passing test is not mistaken for a working login.
note: 'unverifiable_by_test',
});
}));
/*
* Check DNS for the proof, and record the answer.
*
* Verification is the whole point of the domain table: until this succeeds the domain routes nobody
* and the login callback refuses to accept an assertion for it, so a claim on a domain the tenant
* does not control buys them nothing at all.
*
* Deliberately pull-based rather than a background sweep. The admin has just edited DNS and wants to
* know now, and a per-request check means there is no scheduler to fall over quietly and no window
* where a verified domain sits unnoticed.
*/
router.post('/:orgId/sso/:id/domains/:domain/verify', requireOrgAdmin, requireVerifiedAdmin, asyncRoute(async (req, res) => {
const provider = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?')
.get(req.params.id, req.orgId);
if (!provider) return res.status(404).json({ error: 'Not found' });
const row = db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ? AND domain = ?')
.get(provider.id, String(req.params.domain).toLowerCase());
if (!row) return res.status(404).json({ error: 'Not found' });
if (row.verified_at) return res.json({ ok: true, domain: row.domain, verified: true, already: true });
/*
* A lapsed claim is RELEASED, not reissued.
*
* Reissuing here renewed the clock, so pressing Verify once per window held a domain forever
* the exact squatting the time limit exists to stop, performed through the endpoint meant to
* enforce it. Releasing it frees the domain for anyone else, and re-adding it is an ordinary new
* claim: new token, and the operator is notified again.
*/
if (domainVerify.isClaimExpired(row)) {
db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(row.id);
logActivity(req.user.id, 'org_sso_domain_lapsed', `${row.domain} org=${req.orgId}`, null, getClientIp(req));
return res.status(409).json({
ok: false,
domain: row.domain,
verified: false,
expired: true,
error: 'That claim expired and has been released. Add the domain again to get a new record.',
});
}
const result = await domainVerify.check(row.domain, row.token);
if (result.ok) {
db.prepare("UPDATE org_sso_domains SET verified_at = strftime('%s','now'), last_checked_at = strftime('%s','now'), last_error = NULL WHERE id = ?")
.run(row.id);
logActivity(req.user.id, 'org_sso_domain_verified', `${row.domain} via ${result.via} org=${req.orgId}`, null, getClientIp(req));
console.log(`[org-sso] ${row.domain} verified via ${result.via} for org ${req.orgId}`);
return res.json({ ok: true, domain: row.domain, verified: true, via: result.via });
}
db.prepare("UPDATE org_sso_domains SET last_checked_at = strftime('%s','now'), last_error = ? WHERE id = ?")
.run(result.error, row.id);
res.status(400).json({
ok: false,
domain: row.domain,
verified: false,
error: result.error,
...domainVerify.instructions(row.domain, row.token),
});
}));
/*
* SSO-only: requiring the organization's identity provider.
*/
/** Only a VERIFIED domain can compel anyone — see the note on ssoOnlyForEmail. */
function verifiedDomainCount(orgId) {
return db.prepare(`
SELECT COUNT(*) AS n FROM org_sso_domains d
JOIN org_sso_providers p ON p.id = d.provider_id
WHERE d.organization_id = ? AND d.verified_at IS NOT NULL AND p.enabled = 1
`).get(orgId).n;
}
router.get('/:orgId/sso-only', requireOrgAdmin, (req, res) => {
const org = db.prepare('SELECT sso_only FROM organizations WHERE id = ?').get(req.orgId);
const pending = db.prepare(
"SELECT id, requested_by, reason, created_at FROM org_sso_only_requests WHERE organization_id = ? AND status = 'pending' ORDER BY created_at DESC"
).get(req.orgId);
res.json({
sso_only: !!(org && org.sso_only),
verified_domains: verifiedDomainCount(req.orgId),
pending_removal_request: pending || null,
});
});
/*
* Turn it ON. An org admin does this alone: it can only ever reduce the ways into their own tenant,
* and the people affected are their own.
*/
router.post('/:orgId/sso-only', requireOrgAdmin, requireVerifiedAdmin, (req, res) => {
/*
* Refuse when nothing is proved. Otherwise an organization could switch off password login for
* accounts it cannot offer any other way in for locking its own people out of a product they
* can then only reach by asking the operator to undo it.
*/
if (!verifiedDomainCount(req.orgId)) {
return res.status(400).json({
error: 'Verify at least one sign-in domain before requiring single sign-on — otherwise nobody could sign in.',
code: 'no_verified_domain',
});
}
/*
* Do not let the person pressing this button lock themselves out.
*
* The commonest onboarding shape is: sign up with a personal or consultancy address, create the
* organization, verify the company domain, turn this on. Enforcement then covers them (they are
* a member) while their own address is outside the verified domains so passwords are refused
* AND their org's provider will not assert for them either, because assertions are confined to
* verified domains. There is no self-service way back: no route removes a membership, and
* password reset succeeds but login still refuses. A review walked into it on the happy path.
*
* They are told exactly which address is the problem, and what to do about it.
*/
const domains = enforcingDomains(req.orgId);
const at = String(req.user.email || '').lastIndexOf('@');
const ownDomain = at === -1 ? '' : String(req.user.email).slice(at + 1).toLowerCase().replace(/\.+$/, '');
if (!domains.includes(ownDomain)) {
return res.status(400).json({
error: `Your own address (${req.user.email}) is not at a verified domain (${domains.join(', ')}), `
+ 'so requiring single sign-on would lock you out with no way back. Verify that domain first, '
+ 'or hand ownership to someone whose address is covered.',
code: 'would_lock_out_actor',
});
}
/*
* Everyone ELSE in the same position is reported rather than refused they may be exactly the
* contractors this is meant to shut out. But the admin must find out here, not from a support
* ticket after the fact.
*/
const stranded = db.prepare(`
SELECT DISTINCT u.email FROM users u
WHERE u.id IN (
SELECT m.user_id FROM organization_members m WHERE m.organization_id = ?
UNION
SELECT wm.user_id FROM workspace_members wm JOIN workspaces w ON w.id = wm.workspace_id
WHERE w.organization_id = ?
)
`).all(req.orgId, req.orgId)
.map((r) => r.email)
.filter((e) => {
const i = String(e).lastIndexOf('@');
return i === -1 || !domains.includes(String(e).slice(i + 1).toLowerCase().replace(/\.+$/, ''));
});
db.prepare('UPDATE organizations SET sso_only = 1 WHERE id = ?').run(req.orgId);
logActivity(req.user.id, 'org_sso_only_enabled',
`org=${req.orgId} stranded=${stranded.length}`, null, getClientIp(req));
console.log(`[org-sso] SSO-only ENABLED for org ${req.orgId} by ${req.user.email}`
+ (stranded.length ? `${stranded.length} member(s) outside the verified domains: ${stranded.join(', ')}` : ''));
res.json({ sso_only: true, stranded_members: stranded });
});
/*
* Turning it OFF is a REQUEST, not a switch.
*
* This is the direction that re-opens password login, so it is the direction an attacker who has
* taken an org admin would take, and it is also what a customer will demand at their worst moment
* identity provider down, nobody can work which is precisely when a self-service toggle gets
* flipped without thinking. A platform admin has to approve it.
*/
router.post('/:orgId/sso-only/removal-request', requireOrgAdmin, requireVerifiedAdmin, (req, res) => {
const org = db.prepare('SELECT sso_only, name FROM organizations WHERE id = ?').get(req.orgId);
if (!org || !org.sso_only) return res.status(400).json({ error: 'Single sign-on is not required for this organization' });
const existing = db.prepare("SELECT id FROM org_sso_only_requests WHERE organization_id = ? AND status = 'pending'").get(req.orgId);
if (existing) return res.status(409).json({ error: 'A removal request is already awaiting approval', request_id: existing.id });
const id = crypto.randomUUID();
const reason = String((req.body && req.body.reason) || '').slice(0, 500);
db.prepare('INSERT INTO org_sso_only_requests (id, organization_id, requested_by, reason) VALUES (?, ?, ?, ?)')
.run(id, req.orgId, req.user.id, reason);
notifyOperatorOfRemovalRequest(req, { id, orgId: req.orgId, orgName: org.name, reason });
logActivity(req.user.id, 'org_sso_only_removal_requested', `org=${req.orgId} id=${id}`, null, getClientIp(req));
res.status(202).json({ status: 'pending', request_id: id });
});
/** Withdrawing your own request needs nobody's approval — it only ever keeps SSO required. */
router.delete('/:orgId/sso-only/removal-request/:id', requireOrgAdmin, requireVerifiedAdmin, (req, res) => {
const row = db.prepare("SELECT * FROM org_sso_only_requests WHERE id = ? AND organization_id = ? AND status = 'pending'")
.get(req.params.id, req.orgId);
if (!row) return res.status(404).json({ error: 'Not found' });
db.prepare("UPDATE org_sso_only_requests SET status = 'cancelled', decided_at = strftime('%s','now'), decided_by = ? WHERE id = ?")
.run(req.user.id, row.id);
res.json({ status: 'cancelled' });
});
/*
* The operator's side.
*
* Approval is an authenticated platform_admin action, NOT a link in an email: a token that acts on
* its own turns every forwarded or archived message into a way to re-open password login for a
* customer. The mail says what happened and where to go; the decision is made signed in.
*/
function requirePlatformAdmin(req, res, next) {
if (!req.user || req.user.role !== 'platform_admin') return res.status(404).json({ error: 'Not found' });
next();
}
router.get('/sso-only/removal-requests', requirePlatformAdmin, (req, res) => {
const rows = db.prepare(`
SELECT r.id, r.organization_id, r.reason, r.created_at, o.name AS organization_name, u.email AS requested_by_email
FROM org_sso_only_requests r
LEFT JOIN organizations o ON o.id = r.organization_id
LEFT JOIN users u ON u.id = r.requested_by
WHERE r.status = 'pending'
ORDER BY r.created_at
`).all();
res.json({ requests: rows });
});
router.post('/sso-only/removal-requests/:id/:decision', requirePlatformAdmin, (req, res) => {
const decision = req.params.decision === 'approve' ? 'approved'
: req.params.decision === 'reject' ? 'rejected' : null;
if (!decision) return res.status(400).json({ error: 'decision must be approve or reject' });
const row = db.prepare("SELECT * FROM org_sso_only_requests WHERE id = ? AND status = 'pending'").get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found' });
const note = String((req.body && req.body.note) || '').slice(0, 500);
db.transaction(() => {
db.prepare("UPDATE org_sso_only_requests SET status = ?, decided_by = ?, decided_at = strftime('%s','now'), decision_note = ? WHERE id = ?")
.run(decision, req.user.id, note, row.id);
// Only an approval changes anything. A rejection leaves SSO required, which is the safe state.
if (decision === 'approved') db.prepare('UPDATE organizations SET sso_only = 0 WHERE id = ?').run(row.organization_id);
})();
logActivity(req.user.id, `org_sso_only_${decision}`, `org=${row.organization_id} id=${row.id}`, null, getClientIp(req));
console.log(`[org-sso] SSO-only removal ${decision} for org ${row.organization_id} by ${req.user.email}`);
res.json({ status: decision, organization_id: row.organization_id });
});
router.delete('/:orgId/sso/:id', requireOrgAdmin, requireVerifiedAdmin, (req, res) => {
const existing = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId);
if (!existing) return res.status(404).json({ error: 'Not found' });
/*
* Take the domain rows and the accounts with it, in one transaction.
*
* Deleting only the provider left both behind, and both were unrecoverable in the product:
*
* - `domain` is globally UNIQUE and a VERIFIED row never expires, so an orphaned row blocked
* its own domain forever for this organization and for every other one while being
* invisible in the API and routing nobody. Re-claiming your own domain returned 409. The only
* way out was SQL.
* - the users this provider established kept pointing at a slug nothing answers to. They could
* not sign in (no provider), could not use a password (auth_provider is not 'local') and
* could not register (address taken).
*
* Both are handled here, at the moment the intent is known, rather than inferred later from the
* absence of configuration which is what made an unset GOOGLE_CLIENT_ID look like a deletion.
*/
try {
assertEnforcementNotReduced(req.orgId, existing.id, [], 'deleting this provider');
} catch (e) {
return res.status(e.status || 400).json({ error: e.message, code: e.code });
}
const freed = db.transaction(() => {
const domains = db.prepare('DELETE FROM org_sso_domains WHERE provider_id = ?').run(existing.id).changes;
// Back to a local account, so the owner can recover it by proving the mailbox — strictly
// stronger evidence than the identity-provider assertion that created it.
const users = db.prepare("UPDATE users SET auth_provider = 'local', provider_id = NULL WHERE auth_provider = ?")
.run(existing.slug).changes;
db.prepare('DELETE FROM org_sso_providers WHERE id = ?').run(existing.id);
return { domains, users };
})();
logActivity(req.user.id, 'org_sso_deleted',
`${existing.name} (${existing.slug}) org=${req.orgId} domains=${freed.domains} users_reset=${freed.users}`,
null, getClientIp(req));
console.log(`[org-sso] deleted ${existing.slug}: released ${freed.domains} domain(s), returned ${freed.users} account(s) to local`);
res.json({ success: true, domains_released: freed.domains, accounts_returned_to_local: freed.users });
});
module.exports = router;

View file

@ -14,7 +14,9 @@ const { sendEmail } = require('../services/email');
const NAME_MAX = 80;
const SLUG_MAX = 60;
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Markup characters are not legal here. The looser form admitted < > " ' and an admin-
// chosen email became stored XSS in the platform admin's user list.
const EMAIL_RE = /^[^\s@<>"'`\\;,()\[\]]+@[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/;
const WORKSPACE_ROLES = ['workspace_admin', 'workspace_editor', 'workspace_viewer'];
// Operational policy - env-configurable with conservative defaults. Restart

View file

@ -1,3 +1,14 @@
/*
* FIRST, before any dependency is required: make sure they are installed and loadable.
*
* A rollback restores an older package.json but not its packages, and a Node upgrade leaves the
* native database module compiled against the wrong ABI. Both present as a server that will not
* start, with an error naming a file rather than the action needed and the rollback case happens
* precisely when something else has already gone wrong. Repairing takes seconds; diagnosing at 2am
* does not. ST_SKIP_DEP_PREFLIGHT=1 turns it off.
*/
require('./lib/preflight-deps').preflight();
const express = require('express');
const http = require('http');
const https = require('https');
@ -173,6 +184,29 @@ app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), strip
// 12mb so AI-designed signs with embedded generated images (base64 data URLs)
// can be published. #41 follow-up: upload generated images to the content store
// and reference by URL instead of embedding, to keep widget configs small.
/*
* Collapse duplicate slashes in the PATH before anything routes on it.
*
* Express normalises the mount boundary for a router, so `/api/auth//login` still reaches the login
* handler but `app.use('/api/auth/login', rateLimit(...))` does NOT match it, so the limiter
* never runs. One extra slash therefore removed EVERY per-endpoint limit under /api/auth: unlimited
* password guesses (a review got a real session after 60 unthrottled attempts), unlimited TOTP
* codes, unlimited password-reset mail to any address, and the SSO discovery cap that exists to
* stop customer enumeration. It also made the per-account lockout a denial-of-service tool.
*
* Fixing it inside the limiter's key is not enough the middleware is never invoked. The path has
* to be one canonical thing before routing, which is what this does. Query and body are untouched.
*/
app.use((req, res, next) => {
const q = req.url.indexOf('?');
const path = q === -1 ? req.url : req.url.slice(0, q);
if (path.includes('//')) {
const collapsed = path.replace(/\/{2,}/g, '/');
req.url = q === -1 ? collapsed : collapsed + req.url.slice(q);
}
next();
});
app.use(express.json({ limit: '12mb' }));
const { sanitizeBody } = require('./middleware/sanitize');
app.use(sanitizeBody);
@ -522,6 +556,52 @@ app.use('/socket.io-client', express.static(
// safe because the callback runs at request time, which is a subtle thing to depend on.
const limiterTelemetry = require('./lib/limiter-telemetry');
const rateLimits = new Map();
/*
* The bucket key is the SHAPE of the endpoint, never the spelling the caller chose.
*
* Two failures drove this. Express routes non-strictly, so `/api/auth/login/` was a different key
* and bought a fresh ten password attempts. And `/api/organizations/<id>/...` carries three
* caller-chosen segments, so every request minted its own bucket 120 calls with unique ids gave
* zero 429s against the limit that exists to bound outbound OIDC discovery and live DNS lookups.
*
* Fold by EXPLICIT shape, not with a clever catch-all. A single regex that collapsed "anything
* else" put every unknown path in one bucket WITH the real endpoints, so flooding nonsense URLs
* exhausted the limit for `/sso-only` trading a bypass for a denial of service. Known shapes get
* their own keys; everything else shares one, separate from all of them.
*/
const LIMIT_PATH_SHAPES = [
[/^\/api\/auth\/oidc\/[^/]+\/(start|callback)$/, (m) => `/api/auth/oidc/:slug/${m[1]}`],
[/^\/api\/organizations\/sso-only\/removal-requests\/[^/]+\/[^/]+$/, () => '/api/organizations/sso-only/removal-requests/:id/:decision'],
[/^\/api\/organizations\/sso-only\/removal-requests$/, () => '/api/organizations/sso-only/removal-requests'],
[/^\/api\/organizations\/[^/]+\/sso-only\/removal-request\/[^/]+$/, () => '/api/organizations/:id/sso-only/removal-request/:id'],
[/^\/api\/organizations\/[^/]+\/sso-only\/removal-request$/, () => '/api/organizations/:id/sso-only/removal-request'],
[/^\/api\/organizations\/[^/]+\/sso-only$/, () => '/api/organizations/:id/sso-only'],
// The reset/target routes mint a bucket per TARGET without this, which is the same
// caller-chosen-segment defect, at the mount next door.
[/^\/api\/auth\/users\/[^/]+\/(.+)$/, (m) => `/api/auth/users/:id/${m[1]}`],
[/^\/api\/content\/[^/]+$/, () => '/api/content/:id'],
[/^\/api\/organizations\/[^/]+\/sso\/[^/]+\/domains\/[^/]+\/verify$/, () => '/api/organizations/:id/sso/:id/domains/:domain/verify'],
[/^\/api\/organizations\/[^/]+\/sso\/[^/]+\/test$/, () => '/api/organizations/:id/sso/:id/test'],
[/^\/api\/organizations\/[^/]+\/sso\/[^/]+$/, () => '/api/organizations/:id/sso/:id'],
[/^\/api\/organizations\/[^/]+\/sso$/, () => '/api/organizations/:id/sso'],
];
function canonicalLimitPath(rawPath) {
const p = rawPath
.replace(/\/{2,}/g, '/') // collapse doubled separators
.replace(/\/+$/, '') // a trailing slash is the same endpoint
.toLowerCase()
|| '/';
for (const [re, to] of LIMIT_PATH_SHAPES) {
const m = p.match(re);
if (m) return to(m);
}
// Unrecognised, but still under a mount whose ids are caller-chosen: one shared bucket, kept
// apart from every real endpoint so flooding it cannot starve them.
if (p.startsWith('/api/organizations/')) return '/api/organizations/:unmatched';
return p;
}
function rateLimit(windowMs, maxRequests) {
return (req, res, next) => {
// #100: key on the FULL path, not req.path. These limiters are mounted via
@ -529,7 +609,18 @@ function rateLimit(windowMs, maxRequests) {
// req.path was '/' for ALL of them - i.e. /login, /register, /totp/verify shared
// ONE per-IP counter (coupled limits; the /totp/verify brute-force limit wasn't
// actually independent). originalUrl keeps each endpoint's limit separate.
const key = getClientIp(req) + (req.originalUrl || req.url || req.path).split('?')[0];
/*
* NORMALISE THE PATH, or the key is caller-controlled and the limit is decorative.
*
* Express routes non-strictly, so `/api/auth/login/` reaches the same handler as
* `/api/auth/login` with a different originalUrl, hence a different bucket, hence a fresh ten
* attempts. A review walked straight past the login limiter that way. Any path segment the
* caller chooses does the same thing, and `/api/auth/oidc/:slug/...` has one by design, so the
* slug is folded out too: one bucket per IP per ENDPOINT, not per spelling of it.
*/
const rawPath = (req.originalUrl || req.url || req.path).split('?')[0];
const normalisedPath = canonicalLimitPath(rawPath);
const key = getClientIp(req) + normalisedPath;
const now = Date.now();
const windowStart = now - windowMs;
let hits = rateLimits.get(key) || [];
@ -573,6 +664,15 @@ app.use('/api/auth/register', rateLimit(60000, 5)); // 5 registrations per minut
app.use('/api/auth/totp/verify', rateLimit(60000, 10));
// Email-verification resend: cap so it can't be used to spray mail at an address.
app.use('/api/auth/resend-verification', rateLimit(60000, 5));
// Domain lookup is unauthenticated by necessity (it runs before login). Rate limited so it
// cannot be walked to enumerate which customers use SSO.
app.use('/api/auth/sso/discover', rateLimit(60000, 10));
// 10/min was wrong for this one: org SSO is used by companies behind a SINGLE corporate egress IP,
// and this is their only entry point, so the 11th employee of the morning met a raw JSON 429 with no
// login page. It is a redirect, not a credential check.
app.use('/api/auth/sso/start', rateLimit(60000, 120));
// The OIDC endpoints had no limit at all, which left the callback's parsing as a free amplifier.
app.use('/api/auth/oidc', rateLimit(60000, 120));
// Self-service password reset. The request endpoint is the spray surface (it sends mail to
// an address the caller supplies), so it gets the tighter cap; the redeem endpoint is a
// 32-byte-token guess, capped mostly to keep the bcrypt work bounded.
@ -583,6 +683,14 @@ app.use('/api/auth/reset-password', rateLimit(60000, 10));
// path prefix first, so this fires before /api/auth catches the request.
app.use('/api/auth/users', rateLimit(60000, 20));
app.use('/api/auth', require('./routes/auth'));
// Per-organization SSO configuration. Mounted under /api/organizations so the org id is the
// route's own subject, which is what the org_owner/org_admin check keys on.
/*
* Rate-limited because these routes do outbound work on caller-supplied input: OIDC discovery on a
* customer-chosen issuer, and a live DNS lookup per domain verification. Everything under
* /api/auth/* already had a limit; this router was mounted without one.
*/
app.use('/api/organizations', rateLimit(60000, 60), require('./routes/org-sso'));
// Rate limit pairing to prevent brute force (5 attempts per minute per IP).
// #88: bind this to the whole /api/provision surface, not just /pair - the bare
// POST /api/provision (routes/provisioning.js) is a second pairing endpoint that

View file

@ -72,7 +72,11 @@ db.exec(`
);
CREATE TABLE organizations (
id TEXT PRIMARY KEY, name TEXT NOT NULL,
owner_user_id TEXT, plan_id TEXT, subscription_status TEXT
owner_user_id TEXT, plan_id TEXT, subscription_status TEXT,
-- Mirrors the real schema. Login refuses when it cannot determine whether an organization
-- requires single sign-on, so a fixture missing this column fails closed correctly, but it
-- is the fixture that is wrong, not the guard.
sso_only INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View file

@ -0,0 +1,774 @@
'use strict';
/*
* The SSO that shipped before this verified nothing that mattered, and had no tests at all.
*
* Google's path asked `tokeninfo?access_token=` whether a token was valid and trusted the email in
* the answer; Microsoft's handed a bearer token to Graph /me and trusted that. Neither asked WHO
* THE TOKEN WAS ISSUED FOR. An access token is a bearer credential for a resource, minted for some
* application so any site a user signed into that requested `email` or `User.Read` could replay
* their token and be handed a session as them.
*
* These tests exist so that cannot come back. Every one of them describes an attack that the old
* code would have waved through, and they run against a REAL RSA keypair and a REAL JWKS document
* so the verifier is exercised the way a provider would exercise it not against a stub that
* agrees with us.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const jwt = require('jsonwebtoken');
const oidc = require('../lib/oidc');
const providers = require('../lib/oidc-providers');
// ---------------------------------------------------------------------------------------------
// A pretend identity provider: one keypair, one JWKS, one discovery document.
const ISSUER = 'https://idp.example.com';
const CLIENT_ID = 'screentinker-test-client';
const KID = 'test-key-1';
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const JWKS = { keys: [{ ...publicKey.export({ format: 'jwk' }), kid: KID, use: 'sig', alg: 'RS256' }] };
// A second keypair nobody should trust — the "signed by someone else" case.
const rogue = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
function discoveryDoc(issuer = ISSUER) {
return {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
};
}
/** Point global fetch at the pretend provider. Returns a restore function. */
function mockProvider({ doc = discoveryDoc(), jwks = JWKS } = {}) {
const real = global.fetch;
global.fetch = async (url) => {
const u = String(url);
if (u.endsWith('/.well-known/openid-configuration')) {
return { ok: true, status: 200, json: async () => doc };
}
if (u.endsWith('/jwks')) {
return { ok: true, status: 200, json: async () => jwks };
}
return { ok: false, status: 404, json: async () => ({}) };
};
oidc._resetCaches();
return () => { global.fetch = real; oidc._resetCaches(); };
}
const idToken = (claims = {}, { key = privateKey, alg = 'RS256', kid = KID } = {}) => jwt.sign(
{ iss: ISSUER, aud: CLIENT_ID, sub: 'user-123', email: 'a@example.com', nonce: 'NONCE', ...claims },
key, { algorithm: alg, keyid: kid, expiresIn: '5m' },
);
const verify = (token, over = {}) =>
oidc.verifyIdToken(token, { issuer: ISSUER, clientId: CLIENT_ID, nonce: 'NONCE', ...over });
// ---------------------------------------------------------------------------------------------
test('a well-formed token from the right provider verifies', async () => {
const restore = mockProvider();
try {
const claims = await verify(idToken());
assert.equal(claims.sub, 'user-123');
assert.equal(claims.email, 'a@example.com');
} finally { restore(); }
});
test('THE OLD BUG: a token minted for a DIFFERENT application is refused', async () => {
// This is the whole reason the previous implementation was unsafe. Same provider, same user,
// real signature — but issued to somebody else's client. It must not buy a session here.
const restore = mockProvider();
try {
await assert.rejects(() => verify(idToken({ aud: 'someone-elses-client' })), /audience/i);
} finally { restore(); }
});
test('...and neither is one that merely LISTS us alongside its real audience', async () => {
// aud can be an array. azp names who it was actually issued to, and if that is not us then we
// are a bystander in someone else's token — the confused-deputy case.
const restore = mockProvider();
try {
await assert.rejects(
() => verify(idToken({ aud: [CLIENT_ID, 'other'], azp: 'other' })),
/issued to a different application/i,
);
} finally { restore(); }
});
test('a token captured from an earlier login cannot be replayed', async () => {
// The nonce is minted per login and kept in a signed cookie. Without this check a correctly
// audienced token, obtained any way at all, would be reusable forever.
const restore = mockProvider();
try {
await assert.rejects(() => verify(idToken({ nonce: 'A-DIFFERENT-LOGIN' })), /nonce/i);
} finally { restore(); }
});
test('alg:none is refused', async () => {
const restore = mockProvider();
try {
// Hand-built, because jsonwebtoken will not sign 'none' for you.
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT', kid: KID })).toString('base64url');
const body = Buffer.from(JSON.stringify({
iss: ISSUER, aud: CLIENT_ID, sub: 'x', email: 'a@example.com', nonce: 'NONCE',
exp: Math.floor(Date.now() / 1000) + 300,
})).toString('base64url');
await assert.rejects(() => verify(`${header}.${body}.`), /algorithm/i);
} finally { restore(); }
});
test('an HMAC-signed token is refused even though the "key" is public', async () => {
// HS256 verifies with a shared secret. The only key we hold for a provider is its PUBLIC one,
// which the attacker also has — so accepting HMAC would let anyone sign their own identity.
const restore = mockProvider();
try {
const forged = jwt.sign(
{ iss: ISSUER, aud: CLIENT_ID, sub: 'x', email: 'admin@example.com', nonce: 'NONCE' },
publicKey.export({ type: 'spki', format: 'pem' }),
{ algorithm: 'HS256', keyid: KID, expiresIn: '5m' },
);
await assert.rejects(() => verify(forged), /algorithm/i);
} finally { restore(); }
});
test('a token signed by the wrong key is refused', async () => {
const restore = mockProvider();
try {
await assert.rejects(() => verify(idToken({}, { key: rogue.privateKey })), /signature/i);
} finally { restore(); }
});
test('an expired token is refused', async () => {
const restore = mockProvider();
try {
const stale = jwt.sign(
{ iss: ISSUER, aud: CLIENT_ID, sub: 'x', email: 'a@example.com', nonce: 'NONCE',
exp: Math.floor(Date.now() / 1000) - 3600 },
privateKey, { algorithm: 'RS256', keyid: KID },
);
await assert.rejects(() => verify(stale), /expired/i);
} finally { restore(); }
});
test('a provider whose discovery claims a different issuer is refused', async () => {
// Discovery is fetched from a URL derived from the configured issuer, so a document naming a
// DIFFERENT one is either broken or hostile. Either way its tokens must not be accepted under a
// name it does not own.
const restore = mockProvider({ doc: discoveryDoc('https://evil.example.com') });
try {
await assert.rejects(() => verify(idToken()), /issuer mismatch/i);
} finally { restore(); }
});
test('verification cannot be skipped by omitting the nonce', async () => {
// Belt and braces: the caller must always have a nonce to compare, so a coding mistake that
// forgets to pass one fails closed rather than accepting anything.
const restore = mockProvider();
try {
await assert.rejects(() => verify(idToken(), { nonce: undefined }), /nonce/i);
} finally { restore(); }
});
test('an unknown kid triggers exactly one JWKS refresh, then gives up', async () => {
// Key rotation is normal and must not fail every login until a cache expires; a token quoting
// nonsense must not become a way to hammer the provider either.
let jwksFetches = 0;
const real = global.fetch;
global.fetch = async (url) => {
const u = String(url);
if (u.endsWith('/.well-known/openid-configuration')) return { ok: true, status: 200, json: async () => discoveryDoc() };
if (u.endsWith('/jwks')) { jwksFetches++; return { ok: true, status: 200, json: async () => JWKS }; }
return { ok: false, status: 404, json: async () => ({}) };
};
oidc._resetCaches();
try {
await assert.rejects(() => verify(idToken({}, { kid: 'no-such-kid' })), /no signing key/i);
assert.equal(jwksFetches, 1, 'one refresh, not a loop');
} finally { global.fetch = real; oidc._resetCaches(); }
});
// ---------------------------------------------------------------------------------------------
// PKCE
test('PKCE uses S256 and never sends the verifier', () => {
const { verifier, challenge, method } = oidc.createPkce();
assert.equal(method, 'S256');
assert.notEqual(verifier, challenge, 'a plain challenge would make PKCE pointless');
const expected = crypto.createHash('sha256').update(verifier).digest('base64url');
assert.equal(challenge, expected);
assert.ok(verifier.length >= 43, 'RFC 7636 wants at least 43 characters of entropy');
});
test('every login gets fresh values', () => {
const a = oidc.createPkce(); const b = oidc.createPkce();
assert.notEqual(a.verifier, b.verifier);
assert.notEqual(oidc.randomToken(), oidc.randomToken());
});
// ---------------------------------------------------------------------------------------------
// The provider registry
test('Google registers from the variable the README always documented', () => {
const [g] = providers.list({ GOOGLE_CLIENT_ID: 'g' });
assert.equal(g.issuer, 'https://accounts.google.com');
});
test('a single-tenant Microsoft app narrows the issuer, so another tenant fails iss', () => {
const [ms] = providers.list({ MICROSOFT_CLIENT_ID: 'm', MICROSOFT_TENANT_ID: 'abc-123' });
assert.equal(ms.issuer, 'https://login.microsoftonline.com/abc-123/v2.0');
});
test('MULTI-TENANT MICROSOFT IS REFUSED, not silently broken', () => {
/*
* Two reasons pointing the same way. It cannot work: Microsoft's `common` metadata advertises the
* literal template `https://login.microsoftonline.com/{tenantid}/v2.0`, so the issuer can never
* equal the configured URL and every login fails at /start anyway.
*
* And the obvious patch is dangerous: loosening the iss comparison accepts tokens from EVERY
* Azure tenant, which is nOAuth any tenant admin can set an arbitrary unverified `email` on
* their own user and be issued a session as that address here.
*/
for (const tenant of ['common', 'organizations', 'consumers', '']) {
assert.deepEqual(providers.list({ MICROSOFT_CLIENT_ID: 'm', MICROSOFT_TENANT_ID: tenant }), [],
`MICROSOFT_TENANT_ID=${tenant || '(unset)'} must not register a provider`);
}
});
test('any OIDC provider can be added by env', () => {
const list = providers.list({
OIDC_PROVIDERS: 'authentik',
OIDC_AUTHENTIK_ISSUER: 'https://id.example.com/application/o/st/',
OIDC_AUTHENTIK_CLIENT_ID: 'abc',
OIDC_AUTHENTIK_NAME: 'Company SSO',
});
assert.equal(list.length, 1);
assert.equal(list[0].slug, 'authentik');
assert.equal(list[0].name, 'Company SSO');
assert.equal(list[0].issuer, 'https://id.example.com/application/o/st', 'trailing slash normalised');
assert.equal(list[0].clientSecret, null, 'PKCE means a public client is fine');
});
test('an incomplete or malformed provider is ignored rather than crashing boot', () => {
assert.equal(providers.list({ OIDC_PROVIDERS: 'broken' }).length, 0, 'no issuer/client id');
assert.equal(providers.list({
OIDC_PROVIDERS: '../etc/passwd',
OIDC_ISSUER: 'https://x', OIDC_CLIENT_ID: 'y',
}).length, 0, 'a slug that is not URL-safe never becomes a route');
});
test('the browser is told slugs and names only — never a client id or secret', () => {
const pub = providers.publicList({
GOOGLE_CLIENT_ID: 'super-secret-id',
OIDC_PROVIDERS: 'okta', OIDC_OKTA_ISSUER: 'https://x.okta.com',
OIDC_OKTA_CLIENT_ID: 'id', OIDC_OKTA_CLIENT_SECRET: 'shh',
});
const serialised = JSON.stringify(pub);
assert.ok(!serialised.includes('super-secret-id'));
assert.ok(!serialised.includes('shh'));
assert.deepEqual(Object.keys(pub[0]).sort(), ['name', 'slug']);
});
// ---------------------------------------------------------------------------------------------
// Per-organization SSO.
//
// Instance providers belong to whoever runs the server; these belong to a CUSTOMER. Two properties
// matter more than the feature itself: one organization must not be able to capture another's
// logins, and the login page must not become a way to enumerate who the customers are.
const Database = require('better-sqlite3');
function orgDb() {
const d = new Database(':memory:');
d.exec(`
CREATE TABLE org_sso_providers (
id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL, issuer TEXT NOT NULL, client_id TEXT NOT NULL, client_secret_enc TEXT,
scopes TEXT NOT NULL DEFAULT 'openid email profile', email_domains TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0);
CREATE TABLE organizations (id TEXT PRIMARY KEY, name TEXT, sso_only INTEGER NOT NULL DEFAULT 0);
CREATE TABLE org_sso_domains (
id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, provider_id TEXT, domain TEXT NOT NULL UNIQUE,
token TEXT NOT NULL, token_issued_at INTEGER NOT NULL DEFAULT 0, verified_at INTEGER,
last_checked_at INTEGER, last_error TEXT, created_at INTEGER NOT NULL DEFAULT 0);
`);
return d;
}
/*
* `domains` are VERIFIED (DNS proof recorded); `pending` are claimed but unproven. The distinction
* is the whole point of the domain table, so the harness makes it impossible to write a test that
* blurs the two: a test that wants routing must say which state it is testing.
*/
function withOrgDb(rows, fn) {
const d = orgDb();
let n = 0;
for (const r of rows) {
d.prepare('INSERT OR IGNORE INTO organizations (id, name, sso_only) VALUES (?, ?, ?)')
.run(r.org, r.org, r.ssoOnly ? 1 : 0);
const typed = [...(r.domains || '').split(','), ...(r.pending || '').split(',')].filter(Boolean).join(',');
d.prepare(`INSERT INTO org_sso_providers (id, organization_id, slug, name, issuer, client_id, email_domains, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(r.id, r.org, r.slug, r.name, r.issuer || ISSUER, r.clientId || 'cid', typed, r.enabled === undefined ? 1 : r.enabled);
const addDomain = (dom, verifiedAt) => d.prepare(
`INSERT INTO org_sso_domains (id, organization_id, provider_id, domain, token, token_issued_at, verified_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(`dom${++n}`, r.org, r.id, dom, `tok${n}`, Math.floor(Date.now() / 1000), verifiedAt);
// Verified in claim order unless the test pins it, so "who proved it first" stays testable.
for (const dom of (r.domains || '').split(',').filter(Boolean)) addDomain(dom, (r.verifiedAt || 1000) + n);
for (const dom of (r.pending || '').split(',').filter(Boolean)) addDomain(dom, null);
}
// Swap the module's lazily-resolved connection for this in-memory one.
const real = require('../db/database');
const saved = real.db;
real.db = d;
delete require.cache[require.resolve('../lib/oidc-providers')];
const mod = require('../lib/oidc-providers');
try { return fn(mod); } finally {
real.db = saved;
delete require.cache[require.resolve('../lib/oidc-providers')];
}
}
test('an org provider is found by the email DOMAIN', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com,acme.co.uk' }], (m) => {
assert.equal(m.forEmail('someone@acme.com').name, 'Acme SSO');
assert.equal(m.forEmail('someone@ACME.CO.UK').name, 'Acme SSO', 'case-insensitive');
assert.equal(m.forEmail('someone@other.com'), null);
assert.equal(m.forEmail('not-an-email'), null);
});
});
test('a disabled provider stops answering for its domain', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.com', enabled: 0 }], (m) => {
assert.equal(m.forEmail('x@acme.com'), null);
assert.equal(m.getOrgProvider('orgaaa'), null, 'and cannot be started directly either');
});
});
test('ORG PROVIDERS ARE NEVER PUBLISHED to the whole internet', () => {
// The login page lists instance-wide providers only. Listing a customer's IdP would both offer it
// to people it does not belong to and leak the customer list.
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com' }], (m) => {
const pub = m.publicList({ GOOGLE_CLIENT_ID: 'g' });
assert.deepEqual(pub.map((p) => p.slug), ['google']);
assert.ok(!JSON.stringify(pub).includes('Acme'), 'no customer name anywhere in the public list');
});
});
test('an org provider is still resolvable by slug, so the shared login flow can run it', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com' }], (m) => {
const p = m.get('orgaaa', {});
assert.equal(p.name, 'Acme SSO');
assert.equal(p.organizationId, 'org-a', 'carries its org so the callback can grant membership');
assert.equal(p.source, 'org');
});
});
test('an instance provider wins a slug clash with an org one', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'google', name: 'Impostor', domains: 'evil.com' }], (m) => {
// Org slugs are randomly generated so this cannot happen by accident — but if it ever did, a
// tenant must not be able to shadow the platform's own Google button.
assert.equal(m.get('google', { GOOGLE_CLIENT_ID: 'real' }).name, 'Google');
});
});
test('THE SHIPPED SCHEMA makes one domain, one organization a constraint', () => {
/*
* Uniqueness was enforced only by a check in the route, which a race defeated twice in review.
* It is now a UNIQUE constraint, so a second claim cannot exist even if the check is bypassed.
*
* Read from server/db/database.js, NOT from the test harness. The earlier version of this
* test asserted against the harness's own CREATE TABLE and therefore stayed green when UNIQUE was
* removed from the shipped schema it tested a copy of the thing it was named after.
*/
const schema = fs.readFileSync(require.resolve('../db/database.js'), 'utf8');
const table = schema.slice(schema.indexOf('CREATE TABLE IF NOT EXISTS org_sso_domains'));
const body = table.slice(0, table.indexOf('`,'));
assert.match(body, /domain\s+TEXT\s+NOT NULL\s+UNIQUE/, 'org_sso_domains.domain must be UNIQUE');
// And the row must not outlive its provider: a verified row never expires, so an orphan would
// block its domain for every organization, forever, while being invisible in the API.
assert.match(body, /FOREIGN KEY \(provider_id\) REFERENCES org_sso_providers\(id\) ON DELETE CASCADE/,
'a domain row must be removed with its provider');
});
test('no database means no org providers, and no crash', () => {
// The env-only paths must keep working on an instance where the table has not been migrated yet.
const m = require('../lib/oidc-providers');
assert.doesNotThrow(() => m.publicList({ GOOGLE_CLIENT_ID: 'g' }));
});
// ---------------------------------------------------------------------------------------------
// Regressions for defects found in security review. Each one was demonstrated end to end against a
// running server before it was fixed; none of them was hypothetical.
test('TAKEOVER: an org provider may not assert an email outside its own domains', () => {
/*
* The worst defect in this feature. An org admin supplies the issuer and client id, so they
* control the IdP completely and can mint a token asserting ANY email with email_verified:true
* including a platform_admin's. Every cryptographic check passes honestly, because the attacker
* IS the issuer. Three reviewers demonstrated a full session as the victim independently.
*
* The confinement lives in the callback; this pins the data it depends on, so a provider loaded
* from the database always carries the domains its assertions are checked against.
*/
withOrgDb([{ id: '1', org: 'org-evil', slug: 'orgevil', name: 'Evil', domains: 'evil.test' }], (m) => {
const p = m.getOrgProvider('orgevil');
assert.equal(p.emailDomains, 'evil.test', 'the callback cannot confine what it cannot see');
assert.ok(!p.emailDomains.includes('victim'), 'and only ever the domains that were PROVED');
assert.equal(p.organizationId, 'org-evil', 'and must know this is a tenant provider, not the operator\'s');
});
});
test('an INSTANCE provider carries no organization, so it is not domain-confined', () => {
// Operator-chosen providers keep the trust they have always had; confinement targets tenants.
const [g] = providers.list({ GOOGLE_CLIENT_ID: 'g' });
assert.equal(g.organizationId, undefined);
assert.equal(g.source, 'env');
});
test('a domain routes to exactly the provider that verified it', () => {
/*
* forEmail used an unordered SELECT over a comma column, so deleting and re-adding a provider
* silently flipped which IdP a whole domain routed to.
*
* Two earlier versions of this test were hollow: one asserted only that two calls agreed with
* each other (an unordered scan satisfies that within a process), the next used two DIFFERENT
* domains so no ordering was exercised at all. The property that actually matters is that a
* domain reaches its OWN provider and never a sibling's, so assert that.
*/
withOrgDb([
{ id: 'a', org: 'org-a', slug: 'orgaaa', name: 'Alpha', domains: 'alpha.test' },
{ id: 'b', org: 'org-b', slug: 'orgbbb', name: 'Beta', domains: 'beta.test', pending: 'gamma.test' },
], (m) => {
assert.equal(m.forEmail('x@alpha.test').name, 'Alpha');
assert.equal(m.forEmail('x@beta.test').name, 'Beta');
assert.equal(m.forEmail('x@gamma.test'), null, 'unverified, so it belongs to nobody');
// Beta must not inherit Alpha's domain through a join or an ordering accident.
assert.equal(m.getOrgProvider('orgbbb').emailDomains, 'beta.test');
assert.equal(m.getOrgProvider('orgaaa').emailDomains, 'alpha.test');
});
});
test('AN UNVERIFIED DOMAIN ROUTES NOBODY', () => {
/*
* The point of DNS verification. A tenant may type any domain including a company they have
* nothing to do with and until a record proves control it must buy them nothing: no routing,
* and (see the callback tests) no ability to assert an address inside it.
*/
withOrgDb([{ id: '1', org: 'org-x', slug: 'orgxxx', name: 'Squatter', pending: 'victim-corp.test' }], (m) => {
assert.equal(m.forEmail('ceo@victim-corp.test'), null, 'a claim is not a proof');
const p = m.getOrgProvider('orgxxx');
assert.equal(p.emailDomains, '', 'and the callback is given nothing it may confine to');
});
});
test('verifying one domain does not carry over to the others claimed with it', () => {
withOrgDb([{
id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme',
domains: 'acme.test', pending: 'acme-partner.test',
}], (m) => {
assert.equal(m.forEmail('x@acme.test').name, 'Acme');
assert.equal(m.forEmail('x@acme-partner.test'), null);
assert.equal(m.getOrgProvider('orgaaa').emailDomains, 'acme.test');
});
});
test('a secret that cannot be decrypted fails CLOSED', () => {
// decrypt() returns null after a JWT_SECRET rotation, which silently downgraded a confidential
// client to a public one — the login then failed at the provider with an error nobody could act
// on, while the admin screen still said "a secret is set".
withOrgDb([{ id: '1', org: 'o', slug: 'orgsec', name: 'X', domains: 'x.test' }], (m) => {
const real = require('../db/database');
real.db.prepare('UPDATE org_sso_providers SET client_secret_enc = ? WHERE id = ?').run('not-decryptable', '1');
assert.throws(() => m.getOrgProvider('orgsec'), /could not be decrypted/);
});
});
test('a tenant cannot claim a public email provider as its sign-in domain', () => {
/*
* Demonstrated in review: a tenant claimed gmail.com, after which /sso/discover answered
* {"sso":true} for every Gmail address and the login page offered "sign in with your
* organization" a phishing hop launched from the vendor's own login screen, pointed at
* infrastructure the tenant controls. First-claim-wins also meant one cheap account could deny a
* public domain to everyone else.
*/
const { isPublicEmailDomain } = require('../lib/public-email-domains');
for (const d of ['gmail.com', 'outlook.com', 'hotmail.co.uk', 'yahoo.com', 'icloud.com',
'proton.me', 'qq.com', 'mail.ru', 'comcast.net', 'gmx.de']) {
assert.ok(isPublicEmailDomain(d), `${d} must be refused as an org sign-in domain`);
}
// ...and a real company domain is still fine, or the feature would be pointless.
for (const d of ['acme.com', 'bigcorp.io', 'my-company.co.uk', 'mail.acme.com']) {
assert.equal(isPublicEmailDomain(d), false, `${d} must remain claimable`);
}
});
test('the blocklist is case- and whitespace-insensitive', () => {
// Domains arrive from a form. ` GMAIL.COM ` must not slip through a lowercase-only comparison.
const { isPublicEmailDomain } = require('../lib/public-email-domains');
assert.ok(isPublicEmailDomain(' GMAIL.COM '));
assert.ok(isPublicEmailDomain('Outlook.Com'));
});
// ---------------------------------------------------------------------------------------------
// The confinement itself.
//
// Everything above tests the DATA the callback confines against. These test the DECISION, which is
// what actually stops the takeover — and each one below was checked by reverting the guard and
// confirming the test goes red. A security test that passes against the vulnerable code is worse
// than no test, because it is read as coverage.
const authRoutes = require('../routes/auth');
const { emailAllowedForProvider } = authRoutes;
const orgProvider = (domains) => ({ slug: 'orgabc', organizationId: 'org-a', emailDomains: domains });
test('CONFINEMENT: an org provider may only assert inside its verified domains', () => {
const p = orgProvider('acme.test');
assert.equal(emailAllowedForProvider(p, 'staff@acme.test'), true);
assert.equal(emailAllowedForProvider(p, 'victim@other.test'), false, 'THE TAKEOVER');
assert.equal(emailAllowedForProvider(p, 'admin@screentinker.com'), false);
});
test('CONFINEMENT: a provider with nothing verified may assert NOTHING', () => {
// The squatting case. A tenant types a domain, proves nothing, and must get nowhere — including
// for the domain they typed.
const p = orgProvider('');
assert.equal(emailAllowedForProvider(p, 'ceo@victim-corp.test'), false);
assert.equal(emailAllowedForProvider(p, 'anyone@anywhere.test'), false);
});
test('CONFINEMENT: the domain cannot be smuggled past the check', () => {
const p = orgProvider('acme.test');
for (const evil of [
'victim@other.test', // plainly outside
'victim@acme.test.evil.test', // suffix, not the domain
'victim@evil.test@acme.test\n', // trailing newline
'victim@sub.acme.test', // subdomain is a different domain
'victim@acme.test.', // trailing dot
'victim@ACME.TEST.EVIL.TEST',
'no-at-sign',
'victim@',
'',
]) {
assert.equal(emailAllowedForProvider(p, evil), false, `must refuse: ${JSON.stringify(evil)}`);
}
// ...while the legitimate forms still work, including the ones case normalisation must handle.
assert.equal(emailAllowedForProvider(p, 'Staff@Acme.Test'), true);
assert.equal(emailAllowedForProvider(p, 'a.b+tag@acme.test'), true);
});
test('CONFINEMENT: an INSTANCE provider is exempt, because the operator chose it', () => {
// Per-org verification is for tenant-supplied providers only. The instance's own Google or Okta
// is the operator's decision and is not domain-restricted — the same trust it has always had.
const instance = { slug: 'google', emailDomains: '' };
assert.equal(emailAllowedForProvider(instance, 'anyone@anywhere.test'), true);
assert.equal(emailAllowedForProvider(instance, 'admin@gmail.com'), true);
});
// ---------------------------------------------------------------------------------------------
// Domain ownership.
//
// A claim is not a proof. These pin the part that makes that true: an unverified domain routes
// nobody, a claim lapses so it cannot be held forever, and a lapsed claim's token is dead so a
// record left behind from an earlier attempt cannot satisfy a later one.
const domainVerify = require('../lib/domain-verify');
const NOW = 1800000000;
test('an unverified claim lapses after 8 hours; a verified one never does', () => {
const claim = (agoS, verified) => ({ token_issued_at: NOW - agoS, verified_at: verified ? NOW - 99 : null });
assert.equal(domainVerify.isClaimExpired(claim(60, false), NOW), false, 'a minute old');
assert.equal(domainVerify.isClaimExpired(claim(8 * 3600 - 30, false), NOW), false, 'just inside');
assert.equal(domainVerify.isClaimExpired(claim(8 * 3600 + 1, false), NOW), true, 'just outside');
// Proof does not rot. Re-verifying on a timer would log a customer out over a DNS edit made
// months after they legitimately proved the domain.
assert.equal(domainVerify.isClaimExpired(claim(365 * 86400, true), NOW), false, 'verified, a year old');
});
test('the DNS record is per-domain and per-claim, so an old record proves nothing', () => {
const a = domainVerify.newToken();
const b = domainVerify.newToken();
assert.notEqual(a, b, 'two claims never share a token');
assert.ok(a.length >= 32, 'not guessable');
const one = domainVerify.instructions('acme.test', a);
const two = domainVerify.instructions('acme.test', b);
assert.equal(one.record_name, '_screentinker-verify.acme.test');
assert.notEqual(one.txt_value, two.txt_value, 'reissuing changes what must be published');
// TXT is the only accepted form: a CNAME alternative would need a wildcard zone this project
// does not operate, so offering one would document a check that could never pass.
assert.equal(one.cname_value, undefined, 'no CNAME form is advertised');
// The record lives at a dedicated name, never the apex, where it would sit beside SPF and DMARC.
assert.ok(!domainVerify.instructions('acme.test', a).record_name.startsWith('acme.test'));
});
test('a lapsed claim frees the domain for someone else', () => {
// The anti-squat property: a domain nobody can prove cannot be held indefinitely by whoever typed
// it first. Modelled here on the same predicate the route uses to decide whether a row blocks.
const squatter = { domain: 'victim-corp.test', token_issued_at: NOW - (9 * 3600), verified_at: null };
const owner = { domain: 'victim-corp.test', token_issued_at: NOW - 60, verified_at: NOW };
assert.equal(domainVerify.isClaimExpired(squatter, NOW), true, 'the squatter no longer blocks it');
assert.equal(domainVerify.isClaimExpired(owner, NOW), false, 'the real owner, having proved it, does');
});
/*
* The proof name must not be delegated.
*
* A TXT lookup follows CNAMEs transparently, and RFC 4592 means a wildcard `*.victim.com`
* synthesizes `_screentinker-verify.victim.com` as well. So a wildcard CNAME pointing at anything
* the attacker controls lets them publish the token in THEIR zone and prove a domain they do not
* own turning an ordinary subdomain takeover into the whole company's sign-in. A review did
* exactly this against a real authoritative zone.
*
* The resolver is stubbed rather than mocked at the network layer: `dns.promises` is a singleton,
* so replacing the two methods is enough and the real check() runs unmodified.
*/
const dnsPromises = require('node:dns').promises;
function withStubbedDns({ cname, txt }, fn) {
const realCname = dnsPromises.resolveCname;
const realTxt = dnsPromises.resolveTxt;
const nx = () => { const e = new Error('queryTxt ENOTFOUND'); e.code = 'ENOTFOUND'; throw e; };
dnsPromises.resolveCname = async () => (cname ? cname : nx());
dnsPromises.resolveTxt = async () => (txt ? txt : nx());
return Promise.resolve(fn()).finally(() => {
dnsPromises.resolveCname = realCname;
dnsPromises.resolveTxt = realTxt;
});
}
test('DELEGATION: a CNAME at the proof name is refused, even when the TXT matches', async () => {
const token = 'deadbeefdeadbeefdeadbeefdeadbeef';
// The attacker owns takeover.attacker.test and publishes a perfect token there; victim.test has
// a wildcard CNAME pointing at it. Without the refusal this returns ok:true.
const r = await withStubbedDns(
{ cname: ['takeover.attacker.test'], txt: [[`st-verify=${token}`]] },
() => domainVerify.check('victim.test', token),
);
assert.equal(r.ok, false, 'a delegated proof name must never verify');
assert.match(r.error, /CNAME/, 'and the admin is told exactly why');
});
test('an ordinary TXT proof in the domain\'s own zone still verifies', async () => {
const token = 'cafebabecafebabecafebabecafebabe';
const r = await withStubbedDns({ cname: null, txt: [[`st-verify=${token}`]] },
() => domainVerify.check('acme.test', token));
assert.equal(r.ok, true);
assert.equal(r.via, 'TXT');
});
test('a wildcard TXT answers with its own value, which is not a proof', async () => {
const r = await withStubbedDns({ cname: null, txt: [['v=spf1 -all']] },
() => domainVerify.check('victim.test', 'sometoken'));
assert.equal(r.ok, false);
assert.match(r.error, /does not match/);
});
test('a 255-byte-split TXT record is joined before comparing', async () => {
// resolveTxt returns one array of chunks per record; a long value arrives split.
const token = 'a'.repeat(32);
const full = `st-verify=${token}`;
const r = await withStubbedDns({ cname: null, txt: [[full.slice(0, 5), full.slice(5)]] },
() => domainVerify.check('acme.test', token));
assert.equal(r.ok, true, 'chunks of ONE record are concatenated');
});
test('chunks are never joined ACROSS records', async () => {
const token = 'b'.repeat(32);
const full = `st-verify=${token}`;
const r = await withStubbedDns({ cname: null, txt: [[full.slice(0, 5)], [full.slice(5)]] },
() => domainVerify.check('acme.test', token));
assert.equal(r.ok, false, 'two unrelated records must not add up to a proof');
});
test('SSRF: a trailing root dot is the same host, and does not slip the guard', () => {
// `https://localhost./` is a legal fully-qualified spelling that WHATWG URL preserves, so it
// matched neither `localhost` nor `*.localhost` and was allowed. The literal-IP forms were never
// affected — the parser normalises those itself.
for (const u of ['https://localhost./', 'https://LOCALHOST./', 'https://foo.localhost./']) {
assert.throws(() => oidc.assertFetchable(u), /not publicly routable/, u);
}
// and a real host that merely ends in a dot is still fine
for (const u of ['https://accounts.google.com./', 'https://fcm.googleapis.com./']) {
assert.doesNotThrow(() => oidc.assertFetchable(u), u);
}
});
test('a user object never leaves the server carrying a reset or verify hash', () => {
/*
* Two call sites each stripped three columns and stopped, so every login response also carried
* `password_reset_hash` and `email_verify_hash` live credentials for taking the account over.
* The sanitiser is one function now; this pins the list so the next column added to `users` has
* to be considered rather than shipped.
*/
const src = fs.readFileSync(require.resolve('../routes/auth.js'), 'utf8');
const block = src.slice(src.indexOf('const PRIVATE_USER_FIELDS'), src.indexOf('function publicUser'));
for (const field of ['password_hash', 'totp_secret_enc', 'totp_last_step',
'password_reset_hash', 'password_reset_expires', 'email_verify_hash', 'email_verify_expires']) {
assert.ok(block.includes(`'${field}'`), `${field} must never be serialised to a client`);
}
// And nothing may hand-roll the old partial strip again.
assert.ok(!/totp_last_step,\s*\.\.\.safeUser/.test(src), 'use publicUser(), not an inline destructure');
});
// ---------------------------------------------------------------------------------------------
// SSO-only: an organization requiring its own identity provider.
test('SSO-ONLY applies to a VERIFIED domain', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.test', ssoOnly: true }], (m) => {
const hit = m.ssoOnlyForEmail('staff@acme.test');
assert.ok(hit, 'password login must be refused for this address');
assert.equal(hit.organization_id, 'org-a');
assert.equal(m.ssoOnlyForEmail('staff@ACME.TEST').organization_id, 'org-a', 'case-insensitive');
assert.equal(m.ssoOnlyForEmail('someone@elsewhere.test'), null, 'and nobody else is affected');
});
});
test('SSO-ONLY CANNOT be imposed through a domain that was only claimed', () => {
/*
* The dangerous shape: switching off password login for a domain the tenant never proved would
* be a denial-of-service against a company they have nothing to do with every account at that
* address locked out of a product the squatter does not own.
*/
withOrgDb([{ id: '1', org: 'org-x', slug: 'orgxxx', name: 'Squatter', pending: 'victim-corp.test', ssoOnly: true }], (m) => {
assert.equal(m.ssoOnlyForEmail('ceo@victim-corp.test'), null, 'an unproved domain compels nobody');
});
});
test('SSO-ONLY stops applying when the provider is disabled', () => {
// Otherwise disabling a broken provider would leave its users with no way in at all: no SSO
// (disabled) and no password (still enforced).
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.test', enabled: 0, ssoOnly: true }], (m) => {
assert.equal(m.ssoOnlyForEmail('staff@acme.test'), null);
});
});
test('SSO-ONLY is off unless the organization turned it on', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.test' }], (m) => {
assert.equal(m.ssoOnlyForEmail('staff@acme.test'), null, 'having SSO is not the same as requiring it');
});
});
test('the login gate exempts platform_admin, and that exemption is deliberate', () => {
/*
* The operator approves turning SSO-only OFF. If the operator's own address sat at an SSO-only
* domain and that identity provider broke, nobody could sign in to approve anything and the
* instance would be bricked. Pinned as source because it is a security-relevant exemption that
* must not be "tidied away" by someone who reads it as a convenience.
*/
const src = fs.readFileSync(require.resolve('../routes/auth.js'), 'utf8');
assert.match(src, /user\.role !== 'platform_admin'[\s\S]{0,600}ssoOnlyForUser/,
'the break-glass exemption must guard the SSO-only check');
assert.match(src, /code: 'sso_required'/, 'and the refusal must be distinguishable from a bad password');
});

View file

@ -0,0 +1,83 @@
'use strict';
/*
* The boot-time dependency check.
*
* It exists for the moments nobody is at their best: a rollback that restores an older
* package.json but not its packages, and a Node upgrade that leaves the native database module
* compiled against the wrong ABI. Both present as "server will not start", with an error naming a
* file rather than the action needed.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const preflight = require('../lib/preflight-deps');
test('a healthy install reports nothing missing and nothing broken', () => {
assert.deepEqual(preflight.missingDeps(), [], 'this tree should be complete');
assert.equal(preflight.nativeModuleBroken(), null, 'and the native module should load');
});
test('THE NATIVE CHECK CONSTRUCTS A DATABASE, it does not merely require the module', () => {
/*
* better-sqlite3's entry point is plain JavaScript that loads the compiled binding lazily, so
* `require()` SUCCEEDS under a Node whose ABI the binary was never built for. The first version
* of this check stopped at require and therefore reported a genuinely broken install verified
* against a real Node 18 / Node 20 mismatch as healthy.
*
* Pinned as source because the failure is invisible: the check keeps passing, on every machine
* where nothing is wrong, right up until the one where something is.
*/
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'preflight-deps.js'), 'utf8');
const fn = src.slice(src.indexOf('function nativeModuleBroken'), src.indexOf('function run('));
assert.match(fn, /new Database\(':memory:'\)/,
'nativeModuleBroken must open a database, or an ABI mismatch goes undetected');
assert.ok(!/^\s*require\('better-sqlite3'\);\s*$/m.test(fn), 'a bare require is not a load test');
});
test('missingDeps agrees with what is actually on disk', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
const declared = Object.keys(pkg.dependencies || {});
assert.ok(declared.length > 0, 'the server declares dependencies');
const reported = preflight.missingDeps();
for (const name of declared) {
const present = fs.existsSync(path.join(__dirname, '..', 'node_modules', name, 'package.json'));
assert.equal(present, !reported.includes(name), `${name}: presence and report disagree`);
}
});
test('the preflight uses only Node builtins', () => {
/*
* It runs BEFORE dependencies are installed, so anything it imported could be the very thing
* that is missing and the failure would be the one it exists to prevent, with an extra layer
* of confusion on top.
*/
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'preflight-deps.js'), 'utf8');
const requires = [...src.matchAll(/require\('([^']+)'\)/g)].map((m) => m[1]);
const builtins = new Set(['fs', 'path', 'child_process', 'os', 'crypto', 'util']);
for (const r of requires) {
// better-sqlite3 is the thing being TESTED for loadability, not a dependency of this file.
if (r === 'better-sqlite3') continue;
assert.ok(builtins.has(r) || r.startsWith('.'), `preflight must not depend on ${r}`);
}
});
test('it can be turned off for an air-gapped host', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'preflight-deps.js'), 'utf8');
assert.match(src, /ST_SKIP_DEP_PREFLIGHT/, 'an operator who manages node_modules must be able to opt out');
const saved = process.env.ST_SKIP_DEP_PREFLIGHT;
process.env.ST_SKIP_DEP_PREFLIGHT = '1';
try { assert.doesNotThrow(() => preflight.preflight(), 'opting out must be a clean no-op'); }
finally { if (saved === undefined) delete process.env.ST_SKIP_DEP_PREFLIGHT; else process.env.ST_SKIP_DEP_PREFLIGHT = saved; }
});
test('server.js runs the preflight BEFORE requiring anything', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
const preflightAt = src.indexOf("require('./lib/preflight-deps')");
const firstDep = src.indexOf("require('express')");
assert.ok(preflightAt > -1, 'server.js must run the preflight');
assert.ok(preflightAt < firstDep, 'it must come before the first dependency, or it cannot help');
});