feat: make OAuth admin settings read-only when ENABLE_OAUTH_PERSISTENT_CONFIG is off (#28276)

When ENABLE_OAUTH_PERSISTENT_CONFIG is off (the default), oauth.* config is
never persisted and is read from environment variables, but the admin panel
still let admins edit the OAuth/OIDC fields and silently dropped every save on
restart, which kept confusing users who missed the docs warning
(open-webui/open-webui#28247).

The OAuth/OIDC section is now read-only in that case: the admin oauth config
endpoint reports the flag and the UI wraps the section in a disabled fieldset,
slightly dimmed with every control inert but all values still visible, plus a
note naming the env var. Saving skips the OAuth POST since nothing can change.
With the flag enabled the section behaves exactly as before.

Known limits: the guard is UI-side only (the POST endpoint keeps accepting
writes, unchanged), and disabled fields mean values cannot be selected and the
masked client secret cannot be revealed while read-only. Switch.svelte gains a
disabled:cursor-not-allowed style that applies to any disabled switch app-wide.

Co-authored-by: Tim Baek <tim@openwebui.com>
This commit is contained in:
Classic298
2026-08-11 05:41:07 +02:00
committed by GitHub
parent a3e5d0b362
commit a680f21e12
3 changed files with 291 additions and 268 deletions
+9 -3
View File
@@ -1428,11 +1428,13 @@ def _parse_oauth_update_value(field: str, value):
async def get_oauth_config_values() -> dict:
values = await Config.get_many(*OAUTH_CONFIG_KEYS.values())
return {
form_values = {
field: _format_oauth_form_value(field, values[storage_key])
for field, storage_key in OAUTH_CONFIG_KEYS.items()
if storage_key in values
}
form_values['ENABLE_OAUTH_PERSISTENT_CONFIG'] = Config.OAUTH_PERSISTENT_ENABLED
return form_values
def oauth_config_updates(data: dict) -> dict:
@@ -1443,12 +1445,16 @@ def oauth_config_updates(data: dict) -> dict:
}
@router.get('/admin/config/oauth', response_model=OAuthConfigForm)
class OAuthConfigResponse(OAuthConfigForm):
ENABLE_OAUTH_PERSISTENT_CONFIG: bool
@router.get('/admin/config/oauth', response_model=OAuthConfigResponse)
async def get_oauth_config(request: Request, user=Depends(get_admin_user)):
return await get_oauth_config_values()
@router.post('/admin/config/oauth', response_model=OAuthConfigForm)
@router.post('/admin/config/oauth', response_model=OAuthConfigResponse)
async def update_oauth_config(request: Request, form_data: OAuthConfigForm, user=Depends(get_admin_user)):
await Config.upsert(oauth_config_updates(form_data.model_dump(exclude_none=True)))
return await get_oauth_config_values()
@@ -49,6 +49,7 @@
};
let oauthConfig: any = null;
$: oauthEditable = oauthConfig?.ENABLE_OAUTH_PERSISTENT_CONFIG ?? true;
const inputClass =
'w-full h-7 rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const textareaClass =
@@ -74,7 +75,7 @@
};
const updateOAuthHandler = async () => {
if (!oauthConfig) return true;
if (!oauthConfig || !oauthEditable) return true;
const res = await updateOAuthConfig(localStorage.token, oauthConfig).catch((error) => {
toast.error(`${error}`);
return null;
@@ -526,311 +527,327 @@
{#if oauthConfig}
<AdminSettingSection title={$i18n.t('OAuth / OIDC')}>
<AdminSettingRow
label={$i18n.t('OAuth / OIDC')}
description={$i18n.t('Allow users to authenticate with an OAuth / OIDC provider.')}
let:labelId
>
<Switch bind:state={oauthConfig.ENABLE_OAUTH} ariaLabelledbyId={labelId} />
</AdminSettingRow>
{#if oauthConfig.ENABLE_OAUTH}
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Provider Name')}
description={$i18n.t('Display name shown for the OAuth provider.')}
>
<input
class={inputClass}
placeholder="SSO"
bind:value={oauthConfig.OAUTH_PROVIDER_NAME}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Provider URL')}
description={$i18n.t('OpenID discovery URL for this provider.')}
>
<input
class={inputClass}
placeholder="https://accounts.google.com/.well-known/openid-configuration"
bind:value={oauthConfig.OPENID_PROVIDER_URL}
/>
</AdminSettingField>
</div>
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Client ID')}
description={$i18n.t('OAuth client identifier from the provider.')}
>
<input
class={inputClass}
placeholder={$i18n.t('Enter Client ID')}
bind:value={oauthConfig.OAUTH_CLIENT_ID}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Client Secret')}
description={$i18n.t('OAuth client secret from the provider.')}
>
<SensitiveInput
variant="settings"
placeholder={$i18n.t('Enter Client Secret')}
required={false}
bind:value={oauthConfig.OAUTH_CLIENT_SECRET}
/>
</AdminSettingField>
</div>
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Redirect URI')}
description={$i18n.t('Callback URI registered with the provider.')}
>
<input
class={inputClass}
placeholder={$i18n.t('Enter Redirect URI')}
bind:value={oauthConfig.OPENID_REDIRECT_URI}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Scopes')}
description={$i18n.t('OAuth scopes requested during sign-in.')}
>
<input
class={inputClass}
placeholder="openid email profile"
bind:value={oauthConfig.OAUTH_SCOPES}
/>
</AdminSettingField>
</div>
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Email Claim')}
description={$i18n.t('Claim used as the user email address.')}
>
<input
class={inputClass}
placeholder="email"
bind:value={oauthConfig.OAUTH_EMAIL_CLAIM}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Username Claim')}
description={$i18n.t('Claim used as the display name.')}
>
<input
class={inputClass}
placeholder="name"
bind:value={oauthConfig.OAUTH_USERNAME_CLAIM}
/>
</AdminSettingField>
</div>
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Picture Claim')}
description={$i18n.t('Claim used as the profile picture URL.')}
>
<input
class={inputClass}
placeholder="picture"
bind:value={oauthConfig.OAUTH_PICTURE_CLAIM}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Sub Claim')}
description={$i18n.t('Claim used as the stable user identifier.')}
>
<input
class={inputClass}
placeholder="sub"
bind:value={oauthConfig.OAUTH_SUB_CLAIM}
/>
</AdminSettingField>
</div>
<AdminSettingRow
label={$i18n.t('OAuth Signup')}
description={$i18n.t('Allow users to create accounts through OAuth.')}
let:labelId
{#if !oauthEditable}
<div
class="rounded-lg bg-yellow-500/10 px-2 py-1.5 text-[0.6875rem] text-yellow-700 dark:text-yellow-200"
>
<Switch bind:state={oauthConfig.ENABLE_OAUTH_SIGNUP} ariaLabelledbyId={labelId} />
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('Merge Accounts by Email')}
description={$i18n.t('Link OAuth sign-ins to existing accounts with the same email.')}
let:labelId
>
<Switch
bind:state={oauthConfig.OAUTH_MERGE_ACCOUNTS_BY_EMAIL}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('Auto Redirect')}
description={$i18n.t(
'Send users directly to the OAuth provider from the sign-in page.'
{$i18n.t(
'These settings are read from environment variables and cannot be edited here while {{ENV_VAR}} is disabled.',
{ ENV_VAR: 'ENABLE_OAUTH_PERSISTENT_CONFIG' }
)}
let:labelId
>
<Switch bind:state={oauthConfig.OAUTH_AUTO_REDIRECT} ariaLabelledbyId={labelId} />
</AdminSettingRow>
</div>
{/if}
<AdminSettingField
label={$i18n.t('Allowed Domains')}
description={$i18n.t('Email domains allowed to sign in with OAuth.')}
>
<input
class={inputClass}
placeholder="* (all domains)"
bind:value={oauthConfig.OAUTH_ALLOWED_DOMAINS}
/>
</AdminSettingField>
<!-- LICENSE covers this Open WebUI wordmark.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
<fieldset
class="flex min-w-0 flex-col gap-2.5 disabled:cursor-not-allowed disabled:opacity-75"
disabled={!oauthEditable}
>
<AdminSettingRow
label={$i18n.t('Role Mapping')}
description={$i18n.t('Map OAuth claims to Open WebUI roles.')}
label={$i18n.t('OAuth / OIDC')}
description={$i18n.t('Allow users to authenticate with an OAuth / OIDC provider.')}
let:labelId
>
<Switch
bind:state={oauthConfig.ENABLE_OAUTH_ROLE_MANAGEMENT}
ariaLabelledbyId={labelId}
/>
<Switch bind:state={oauthConfig.ENABLE_OAUTH} ariaLabelledbyId={labelId} />
</AdminSettingRow>
{#if oauthConfig.ENABLE_OAUTH_ROLE_MANAGEMENT}
{#if oauthConfig.ENABLE_OAUTH}
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Roles Claim')}
description={$i18n.t('Claim containing provider roles.')}
label={$i18n.t('Provider Name')}
description={$i18n.t('Display name shown for the OAuth provider.')}
>
<input
class={inputClass}
placeholder="roles"
bind:value={oauthConfig.OAUTH_ROLES_CLAIM}
placeholder="SSO"
bind:value={oauthConfig.OAUTH_PROVIDER_NAME}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Admin Roles')}
description={$i18n.t('Provider roles that grant admin access.')}
label={$i18n.t('Provider URL')}
description={$i18n.t('OpenID discovery URL for this provider.')}
>
<input
class={inputClass}
placeholder="admin"
bind:value={oauthConfig.OAUTH_ADMIN_ROLES}
placeholder="https://accounts.google.com/.well-known/openid-configuration"
bind:value={oauthConfig.OPENID_PROVIDER_URL}
/>
</AdminSettingField>
</div>
<AdminSettingField
label={$i18n.t('Allowed Roles')}
description={$i18n.t('Provider roles allowed to sign in.')}
>
<input
class={inputClass}
placeholder="*"
bind:value={oauthConfig.OAUTH_ALLOWED_ROLES}
/>
</AdminSettingField>
{/if}
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Client ID')}
description={$i18n.t('OAuth client identifier from the provider.')}
>
<input
class={inputClass}
placeholder={$i18n.t('Enter Client ID')}
bind:value={oauthConfig.OAUTH_CLIENT_ID}
/>
</AdminSettingField>
<!-- LICENSE covers this Open WebUI wordmark.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
<AdminSettingRow
label={$i18n.t('Group Mapping')}
description={$i18n.t('Map OAuth claims to Open WebUI groups.')}
let:labelId
>
<Switch
bind:state={oauthConfig.ENABLE_OAUTH_GROUP_MANAGEMENT}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
<AdminSettingField
label={$i18n.t('Client Secret')}
description={$i18n.t('OAuth client secret from the provider.')}
>
<SensitiveInput
variant="settings"
placeholder={$i18n.t('Enter Client Secret')}
required={false}
bind:value={oauthConfig.OAUTH_CLIENT_SECRET}
/>
</AdminSettingField>
</div>
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Redirect URI')}
description={$i18n.t('Callback URI registered with the provider.')}
>
<input
class={inputClass}
placeholder={$i18n.t('Enter Redirect URI')}
bind:value={oauthConfig.OPENID_REDIRECT_URI}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Scopes')}
description={$i18n.t('OAuth scopes requested during sign-in.')}
>
<input
class={inputClass}
placeholder="openid email profile"
bind:value={oauthConfig.OAUTH_SCOPES}
/>
</AdminSettingField>
</div>
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Email Claim')}
description={$i18n.t('Claim used as the user email address.')}
>
<input
class={inputClass}
placeholder="email"
bind:value={oauthConfig.OAUTH_EMAIL_CLAIM}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Username Claim')}
description={$i18n.t('Claim used as the display name.')}
>
<input
class={inputClass}
placeholder="name"
bind:value={oauthConfig.OAUTH_USERNAME_CLAIM}
/>
</AdminSettingField>
</div>
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Picture Claim')}
description={$i18n.t('Claim used as the profile picture URL.')}
>
<input
class={inputClass}
placeholder="picture"
bind:value={oauthConfig.OAUTH_PICTURE_CLAIM}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Sub Claim')}
description={$i18n.t('Claim used as the stable user identifier.')}
>
<input
class={inputClass}
placeholder="sub"
bind:value={oauthConfig.OAUTH_SUB_CLAIM}
/>
</AdminSettingField>
</div>
{#if oauthConfig.ENABLE_OAUTH_GROUP_MANAGEMENT}
<AdminSettingRow
label={$i18n.t('Auto-Create Groups')}
description={$i18n.t('Create missing groups from OAuth claims.')}
label={$i18n.t('OAuth Signup')}
description={$i18n.t('Allow users to create accounts through OAuth.')}
let:labelId
>
<Switch bind:state={oauthConfig.ENABLE_OAUTH_SIGNUP} ariaLabelledbyId={labelId} />
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('Merge Accounts by Email')}
description={$i18n.t('Link OAuth sign-ins to existing accounts with the same email.')}
let:labelId
>
<Switch
bind:state={oauthConfig.ENABLE_OAUTH_GROUP_CREATION}
bind:state={oauthConfig.OAUTH_MERGE_ACCOUNTS_BY_EMAIL}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Group Claim')}
description={$i18n.t('Claim containing provider groups.')}
>
<input
class={inputClass}
placeholder="groups"
bind:value={oauthConfig.OAUTH_GROUP_CLAIM}
/>
</AdminSettingField>
<AdminSettingRow
label={$i18n.t('Auto Redirect')}
description={$i18n.t(
'Send users directly to the OAuth provider from the sign-in page.'
)}
let:labelId
>
<Switch bind:state={oauthConfig.OAUTH_AUTO_REDIRECT} ariaLabelledbyId={labelId} />
</AdminSettingRow>
<AdminSettingField
label={$i18n.t('Allowed Domains')}
description={$i18n.t('Email domains allowed to sign in with OAuth.')}
>
<input
class={inputClass}
placeholder="* (all domains)"
bind:value={oauthConfig.OAUTH_ALLOWED_DOMAINS}
/>
</AdminSettingField>
<!-- LICENSE covers this Open WebUI wordmark.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
<AdminSettingRow
label={$i18n.t('Role Mapping')}
description={$i18n.t('Map OAuth claims to Open WebUI roles.')}
let:labelId
>
<Switch
bind:state={oauthConfig.ENABLE_OAUTH_ROLE_MANAGEMENT}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
{#if oauthConfig.ENABLE_OAUTH_ROLE_MANAGEMENT}
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Roles Claim')}
description={$i18n.t('Claim containing provider roles.')}
>
<input
class={inputClass}
placeholder="roles"
bind:value={oauthConfig.OAUTH_ROLES_CLAIM}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Admin Roles')}
description={$i18n.t('Provider roles that grant admin access.')}
>
<input
class={inputClass}
placeholder="admin"
bind:value={oauthConfig.OAUTH_ADMIN_ROLES}
/>
</AdminSettingField>
</div>
<AdminSettingField
label={$i18n.t('Blocked Groups')}
description={$i18n.t('Provider groups blocked from signing in.')}
label={$i18n.t('Allowed Roles')}
description={$i18n.t('Provider roles allowed to sign in.')}
>
<input
class={inputClass}
placeholder={$i18n.t('Comma-separated group names')}
bind:value={oauthConfig.OAUTH_BLOCKED_GROUPS}
placeholder="*"
bind:value={oauthConfig.OAUTH_ALLOWED_ROLES}
/>
</AdminSettingField>
</div>
{/if}
<!-- LICENSE covers this Open WebUI wordmark.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
<AdminSettingRow
label={$i18n.t('Group Mapping')}
description={$i18n.t('Map OAuth claims to Open WebUI groups.')}
let:labelId
>
<Switch
bind:state={oauthConfig.ENABLE_OAUTH_GROUP_MANAGEMENT}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
{#if oauthConfig.ENABLE_OAUTH_GROUP_MANAGEMENT}
<AdminSettingRow
label={$i18n.t('Auto-Create Groups')}
description={$i18n.t('Create missing groups from OAuth claims.')}
let:labelId
>
<Switch
bind:state={oauthConfig.ENABLE_OAUTH_GROUP_CREATION}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
label={$i18n.t('Group Claim')}
description={$i18n.t('Claim containing provider groups.')}
>
<input
class={inputClass}
placeholder="groups"
bind:value={oauthConfig.OAUTH_GROUP_CLAIM}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Blocked Groups')}
description={$i18n.t('Provider groups blocked from signing in.')}
>
<input
class={inputClass}
placeholder={$i18n.t('Comma-separated group names')}
bind:value={oauthConfig.OAUTH_BLOCKED_GROUPS}
/>
</AdminSettingField>
</div>
{/if}
<AdminSettingRow
label={$i18n.t('Update Email')}
description={$i18n.t('Refresh the account email from OAuth on sign-in.')}
let:labelId
>
<Switch
bind:state={oauthConfig.OAUTH_UPDATE_EMAIL_ON_LOGIN}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('Update Name')}
description={$i18n.t('Refresh the account name from OAuth on sign-in.')}
let:labelId
>
<Switch
bind:state={oauthConfig.OAUTH_UPDATE_NAME_ON_LOGIN}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('Update Picture')}
description={$i18n.t('Refresh the profile picture from OAuth on sign-in.')}
let:labelId
>
<Switch
bind:state={oauthConfig.OAUTH_UPDATE_PICTURE_ON_LOGIN}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
{/if}
<AdminSettingRow
label={$i18n.t('Update Email')}
description={$i18n.t('Refresh the account email from OAuth on sign-in.')}
let:labelId
>
<Switch
bind:state={oauthConfig.OAUTH_UPDATE_EMAIL_ON_LOGIN}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('Update Name')}
description={$i18n.t('Refresh the account name from OAuth on sign-in.')}
let:labelId
>
<Switch
bind:state={oauthConfig.OAUTH_UPDATE_NAME_ON_LOGIN}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('Update Picture')}
description={$i18n.t('Refresh the profile picture from OAuth on sign-in.')}
let:labelId
>
<Switch
bind:state={oauthConfig.OAUTH_UPDATE_PICTURE_ON_LOGIN}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
{/if}
</fieldset>
</AdminSettingSection>
{/if}
</div>
+1 -1
View File
@@ -36,7 +36,7 @@
{id}
aria-labelledby={ariaLabelledbyId || undefined}
aria-label={ariaLabel || undefined}
class="relative h-4 min-h-4 w-7 shrink-0 cursor-pointer rounded-full mx-[0.0625rem] transition-colors duration-150 {($settings?.highContrastMode ??
class="relative h-4 min-h-4 w-7 shrink-0 cursor-pointer rounded-full mx-[0.0625rem] transition-colors duration-150 disabled:cursor-not-allowed {($settings?.highContrastMode ??
false)
? 'focus:outline focus:outline-2 focus:outline-gray-800 focus:dark:outline-gray-200'
: 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-400 dark:focus-visible:outline-gray-500'} {state