Skip to content

Commit ab25755

Browse files
authored
fix(sso): link Entra sign-ins to existing accounts and enforce unique provider IDs (#6311)
* fix(sso): link Entra sign-ins to existing accounts and enforce unique provider IDs Better Auth 1.6.23 calls the account-linking handler with trustProviderByName: false, which disables the trustedProviders allowlist for SSO entirely. Trust now comes only from the provider's domainVerified flag, which Sim never set — so any user who already had a Sim account was stranded on "account not linked". Entra never sends email_verified, so this hit every Microsoft tenant. Sim already proves domain ownership via sso_domain before a provider can be registered, so the register route mirrors that decision onto domainVerified. The column defaults to true so existing providers keep signing in across the deploy, since enabling the option turns sign-in into a hard gate. Also enforces the providerId uniqueness Better Auth already assumes: it rejects any id that exists in any tenant and resolves providers by that column alone, so a second customer picking "azure-ad" could not register at all and got an opaque 422. Sim now returns a 409 naming a free id, and a unique index makes the duplicate-row state unreachable. * fix(sso): revoke domain trust when verification is removed mid-update The create path re-checks domain ownership after Better Auth persists the provider and rolls the row back if the verified sso_domain row disappeared in that window. The update path had no equivalent, so deleting the verified domain while updateSSOProvider was in flight still set domainVerified, restoring same-email account-linking trust for a domain the org no longer proves it owns. The update path has no newly-created row to roll back, so it clears the flag instead: that denies linking and blocks sign-in on the provider until the domain is verified again. * fix(sso): make domain-trust grants atomic and propagate revocation Greptile flagged that the ownership check and the domainVerified write were separate statements, so a domain deleted between them still ended with trust granted. Two changes close it from both sides. The grant now folds the ownership test into the UPDATE's WHERE clause, so Postgres evaluates both in one statement and the write matches nothing once the proof is gone. Removing a verified domain now clears domainVerified for providers on that domain, in the same transaction as the delete. This was a standing gap, not just a race: deleting a domain previously left linking trust set indefinitely. Together the provider cannot end up trusted without current ownership in either commit order — if the grant lands first the delete clears it, and if the delete lands first the grant no-ops. * fix(sso): report a refused domain-trust grant instead of returning success The conditional grant could match zero rows if the verified domain was deleted between the pre-write check and the write. The route ignored that and returned 200, leaving a provider that cannot sign anyone in while telling the admin it saved. The grant now reports whether it matched, and that result is the single decision point on both paths: the create path rolls the provider back, the update path clears the flag, and both return SSO_DOMAIN_NOT_VERIFIED. This also drops the separate post-write ownership read, since the UPDATE re-tests ownership itself. * feat(sso): let admins map IdP claims, and trim setup comments Identity providers disagree on which claim carries each value — Entra can send the address as `upn` rather than `email` — and the mapping was hardcoded, so a mismatch had no fix in the UI at all. Adds an Attribute mapping section for both protocols, defaulting to each protocol's standard claim names shown as placeholders, so the common case still needs no input. Editing an existing provider now loads its stored mapping and only treats a value as an override when it differs from the default, so a saved custom mapping is never silently rewritten. * feat(sso): expose the standard enterprise IdP options in the setup form Rounds out the form with the options Better Auth already accepts but the UI hid, so a non-standard IdP no longer dead-ends at a field that cannot be set. SAML gains signature algorithm, digest algorithm and NameID format. Only SHA-256 and stronger are offered: Better Auth warns on SHA-1 as deprecated and rejects anything outside its secure set, so weaker choices would only produce failed saves. SAML also surfaces the SP Entity ID beside the ACS URL. IdP admins are usually handed a vendor metadata document; Sim does not publish one, and these are the two values it would carry. OIDC gains authorization, token and JWKS endpoint overrides for providers whose discovery document is incomplete or unreachable. Discovery still fills them in when they are left blank. All of these load from the stored config when editing, so re-saving a provider cannot quietly drop them. * fix(sso): withhold domain trust from personal providers on the hosted deployment A personal (org-less) provider has no verified domain behind it, but the trust grant treated it as authoritative anyway. On the multi-tenant deployment that is an account-takeover primitive: anyone able to register one could claim a domain they do not own, point it at their own IdP, and have a sign-in auto-link to an existing account on that domain. Sim's UI always registers org-scoped, so this only reaches direct API callers. Self-hosted deployments are single-tenant — the operator is the only tenant — so the org-less path keeps working there. Also clears the attribute mapping when the protocol changes: claim names are protocol-specific, so an OIDC override carried into a SAML config would save a mapping the IdP cannot resolve. * docs(sso): correct the personal-provider trust note after the hosted gating * fix(sso): drop the inert SAML algorithm selects, make NameID format clearable The signature and digest algorithm selects were placebo controls. Tracing @better-auth/sso 1.6.23, those two values are only read by validateConfigAlgorithms, mergeSAMLConfig and sanitizeProvider — createSP and createIdP never pass them to samlify, so nothing they select reaches the SAML exchange. Bugbot separately noted they could not be cleared, since Better Auth merges with `??` and omitting a key keeps the stored value. A control that neither applies nor clears should not exist, so both are removed. NameID format is genuinely wired (createSP passes it as nameIDFormat) and is kept, but is now always sent rather than omitted when set to the provider default. samlify falsy-guards the value, so an empty string reads as unset and "Provider default" can actually clear a stored override. The read-only provider view also now shows the SP Entity ID and the ACS label for SAML — admins land there after saving and need the same two values the form says their IdP requires. * docs(sso): tighten the personal-provider note to the self-host path it describes * fix(sso): forward an empty SAML NameID format so the provider default can be restored The form sends an empty identifierFormat when the admin selects "Provider default", but the route dropped it with a truthiness check. Better Auth merges SAML config with `??`, so an omitted key retains the stored value — the selection appeared to apply and silently did not. Forwarding the empty string makes it reach the merge, and samlify falsy-guards nameIDFormat, so it reads as unset. Selecting the provider default now actually clears a stored override. * fix(sso): revoke trust for providers whose domain is spelled with a wildcard Migration 0268 grandfathered providers by normalizing their domain with lower + btrim + a stripped leading `*.`, so sso_provider.domain can hold `*.acme.com` while its verified sso_domain row holds `acme.com`. The revoke on domain deletion compared the raw column, so such a provider matched nothing and kept domainVerified after its ownership proof was gone. The comparison now applies the same normalization 0268 used, so a grandfathered row is matched the way it was written. * fix(db): give the SSO index migration the concurrent-build convention it skipped packages/db/scripts/migrate.ts documents the required shape for CONCURRENTLY statements, and the previous migration follows it. 0284 did not, and the omission is silently destructive. migrate.ts sets a session lock_timeout of 5s, which survives the embedded COMMIT. CREATE INDEX CONCURRENTLY waits on every concurrent write transaction in the database — not only ones touching this table — so on a busy database the build is cancelled with 55P03 and leaves an INVALID index. The retry then replays the file, IF NOT EXISTS skips the invalid index, and DROP INDEX removes the only working index on provider_id. The migration journals as applied and exits 0 with provider_id unindexed and uniqueness unenforced, reopening the cross-tenant provider resolution this migration exists to close. Adds SET lock_timeout = 0 around the concurrent statements, a pre-drop of the target index name so a replay rebuilds rather than skips, and restores the 5s timeout afterwards. Verified by stranding an INVALID index and replaying: the end state is a valid unique index with uniqueness enforced. Also corrects the sso() comment that claimed domainVerified confines linking to matching email domains. link-account.mjs blocks on `!isTrustedProvider && !userInfo.emailVerified`, so an IdP asserting email_verified links regardless of domain — the flag narrows nothing on its own. * fix(sso): give the Enter shortcut the same guard as the Add domain button The Enter handler called handleAdd unconditionally while the button was disabled during an in-flight add, so repeated presses could issue overlapping requests. Both now read one canAddDomain flag rather than duplicating the condition. * fix(sso): stop the provider ID being editable after it is saved Renaming it was never useful and always destructive. The value forms the redirect URL registered with the identity provider, so changing it breaks sign-in until the IdP is updated. Worse, the register route selects create-vs-update by (providerId, organizationId), so a renamed id misses and registers a SECOND provider; the settings page renders providers[0], so the duplicate is invisible, there is no delete action to remove it, and existing account rows still reference the old id. Editing now shows it as a read-only copyable value, and the create form says up front that it cannot be changed later. Also hoists the suggestion list to module scope — it was rebuilding 44 objects on every keystroke anywhere in the form. * fix(sso): stop persisting generated IdP metadata so SAML cert rotation works The route stored an IdP metadata document built from cert + entryPoint even when the admin supplied none. The form loads that document back into its optional metadata field and resends it, and on the next save it wins over the certificate — so rotating a SAML signing certificate through the form appeared to succeed and changed nothing. Only metadata the admin actually pasted is persisted now. With none stored, Better Auth's createIdP builds the IdP from issuer, entryPoint and cert, which are the fields the form edits. No SAML providers exist in production, so this changes no live tenant. * fix(sso): always write SAML IdP metadata so clearing it takes effect on update Not storing generated metadata fixed new providers but not existing ones: Better Auth merges SAML config with `??`, so omitting the key let a previously stored document survive and keep overriding the certificate. The key is now always written, empty when the admin supplied none. createIdP falsy-guards it and falls back to issuer/entryPoint/cert, so clearing the field actually clears it. * fix(sso): hold the domain proof under a row lock while granting trust Two fixes from review. The trust grant folded the ownership test into the UPDATE's WHERE clause, but under READ COMMITTED the EXISTS subquery is evaluated against the statement's original snapshot. A delete committing while the UPDATE waited on the provider row could therefore still see the removed sso_domain row and grant trust after ownership was gone. The grant now selects the proof FOR SHARE inside a transaction before writing, so the delete blocks until it commits, and if the delete committed first the select finds nothing and no trust is written. Editing a SAML provider also broke on configs written by the previous commit: hydration used `config.idpMetadata?.metadata || config.idpMetadata`, and `{ metadata: '' }` is falsy at the property but truthy as an object, so an object landed in a string field and failed validation on save. It now narrows on the type and handles both the object and legacy bare-string shapes. * refactor(sso): write the two merge-sensitive SAML fields the same way idpMetadata and identifierFormat both exist to defeat Better Auth's `??` merge, which silently keeps a stored value when a key is omitted, but they were written differently — one always, one only when defined. Both are now always written, empty when unset, under one comment explaining why and noting that each is falsy-guarded downstream. Also drops a redundant saveDisabled: false; the prop already defaults to false. * fix(sso): report the row the trust grant actually matched The grant returned true once it found the proof, without checking that the provider UPDATE matched anything, so its boolean did not always mean what callers read it to mean. It now reports the matched row. * fix(sso): restore provider domain trust when a domain is re-verified * fix(sso): correct the domain-removal warning now that it disables sign-in * chore(sso): trim verbose comments * fix(sso): revert a rejected SSO update instead of leaving it stored
1 parent 293dc5b commit ab25755

15 files changed

Lines changed: 19703 additions & 159 deletions

File tree

apps/docs/content/docs/en/platform/enterprise/sso.mdx

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,21 @@ Single Sign-On lets your team sign in to Sim through your company's identity pro
1212

1313
---
1414

15+
## Before you start
16+
17+
<Callout type="warning">
18+
[Verify your email domain](/platform/enterprise/verified-domains) first. SSO cannot be saved until the domain shows as **Verified**, and DNS changes take time to propagate.
19+
</Callout>
20+
21+
Decide your **Provider ID** before configuring your identity provider — it becomes part of the callback URL you register there, so changing it later means redoing that step.
22+
23+
---
24+
1525
## Setup
1626

1727
### 1. Open SSO settings
1828

19-
Go to **Settings → Enterprise → Single Sign-On** in your workspace.
29+
Go to **Settings → Security → Single sign-on** in your organization settings.
2030

2131
### 2. Choose a protocol
2232

@@ -33,7 +43,7 @@ Go to **Settings → Enterprise → Single Sign-On** in your workspace.
3343

3444
| Field | What to enter |
3545
|-------|--------------|
36-
| **Provider ID** | A short slug identifying this connection, e.g. `okta` or `azure-ad`. Letters, numbers, and dashes only. |
46+
| **Provider ID** | A short slug identifying this connection. Letters, numbers, and dashes only. It must be **unique across every Sim organization**, so include something specific to you — `azure-ad-acme`, not `azure-ad`. If the ID is taken, Sim tells you and suggests a free one. |
3747
| **Issuer URL** | The identity provider's issuer URL. Must be HTTPS. |
3848
| **Domain** | Your organization's email domain, e.g. `company.com`. Users with this domain will be routed through SSO at sign-in. |
3949

@@ -118,19 +128,21 @@ The issuer URL uses Okta's default authorization server, which is pre-configured
118128
**In Azure** ([official docs](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app)):
119129

120130
1. Go to **Microsoft Entra ID → App registrations → New registration**
121-
2. Under **Redirect URI**, select **Web** and enter your Sim callback URL:
131+
2. Under **Redirect URI**, select **Web** and enter your Sim callback URL, using the Provider ID you chose:
122132
```
123-
https://sim.ai/api/auth/sso/callback/azure-ad
133+
https://sim.ai/api/auth/sso/callback/azure-ad-acme
124134
```
125135
3. After registration, go to **Certificates & secrets → New client secret** and copy the value immediately — it won't be shown again
126136
4. Go to **Overview** and copy the **Application (client) ID** and **Directory (tenant) ID**
137+
5. Go to **Token configuration → Add optional claim**, choose **ID**, and add **email**. Entra omits the email address for managed users without this claim, and sign-in then fails with a missing-user-info error
138+
6. If **Enterprise applications → Sim → Properties → Assignment required** is **Yes**, assign the users or groups who should sign in. Microsoft rejects unassigned users before they reach Sim
127139

128140
**In Sim:**
129141

130142
| Field | Value |
131143
|-------|-------|
132144
| Provider Type | OIDC |
133-
| Provider ID | `azure-ad` |
145+
| Provider ID | `azure-ad-acme` (must be globally unique) |
134146
| Issuer URL | `https://login.microsoftonline.com/{tenant-id}/v2.0` |
135147
| Domain | `company.com` |
136148
| Client ID | Application (client) ID |
@@ -252,7 +264,7 @@ SSO provisioning creates internal organization members. External workspace membe
252264
},
253265
{
254266
question: "A user already has an account with the same email — what happens when they sign in with SSO?",
255-
answer: "Sim links the SSO identity to the existing account automatically, as long as your identity provider reports the email as verified (email_verified) or the provider is trusted. Most OIDC providers (Okta, Google Workspace, Auth0) assert email_verified, so linking just works. If sign-in fails with 'account not linked' — common with SAML providers that omit the claim — add the provider's ID to SSO_TRUSTED_PROVIDER_IDS on self-hosted and restart."
267+
answer: "Sim links the SSO identity to that account automatically. Linking is authorized by your verified domain: because you proved ownership of the domain before configuring SSO, Sim treats your identity provider as authoritative for email addresses on it. This works the same for OIDC and SAML, and does not depend on your IdP sending an email_verified claim — Microsoft Entra, for example, never sends one."
256268
},
257269
{
258270
question: "Who can configure SSO on Sim Cloud?",
@@ -264,7 +276,7 @@ SSO provisioning creates internal organization members. External workspace membe
264276
},
265277
{
266278
question: "How do I update or replace an existing SSO configuration?",
267-
answer: "Open Settings → Enterprise → Single Sign-On and click Edit. Update the fields and save. The existing provider configuration is replaced."
279+
answer: "Open Settings → Security → Single sign-on and click Edit. Update the fields and save. The existing provider configuration is replaced."
268280
}
269281
]} />
270282

