mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
Merge branch 'main' into bkellam/account_syncing
This commit is contained in:
commit
385e554ce3
47 changed files with 7466 additions and 346 deletions
|
|
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
- [Experimental][Sourcebot EE] Added GitLab permission syncing. [#585](https://github.com/sourcebot-dev/sourcebot/pull/585)
|
- [Experimental][Sourcebot EE] Added GitLab permission syncing. [#585](https://github.com/sourcebot-dev/sourcebot/pull/585)
|
||||||
|
- [Sourcebot EE] Added external identity provider config and support for multiple accounts. [#595](https://github.com/sourcebot-dev/sourcebot/pull/595)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- [ask sb] Fixed issue where reasoning tokens would appear in `text` content for openai compatible models. [#582](https://github.com/sourcebot-dev/sourcebot/pull/582)
|
- [ask sb] Fixed issue where reasoning tokens would appear in `text` content for openai compatible models. [#582](https://github.com/sourcebot-dev/sourcebot/pull/582)
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"docs/configuration/language-model-providers",
|
"docs/configuration/language-model-providers",
|
||||||
|
"docs/configuration/idp",
|
||||||
{
|
{
|
||||||
"group": "Authentication",
|
"group": "Authentication",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
|
|
||||||
|
|
@ -26,95 +26,5 @@ See [transactional emails](/docs/configuration/transactional-emails) for more de
|
||||||
|
|
||||||
# Enterprise Authentication Providers
|
# Enterprise Authentication Providers
|
||||||
|
|
||||||
The following authentication providers require an [enterprise license](/docs/license-key) to be enabled.
|
Sourcebot supports authentication using several different [external identity providers](/docs/configuration/idp) as well. These identity providers require an
|
||||||
|
[enterprise license](/docs/license-key)
|
||||||
### GitHub
|
|
||||||
---
|
|
||||||
|
|
||||||
[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github)
|
|
||||||
|
|
||||||
Authentication using both a **GitHub OAuth App** and a **GitHub App** is supported. In both cases, you must provide Sourcebot the `CLIENT_ID` and `SECRET_ID` and configure the
|
|
||||||
callback URL correctly (more info in Auth.js docs).
|
|
||||||
|
|
||||||
When using a **GitHub App** for auth, enable the following permissions:
|
|
||||||
- `“Email addresses” account permissions (read)`
|
|
||||||
- `"Metadata" repository permissions (read)` (only needed if enabling [permission syncing](/docs/features/permission-syncing))
|
|
||||||
|
|
||||||
**Required environment variables:**
|
|
||||||
- `AUTH_EE_GITHUB_CLIENT_ID`
|
|
||||||
- `AUTH_EE_GITHUB_CLIENT_SECRET`
|
|
||||||
|
|
||||||
Optional environment variables:
|
|
||||||
- `AUTH_EE_GITHUB_BASE_URL` - Base URL for GitHub Enterprise (defaults to https://github.com)
|
|
||||||
|
|
||||||
### GitLab
|
|
||||||
---
|
|
||||||
|
|
||||||
[Auth.js GitLab Provider Docs](https://authjs.dev/getting-started/providers/gitlab)
|
|
||||||
|
|
||||||
Authentication using GitLab is supported via a [OAuth2.0 app](https://docs.gitlab.com/integration/oauth_provider/#create-an-instance-wide-application) installed on the GitLab instance. Follow the instructions in the [GitLab docs](https://docs.gitlab.com/integration/oauth_provider/) to create an app. The callback URL should be configurd to `<sourcebot_deployment_url>/api/auth/callback/gitlab`, and the following scopes need to be set:
|
|
||||||
|
|
||||||
| Scope | Required | Notes |
|
|
||||||
|------------|----------|----------------------------------------------------------------------------------------------------|
|
|
||||||
| read_user | Yes | Allows Sourcebot to read basic user information required for authentication. |
|
|
||||||
| read_api | Conditional | Required **only** when [permission syncing](/docs/features/permission-syncing) is enabled. Enables Sourcebot to list all repositories and projects for the authenticated user. |
|
|
||||||
|
|
||||||
|
|
||||||
**Required environment variables:**
|
|
||||||
- `AUTH_EE_GITLAB_CLIENT_ID`
|
|
||||||
- `AUTH_EE_GITLAB_CLIENT_SECRET`
|
|
||||||
|
|
||||||
Optional environment variables:
|
|
||||||
- `AUTH_EE_GITLAB_BASE_URL` - Base URL for GitLab instance (defaults to https://gitlab.com)
|
|
||||||
|
|
||||||
### Google
|
|
||||||
---
|
|
||||||
|
|
||||||
[Auth.js Google Provider Docs](https://authjs.dev/getting-started/providers/google)
|
|
||||||
|
|
||||||
**Required environment variables:**
|
|
||||||
- `AUTH_EE_GOOGLE_CLIENT_ID`
|
|
||||||
- `AUTH_EE_GOOGLE_CLIENT_SECRET`
|
|
||||||
|
|
||||||
### GCP IAP
|
|
||||||
---
|
|
||||||
|
|
||||||
<Note>If you're running Sourcebot in an environment that blocks egress, make sure you allow the [IAP IP ranges](https://www.gstatic.com/ipranges/goog.json)</Note>
|
|
||||||
|
|
||||||
Custom provider built to enable automatic Sourcebot account registration/login when using GCP IAP.
|
|
||||||
|
|
||||||
**Required environment variables**
|
|
||||||
- `AUTH_EE_GCP_IAP_ENABLED`
|
|
||||||
- `AUTH_EE_GCP_IAP_AUDIENCE`
|
|
||||||
- This can be found by selecting the ⋮ icon next to the IAP-enabled backend service and pressing `Get JWT audience code`
|
|
||||||
|
|
||||||
### Okta
|
|
||||||
---
|
|
||||||
|
|
||||||
[Auth.js Okta Provider Docs](https://authjs.dev/getting-started/providers/okta)
|
|
||||||
|
|
||||||
**Required environment variables:**
|
|
||||||
- `AUTH_EE_OKTA_CLIENT_ID`
|
|
||||||
- `AUTH_EE_OKTA_CLIENT_SECRET`
|
|
||||||
- `AUTH_EE_OKTA_ISSUER`
|
|
||||||
|
|
||||||
### Keycloak
|
|
||||||
---
|
|
||||||
|
|
||||||
[Auth.js Keycloak Provider Docs](https://authjs.dev/getting-started/providers/keycloak)
|
|
||||||
|
|
||||||
**Required environment variables:**
|
|
||||||
- `AUTH_EE_KEYCLOAK_CLIENT_ID`
|
|
||||||
- `AUTH_EE_KEYCLOAK_CLIENT_SECRET`
|
|
||||||
- `AUTH_EE_KEYCLOAK_ISSUER`
|
|
||||||
|
|
||||||
### Microsoft Entra ID
|
|
||||||
|
|
||||||
[Auth.js Microsoft Entra ID Provider Docs](https://authjs.dev/getting-started/providers/microsoft-entra-id)
|
|
||||||
|
|
||||||
**Required environment variables:**
|
|
||||||
- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID`
|
|
||||||
- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET`
|
|
||||||
- `AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER`
|
|
||||||
|
|
||||||
---
|
|
||||||
371
docs/docs/configuration/idp.mdx
Normal file
371
docs/docs/configuration/idp.mdx
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
---
|
||||||
|
title: External Identity Providers
|
||||||
|
sidebarTitle: External identity providers
|
||||||
|
---
|
||||||
|
|
||||||
|
import LicenseKeyRequired from '/snippets/license-key-required.mdx'
|
||||||
|
|
||||||
|
<LicenseKeyRequired />
|
||||||
|
|
||||||
|
You can connect Sourcebot to various **external identity providers** to associate a Sourcebot user with one or more external service accounts (ex. Google, GitHub, etc).
|
||||||
|
|
||||||
|
External identity providers can be used for [authentication](/docs/configuration/auth) and/or [permission syncing](/docs/features/permission-syncing). They're defined in the
|
||||||
|
[config file](/docs/configuration/config-file) in the top-level `identityProviders` object:
|
||||||
|
|
||||||
|
```json wrap icon="code" Example config with both google and github identity providers defined
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
|
||||||
|
"identityProviders": [
|
||||||
|
{
|
||||||
|
"provider": "github",
|
||||||
|
"purpose": "account_linking",
|
||||||
|
"accountLinkingRequired": true,
|
||||||
|
/*
|
||||||
|
Secrets are provided through environment variables. Set the secret into
|
||||||
|
an env var and provide the name here to tell Sourcebot where to get
|
||||||
|
the value
|
||||||
|
*/
|
||||||
|
"clientId": {
|
||||||
|
"env": "GITHUB_IDENTITY_PROVIDER_CLIENT_ID"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"env": "GITHUB_IDENTITY_PROVIDER_CLIENT_SECRET"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"provider": "google",
|
||||||
|
"clientId": {
|
||||||
|
"env": "GOOGLE_IDENTITY_PROVIDER_CLIENT_ID"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"env": "GOOGLE_IDENTITY_PROVIDER_CLIENT_SECRET"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Supported External Identity Providers
|
||||||
|
|
||||||
|
Sourcebot uses [Auth.js](https://authjs.dev/) to connect to external identity providers. If there's a provider supported by Auth.js that you don't see below, please submit a
|
||||||
|
[feature request](https://github.com/sourcebot-dev/sourcebot/issues) to have it added.
|
||||||
|
|
||||||
|
### GitHub
|
||||||
|
|
||||||
|
[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github)
|
||||||
|
|
||||||
|
A GitHub connection can be used for either [authentication](/docs/configuration/auth) or [permission syncing](/docs/features/permission-syncing). This is controlled using the `purpose` field
|
||||||
|
in the GitHub identity provider config.
|
||||||
|
|
||||||
|
<Accordion title="instructions">
|
||||||
|
<Steps>
|
||||||
|
<Step title="Register an Oauth Client">
|
||||||
|
To begin, you must register an Oauth client in GitHub to faciliate the identity provider connection. You can do this by creating a **GitHub App** or a **GitHub OAuth App**. Either
|
||||||
|
one works, but the **GitHub App** is the [recommended mechanism](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps).
|
||||||
|
|
||||||
|
|
||||||
|
The result of registering an OAuth client is a `CLIENT_ID` and `CLIENT_SECRET` which you'll provide to Sourcebot.
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="GitHub App">
|
||||||
|
<Note>You don't need to install the app to use it as an external identity provider</Note>
|
||||||
|
Follow [this guide](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) to register a new GitHub App.
|
||||||
|
|
||||||
|
When asked to provide a callback url, provide `<sourcebot_url>/api/auth/callback/github` (ex. https://sourcebot.coolcorp.com/api/auth/callback/github)
|
||||||
|
|
||||||
|
Set the following fine-grained permissions in the GitHub App:
|
||||||
|
- `“Email addresses” account permissions (read)`
|
||||||
|
- `"Metadata" repository permissions (read)` (only needed if using permission syncing)
|
||||||
|
</Tab>
|
||||||
|
<Tab title="GitHub OAuth App">
|
||||||
|
Follow [this guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) by GitHub to create an OAuth App.
|
||||||
|
|
||||||
|
When asked to provide a callback url, provide `<sourcebot_url>/api/auth/callback/github` (ex. https://sourcebot.coolcorp.com/api/auth/callback/github)
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
</Step>
|
||||||
|
<Step title="Define environemnt variables">
|
||||||
|
To provide Sourcebot the client id and secret for your OAuth client you must set them as environment variables. These can be named whatever you like
|
||||||
|
(ex. `GITHUB_IDENTITY_PROVIDER_CLIENT_ID` and `GITHUB_IDENTITY_PROVIDER_CLIENT_SECRET`)
|
||||||
|
</Step>
|
||||||
|
<Step title="Define the identity provider config">
|
||||||
|
Finally, pass the client id and secret to Sourcebot by defining a `identityProvider` object in the [config file](/docs/configuration/config-file):
|
||||||
|
|
||||||
|
```json wrap icon="code"
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
|
||||||
|
"identityProviders": [
|
||||||
|
{
|
||||||
|
"provider": "github",
|
||||||
|
// "sso" for auth + perm sync, "account_linking" for only perm sync
|
||||||
|
"purpose": "account_linking",
|
||||||
|
// if purpose == "account_linking" this controls if a user must connect to the IdP
|
||||||
|
"accountLinkingRequired": true,
|
||||||
|
"clientId": {
|
||||||
|
"env": "YOUR_CLIENT_ID_ENV_VAR"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"env": "YOUR_CLIENT_SECRET_ENV_VAR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
### GitLab
|
||||||
|
|
||||||
|
[Auth.js GitLab Provider Docs](https://authjs.dev/getting-started/providers/gitlab)
|
||||||
|
|
||||||
|
A GitLab connection can be used for either [authentication](/docs/configuration/auth) or [permission syncing](/docs/features/permission-syncing). This is controlled using the `purpose` field
|
||||||
|
in the GitLab identity provider config.
|
||||||
|
|
||||||
|
<Accordion title="instructions">
|
||||||
|
<Steps>
|
||||||
|
<Step title="Register an OAuth Application">
|
||||||
|
To begin, you must register an OAuth application in GitLab to facilitate the identity provider connection.
|
||||||
|
|
||||||
|
Follow [this guide](https://docs.gitlab.com/integration/oauth_provider/) by GitLab to create an OAuth application.
|
||||||
|
|
||||||
|
When configuring your application:
|
||||||
|
- Set the callback URL to `<sourcebot_url>/api/auth/callback/gitlab` (ex. https://sourcebot.coolcorp.com/api/auth/callback/gitlab)
|
||||||
|
- Enable the `read_user` scope
|
||||||
|
- If using for permission syncing, also enable the `read_api` scope
|
||||||
|
|
||||||
|
The result of registering an OAuth application is an `APPLICATION_ID` (`CLIENT_ID`) and `SECRET` (`CLIENT_SECRET`) which you'll provide to Sourcebot.
|
||||||
|
</Step>
|
||||||
|
<Step title="Define environment variables">
|
||||||
|
To provide Sourcebot the client id and secret for your OAuth application you must set them as environment variables. These can be named whatever you like
|
||||||
|
(ex. `GITLAB_IDENTITY_PROVIDER_CLIENT_ID` and `GITLAB_IDENTITY_PROVIDER_CLIENT_SECRET`)
|
||||||
|
</Step>
|
||||||
|
<Step title="Define the identity provider config">
|
||||||
|
Finally, pass the client id and secret to Sourcebot by defining a `identityProvider` object in the [config file](/docs/configuration/config-file):
|
||||||
|
|
||||||
|
```json wrap icon="code"
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
|
||||||
|
"identityProviders": [
|
||||||
|
{
|
||||||
|
"provider": "gitlab",
|
||||||
|
// "sso" for auth + perm sync, "account_linking" for only perm sync
|
||||||
|
"purpose": "account_linking",
|
||||||
|
// if purpose == "account_linking" this controls if a user must connect to the IdP
|
||||||
|
"accountLinkingRequired": true,
|
||||||
|
"clientId": {
|
||||||
|
"env": "YOUR_CLIENT_ID_ENV_VAR"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"env": "YOUR_CLIENT_SECRET_ENV_VAR"
|
||||||
|
},
|
||||||
|
// Optional: for self-hosted GitLab instances
|
||||||
|
"baseUrl": "https://gitlab.example.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
### Google
|
||||||
|
|
||||||
|
[Auth.js Google Provider Docs](https://authjs.dev/getting-started/providers/google)
|
||||||
|
|
||||||
|
A Google connection can be used for [authentication](/docs/configuration/auth).
|
||||||
|
|
||||||
|
<Accordion title="instructions">
|
||||||
|
<Steps>
|
||||||
|
<Step title="Register an OAuth Client">
|
||||||
|
To begin, you must register an OAuth client in Google Cloud Console to facilitate the identity provider connection.
|
||||||
|
|
||||||
|
Follow [this guide](https://support.google.com/cloud/answer/6158849) by Google to create OAuth 2.0 credentials.
|
||||||
|
|
||||||
|
When configuring your OAuth client:
|
||||||
|
- Set the application type to "Web application"
|
||||||
|
- Add `<sourcebot_url>/api/auth/callback/google` to the authorized redirect URIs (ex. https://sourcebot.coolcorp.com/api/auth/callback/google)
|
||||||
|
|
||||||
|
The result of creating OAuth credentials is a `CLIENT_ID` and `CLIENT_SECRET` which you'll provide to Sourcebot.
|
||||||
|
</Step>
|
||||||
|
<Step title="Define environment variables">
|
||||||
|
To provide Sourcebot the client id and secret for your OAuth client you must set them as environment variables. These can be named whatever you like
|
||||||
|
(ex. `GOOGLE_IDENTITY_PROVIDER_CLIENT_ID` and `GOOGLE_IDENTITY_PROVIDER_CLIENT_SECRET`)
|
||||||
|
</Step>
|
||||||
|
<Step title="Define the identity provider config">
|
||||||
|
Finally, pass the client id and secret to Sourcebot by defining a `identityProvider` object in the [config file](/docs/configuration/config-file):
|
||||||
|
|
||||||
|
```json wrap icon="code"
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
|
||||||
|
"identityProviders": [
|
||||||
|
{
|
||||||
|
"provider": "google",
|
||||||
|
"purpose": "sso",
|
||||||
|
"clientId": {
|
||||||
|
"env": "YOUR_CLIENT_ID_ENV_VAR"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"env": "YOUR_CLIENT_SECRET_ENV_VAR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
### Okta
|
||||||
|
|
||||||
|
[Auth.js Okta Provider Docs](https://authjs.dev/getting-started/providers/okta)
|
||||||
|
|
||||||
|
An Okta connection can be used for [authentication](/docs/configuration/auth).
|
||||||
|
|
||||||
|
<Accordion title="instructions">
|
||||||
|
<Steps>
|
||||||
|
<Step title="Register an OAuth Application">
|
||||||
|
To begin, you must register an OAuth application in Okta to facilitate the identity provider connection.
|
||||||
|
|
||||||
|
Follow [this guide](https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/) by Okta to create an OAuth application.
|
||||||
|
|
||||||
|
When configuring your application:
|
||||||
|
- Set the application type to "Web Application"
|
||||||
|
- Add `<sourcebot_url>/api/auth/callback/okta` to the sign-in redirect URIs (ex. https://sourcebot.coolcorp.com/api/auth/callback/okta)
|
||||||
|
|
||||||
|
The result of creating an OAuth application is a `CLIENT_ID`, `CLIENT_SECRET`, and `ISSUER` URL which you'll provide to Sourcebot.
|
||||||
|
</Step>
|
||||||
|
<Step title="Define environment variables">
|
||||||
|
To provide Sourcebot the client id, client secret, and issuer for your OAuth application you must set them as environment variables. These can be named whatever you like
|
||||||
|
(ex. `OKTA_IDENTITY_PROVIDER_CLIENT_ID`, `OKTA_IDENTITY_PROVIDER_CLIENT_SECRET`, and `OKTA_IDENTITY_PROVIDER_ISSUER`)
|
||||||
|
</Step>
|
||||||
|
<Step title="Define the identity provider config">
|
||||||
|
Finally, pass the client id, client secret, and issuer to Sourcebot by defining a `identityProvider` object in the [config file](/docs/configuration/config-file):
|
||||||
|
|
||||||
|
```json wrap icon="code"
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
|
||||||
|
"identityProviders": [
|
||||||
|
{
|
||||||
|
"provider": "okta",
|
||||||
|
"purpose": "sso",
|
||||||
|
"clientId": {
|
||||||
|
"env": "YOUR_CLIENT_ID_ENV_VAR"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"env": "YOUR_CLIENT_SECRET_ENV_VAR"
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"env": "YOUR_ISSUER_ENV_VAR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
### Keycloak
|
||||||
|
|
||||||
|
[Auth.js Keycloak Provider Docs](https://authjs.dev/getting-started/providers/keycloak)
|
||||||
|
|
||||||
|
A Keycloak connection can be used for [authentication](/docs/configuration/auth).
|
||||||
|
|
||||||
|
<Accordion title="instructions">
|
||||||
|
<Steps>
|
||||||
|
<Step title="Register an OAuth Client">
|
||||||
|
To begin, you must register an OAuth client in Keycloak to facilitate the identity provider connection.
|
||||||
|
|
||||||
|
Follow [this guide](https://www.keycloak.org/docs/latest/server_admin/#_oidc_clients) by Keycloak to create an OpenID Connect client.
|
||||||
|
|
||||||
|
When configuring your client:
|
||||||
|
- Set the client protocol to "openid-connect"
|
||||||
|
- Set the access type to "confidential"
|
||||||
|
- Add `<sourcebot_url>/api/auth/callback/keycloak` to the valid redirect URIs (ex. https://sourcebot.coolcorp.com/api/auth/callback/keycloak)
|
||||||
|
|
||||||
|
The result of creating an OAuth client is a `CLIENT_ID`, `CLIENT_SECRET`, and an `ISSUER` URL (typically in the format `https://<keycloak-domain>/realms/<realm-name>`) which you'll provide to Sourcebot.
|
||||||
|
</Step>
|
||||||
|
<Step title="Define environment variables">
|
||||||
|
To provide Sourcebot the client id, client secret, and issuer for your OAuth client you must set them as environment variables. These can be named whatever you like
|
||||||
|
(ex. `KEYCLOAK_IDENTITY_PROVIDER_CLIENT_ID`, `KEYCLOAK_IDENTITY_PROVIDER_CLIENT_SECRET`, and `KEYCLOAK_IDENTITY_PROVIDER_ISSUER`)
|
||||||
|
</Step>
|
||||||
|
<Step title="Define the identity provider config">
|
||||||
|
Finally, pass the client id, client secret, and issuer to Sourcebot by defining a `identityProvider` object in the [config file](/docs/configuration/config-file):
|
||||||
|
|
||||||
|
```json wrap icon="code"
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
|
||||||
|
"identityProviders": [
|
||||||
|
{
|
||||||
|
"provider": "keycloak",
|
||||||
|
"purpose": "sso",
|
||||||
|
"clientId": {
|
||||||
|
"env": "YOUR_CLIENT_ID_ENV_VAR"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"env": "YOUR_CLIENT_SECRET_ENV_VAR"
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"env": "YOUR_ISSUER_ENV_VAR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
### Microsoft Entra ID
|
||||||
|
|
||||||
|
[Auth.js Microsoft Entra ID Provider Docs](https://authjs.dev/getting-started/providers/microsoft-entra-id)
|
||||||
|
|
||||||
|
A Microsoft Entra ID connection can be used for [authentication](/docs/configuration/auth).
|
||||||
|
|
||||||
|
<Accordion title="instructions">
|
||||||
|
<Steps>
|
||||||
|
<Step title="Register an OAuth Application">
|
||||||
|
To begin, you must register an OAuth application in Microsoft Entra ID (formerly Azure Active Directory) to facilitate the identity provider connection.
|
||||||
|
|
||||||
|
Follow [this guide](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) by Microsoft to register an application.
|
||||||
|
|
||||||
|
When configuring your application:
|
||||||
|
- Under "Authentication", add a platform and select "Web"
|
||||||
|
- Set the redirect URI to `<sourcebot_url>/api/auth/callback/microsoft-entra-id` (ex. https://sourcebot.coolcorp.com/api/auth/callback/microsoft-entra-id)
|
||||||
|
- Under "Certificates & secrets", create a new client secret
|
||||||
|
|
||||||
|
The result of registering an application is a `CLIENT_ID` (Application ID), `CLIENT_SECRET`, and `TENANT_ID` which you'll use to construct the issuer URL.
|
||||||
|
</Step>
|
||||||
|
<Step title="Define environment variables">
|
||||||
|
To provide Sourcebot the client id, client secret, and issuer for your OAuth application you must set them as environment variables. These can be named whatever you like
|
||||||
|
(ex. `MICROSOFT_ENTRA_ID_IDENTITY_PROVIDER_CLIENT_ID`, `MICROSOFT_ENTRA_ID_IDENTITY_PROVIDER_CLIENT_SECRET`, and `MICROSOFT_ENTRA_ID_IDENTITY_PROVIDER_ISSUER`)
|
||||||
|
|
||||||
|
The issuer URL should be in the format: `https://login.microsoftonline.com/<TENANT_ID>/v2.0`
|
||||||
|
</Step>
|
||||||
|
<Step title="Define the identity provider config">
|
||||||
|
Finally, pass the client id, client secret, and issuer to Sourcebot by defining a `identityProvider` object in the [config file](/docs/configuration/config-file):
|
||||||
|
|
||||||
|
```json wrap icon="code"
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
|
||||||
|
"identityProviders": [
|
||||||
|
{
|
||||||
|
"provider": "microsoft-entra-id",
|
||||||
|
"purpose": "sso",
|
||||||
|
"clientId": {
|
||||||
|
"env": "YOUR_CLIENT_ID_ENV_VAR"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"env": "YOUR_CLIENT_SECRET_ENV_VAR"
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"env": "YOUR_ISSUER_ENV_VAR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
|
@ -12,10 +12,12 @@ import ExperimentalFeatureWarning from '/snippets/experimental-feature-warning.m
|
||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
|
|
||||||
Permission syncing allows you to sync Access Permission Lists (ACLs) from a code host to Sourcebot. When configured, users signed into Sourcebot (via the code host's OAuth provider) will only be able to access repositories that they have access to on the code host. Practically, this means:
|
Permission syncing allows you to sync Access Permission Lists (ACLs) from a code host to Sourcebot. When configured, users signed into Sourcebot will only be able to access repositories
|
||||||
|
that they have access to on the code host. Practically, this means:
|
||||||
|
|
||||||
- Code Search results will only include repositories that the user has access to.
|
- Code Search results will only include repositories that the user has access to.
|
||||||
- Code navigation results will only include repositories that the user has access to.
|
- Code navigation results will only include repositories that the user has access to.
|
||||||
|
- MCP results will only include results from repositories the user has access to.
|
||||||
- Ask Sourcebot (and the underlying LLM) will only have access to repositories that the user has access to.
|
- Ask Sourcebot (and the underlying LLM) will only have access to repositories that the user has access to.
|
||||||
- File browsing is scoped to the repositories that the user has access to.
|
- File browsing is scoped to the repositories that the user has access to.
|
||||||
|
|
||||||
|
|
@ -46,7 +48,7 @@ We are actively working on supporting more code hosts. If you'd like to see a sp
|
||||||
|
|
||||||
## GitHub
|
## GitHub
|
||||||
|
|
||||||
Prerequisite: [Add GitHub as an OAuth provider](/docs/configuration/auth/providers#github).
|
Prerequisite: Configure GitHub as an [external identity provider](/docs/configuration/idp).
|
||||||
|
|
||||||
Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and **GitHub Enterprise Server**. For organization-owned repositories, users that have **read-only** access (or above) via the following methods will have their access synced to Sourcebot:
|
Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and **GitHub Enterprise Server**. For organization-owned repositories, users that have **read-only** access (or above) via the following methods will have their access synced to Sourcebot:
|
||||||
- Outside collaborators
|
- Outside collaborators
|
||||||
|
|
@ -56,18 +58,18 @@ Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and *
|
||||||
- Organization owners.
|
- Organization owners.
|
||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
- A GitHub OAuth provider must be configured to (1) correlate a Sourcebot user with a GitHub user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
|
- A GitHub [external identity provider](/docs/configuration/idp) must be configured to (1) correlate a Sourcebot user with a GitHub user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
|
||||||
- OAuth tokens must assume the `repo` scope in order to use the [List repositories for the authenticated user API](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user) during [User driven syncing](/docs/features/permission-syncing#how-it-works). Sourcebot **will only** use this token for **reads**.
|
- OAuth tokens must assume the `repo` scope in order to use the [List repositories for the authenticated user API](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user) during [User driven syncing](/docs/features/permission-syncing#how-it-works). Sourcebot **will only** use this token for **reads**.
|
||||||
|
|
||||||
## GitLab
|
## GitLab
|
||||||
|
|
||||||
Prerequisite: [Add GitLab as an OAuth provider](/docs/configuration/auth/providers#gitlab).
|
Prerequisite: Configure GitLab as an [external identity provider](/docs/configuration/idp).
|
||||||
|
|
||||||
Permission syncing works with **GitLab Self-managed** and **GitLab Cloud**. Users with **Guest** role or above with membership to a group or project will have their access synced to Sourcebot. Both direct and indirect membership to a group or project will be synced with Sourcebot. For more details, see the [GitLab docs](https://docs.gitlab.com/user/project/members/#membership-types).
|
Permission syncing works with **GitLab Self-managed** and **GitLab Cloud**. Users with **Guest** role or above with membership to a group or project will have their access synced to Sourcebot. Both direct and indirect membership to a group or project will be synced with Sourcebot. For more details, see the [GitLab docs](https://docs.gitlab.com/user/project/members/#membership-types).
|
||||||
|
|
||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
- A GitLab OAuth provider must be configured to (1) correlate a Sourcebot user with a GitLab user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
|
- A GitLab [external identity provider](/docs/configuration/idp) must be configured to (1) correlate a Sourcebot user with a GitLab user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
|
||||||
- OAuth tokens require the `read_api` scope in order to use the [List projects for the authenticated user API](https://docs.gitlab.com/ee/api/projects.html#list-all-projects) during [User driven syncing](/docs/features/permission-syncing#how-it-works).
|
- OAuth tokens require the `read_api` scope in order to use the [List projects for the authenticated user API](https://docs.gitlab.com/ee/api/projects.html#list-all-projects) during [User driven syncing](/docs/features/permission-syncing#how-it-works).
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ sidebarTitle: License key
|
||||||
If you'd like a trial license, [reach out](https://www.sourcebot.dev/contact) and we'll send one over within 24 hours
|
If you'd like a trial license, [reach out](https://www.sourcebot.dev/contact) and we'll send one over within 24 hours
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
All core Sourcebot features are available [FSL licensed](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license) without any limits. Some additional features require a license key. See the [pricing page](https://www.sourcebot.dev/pricing) for more details.
|
All core Sourcebot features are available under the [FSL license](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license). Some additional features require a license key. See the [pricing page](https://www.sourcebot.dev/pricing) for more details.
|
||||||
|
|
||||||
|
|
||||||
## Activating a license key
|
## Activating a license key
|
||||||
|
|
@ -25,7 +25,7 @@ docker run \
|
||||||
## Feature availability
|
## Feature availability
|
||||||
---
|
---
|
||||||
|
|
||||||
| Feature | OSS | Licensed |
|
| Feature | [FSL](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license) | [Enterprise](https://github.com/sourcebot-dev/sourcebot/blob/main/ee/LICENSE) |
|
||||||
|:---------|:-----|:----------|
|
|:---------|:-----|:----------|
|
||||||
| [Search](/docs/features/search/syntax-reference) | ✅ | ✅ |
|
| [Search](/docs/features/search/syntax-reference) | ✅ | ✅ |
|
||||||
| [Full code host support](/docs/connections/overview) | ✅ | ✅ |
|
| [Full code host support](/docs/connections/overview) | ✅ | ✅ |
|
||||||
|
|
@ -34,6 +34,7 @@ docker run \
|
||||||
| [Login with credentials](/docs/configuration/auth/overview) | ✅ | ✅ |
|
| [Login with credentials](/docs/configuration/auth/overview) | ✅ | ✅ |
|
||||||
| [Login with email codes](/docs/configuration/auth/overview) | ✅ | ✅ |
|
| [Login with email codes](/docs/configuration/auth/overview) | ✅ | ✅ |
|
||||||
| [Login with SSO](/docs/configuration/auth/overview#enterprise-authentication-providers) | 🛑 | ✅ |
|
| [Login with SSO](/docs/configuration/auth/overview#enterprise-authentication-providers) | 🛑 | ✅ |
|
||||||
|
| [Permission syncing](/docs/features/permission-syncing) | 🛑 | ✅ |
|
||||||
| [Code navigation](/docs/features/code-navigation) | 🛑 | ✅ |
|
| [Code navigation](/docs/features/code-navigation) | 🛑 | ✅ |
|
||||||
| [Search contexts](/docs/features/search/search-contexts) | 🛑 | ✅ |
|
| [Search contexts](/docs/features/search/search-contexts) | 🛑 | ✅ |
|
||||||
| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ |
|
| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ |
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"const": "githubApp",
|
"const": "github",
|
||||||
"description": "GitHub App Configuration"
|
"description": "GitHub App Configuration"
|
||||||
},
|
},
|
||||||
"deploymentHostname": {
|
"deploymentHostname": {
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"const": "githubApp",
|
"const": "github",
|
||||||
"description": "GitHub App Configuration"
|
"description": "GitHub App Configuration"
|
||||||
},
|
},
|
||||||
"deploymentHostname": {
|
"deploymentHostname": {
|
||||||
|
|
|
||||||
1299
docs/snippets/schemas/v3/identityProvider.schema.mdx
Normal file
1299
docs/snippets/schemas/v3/identityProvider.schema.mdx
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -50,7 +50,7 @@ export class GithubAppManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const githubApps = config.apps.filter(app => app.type === 'githubApp') as GitHubAppConfig[];
|
const githubApps = config.apps.filter(app => app.type === 'github') as GitHubAppConfig[];
|
||||||
logger.info(`Found ${githubApps.length} GitHub apps in config`);
|
logger.info(`Found ${githubApps.length} GitHub apps in config`);
|
||||||
|
|
||||||
for (const app of githubApps) {
|
for (const app of githubApps) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"const": "githubApp",
|
"const": "github",
|
||||||
"description": "GitHub App Configuration"
|
"description": "GitHub App Configuration"
|
||||||
},
|
},
|
||||||
"deploymentHostname": {
|
"deploymentHostname": {
|
||||||
|
|
@ -69,7 +69,7 @@ const schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"const": "githubApp",
|
"const": "github",
|
||||||
"description": "GitHub App Configuration"
|
"description": "GitHub App Configuration"
|
||||||
},
|
},
|
||||||
"deploymentHostname": {
|
"deploymentHostname": {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export interface GitHubAppConfig {
|
||||||
/**
|
/**
|
||||||
* GitHub App Configuration
|
* GitHub App Configuration
|
||||||
*/
|
*/
|
||||||
type: "githubApp";
|
type: "github";
|
||||||
/**
|
/**
|
||||||
* The hostname of the GitHub App deployment.
|
* The hostname of the GitHub App deployment.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
1298
packages/schemas/src/v3/identityProvider.schema.ts
Normal file
1298
packages/schemas/src/v3/identityProvider.schema.ts
Normal file
File diff suppressed because it is too large
Load diff
257
packages/schemas/src/v3/identityProvider.type.ts
Normal file
257
packages/schemas/src/v3/identityProvider.type.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
|
||||||
|
|
||||||
|
export type IdentityProviderConfig =
|
||||||
|
| GitHubIdentityProviderConfig
|
||||||
|
| GitLabIdentityProviderConfig
|
||||||
|
| GoogleIdentityProviderConfig
|
||||||
|
| OktaIdentityProviderConfig
|
||||||
|
| KeycloakIdentityProviderConfig
|
||||||
|
| MicrosoftEntraIDIdentityProviderConfig
|
||||||
|
| GCPIAPIdentityProviderConfig;
|
||||||
|
|
||||||
|
export interface GitHubIdentityProviderConfig {
|
||||||
|
provider: "github";
|
||||||
|
purpose: "sso" | "account_linking";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* The URL of the GitHub host. Defaults to https://github.com
|
||||||
|
*/
|
||||||
|
baseUrl?: string;
|
||||||
|
accountLinkingRequired?: boolean;
|
||||||
|
}
|
||||||
|
export interface GitLabIdentityProviderConfig {
|
||||||
|
provider: "gitlab";
|
||||||
|
purpose: "sso" | "account_linking";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* The URL of the GitLab host. Defaults to https://gitlab.com
|
||||||
|
*/
|
||||||
|
baseUrl?: string;
|
||||||
|
accountLinkingRequired?: boolean;
|
||||||
|
}
|
||||||
|
export interface GoogleIdentityProviderConfig {
|
||||||
|
provider: "google";
|
||||||
|
purpose: "sso";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface OktaIdentityProviderConfig {
|
||||||
|
provider: "okta";
|
||||||
|
purpose: "sso";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
issuer:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface KeycloakIdentityProviderConfig {
|
||||||
|
provider: "keycloak";
|
||||||
|
purpose: "sso";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
issuer:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface MicrosoftEntraIDIdentityProviderConfig {
|
||||||
|
provider: "microsoft-entra-id";
|
||||||
|
purpose: "sso";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
issuer:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface GCPIAPIdentityProviderConfig {
|
||||||
|
provider: "gcp-iap";
|
||||||
|
purpose: "sso";
|
||||||
|
audience:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -26,6 +26,14 @@ export type LanguageModel =
|
||||||
| OpenRouterLanguageModel
|
| OpenRouterLanguageModel
|
||||||
| XaiLanguageModel;
|
| XaiLanguageModel;
|
||||||
export type AppConfig = GitHubAppConfig;
|
export type AppConfig = GitHubAppConfig;
|
||||||
|
export type IdentityProviderConfig =
|
||||||
|
| GitHubIdentityProviderConfig
|
||||||
|
| GitLabIdentityProviderConfig
|
||||||
|
| GoogleIdentityProviderConfig
|
||||||
|
| OktaIdentityProviderConfig
|
||||||
|
| KeycloakIdentityProviderConfig
|
||||||
|
| MicrosoftEntraIDIdentityProviderConfig
|
||||||
|
| GCPIAPIdentityProviderConfig;
|
||||||
|
|
||||||
export interface SourcebotConfig {
|
export interface SourcebotConfig {
|
||||||
$schema?: string;
|
$schema?: string;
|
||||||
|
|
@ -50,6 +58,10 @@ export interface SourcebotConfig {
|
||||||
* Defines a collection of apps that are available to Sourcebot.
|
* Defines a collection of apps that are available to Sourcebot.
|
||||||
*/
|
*/
|
||||||
apps?: AppConfig[];
|
apps?: AppConfig[];
|
||||||
|
/**
|
||||||
|
* Defines a collection of identity providers that are available to Sourcebot.
|
||||||
|
*/
|
||||||
|
identityProviders?: IdentityProviderConfig[];
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Defines the global settings for Sourcebot.
|
* Defines the global settings for Sourcebot.
|
||||||
|
|
@ -1078,7 +1090,7 @@ export interface GitHubAppConfig {
|
||||||
/**
|
/**
|
||||||
* GitHub App Configuration
|
* GitHub App Configuration
|
||||||
*/
|
*/
|
||||||
type: "githubApp";
|
type: "github";
|
||||||
/**
|
/**
|
||||||
* The hostname of the GitHub App deployment.
|
* The hostname of the GitHub App deployment.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1104,3 +1116,249 @@ export interface GitHubAppConfig {
|
||||||
googleCloudSecret: string;
|
googleCloudSecret: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export interface GitHubIdentityProviderConfig {
|
||||||
|
provider: "github";
|
||||||
|
purpose: "sso" | "account_linking";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* The URL of the GitHub host. Defaults to https://github.com
|
||||||
|
*/
|
||||||
|
baseUrl?: string;
|
||||||
|
accountLinkingRequired?: boolean;
|
||||||
|
}
|
||||||
|
export interface GitLabIdentityProviderConfig {
|
||||||
|
provider: "gitlab";
|
||||||
|
purpose: "sso" | "account_linking";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* The URL of the GitLab host. Defaults to https://gitlab.com
|
||||||
|
*/
|
||||||
|
baseUrl?: string;
|
||||||
|
accountLinkingRequired?: boolean;
|
||||||
|
}
|
||||||
|
export interface GoogleIdentityProviderConfig {
|
||||||
|
provider: "google";
|
||||||
|
purpose: "sso";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface OktaIdentityProviderConfig {
|
||||||
|
provider: "okta";
|
||||||
|
purpose: "sso";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
issuer:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface KeycloakIdentityProviderConfig {
|
||||||
|
provider: "keycloak";
|
||||||
|
purpose: "sso";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
issuer:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface MicrosoftEntraIDIdentityProviderConfig {
|
||||||
|
provider: "microsoft-entra-id";
|
||||||
|
purpose: "sso";
|
||||||
|
clientId:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
clientSecret:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
issuer:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface GCPIAPIdentityProviderConfig {
|
||||||
|
provider: "gcp-iap";
|
||||||
|
purpose: "sso";
|
||||||
|
audience:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The resource name of a Google Cloud secret. Must be in the format `projects/<project-id>/secrets/<secret-name>/versions/<version-id>`. See https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets
|
||||||
|
*/
|
||||||
|
googleCloudSecret: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,11 @@ export const loadJsonFile = async <T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadConfig = async (configPath: string): Promise<SourcebotConfig> => {
|
export const loadConfig = async (configPath?: string): Promise<SourcebotConfig> => {
|
||||||
|
if (!configPath) {
|
||||||
|
throw new Error('CONFIG_PATH is required but not provided');
|
||||||
|
}
|
||||||
|
|
||||||
const configContent = await (async () => {
|
const configContent = await (async () => {
|
||||||
if (isRemotePath(configPath)) {
|
if (isRemotePath(configPath)) {
|
||||||
const response = await fetch(configPath);
|
const response = await fetch(configPath);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { UpgradeGuard } from "./components/upgradeGuard";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { getSelectorsByUserAgent } from "react-device-detect";
|
import { getSelectorsByUserAgent } from "react-device-detect";
|
||||||
import { MobileUnsupportedSplashScreen } from "./components/mobileUnsupportedSplashScreen";
|
import { MobileUnsupportedSplashScreen } from "./components/mobileUnsupportedSplashScreen";
|
||||||
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/constants";
|
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME } from "@/lib/constants";
|
||||||
import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide";
|
import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide";
|
||||||
import { SyntaxGuideProvider } from "./components/syntaxGuideProvider";
|
import { SyntaxGuideProvider } from "./components/syntaxGuideProvider";
|
||||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||||
|
|
@ -23,6 +23,8 @@ import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
|
||||||
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||||
import { GitHubStarToast } from "./components/githubStarToast";
|
import { GitHubStarToast } from "./components/githubStarToast";
|
||||||
import { UpgradeToast } from "./components/upgradeToast";
|
import { UpgradeToast } from "./components/upgradeToast";
|
||||||
|
import { getLinkedAccountProviderStates } from "@/ee/features/permissionSyncing/actions";
|
||||||
|
import { LinkAccounts } from "@/ee/features/permissionSyncing/components/linkAccounts";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode,
|
||||||
|
|
@ -123,6 +125,42 @@ export default async function Layout(props: LayoutProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasEntitlement("permission-syncing")) {
|
||||||
|
const linkedAccountProviderStates = await getLinkedAccountProviderStates();
|
||||||
|
if (isServiceError(linkedAccountProviderStates)) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center p-6">
|
||||||
|
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-md p-6 max-w-md w-full text-center">
|
||||||
|
<h2 className="text-lg font-semibold text-red-800 mb-2">An error occurred</h2>
|
||||||
|
<p className="text-red-700 mb-1">
|
||||||
|
{typeof linkedAccountProviderStates.message === 'string'
|
||||||
|
? linkedAccountProviderStates.message
|
||||||
|
: "A server error occurred while checking your account status. Please try again or contact support."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUnlinkedProviders = linkedAccountProviderStates.some(state => state.isLinked === false);
|
||||||
|
if (hasUnlinkedProviders) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const hasSkippedOptional = cookieStore.has(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME);
|
||||||
|
|
||||||
|
const hasUnlinkedRequiredProviders = linkedAccountProviderStates.some(state => state.required && !state.isLinked)
|
||||||
|
const shouldShowLinkAccounts = hasUnlinkedRequiredProviders || !hasSkippedOptional;
|
||||||
|
if (shouldShowLinkAccounts) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-6">
|
||||||
|
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
|
||||||
|
<LinkAccounts linkedAccountProviderStates={linkedAccountProviderStates} callbackUrl={`/${domain}`}/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (IS_BILLING_ENABLED) {
|
if (IS_BILLING_ENABLED) {
|
||||||
const subscription = await getSubscriptionInfo(domain);
|
const subscription = await getSubscriptionInfo(domain);
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
import { getOrgFromDomain } from "@/data/org";
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
import { OrgRole } from "@prisma/client";
|
import { OrgRole } from "@prisma/client";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
|
import { hasEntitlement } from "@sourcebot/shared";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -68,6 +69,8 @@ export default async function SettingsLayout(
|
||||||
throw new ServiceErrorException(connectionStats);
|
throw new ServiceErrorException(connectionStats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasPermissionSyncingEntitlement = await hasEntitlement("permission-syncing");
|
||||||
|
|
||||||
const sidebarNavItems: SidebarNavItem[] = [
|
const sidebarNavItems: SidebarNavItem[] = [
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
|
|
@ -114,6 +117,12 @@ export default async function SettingsLayout(
|
||||||
title: "Analytics",
|
title: "Analytics",
|
||||||
href: `/${domain}/settings/analytics`,
|
href: `/${domain}/settings/analytics`,
|
||||||
},
|
},
|
||||||
|
...(hasPermissionSyncingEntitlement ? [
|
||||||
|
{
|
||||||
|
title: "Linked Accounts",
|
||||||
|
href: `/${domain}/settings/permission-syncing`,
|
||||||
|
}
|
||||||
|
] : []),
|
||||||
...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [
|
...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [
|
||||||
{
|
{
|
||||||
title: "License",
|
title: "License",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { hasEntitlement } from "@sourcebot/shared";
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { LinkedAccountsSettings } from "@/ee/features/permissionSyncing/components/linkedAccountsSettings";
|
||||||
|
|
||||||
|
export default async function PermissionSyncingPage() {
|
||||||
|
const hasPermissionSyncingEntitlement = await hasEntitlement("permission-syncing");
|
||||||
|
if (!hasPermissionSyncingEntitlement) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LinkedAccountsSettings />;
|
||||||
|
}
|
||||||
|
|
@ -8,10 +8,10 @@ import { CredentialsForm } from "@/app/login/components/credentialsForm";
|
||||||
import { DividerSet } from "@/app/components/dividerSet";
|
import { DividerSet } from "@/app/components/dividerSet";
|
||||||
import { ProviderButton } from "@/app/components/providerButton";
|
import { ProviderButton } from "@/app/components/providerButton";
|
||||||
import { AuthSecurityNotice } from "@/app/components/authSecurityNotice";
|
import { AuthSecurityNotice } from "@/app/components/authSecurityNotice";
|
||||||
import type { AuthProvider } from "@/lib/authProviders";
|
import type { IdentityProviderMetadata } from "@/lib/identityProviders";
|
||||||
|
|
||||||
interface AuthMethodSelectorProps {
|
interface AuthMethodSelectorProps {
|
||||||
providers: AuthProvider[];
|
providers: IdentityProviderMetadata[];
|
||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
context: "login" | "signup";
|
context: "login" | "signup";
|
||||||
onProviderClick?: (providerId: string) => void;
|
onProviderClick?: (providerId: string) => void;
|
||||||
|
|
@ -35,11 +35,11 @@ export const AuthMethodSelector = ({
|
||||||
}, [callbackUrl, onProviderClick]);
|
}, [callbackUrl, onProviderClick]);
|
||||||
|
|
||||||
// Separate OAuth providers from special auth methods
|
// Separate OAuth providers from special auth methods
|
||||||
const oauthProviders = providers.filter(p =>
|
const oauthProviders = providers.filter(p => p.purpose === "sso" &&
|
||||||
!["credentials", "nodemailer"].includes(p.id)
|
!["credentials", "nodemailer"].includes(p.id)
|
||||||
);
|
);
|
||||||
const hasCredentials = providers.some(p => p.id === "credentials");
|
const hasCredentials = providers.some(p => p.purpose === "sso" && p.id === "credentials");
|
||||||
const hasMagicLink = providers.some(p => p.id === "nodemailer");
|
const hasMagicLink = providers.some(p => p.purpose === "sso" && p.id === "nodemailer");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
import { AuthMethodSelector } from "@/app/components/authMethodSelector";
|
import { AuthMethodSelector } from "@/app/components/authMethodSelector";
|
||||||
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||||
import { getAuthProviders } from "@/lib/authProviders";
|
import { getIdentityProviderMetadata, IdentityProviderMetadata } from "@/lib/identityProviders";
|
||||||
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
|
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
|
||||||
|
|
||||||
interface InvitePageProps {
|
interface InvitePageProps {
|
||||||
|
|
@ -30,7 +30,7 @@ export default async function InvitePage(props: InvitePageProps) {
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const providers = getAuthProviders();
|
const providers = getIdentityProviderMetadata();
|
||||||
return <WelcomeCard inviteLinkId={inviteLinkId} providers={providers} />;
|
return <WelcomeCard inviteLinkId={inviteLinkId} providers={providers} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ export default async function InvitePage(props: InvitePageProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WelcomeCard({ inviteLinkId, providers }: { inviteLinkId: string; providers: import("@/lib/authProviders").AuthProvider[] }) {
|
function WelcomeCard({ inviteLinkId, providers }: { inviteLinkId: string; providers: IdentityProviderMetadata[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-[var(--background)] to-[var(--accent)]/30 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-gradient-to-br from-[var(--background)] to-[var(--accent)]/30 flex items-center justify-center p-6">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
import { AuthMethodSelector } from "@/app/components/authMethodSelector";
|
import { AuthMethodSelector } from "@/app/components/authMethodSelector";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { AuthProvider } from "@/lib/authProviders";
|
import type { IdentityProviderMetadata } from "@/lib/identityProviders";
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
providers: AuthProvider[];
|
providers: IdentityProviderMetadata[];
|
||||||
context: "login" | "signup";
|
context: "login" | "signup";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { auth } from "@/auth";
|
||||||
import { LoginForm } from "./components/loginForm";
|
import { LoginForm } from "./components/loginForm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Footer } from "@/app/components/footer";
|
import { Footer } from "@/app/components/footer";
|
||||||
import { getAuthProviders } from "@/lib/authProviders";
|
import { getIdentityProviderMetadata } from "@/lib/identityProviders";
|
||||||
import { getOrgFromDomain } from "@/data/org";
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ export default async function Login(props: LoginProps) {
|
||||||
return redirect("/onboard");
|
return redirect("/onboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
const providers = getAuthProviders();
|
const providers = getIdentityProviderMetadata();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-backgroundSecondary">
|
<div className="flex flex-col min-h-screen bg-backgroundSecondary">
|
||||||
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
|
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||||
import { AuthMethodSelector } from "@/app/components/authMethodSelector"
|
import { AuthMethodSelector } from "@/app/components/authMethodSelector"
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { getAuthProviders } from "@/lib/authProviders";
|
import { getIdentityProviderMetadata } from "@/lib/identityProviders";
|
||||||
import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings";
|
import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings";
|
||||||
import { CompleteOnboardingButton } from "./components/completeOnboardingButton";
|
import { CompleteOnboardingButton } from "./components/completeOnboardingButton";
|
||||||
import { getOrgFromDomain } from "@/data/org";
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
|
@ -41,7 +41,7 @@ interface ResourceCard {
|
||||||
|
|
||||||
export default async function Onboarding(props: OnboardingProps) {
|
export default async function Onboarding(props: OnboardingProps) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const providers = getAuthProviders();
|
const providers = getIdentityProviderMetadata();
|
||||||
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { LoginForm } from "../login/components/loginForm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Footer } from "@/app/components/footer";
|
import { Footer } from "@/app/components/footer";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { getAuthProviders } from "@/lib/authProviders";
|
import { getIdentityProviderMetadata } from "@/lib/identityProviders";
|
||||||
import { getOrgFromDomain } from "@/data/org";
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ export default async function Signup(props: LoginProps) {
|
||||||
return redirect("/onboard");
|
return redirect("/onboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
const providers = getAuthProviders();
|
const providers = getIdentityProviderMetadata();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-backgroundSecondary">
|
<div className="flex flex-col min-h-screen bg-backgroundSecondary">
|
||||||
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
|
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
|
||||||
|
|
|
||||||
|
|
@ -13,39 +13,54 @@ import { createTransport } from 'nodemailer';
|
||||||
import { render } from '@react-email/render';
|
import { render } from '@react-email/render';
|
||||||
import MagicLinkEmail from './emails/magicLinkEmail';
|
import MagicLinkEmail from './emails/magicLinkEmail';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { getSSOProviders } from '@/ee/features/sso/sso';
|
import { getEEIdentityProviders } from '@/ee/features/sso/sso';
|
||||||
import { hasEntitlement } from '@sourcebot/shared';
|
import { hasEntitlement } from '@sourcebot/shared';
|
||||||
import { onCreateUser } from '@/lib/authUtils';
|
import { onCreateUser } from '@/lib/authUtils';
|
||||||
import { getAuditService } from '@/ee/features/audit/factory';
|
import { getAuditService } from '@/ee/features/audit/factory';
|
||||||
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
|
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
|
||||||
|
import { refreshLinkedAccountTokens } from '@/ee/features/permissionSyncing/tokenRefresh';
|
||||||
|
|
||||||
const auditService = getAuditService();
|
const auditService = getAuditService();
|
||||||
|
const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : [];
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export type IdentityProvider = {
|
||||||
|
provider: Provider;
|
||||||
|
purpose: "sso" | "account_linking";
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LinkedAccountToken = {
|
||||||
|
provider: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresAt: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
export type LinkedAccountTokensMap = Record<string, LinkedAccountToken>;
|
||||||
|
|
||||||
declare module 'next-auth' {
|
declare module 'next-auth' {
|
||||||
interface Session {
|
interface Session {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
} & DefaultSession['user'];
|
} & DefaultSession['user'];
|
||||||
|
linkedAccountProviderErrors?: Record<string, string>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'next-auth/jwt' {
|
declare module 'next-auth/jwt' {
|
||||||
interface JWT {
|
interface JWT {
|
||||||
userId: string
|
userId: string;
|
||||||
|
linkedAccountTokens?: LinkedAccountTokensMap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProviders = () => {
|
export const getProviders = () => {
|
||||||
const providers: Provider[] = [];
|
const providers: IdentityProvider[] = eeIdentityProviders;
|
||||||
|
|
||||||
if (hasEntitlement("sso")) {
|
|
||||||
providers.push(...getSSOProviders());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS && env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true') {
|
if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS && env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true') {
|
||||||
providers.push(EmailProvider({
|
providers.push({ provider: EmailProvider({
|
||||||
server: env.SMTP_CONNECTION_URL,
|
server: env.SMTP_CONNECTION_URL,
|
||||||
from: env.EMAIL_FROM_ADDRESS,
|
from: env.EMAIL_FROM_ADDRESS,
|
||||||
maxAge: 60 * 10,
|
maxAge: 60 * 10,
|
||||||
|
|
@ -69,11 +84,11 @@ export const getProviders = () => {
|
||||||
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
|
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}), purpose: "sso"});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.AUTH_CREDENTIALS_LOGIN_ENABLED === 'true') {
|
if (env.AUTH_CREDENTIALS_LOGIN_ENABLED === 'true') {
|
||||||
providers.push(Credentials({
|
providers.push({ provider: Credentials({
|
||||||
credentials: {
|
credentials: {
|
||||||
email: {},
|
email: {},
|
||||||
password: {}
|
password: {}
|
||||||
|
|
@ -126,7 +141,7 @@ export const getProviders = () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}), purpose: "sso"});
|
||||||
}
|
}
|
||||||
|
|
||||||
return providers;
|
return providers;
|
||||||
|
|
@ -176,13 +191,30 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user: _user }) {
|
async jwt({ token, user: _user, account }) {
|
||||||
const user = _user as User | undefined;
|
const user = _user as User | undefined;
|
||||||
// @note: `user` will be available on signUp or signIn triggers.
|
// @note: `user` will be available on signUp or signIn triggers.
|
||||||
// Cache the userId in the JWT for later use.
|
// Cache the userId in the JWT for later use.
|
||||||
if (user) {
|
if (user) {
|
||||||
token.userId = user.id;
|
token.userId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasEntitlement('permission-syncing')) {
|
||||||
|
if (account && account.access_token && account.refresh_token && account.expires_at) {
|
||||||
|
token.linkedAccountTokens = token.linkedAccountTokens || {};
|
||||||
|
token.linkedAccountTokens[account.providerAccountId] = {
|
||||||
|
provider: account.provider,
|
||||||
|
accessToken: account.access_token,
|
||||||
|
refreshToken: account.refresh_token,
|
||||||
|
expiresAt: account.expires_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.linkedAccountTokens) {
|
||||||
|
token.linkedAccountTokens = await refreshLinkedAccountTokens(token.linkedAccountTokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
|
|
@ -193,10 +225,23 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
// Propagate the userId to the session.
|
// Propagate the userId to the session.
|
||||||
id: token.userId,
|
id: token.userId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass only linked account provider errors to the session (not sensitive tokens)
|
||||||
|
if (token.linkedAccountTokens) {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
for (const [providerAccountId, tokenData] of Object.entries(token.linkedAccountTokens)) {
|
||||||
|
if (tokenData.error) {
|
||||||
|
errors[providerAccountId] = tokenData.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
session.linkedAccountProviderErrors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
providers: getProviders(),
|
providers: getProviders().map((provider) => provider.provider),
|
||||||
pages: {
|
pages: {
|
||||||
signIn: "/login",
|
signIn: "/login",
|
||||||
// We set redirect to false in signInOptions so we can pass the email is as a param
|
// We set redirect to false in signInOptions so we can pass the email is as a param
|
||||||
|
|
|
||||||
107
packages/web/src/ee/features/permissionSyncing/actions.ts
Normal file
107
packages/web/src/ee/features/permissionSyncing/actions.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { sew } from "@/actions";
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { withAuthV2, withMinimumOrgRole } from "@/withAuthV2";
|
||||||
|
import { loadConfig } from "@sourcebot/shared";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
import { OrgRole } from "@sourcebot/db";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME } from "@/lib/constants";
|
||||||
|
import { LinkedAccountProviderState } from "@/ee/features/permissionSyncing/types";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
const logger = createLogger('web-ee-permission-syncing-actions');
|
||||||
|
|
||||||
|
export const getLinkedAccountProviderStates = async () => sew(() =>
|
||||||
|
withAuthV2(async ({ prisma, role, user }) =>
|
||||||
|
withMinimumOrgRole(role, OrgRole.MEMBER, async () => {
|
||||||
|
const config = await loadConfig(env.CONFIG_PATH);
|
||||||
|
const linkedAccountProviderConfigs = config.identityProviders ?? [];
|
||||||
|
const linkedAccounts = await prisma.account.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
provider: {
|
||||||
|
in: linkedAccountProviderConfigs.map(p => p.provider)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
provider: true,
|
||||||
|
providerAccountId: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch the session to get token errors
|
||||||
|
const session = await auth();
|
||||||
|
const providerErrors = session?.linkedAccountProviderErrors;
|
||||||
|
|
||||||
|
const linkedAccountProviderState: LinkedAccountProviderState[] = [];
|
||||||
|
for (const linkedAccountProviderConfig of linkedAccountProviderConfigs) {
|
||||||
|
if (linkedAccountProviderConfig.purpose === "account_linking") {
|
||||||
|
const linkedAccount = linkedAccounts.find(
|
||||||
|
account => account.provider === linkedAccountProviderConfig.provider
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLinked = !!linkedAccount;
|
||||||
|
const isRequired = linkedAccountProviderConfig.accountLinkingRequired ?? false;
|
||||||
|
const providerError = linkedAccount ? providerErrors?.[linkedAccount.providerAccountId] : undefined;
|
||||||
|
|
||||||
|
linkedAccountProviderState.push({
|
||||||
|
id: linkedAccountProviderConfig.provider,
|
||||||
|
required: isRequired,
|
||||||
|
isLinked,
|
||||||
|
linkedAccountId: linkedAccount?.providerAccountId,
|
||||||
|
error: providerError
|
||||||
|
} as LinkedAccountProviderState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return linkedAccountProviderState;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export const unlinkLinkedAccountProvider = async (provider: string) => sew(() =>
|
||||||
|
withAuthV2(async ({ prisma, role, user }) =>
|
||||||
|
withMinimumOrgRole(role, OrgRole.MEMBER, async () => {
|
||||||
|
const config = await loadConfig(env.CONFIG_PATH);
|
||||||
|
const identityProviders = config.identityProviders ?? [];
|
||||||
|
|
||||||
|
const providerConfig = identityProviders.find(idp => idp.provider === provider)
|
||||||
|
if (!providerConfig || providerConfig.purpose !== "account_linking") {
|
||||||
|
throw new Error("Provider is not a linked account provider");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the account
|
||||||
|
const result = await prisma.account.deleteMany({
|
||||||
|
where: {
|
||||||
|
provider,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Unlinked account provider ${provider} for user ${user.id}. Deleted ${result.count} account(s).`);
|
||||||
|
|
||||||
|
// If we're unlinking a required identity provider then we want to wipe the optional skip cookie if it exists so that we give the
|
||||||
|
// user the option of linking optional providers in the same link accounts screen
|
||||||
|
const isRequired = providerConfig.accountLinkingRequired ?? false;
|
||||||
|
if (isRequired) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, count: result.count };
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const skipOptionalProvidersLink = async () => sew(async () => {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME, 'true', {
|
||||||
|
httpOnly: false, // Allow client-side access
|
||||||
|
maxAge: 365 * 24 * 60 * 60, // 1 year in seconds
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { skipOptionalProvidersLink } from "@/ee/features/permissionSyncing/actions";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LinkedAccountProviderCard } from "./linkedAccountProviderCard";
|
||||||
|
import { LinkedAccountProviderState } from "@/ee/features/permissionSyncing/types";
|
||||||
|
|
||||||
|
interface LinkAccountsProps {
|
||||||
|
linkedAccountProviderStates: LinkedAccountProviderState[]
|
||||||
|
callbackUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkAccounts = ({ linkedAccountProviderStates, callbackUrl }: LinkAccountsProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSkipping, setIsSkipping] = useState(false);
|
||||||
|
|
||||||
|
const handleSkip = async () => {
|
||||||
|
setIsSkipping(true);
|
||||||
|
try {
|
||||||
|
await skipOptionalProvidersLink();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to skip optional providers:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSkipping(false);
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSkip = !linkedAccountProviderStates.some(state => state.required && !state.isLinked);
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Connect Your Accounts</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Link the following accounts to enable permission syncing and access all features.
|
||||||
|
<br />
|
||||||
|
You can manage your linked accounts later in <strong>Settings → Linked Accounts.</strong>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{linkedAccountProviderStates
|
||||||
|
.sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
|
||||||
|
.map(state => (
|
||||||
|
<LinkedAccountProviderCard
|
||||||
|
key={state.id}
|
||||||
|
linkedAccountProviderState={state}
|
||||||
|
callbackUrl={callbackUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{canSkip && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={isSkipping}
|
||||||
|
>
|
||||||
|
{isSkipping ? "Skipping..." : "Skip for now"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Link2 } from "lucide-react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
|
||||||
|
interface LinkButtonProps {
|
||||||
|
provider: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkButton = ({ provider, callbackUrl }: LinkButtonProps) => {
|
||||||
|
const handleLink = () => {
|
||||||
|
signIn(provider, {
|
||||||
|
redirectTo: callbackUrl
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleLink}
|
||||||
|
className="transition-all hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { getAuthProviderInfo } from "@/lib/utils";
|
||||||
|
import { Check, X, AlertCircle } from "lucide-react";
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ProviderIcon } from "./providerIcon";
|
||||||
|
import { ProviderInfo } from "./providerInfo";
|
||||||
|
import { UnlinkButton } from "./unlinkButton";
|
||||||
|
import { LinkButton } from "./linkButton";
|
||||||
|
import { LinkedAccountProviderState } from "@/ee/features/permissionSyncing/types"
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||||
|
|
||||||
|
interface LinkedAccountProviderCardProps {
|
||||||
|
linkedAccountProviderState: LinkedAccountProviderState;
|
||||||
|
callbackUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkedAccountProviderCard({
|
||||||
|
linkedAccountProviderState,
|
||||||
|
callbackUrl,
|
||||||
|
}: LinkedAccountProviderCardProps) {
|
||||||
|
const providerInfo = getAuthProviderInfo(linkedAccountProviderState.id);
|
||||||
|
const defaultCallbackUrl = `/${SINGLE_TENANT_ORG_DOMAIN}/settings/permission-syncing`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<ProviderIcon
|
||||||
|
icon={providerInfo.icon}
|
||||||
|
displayName={providerInfo.displayName}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
<ProviderInfo
|
||||||
|
providerId={linkedAccountProviderState.id}
|
||||||
|
required={linkedAccountProviderState.required}
|
||||||
|
showBadge={true}
|
||||||
|
/>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{linkedAccountProviderState.isLinked? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Check className="h-3.5 w-3.5 text-green-600 dark:text-green-500" />
|
||||||
|
<span className="text-green-600 dark:text-green-500 font-medium">
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{linkedAccountProviderState.linkedAccountId && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<span className="text-muted-foreground font-mono truncate">
|
||||||
|
{linkedAccountProviderState.linkedAccountId}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Not connected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{linkedAccountProviderState.error && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
<span className="text-destructive font-medium">
|
||||||
|
Token refresh failed - please reconnect
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 ml-4">
|
||||||
|
{linkedAccountProviderState.isLinked? (
|
||||||
|
<UnlinkButton
|
||||||
|
provider={linkedAccountProviderState.id}
|
||||||
|
providerName={providerInfo.displayName}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LinkButton
|
||||||
|
provider={linkedAccountProviderState.id}
|
||||||
|
callbackUrl={callbackUrl ?? defaultCallbackUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { getLinkedAccountProviderStates } from "@/ee/features/permissionSyncing/actions"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { LinkedAccountProviderCard } from "./linkedAccountProviderCard";
|
||||||
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
|
||||||
|
export async function LinkedAccountsSettings() {
|
||||||
|
const linkedAccountProviderStates = await getLinkedAccountProviderStates();
|
||||||
|
if (isServiceError(linkedAccountProviderStates)) {
|
||||||
|
return <div className="min-h-screen flex flex-col items-center justify-center p-6">
|
||||||
|
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-md p-6 max-w-md w-full text-center">
|
||||||
|
<h2 className="text-lg font-semibold text-red-800 mb-2">An error occurred</h2>
|
||||||
|
<p className="text-red-700 mb-1">
|
||||||
|
{typeof linkedAccountProviderStates.message === 'string'
|
||||||
|
? linkedAccountProviderStates.message
|
||||||
|
: "A server error occurred while checking your account status. Please try again or contact support."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Linked Accounts</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage your linked account integrations for permission syncing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkedAccountProviderStates.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-3 mb-4">
|
||||||
|
<ShieldCheck className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground mb-1">No integration providers configured</p>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-sm">
|
||||||
|
Contact your administrator to configure integration providers for your organization.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{linkedAccountProviderStates
|
||||||
|
.sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
|
||||||
|
.map((state) => {
|
||||||
|
return (
|
||||||
|
<LinkedAccountProviderCard
|
||||||
|
key={state.id}
|
||||||
|
linkedAccountProviderState={state}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface ProviderBadgeProps {
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderBadge({ required }: ProviderBadgeProps) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={required ? "default" : "secondary"}
|
||||||
|
className="text-xs font-medium"
|
||||||
|
>
|
||||||
|
{required ? "Required" : "Optional"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
|
interface ProviderIconProps {
|
||||||
|
icon?: {
|
||||||
|
src: string;
|
||||||
|
className?: string;
|
||||||
|
} | null;
|
||||||
|
displayName: string;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: {
|
||||||
|
container: "h-8 w-8",
|
||||||
|
icon: "h-4 w-4"
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
container: "h-10 w-10",
|
||||||
|
icon: "h-5 w-5"
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
container: "h-12 w-12",
|
||||||
|
icon: "h-6 w-6"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeDimensions = {
|
||||||
|
sm: { width: 16, height: 16 },
|
||||||
|
md: { width: 20, height: 20 },
|
||||||
|
lg: { width: 24, height: 24 }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProviderIcon({ icon, displayName, size = "md" }: ProviderIconProps) {
|
||||||
|
const sizes = sizeClasses[size];
|
||||||
|
const dimensions = sizeDimensions[size];
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
return (
|
||||||
|
<div className={`${sizes.container} rounded-md border border-border bg-background flex items-center justify-center`}>
|
||||||
|
<Image
|
||||||
|
src={icon.src}
|
||||||
|
alt={displayName}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
className={`${sizes.icon} ${icon.className || ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${sizes.container} rounded-lg border border-border flex items-center justify-center bg-muted`}>
|
||||||
|
<ShieldCheck className={`${sizes.icon} text-muted-foreground`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { getAuthProviderInfo } from "@/lib/utils";
|
||||||
|
import { ProviderBadge } from "./providerBadge";
|
||||||
|
|
||||||
|
interface ProviderInfoProps {
|
||||||
|
providerId: string;
|
||||||
|
required: boolean;
|
||||||
|
showBadge?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderInfo({ providerId, required, showBadge = true }: ProviderInfoProps) {
|
||||||
|
const providerInfo = getAuthProviderInfo(providerId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{providerInfo.displayName}
|
||||||
|
</span>
|
||||||
|
{showBadge && <ProviderBadge required={required} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Unlink, Loader2 } from "lucide-react";
|
||||||
|
import { unlinkLinkedAccountProvider } from "../actions";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
|
||||||
|
interface UnlinkButtonProps {
|
||||||
|
provider: string;
|
||||||
|
providerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnlinkButton = ({ provider, providerName }: UnlinkButtonProps) => {
|
||||||
|
const [isUnlinking, setIsUnlinking] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleUnlink = async () => {
|
||||||
|
if (!confirm(`Are you sure you want to disconnect your ${providerName} account?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUnlinking(true);
|
||||||
|
try {
|
||||||
|
const result = await unlinkLinkedAccountProvider(provider);
|
||||||
|
|
||||||
|
if (isServiceError(result)) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Failed to disconnect account. ${result.message}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setIsUnlinking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: `✅ ${providerName} account disconnected successfully.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh the page to show updated state
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Failed to disconnect account. ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setIsUnlinking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleUnlink}
|
||||||
|
disabled={isUnlinking}
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
>
|
||||||
|
{isUnlinking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
Disconnecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Unlink className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Disconnect
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
165
packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts
Normal file
165
packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { loadConfig } from "@sourcebot/shared";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { getTokenFromConfig } from '@sourcebot/crypto';
|
||||||
|
import { GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type";
|
||||||
|
import { LinkedAccountTokensMap } from "@/auth"
|
||||||
|
const { prisma } = await import('@/prisma');
|
||||||
|
|
||||||
|
const logger = createLogger('web-ee-token-refresh');
|
||||||
|
|
||||||
|
export async function refreshLinkedAccountTokens(
|
||||||
|
currentTokens: LinkedAccountTokensMap | undefined
|
||||||
|
): Promise<LinkedAccountTokensMap> {
|
||||||
|
if (!currentTokens) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const bufferTimeS = 5 * 60; // 5 minutes
|
||||||
|
|
||||||
|
const updatedTokens: LinkedAccountTokensMap = { ...currentTokens };
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(currentTokens).map(async ([providerAccountId, tokenData]) => {
|
||||||
|
const provider = tokenData.provider;
|
||||||
|
if (provider !== 'github' && provider !== 'gitlab') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenData.expiresAt && now >= (tokenData.expiresAt - bufferTimeS)) {
|
||||||
|
try {
|
||||||
|
logger.info(`Refreshing token for providerAccountId: ${providerAccountId} (${tokenData.provider})`);
|
||||||
|
const refreshedTokens = await refreshOAuthToken(
|
||||||
|
provider,
|
||||||
|
tokenData.refreshToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (refreshedTokens) {
|
||||||
|
await prisma.account.update({
|
||||||
|
where: {
|
||||||
|
provider_providerAccountId: {
|
||||||
|
provider: provider,
|
||||||
|
providerAccountId: providerAccountId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
access_token: refreshedTokens.accessToken,
|
||||||
|
refresh_token: refreshedTokens.refreshToken,
|
||||||
|
expires_at: refreshedTokens.expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
updatedTokens[providerAccountId] = {
|
||||||
|
provider: tokenData.provider,
|
||||||
|
accessToken: refreshedTokens.accessToken,
|
||||||
|
refreshToken: refreshedTokens.refreshToken ?? tokenData.refreshToken,
|
||||||
|
expiresAt: refreshedTokens.expiresAt,
|
||||||
|
};
|
||||||
|
logger.info(`Successfully refreshed token for provider: ${provider}`);
|
||||||
|
} else {
|
||||||
|
logger.error(`Failed to refresh token for provider: ${provider}`);
|
||||||
|
updatedTokens[providerAccountId] = {
|
||||||
|
...tokenData,
|
||||||
|
error: 'RefreshTokenError',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error refreshing token for provider ${provider}:`, error);
|
||||||
|
updatedTokens[providerAccountId] = {
|
||||||
|
...tokenData,
|
||||||
|
error: 'RefreshTokenError',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return updatedTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshOAuthToken(
|
||||||
|
provider: string,
|
||||||
|
refreshToken: string,
|
||||||
|
): Promise<{ accessToken: string; refreshToken: string | null; expiresAt: number } | null> {
|
||||||
|
try {
|
||||||
|
const config = await loadConfig(env.CONFIG_PATH);
|
||||||
|
const identityProviders = config?.identityProviders ?? [];
|
||||||
|
|
||||||
|
const providerConfigs = identityProviders.filter(idp => idp.provider === provider);
|
||||||
|
if (providerConfigs.length === 0) {
|
||||||
|
logger.error(`Provider config not found or invalid for: ${provider}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop through all provider configs and return on first successful fetch
|
||||||
|
//
|
||||||
|
// The reason we have to do this is because 1) we might have multiple providers of the same type (ex. we're connecting to multiple gitlab instances) and 2) there isn't
|
||||||
|
// a trivial way to map a provider config to the associated Account object in the DB. The reason the config is involved at all here is because we need the client
|
||||||
|
// id/secret in order to refresh the token, and that info is in the config. We could in theory bypass this by storing the client id/secret for the provider in the
|
||||||
|
// Account table but we decided not to do that since these are secret. Instead, we simply try all of the client/id secrets for the associated provider type. This is safe
|
||||||
|
// to do because only the correct client id/secret will work since we're using a specific refresh token.
|
||||||
|
for (const providerConfig of providerConfigs) {
|
||||||
|
try {
|
||||||
|
// Get client credentials from config
|
||||||
|
const linkedAccountProviderConfig = providerConfig as GitHubIdentityProviderConfig | GitLabIdentityProviderConfig
|
||||||
|
const clientId = await getTokenFromConfig(linkedAccountProviderConfig.clientId);
|
||||||
|
const clientSecret = await getTokenFromConfig(linkedAccountProviderConfig.clientSecret);
|
||||||
|
const baseUrl = linkedAccountProviderConfig.baseUrl
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
if (baseUrl) {
|
||||||
|
url = provider === 'github'
|
||||||
|
? `${baseUrl}/login/oauth/access_token`
|
||||||
|
: `${baseUrl}/oauth/token`;
|
||||||
|
} else if (provider === 'github') {
|
||||||
|
url = 'https://github.com/login/oauth/access_token';
|
||||||
|
} else if (provider === 'gitlab') {
|
||||||
|
url = 'https://gitlab.com/oauth/token';
|
||||||
|
} else {
|
||||||
|
logger.error(`Unsupported provider for token refresh: ${provider}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.debug(`Failed to refresh ${provider} token with config: ${response.status} ${errorText}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token ?? null,
|
||||||
|
expiresAt: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (configError) {
|
||||||
|
logger.debug(`Error trying provider config for ${provider}:`, configError);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(`All provider configs failed for: ${provider}`);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error refreshing ${provider} token:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/web/src/ee/features/permissionSyncing/types.ts
Normal file
7
packages/web/src/ee/features/permissionSyncing/types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export type LinkedAccountProviderState = {
|
||||||
|
id: string;
|
||||||
|
required: boolean;
|
||||||
|
isLinked: boolean;
|
||||||
|
linkedAccountId?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { Provider } from "next-auth/providers";
|
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
import GitHub from "next-auth/providers/github";
|
import GitHub from "next-auth/providers/github";
|
||||||
import Google from "next-auth/providers/google";
|
import Google from "next-auth/providers/google";
|
||||||
|
|
@ -10,174 +9,266 @@ import { prisma } from "@/prisma";
|
||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
import type { User as AuthJsUser } from "next-auth";
|
import type { User as AuthJsUser } from "next-auth";
|
||||||
|
import type { Provider } from "next-auth/providers";
|
||||||
import { onCreateUser } from "@/lib/authUtils";
|
import { onCreateUser } from "@/lib/authUtils";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { hasEntitlement } from "@sourcebot/shared";
|
import { hasEntitlement, loadConfig } from "@sourcebot/shared";
|
||||||
|
import { getTokenFromConfig } from "@sourcebot/crypto";
|
||||||
|
import type { IdentityProvider } from "@/auth";
|
||||||
|
import { GCPIAPIdentityProviderConfig, GitHubIdentityProviderConfig, GitLabIdentityProviderConfig, GoogleIdentityProviderConfig, KeycloakIdentityProviderConfig, MicrosoftEntraIDIdentityProviderConfig, OktaIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type";
|
||||||
|
|
||||||
const logger = createLogger('web-sso');
|
const logger = createLogger('web-sso');
|
||||||
|
|
||||||
export const getSSOProviders = (): Provider[] => {
|
const GITHUB_CLOUD_HOSTNAME = "github.com"
|
||||||
const providers: Provider[] = [];
|
|
||||||
|
|
||||||
if (env.AUTH_EE_GITHUB_CLIENT_ID && env.AUTH_EE_GITHUB_CLIENT_SECRET) {
|
export const getEEIdentityProviders = async (): Promise<IdentityProvider[]> => {
|
||||||
providers.push(GitHub({
|
const providers: IdentityProvider[] = [];
|
||||||
clientId: env.AUTH_EE_GITHUB_CLIENT_ID,
|
|
||||||
clientSecret: env.AUTH_EE_GITHUB_CLIENT_SECRET,
|
const config = await loadConfig(env.CONFIG_PATH);
|
||||||
enterprise: {
|
const identityProviders = config?.identityProviders ?? [];
|
||||||
baseUrl: env.AUTH_EE_GITHUB_BASE_URL,
|
|
||||||
|
for (const identityProvider of identityProviders) {
|
||||||
|
if (identityProvider.provider === "github") {
|
||||||
|
const providerConfig = identityProvider as GitHubIdentityProviderConfig;
|
||||||
|
const clientId = await getTokenFromConfig(providerConfig.clientId);
|
||||||
|
const clientSecret = await getTokenFromConfig(providerConfig.clientSecret);
|
||||||
|
const baseUrl = providerConfig.baseUrl;
|
||||||
|
providers.push({ provider: createGitHubProvider(clientId, clientSecret, baseUrl), purpose: providerConfig.purpose, required: providerConfig.accountLinkingRequired ?? false});
|
||||||
|
}
|
||||||
|
if (identityProvider.provider === "gitlab") {
|
||||||
|
const providerConfig = identityProvider as GitLabIdentityProviderConfig;
|
||||||
|
const clientId = await getTokenFromConfig(providerConfig.clientId);
|
||||||
|
const clientSecret = await getTokenFromConfig(providerConfig.clientSecret);
|
||||||
|
const baseUrl = providerConfig.baseUrl;
|
||||||
|
providers.push({ provider: createGitLabProvider(clientId, clientSecret, baseUrl), purpose: providerConfig.purpose, required: providerConfig.accountLinkingRequired ?? false});
|
||||||
|
}
|
||||||
|
if (identityProvider.provider === "google") {
|
||||||
|
const providerConfig = identityProvider as GoogleIdentityProviderConfig;
|
||||||
|
const clientId = await getTokenFromConfig(providerConfig.clientId);
|
||||||
|
const clientSecret = await getTokenFromConfig(providerConfig.clientSecret);
|
||||||
|
providers.push({ provider: createGoogleProvider(clientId, clientSecret), purpose: providerConfig.purpose});
|
||||||
|
}
|
||||||
|
if (identityProvider.provider === "okta") {
|
||||||
|
const providerConfig = identityProvider as OktaIdentityProviderConfig;
|
||||||
|
const clientId = await getTokenFromConfig(providerConfig.clientId);
|
||||||
|
const clientSecret = await getTokenFromConfig(providerConfig.clientSecret);
|
||||||
|
const issuer = await getTokenFromConfig(providerConfig.issuer);
|
||||||
|
providers.push({ provider: createOktaProvider(clientId, clientSecret, issuer), purpose: providerConfig.purpose});
|
||||||
|
}
|
||||||
|
if (identityProvider.provider === "keycloak") {
|
||||||
|
const providerConfig = identityProvider as KeycloakIdentityProviderConfig;
|
||||||
|
const clientId = await getTokenFromConfig(providerConfig.clientId);
|
||||||
|
const clientSecret = await getTokenFromConfig(providerConfig.clientSecret);
|
||||||
|
const issuer = await getTokenFromConfig(providerConfig.issuer);
|
||||||
|
providers.push({ provider: createKeycloakProvider(clientId, clientSecret, issuer), purpose: providerConfig.purpose });
|
||||||
|
}
|
||||||
|
if (identityProvider.provider === "microsoft-entra-id") {
|
||||||
|
const providerConfig = identityProvider as MicrosoftEntraIDIdentityProviderConfig;
|
||||||
|
const clientId = await getTokenFromConfig(providerConfig.clientId);
|
||||||
|
const clientSecret = await getTokenFromConfig(providerConfig.clientSecret);
|
||||||
|
const issuer = await getTokenFromConfig(providerConfig.issuer);
|
||||||
|
providers.push({ provider: createMicrosoftEntraIDProvider(clientId, clientSecret, issuer), purpose: providerConfig.purpose });
|
||||||
|
}
|
||||||
|
if (identityProvider.provider === "gcp-iap") {
|
||||||
|
const providerConfig = identityProvider as GCPIAPIdentityProviderConfig;
|
||||||
|
const audience = await getTokenFromConfig(providerConfig.audience);
|
||||||
|
providers.push({ provider: createGCPIAPProvider(audience), purpose: providerConfig.purpose });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @deprecate in favor of defining identity providers throught the identityProvider object in the config file. This was done to allow for more control over
|
||||||
|
// which identity providers are defined and their purpose. We've left this logic here to support backwards compat with deployments that expect these env vars,
|
||||||
|
// but this logic will be removed in the future
|
||||||
|
// We only go through this path if no identityProviders are defined in the config to prevent accidental duplication of providers
|
||||||
|
if (identityProviders.length == 0) {
|
||||||
|
if (env.AUTH_EE_GITHUB_CLIENT_ID && env.AUTH_EE_GITHUB_CLIENT_SECRET) {
|
||||||
|
providers.push({ provider: createGitHubProvider(env.AUTH_EE_GITHUB_CLIENT_ID, env.AUTH_EE_GITHUB_CLIENT_SECRET, env.AUTH_EE_GITHUB_BASE_URL), purpose: "sso" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.AUTH_EE_GITLAB_CLIENT_ID && env.AUTH_EE_GITLAB_CLIENT_SECRET) {
|
||||||
|
providers.push({ provider: createGitLabProvider(env.AUTH_EE_GITLAB_CLIENT_ID, env.AUTH_EE_GITLAB_CLIENT_SECRET, env.AUTH_EE_GITLAB_BASE_URL), purpose: "sso" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.AUTH_EE_GOOGLE_CLIENT_ID && env.AUTH_EE_GOOGLE_CLIENT_SECRET) {
|
||||||
|
providers.push({ provider: createGoogleProvider(env.AUTH_EE_GOOGLE_CLIENT_ID, env.AUTH_EE_GOOGLE_CLIENT_SECRET), purpose: "sso" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.AUTH_EE_OKTA_CLIENT_ID && env.AUTH_EE_OKTA_CLIENT_SECRET && env.AUTH_EE_OKTA_ISSUER) {
|
||||||
|
providers.push({ provider: createOktaProvider(env.AUTH_EE_OKTA_CLIENT_ID, env.AUTH_EE_OKTA_CLIENT_SECRET, env.AUTH_EE_OKTA_ISSUER), purpose: "sso" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.AUTH_EE_KEYCLOAK_CLIENT_ID && env.AUTH_EE_KEYCLOAK_CLIENT_SECRET && env.AUTH_EE_KEYCLOAK_ISSUER) {
|
||||||
|
providers.push({ provider: createKeycloakProvider(env.AUTH_EE_KEYCLOAK_CLIENT_ID, env.AUTH_EE_KEYCLOAK_CLIENT_SECRET, env.AUTH_EE_KEYCLOAK_ISSUER), purpose: "sso" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID && env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET && env.AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER) {
|
||||||
|
providers.push({ provider: createMicrosoftEntraIDProvider(env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID, env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET, env.AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER), purpose: "sso" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
|
||||||
|
providers.push({ provider: createGCPIAPProvider(env.AUTH_EE_GCP_IAP_AUDIENCE), purpose: "sso" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createGitHubProvider = (clientId: string, clientSecret: string, baseUrl?: string): Provider => {
|
||||||
|
const hostname = baseUrl ? new URL(baseUrl).hostname : GITHUB_CLOUD_HOSTNAME
|
||||||
|
return GitHub({
|
||||||
|
clientId: clientId,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
...(hostname === GITHUB_CLOUD_HOSTNAME ? { enterprise: { baseUrl: baseUrl } } : {}), // if this is set the provider expects GHE so we need this check
|
||||||
|
authorization: {
|
||||||
|
params: {
|
||||||
|
scope: [
|
||||||
|
'read:user',
|
||||||
|
'user:email',
|
||||||
|
// Permission syncing requires the `repo` scope in order to fetch repositories
|
||||||
|
// for the authenticated user.
|
||||||
|
// @see: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user
|
||||||
|
...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ?
|
||||||
|
['repo'] :
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
].join(' '),
|
||||||
},
|
},
|
||||||
authorization: {
|
},
|
||||||
params: {
|
});
|
||||||
scope: [
|
}
|
||||||
'read:user',
|
|
||||||
'user:email',
|
const createGitLabProvider = (clientId: string, clientSecret: string, baseUrl?: string): Provider => {
|
||||||
// Permission syncing requires the `repo` scope in order to fetch repositories
|
const url = baseUrl ?? 'https://gitlab.com';
|
||||||
// for the authenticated user.
|
return Gitlab({
|
||||||
// @see: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user
|
clientId: clientId,
|
||||||
...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ?
|
clientSecret: clientSecret,
|
||||||
['repo'] :
|
authorization: {
|
||||||
[]
|
url: `${url}/oauth/authorize`,
|
||||||
),
|
params: {
|
||||||
].join(' '),
|
scope: [
|
||||||
},
|
"read_user",
|
||||||
|
// Permission syncing requires the `read_api` scope in order to fetch projects
|
||||||
|
// for the authenticated user and project members.
|
||||||
|
// @see: https://docs.gitlab.com/ee/api/projects.html#list-all-projects
|
||||||
|
...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ?
|
||||||
|
['read_api'] :
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
].join(' '),
|
||||||
},
|
},
|
||||||
}));
|
},
|
||||||
}
|
token: {
|
||||||
|
url: `${url}/oauth/token`,
|
||||||
|
},
|
||||||
|
userinfo: {
|
||||||
|
url: `${url}/api/v4/user`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (env.AUTH_EE_GITLAB_CLIENT_ID && env.AUTH_EE_GITLAB_CLIENT_SECRET) {
|
const createGoogleProvider = (clientId: string, clientSecret: string): Provider => {
|
||||||
providers.push(Gitlab({
|
return Google({
|
||||||
clientId: env.AUTH_EE_GITLAB_CLIENT_ID,
|
clientId: clientId,
|
||||||
clientSecret: env.AUTH_EE_GITLAB_CLIENT_SECRET,
|
clientSecret: clientSecret,
|
||||||
authorization: {
|
});
|
||||||
url: `${env.AUTH_EE_GITLAB_BASE_URL}/oauth/authorize`,
|
}
|
||||||
params: {
|
|
||||||
scope: [
|
|
||||||
"read_user",
|
|
||||||
// Permission syncing requires the `read_api` scope in order to fetch projects
|
|
||||||
// for the authenticated user and project members.
|
|
||||||
// @see: https://docs.gitlab.com/ee/api/projects.html#list-all-projects
|
|
||||||
...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ?
|
|
||||||
['read_api'] :
|
|
||||||
[]
|
|
||||||
),
|
|
||||||
].join(' '),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
token: {
|
|
||||||
url: `${env.AUTH_EE_GITLAB_BASE_URL}/oauth/token`,
|
|
||||||
},
|
|
||||||
userinfo: {
|
|
||||||
url: `${env.AUTH_EE_GITLAB_BASE_URL}/api/v4/user`,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.AUTH_EE_GOOGLE_CLIENT_ID && env.AUTH_EE_GOOGLE_CLIENT_SECRET) {
|
const createOktaProvider = (clientId: string, clientSecret: string, issuer: string): Provider => {
|
||||||
providers.push(Google({
|
return Okta({
|
||||||
clientId: env.AUTH_EE_GOOGLE_CLIENT_ID,
|
clientId: clientId,
|
||||||
clientSecret: env.AUTH_EE_GOOGLE_CLIENT_SECRET,
|
clientSecret: clientSecret,
|
||||||
}));
|
issuer: issuer,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (env.AUTH_EE_OKTA_CLIENT_ID && env.AUTH_EE_OKTA_CLIENT_SECRET && env.AUTH_EE_OKTA_ISSUER) {
|
const createKeycloakProvider = (clientId: string, clientSecret: string, issuer: string): Provider => {
|
||||||
providers.push(Okta({
|
return Keycloak({
|
||||||
clientId: env.AUTH_EE_OKTA_CLIENT_ID,
|
clientId: clientId,
|
||||||
clientSecret: env.AUTH_EE_OKTA_CLIENT_SECRET,
|
clientSecret: clientSecret,
|
||||||
issuer: env.AUTH_EE_OKTA_ISSUER,
|
issuer: issuer,
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.AUTH_EE_KEYCLOAK_CLIENT_ID && env.AUTH_EE_KEYCLOAK_CLIENT_SECRET && env.AUTH_EE_KEYCLOAK_ISSUER) {
|
const createMicrosoftEntraIDProvider = (clientId: string, clientSecret: string, issuer: string): Provider => {
|
||||||
providers.push(Keycloak({
|
return MicrosoftEntraID({
|
||||||
clientId: env.AUTH_EE_KEYCLOAK_CLIENT_ID,
|
clientId: clientId,
|
||||||
clientSecret: env.AUTH_EE_KEYCLOAK_CLIENT_SECRET,
|
clientSecret: clientSecret,
|
||||||
issuer: env.AUTH_EE_KEYCLOAK_ISSUER,
|
issuer: issuer,
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID && env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET && env.AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER) {
|
const createGCPIAPProvider = (audience: string): Provider => {
|
||||||
providers.push(MicrosoftEntraID({
|
return Credentials({
|
||||||
clientId: env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID,
|
id: "gcp-iap",
|
||||||
clientSecret: env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET,
|
name: "Google Cloud IAP",
|
||||||
issuer: env.AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER,
|
credentials: {},
|
||||||
}));
|
authorize: async (credentials, req) => {
|
||||||
}
|
try {
|
||||||
|
const iapAssertion = req.headers?.get("x-goog-iap-jwt-assertion");
|
||||||
if (env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
|
if (!iapAssertion || typeof iapAssertion !== "string") {
|
||||||
providers.push(Credentials({
|
logger.warn("No IAP assertion found in headers");
|
||||||
id: "gcp-iap",
|
|
||||||
name: "Google Cloud IAP",
|
|
||||||
credentials: {},
|
|
||||||
authorize: async (credentials, req) => {
|
|
||||||
try {
|
|
||||||
const iapAssertion = req.headers?.get("x-goog-iap-jwt-assertion");
|
|
||||||
if (!iapAssertion || typeof iapAssertion !== "string") {
|
|
||||||
logger.warn("No IAP assertion found in headers");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oauth2Client = new OAuth2Client();
|
|
||||||
|
|
||||||
const { pubkeys } = await oauth2Client.getIapPublicKeys();
|
|
||||||
const ticket = await oauth2Client.verifySignedJwtWithCertsAsync(
|
|
||||||
iapAssertion,
|
|
||||||
pubkeys,
|
|
||||||
env.AUTH_EE_GCP_IAP_AUDIENCE,
|
|
||||||
['https://cloud.google.com/iap']
|
|
||||||
);
|
|
||||||
|
|
||||||
const payload = ticket.getPayload();
|
|
||||||
if (!payload) {
|
|
||||||
logger.warn("Invalid IAP token payload");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = payload.email;
|
|
||||||
const name = payload.name || payload.email;
|
|
||||||
const image = payload.picture;
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
logger.warn("Missing email in IAP token");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
|
||||||
where: { email }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingUser) {
|
|
||||||
const newUser = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
image,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const authJsUser: AuthJsUser = {
|
|
||||||
id: newUser.id,
|
|
||||||
email: newUser.email,
|
|
||||||
name: newUser.name,
|
|
||||||
image: newUser.image,
|
|
||||||
};
|
|
||||||
|
|
||||||
await onCreateUser({ user: authJsUser });
|
|
||||||
return authJsUser;
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
id: existingUser.id,
|
|
||||||
email: existingUser.email,
|
|
||||||
name: existingUser.name,
|
|
||||||
image: existingUser.image,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error verifying IAP token:", error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return providers;
|
const oauth2Client = new OAuth2Client();
|
||||||
|
|
||||||
|
const { pubkeys } = await oauth2Client.getIapPublicKeys();
|
||||||
|
const ticket = await oauth2Client.verifySignedJwtWithCertsAsync(
|
||||||
|
iapAssertion,
|
||||||
|
pubkeys,
|
||||||
|
audience,
|
||||||
|
['https://cloud.google.com/iap']
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = ticket.getPayload();
|
||||||
|
if (!payload) {
|
||||||
|
logger.warn("Invalid IAP token payload");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = payload.email;
|
||||||
|
const name = payload.name || payload.email;
|
||||||
|
const image = payload.picture;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
logger.warn("Missing email in IAP token");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
const newUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const authJsUser: AuthJsUser = {
|
||||||
|
id: newUser.id,
|
||||||
|
email: newUser.email,
|
||||||
|
name: newUser.name,
|
||||||
|
image: newUser.image,
|
||||||
|
};
|
||||||
|
|
||||||
|
await onCreateUser({ user: authJsUser });
|
||||||
|
return authJsUser;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
id: existingUser.id,
|
||||||
|
email: existingUser.email,
|
||||||
|
name: existingUser.name,
|
||||||
|
image: existingUser.image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error verifying IAP token:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -22,15 +22,18 @@ import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
|
||||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||||
import { getTokenFromConfig } from "@sourcebot/crypto";
|
import { getTokenFromConfig } from "@sourcebot/crypto";
|
||||||
import { ChatVisibility, OrgRole, Prisma } from "@sourcebot/db";
|
import { ChatVisibility, OrgRole, Prisma } from "@sourcebot/db";
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { LanguageModel } from "@sourcebot/schemas/v3/languageModel.type";
|
import { LanguageModel } from "@sourcebot/schemas/v3/languageModel.type";
|
||||||
import { Token } from "@sourcebot/schemas/v3/shared.type";
|
import { Token } from "@sourcebot/schemas/v3/shared.type";
|
||||||
import { loadConfig } from "@sourcebot/shared";
|
|
||||||
import { generateText, JSONValue, extractReasoningMiddleware, wrapLanguageModel } from "ai";
|
import { generateText, JSONValue, extractReasoningMiddleware, wrapLanguageModel } from "ai";
|
||||||
|
import { loadConfig } from "@sourcebot/shared";
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { LanguageModelInfo, SBChatMessage } from "./types";
|
import { LanguageModelInfo, SBChatMessage } from "./types";
|
||||||
|
|
||||||
|
const logger = createLogger('chat-actions');
|
||||||
|
|
||||||
export const createChat = async (domain: string) => sew(() =>
|
export const createChat = async (domain: string) => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
|
|
@ -189,7 +192,7 @@ export const updateChatName = async ({ chatId, name }: { chatId: string, name: s
|
||||||
|
|
||||||
export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }, domain: string) => sew(() =>
|
export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }, domain: string) => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async () => {
|
||||||
// From the language model ID, attempt to find the
|
// From the language model ID, attempt to find the
|
||||||
// corresponding config in `config.json`.
|
// corresponding config in `config.json`.
|
||||||
const languageModelConfig =
|
const languageModelConfig =
|
||||||
|
|
@ -355,25 +358,20 @@ export const getConfiguredLanguageModelsInfo = async (): Promise<LanguageModelIn
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the full configuration of the language models.
|
* Returns the full configuration of the language models.
|
||||||
*
|
*
|
||||||
* @warning Do NOT call this function from the client,
|
* @warning Do NOT call this function from the client,
|
||||||
* or pass the result of calling this function to the client.
|
* or pass the result of calling this function to the client.
|
||||||
*/
|
*/
|
||||||
export const _getConfiguredLanguageModelsFull = async (): Promise<LanguageModel[]> => {
|
export const _getConfiguredLanguageModelsFull = async (): Promise<LanguageModel[]> => {
|
||||||
if (!env.CONFIG_PATH) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await loadConfig(env.CONFIG_PATH);
|
const config = await loadConfig(env.CONFIG_PATH);
|
||||||
return config.models ?? [];
|
return config.models ?? [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load config file ${env.CONFIG_PATH}: ${error}`);
|
logger.error('Failed to load language model configuration', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const _getAISDKLanguageModelAndOptions = async (config: LanguageModel): Promise<{
|
export const _getAISDKLanguageModelAndOptions = async (config: LanguageModel): Promise<{
|
||||||
model: AISDKLanguageModelV2,
|
model: AISDKLanguageModelV2,
|
||||||
providerOptions?: Record<string, Record<string, JSONValue>>,
|
providerOptions?: Record<string, Record<string, JSONValue>>,
|
||||||
|
|
|
||||||
|
|
@ -65,30 +65,28 @@ const initSingleTenancy = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync anonymous access config from the config file
|
// Sync anonymous access config from the config file
|
||||||
if (env.CONFIG_PATH) {
|
const config = await loadConfig(env.CONFIG_PATH);
|
||||||
const config = await loadConfig(env.CONFIG_PATH);
|
const forceEnableAnonymousAccess = config.settings?.enablePublicAccess ?? env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true';
|
||||||
const forceEnableAnonymousAccess = config.settings?.enablePublicAccess ?? env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true';
|
|
||||||
|
|
||||||
if (forceEnableAnonymousAccess) {
|
if (forceEnableAnonymousAccess) {
|
||||||
if (!hasAnonymousAccessEntitlement) {
|
if (!hasAnonymousAccessEntitlement) {
|
||||||
logger.warn(`FORCE_ENABLE_ANONYMOUS_ACCESS env var is set to true but anonymous access entitlement is not available. Setting will be ignored.`);
|
logger.warn(`FORCE_ENABLE_ANONYMOUS_ACCESS env var is set to true but anonymous access entitlement is not available. Setting will be ignored.`);
|
||||||
} else {
|
} else {
|
||||||
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
||||||
if (org) {
|
if (org) {
|
||||||
const currentMetadata = getOrgMetadata(org);
|
const currentMetadata = getOrgMetadata(org);
|
||||||
const mergedMetadata = {
|
const mergedMetadata = {
|
||||||
...(currentMetadata ?? {}),
|
...(currentMetadata ?? {}),
|
||||||
anonymousAccessEnabled: true,
|
anonymousAccessEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await prisma.org.update({
|
await prisma.org.update({
|
||||||
where: { id: org.id },
|
where: { id: org.id },
|
||||||
data: {
|
data: {
|
||||||
metadata: mergedMetadata,
|
metadata: mergedMetadata,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logger.info(`Anonymous access enabled via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`);
|
logger.info(`Anonymous access enabled via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { getProviders } from "@/auth";
|
|
||||||
|
|
||||||
export interface AuthProvider {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAuthProviders = (): AuthProvider[] => {
|
|
||||||
const providers = getProviders();
|
|
||||||
return providers.map((provider) => {
|
|
||||||
if (typeof provider === "function") {
|
|
||||||
const providerInfo = provider();
|
|
||||||
return { id: providerInfo.id, name: providerInfo.name };
|
|
||||||
} else {
|
|
||||||
return { id: provider.id, name: provider.name };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -24,6 +24,7 @@ export const TEAM_FEATURES = [
|
||||||
|
|
||||||
export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile-unsupported-splash-screen-dismissed';
|
export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile-unsupported-splash-screen-dismissed';
|
||||||
export const AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME = 'sb.agentic-search-tutorial-dismissed';
|
export const AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME = 'sb.agentic-search-tutorial-dismissed';
|
||||||
|
export const OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME = 'sb.optional-providers-link-skipped';
|
||||||
|
|
||||||
// NOTE: changing SOURCEBOT_GUEST_USER_ID may break backwards compatibility since this value is used
|
// NOTE: changing SOURCEBOT_GUEST_USER_ID may break backwards compatibility since this value is used
|
||||||
// to detect old guest users in the DB. If you change this value ensure it doesn't break upgrade flows
|
// to detect old guest users in the DB. If you change this value ensure it doesn't break upgrade flows
|
||||||
|
|
|
||||||
30
packages/web/src/lib/identityProviders.ts
Normal file
30
packages/web/src/lib/identityProviders.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { getProviders } from "@/auth";
|
||||||
|
|
||||||
|
export interface IdentityProviderMetadata {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
purpose: "sso" | "account_linking";
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getIdentityProviderMetadata = (): IdentityProviderMetadata[] => {
|
||||||
|
const providers = getProviders();
|
||||||
|
return providers.map((provider) => {
|
||||||
|
if (typeof provider.provider === "function") {
|
||||||
|
const providerInfo = provider.provider();
|
||||||
|
return {
|
||||||
|
id: providerInfo.id,
|
||||||
|
name: providerInfo.name,
|
||||||
|
purpose: provider.purpose,
|
||||||
|
required: provider.required ?? false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
id: provider.provider.id,
|
||||||
|
name: provider.provider.name,
|
||||||
|
purpose: provider.purpose,
|
||||||
|
required: provider.required ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"const": "githubApp",
|
"const": "github",
|
||||||
"description": "GitHub App Configuration"
|
"description": "GitHub App Configuration"
|
||||||
},
|
},
|
||||||
"deploymentHostname": {
|
"deploymentHostname": {
|
||||||
|
|
|
||||||
198
schemas/v3/identityProvider.json
Normal file
198
schemas/v3/identityProvider.json
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "IdentityProviderConfig",
|
||||||
|
"definitions": {
|
||||||
|
"GitHubIdentityProviderConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"provider": {
|
||||||
|
"const": "github"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"enum": ["sso", "account_linking"]
|
||||||
|
},
|
||||||
|
"clientId": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"baseUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "url",
|
||||||
|
"default": "https://github.com",
|
||||||
|
"description": "The URL of the GitHub host. Defaults to https://github.com",
|
||||||
|
"examples": [
|
||||||
|
"https://github.com",
|
||||||
|
"https://github.example.com"
|
||||||
|
],
|
||||||
|
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
|
||||||
|
},
|
||||||
|
"accountLinkingRequired": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["provider", "purpose", "clientId", "clientSecret"]
|
||||||
|
},
|
||||||
|
"GitLabIdentityProviderConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"provider": {
|
||||||
|
"const": "gitlab"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"enum": ["sso", "account_linking"]
|
||||||
|
},
|
||||||
|
"clientId": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"baseUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "url",
|
||||||
|
"default": "https://gitlab.com",
|
||||||
|
"description": "The URL of the GitLab host. Defaults to https://gitlab.com",
|
||||||
|
"examples": [
|
||||||
|
"https://gitlab.com",
|
||||||
|
"https://gitlab.example.com"
|
||||||
|
],
|
||||||
|
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
|
||||||
|
},
|
||||||
|
"accountLinkingRequired": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["provider", "purpose", "clientId", "clientSecret"]
|
||||||
|
},
|
||||||
|
"GoogleIdentityProviderConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"provider": {
|
||||||
|
"const": "google"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"const": "sso"
|
||||||
|
},
|
||||||
|
"clientId": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["provider", "purpose", "clientId", "clientSecret"]
|
||||||
|
},
|
||||||
|
"OktaIdentityProviderConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"provider": {
|
||||||
|
"const": "okta"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"const": "sso"
|
||||||
|
},
|
||||||
|
"clientId": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["provider", "purpose", "clientId", "clientSecret", "issuer"]
|
||||||
|
},
|
||||||
|
"KeycloakIdentityProviderConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"provider": {
|
||||||
|
"const": "keycloak"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"const": "sso"
|
||||||
|
},
|
||||||
|
"clientId": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["provider", "purpose", "clientId", "clientSecret", "issuer"]
|
||||||
|
},
|
||||||
|
"MicrosoftEntraIDIdentityProviderConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"provider": {
|
||||||
|
"const": "microsoft-entra-id"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"const": "sso"
|
||||||
|
},
|
||||||
|
"clientId": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["provider", "purpose", "clientId", "clientSecret", "issuer"]
|
||||||
|
},
|
||||||
|
"GCPIAPIdentityProviderConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"provider": {
|
||||||
|
"const": "gcp-iap"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"const": "sso"
|
||||||
|
},
|
||||||
|
"audience": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["provider", "purpose", "audience"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/GitHubIdentityProviderConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/GitLabIdentityProviderConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/GoogleIdentityProviderConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/OktaIdentityProviderConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/KeycloakIdentityProviderConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/MicrosoftEntraIDIdentityProviderConfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/GCPIAPIdentityProviderConfig"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -125,6 +125,13 @@
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "./app.json"
|
"$ref": "./app.json"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"identityProviders": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Defines a collection of identity providers that are available to Sourcebot.",
|
||||||
|
"items": {
|
||||||
|
"$ref": "./identityProvider.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue