Skip to content

Commit 987eb2b

Browse files
committed
fix(search): support GitHub App grants and verify connector ACLs
1 parent 6e12f9e commit 987eb2b

17 files changed

Lines changed: 25326 additions & 26 deletions

apps/docs/content/docs/search/github.mdx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ GitHub Search indexes text files from a repository on `github.com`. A workspace
1111

1212
## Before you start
1313

14-
Your Sim deployment needs a GitHub App configured as described below. An owner or administrator of the target repositories must install that App on them. Each person needs a verified GitHub email address matching their Sim account; the address can be private or secondary.
14+
Your Sim deployment needs a GitHub App configured as described below. For private repositories, an owner or administrator must install that App on them. Each person needs a verified GitHub email address matching their Sim account; the address can be private or secondary.
1515

1616
## Configure the GitHub App
1717

@@ -68,6 +68,8 @@ Expand **Account permissions** and set **Email addresses → Access: Read-only**
6868

6969
Leave every other permission at **No access**. Sim does not need issue, pull-request, administration, or write permissions. GitHub's [registration guide](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) explains these settings.
7070

71+
GitHub App user tokens use these permissions rather than OAuth scopes. An empty `scope` value in the token response is expected; see GitHub's [user token reference](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app).
72+
7173
Under **Where can this GitHub App be installed?**, choose **Only on this account** for an organization-owned App used only by members of that organization. Choose **Any account** when teammates or repository owners are outside that organization, or the App is owned by your personal account. A private App owned by a personal account can only be authorized by its owner; see GitHub's [App visibility rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/making-a-github-app-public-or-private).
7274

7375
Select **Create GitHub App**.
@@ -84,7 +86,7 @@ GITHUB_APP_CLIENT_ID=<Client ID>
8486
GITHUB_APP_CLIENT_SECRET=<Client secret>
8587
```
8688

87-
Use the **Client ID**, not the numeric **App ID**. These settings are separate from GitHub sign-in. An App private key is not required. Keep expiring user tokens enabled so Sim can [refresh them](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens).
89+
Use the **Client ID** and **client secret** from **Developer settings → GitHub Apps**. OAuth App credentials used for GitHub sign-in are not compatible. The numeric **App ID** and downloaded private key are not used by this connector. Keep expiring user tokens enabled so Sim can [refresh them](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens).
8890

8991
In the App's sidebar, choose **Install App**, select the target account, and grant access to the repositories you want to search. Return to Sim and select **Connect account**. Every teammate must authorize from Sim too.
9092

@@ -123,7 +125,7 @@ You can instead select an existing account under **Sync documents with** to supp
123125

124126
### Connect your account
125127

126-
On the GitHub source row, select **Connect account** and authorize the App. Teammates repeat this step after joining the Sim workspace. A person only gains access to repository files that their account and the App installation can both read.
128+
On the GitHub source row, select **Connect account** and authorize the App. Teammates repeat this step after joining the Sim workspace. For private repositories, both the person's account and the App installation must have access. GitHub also permits App user tokens to read public repositories without an installation; see [GitHub's permission rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app).
127129

128130
With **Connected members**, indexing begins after someone connects. A dedicated indexing account can start syncing immediately; each teammate still connects before searching. Admins can open **Manage** on the source to inspect sync progress and errors.
129131

@@ -135,6 +137,8 @@ With **Connected members**, indexing begins after someone connects. A dedicated
135137
| Problem | Next step |
136138
|---|---|
137139
| GitHub is unavailable in Search | Ask the deployment admin to configure the App client credentials and enable member connections. |
140+
| GitHub rejects `redirect_uri` | Register the exact callback on the GitHub App whose Client ID Sim uses: `http://localhost:3000/api/auth/oauth2/callback/github-repositories` for the default local server, or your production Sim origin followed by `/api/auth/oauth2/callback/github-repositories`. The scheme, host, port, and path must match; keep wildcard matching disabled. See [callback matching](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url). |
141+
| Wrong app credentials | Copy the Client ID and client secret from **GitHub Apps**, not **OAuth Apps**. Set `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET`, then restart Sim. A numeric App ID or private-key file cannot replace them. |
138142
| Repository cannot be read | Confirm the App is installed on that repository and your GitHub account has access. For SAML organizations, establish your GitHub SSO session before reconnecting. |
139143
| A teammate cannot authorize the App | Check **Where can this GitHub App be installed?** and the App owner. A private organization App accepts only organization members; a private personal App accepts only its owner. |
140144
| Identity verification fails | Verify the email used by your Sim account in GitHub's email settings, then reconnect. A public profile email alone is insufficient. |

