Skip to content

Commit 6116e19

Browse files
committed
feat(auth): control OAuth rollout through AppConfig
1 parent f2fbd76 commit 6116e19

28 files changed

Lines changed: 402 additions & 129 deletions

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,13 @@ Save it to avoid repeating the flag:
237237
sim configure --set-endpoint http://localhost:3000 --profile local
238238
```
239239

240-
A self-hosted deployment offers OAuth sign-in when
241-
`OAUTH_PROVIDER_ENABLED=true` and authentication is enabled. Leave it unset to
242-
use the pairing-code handoff; `DISABLE_AUTH=true` also forces OAuth off.
240+
A deployment offers OAuth sign-in when its global `oauth-provider` feature flag
241+
is enabled. With AppConfig, enable it in the existing `feature-flags` document
242+
using `"oauth-provider": { "enabled": true }`. When AppConfig is disabled or no
243+
AppConfig document has been loaded, `OAUTH_PROVIDER_ENABLED=true` supplies the fallback.
244+
With the provider off, the CLI uses the pairing-code handoff; `DISABLE_AUTH=true`
245+
always forces OAuth off. Operators must apply the database migration and drain
246+
older app instances before enabling it. See [Sign in with Sim](/platform/self-hosting/authentication#sign-in-with-sim).
243247

244248
## Where the login is stored
245249

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Persist that value as `CRON_SECRET` on the app **and** on whatever calls these e
9090
<Callout type="warn">
9191
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.
9292

93-
OAuth token cleanup continues after `OAUTH_PROVIDER_ENABLED=false` so rows created while the provider was enabled do not become permanent.
93+
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.
9494
</Callout>
9595

9696
```bash

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

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,23 +85,46 @@ Your deployment can act as an OAuth 2.0 authorization server using authorization
8585
code with PKCE and current OAuth security guidance. The Sim CLI uses it when
8686
enabled; see [CLI authentication](/cli/authentication).
8787

88-
Use a two-phase rollout: apply the database migration while this flag is unset,
89-
deploy and drain every older app instance, then enable it in a separate config
90-
rollout:
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.
91+
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:
91107

92108
```bash
93109
OAUTH_PROVIDER_ENABLED=true
94110
```
95111

96-
With it unset or false, the discovery document at `/.well-known/oauth-authorization-server`
97-
returns 404 and the CLI falls back to the pairing-code handoff on its own.
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.
98119
`DISABLE_AUTH=true` also forces the provider off because the authorization flow
99120
requires a real Better Auth user session.
100121

101122
Access tokens are opaque and last an hour; refresh tokens rotate on every use.
102123
Each login has a fixed thirty-day lifetime that refreshing does not extend.
103-
Nothing is cached, so revoking a grant under
104-
**Settings → Authorized apps** stops the app on its very next request.
124+
Token validation checks current grants, so revoking a grant under
125+
**Settings → 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.
105128

