Files
vikunja/pkg/modules/auth/openid/providers.go
kolaente ced7ebd97f fix(auth): tolerate string booleans in oidc provider config (#2599)
The four boolean OIDC provider fields (emailfallback, usernamefallback,
forceuserinfo, requireavailability) were parsed with a strict .(bool)
type assertion. That works for YAML/JSON config where leaves are native
bools, but fails for every other input path: env vars always arrive as
strings, and GetConfigValueFromFile (used by the *.file Docker secret
convention) also always returns strings. The assertion would silently
zero the field for emailfallback and usernamefallback, and log an error
and zero the field for forceuserinfo and requireavailability, which is
what #2599 reports.

Extract a small parseBoolField helper that accepts both native bools and
strings (via strconv.ParseBool) and logs a parse error from each call
site. This also fixes the previously-silent drop of stringified
emailfallback / usernamefallback values — those now log an error if the
input is garbage, matching the behaviour of the other two fields.

Fixes #2599
2026-04-11 19:10:26 +00:00

322 lines
8.6 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package openid
import (
"errors"
"fmt"
"strconv"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// ErrDuplicateOIDCIssuer is returned when two configured providers resolve to the same issuer URL.
type ErrDuplicateOIDCIssuer struct {
Issuer string
Provider1 string
Provider2 string
}
func (e *ErrDuplicateOIDCIssuer) Error() string {
return fmt.Sprintf(
"duplicate OpenID Connect issuer %q: providers %q and %q resolve to the same issuer, which will cause team sync conflicts",
e.Issuer, e.Provider1, e.Provider2,
)
}
// IsErrDuplicateOIDCIssuer checks if an error is a duplicate issuer error.
func IsErrDuplicateOIDCIssuer(err error) bool {
var target *ErrDuplicateOIDCIssuer
return errors.As(err, &target)
}
// FindDuplicateIssuers checks a map of provider key → issuer URL for duplicates.
// It returns a list of all duplicate pairs found.
func FindDuplicateIssuers(providerIssuers map[string]string) []ErrDuplicateOIDCIssuer {
issuerToKey := make(map[string]string)
var duplicates []ErrDuplicateOIDCIssuer
for key, issuer := range providerIssuers {
if existingKey, exists := issuerToKey[issuer]; exists {
duplicates = append(duplicates, ErrDuplicateOIDCIssuer{
Issuer: issuer,
Provider1: existingKey,
Provider2: key,
})
}
issuerToKey[issuer] = key
}
return duplicates
}
// GetAllProviders returns all configured providers
func GetAllProviders() (providers []*Provider, err error) {
if !config.AuthOpenIDEnabled.GetBool() {
return nil, nil
}
providers = []*Provider{}
exists, err := keyvalue.GetWithValue("openid_providers", &providers)
if !exists {
rawProviders := config.AuthOpenIDProviders.Get()
if rawProviders == nil {
return nil, nil
}
rawProvider, is := rawProviders.(map[string]interface{})
if !is {
// Try to convert from map[interface{}]interface{} (YAML format)
if rawProviderInterface, ok := rawProviders.(map[interface{}]interface{}); ok {
rawProvider = make(map[string]interface{}, len(rawProviderInterface))
for k, v := range rawProviderInterface {
if key, keyOK := k.(string); keyOK {
rawProvider[key] = v
}
}
} else {
log.Criticalf("It looks like your openid configuration is in the wrong format. Please check the docs for the correct format.")
return
}
}
for key, p := range rawProvider {
var pi map[string]interface{}
var is bool
pi, is = p.(map[string]interface{})
// JSON config is a map[string]interface{}, other providers are not. Under the hood they are all strings so
// it is safe to cast.
if !is {
if pis, pisOK := p.(map[interface{}]interface{}); pisOK {
pi = make(map[string]interface{}, len(pis))
for i, s := range pis {
if key, keyOK := i.(string); keyOK {
pi[key] = s
}
}
} else {
log.Errorf("Provider %s has invalid configuration format, skipping", key)
continue
}
}
provider, err := getProviderFromMap(pi, key)
if err != nil {
log.Errorf("Error while getting openid provider %s: %s", key, err)
continue
}
if provider == nil {
log.Errorf("Could not openid provider %s, please check your config", key)
continue
}
providers = append(providers, provider)
err = keyvalue.Put("openid_provider_"+key, provider)
if err != nil {
return nil, err
}
}
// Check for duplicate issuers across providers
providerIssuers := make(map[string]string)
for _, p := range providers {
issuer, issuerErr := p.Issuer()
if issuerErr != nil {
log.Errorf("Error getting issuer for openid provider %s: %s", p.Key, issuerErr)
continue
}
providerIssuers[p.Key] = issuer
}
if duplicates := FindDuplicateIssuers(providerIssuers); len(duplicates) > 0 {
return nil, &duplicates[0]
}
err = keyvalue.Put("openid_providers", providers)
}
return
}
// GetProvider retrieves a provider from keyvalue
func GetProvider(key string) (provider *Provider, err error) {
provider = &Provider{}
exists, err := keyvalue.GetWithValue("openid_provider_"+key, provider)
if err != nil {
return nil, err
}
if !exists {
_, err = GetAllProviders() // This will put all providers in cache
if err != nil {
return nil, err
}
_, err = keyvalue.GetWithValue("openid_provider_"+key, provider)
if err != nil {
return nil, err
}
}
err = provider.setOicdProvider()
return
}
// parseBoolField reads a boolean-valued config field from a provider map,
// tolerating both native bools (from YAML/JSON) and strings (from env vars or
// the GetConfigValueFromFile path, which always return strings). Missing or
// empty values default to false with no error.
func parseBoolField(pi map[string]interface{}, key string) (val bool, err error) {
raw, exists := pi[key]
if !exists {
return false, nil
}
switch v := raw.(type) {
case bool:
return v, nil
case string:
if v == "" {
return false, nil
}
return strconv.ParseBool(v)
default:
return false, fmt.Errorf("expected bool, got %T", raw)
}
}
func getProviderFromMap(pi map[string]interface{}, key string) (provider *Provider, err error) {
requiredKeys := []string{
"name",
"authurl",
"clientsecret",
"clientid",
}
allKeys := append(
[]string{
"logouturl",
"scope",
"emailfallback",
"usernamefallback",
"forceuserinfo",
"requireavailability",
},
requiredKeys...,
)
for _, configKey := range allKeys {
valueFromFile := config.GetConfigValueFromFile("auth.openid.providers." + key + "." + configKey)
if valueFromFile != "" {
pi[configKey] = valueFromFile
}
}
for _, key := range requiredKeys {
if _, exists := pi[key]; !exists {
return nil, fmt.Errorf("required key '%s' is missing in the provider configuration", key)
}
}
name, is := pi["name"].(string)
if !is {
return nil, nil
}
var logoutURL string
logoutValue, exists := pi["logouturl"]
if exists {
url, ok := logoutValue.(string)
if ok {
logoutURL = url
}
}
var scope string
if scopeValue, exists := pi["scope"]; exists {
scope = scopeValue.(string)
}
if scope == "" {
scope = "openid profile email"
}
emailFallback, err := parseBoolField(pi, "emailfallback")
if err != nil {
log.Errorf("emailfallback is not a boolean for provider %s: %s", key, err)
}
usernameFallback, err := parseBoolField(pi, "usernamefallback")
if err != nil {
log.Errorf("usernamefallback is not a boolean for provider %s: %s", key, err)
}
forceUserInfo, err := parseBoolField(pi, "forceuserinfo")
if err != nil {
log.Errorf("forceuserinfo is not a boolean for provider %s: %s", key, err)
}
requireAvailability, err := parseBoolField(pi, "requireavailability")
if err != nil {
log.Errorf("requireavailability is not a boolean for provider %s: %s", key, err)
}
provider = &Provider{
Name: name,
Key: key,
AuthURL: pi["authurl"].(string),
OriginalAuthURL: pi["authurl"].(string),
ClientSecret: pi["clientsecret"].(string),
LogoutURL: logoutURL,
Scope: scope,
EmailFallback: emailFallback,
UsernameFallback: usernameFallback,
ForceUserInfo: forceUserInfo,
RequireAvailability: requireAvailability,
}
cl, is := pi["clientid"].(int)
if is {
provider.ClientID = strconv.Itoa(cl)
} else {
provider.ClientID = pi["clientid"].(string)
}
err = provider.setOicdProvider()
if err != nil {
return
}
provider.Oauth2Config = &oauth2.Config{
ClientID: provider.ClientID,
ClientSecret: provider.ClientSecret,
// Discovery returns the OAuth2 endpoints.
Endpoint: provider.openIDProvider.Endpoint(),
// "openid" is a required scope for OpenID Connect flows.
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
provider.AuthURL = provider.Oauth2Config.Endpoint.AuthURL
return
}
func CleanupSavedOpenIDProviders() {
_ = keyvalue.Del("openid_providers")
}