Skip to content

Commit b44507c

Browse files
committed
feat(auth): enable OAuth with permission-group controls
1 parent 443b6c1 commit b44507c

50 files changed

Lines changed: 938 additions & 403 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-build.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ jobs:
3434
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
3535
OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
3636
BETTER_AUTH_SECRET: oauth-postgres-ci-secret-at-least-32-characters
37-
OAUTH_PROVIDER_ENABLED: 'true'
3837
NEXT_PUBLIC_APP_URL: https://test.sim.ai
3938
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
4039

apps/docs/content/docs/cli/authentication.mdx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ sim login --method oauth
2323
sim login --method api-key
2424
```
2525

26-
`--method oauth` requires OAuth support and never falls back to an API key. The
27-
server must enable `OAUTH_PROVIDER_ENABLED=true`. Explicit OAuth selection also
28-
overrides SSH/headless detection; your browser still needs to reach the CLI's
26+
`--method oauth` requires a server with OAuth support and authentication enabled;
27+
it never falls back to an API key. Explicit OAuth selection also overrides
28+
SSH/headless detection; your browser still needs to reach the CLI's
2929
local callback. `--method api-key` uses pairing-code approval to create a new
3030
permanent API key. To supply an existing key, set `SIM_API_KEY` instead.
3131

@@ -257,13 +257,11 @@ Save it to avoid repeating the flag:
257257
sim configure --set-endpoint http://localhost:3000 --profile local
258258
```
259259