apps/docs/content/docs/search/jira.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ Sim checks Jira separately using each connected person's account. Issue content
9696
| Projects are empty or disabled | Enter the domain and connect a browsing account, or switch to manual project keys. Check that the account can browse those projects. |
9797
| Connected, but no issues | Confirm the authorized site matches the configured domain. Check project access, issue security, and the JQL filter. An admin's Jira access does not grant access to other members. |
9898
| Email mismatch | Sign in to Atlassian with the email shown by Sim's connection flow. |
99+
| Atlassian says the callback URL is invalid | Ask the deployment operator to check the OAuth app identified by `JIRA_CLIENT_ID`. Its saved callback must exactly match the authorization request's `redirect_uri`, including scheme, hostname, port, and `/api/auth/oauth2/callback/jira` path. |
99100
| **Reconnect** | Reauthorize the Jira account and grant all requested permissions. This is needed after a grant is revoked or its required permissions change. |
100101
| Connection tab does not open | Allow pop-ups for Sim, then click **Connect account** again. |
101102

@@ -131,8 +132,8 @@ The deployment operator configures one shared Jira OAuth integration. Teammates
131132

132133
Example callback in Atlassian's developer console. Replace `sim.example.com` with your Sim domain.
133134

134-
3. Under **Permissions**, add the Jira and Jira Service Management APIs and configure the full scope set requested by Sim's Jira integration. Also add **User Identity API** with `read:me`. Sim requests `offline_access` for refresh tokens. The Search read scopes above are only a subset of this shared integration's permissions. Check the `jira` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts).
135+
3. Under **Permissions**, add **Jira API**, then **Configure** its classic and granular scopes for Jira, Jira Service Management, and Assets. Separately add **User Identity API** with `read:me`. Sim requests `offline_access` in the authorization URL for refresh tokens. Configure the full `jira` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts); the Search read scopes above are only a subset of this shared integration's permissions.
135136
4. Under **Distribution**, enable sharing so teammates can authorize the app. Copy the client ID and secret from **Settings** into `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET`, set the correct `NEXT_PUBLIC_APP_URL`, and restart Sim.
136137
5. Start a connection from Search. Confirm that Atlassian lists the intended site, then return to Sim. After changing requested scopes, reconnect previously authorized accounts.
137138

138-
If Atlassian reports a callback mismatch, compare the registered callback with the exact Sim origin and `/jira` callback path. If only the app owner can connect, check **Distribution**. See Atlassian's [OAuth configuration and sharing guide](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth).
139+
For a local instance using `NEXT_PUBLIC_APP_URL=http://localhost:3000`, register `http://localhost:3000/api/auth/oauth2/callback/jira`. Use a separate development OAuth app when production callbacks must remain unchanged. After updating local client credentials or the app URL, restart Sim and begin a new connection from Search. If only the app owner can connect, check **Distribution**. See Atlassian's [OAuth configuration and sharing guide](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth).

apps/sim/lib/credential-groups/oauth-state.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,34 @@ describe('credential group OAuth state', () => {
114114
).rejects.toThrow('Credential group OAuth requires Redis')
115115
})
116116