106129
### Registering an app
107130

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ Google, GitHub, and Microsoft sign-in, their callback URLs, and the `DISABLE_*_A
127127

128128
| Variable | Description |
129129
| --- | --- |
130-
| `OAUTH_PROVIDER_ENABLED` | Set to `true` in a second rollout after the migration is applied and every older app instance is drained. Unset/false uses the CLI pairing-code handoff. `DISABLE_AUTH=true` always forces it off. See [Authentication](/platform/self-hosting/authentication#sign-in-with-sim) |
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) |
131131

132132
## Integration Credentials
133133

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/core/config/env-flags'
5+
import { isOAuthProviderEnabled } from '@/lib/auth/oauth-provider-feature'
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 (!isOAuthProviderEnabled) redirect('/')
25+
if (!(await isOAuthProviderEnabled())) redirect('/')
2626

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

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ const flags = vi.hoisted(() => ({
1313

1414
vi.mock('@/lib/core/config/env-flags', () => ({
1515
...envFlagsMock,
16-
get isOAuthProviderEnabled() {
17-
return flags.enabled
18-
},
1916
get isRegistrationDisabled() {
2017
return flags.registrationDisabled
2118
},
2219
}))
2320

21+
vi.mock('@/lib/auth/oauth-provider-feature', () => ({
22+
isOAuthProviderEnabled: vi.fn(async () => flags.enabled),
23+
}))
24+
2425
vi.mock('@/lib/core/config/env', () => {
2526
const mock = createEnvMock({ NEXT_PUBLIC_APP_URL: 'https://sim.test' })
2627
return {

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

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

apps/sim/app/api/auth/[...all]/route.test.ts

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const handlerMocks = vi.hoisted(() => ({
1414
user: { id: 'anon' },
1515
session: { id: 'anon-session' },
1616
})),
17+
oauthEnabled: vi.fn(),
1718
}))
1819

1920
vi.mock('better-auth/next-js', () => ({
@@ -26,6 +27,9 @@ vi.mock('better-auth/next-js', () => ({
2627
vi.mock('@/lib/auth', () => ({
2728
auth: { handler: {} },
2829
}))
30+
vi.mock('@/lib/auth/oauth-provider-feature', () => ({
31+
isOAuthProviderEnabled: handlerMocks.oauthEnabled,
32+
}))
2933

3034
vi.mock('@/lib/auth/anonymous', () => ({
3135
ensureAnonymousUserExists: handlerMocks.ensureAnonymousUserExists,
@@ -63,6 +67,7 @@ vi.mock('@/app/api/credential-groups/oauth-callback', () => ({
6367
import { GET, POST } from '@/app/api/auth/[...all]/route'
6468

6569
afterAll(resetEnvFlagsMock)
70+
beforeEach(() => handlerMocks.oauthEnabled.mockResolvedValue(true))
6671

6772
describe('auth catch-all route managed OAuth callbacks', () => {
6873
beforeEach(() => {
@@ -97,21 +102,26 @@ describe('auth catch-all route managed OAuth callbacks', () => {
97102
expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled()
98103
})
99104

100-
it('leaves ordinary connector callbacks with Better Auth', async () => {
101-
handlerMocks.betterAuthGET.mockResolvedValueOnce(new Response(null, { status: 204 }))
102-
const request = createMockRequest(
103-
'GET',
104-
undefined,
105-
{},
106-
'http://localhost:3000/api/auth/oauth2/callback/jira?state=better-auth-state&code=code-1'
107-
)
105+
it.each([true, false])(
106+
'preserves connector callbacks when the provider is enabled=%s',
107+
async (enabled) => {
108+
handlerMocks.oauthEnabled.mockResolvedValue(enabled)
109+
handlerMocks.betterAuthGET.mockResolvedValueOnce(new Response(null, { status: 204 }))
110+
const request = createMockRequest(
111+
'GET',
112+
undefined,
113+
{},
114+
'http://localhost:3000/api/auth/oauth2/callback/jira?state=better-auth-state&code=code-1'
115+
)
108116

109-
const response = await GET(request)
117+
const response = await GET(request)
110118

111-
expect(response.status).toBe(204)
112-
expect(handlerMocks.betterAuthGET).toHaveBeenCalledWith(request)
113-
expect(handlerMocks.credentialGroupCallback).not.toHaveBeenCalled()
114-
})
119+
expect(response.status).toBe(204)
120+
expect(handlerMocks.betterAuthGET).toHaveBeenCalledWith(request)
121+
expect(handlerMocks.credentialGroupCallback).not.toHaveBeenCalled()
122+
expect(handlerMocks.oauthEnabled).not.toHaveBeenCalled()
123+
}
124+
)
115125

116126
it('rejects a managed state sent to an unsupported connector callback', async () => {
117127
const request = createMockRequest(
@@ -361,10 +371,46 @@ describe('OAuth provider client endpoints', () => {
361371
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
362372
})
363373

374+
it.each(['oauth2/consent', 'oauth2/continue', 'oauth2/public-client-prelogin'])(
375+
'stops serving %s after the runtime flag changes',
376+
async (path) => {
377+
const request = () =>
378+
createMockRequest('POST', {}, {}, `http://localhost:3000/api/auth/${path}`)
379+
expect((await POST(request())).status).toBe(200)
380+
handlerMocks.betterAuthPOST.mockClear()
381+
382+
handlerMocks.oauthEnabled.mockResolvedValue(false)
383+
const disabled = await POST(request())
384+
expect(disabled.status).toBe(404)
385+
expect(disabled.headers.get('cache-control')).toBe('no-store')
386+
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
387+
388+
handlerMocks.oauthEnabled.mockResolvedValue(true)
389+
expect((await POST(request())).status).toBe(200)
390+
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledOnce()
391+
}
392+
)
393+
394+
it.each([true, false])(
395+
'preserves connector POST callbacks when the provider is enabled=%s',
396+
async (enabled) => {
397+
handlerMocks.oauthEnabled.mockResolvedValue(enabled)
398+
const request = createMockRequest(
399+
'POST',
400+
{},
401+
{},
402+
'http://localhost:3000/api/auth/oauth2/callback/jira'
403+
)
404+
expect((await POST(request)).status).toBe(200)
405+
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledExactlyOnceWith(request)
406+
expect(handlerMocks.oauthEnabled).not.toHaveBeenCalled()
407+
}
408+
)
409+
364410
it.each([true, false])(
365411
'preserves authenticated connector linking when the OAuth provider is enabled=%s',
366412
async (enabled) => {
367-
setEnvFlags({ isOAuthProviderEnabled: enabled })
413+
handlerMocks.oauthEnabled.mockResolvedValue(enabled)
368414
const request = createMockRequest(
369415
'POST',
370416
{ providerId: 'google-email', callbackURL: 'http://localhost:3000/workspace' },
@@ -376,6 +422,7 @@ describe('OAuth provider client endpoints', () => {
376422

377423
expect(response.status).toBe(200)
378424
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledExactlyOnceWith(request)
425+
expect(handlerMocks.oauthEnabled).not.toHaveBeenCalled()
379426
}
380427
)
381428
})

apps/sim/app/api/auth/[...all]/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { sharedCredentialGroupOAuthCallbackContract } from '@/lib/api/contracts/
44
import { parseRequest } from '@/lib/api/server'
55
import { auth } from '@/lib/auth'
66
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
7+
import { isOAuthProviderEnabled } from '@/lib/auth/oauth-provider-feature'
78
import { isAuthDisabled } from '@/lib/core/config/env-flags'
89
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
910
import { isCredentialGroupOAuthState } from '@/lib/credential-groups/oauth-state'
@@ -186,5 +187,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
186187
)
187188
}
188189

190+
if (OAUTH_PROVIDER_PROTOCOL_POST_PATHS.has(path) && !(await isOAuthProviderEnabled())) {
191+
return NextResponse.json(
192+
{ error: 'OAuth provider is not enabled' },
193+
{ status: 404, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } }
194+
)
195+
}
196+
189197
return betterAuthPOST(request)
190198
})

0 commit comments

Comments
 (0)