mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
The previous fix let the instance-wide Microsoft button work and left the customer-facing path broken, which is the worst way round. An organization that brings its own Entra tenant would publish the TXT record, watch its domain go green, and still be refused at login with `email_unverified` -- because Entra sends no such claim and rowToProvider pinned the assumption off for every org provider. Requiring a claim Microsoft does not emit is not a security control, it is an outage. What makes it safe to stop requiring it is the proof that already gates these providers: the callback confines an org provider to its DNS-verified domains, and an address only reaches the check after passing that. Whoever controls a domain's DNS controls its mail, which is the same trust that makes a verification link meaningful. So the assumption is DERIVED from proof -- `verified.length > 0` -- rather than pinned off. A provider that has verified nothing still assumes nothing, which is belt and braces: emailAllowedForProvider already refuses it, since an empty allow-list matches no domain, but deriving it here means a future reordering of those checks cannot silently widen it. It is never a column, and there is no column for it to be read from. An organization must not be able to switch this on for itself; it is a consequence of DNS proof, not a setting. A test asserts both -- that the value is derived next to `source: 'org'`, and that no `assume_email_verified` exists in the schema. Domain confinement is untouched. An explicit `email_verified: false` is still refused from anyone. Mutations all fail the tests: assuming unconditionally, never assuming, and reading it from the row.
472 lines
22 KiB
JavaScript
472 lines
22 KiB
JavaScript
'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,
|
|
// Escape hatch for an IdP that verifies addresses but does not say so in the token. Off unless
|
|
// the operator sets it, and it only ever covers an ABSENT claim — see emailIsVerified().
|
|
assumeEmailVerified: /^(1|true|yes)$/i.test((env[envKey(slug, 'ASSUME_EMAIL_VERIFIED')] || '').trim()),
|
|
source: 'env',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* May this provider's assertion of `email` be treated as verified?
|
|
*
|
|
* The login callback used to demand `claims.email_verified === true` outright. That is correct for a
|
|
* provider a CUSTOMER configured — such a provider is chosen by the party it vouches for, so
|
|
* anything it merely asserts is worth nothing — but it made Microsoft sign-in impossible, because
|
|
* **Entra ID v2 does not emit the claim at all**. Every Entra login authenticated successfully and
|
|
* was then refused with `email_unverified`.
|
|
*
|
|
* The distinction that resolves it: `users.email_verified` is OUR state and this is the IdP's claim.
|
|
* Whether an address is trustworthy is a decision about WHO WE TRUSTED, not a field we can insist a
|
|
* provider populate. An instance-wide provider was chosen by the operator — the same trust that
|
|
* already exempts it from domain confinement — and the Microsoft entry is additionally pinned to one
|
|
* tenant GUID, so only that directory can issue tokens for it.
|
|
*
|
|
* An organization's own provider may assume too, but only once it has DNS-verified a domain — the
|
|
* callback confines it to those domains, so it can only ever speak for names it proved it controls.
|
|
* Requiring the claim from it as well meant a customer's Entra tenant went green on domain
|
|
* verification and then failed the login anyway.
|
|
*
|
|
* Three limits keep this from becoming the hole the strict check was closing:
|
|
* - an EXPLICIT `email_verified: false` is always refused. Assuming only ever covers an omitted
|
|
* claim, never a provider actively saying the address is unverified;
|
|
* - for an org provider it is DERIVED from proof (`verified.length > 0`) and is never a column, so
|
|
* a customer cannot switch it on for themselves;
|
|
* - domain confinement is unchanged and still runs first, so an org provider that assumes still
|
|
* cannot assert an address outside a domain it has proven.
|
|
*/
|
|
function emailIsVerified(claims, provider) {
|
|
const asserted = (claims || {}).email_verified;
|
|
if (asserted === true) return true;
|
|
if (asserted === undefined || asserted === null) return !!(provider && provider.assumeEmailVerified);
|
|
return false; // explicit false, or anything else the provider chose to send
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
// Google DOES send email_verified. Nothing to assume, so it stays strict.
|
|
assumeEmailVerified: false,
|
|
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,
|
|
/*
|
|
* Entra ID v2 never sends email_verified, so demanding it refused every Microsoft login.
|
|
* Safe here specifically because this entry is operator-chosen AND pinned to one tenant
|
|
* GUID above: only that directory can issue a token whose `iss` matches. An explicit
|
|
* email_verified:false is still refused — see emailIsVerified().
|
|
*/
|
|
assumeEmailVerified: true,
|
|
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) {
|
|
// Resolved once: it decides both which addresses this provider may assert and, below, whether it
|
|
// has proven anything at all.
|
|
const verified = verifiedDomainsFor(row.id);
|
|
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,
|
|
/*
|
|
* DERIVED from proof, never read from a column.
|
|
*
|
|
* Entra ID v2 omits email_verified, so demanding it refused every customer who brought their own
|
|
* Microsoft tenant — the domain went green and the login still failed. Requiring a claim
|
|
* Microsoft does not send is not a security control, it is an outage.
|
|
*
|
|
* What makes it safe to stop requiring it is the proof that already gates this provider: the
|
|
* callback confines it to DNS-verified domains, and an address is only reached here after
|
|
* passing that. Whoever controls a domain's DNS controls its mail, which is the same trust that
|
|
* makes a verification link meaningful in the first place.
|
|
*
|
|
* So the assumption is tied to having proven SOMETHING. A provider with no verified domain
|
|
* assumes nothing — belt and braces, because emailAllowedForProvider already refuses it (an
|
|
* empty allow-list matches no domain), and this way a future refactor that reorders those checks
|
|
* cannot silently widen it.
|
|
*
|
|
* ⚠️ Still never a column. An org must not be able to switch this on for itself; it is a
|
|
* consequence of DNS proof, not a setting.
|
|
*/
|
|
assumeEmailVerified: verified.length > 0,
|
|
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: verified.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, emailIsVerified, DEFAULT_SCOPES, SLUG_RE,
|
|
};
|