mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-07-16 23:11:04 -05:00
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
322 lines
8.6 KiB
Go
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")
|
|
}
|