Compare commits

...

2 Commits

11 changed files with 563 additions and 335 deletions
+1
View File
@@ -27,6 +27,7 @@ require (
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/expr-lang/expr v1.17.8
github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+2
View File
@@ -28,6 +28,8 @@ github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcej
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
+8 -1
View File
@@ -38,6 +38,8 @@ func scanAuthProvider(row interface {
&item.OIDCRedirectURL,
&item.OIDCScopes,
&item.OIDCTLSInsecureSkipVerify,
&item.OIDCGroupsClaim,
&item.OIDCAdmissionExpr,
&defaultUserGroupID,
&item.UserCount,
&item.CreatedAt,
@@ -56,6 +58,7 @@ const authProviderSelectCols = `
p.ldap_tls_insecure_skip_verify,
p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url,
p.oidc_userinfo_url, p.oidc_redirect_url, p.oidc_scopes, p.oidc_tls_insecure_skip_verify,
p.oidc_groups_claim, p.oidc_admission_expr,
COALESCE(ug.public_id, ''),
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
p.created_at, p.updated_at`
@@ -153,14 +156,16 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
ldap_tls_insecure_skip_verify,
oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url,
oidc_userinfo_url, oidc_redirect_url, oidc_scopes, oidc_tls_insecure_skip_verify,
oidc_groups_claim, oidc_admission_expr,
default_user_group_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
item.ID, item.Name, item.Type, item.Enabled,
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr,
item.DefaultUserGroupID, item.DefaultUserGroupID,
item.CreatedAt, item.UpdatedAt,
)
@@ -190,6 +195,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
ldap_tls_insecure_skip_verify = ?,
oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?,
oidc_userinfo_url = ?, oidc_redirect_url = ?, oidc_scopes = ?, oidc_tls_insecure_skip_verify = ?,
oidc_groups_claim = ?, oidc_admission_expr = ?,
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
updated_at = ?
WHERE public_id = ?`,
@@ -198,6 +204,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr,
item.DefaultUserGroupID, item.DefaultUserGroupID,
item.UpdatedAt,
strings.TrimSpace(item.ID),
@@ -56,6 +56,13 @@ func (api *API) CreateAuthProvider(w http.ResponseWriter, r *http.Request, _ map
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "type must be ldap or oidc")
return
}
if req.Type == db.AuthProviderTypeOIDC && strings.TrimSpace(req.OIDCAdmissionExpr) != "" {
err = oidcCompileAdmissionExpr(strings.TrimSpace(req.OIDCAdmissionExpr))
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid admission expression: "+err.Error())
return
}
}
created, err = api.store(r).CreateAuthProvider(req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
@@ -84,6 +91,13 @@ func (api *API) UpdateAuthProvider(w http.ResponseWriter, r *http.Request, param
}
req.ID = existing.ID
req.Type = existing.Type
if req.Type == db.AuthProviderTypeOIDC && strings.TrimSpace(req.OIDCAdmissionExpr) != "" {
err = oidcCompileAdmissionExpr(strings.TrimSpace(req.OIDCAdmissionExpr))
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid admission expression: "+err.Error())
return
}
}
if existing.ID == db.BuiltinDBProviderPublicID {
existing.Enabled = req.Enabled
if strings.TrimSpace(req.Name) != "" {
+123 -4
View File
@@ -17,6 +17,8 @@ import "net/url"
import "strings"
import "time"
import "github.com/expr-lang/expr"
import "codit/internal/db"
import "codit/internal/models"
@@ -26,10 +28,11 @@ type oidcTokenResponse struct {
}
type oidcUserClaims struct {
Sub string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
Email string `json:"email"`
Sub string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
Email string `json:"email"`
Raw map[string]any `json:"-"`
}
type oidcErrorResponse struct {
@@ -359,6 +362,10 @@ func (api *API) oidcUserInfoFromProvider(ctx context.Context, p models.AuthProvi
if strings.TrimSpace(claims.Sub) == "" {
return claims, errors.New("userinfo missing sub")
}
var raw map[string]any
if json.Unmarshal(body, &raw) == nil {
claims.Raw = raw
}
return claims, nil
}
@@ -370,6 +377,11 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
var err error
var created models.User
err = oidcCheckAdmission(provider, claims)
if err != nil {
return user, err
}
user, err = api.storeContext(ctx).GetUserByProviderAndSub(provider.ID, claims.Sub)
if err == nil {
if user.Disabled {
@@ -402,6 +414,109 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
return created, nil
}
// oidcAdmissionEnvType defines the expression environment for type-checking at save time.
// Variables exposed: email, email_domain, sub, username, groups ([]string), claims (raw map).
var oidcAdmissionEnvType = map[string]any{
"email": "",
"email_domain": "",
"sub": "",
"username": "",
"groups": []string{},
"claims": map[string]any{},
}
func oidcCompileAdmissionExpr(exprStr string) error {
var err error
_, err = expr.Compile(exprStr, expr.Env(oidcAdmissionEnvType), expr.AsBool())
return err
}
func oidcExtractGroups(raw map[string]any, claimName string) []string {
var val any
var ok bool
var arr []any
var s string
var result []string
var i int
if raw == nil {
return []string{}
}
val, ok = raw[claimName]
if !ok {
return []string{}
}
arr, ok = val.([]any)
if ok {
for i = 0; i < len(arr); i++ {
s, ok = arr[i].(string)
if ok && s != "" {
result = append(result, s)
}
}
if result == nil {
return []string{}
}
return result
}
s, ok = val.(string)
if ok && s != "" {
return []string{s}
}
return []string{}
}
func oidcBuildAdmissionEnv(provider models.AuthProvider, claims oidcUserClaims) map[string]any {
var emailDomain string
var atIdx int
var groupsClaim string
var raw map[string]any
atIdx = strings.LastIndex(claims.Email, "@")
if atIdx >= 0 {
emailDomain = strings.ToLower(claims.Email[atIdx+1:])
}
groupsClaim = strings.TrimSpace(provider.OIDCGroupsClaim)
if groupsClaim == "" {
groupsClaim = "groups"
}
raw = claims.Raw
if raw == nil {
raw = map[string]any{}
}
return map[string]any{
"email": strings.TrimSpace(claims.Email),
"email_domain": emailDomain,
"sub": strings.TrimSpace(claims.Sub),
"username": strings.TrimSpace(claims.PreferredUsername),
"groups": oidcExtractGroups(raw, groupsClaim),
"claims": raw,
}
}
func oidcCheckAdmission(provider models.AuthProvider, claims oidcUserClaims) error {
var exprStr string
var env map[string]any
var result any
var ok bool
var err error
exprStr = strings.TrimSpace(provider.OIDCAdmissionExpr)
if exprStr == "" {
return nil
}
env = oidcBuildAdmissionEnv(provider, claims)
result, err = expr.Eval(exprStr, env)
if err != nil {
return fmt.Errorf("admission expression error: %v", err)
}
ok, _ = result.(bool)
if !ok {
return errors.New("oidc admission denied")
}
return nil
}
func oidcIDTokenAudienceContains(aud json.RawMessage, clientID string) bool {
var single string
var multiple []string
@@ -458,6 +573,10 @@ func oidcClaimsFromIDToken(idToken string, clientID string) (oidcUserClaims, err
if strings.TrimSpace(claims.Sub) == "" {
return claims, errors.New("id token missing sub")
}
var raw map[string]any
if json.Unmarshal(payload, &raw) == nil {
claims.Raw = raw
}
return claims, nil
}
+2
View File
@@ -36,6 +36,8 @@ type AuthProvider struct {
OIDCRedirectURL string `json:"oidc_redirect_url"`
OIDCScopes string `json:"oidc_scopes"`
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
OIDCGroupsClaim string `json:"oidc_groups_claim"`
OIDCAdmissionExpr string `json:"oidc_admission_expr"`
DefaultUserGroupID string `json:"default_user_group_id"`
UserCount int `json:"user_count,omitempty"`
CreatedAt int64 `json:"created_at"`
@@ -0,0 +1,2 @@
ALTER TABLE auth_providers ADD COLUMN oidc_groups_claim TEXT NOT NULL DEFAULT 'groups';
ALTER TABLE auth_providers ADD COLUMN oidc_admission_expr TEXT NOT NULL DEFAULT '';
+2
View File
@@ -453,6 +453,8 @@ export interface AuthProvider {
oidc_redirect_url: string
oidc_scopes: string
oidc_tls_insecure_skip_verify: boolean
oidc_groups_claim: string
oidc_admission_expr: string
default_user_group_id: string
user_count?: number
created_at: number
@@ -0,0 +1,86 @@
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent'
import { AuthProvider, UserGroup } from '../api'
type AuthProviderDetailsDialogProps = {
item: AuthProvider | null
userGroups: UserGroup[]
onClose: () => void
}
function fmt(value: number | undefined): string {
if (!value || value <= 0) {
return '-'
}
return new Date(value * 1000).toLocaleString()
}
function yesNo(value: boolean): string {
return value ? 'yes' : 'no'
}
function secretState(value: string): string {
return value ? 'Configured' : 'Not configured'
}
export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDialogProps) {
const item: AuthProvider | null = props.item
const defaultGroup: UserGroup | undefined = props.userGroups.find((group: UserGroup) => group.id === item?.default_user_group_id)
return (
<Dialog open={Boolean(item)} onClose={props.onClose} maxWidth="md" fullWidth>
<DialogTitle>Auth Provider Details</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1">{item?.name || '-'}</Typography>
{item ? <Chip label={item.type.toUpperCase()} size="small" variant="outlined" /> : null}
{item ? <Chip label={item.enabled ? 'Enabled' : 'Disabled'} size="small" color={item.enabled ? 'success' : 'error'} /> : null}
</Box>
<TextField label="Provider ID" value={item?.id || ''} InputProps={{ readOnly: true }} />
<TextField label="Name" value={item?.name || ''} InputProps={{ readOnly: true }} />
<TextField label="Type" value={item?.type || ''} InputProps={{ readOnly: true }} />
<TextField label="Enabled" value={yesNo(Boolean(item?.enabled))} InputProps={{ readOnly: true }} />
<TextField label="Users" value={String(item?.user_count ?? 0)} InputProps={{ readOnly: true }} />
<TextField label="Default User Group" value={defaultGroup ? `${defaultGroup.name} (${defaultGroup.id})` : item?.default_user_group_id || '-'} InputProps={{ readOnly: true }} />
{item?.type === 'ldap' ? (
<>
<TextField label="LDAP URL" value={item.ldap_url || ''} InputProps={{ readOnly: true }} />
<TextField label="Bind DN" value={item.ldap_bind_dn || '-'} InputProps={{ readOnly: true }} />
<TextField label="Bind Password" value={secretState(item.ldap_bind_password)} InputProps={{ readOnly: true }} />
<TextField label="User Base DN" value={item.ldap_user_base_dn || ''} InputProps={{ readOnly: true }} />
<TextField label="User Filter" value={item.ldap_user_filter || ''} InputProps={{ readOnly: true }} />
<TextField label="TLS Insecure Skip Verify" value={yesNo(item.ldap_tls_insecure_skip_verify)} InputProps={{ readOnly: true }} />
</>
) : null}
{item?.type === 'oidc' ? (
<>
<TextField label="Client ID" value={item.oidc_client_id || ''} InputProps={{ readOnly: true }} />
<TextField label="Client Secret" value={secretState(item.oidc_client_secret)} InputProps={{ readOnly: true }} />
<TextField label="Authorize URL" value={item.oidc_authorize_url || ''} InputProps={{ readOnly: true }} />
<TextField label="Token URL" value={item.oidc_token_url || ''} InputProps={{ readOnly: true }} />
<TextField label="UserInfo URL" value={item.oidc_userinfo_url || '-'} InputProps={{ readOnly: true }} />
<TextField label="Redirect URL" value={item.oidc_redirect_url || ''} InputProps={{ readOnly: true }} />
<TextField label="Scopes" value={item.oidc_scopes || ''} InputProps={{ readOnly: true }} />
<TextField label="TLS Insecure Skip Verify" value={yesNo(item.oidc_tls_insecure_skip_verify)} InputProps={{ readOnly: true }} />
<TextField label="Groups Claim" value={item.oidc_groups_claim || 'groups'} InputProps={{ readOnly: true }} />
<TextField label="Admission Expression" value={item.oidc_admission_expr || '-'} InputProps={{ readOnly: true }} multiline minRows={2} />
</>
) : null}
<TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} />
<TextField label="Updated At" value={fmt(item?.updated_at)} InputProps={{ readOnly: true }} />
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={props.onClose}>Close</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,295 @@
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import { useEffect, useRef, useState } from 'react'
import Autocomplete from './Autocomplete'
import FormDialogContent from './FormDialogContent'
import PageAlert from './PageAlert'
import SelectField from './SelectField'
import { api, AuthProvider, UserGroup } from '../api'
export type AuthProviderFormState = {
name: string
type: 'ldap' | 'oidc' | 'db'
enabled: boolean
ldap_url: string
ldap_bind_dn: string
ldap_bind_password: string
ldap_user_base_dn: string
ldap_user_filter: string
ldap_tls_insecure_skip_verify: boolean
oidc_client_id: string
oidc_client_secret: string
oidc_authorize_url: string
oidc_token_url: string
oidc_userinfo_url: string
oidc_redirect_url: string
oidc_scopes: string
oidc_tls_insecure_skip_verify: boolean
oidc_groups_claim: string
oidc_admission_expr: string
default_user_group_id: string
}
type AuthProviderFormDialogProps = {
open: boolean
busy: boolean
dialogError: string | null
editingProvider: AuthProvider | null
userGroups: UserGroup[]
form: AuthProviderFormState
setForm: React.Dispatch<React.SetStateAction<AuthProviderFormState>>
onClose: () => void
onSave: () => void
onDialogErrorClose: () => void
}
export function emptyAuthProviderForm(): AuthProviderFormState {
return {
name: '',
type: 'ldap',
enabled: true,
ldap_url: '',
ldap_bind_dn: '',
ldap_bind_password: '',
ldap_user_base_dn: '',
ldap_user_filter: '(uid={username})',
ldap_tls_insecure_skip_verify: false,
oidc_client_id: '',
oidc_client_secret: '',
oidc_authorize_url: '',
oidc_token_url: '',
oidc_userinfo_url: '',
oidc_redirect_url: '',
oidc_scopes: 'openid profile email',
oidc_tls_insecure_skip_verify: false,
oidc_groups_claim: 'groups',
oidc_admission_expr: '',
default_user_group_id: ''
}
}
export function authProviderToForm(item: AuthProvider): AuthProviderFormState {
return {
name: item.name,
type: item.type,
enabled: item.enabled,
ldap_url: item.ldap_url,
ldap_bind_dn: item.ldap_bind_dn,
ldap_bind_password: item.ldap_bind_password,
ldap_user_base_dn: item.ldap_user_base_dn,
ldap_user_filter: item.ldap_user_filter || '(uid={username})',
ldap_tls_insecure_skip_verify: item.ldap_tls_insecure_skip_verify,
oidc_client_id: item.oidc_client_id,
oidc_client_secret: item.oidc_client_secret,
oidc_authorize_url: item.oidc_authorize_url,
oidc_token_url: item.oidc_token_url,
oidc_userinfo_url: item.oidc_userinfo_url,
oidc_redirect_url: item.oidc_redirect_url,
oidc_scopes: item.oidc_scopes || 'openid profile email',
oidc_tls_insecure_skip_verify: item.oidc_tls_insecure_skip_verify,
oidc_groups_claim: item.oidc_groups_claim || 'groups',
oidc_admission_expr: item.oidc_admission_expr || '',
default_user_group_id: item.default_user_group_id
}
}
export default function AuthProviderFormDialog(props: AuthProviderFormDialogProps) {
const isLDAP: boolean = props.form.type === 'ldap'
const isOIDC: boolean = props.form.type === 'oidc'
const [testing, setTesting] = useState<boolean>(false)
const [testError, setTestError] = useState<string | null>(null)
const [testResult, setTestResult] = useState<string | null>(null)
const [testUsername, setTestUsername] = useState<string>('')
const [testPassword, setTestPassword] = useState<string>('')
const testControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
if (!props.open) {
setTesting(false)
setTestError(null)
setTestResult(null)
setTestUsername('')
setTestPassword('')
}
}, [props.open])
useEffect(() => {
return () => {
if (testControllerRef.current) {
testControllerRef.current.abort()
}
}
}, [])
const handleTest = async () => {
const provider: AuthProvider | null = props.editingProvider
const controller: AbortController = new AbortController()
if (!provider) {
return
}
if (testing) {
if (testControllerRef.current) {
testControllerRef.current.abort()
}
return
}
testControllerRef.current = controller
setTesting(true)
setTestError(null)
setTestResult(null)
try {
const result: { status: string; user?: string } = await api.testAuthProvider(
provider.id,
{ username: testUsername.trim() || undefined, password: testPassword || undefined },
controller.signal
)
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
setTestResult('LDAP test canceled.')
return
}
setTestError(err instanceof Error ? err.message : 'LDAP test failed')
} finally {
setTesting(false)
testControllerRef.current = null
}
}
return (
<Dialog open={props.open} onClose={() => { if (!props.busy) props.onClose() }} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">
{props.editingProvider ? `Edit Provider: ${props.editingProvider.name}` : 'New Provider'}
</Typography>
<PageAlert message={props.dialogError} onClose={props.onDialogErrorClose} />
</Box>
</DialogTitle>
<FormDialogContent>
<TextField label="Name" value={props.form.name} onChange={(event) => props.setForm((prev) => ({ ...prev, name: event.target.value }))} />
{!props.editingProvider ? (
<SelectField select label="Type" value={props.form.type} onChange={(event) => props.setForm((prev) => ({ ...prev, type: event.target.value as 'ldap' | 'oidc' }))}>
<MenuItem value="ldap">LDAP</MenuItem>
<MenuItem value="oidc">OIDC</MenuItem>
</SelectField>
) : null}
<FormControlLabel
control={<Checkbox checked={props.form.enabled} onChange={(event) => props.setForm((prev) => ({ ...prev, enabled: event.target.checked }))} />}
label="Enabled"
/>
{props.editingProvider?.type !== 'db' ? (
<>
<Autocomplete<UserGroup, false, false, false>
options={props.userGroups}
value={props.userGroups.find((group: UserGroup) => group.id === props.form.default_user_group_id) ?? null}
onChange={(_event, value) => props.setForm((prev) => ({ ...prev, default_user_group_id: value?.id ?? '' }))}
getOptionLabel={(group) => group.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No user groups"
renderInput={(params) => (
<TextField
{...params}
label="Default User Group"
helperText="New users from this provider are added to this group automatically."
/>
)}
/>
{isLDAP ? (
<>
<TextField label="LDAP URL" value={props.form.ldap_url} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_url: event.target.value }))} helperText="Example: ldap://ldap.example.com:389" />
<TextField label="Bind DN (optional)" value={props.form.ldap_bind_dn} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_bind_dn: event.target.value }))} />
<TextField label="Bind Password" type="password" value={props.form.ldap_bind_password} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_bind_password: event.target.value }))} />
<TextField label="User Base DN" value={props.form.ldap_user_base_dn} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_user_base_dn: event.target.value }))} />
<TextField label="User Filter" value={props.form.ldap_user_filter} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_user_filter: event.target.value }))} helperText="Use {username} as placeholder. Example: (uid={username})" />
<FormControlLabel
control={<Checkbox checked={props.form.ldap_tls_insecure_skip_verify} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))} />}
label="TLS insecure skip verify (testing/self-signed only)"
/>
</>
) : null}
{isOIDC ? (
<>
<TextField label="Client ID" value={props.form.oidc_client_id} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_client_id: event.target.value }))} />
<TextField label="Client Secret" type="password" value={props.form.oidc_client_secret} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_client_secret: event.target.value }))} />
<TextField label="Authorize URL" value={props.form.oidc_authorize_url} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_authorize_url: event.target.value }))} />
<TextField label="Token URL" value={props.form.oidc_token_url} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_token_url: event.target.value }))} />
<TextField label="UserInfo URL (optional)" value={props.form.oidc_userinfo_url} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_userinfo_url: event.target.value }))} />
<TextField
label="Redirect URL"
value={props.form.oidc_redirect_url}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_redirect_url: event.target.value }))}
helperText={props.editingProvider ? `Callback URL: /api/auth/oidc/${props.editingProvider.id}/callback` : 'You can set this after creating the provider once you have its ID.'}
/>
<TextField label="Scopes" value={props.form.oidc_scopes} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_scopes: event.target.value }))} helperText="Space-separated. Example: openid profile email" />
<FormControlLabel
control={<Checkbox checked={props.form.oidc_tls_insecure_skip_verify} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: event.target.checked }))} />}
label="OIDC TLS insecure skip verify (testing/self-signed only)"
/>
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Admission</Typography>
<TextField label="Groups Claim" value={props.form.oidc_groups_claim} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_groups_claim: event.target.value }))} helperText="Claim name containing group membership. Default: groups" />
<TextField
label="Admission Expression (optional)"
value={props.form.oidc_admission_expr}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_admission_expr: event.target.value }))}
multiline
minRows={2}
helperText={
<span>
Boolean expression. Empty = allow all.
Variables: <code>email</code>, <code>email_domain</code>, <code>groups</code> (array), <code>sub</code>, <code>username</code>, <code>claims</code> (map).
Examples: <code>{'email_domain == "company.com"'}</code>, <code>{"\"admin\" in groups"}</code>,
<code>{'email_domain == "corp.com" && "dev" in groups'}</code>
</span>
}
/>
</Box>
</>
) : null}
{props.editingProvider && props.editingProvider.type === 'ldap' ? (
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Test LDAP Connection</Typography>
{testError ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'error.main' }}>
<ErrorOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">{testError}</Typography>
</Box>
) : testResult ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'success.main' }}>
<CheckCircleOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">{testResult}</Typography>
</Box>
) : null}
<TextField label="Test Username (optional)" size="small" value={testUsername} onChange={(event) => setTestUsername(event.target.value)} />
<TextField label="Test Password (optional)" type="password" size="small" value={testPassword} onChange={(event) => setTestPassword(event.target.value)} />
<Box>
<Button variant="outlined" size="small" onClick={handleTest} color={testing ? 'warning' : 'primary'}>
{testing ? 'Cancel Test' : 'Test Connection'}
</Button>
</Box>
</Box>
) : null}
</>
) : null}
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onSave} disabled={props.busy}>
{props.busy ? 'Saving...' : props.editingProvider ? 'Save' : 'Create'}
</Button>
<Button onClick={props.onClose} disabled={props.busy}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
+28 -330
View File
@@ -3,72 +3,21 @@ import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Chip from '@mui/material/Chip'
import Dialog from '../components/ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import CompactListItemText from '../components/CompactListItemText'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import SelectField from '../components/SelectField'
import PageAlert from '../components/PageAlert'
import Autocomplete from '../components/Autocomplete'
import AuthProviderDetailsDialog from '../components/AuthProviderDetailsDialog'
import AuthProviderFormDialog, { AuthProviderFormState, authProviderToForm, emptyAuthProviderForm } from '../components/AuthProviderFormDialog'
import { api, AuthProvider, UserGroup } from '../api'
type ProviderFormState = {
name: string
type: 'ldap' | 'oidc' | 'db'
enabled: boolean
ldap_url: string
ldap_bind_dn: string
ldap_bind_password: string
ldap_user_base_dn: string
ldap_user_filter: string
ldap_tls_insecure_skip_verify: boolean
oidc_client_id: string
oidc_client_secret: string
oidc_authorize_url: string
oidc_token_url: string
oidc_userinfo_url: string
oidc_redirect_url: string
oidc_scopes: string
oidc_tls_insecure_skip_verify: boolean
default_user_group_id: string
}
function emptyProviderForm(): ProviderFormState {
return {
name: '',
type: 'ldap',
enabled: true,
ldap_url: '',
ldap_bind_dn: '',
ldap_bind_password: '',
ldap_user_base_dn: '',
ldap_user_filter: '(uid={username})',
ldap_tls_insecure_skip_verify: false,
oidc_client_id: '',
oidc_client_secret: '',
oidc_authorize_url: '',
oidc_token_url: '',
oidc_userinfo_url: '',
oidc_redirect_url: '',
oidc_scopes: 'openid profile email',
oidc_tls_insecure_skip_verify: false,
default_user_group_id: ''
}
}
export default function AdminSiteAuthPage() {
// Providers list
const [providers, setProviders] = useState<AuthProvider[]>([])
@@ -80,9 +29,10 @@ export default function AdminSiteAuthPage() {
// Provider dialog
const [dialogOpen, setDialogOpen] = useState(false)
const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null)
const [form, setForm] = useState<ProviderFormState>(emptyProviderForm())
const [form, setForm] = useState<AuthProviderFormState>(emptyAuthProviderForm())
const [dialogError, setDialogError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [detailsItem, setDetailsItem] = useState<AuthProvider | null>(null)
// Delete dialog
const [deleteItem, setDeleteItem] = useState<AuthProvider | null>(null)
@@ -91,14 +41,6 @@ export default function AdminSiteAuthPage() {
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
// LDAP test
const [testing, setTesting] = useState(false)
const [testError, setTestError] = useState<string | null>(null)
const [testResult, setTestResult] = useState<string | null>(null)
const [testUsername, setTestUsername] = useState('')
const [testPassword, setTestPassword] = useState('')
const testControllerRef = useRef<AbortController | null>(null)
const filteredProviders = useMemo(() => {
const needle = providerSearch.trim().toLowerCase()
if (!needle) return providers
@@ -124,50 +66,17 @@ export default function AdminSiteAuthPage() {
loadProviders()
}, [])
useEffect(() => {
return () => {
if (testControllerRef.current) testControllerRef.current.abort()
}
}, [])
const openCreate = () => {
setEditingProvider(null)
setForm(emptyProviderForm())
setForm(emptyAuthProviderForm())
setDialogError(null)
setTestError(null)
setTestResult(null)
setTestUsername('')
setTestPassword('')
setDialogOpen(true)
}
const openEdit = (item: AuthProvider) => {
setEditingProvider(item)
setForm({
name: item.name,
type: item.type,
enabled: item.enabled,
ldap_url: item.ldap_url,
ldap_bind_dn: item.ldap_bind_dn,
ldap_bind_password: item.ldap_bind_password,
ldap_user_base_dn: item.ldap_user_base_dn,
ldap_user_filter: item.ldap_user_filter || '(uid={username})',
ldap_tls_insecure_skip_verify: item.ldap_tls_insecure_skip_verify,
oidc_client_id: item.oidc_client_id,
oidc_client_secret: item.oidc_client_secret,
oidc_authorize_url: item.oidc_authorize_url,
oidc_token_url: item.oidc_token_url,
oidc_userinfo_url: item.oidc_userinfo_url,
oidc_redirect_url: item.oidc_redirect_url,
oidc_scopes: item.oidc_scopes || 'openid profile email',
oidc_tls_insecure_skip_verify: item.oidc_tls_insecure_skip_verify,
default_user_group_id: item.default_user_group_id
})
setForm(authProviderToForm(item))
setDialogError(null)
setTestError(null)
setTestResult(null)
setTestUsername('')
setTestPassword('')
setDialogOpen(true)
}
@@ -208,39 +117,6 @@ export default function AdminSiteAuthPage() {
}
}
const handleTest = async () => {
if (!editingProvider) return
if (testing) {
if (testControllerRef.current) testControllerRef.current.abort()
return
}
const controller = new AbortController()
testControllerRef.current = controller
setTesting(true)
setTestError(null)
setTestResult(null)
try {
const result = await api.testAuthProvider(
editingProvider.id,
{ username: testUsername.trim() || undefined, password: testPassword || undefined },
controller.signal
)
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
setTestResult('LDAP test canceled.')
return
}
setTestError(err instanceof Error ? err.message : 'LDAP test failed')
} finally {
setTesting(false)
testControllerRef.current = null
}
}
const isLDAP = form.type === 'ldap'
const isOIDC = form.type === 'oidc'
return (
<Box sx={{ display: 'grid', gap: 1, minWidth: 0 }}>
<Typography variant="h5" sx={{ mb: 1 }}>Admin: Site Authentication</Typography>
@@ -284,9 +160,9 @@ export default function AdminSiteAuthPage() {
<Typography sx={{ wordBreak: 'break-word' }}>{item.name}</Typography>
<Chip label={item.type.toUpperCase()} size="small" variant="outlined" />
{item.enabled ? (
<Chip label="enabled" size="small" color="success" />
<Chip label="Enabled" size="small" color="success" />
) : (
<Chip label="disabled" size="small" />
<Chip label="Disabled" size="small" color="error" />
)}
</Box>
}
@@ -300,6 +176,7 @@ export default function AdminSiteAuthPage() {
disableTypography
/>
<ListRowActions>
<ListRowActionButton onClick={() => setDetailsItem(item)}>View</ListRowActionButton>
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
<ListRowActionButton color="error" disabled={item.id === 'db'} onClick={() => {
setDeleteError(null)
@@ -315,203 +192,24 @@ export default function AdminSiteAuthPage() {
</List>
</SectionCard>
{/* Create / Edit dialog */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">
{editingProvider ? `Edit Provider: ${editingProvider.name}` : 'New Provider'}
</Typography>
<PageAlert message={dialogError} onClose={() => setDialogError(null)} />
</Box>
</DialogTitle>
<FormDialogContent>
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
{!editingProvider ? (
<SelectField
select
label="Type"
value={form.type}
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value as 'ldap' | 'oidc' }))}
>
<MenuItem value="ldap">LDAP</MenuItem>
<MenuItem value="oidc">OIDC</MenuItem>
</SelectField>
) : null}
<FormControlLabel
control={
<Checkbox
checked={form.enabled}
onChange={(e) => setForm((prev) => ({ ...prev, enabled: e.target.checked }))}
/>
}
label="Enabled"
/>
{editingProvider?.type !== 'db' ? (
<>
<Autocomplete<UserGroup, false, false, false>
options={userGroups}
value={userGroups.find((g) => g.id === form.default_user_group_id) ?? null}
onChange={(_event, value) => setForm((prev) => ({ ...prev, default_user_group_id: value?.id ?? '' }))}
getOptionLabel={(g) => g.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No user groups"
renderInput={(params) => (
<TextField
{...params}
label="Default User Group"
helperText="New users from this provider are added to this group automatically."
/>
)}
/>
{isLDAP ? (
<>
<TextField
label="LDAP URL"
value={form.ldap_url}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_url: e.target.value }))}
helperText="Example: ldap://ldap.example.com:389"
/>
<TextField
label="Bind DN (optional)"
value={form.ldap_bind_dn}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_bind_dn: e.target.value }))}
/>
<TextField
label="Bind Password"
type="password"
value={form.ldap_bind_password}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_bind_password: e.target.value }))}
/>
<TextField
label="User Base DN"
value={form.ldap_user_base_dn}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_user_base_dn: e.target.value }))}
/>
<TextField
label="User Filter"
value={form.ldap_user_filter}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_user_filter: e.target.value }))}
helperText="Use {username} as placeholder. Example: (uid={username})"
/>
<FormControlLabel
control={
<Checkbox
checked={form.ldap_tls_insecure_skip_verify}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: e.target.checked }))}
/>
}
label="TLS insecure skip verify (testing/self-signed only)"
/>
</>
) : null}
{isOIDC ? (
<>
<TextField
label="Client ID"
value={form.oidc_client_id}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_client_id: e.target.value }))}
/>
<TextField
label="Client Secret"
type="password"
value={form.oidc_client_secret}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_client_secret: e.target.value }))}
/>
<TextField
label="Authorize URL"
value={form.oidc_authorize_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_authorize_url: e.target.value }))}
/>
<TextField
label="Token URL"
value={form.oidc_token_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_token_url: e.target.value }))}
/>
<TextField
label="UserInfo URL (optional)"
value={form.oidc_userinfo_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_userinfo_url: e.target.value }))}
/>
<TextField
label="Redirect URL"
value={form.oidc_redirect_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_redirect_url: e.target.value }))}
helperText={
editingProvider
? `Callback URL: /api/auth/oidc/${editingProvider.id}/callback`
: 'You can set this after creating the provider once you have its ID.'
}
/>
<TextField
label="Scopes"
value={form.oidc_scopes}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_scopes: e.target.value }))}
helperText="Space-separated. Example: openid profile email"
/>
<FormControlLabel
control={
<Checkbox
checked={form.oidc_tls_insecure_skip_verify}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: e.target.checked }))}
/>
}
label="OIDC TLS insecure skip verify (testing/self-signed only)"
/>
</>
) : null}
{editingProvider && editingProvider.type === 'ldap' ? (
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Test LDAP Connection</Typography>
{testError ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'error.main' }}>
<ErrorOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">{testError}</Typography>
</Box>
) : testResult ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'success.main' }}>
<CheckCircleOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">{testResult}</Typography>
</Box>
) : null}
<TextField
label="Test Username (optional)"
size="small"
value={testUsername}
onChange={(e) => setTestUsername(e.target.value)}
/>
<TextField
label="Test Password (optional)"
type="password"
size="small"
value={testPassword}
onChange={(e) => setTestPassword(e.target.value)}
/>
<Box>
<Button
variant="outlined"
size="small"
onClick={handleTest}
color={testing ? 'warning' : 'primary'}
>
{testing ? 'Cancel Test' : 'Test Connection'}
</Button>
</Box>
</Box>
) : null}
</>) : null}
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={handleSaveProvider} disabled={saving}>
{saving ? 'Saving...' : editingProvider ? 'Save' : 'Create'}
</Button>
<Button onClick={() => setDialogOpen(false)} disabled={saving}>Cancel</Button>
</DialogActions>
</Dialog>
<AuthProviderFormDialog
open={dialogOpen}
busy={saving}
dialogError={dialogError}
editingProvider={editingProvider}
userGroups={userGroups}
form={form}
setForm={setForm}
onClose={() => setDialogOpen(false)}
onSave={handleSaveProvider}
onDialogErrorClose={() => setDialogError(null)}
/>
<AuthProviderDetailsDialog
item={detailsItem}
userGroups={userGroups}
onClose={() => setDetailsItem(null)}
/>
{/* Delete dialog */}
<ConfirmDeleteDialog