@@ -285,22 +297,18 @@ NEXT_PUBLIC_SSO_ENABLED=true
285297
ORGANIZATIONS_ENABLED=true
286298
NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true
287299

288-
# Optional: comma-separated SSO provider IDs to trust for automatic account linking
289-
# (links an SSO sign-in to an existing account with the same email). Needed when your
290-
# IdP does not assert email_verified — typically SAML providers, or OIDC providers that
291-
# omit the claim. Set it to the Provider ID you registered, then restart.
292-
# (If you also keep SSO_PROVIDER_ID in the app's environment, that provider is trusted
293-
# without listing it here.)
300+
# Optional: comma-separated provider IDs to trust for automatic account linking.
301+
# This applies to non-SSO providers only — SSO linking is authorized by the
302+
# verified domain on the provider itself, not by this list.
294303
SSO_TRUSTED_PROVIDER_IDS=custom-oidc,partner-saml
295304
```
296305

297306
<Callout type="info">
298307
When someone signs in with SSO and an account with the same email already exists
299308
(for example, they previously signed up with email/password), Sim links the SSO
300-
identity to that account automatically as long as your IdP reports the email as
301-
verified, or the provider is trusted. If you hit an `account not linked` error,
302-
either confirm your IdP sends `email_verified`, or add the provider's ID to
303-
`SSO_TRUSTED_PROVIDER_IDS` and restart.
309+
identity to that account automatically. That linking is authorized by the verified
310+
domain attached to the provider, so it works for both OIDC and SAML and does not
311+
depend on your IdP asserting `email_verified`.
304312
</Callout>
305313

306314
You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database.

apps/sim/app/api/auth/sso/register/route.test.ts

Lines changed: 219 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,19 @@ function queueMembers(rows: Array<Record<string, unknown>>) {
3939
}
4040

4141
/**
42-
* Queues existing SSO provider rows for BOTH domain-conflict lookups (the
43-
* pre-registration check and the post-registration re-check).
42+
* Queues the sso_provider lookups a registration performs, in route order:
43+
* providerId conflict then domain conflict, once before OIDC discovery and again
44+
* immediately before the write. `providerIdRows` defaults to empty so
45+
* domain-conflict tests are unaffected by the providerId check.
4446
*/
45-
function queueProviders(rows: Array<Record<string, unknown>>) {
46-
queueTableRows(schemaMock.ssoProvider, rows)
47-
queueTableRows(schemaMock.ssoProvider, rows)
47+
function queueProviders(
48+
domainRows: Array<Record<string, unknown>>,
49+
providerIdRows: Array<Record<string, unknown>> = []
50+
) {
51+
queueTableRows(schemaMock.ssoProvider, providerIdRows)
52+
queueTableRows(schemaMock.ssoProvider, domainRows)
53+
queueTableRows(schemaMock.ssoProvider, providerIdRows)
54+
queueTableRows(schemaMock.ssoProvider, domainRows)
4855
}
4956

5057
vi.mock('@/lib/auth', () => ({
@@ -109,12 +116,14 @@ describe('POST /api/auth/sso/register', () => {
109116
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test'))
110117
mockRegisterSSOProvider.mockResolvedValue({ id: 'row-1', providerId: 'acme-oidc' })
111118
mockUpdateSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
119+
// The trust UPDATE reports the row it matched; by default the provider exists.
120+
dbChainMockFns.returning.mockResolvedValue([{ id: 'provider-row' }])
112121
// Default: the org has already verified the domain, so the ownership gate
113-
// passes and each test exercises the logic beyond it. The gate is checked
114-
// three times for a successful org-scoped registration (fail-fast entry +
115-
// authoritative re-check before the write + compensating re-check after the
116-
// write), so queue three rows. Gate-specific tests reset the queue to assert
117-
// the unverified paths.
122+
// passes and each test exercises the logic beyond it. A successful org-scoped
123+
// registration reads it three times: the fail-fast entry gate, the
124+
// authoritative re-check before the write, and the locking read inside the
125+
// trust transaction. Gate-specific tests reset the queue to assert the
126+
// unverified paths.
118127
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
119128
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
120129
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
@@ -175,7 +184,7 @@ describe('POST /api/auth/sso/register', () => {
175184
queueMembers([{ organizationId: 'org1', role: 'owner' }])
176185
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified
177186
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check: verified
178-
queueTableRows(schemaMock.ssoDomain, []) // post-write compensating check: revoked
187+
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
179188
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
180189
const json = await res.json()
181190
expect(res.status).toBe(403)
@@ -214,6 +223,204 @@ describe('POST /api/auth/sso/register', () => {
214223
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
215224
})
216225

226+
/**
227+
* Better Auth scopes providerId uniqueness globally, not per tenant, and would
228+
* otherwise reject this with an opaque 422 that reads like a bug. Sim catches
229+
* it first and returns a 409 naming a free id.
230+
*/
231+
it('rejects a providerId already taken by another organization', async () => {
232+
queueMembers([{ organizationId: 'org-b', role: 'owner' }])
233+
queueProviders([], [{ domain: 'other.com', userId: 'u-other', organizationId: 'org-other' }])
234+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-b' }))
235+
const json = await res.json()
236+
expect(res.status).toBe(409)
237+
expect(json.code).toBe('SSO_PROVIDER_ID_TAKEN')
238+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
239+
})
240+
241+
it('suggests a free, domain-scoped providerId when the requested one is taken', async () => {
242+
queueMembers([{ organizationId: 'org-b', role: 'owner' }])
243+
queueProviders([], [{ domain: 'other.com', userId: 'u-other', organizationId: 'org-other' }])
244+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-b' }))
245+
const json = await res.json()
246+
expect(json.error).toContain('acme-oidc-acme')
247+
})
248+
249+
it('does not treat the caller’s own provider as a providerId conflict', async () => {
250+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
251+
queueProviders([], [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }])
252+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
253+
expect(res.status).toBe(200)
254+
})
255+
256+
/**
257+
* Better Auth's `isTrustedProvider` reads this flag, and it is the only thing
258+
* that lets an SSO sign-in link to a pre-existing same-email account once the
259+
* plugin stopped honouring `trustedProviders` for SSO. `registerSSOProvider`
260+
* always persists `false`, so the route must set it after the write.
261+
*/
262+
it('marks the provider domain-verified after registering', async () => {
263+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
264+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
265+
expect(res.status).toBe(200)
266+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
267+
})
268+
269+
/** updateSSOProvider resets domainVerified to false whenever the domain changes. */
270+
it('re-marks the provider domain-verified after an update', async () => {
271+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
272+
queueProviders([])
273+
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }])
274+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
275+
expect(res.status).toBe(200)
276+
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
277+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
278+
})
279+
280+
/**
281+
* The create path rolls the provider back when verification is revoked during the
282+
* write. The update path has no new row to delete, so it restores the pre-update
283+
* config and clears the trust flag together. Clearing alone would leave the
284+
* rejected config stored, and re-verifying the domain regrants trust
285+
* automatically — silently activating a config the caller was told had failed.
286+
*/
287+
it('reverts the config and revokes trust when verification is removed mid-update', async () => {
288+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
289+
resetDbChainMock()
290+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
291+
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate
292+
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check
293+
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
294+
queueProviders([])
295+
queueTableRows(schemaMock.ssoProvider, [
296+
{
297+
id: 'p1',
298+
issuer: 'https://old-issuer.example.com',
299+
domain: 'acme.com',
300+
oidcConfig: '{"stored":"oidc"}',
301+
samlConfig: null,
302+
},
303+
]) // provider already owned → update path
304+
305+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
306+
expect(res.status).toBe(403)
307+
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
308+
// The conditional grant UPDATE is still issued — it simply matches no rows once
309+
// the proof is gone — so the signal is the restoring write plus the 403.
310+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
311+
issuer: 'https://old-issuer.example.com',
312+
domain: 'acme.com',
313+
oidcConfig: '{"stored":"oidc"}',
314+
samlConfig: null,
315+
domainVerified: false,
316+
})
317+
})
318+
319+
it('does not mark domain-verified when the registration is rolled back', async () => {
320+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
321+
resetDbChainMock()
322+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
323+
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
324+
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
325+
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
326+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
327+
expect(res.status).toBe(403)
328+
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created…
329+
expect(dbChainMockFns.delete).toHaveBeenCalled() // …then rolled back
330+
})
331+
332+
/**
333+
* A personal provider has no verified domain behind it. On the hosted
334+
* multi-tenant deployment that must grant no linking authority, or anyone able
335+
* to register one could claim a domain they do not own and have their own IdP
336+
* auto-link to existing accounts on it.
337+
*/
338+
it('does not grant domain trust to a personal provider when hosted', async () => {
339+
setEnvFlags({ isSsoEnabled: true, isHosted: true })
340+
const res = await POST(request(OIDC_BODY))
341+
expect(res.status).toBe(200)
342+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: false })
343+
})
344+
345+
it('grants domain trust to a personal provider when self-hosted', async () => {
346+
setEnvFlags({ isSsoEnabled: true, isHosted: false })
347+
const res = await POST(request(OIDC_BODY))
348+
expect(res.status).toBe(200)
349+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
350+
})
351+
352+
/**
353+
* Better Auth merges SAML config with `??`, so dropping an empty identifierFormat
354+
* would silently retain a previously stored NameID format while the admin had
355+
* selected the provider default.
356+
*/
357+
it('forwards an empty SAML identifierFormat so the provider default can be restored', async () => {
358+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
359+
queueProviders([])
360+
await POST(
361+
request({
362+
providerType: 'saml',
363+
providerId: 'acme-saml',
364+
issuer: 'https://idp.acme.com',
365+
domain: 'acme.com',
366+
orgId: 'org1',
367+
entryPoint: 'https://idp.acme.com/sso',
368+
cert: 'CERT',
369+
identifierFormat: '',
370+
})
371+
)
372+
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
373+
const sent = mockRegisterSSOProvider.mock.calls[0][0].body
374+
expect(sent.samlConfig).toHaveProperty('identifierFormat', '')
375+
})
376+
377+
/**
378+
* Persisting generated IdP metadata made re-saving destructive: the form loaded
379+
* it back, resent it, and it then won over the certificate — so rotating a SAML
380+
* cert through the form silently did nothing.
381+
*/
382+
it('writes empty IdP metadata when the admin supplied none, so a stored one clears', async () => {
383+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
384+
queueProviders([])
385+
await POST(
386+
request({
387+
providerType: 'saml',
388+
providerId: 'acme-saml',
389+
issuer: 'https://idp.acme.com',
390+
domain: 'acme.com',
391+
orgId: 'org1',
392+
entryPoint: 'https://idp.acme.com/sso',
393+
cert: 'ORIGINAL-CERT',
394+
})
395+
)
396+
const sent = mockRegisterSSOProvider.mock.calls[0][0].body
397+
// Written as empty rather than omitted: Better Auth merges with `??`, so an
398+
// omitted key would retain a previously stored document on update.
399+
expect(sent.samlConfig.idpMetadata).toEqual({ metadata: '' })
400+
expect(sent.samlConfig.cert).toBe('ORIGINAL-CERT')
401+
})
402+
403+
it('persists IdP metadata the admin did supply', async () => {
404+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
405+
queueProviders([])
406+
await POST(
407+
request({
408+
providerType: 'saml',
409+
providerId: 'acme-saml',
410+
issuer: 'https://idp.acme.com',
411+
domain: 'acme.com',
412+
orgId: 'org1',
413+
entryPoint: 'https://idp.acme.com/sso',
414+
cert: 'CERT',
415+
idpMetadata: '<EntityDescriptor>supplied</EntityDescriptor>',
416+
})
417+
)
418+
const sent = mockRegisterSSOProvider.mock.calls[0][0].body
419+
expect(sent.samlConfig.idpMetadata).toEqual({
420+
metadata: '<EntityDescriptor>supplied</EntityDescriptor>',
421+
})
422+
})
423+
217424
it('nests the attribute mapping inside oidcConfig (Better Auth reads it there)', async () => {
218425
queueMembers([{ organizationId: 'org1', role: 'owner' }])
219426
await POST(
@@ -227,8 +434,7 @@ describe('POST /api/auth/sso/register', () => {
227434

228435
it('routes an edit of an existing owned provider through updateSSOProvider', async () => {
229436
queueMembers([{ organizationId: 'org1', role: 'owner' }])
230-
queueTableRows(schemaMock.ssoProvider, []) // findDomainConflict #1 → no conflict
231-
queueTableRows(schemaMock.ssoProvider, []) // findDomainConflict #2 → no conflict
437+
queueProviders([]) // no providerId or domain conflicts on either pass
232438
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → edit
233439
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
234440
expect(res.status).toBe(200)

0 commit comments

Comments
 (0)