Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ The main process and two preloads are bundled by esbuild into `dist/main.cjs`, `
2. `apps/sim/app/desktop/auth/page.tsx` requires a browser session (redirects through `/login?callbackUrl=…`) and renders a **Continue** gesture gate; only that click mints a one-time token (`POST /api/desktop/auth/handoff`) and sends the browser to the loopback (`http://127.0.0.1:<port>/auth/callback?token=…&state=…`, RFC 8252 §7.3). The gate is the security boundary — state and port are attacker-choosable in a crafted link, so a bare GET must never mint. The loopback is the single hand-back channel: no OS scheme registration, works identically in dev and packaged builds. Interception of loopback is mitigated the way PKCE mitigates it: the token is single-use, short-TTL, and bound to a 128-bit state the app compares in constant time. (RFC 8252's *most*-preferred callback is claimed-`https` / macOS universal links, which bind the OS to a verified app identity — a future hardening step that needs an associated-domains entitlement + `apple-app-site-association` on the origin.)
3. The loopback fires the callback in the main process: the app validates the state, then a renderer in the app partition POSTs the token to `/api/auth/one-time-token/verify` (same-origin ⇒ trustedOrigins/CSRF pass; better-auth sets the session cookie and burns the token) and loads `/workspace`.
- **The app gets its own session, never the browser's.** The token is minted by `POST /api/desktop/auth/handoff` (`apps/sim/lib/auth/desktop-handoff.ts`), which creates a *new* session row for the user and points the one-time token at that. Better-auth's own `generateOneTimeToken` binds to the *calling* session, so using it directly made the app and the authorizing browser share one row: signing out of either deleted the row the other was still presenting, and — because the session cookie cache is not revalidated against the database — the survivor kept looking signed in while every database-backed session resolution failed (the socket handshake logged `Session not found` and 401-looped). A session per device is what better-auth's own device authorization grant does and what RFC 8252 assumes: sign-out, revocation, and expiry apply to one surface at a time. **Do not "simplify" this back to `generateOneTimeToken`.**
- Integration connects are **same-window redirects** (`client.oauth2.link`), not popups. Unknown provider hosts stay in-window (lenient default); Google/Microsoft connects get a native dialog offering to finish in the browser — the browser is signed in after the login handoff, tokens land server-side, the app just refreshes.
- Integration connects are **same-window redirects** (`client.linkSocial`), not popups. Unknown provider hosts stay in-window (lenient default); Google/Microsoft connects get a native dialog offering to finish in the browser — the browser is signed in after the login handoff, tokens land server-side, the app just refreshes.
- The MCP OAuth popup (`mcp-oauth-*`) is allowed as a same-partition child so `window.opener.postMessage` keeps working.
- Sign-out **revokes server-side first** (`POST /api/auth/sign-out` from the app-origin renderer — a main-process `session.fetch` would be rejected by better-auth's origin check), then clears cookies/localStorage/IndexedDB/cache/service workers plus any pending handoff state. The revoke is not optional: since the app owns its own session row, clearing the partition alone would strand a live 30-day credential that nothing can revoke. Two signals trigger teardown: the `/login?fromLogout=true` navigation (fast path) **and** deletion of the better-auth session cookie confirmed by a `get-session` probe (robust backstop — catches every sign-out path, not just the settings one, and rotation can't cause a false teardown). API 401s (probe-confirmed) surface a native re-auth prompt.

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/cli/organizations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,7 @@ sim organizations sso providers save [options]
| `--domain <value>` | Yes | Email domain already verified by this organization. |
| `--jit-provisioning-enabled` | No | Allow SSO sign-in to provision organization membership, subject to eligibility and available seats. Defaults to true. |
| `--no-jit-provisioning-enabled` | No | Send --jit-provisioning-enabled as false. |
| `--mapping <json\|@file>` | No | Identity-provider claims mapped to user fields. Defaults to &#123;"id":"sub","email":"email","name":"name","image":"picture"&#125;. (JSON, or @path / @- to read a file or stdin). |
| `--mapping <json\|@file>` | No | Identity-provider claims mapped to user fields. Defaults to &#123;"email":"email","name":"name","image":"picture"&#125;. (JSON, or @path / @- to read a file or stdin). |
| `--client-id <value>` | Conditional | Identity provider client identifier. Available when providerType is oidc. Required when providerType is oidc. |
| `--client-secret <value\|@file>` | Conditional | Write-only OIDC client secret; the redacted marker from providers get preserves an existing secret. Passing it inline exposes it to shell history and process listings. Required when --provider-type is oidc (@path / @- reads a file or stdin verbatim, including trailing newlines; @@value for a literal leading @). |
| `--scopes <json\|@file>` | No | OIDC scopes; offline_access is omitted. Available when providerType is oidc. Defaults to ["openid","profile","email"]. (JSON, or @path / @- to read a file or stdin). |
Expand All @@ -926,7 +926,7 @@ sim organizations sso providers save [options]
| `--jwks-endpoint <value>` | No | Optional signing-key endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. |
| `--entry-point <value>` | Conditional | Identity provider SAML sign-in endpoint. Available when providerType is saml. Required when providerType is saml. |
| `--cert <value>` | Conditional | Identity provider signing certificate. Available when providerType is saml. Required when providerType is saml. |
| `--callback-url <value>` | No | SAML callback URL; defaults to this provider’s Sim callback. Available when providerType is saml. |
| `--callback-url <value>` | No | SAML ACS URL advertised to the identity provider; defaults to this provider’s Sim ACS URL. Available when providerType is saml. |
| `--audience <value>` | No | SAML audience; omission preserves the saved value. Available when providerType is saml. |
| `--want-assertions-signed` | No | Require signed assertions; omission preserves the saved value. Available when providerType is saml. |
| `--no-want-assertions-signed` | No | Send --want-assertions-signed as false. |
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/cli/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4179,7 +4179,7 @@ sim organizations sso providers save [options]
| `--domain <value>` | Yes | Email domain already verified by this organization. |
| `--jit-provisioning-enabled` | No | Allow SSO sign-in to provision organization membership, subject to eligibility and available seats. Defaults to true. |
| `--no-jit-provisioning-enabled` | No | Send --jit-provisioning-enabled as false. |
| `--mapping <json\|@file>` | No | Identity-provider claims mapped to user fields. Defaults to &#123;"id":"sub","email":"email","name":"name","image":"picture"&#125;. (JSON, or @path / @- to read a file or stdin). |
| `--mapping <json\|@file>` | No | Identity-provider claims mapped to user fields. Defaults to &#123;"email":"email","name":"name","image":"picture"&#125;. (JSON, or @path / @- to read a file or stdin). |
| `--client-id <value>` | Conditional | Identity provider client identifier. Available when providerType is oidc. Required when providerType is oidc. |
| `--client-secret <value\|@file>` | Conditional | Write-only OIDC client secret; the redacted marker from providers get preserves an existing secret. Passing it inline exposes it to shell history and process listings. Required when --provider-type is oidc (@path / @- reads a file or stdin verbatim, including trailing newlines; @@value for a literal leading @). |
| `--scopes <json\|@file>` | No | OIDC scopes; offline_access is omitted. Available when providerType is oidc. Defaults to ["openid","profile","email"]. (JSON, or @path / @- to read a file or stdin). |
Expand All @@ -4193,7 +4193,7 @@ sim organizations sso providers save [options]
| `--jwks-endpoint <value>` | No | Optional signing-key endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. |
| `--entry-point <value>` | Conditional | Identity provider SAML sign-in endpoint. Available when providerType is saml. Required when providerType is saml. |
| `--cert <value>` | Conditional | Identity provider signing certificate. Available when providerType is saml. Required when providerType is saml. |
| `--callback-url <value>` | No | SAML callback URL; defaults to this provider’s Sim callback. Available when providerType is saml. |
| `--callback-url <value>` | No | SAML ACS URL advertised to the identity provider; defaults to this provider’s Sim ACS URL. Available when providerType is saml. |
| `--audience <value>` | No | SAML audience; omission preserves the saved value. Available when providerType is saml. |
| `--want-assertions-signed` | No | Require signed assertions; omission preserves the saved value. Available when providerType is saml. |
| `--no-want-assertions-signed` | No | Send --want-assertions-signed as false. |
Expand Down
16 changes: 7 additions & 9 deletions apps/docs/content/docs/platform/enterprise/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ Use OIDC when your provider supports OpenID Connect, or SAML 2.0 for a SAML conn
| **Domain** | The verified email domain served by this connection, such as `acme.com` |
| **On first SSO sign-in** | **Automatic** adds new organization members; **Invite only** authenticates without adding membership. See [membership behavior](#membership-and-account-linking). |

For OIDC, also enter **Client ID** and **Client secret**. Sim discovers authorization, token, UserInfo, and JWKS endpoints from `{issuer}/.well-known/openid-configuration`. Under **Advanced options**, you can set **Scopes** (default `openid,profile,email`) and override authorization, token, or JWKS endpoints if discovery does not supply usable values.
For OIDC, also enter **Client ID** and **Client secret**. Sim discovers authorization, token, UserInfo, and JWKS endpoints from `{issuer}/.well-known/openid-configuration`. The issuer must match the `iss` claim of the provider's ID tokens exactly. Under **Advanced options**, you can set **Scopes** (default `openid,profile,email`) and override authorization, token, or JWKS endpoints if discovery does not supply usable values.

For SAML, also enter **Entry point URL**, the provider's SSO service URL, and **Identity provider certificate**, its Base64 X.509 certificate. Under **Advanced options**, you can configure Audience, Callback URL override, signed-assertion requirements, NameID format, and IdP metadata XML. Metadata does not replace the required certificate.
For SAML, also enter **Entry point URL**, the provider's SSO service URL, and **Identity provider certificate**, its Base64 X.509 certificate. Under **Advanced options**, you can configure Audience, Callback URL override, signed-assertion requirements, NameID format, and IdP metadata XML. Metadata does not replace the required certificate. When signed assertions are required, the identity provider must sign the assertion itself; a signature on the response alone is rejected.

**Attribute mapping** lets either protocol override the email, name, and stable user-ID claim names. Leave a mapping blank to use the protocol default.
**Attribute mapping** lets either protocol override the email and name claim names. Leave a mapping blank to use the protocol default. Sim identifies each user by the OIDC `sub` claim or the SAML NameID, so choose a NameID format that stays stable for each person.

### Register the callback URL

Expand All @@ -62,9 +62,9 @@ Copy **Callback URL** for OIDC or **ACS URL (Reply URL)** for SAML, then registe
| Protocol | Callback format |
| --- | --- |
| OIDC | `https://<your-sim-domain>/api/auth/sso/callback/{provider-id}` |
| SAML | `https://<your-sim-domain>/api/auth/sso/saml2/callback/{provider-id}` |
| SAML | `https://<your-sim-domain>/api/auth/sso/saml2/sp/acs/{provider-id}` |

Sim Cloud uses `www.sim.ai`; self-hosted deployments use their public domain. Use your chosen **Provider ID** in both systems. For SAML, register the **SP Entity ID** as the provider's audience/Entity ID. If you set a **Callback URL override**, Sim's copyable ACS URL uses that override.
Sim Cloud uses `www.sim.ai`; self-hosted deployments use their public domain. SAML providers saved before the ACS URL changed keep `/api/auth/sso/saml2/callback/{provider-id}`, which still works, so their identity provider needs no change. Use your chosen **Provider ID** in both systems. For SAML, register the **SP Entity ID** as the provider's audience/Entity ID. If you set a **Callback URL override**, Sim's copyable ACS URL uses that override.

## Provider guides

Expand Down Expand Up @@ -186,7 +186,7 @@ See [Microsoft SAML setup](https://learn.microsoft.com/en-us/entra/identity/ente
3. Set **Relying party identifier** to Sim's **SP Entity ID**. Sim Cloud normally shows `https://www.sim.ai`; copy the value from your own deployment.
4. Add a **SAML Assertion Consumer Service** endpoint with HTTP POST binding:
```
https://<your-sim-domain>/api/auth/sso/saml2/callback/acme-adfs
https://<your-sim-domain>/api/auth/sso/saml2/sp/acs/acme-adfs
```
5. Under **Certificates**, export the **Token-signing certificate** using **View Certificate → Details → Copy to File → Base-64 encoded X.509 (.CER)**. Paste the text, including its header and footer, into Sim.
6. Copy the **Federation Service identifier** from **Service → Federation Service Properties**, and the SSO endpoint, such as `https://adfs.acme.com/adfs/ls`.
Expand Down Expand Up @@ -215,9 +215,7 @@ Select **Save** for a new provider. Sign out, select **Sign in with SSO** on you

If multiple providers serve the domain, ordinary sign-in uses the one marked **Primary**. Other providers can be reached through their **Test sign-in link**.

<Callout type="warn">
Start SAML testing from Sim's **Sign in with SSO** flow. Sim rejects unsolicited SAML assertions, so a test started from an identity-provider app portal can fail even with a correct configuration.
</Callout>
SAML sign-in can start from Sim's **Sign in with SSO** flow or from the identity provider's app portal. Either way, Sim checks the assertion's signature, audience, recipient, and validity window, and accepts each assertion only once.

An OIDC dashboard can use the saved provider's **Initiate login URL**. Sim starts that provider's sign-in without asking for an email when the domain is verified and the launch includes a matching issuer. An already signed-in account goes directly to Sim.

Expand Down
14 changes: 5 additions & 9 deletions apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -23515,7 +23515,6 @@
},
"mapping": {
"default": {
"id": "sub",
"email": "email",
"name": "name",
"image": "picture"
Expand All @@ -23524,8 +23523,7 @@
"type": "object",
"properties": {
"id": {
"default": "sub",
"description": "Claim holding the stable identity identifier.",
"description": "Deprecated and ignored: users are identified by the OIDC sub claim or the SAML NameID.",
"type": "string",
"minLength": 1,
"maxLength": 255
Expand All @@ -23546,7 +23544,7 @@
},
"image": {
"default": "picture",
"description": "Claim holding the avatar URL.",
"description": "Claim holding the avatar URL; OIDC only.",
"type": "string",
"minLength": 1,
"maxLength": 255
Expand Down Expand Up @@ -23656,7 +23654,6 @@
},
"mapping": {
"default": {
"id": "sub",
"email": "email",
"name": "name",
"image": "picture"
Expand All @@ -23665,8 +23662,7 @@
"type": "object",
"properties": {
"id": {
"default": "sub",
"description": "Claim holding the stable identity identifier.",
"description": "Deprecated and ignored: users are identified by the OIDC sub claim or the SAML NameID.",
"type": "string",
"minLength": 1,
"maxLength": 255
Expand All @@ -23687,7 +23683,7 @@
},
"image": {
"default": "picture",
"description": "Claim holding the avatar URL.",
"description": "Claim holding the avatar URL; OIDC only.",
"type": "string",
"minLength": 1,
"maxLength": 255
Expand All @@ -23708,7 +23704,7 @@
"description": "Identity provider signing certificate."
},
"callbackUrl": {
"description": "SAML callback URL; defaults to this provider’s Sim callback.",
"description": "SAML ACS URL advertised to the identity provider; defaults to this provider’s Sim ACS URL.",
"type": "string",
"maxLength": 2048,
"format": "uri"
Expand Down
13 changes: 13 additions & 0 deletions apps/sim/app/(auth)/oauth/sign-in/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ describe('OAuth login bridge', () => {
expect(callback.searchParams.has('ba_iat')).toBe(false)
})

it('restores the client resource the authorize route handed the provider', async () => {
const response = await GET(
request(
'client_id=mcp-client&scope=api%3Aread&sim_resource=https%3A%2F%2Fsim.test%2Fapi%2Fmcp&sig=signed'
),
{ params: Promise.resolve({}) }
)
const { callback } = redirectParts(response)

expect(callback.searchParams.get('resource')).toBe('https://sim.test/api/mcp')
expect(callback.searchParams.has('sim_resource')).toBe(false)
})

it('uses the same redirect precedence as the form and requires authentication for OAuth', async () => {
const destination = new URL('/login', 'https://sim.test')
destination.searchParams.set('callbackUrl', '/api/auth/oauth2/authorize?client_id=sim-cli')
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/app/(auth)/oauth/sign-in/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server'
import { OAUTH_AUTHORIZED_RESOURCE_PARAM } from '@/lib/auth/oauth-resource'
import { isAuthDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -45,6 +46,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {

const params = new URLSearchParams(request.nextUrl.search)
for (const name of SIGNED_QUERY_PARAMS) params.delete(name)
/**
* The authorize route renamed the client's `resource` for the provider;
* restore it so the resumed request is validated again.
*/
const authorizedResource = params.get(OAUTH_AUTHORIZED_RESOURCE_PARAM)
params.delete(OAUTH_AUTHORIZED_RESOURCE_PARAM)
if (authorizedResource !== null) params.set('resource', authorizedResource)
const requiresLogin = consumeInteractivePrompt(params)

const authorizeUrl = `/api/auth/oauth2/authorize?${params.toString()}`
Expand Down
Loading
Loading