feat: add custom oauth support (#2336)

* custom oauth

* all

* revert ee changes

* fix frontend
This commit is contained in:
Ruben Fiszel
2023-09-26 02:56:02 -07:00
committed by GitHub
parent 47730131be
commit 2ee959cd2e
10 changed files with 362 additions and 160 deletions
+81 -50
View File
@@ -182,7 +182,7 @@ pub fn build_oauth_clients(
.as_ref()
.map(|c| (x.0.clone(), (x.1, c.clone())))
}))
.map(|(k, (client_params, config))| {
.filter_map(|(k, (client_params, config))| {
let named_client = build_basic_client(
k.clone(),
config.clone(),
@@ -191,17 +191,25 @@ pub fn build_oauth_clients(
base_url,
None,
);
(
named_client.0,
ClientWithScopes {
client: named_client.1,
scopes: config.scopes.unwrap_or(vec![]),
extra_params: config.extra_params,
extra_params_callback: config.extra_params_callback,
allowed_domains: client_params.allowed_domains.clone(),
userinfo_url: config.userinfo_url,
},
)
named_client
.map(|named_client| {
(
named_client.0,
ClientWithScopes {
client: named_client.1,
scopes: config.scopes.unwrap_or(vec![]),
extra_params: config.extra_params,
extra_params_callback: config.extra_params_callback,
allowed_domains: client_params.allowed_domains.clone(),
userinfo_url: config.userinfo_url,
},
)
})
.map_err(|e| {
tracing::error!("Error building oauth client {k}: {e}");
e
})
.ok()
})
.collect();
@@ -213,7 +221,7 @@ pub fn build_oauth_clients(
.as_ref()
.map(|c| (x.0.clone(), (x.1, c.clone())))
}))
.map(|(k, (client_params, config))| {
.filter_map(|(k, (client_params, config))| {
let named_client = build_basic_client(
k.clone(),
config.clone(),
@@ -226,43 +234,61 @@ pub fn build_oauth_clients(
None
},
);
(
named_client.0,
ClientWithScopes {
client: named_client.1,
scopes: config.scopes.unwrap_or(vec![]),
extra_params: config.extra_params,
extra_params_callback: config.extra_params_callback,
allowed_domains: None,
userinfo_url: None,
},
)
named_client
.map(|named_client| {
(
named_client.0,
ClientWithScopes {
client: named_client.1,
scopes: config.scopes.unwrap_or(vec![]),
extra_params: config.extra_params,
extra_params_callback: config.extra_params_callback,
allowed_domains: None,
userinfo_url: None,
},
)
})
.map_err(|e| {
tracing::error!("Error building oauth client {k}: {e}");
e
})
.ok()
})
.collect();
let slack = oauths.get("slack").map(|v| {
build_basic_client(
"slack".to_string(),
OAuthConfig {
auth_url: "https://slack.com/oauth/authorize".to_string(),
token_url: "https://slack.com/api/oauth.access".to_string(),
userinfo_url: None,
scopes: None,
extra_params: None,
extra_params_callback: None,
req_body_auth: None,
},
v.clone(),
false,
base_url,
Some(format!("{base_url}/oauth/callback_slack")),
)
.1
});
Ok(AllClients { logins, connects, slack })
let slack = oauths
.get("slack")
.map(|v| {
build_basic_client(
"slack".to_string(),
OAuthConfig {
auth_url: "https://slack.com/oauth/authorize".to_string(),
token_url: "https://slack.com/api/oauth.access".to_string(),
userinfo_url: None,
scopes: None,
extra_params: None,
extra_params_callback: None,
req_body_auth: None,
},
v.clone(),
false,
base_url,
Some(format!("{base_url}/oauth/callback_slack")),
)
.map(|x| x.1)
.map_err(|e| {
tracing::error!("Error building oauth slack client: {e}");
e
})
.ok()
})
.flatten();
let all_clients = AllClients { logins, connects, slack };
tracing::info!("Final oauth config: {all_clients:#?}");
Ok(all_clients)
}
use anyhow::anyhow;
pub fn build_basic_client(
name: String,
config: OAuthConfig,
@@ -270,9 +296,11 @@ pub fn build_basic_client(
login: bool,
base_url: &str,
override_callback: Option<String>,
) -> (String, OClient) {
let auth_url = Url::parse(&config.auth_url).expect("Invalid authorization endpoint URL");
let token_url = Url::parse(&config.token_url).expect("Invalid token endpoint URL");
) -> error::Result<(String, OClient)> {
let auth_url = Url::parse(&config.auth_url)
.map_err(|e| anyhow!("Invalid authorization endpoint URL: {e}"))?;
let token_url =
Url::parse(&config.token_url).map_err(|e| anyhow!("Invalid token endpoint URL: {e}"))?;
let redirect_url = if login {
format!("{base_url}/user/login_callback/{name}")
@@ -287,9 +315,12 @@ pub fn build_basic_client(
client.set_auth_type(AuthType::RequestBody);
}
client.set_client_secret(client_params.secret.clone());
client.set_redirect_url(Url::parse(&redirect_url).expect("Invalid redirect URL"));
client.set_redirect_url(
Url::parse(&redirect_url).map_err(|e| anyhow!("Invalid redirect URL: {e}"))?,
);
// Set up the config for the Github OAuth2 process.
(name.to_string(), client)
Ok((name.to_string(), client))
}
#[derive(Clone, Debug, Deserialize, Serialize)]
+60 -92
View File
@@ -57,12 +57,10 @@
<script lang="ts">
import { oauthStore, workspaceStore } from '$lib/stores'
import { faMinus, faPlus } from '@fortawesome/free-solid-svg-icons'
import IconedResourceType from './IconedResourceType.svelte'
import { OauthService, ResourceService, VariableService, type TokenResponse } from '$lib/gen'
import { emptyString, truncateRev } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import Icon from 'svelte-awesome'
import Path from './Path.svelte'
import { Button, Drawer, Skeleton } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -71,10 +69,11 @@
import autosize from 'svelte-autosize'
import WhitelistIp from './WhitelistIp.svelte'
import { sendUserToast } from '$lib/toast'
import OauthScopes from './OauthScopes.svelte'
export let newPageOAuth = false
const nativeLanguagesCategory = ['postgresql', 'mysql', 'bigquery', 'snowflake', 'graphql'];
const nativeLanguagesCategory = ['postgresql', 'mysql', 'bigquery', 'snowflake', 'graphql']
let filter = ''
let manual = false
@@ -195,14 +194,14 @@
linkedSecret: undefined
}
])
const filteredNativeLanguages = filteredConnectsManual?.filter(([key, _]) =>
nativeLanguagesCategory.includes(key)
);
const filteredNativeLanguages = filteredConnectsManual?.filter(([key, _]) =>
nativeLanguagesCategory.includes(key)
)
filteredConnectsManual = [
...(filteredNativeLanguages ?? []),
...(filteredConnectsManual ?? []).filter(([key, _]) => !nativeLanguagesCategory.includes(key))
];
filteredConnectsManual = [
...(filteredNativeLanguages ?? []),
...(filteredConnectsManual ?? []).filter(([key, _]) => !nativeLanguagesCategory.includes(key))
]
}
async function next() {
@@ -397,93 +396,62 @@
{#if manual == false && resource_type != ''}
<h3>Scopes</h3>
{#if !manual && resource_type != ''}
{#each scopes as v}
<div class="flex flex-row max-w-md mb-2">
<input type="text" bind:value={v} />
<Button
variant="border"
color="red"
size="xs"
btnClasses="mx-6"
on:click={() => {
scopes = scopes.filter((el) => el != v)
}}
>
<Icon data={faMinus} />
</Button>
</div>
{/each}
<div class="flex items-center mt-1">
<Button
variant="border"
color="blue"
hover="yo"
size="sm"
endIcon={{ icon: faPlus }}
on:click={() => {
scopes = scopes.concat('')
}}
>
Add item
</Button>
<span class="ml-2 text-sm text-tertiary">
({(scopes ?? []).length} item{(scopes ?? []).length > 1 ? 's' : ''})
</span>
</div>
<OauthScopes bind:scopes />
{/if}
{/if}
<h2 class="mt-8 mb-4">Others</h2>
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnectsManual}
{#each filteredConnectsManual as [key, _]}
{#if nativeLanguagesCategory.includes(key)}
<Button
size="sm"
variant="border"
color={key === resource_type ? 'blue' : 'light'}
btnClasses={key === resource_type ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
manual = true;
resource_type = key;
next();
dispatch('click');
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/if}
{/each}
{/if}
</div>
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnectsManual}
{#each filteredConnectsManual as [key, _]}
{#if nativeLanguagesCategory.includes(key)}
<Button
size="sm"
variant="border"
color={key === resource_type ? 'blue' : 'light'}
btnClasses={key === resource_type ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
manual = true
resource_type = key
next()
dispatch('click')
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/if}
{/each}
{/if}
</div>
<h2 class="mt-8 mb-4"></h2>
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnectsManual}
{#each filteredConnectsManual as [key, _]}
{#if !nativeLanguagesCategory.includes(key)} <!-- Exclude specific items -->
<Button
size="sm"
variant="border"
color={key === resource_type ? 'blue' : 'light'}
btnClasses={key === resource_type ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
manual = true;
resource_type = key;
next();
dispatch('click');
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/if}
{/each}
{:else}
{#each new Array(9) as _}
<Skeleton layout={[[2]]} />
{/each}
{/if}
</div>
<h2 class="mt-8 mb-4" />
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnectsManual}
{#each filteredConnectsManual as [key, _]}
{#if !nativeLanguagesCategory.includes(key)}
<!-- Exclude specific items -->
<Button
size="sm"
variant="border"
color={key === resource_type ? 'blue' : 'light'}
btnClasses={key === resource_type ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
manual = true
resource_type = key
next()
dispatch('click')
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/if}
{/each}
{:else}
{#each new Array(9) as _}
<Skeleton layout={[[2]]} />
{/each}
{/if}
</div>
{:else if step == 2 && manual}
<Path
bind:error={pathError}
@@ -0,0 +1,76 @@
<script lang="ts">
import OauthExtraParams from './OauthExtraParams.svelte'
import OauthScopes from './OauthScopes.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
export let connect_config = {
scopes: [],
auth_url: '',
token_url: '',
req_body_auth: false,
extra_params: {},
extra_params_callback: {}
}
$: if (!connect_config) {
connect_config = {
scopes: [],
auth_url: '',
token_url: '',
req_body_auth: false,
extra_params: {},
extra_params_callback: {}
}
}
</script>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Auth URL</span>
<input
type="text"
placeholder="https://github.com/login/oauth/authorize"
bind:value={connect_config.auth_url}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Token URL</span>
<input
type="text"
placeholder="https://github.com/login/oauth/access_token"
bind:value={connect_config.token_url}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Scopes</span>
<OauthScopes bind:scopes={connect_config.scopes} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Extra Query Args for Authorize Request&nbsp;<Tooltip
>Not needed in most cases. Examples of uses: google apis require the 2 extra args
"access_type=offline&prompt=consent"</Tooltip
></span
>
<OauthExtraParams bind:extra_params={connect_config.extra_params} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Extra Query Args for Token request <Tooltip>Not needed in most cases</Tooltip></span
>
<OauthExtraParams bind:extra_params={connect_config.extra_params_callback} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Payload <Tooltip
>Auth is passed in query most commonly. LinkedIn is an example of OAuth using
x-www-form-urlencoded
</Tooltip></span
>
<div>
<Toggle
options={{ left: 'in query args', right: 'in body x-www-form-urlencoded' }}
bind:checked={connect_config.req_body_auth}
/></div
>
</label>
@@ -14,6 +14,9 @@
import KeycloakSetting from './KeycloakSetting.svelte'
import Alert from './common/alert/Alert.svelte'
import { isCloudHosted } from '$lib/cloud'
import { capitalize } from '$lib/utils'
import { enterpriseLicense } from '$lib/stores'
import CustomOauth from './CustomOauth.svelte'
export const settings: Record<string, Setting[]> = {
Core: [
@@ -125,7 +128,7 @@
}
}
initialOauths = (await SettingService.getGlobal({ key: 'oauths' })) ?? {}
oauths = { ...initialOauths }
oauths = JSON.parse(JSON.stringify(initialOauths))
initialValues = Object.fromEntries(
(
await Promise.all(
@@ -136,7 +139,7 @@
)
).flat()
)
values = { ...initialValues }
values = JSON.parse(JSON.stringify(initialValues))
if (values['retention_period_secs'] == undefined) {
values['retention_period_secs'] = 60 * 60 * 24 * 60
}
@@ -158,7 +161,7 @@
name: 'server',
requestBody: newServerConfig
})
serverConfig = { ...newServerConfig }
serverConfig = JSON.parse(JSON.stringify(newServerConfig))
}
await Promise.all(
allSettings
@@ -174,7 +177,7 @@
await SettingService.setGlobal({ key: x.key, requestBody: { value: values?.[x.key] } })
})
)
initialValues = { ...values }
initialValues = JSON.parse(JSON.stringify(initialValues))
if (!deepEqual(initialOauths, oauths)) {
await SettingService.setGlobal({
@@ -183,7 +186,7 @@
value: oauths
}
})
initialOauths = { ...oauths }
initialOauths = JSON.parse(JSON.stringify(oauths))
}
} else {
console.error('Values not loaded')
@@ -208,6 +211,23 @@
}
let to: string = ''
const windmillBuiltins = [
'github',
'gitlab',
'bitbucket',
'slack',
'gsheets',
'gdrive',
'gmail',
'gcal',
'gcloud',
'gworkspace',
'basecamp',
'linkedin'
]
let oauth_name = 'custom'
</script>
<div class="pb-8">
@@ -344,6 +364,11 @@
<Alert type="warning" title="Limited to 50 SSO users">
Without EE, the number of SSO users is limited to 50. SCIM/SAML is available on EE
</Alert>
<div class="py-1" />
<Alert type="info" title="Test on a separate tab">
The recommended workflow is to to save your oauth setting and test them directly on the
login or resource page
</Alert>
<div class="flex flex-col gap-2 py-4">
<OAuthSetting name="google" bind:value={oauths['google']} />
<OAuthSetting name="microsoft" bind:value={oauths['microsoft']} />
@@ -354,6 +379,11 @@
<KeycloakSetting bind:value={oauths['keycloak']} />
</div>
<h4 class="py-4">OAuth</h4>
<Alert type="info" title="Require a corresponding resource type">
After setting an oauth client, make sure that there is a corresponding resource type
with the same name with a "token" field in the admins workspace.
</Alert>
<div class="py-1" />
<OAuthSetting login={false} name="slack" bind:value={oauths['slack']} />
<div class="py-1" />
@@ -362,7 +392,7 @@
{#if oauths[k]}
<div class="flex flex-col gap-2 pb-4">
<div class="flex flex-row items-center gap-2">
<label class="text-md font-medium text-gray-700">{k}</label>
<label class="text-md font-medium text-primary">{k}</label>
<CloseButton
on:close={() => {
delete oauths[k]
@@ -383,6 +413,9 @@
bind:value={oauths[k]['secret']}
/>
</label>
{#if !windmillBuiltins.includes(k) && k != 'slack'}
<CustomOauth bind:connect_config={oauths[k]['connect_config']} />
{/if}
</div>
</div>
{/if}
@@ -390,20 +423,28 @@
{/each}
<div class="flex gap-2">
<input type="text" placeholder="slack" bind:value={resourceName} />
<select name="oauth_name" id="oauth_name" bind:value={oauth_name}>
<option value="custom">Fully Custom (require ee)</option>
{#each windmillBuiltins as name}
<option value={name}>{capitalize(name)}</option>
{/each}
</select>
<input type="text" placeholder="client_id" bind:value={resourceName} />
<Button
variant="border"
color="blue"
hover="yo"
size="sm"
endIcon={{ icon: faPlus }}
disabled={resourceName == ''}
disabled={(oauth_name == 'custom' && resourceName == '') ||
(oauth_name == 'custom' && !$enterpriseLicense)}
on:click={() => {
oauths[resourceName] = { id: '', secret: '' }
let name = oauth_name == 'custom' ? resourceName : oauth_name
oauths[name] = { id: '', secret: '' }
resourceName = ''
}}
>
Add OAuth client
Add OAuth client {oauth_name == 'custom' && !$enterpriseLicense ? '(require ee)' : ''}
</Button>
</div>
</div>
@@ -31,7 +31,7 @@
</script>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 flex gap-4 items-center"
<label class="text-sm font-medium text-primary flex gap-4 items-center"
><div class="w-[120px]"><IconedResourceType name={'keycloak'} after={true} /></div><Toggle
checked={enabled}
on:change={(e) => {
@@ -9,12 +9,11 @@
$: enabled = value != undefined
let allowed_domains = value?.['allowed_domains'] ?? ''
</script>
<div class="flex flex-col gap-1">
<label class="text-sm flex gap-4 items-center font-medium text-gray-700"
<label class="text-sm flex gap-4 items-center font-medium text-primary"
><div class="w-[120px]"><IconedResourceType {name} after={true} /></div><Toggle
checked={enabled}
on:change={(e) => {
@@ -87,4 +86,3 @@
</div>
{/if}
</div>
@@ -0,0 +1,50 @@
<script lang="ts">
import { faMinus, faPlus } from '@fortawesome/free-solid-svg-icons'
import Icon from 'svelte-awesome'
import { Button } from './common'
export let extra_params: Record<string, string> = {}
let extra_params_vec: [string, string][] = Object.entries(extra_params)
function sync() {
extra_params = Object.fromEntries(extra_params_vec)
}
</script>
{#each extra_params_vec as o}
<div class="flex flex-row max-w-md mb-2">
<input type="text" on:keyup={sync} bind:value={o[0]} />
<input type="text" on:keyup={sync} bind:value={o[1]} />
<Button
variant="border"
color="red"
size="xs"
btnClasses="mx-6"
on:click={() => {
extra_params_vec = extra_params_vec.filter((e) => e[0] != o[0])
sync()
}}
>
<Icon data={faMinus} />
</Button>
</div>
{/each}
<div class="flex items-center mt-1">
<Button
variant="border"
color="blue"
hover="yo"
size="sm"
endIcon={{ icon: faPlus }}
on:click={() => {
extra_params_vec = extra_params_vec.concat([['key', 'value']])
sync()
}}
>
Add item
</Button>
<span class="ml-2 text-sm text-tertiary">
({(extra_params_vec ?? []).length} item{(extra_params_vec ?? []).length > 1 ? 's' : ''})
</span>
</div>
@@ -0,0 +1,41 @@
<script lang="ts">
import { faMinus, faPlus } from '@fortawesome/free-solid-svg-icons'
import Icon from 'svelte-awesome'
import { Button } from './common'
export let scopes: string[] = []
</script>
{#each scopes as v}
<div class="flex flex-row max-w-md mb-2">
<input type="text" bind:value={v} />
<Button
variant="border"
color="red"
size="xs"
btnClasses="mx-6"
on:click={() => {
scopes = scopes.filter((el) => el != v)
}}
>
<Icon data={faMinus} />
</Button>
</div>
{/each}
<div class="flex items-center mt-1">
<Button
variant="border"
color="blue"
hover="yo"
size="sm"
endIcon={{ icon: faPlus }}
on:click={() => {
scopes = scopes.concat('')
}}
>
Add item
</Button>
<span class="ml-2 text-sm text-tertiary">
({(scopes ?? []).length} item{(scopes ?? []).length > 1 ? 's' : ''})
</span>
</div>
@@ -33,7 +33,7 @@
</script>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 flex gap-4 items-center"
<label class="text-sm font-medium text-primary flex gap-4 items-center"
><div class="w-[120px]"><IconedResourceType name="okta" after={true} /></div><Toggle
checked={enabled}
on:change={(e) => {
-3
View File
@@ -583,7 +583,6 @@ const config = {
fontSize: '18px',
fontWeight: theme('fontWeight.semibold'),
lineHeight: '1.3',
color: theme('colors.gray.600'),
[`@media (min-width: ${theme('screens.qhd')})`]: {
fontSize: '20px'
}
@@ -592,7 +591,6 @@ const config = {
fontSize: '16px',
fontWeight: theme('fontWeight.semibold'),
lineHeight: '1.5',
color: theme('colors.gray.600'),
[`@media (min-width: ${theme('screens.qhd')})`]: {
fontSize: '18px'
}
@@ -601,7 +599,6 @@ const config = {
fontSize: '16px',
fontWeight: theme('fontWeight.medium'),
lineHeight: '1.5',
color: theme('colors.gray.600'),
[`@media (min-width: ${theme('screens.qhd')})`]: {
fontSize: '18px'
}