117+
it('round-trips a scopeless GitHub App attempt with PKCE and one-time state', async () => {
118+
const created = await createCredentialGroupOAuthAttempt({
119+
provider: 'github-repositories',
120+
workspaceId: 'workspace-1',
121+
email: 'person@example.com',
122+
enrollmentId: 'enrollment-1',
123+
credentialGroupId: 'group-1',
124+
optionId: 'option-1',
125+
authorizationAppId: 'github-app:fixture',
126+
scopeVersion: 1,
127+
requiredScopes: [],
128+
redirectUri: 'https://sim.example.com/api/auth/oauth2/callback/github-repositories',
129+
codeVerifier: 'github-code-verifier',
130+
invitationToken: 'invitation-token',
131+
returnTo: 'search',
132+
})
133+
134+
const consumed = await consumeCredentialGroupOAuthAttempt(created.state)
135+
expect(consumed).toMatchObject({
136+
provider: 'github-repositories',
137+
requiredScopes: [],
138+
codeVerifier: 'github-code-verifier',
139+
returnTo: 'search',
140+
})
141+
expect(credentialGroupOAuthNonceMatches(created.nonce, consumed!.nonceHash)).toBe(true)
142+
await expect(consumeCredentialGroupOAuthAttempt(created.state)).resolves.toBeNull()
143+
})
144+
117145
it('supports providers without PKCE while preserving one-time state', async () => {
118146
const created = await createCredentialGroupOAuthAttempt({
119147
provider: 'slack',
@@ -201,7 +229,7 @@ describe('credential group OAuth state', () => {
201229
await expect(consumeCredentialGroupOAuthAttempt(created.state)).rejects.toThrow('malformed')
202230
await expect(consumeCredentialGroupOAuthAttempt(created.state)).resolves.toBeNull()
203231
})
204-
it.each(['workspaceId', 'email'])(
232+
it.each(['workspaceId', 'email', 'requiredScopes'])(
205233
'rejects stored attempts missing the pinned %s and burns them',
206234
async (field) => {
207235
const created = await createCredentialGroupOAuthAttempt({

apps/sim/lib/credential-groups/oauth-state.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAtt
110110
Number.isInteger(candidate.scopeVersion) &&
111111
candidate.scopeVersion > 0 &&
112112
Array.isArray(candidate.requiredScopes) &&
113-
candidate.requiredScopes.length > 0 &&
114113
candidate.requiredScopes.every((scope) => typeof scope === 'string' && scope.length > 0) &&
115114
typeof candidate.redirectUri === 'string' &&
116115
(candidate.completionRedirect === undefined ||

apps/sim/lib/credentials/managed-oauth.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
4+
import { dbChainMockFns, resetDbChainMock, resetEnvMock, setEnv } from '@sim/testing'
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const mocks = vi.hoisted(() => ({
@@ -45,6 +45,7 @@ import {
4545
SLACK_SEARCH_USER_SCOPES,
4646
} from '@/lib/credential-groups/slack-managed-user-scopes'
4747
import { slackCredentialGroupProviderAdapter } from '@/lib/credential-groups/slack-provider'
48+
import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credential-groups/standard-oauth-provider'
4849
import { rejectManagedOAuthToken, resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth'
4950

5051
function mondayCredentialRow() {
@@ -102,6 +103,61 @@ describe('managed OAuth token resolution', () => {
102103

103104
afterEach(() => {
104105
vi.useRealTimers()
106+
resetEnvMock()
107+
})
108+
109+
it('resolves a scopeless GitHub grant through the canonical provider policy', async () => {
110+
setEnv({ GITHUB_APP_CLIENT_ID: 'fixture-client', GITHUB_APP_CLIENT_SECRET: 'fixture-secret' })
111+
const adapter = createStandardOAuthCredentialGroupProviderAdapter('github-repositories')
112+
const policy = await adapter.getPolicy(undefined, { workspaceId: 'workspace-1' })
113+
expect(policy.requiredScopes).toEqual([])
114+
mocks.getAdapter.mockReturnValue(adapter)
115+
dbChainMockFns.limit.mockResolvedValueOnce([
116+
{
117+
...mondayCredentialRow(),
118+
providerId: policy.providerId,
119+
authorizationAppId: policy.authorizationAppId,
120+
managedOauthScopeVersion: policy.scopeVersion,
121+
grantedScopes: [],
122+
accessTokenExpiresAt: new Date('2026-09-01T13:00:00Z'),
123+
},
124+
])
125+
await expect(
126+
resolveManagedOAuthToken({
127+
...mondayTokenResolutionParams(),
128+
expectedProviderId: policy.providerId,
129+
requiredScopes: [],
130+
})
131+
).resolves.toMatchObject({ refreshed: false })
132+
expect(mocks.decryptSecret).toHaveBeenCalledWith('encrypted-token-set')
133+
})
134+
135+
it.each([null, undefined])(
136+
'rejects missing granted-scope metadata: %s',
137+
async (grantedScopes) => {
138+
dbChainMockFns.limit.mockResolvedValueOnce([{ ...mondayCredentialRow(), grantedScopes }])
139+
await expect(resolveManagedOAuthToken(mondayTokenResolutionParams())).rejects.toMatchObject({
140+
code: 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET',
141+
})
142+
expect(mocks.decryptSecret).not.toHaveBeenCalled()
143+
}
144+
)
145+
146+
it('rejects an empty grant when the provider policy requires scopes', async () => {
147+
mocks.getAdapter.mockReturnValue({
148+
getPolicy: vi.fn().mockResolvedValue({
149+
authorizationAppId: 'monday:monday-client-1',
150+
scopeVersion: 1,
151+
requiredScopes: ['boards:read', 'me:read'],
152+
}),
153+
hasRequiredScopes: (granted: string[], required: string[]) =>
154+
required.every((scope) => granted.includes(scope)),
155+
})
156+
dbChainMockFns.limit.mockResolvedValueOnce([{ ...mondayCredentialRow(), grantedScopes: [] }])
157+
await expect(
158+
resolveManagedOAuthToken({ ...mondayTokenResolutionParams(), requiredScopes: [] })
159+
).rejects.toMatchObject({ code: 'MANAGED_CREDENTIAL_INSUFFICIENT_SCOPE' })
160+
expect(mocks.decryptSecret).not.toHaveBeenCalled()
105161
})
106162

107163
function seedSlackSearchCredential(

apps/sim/lib/credentials/managed-oauth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ async function assertManagedCredentialUsable(
214214
401
215215
)
216216
}
217-
if (!row.authorizationAppId || !row.encryptedOauthTokenSet || !row.grantedScopes?.length) {
217+
if (!row.authorizationAppId || !row.encryptedOauthTokenSet || !row.grantedScopes) {
218218
throw new ManagedOAuthCredentialError(
219219
'MANAGED_CREDENTIAL_INVALID_TOKEN_SET',
220220
'Managed credential metadata is incomplete',
@@ -251,6 +251,7 @@ async function assertManagedCredentialUsable(
251251
}
252252

253253
if (
254+
(row.grantedScopes.length === 0 && policy.requiredScopes.length > 0) ||
254255
!adapter.hasRequiredScopes(policy.requiredScopes, requiredScopes) ||
255256
!adapter.hasRequiredScopes(row.grantedScopes, requiredScopes)
256257
) {

0 commit comments

Comments
 (0)