package authelia import ( "fmt" "os" "gopkg.in/yaml.v3" "github.com/wild-cloud/wild-central/internal/storage" ) // OIDCClient represents a registered OIDC client application type OIDCClient struct { ID string `yaml:"client_id" json:"clientId"` Name string `yaml:"client_name" json:"clientName"` Secret string `yaml:"client_secret" json:"clientSecret,omitempty"` // pbkdf2 hash in config, cleartext on create response RedirectURIs []string `yaml:"redirect_uris" json:"redirectUris"` Scopes []string `yaml:"scopes" json:"scopes"` Policy string `yaml:"authorization_policy" json:"authorizationPolicy"` ConsentMode string `yaml:"consent_mode,omitempty" json:"consentMode,omitempty"` } // OIDCClientUpdate holds partial updates for an OIDC client type OIDCClientUpdate struct { Name *string `json:"clientName,omitempty"` RedirectURIs []string `json:"redirectUris,omitempty"` Scopes []string `json:"scopes,omitempty"` Policy *string `json:"authorizationPolicy,omitempty"` ConsentMode *string `json:"consentMode,omitempty"` } // oidcClientsFile is the top-level structure of the OIDC clients file type oidcClientsFile struct { Clients []OIDCClient `yaml:"clients"` } // ListOIDCClients returns all registered OIDC clients (secrets redacted) func (m *Manager) ListOIDCClients() ([]OIDCClient, error) { clients, err := m.readOIDCClients() if err != nil { return nil, err } // Redact secrets for listing for i := range clients { clients[i].Secret = "" } return clients, nil } // ReadOIDCClients returns all clients with secrets (for config generation) func (m *Manager) ReadOIDCClients() ([]OIDCClient, error) { return m.readOIDCClients() } // AddOIDCClient adds a new OIDC client. Returns the cleartext secret (shown once). func (m *Manager) AddOIDCClient(client OIDCClient) (cleartextSecret string, err error) { if client.ID == "" { return "", fmt.Errorf("client_id is required") } if client.Name == "" { return "", fmt.Errorf("client_name is required") } if len(client.RedirectURIs) == 0 { return "", fmt.Errorf("at least one redirect_uri is required") } lockPath := m.configPath() + ".lock" err = storage.WithLock(lockPath, func() error { clients, readErr := m.readOIDCClients() if readErr != nil { return readErr } for _, c := range clients { if c.ID == client.ID { return fmt.Errorf("client %q already exists", client.ID) } } // Generate client secret secret, genErr := GenerateSecret(32) if genErr != nil { return fmt.Errorf("generating client secret: %w", genErr) } cleartextSecret = secret // Hash for storage client.Secret = hashClientSecret(secret) // Default scopes if len(client.Scopes) == 0 { client.Scopes = []string{"openid", "profile", "email", "groups"} } if client.Policy == "" { client.Policy = "one_factor" } return m.writeOIDCClients(append(clients, client)) }) return } // UpdateOIDCClient applies partial updates to an OIDC client func (m *Manager) UpdateOIDCClient(clientID string, updates OIDCClientUpdate) error { lockPath := m.configPath() + ".lock" return storage.WithLock(lockPath, func() error { clients, err := m.readOIDCClients() if err != nil { return err } found := false for i, c := range clients { if c.ID == clientID { if updates.Name != nil { clients[i].Name = *updates.Name } if updates.RedirectURIs != nil { clients[i].RedirectURIs = updates.RedirectURIs } if updates.Scopes != nil { clients[i].Scopes = updates.Scopes } if updates.Policy != nil { clients[i].Policy = *updates.Policy } if updates.ConsentMode != nil { clients[i].ConsentMode = *updates.ConsentMode } found = true break } } if !found { return fmt.Errorf("client %q not found", clientID) } return m.writeOIDCClients(clients) }) } // DeleteOIDCClient removes an OIDC client by ID func (m *Manager) DeleteOIDCClient(clientID string) error { lockPath := m.configPath() + ".lock" return storage.WithLock(lockPath, func() error { clients, err := m.readOIDCClients() if err != nil { return err } var filtered []OIDCClient found := false for _, c := range clients { if c.ID == clientID { found = true continue } filtered = append(filtered, c) } if !found { return fmt.Errorf("client %q not found", clientID) } return m.writeOIDCClients(filtered) }) } // OIDCClientCount returns the number of registered OIDC clients func (m *Manager) OIDCClientCount() int { clients, err := m.readOIDCClients() if err != nil { return 0 } return len(clients) } // readOIDCClients reads OIDC clients from the dedicated clients file func (m *Manager) readOIDCClients() ([]OIDCClient, error) { data, err := os.ReadFile(m.oidcClientsPath()) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("reading oidc clients: %w", err) } var f oidcClientsFile if err := yaml.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("parsing oidc clients: %w", err) } return f.Clients, nil } // writeOIDCClients writes the OIDC clients to the dedicated clients file func (m *Manager) writeOIDCClients(clients []OIDCClient) error { f := oidcClientsFile{Clients: clients} data, err := yaml.Marshal(f) if err != nil { return fmt.Errorf("marshaling oidc clients: %w", err) } tmpPath := m.oidcClientsPath() + ".tmp" if err := os.WriteFile(tmpPath, data, 0600); err != nil { return fmt.Errorf("writing temp oidc clients: %w", err) } if err := os.Rename(tmpPath, m.oidcClientsPath()); err != nil { os.Remove(tmpPath) return fmt.Errorf("installing oidc clients: %w", err) } return nil } // hashClientSecret wraps the secret with $plaintext$ prefix. // Authelia accepts this format and handles hashing internally. func hashClientSecret(secret string) string { return "$plaintext$" + secret }