260-
A deployment offers OAuth sign-in when its global `oauth-provider` feature flag
261-
is enabled. With AppConfig, enable it in the existing `feature-flags` document
262-
using `"oauth-provider": { "enabled": true }`. When AppConfig is disabled or no
263-
AppConfig document has been loaded, `OAUTH_PROVIDER_ENABLED=true` supplies the fallback.
264-
With the provider off, the CLI uses the pairing-code handoff; `DISABLE_AUTH=true`
265-
always forces OAuth off. Operators must apply the database migration and drain
266-
older app instances before enabling it. See [Sign in with Sim](/platform/self-hosting/authentication#sign-in-with-sim).
260+
OAuth sign-in is available by default when server authentication is enabled.
261+
`DISABLE_AUTH=true` disables OAuth. Older servers without OAuth support use the
262+
pairing-code handoff. Keep the database schema current and drain app instances
263+
that predate the OAuth token-family lifecycle before accepting OAuth traffic.
264+
See [Sign in with Sim](/platform/self-hosting/authentication#sign-in-with-sim).
267265

268266
## Where the login is stored
269267

apps/docs/content/docs/platform/enterprise/access-control.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,14 @@ The **Chat Deployment** row also carries an **auth-mode allowlist** — *Auth mo
173173
| API Keys | Revokes workspace API keys. Members cannot list, create, or revoke one. |
174174
| Personal API Keys | Prevents members from using a personal API key against this workspace. |
175175
| Personal Credentials | Prevents connecting personal credentials, leaving only workspace-shared ones. |
176-
| CLI Access | Prevents approving a CLI login, which mints a key for the public API. |
176+
| CLI Access | Prevents approving or using a CLI login for the public API. |
177+
| OAuth App Access | Prevents OAuth apps from accessing the group's workspaces. The organization default group also governs authorization, token issuance and refresh, and account-level billing and audit reads. |
177178

178179
##### Rows read from the organization default group
179180

180181
Two rows — **Workspace Creation** and **Member Directory** — are read only from the organization's **default group**, because the act they govern names no workspace. On any other group the editor renders them inert, tags them **Organization**, and skips them in **Select All**. Set them on the default group.
181182

182-
Five more rows — **Integrations**, **API Keys**, **Invitations**, **Personal API Keys**, and **CLI Access** — apply on the group in front of you for anything scoped to one of its workspaces. The account-level path of the same action falls back to the default group: minting a personal key, an organization-wide invitation, an account-level CLI login. To close one of these completely, set it on the default group as well.
183+
Six more rows — **Integrations**, **API Keys**, **Invitations**, **Personal API Keys**, **CLI Access**, and **OAuth App Access** — apply on the group in front of you for anything scoped to one of its workspaces. Account-level actions use the organization's default group, including minting a personal key, sending an organization-wide invitation, approving a CLI login, and authorizing or refreshing an OAuth app.
183184

184185
### 4. Choose who it applies to
185186

apps/docs/content/docs/platform/enterprise/self-hosted.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Persist that value as `CRON_SECRET` on the app **and** on whatever calls these e
9191
<Callout type="warn">
9292
Both shipped deployments schedule the data-drain dispatcher and OAuth token cleanup, but **not** the three configurable data-retention endpoints. Setting `DATA_RETENTION_ENABLED=true` alone deletes no retained product data — those windows are evaluated only when one of the three endpoints is called. Add them to `cronjobs.jobs` yourself, or drive them from an external scheduler.
9393

94-
OAuth token cleanup continues when the global `oauth-provider` feature flag is off, so rows created while the provider was enabled do not become permanent. See [Sign in with Sim](/platform/self-hosting/authentication#sign-in-with-sim) for AppConfig and fallback configuration.
94+
OAuth token cleanup runs independently of sign-in activity, removing expired and revoked credentials. See [Sign in with Sim](/platform/self-hosting/authentication#sign-in-with-sim) for provider configuration.
9595
</Callout>
9696

9797
```bash

apps/docs/content/docs/platform/self-hosting/authentication.mdx

Lines changed: 20 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -81,50 +81,32 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th
8181

8282
## Sign in with Sim
8383

84-
Your deployment can act as an OAuth 2.0 authorization server using authorization
85-
code with PKCE and current OAuth security guidance. The Sim CLI uses it when
86-
enabled; see [CLI authentication](/cli/authentication).
84+
Your deployment acts as an OAuth 2.0 authorization server using authorization
85+
code with PKCE. OAuth sign-in is available whenever authentication is enabled;
86+
see [CLI authentication](/cli/authentication).
8787

88-
The global `oauth-provider` feature flag controls availability. Keep it off while
89-
applying the database migration, then deploy and drain every older app instance
90-
before enabling it.
88+
Apply database migrations before deploying a new app version. When upgrading
89+
from a version without the OAuth token-family lifecycle, drain older app
90+
instances before accepting OAuth traffic so every instance enforces the same
91+
refresh and revocation rules.
9192

92-
If your deployment uses AWS AppConfig, add this entry to the existing
93-
`feature-flags` document and deploy that configuration:
94-
95-
```json
96-
{
97-
"oauth-provider": { "enabled": true }
98-
}
99-
```
100-
101-
Preserve the document's other entries. This flag is global: use `enabled`, not
102-
workspace, organization, user, or admin targeting. Set `enabled` to `false` to
103-
turn it off; changes take effect as instances refresh their AppConfig cache.
104-
105-
When AppConfig is disabled or no AppConfig document has been loaded, the
106-
fallback is:
107-
108-
```bash
109-
OAUTH_PROVIDER_ENABLED=true
110-
```
111-
112-
In that fallback mode, unset or false keeps the provider off. An available
113-
AppConfig document takes precedence over this variable, including when the
114-
`oauth-provider` entry is missing or disabled. AppConfig fetch failures retain
115-
the last successfully loaded document.
116-
117-
When the provider is off, discovery at `/.well-known/oauth-authorization-server`
118-
returns 404 and the CLI falls back to the pairing-code handoff.
119-
`DISABLE_AUTH=true` also forces the provider off because the authorization flow
120-
requires a real Better Auth user session.
93+
`DISABLE_AUTH=true` disables OAuth sign-in and discovery because authorization
94+
requires a real Better Auth user session. Older servers without OAuth support
95+
return 404 from `/.well-known/oauth-authorization-server`, and the CLI falls
96+
back to the pairing-code handoff.
12197

12298
Access tokens are opaque and last an hour; refresh tokens rotate on every use.
12399
Each login has a fixed thirty-day lifetime that refreshing does not extend.
124100
Token validation checks current grants, so revoking a grant under
125-
**Settings → General → Authorized apps** stops the app on its very next request. These
126-
settings remain available for reviewing and revoking existing grants while the
127-
provider is off, and scheduled OAuth token cleanup continues.
101+
**Settings → General → Authorized apps** stops the app on its very next request.
102+
Scheduled OAuth token cleanup runs independently of sign-in activity.
103+
104+
Organization admins can restrict **OAuth App Access** under **Credentials & Access**
105+
in [permission groups](/platform/enterprise/access-control). Workspace requests use
106+
the group governing that workspace. Authorizing apps, issuing and refreshing tokens,
107+
and account-level billing and audit reads use the organization's default group.
108+
**CLI Access** also applies to the Sim CLI. Members can still review and revoke
109+
existing grants when OAuth app access is restricted.
128110

129111
### Registering an app
130112

apps/docs/content/docs/platform/self-hosting/environment-variables.mdx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,6 @@ import { Callout } from 'fumadocs-ui/components/callout'
123123

124124
Google, GitHub, and Microsoft sign-in, their callback URLs, and the `DISABLE_*_AUTH` switches are documented in [Authentication](/platform/self-hosting/authentication#social-login).
125125

126-
## Sign in with Sim
127-
128-
| Variable | Description |
129-
| --- | --- |
130-
| `OAUTH_PROVIDER_ENABLED` | Fallback for the global `oauth-provider` feature flag when AppConfig is disabled or no AppConfig document has been loaded. Set to `true` only after the migration is applied and every older app instance is drained. With AppConfig, use `"oauth-provider": { "enabled": true }` in the existing `feature-flags` document instead. `DISABLE_AUTH=true` always forces it off. See [Authentication](/platform/self-hosting/authentication#sign-in-with-sim) |
131-
132126
## Integration Credentials
133127

134128
<Callout type="warn">

apps/sim/.env.example

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ BETTER_AUTH_URL=http://localhost:3000
1111

1212
# Authentication Bypass (Optional - for self-hosted deployments behind private networks)
1313
# DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests.
14-
# OAUTH_PROVIDER_ENABLED=true # Enable Sim's OAuth authorization server after every app instance runs the matching migration/code. DISABLE_AUTH=true forces it off.
1514

1615
# Private-network egress allowlist (Optional - self-hosted only; ignored on Sim Cloud)
1716
# EGRESS_ALLOWED_HOSTS=host.docker.internal,*.svc.cluster.local # Uncomment to let outbound requests reach these hosts on a private network. Widens the SSRF boundary; only use on a trusted private network.

apps/sim/app/(auth)/oauth/consent/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
22
import { redirect } from 'next/navigation'
33
import type { SearchParams } from 'nuqs/server'
44
import { getSession } from '@/lib/auth'
5-
import { isOAuthProviderEnabled } from '@/lib/auth/oauth-provider-feature'
5+
import { isAuthDisabled } from '@/lib/core/config/env-flags'
66
import { OAuthConsentView } from '@/app/(auth)/oauth/consent/consent-view'
77
import { oauthConsentSearchParamsCache } from '@/app/(auth)/oauth/consent/search-params'
88

@@ -22,7 +22,7 @@ export default async function OAuthConsentPage({
2222
}: {
2323
searchParams: Promise<SearchParams>
2424
}) {
25-
if (!(await isOAuthProviderEnabled())) redirect('/')
25+
if (isAuthDisabled) redirect('/')
2626

2727
const [session, raw] = await Promise.all([getSession(), searchParams])
2828

apps/sim/app/(auth)/oauth/sign-in/route.test.ts

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,21 @@ import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

88
const flags = vi.hoisted(() => ({
9-
enabled: true,
9+
authDisabled: false,
1010
registrationDisabled: false,
1111
appUrl: 'https://sim.test',
1212
}))
1313

1414
vi.mock('@/lib/core/config/env-flags', () => ({
1515
...envFlagsMock,
16+
get isAuthDisabled() {
17+
return flags.authDisabled
18+
},
1619
get isRegistrationDisabled() {
1720
return flags.registrationDisabled
1821
},
1922
}))
2023

21-
vi.mock('@/lib/auth/oauth-provider-feature', () => ({
22-
isOAuthProviderEnabled: vi.fn(async () => flags.enabled),
23-
}))
24-
2524
vi.mock('@/lib/core/config/env', () => {
2625
const mock = createEnvMock({ NEXT_PUBLIC_APP_URL: 'https://sim.test' })
2726
return {
@@ -48,24 +47,24 @@ function redirectParts(response: Response): { destination: URL; callback: URL }
4847

4948
describe('OAuth login bridge', () => {
5049
beforeEach(() => {
51-
flags.enabled = true
50+
flags.authDisabled = false
5251
flags.registrationDisabled = false
5352
flags.appUrl = 'https://sim.test'
5453
})
5554

5655
it.each([true, false])(
57-
'keeps the configured auth origin when Next normalizes loopback hosts (enabled=%s)',
58-
async (enabled) => {
59-
flags.enabled = enabled
56+
'keeps the configured auth origin when Next normalizes loopback hosts (authDisabled=%s)',
57+
async (authDisabled) => {
58+
flags.authDisabled = authDisabled
6059
flags.appUrl = 'http://127.0.0.1:37488'
6160
const incoming = new NextRequest(`${flags.appUrl}/oauth/sign-in?client_id=sim-cli`)
6261
expect(incoming.nextUrl.origin).toBe('http://localhost:37488')
6362

6463
const response = await GET(incoming)
6564
const destination = new URL(response.headers.get('location')!)
6665
expect(destination.origin).toBe(flags.appUrl)
67-
expect(destination.pathname).toBe(enabled ? '/signup' : '/')
68-
if (enabled) expect(redirectParts(response).callback.origin).toBe(flags.appUrl)
66+
expect(destination.pathname).toBe(authDisabled ? '/' : '/signup')
67+
if (!authDisabled) expect(redirectParts(response).callback.origin).toBe(flags.appUrl)
6968
}
7069
)
7170

@@ -94,12 +93,12 @@ describe('OAuth login bridge', () => {
9493
expect(callback.searchParams.has('prompt')).toBe(false)
9594
})
9695

97-
it('uses login when registration is disabled and hides a disabled provider', async () => {
96+
it('uses login when registration is disabled and hides OAuth when authentication is disabled', async () => {
9897
flags.registrationDisabled = true
9998
const enabled = await GET(request('client_id=sim-cli'))
10099
expect(redirectParts(enabled).destination.pathname).toBe('/login')
101100

102-
flags.enabled = false
101+
flags.authDisabled = true
103102
const disabled = await GET(request('client_id=sim-cli'))
104103
expect(disabled.status).toBe(302)
105104
expect(new URL(disabled.headers.get('location') as string).pathname).toBe('/')
@@ -155,15 +154,15 @@ describe('OAuth login bridge', () => {
155154
}
156155
})
157156

158-
it('uses the same redirect precedence as the form and ignores a disabled OAuth provider', async () => {
157+
it('uses the same redirect precedence as the form and requires authentication for OAuth', async () => {
159158
const destination = new URL('/login', 'https://sim.test')
160159
destination.searchParams.set('callbackUrl', '/api/auth/oauth2/authorize?client_id=sim-cli')
161160
destination.searchParams.set('redirect', '/workspace')
162161
const headers = { cookie: 'better-auth.session_token=existing.session' }
163162
expect((await proxy(new NextRequest(destination, { headers }))).status).toBe(307)
164163

165164
destination.searchParams.delete('redirect')
166-
flags.enabled = false
165+
flags.authDisabled = true
167166
expect((await proxy(new NextRequest(destination, { headers }))).status).toBe(307)
168167
})
169168
})

apps/sim/app/(auth)/oauth/sign-in/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { type NextRequest, NextResponse } from 'next/server'
2-
import { isOAuthProviderEnabled } from '@/lib/auth/oauth-provider-feature'
3-
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
2+
import { isAuthDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags'
43
import { getBaseUrl } from '@/lib/core/utils/urls'
54
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
65
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
@@ -40,7 +39,7 @@ function consumeInteractivePrompt(params: URLSearchParams): boolean {
4039
*/
4140
export const GET = withRouteHandler(async (request: NextRequest) => {
4241
/** Avoid sending a newly signed-in user to a disabled provider's JSON 404. */
43-
if (!(await isOAuthProviderEnabled())) {
42+
if (isAuthDisabled) {
4443
return NextResponse.redirect(new URL('/', getBaseUrl()), 302)
4544
}
4645

0 commit comments

Comments
 (0)