Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/32960-sso-domain-bare-hosts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-auth': patch
---

fix(plugin-auth)!: an SSO provider's domain list takes bare host names only, and every rule that reads it reads each entry the same way

Clause-②: yes (narrowing) — narrowed: `POST /sso/register`, `POST /sso/update-provider` and the bridges that re-dispatch to them (`POST /admin/sso/register`, `POST /admin/sso/register-saml`, the edit bridge) now answer `400 VALIDATION_FAILED` on `domain` (`fields[0].code: 'invalid_value'`, the message naming the entry) for a `domain` value holding an entry that is not a bare host name (a URL, a port, a path, a user part, an escape, a dot run), where they stored it; and SCIM's adoption of an existing SSO account no longer reads an entry or an address that only folds into a host (full-width letters, for one) as a domain. Widened: SCIM adoption now reads an entry and an address in their one spelling, so an entry with a root dot (`acme.com.`) and the Unicode and punycode forms of an internationalised name (`bücher.de`, `xn--bcher-kva.de`) name the same domain, as the account rule already read them. New exports from the module `sso-account-at-proven-domain.ts`: `ssoDomainEntryHost`, `ssoDomainEntryHosts`, `ssoDomainEntryNotBare`.
Metadata-protocol: none
Skills-docs: none — no skill under `skills/**` teaches the SSO provider domain list; `content/docs/permissions/sso.mdx` gains the rule and how to correct a stored entry in this change.

<!-- adr-0087: not-required (no-migration-prescription) No schema key, accepted value or metadata type is added, removed, renamed or re-shaped: `sys_sso_provider.domain` keeps its type and length. The refusal is a runtime validation at the provider register and edit doors; stored rows are not rewritten, so there is nothing for a migration to rewrite and no tombstone. -->

**BREAKING** for an administrator who registers or edits an SSO provider with a domain entry written as a URL or with a port or path (`https://acme.com`, `acme.com:443`): it is refused with `400 VALIDATION_FAILED` naming the entry, and nothing is saved. Such an entry was stored before and then proved nothing at sign-in under SSO domain verification.

Unchanged: providers already stored (not re-checked or rewritten until a request writes their domain); an edit that writes no domain; the one-provider-per-domain claim rule, which still reads a stored entry of another provider as before.

What to do: write each domain as a bare host name, separated by commas (`https://acme.com` becomes `acme.com`). For a provider already holding such an entry, send the bare host names as `domain` to `POST /api/v1/auth/sso/update-provider` (no console action edits a provider's domain) and verify the domain again.
11 changes: 11 additions & 0 deletions content/docs/permissions/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,17 @@ What verification decides, with it on:
So if your people first sign in with addresses at a subdomain or at another
domain, list each such domain on the provider as a bare host name and verify it.

**A provider's domain list takes bare host names only.** Registering a provider,
or editing its domain, with an entry written any other way (a URL, a port, a
path, a user part) is refused with **400 `VALIDATION_FAILED`** on `domain`,
naming the entry; nothing is saved. Every rule that reads the list (the account
rule above, and SCIM adoption of an existing account) reads each entry the same
way. A provider saved before this rule may still hold such an entry: it is not
rewritten, and it names no domain. To correct one, send the bare host names as
`domain` to `POST /api/v1/auth/sso/update-provider` (`https://acme.com` becomes
`acme.com`; the console has no action that edits a provider's domain), then
verify the domain again, since writing the list clears the verified flag.

### Why an OIDC sign-in was refused

When the identity provider's ID token is not accepted, the callback redirects to
Expand Down
11 changes: 10 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ import {
ssoDomainTooLong,
stageSsoProviderDeleteImpact,
} from './sso-provider-lifecycle.js';
import { ssoDomainEntryNotBare } from './sso-account-at-proven-domain.js';
import {
applyUserBan,
applyUserUnban,
Expand Down Expand Up @@ -6212,7 +6213,9 @@ export class AuthManager {
* it, and nothing of the domain is parsed for a caller without a session.
* A value longer than a provider row holds, or with an entry longer than a
* host name, is refused `400 VALIDATION_FAILED` on `domain` before it is
* parsed (`ssoDomainTooLong`).
* parsed (`ssoDomainTooLong`); [#32960] then so is a value holding an entry
* that is not written as a bare host name (`ssoDomainEntryNotBare`), which
* would be stored and prove nothing at sign-in.
*
* The holding provider is named to a platform administrator, who may read
* every provider; any other caller (an organization administrator on
Expand All @@ -6237,6 +6240,12 @@ export class AuthManager {
if (tooLong) {
throw new APIError(tooLong.status, { code: tooLong.code, message: tooLong.message, field: tooLong.field, fields: tooLong.fields });
}
// [#32960] Then the shape: every entry a bare host name, as the rules that
// judge an address against the list read it (`sso-account-at-proven-domain.ts`).
const notBare = ssoDomainEntryNotBare(requested);
if (notBare) {
throw new APIError(notBare.status, { code: notBare.code, message: notBare.message, field: notBare.field, fields: notBare.fields });
}
const domains = providerDomains(requested);
if (domains.length === 0) return;
const providerId = typeof ctx?.body?.providerId === 'string' ? ctx.body.providerId : '';
Expand Down
30 changes: 12 additions & 18 deletions packages/plugins/plugin-auth/src/scim-sso-adoption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@
* ADR-0134 D3's V1 single-organization mode); one absent is a refusal.
* 4. **On that provider's verified domain.** That provider reads
* `domain_verified` (ADR-0135 D6), and the account's email domain is
* exactly one of its `domain` entries — a comma list, case-folded. No
* subdomain match: the ruling names the verified domain itself.
* exactly one of the domains its `domain` list names, each entry and the
* address read in their one spelling by the parse the account rule shares
* (`sso-account-at-proven-domain.ts`, #32975). No subdomain match: the
* ruling names the verified domain itself.
*
* ## Why `idp_provisioned` alone proves nothing here
*
Expand All @@ -78,6 +80,7 @@
import { isMissingTableError } from '@objectstack/types';
import type { SCIMIdentityResolution, SCIMIdentityResolutionInput } from '@better-auth/scim';
import { DIRECTORY_MANAGED_SOURCE } from './scim-identity-source.js';
import { ssoAddressDomain, ssoDomainEntryHosts } from './sso-account-at-proven-domain.js';

/** The engine surface the judge reads with — satisfied by `IDataEngine`. */
export interface ScimSsoAdoptionEngine {
Expand Down Expand Up @@ -118,20 +121,11 @@ const organizationKey = (value: unknown): string => (typeof value === 'string' ?
/** sqlite answers a boolean as 0/1; anything but true is not verified. */
const isTrue = (value: unknown): boolean => value === true || value === 1;

/** The domain of an address, case-folded — `undefined` for anything that is not exactly one `@`. */
function emailDomain(email: string): string | undefined {
const parts = email.trim().toLowerCase().split('@');
return parts.length === 2 && parts[0] !== '' && parts[1] !== '' ? parts[1] : undefined;
}

/** A provider's `domain` as the email domains it names: a comma list, trimmed and case-folded. */
function providerDomains(domain: unknown): string[] {
if (typeof domain !== 'string') return [];
return domain
.split(',')
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry !== '');
}
// [#32975] The address's domain and a provider's domain list are read through
// the ONE parse every rule that judges an address against that list shares
// (`sso-account-at-proven-domain.ts`): an entry names the same domain here as
// under the account rule, and an entry that parse does not accept (a URL, a
// port, a path) names no domain for adoption either.

/**
* Judge whether the account holding `question.email` is adopted, per the
Expand All @@ -144,7 +138,7 @@ export async function judgeScimSsoAdoption(
question: ScimSsoAdoptionQuestion,
): Promise<ScimSsoAdoptionVerdict> {
const email = question.email.trim().toLowerCase();
const domain = emailDomain(email);
const domain = ssoAddressDomain(email);
const users = (await engine.find('sys_user', {
where: { email },
fields: ['id', 'email', 'source'],
Expand Down Expand Up @@ -200,7 +194,7 @@ export async function judgeScimSsoAdoption(
// 4. On that provider's verified domain.
const verified = ofOrganization.filter((p) => isTrue(p.domain_verified));
if (verified.length === 0) return { adopt: false, reason: 'domain_not_verified' };
const vouching = domain === undefined ? undefined : verified.find((p) => providerDomains(p.domain).includes(domain));
const vouching = domain === null ? undefined : verified.find((p) => ssoDomainEntryHosts(p.domain).includes(domain));
if (!vouching) return { adopt: false, reason: 'email_domain_not_verified' };

return { adopt: true, userId, providerId: String(vouching.provider_id) };
Expand Down
108 changes: 108 additions & 0 deletions packages/plugins/plugin-auth/src/sso-account-at-proven-domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ import {
refuseSsoAccountOutsideProvenDomain,
ssoAddressAtProvenDomain,
ssoAddressDomain,
ssoDomainEntryHosts,
ssoDomainEntryNotBare,
ssoProviderProvenDomains,
type SsoAccountDomainRuleDeps,
} from './sso-account-at-proven-domain.js';
import { judgeScimSsoAdoption, type ScimSsoAdoptionEngine } from './scim-sso-adoption.js';
import { providerDomains } from './sso-provider-lifecycle.js';

const PROVIDER = 'acme-okta';

Expand Down Expand Up @@ -215,3 +219,107 @@ describe('with SSO domain verification on, a provider creates an account only at
expect(reader.find).not.toHaveBeenCalled();
});
});

// ── [#32975] [#32960] One parse for every rule that reads the domain list ──────

/**
* Entry spellings: bare hosts in several spellings, and shapes that are not a
* bare host (a URL, a port, a path, a user part, an escape, full-width
* letters, a dot run).
*/
const ENTRY_SPELLINGS = [
'acme.com',
'ACME.com',
' acme.com ',
'acme.com.',
'bücher.de',
'xn--bcher-kva.de',
'https://acme.com',
'https://acme.com/',
'acme.com:443',
'acme.com/x',
'u@acme.com',
'acme%2ecom',
'\uff41\uff43\uff4d\uff45.com',
'acme.com..',
];
const ADDRESSES = ['bob@acme.com', 'bob@ACME.com', 'bob@xn--bcher-kva.de', 'bob@bücher.de', 'bob@\uff41\uff43\uff4d\uff45.com'];

/** The SCIM adoption rule over one provider row: every condition but the domain holds. */
async function scimAdopts(entry: string, email: string): Promise<boolean> {
const tables: Record<string, unknown[]> = {
sys_user: [{ id: 'usr_1', email: email.toLowerCase(), source: 'idp_provisioned' }],
sys_account: [{ provider_id: PROVIDER }],
sys_scim_subject: [],
sys_sso_provider: [{ provider_id: PROVIDER, organization_id: 'org_1', domain: entry, domain_verified: 1 }],
};
const engine: ScimSsoAdoptionEngine = { find: async (object) => tables[object] ?? [] };
const verdict = await judgeScimSsoAdoption(engine, { email, organizationId: 'org_1' });
return verdict.adopt;
}

describe('[#32975] every rule that reads a provider domain list reads each entry the same way', () => {
it('the account rule and the SCIM adoption rule judge every entry spelling and address identically', async () => {
const disagreements: string[] = [];
for (const entry of ENTRY_SPELLINGS) {
for (const email of ADDRESSES) {
const account = ssoAddressAtProvenDomain(email, ssoProviderProvenDomains(row({ domain: entry }), PROVIDER));
const scim = await scimAdopts(entry, email);
if (account !== scim) disagreements.push(`${JSON.stringify(entry)} × ${email}: account ${account}, scim ${scim}`);
}
}
expect(disagreements).toEqual([]);
});

it('an entry the shared parse does not accept names no domain for SCIM either', async () => {
for (const entry of ['https://acme.com', 'acme.com:443', 'acme.com/x', 'u@acme.com']) {
expect(ssoDomainEntryHosts(entry), entry).toEqual([]);
expect(await scimAdopts(entry, 'bob@acme.com'), entry).toBe(false);
}
});

it('SCIM reads an entry and an address in their one spelling: a root dot and the punycode form are the same domain', async () => {
expect(await scimAdopts('acme.com.', 'bob@acme.com')).toBe(true);
expect(await scimAdopts('bücher.de', 'bob@xn--bcher-kva.de')).toBe(true);
expect(await scimAdopts('xn--bcher-kva.de', 'bob@bücher.de')).toBe(true);
});

it('CONTROL: a bare host name names its domain under the claim rule, the account rule and the SCIM rule', async () => {
expect(providerDomains('acme.com')).toEqual(['acme.com']);
expect(ssoProviderProvenDomains(row({ domain: 'acme.com' }), PROVIDER)).toEqual(['acme.com']);
expect(await scimAdopts('acme.com', 'bob@acme.com')).toBe(true);
});

it('[#32960] every entry register and edit accept names the same domains under the claim rule as under the shared parse', () => {
for (const entry of ENTRY_SPELLINGS) {
const refusal = ssoDomainEntryNotBare(entry);
if (refusal === undefined) {
expect(providerDomains(entry), JSON.stringify(entry)).toEqual(ssoDomainEntryHosts(entry));
} else {
expect(ssoDomainEntryHosts(entry), JSON.stringify(entry)).toEqual([]);
}
}
});
});

describe('[#32960] a requested domain entry that is not a bare host name is refused', () => {
it('names the entry, on domain, with VALIDATION_FAILED / invalid_value', () => {
for (const entry of ['https://acme.com', 'acme.com:443', 'acme.com/x', 'u@acme.com', 'acme%2ecom', 'acme.com..']) {
const refusal = ssoDomainEntryNotBare(`good.example, ${entry}`);
expect(refusal, entry).toMatchObject({
status: 'BAD_REQUEST',
code: 'VALIDATION_FAILED',
field: 'domain',
fields: [{ field: 'domain', code: 'invalid_value' }],
});
expect(refusal!.message, entry).toContain(`'${entry}'`);
}
});

it('CONTROL: bare host names pass, in any case, with a root dot, internationalised, and with blank entries', () => {
for (const domain of ['acme.com', 'ACME.com, eng.acme.com', 'acme.com.', 'bücher.de', 'xn--bcher-kva.de', 'acme.com, , globex.io', '']) {
expect(ssoDomainEntryNotBare(domain), domain).toBeUndefined();
}
expect(ssoDomainEntryNotBare(undefined)).toBeUndefined();
});
});
80 changes: 67 additions & 13 deletions packages/plugins/plugin-auth/src/sso-account-at-proven-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,80 @@ export function ssoAddressDomain(email: unknown): string | null {
}

/**
* One stored `domain` entry as the host it proves, or `null` when it proves
* none: trimmed, one trailing root dot dropped, and then written in its one
* One `domain` entry as the host it names, or `null` when it names none:
* trimmed, one trailing root dot dropped, and then written in its one
* spelling ({@link oneSpelling}). An entry written as a URL, with a port, a
* path, a user part or an escape proves nothing.
* path, a user part or an escape names no domain.
*
* [#32975] This is THE parse of a provider's domain list for every rule that
* judges an address against it: the account rule here and the SCIM adoption
* rule (`scim-sso-adoption.ts`) read each entry through it, so one stored
* entry names the same domain under both. [#32960] Register and edit refuse a
* requested entry it does not accept ({@link ssoDomainEntryNotBare}), so every
* entry stored since then names its domain here exactly as the one-provider
* claim rule (`sso-provider-lifecycle.ts` `providerDomains`) reads it.
*/
function provenEntryHost(entry: string): string | null {
export function ssoDomainEntryHost(entry: string): string | null {
const trimmed = entry.trim();
return oneSpelling(trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed);
}

/**
* A provider's comma-separated `domain` value as the domains it names, each
* once: every entry read by {@link ssoDomainEntryHost}; an entry it does not
* accept names nothing, and the list's other entries still count.
*/
export function ssoDomainEntryHosts(domain: unknown): string[] {
if (typeof domain !== 'string') return [];
const hosts = new Set<string>();
for (const entry of domain.split(',')) {
const host = ssoDomainEntryHost(entry);
if (host) hosts.add(host);
}
return [...hosts];
}

/** The refusal of a requested `domain` entry that is not a bare host name; thrown as better-auth's `APIError`. */
export interface SsoDomainEntryNotBareRefusal {
status: 'BAD_REQUEST';
code: 'VALIDATION_FAILED';
message: string;
field: 'domain';
fields: [{ field: 'domain'; code: 'invalid_value'; message: string }];
}

/**
* [#32960] The refusal for a requested `domain` value holding an entry that is
* not written as a bare host name ({@link ssoDomainEntryHost} accepts none),
* or `undefined`. Blank entries are skipped, as every parse of the list skips
* them. A URL, a port, a path or any other shape would be stored and then
* prove nothing at sign-in, so it is refused when the provider is registered
* or its domain is edited, naming the entry and the accepted shape. Asked
* after the length refusal (`sso-provider-lifecycle.ts` `ssoDomainTooLong`),
* so an entry named here is at most a host name long.
*/
export function ssoDomainEntryNotBare(domain: unknown): SsoDomainEntryNotBareRefusal | undefined {
if (typeof domain !== 'string') return undefined;
for (const entry of domain.split(',')) {
const trimmed = entry.trim();
if (!trimmed || ssoDomainEntryHost(trimmed) !== null) continue;
const message =
`The SSO provider domain entry '${trimmed}' is not a bare host name. Write each email domain as a host name `
+ "alone, such as 'acme.com': no scheme, port, path or user part, separated by commas. Nothing was saved.";
return {
status: 'BAD_REQUEST',
code: 'VALIDATION_FAILED',
message,
field: 'domain',
fields: [{ field: 'domain', code: 'invalid_value', message }],
};
}
return undefined;
}

/**
* The domains a provider row has proven: every entry of its `domain` that
* names the host the provider proved ({@link provenEntryHost}), when
* names the host the provider proved ({@link ssoDomainEntryHost}), when
* `domain_verified` reads on (`1` / `true`); otherwise none. A row that is not
* the provider's own (another `provider_id`) has proven nothing.
*/
Expand All @@ -141,14 +202,7 @@ export function ssoProviderProvenDomains(row: unknown, providerId: string): stri
const record = row as Record<string, unknown>;
if (record.provider_id !== providerId) return [];
if (!ssoToggleStoredOn(record[SSO_DOMAIN_VERIFIED_FIELD])) return [];
const domain = record[SSO_DOMAIN_FIELD];
if (typeof domain !== 'string') return [];
const proven = new Set<string>();
for (const entry of domain.split(',')) {
const host = provenEntryHost(entry);
if (host) proven.add(host);
}
return [...proven];
return ssoDomainEntryHosts(record[SSO_DOMAIN_FIELD]);
}

/** Whether `email` is at one of `provenDomains`, compared in one spelling, by equality. */
Expand Down
Loading
Loading