mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-26 15:15:19 +00:00
Teams synced from OpenID Connect providers were always named with "(OIDC)" suffix (e.g., "DevTeam (OIDC)"). This changes it to use the configured provider name instead (e.g., "DevTeam (Keycloak)"), making it easier to identify which provider a team came from when multiple OIDC providers are configured. Existing team names will be updated automatically on next user login. https://claude.ai/code/session_012LXXPvYe6i27WTcha1PL7A
512 lines
15 KiB
Go
512 lines
15 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 (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"code.vikunja.io/api/pkg/db"
|
|
"code.vikunja.io/api/pkg/log"
|
|
"code.vikunja.io/api/pkg/models"
|
|
"code.vikunja.io/api/pkg/modules/auth"
|
|
"code.vikunja.io/api/pkg/modules/avatar"
|
|
"code.vikunja.io/api/pkg/modules/avatar/upload"
|
|
"code.vikunja.io/api/pkg/user"
|
|
"code.vikunja.io/api/pkg/utils"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
petname "github.com/dustinkirkland/golang-petname"
|
|
"github.com/labstack/echo/v5"
|
|
"golang.org/x/oauth2"
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
// Callback contains the callback after an auth request was made and redirected
|
|
type Callback struct {
|
|
Code string `query:"code" json:"code"`
|
|
Scope string `query:"scope" json:"scope"`
|
|
RedirectURL string `json:"redirect_url"`
|
|
}
|
|
|
|
// Provider is the structure of an OpenID Connect provider
|
|
type Provider struct {
|
|
Name string `json:"name"`
|
|
Key string `json:"key"`
|
|
OriginalAuthURL string `json:"-"`
|
|
AuthURL string `json:"auth_url"`
|
|
LogoutURL string `json:"logout_url"`
|
|
ClientID string `json:"client_id"`
|
|
Scope string `json:"scope"`
|
|
EmailFallback bool `json:"email_fallback"`
|
|
UsernameFallback bool `json:"username_fallback"`
|
|
ForceUserInfo bool `json:"force_user_info"`
|
|
RequireAvailability bool `json:"-"`
|
|
ClientSecret string `json:"-"`
|
|
openIDProvider *oidc.Provider
|
|
Oauth2Config *oauth2.Config `json:"-"`
|
|
}
|
|
|
|
type claims struct {
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
PreferredUsername string `json:"preferred_username"`
|
|
Nickname string `json:"nickname"`
|
|
VikunjaGroups []map[string]interface{} `json:"vikunja_groups"`
|
|
Picture string `json:"picture"`
|
|
ExtraSettingsLinks map[string]any `json:"extra_settings_links"`
|
|
}
|
|
|
|
func init() {
|
|
petname.NonDeterministicMode()
|
|
}
|
|
|
|
func (p *Provider) setOicdProvider() (err error) {
|
|
err = utils.RetryWithBackoff(fmt.Sprintf("OpenID Connect provider '%s'", p.Name), func() error {
|
|
var providerErr error
|
|
p.openIDProvider, providerErr = oidc.NewProvider(context.Background(), p.OriginalAuthURL)
|
|
return providerErr
|
|
})
|
|
|
|
if err != nil && p.RequireAvailability {
|
|
log.Fatalf("OpenID Connect provider '%s' is not available and require_availability is enabled: %s", p.Name, err)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (p *Provider) Issuer() (issuerURL string, err error) {
|
|
type Issuer struct {
|
|
Issuer string `json:"issuer"`
|
|
}
|
|
|
|
if p.openIDProvider == nil {
|
|
err = p.setOicdProvider()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
iss := &Issuer{}
|
|
err = p.openIDProvider.Claims(iss)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return iss.Issuer, nil
|
|
}
|
|
|
|
// HandleCallback handles the auth request callback after redirecting from the provider with an auth code
|
|
// @Summary Authenticate a user with OpenID Connect
|
|
// @Description After a redirect from the OpenID Connect provider to the frontend has been made with the authentication `code`, this endpoint can be used to obtain a jwt token for that user and thus log them in.
|
|
// @ID get-token-openid
|
|
// @tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security JWTKeyAuth
|
|
// @Param callback body openid.Callback true "The openid callback"
|
|
// @Param provider path int true "The OpenID Connect provider key as returned by the /info endpoint"
|
|
// @Success 200 {object} auth.Token
|
|
// @Failure 500 {object} models.Message "Internal error"
|
|
// @Router /auth/openid/{provider}/callback [post]
|
|
func HandleCallback(c *echo.Context) error {
|
|
|
|
provider, oauthToken, idToken, err := getProviderAndOidcTokens(c)
|
|
if err != nil {
|
|
var detailedErr *models.ErrOpenIDBadRequestWithDetails
|
|
if errors.As(err, &detailedErr) {
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{
|
|
"message": detailedErr.Message,
|
|
"details": detailedErr.Details,
|
|
})
|
|
}
|
|
return err
|
|
}
|
|
|
|
cl, err := getClaims(provider, oauthToken, idToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s := db.NewSession()
|
|
defer s.Close()
|
|
|
|
// Check if we have seen this user before
|
|
u, err := getOrCreateUser(s, cl, provider, idToken)
|
|
if err != nil {
|
|
_ = s.Rollback()
|
|
log.Errorf("Error creating new user for provider %s: %v", provider.Name, err)
|
|
return err
|
|
}
|
|
|
|
if u.Status == user.StatusDisabled {
|
|
_ = s.Rollback()
|
|
return &user.ErrAccountDisabled{UserID: u.ID}
|
|
}
|
|
if u.Status == user.StatusAccountLocked {
|
|
_ = s.Rollback()
|
|
return &user.ErrAccountLocked{UserID: u.ID}
|
|
}
|
|
|
|
teamData := getTeamDataFromToken(cl.VikunjaGroups, provider)
|
|
|
|
err = models.SyncExternalTeamsForUser(s, u, teamData, idToken.Issuer, provider.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = s.Commit()
|
|
if err != nil {
|
|
_ = s.Rollback()
|
|
log.Errorf("Error creating new team for provider %s: %v", provider.Name, err)
|
|
return err
|
|
}
|
|
|
|
// Create token
|
|
return auth.NewUserAuthTokenResponse(u, c, false)
|
|
}
|
|
|
|
func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (teamData []*models.Team) {
|
|
teamData = []*models.Team{}
|
|
for _, t := range groups {
|
|
var name string
|
|
var description string
|
|
var oidcID string
|
|
var isPublic bool
|
|
|
|
// Read name
|
|
_, exists := t["name"]
|
|
if exists {
|
|
name = t["name"].(string)
|
|
}
|
|
|
|
// Read description
|
|
_, exists = t["description"]
|
|
if exists {
|
|
description = t["description"].(string)
|
|
}
|
|
|
|
// Read isPublic flag
|
|
_, exists = t["isPublic"]
|
|
if exists {
|
|
isPublic = t["isPublic"].(bool)
|
|
}
|
|
|
|
// Read oidcID
|
|
_, exists = t["oidcID"]
|
|
if exists {
|
|
switch id := t["oidcID"].(type) {
|
|
case string:
|
|
oidcID = id
|
|
case int64:
|
|
oidcID = strconv.FormatInt(id, 10)
|
|
case float64:
|
|
oidcID = strconv.FormatFloat(id, 'f', -1, 64)
|
|
default:
|
|
log.Errorf("No oidcID assigned for %v or type %v not supported", t, t)
|
|
}
|
|
}
|
|
if name == "" || oidcID == "" {
|
|
log.Errorf("Claim of your custom scope does not hold name or oidcID for automatic group assignment through oidc provider. Please check %s", provider.Name)
|
|
continue
|
|
}
|
|
teamData = append(teamData, &models.Team{
|
|
Name: name,
|
|
ExternalID: oidcID,
|
|
Description: description,
|
|
IsPublic: isPublic,
|
|
})
|
|
}
|
|
|
|
return teamData
|
|
}
|
|
|
|
// Download and store a user's avatar from an OpenID provider
|
|
func syncUserAvatarFromOpenID(s *xorm.Session, u *user.User, pictureURL string) (err error) {
|
|
// Don't sync avatar if no picture URL is provided
|
|
if pictureURL == "" {
|
|
return fmt.Errorf("no picture URL provided")
|
|
}
|
|
|
|
log.Debugf("Found avatar URL for user %s: %s", u.Username, pictureURL)
|
|
|
|
// Download avatar
|
|
avatarData, err := utils.DownloadImage(pictureURL)
|
|
if err != nil {
|
|
return fmt.Errorf("error downloading avatar: %w", err)
|
|
}
|
|
|
|
// Process avatar, ensure 1:1 ratio
|
|
processedAvatar, err := utils.CropAvatarTo1x1(avatarData)
|
|
if err != nil {
|
|
return fmt.Errorf("error processing avatar: %w", err)
|
|
}
|
|
|
|
// Set avatar provider to openid
|
|
u.AvatarProvider = "openid"
|
|
|
|
// Store avatar and update user
|
|
err = upload.StoreAvatarFile(s, u, bytes.NewReader(processedAvatar))
|
|
if err != nil {
|
|
return fmt.Errorf("error storing avatar: %w", err)
|
|
}
|
|
|
|
avatar.FlushAllCaches(u)
|
|
|
|
return nil
|
|
}
|
|
|
|
func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *oidc.IDToken) (u *user.User, err error) {
|
|
|
|
// set defaults
|
|
fallbackMatchFound := false
|
|
alreadyCreatedFromIssuer := false
|
|
|
|
// first check if the user already signed up using the provider
|
|
|
|
u, err = user.GetUserWithEmail(s, &user.User{
|
|
Issuer: idToken.Issuer,
|
|
Subject: idToken.Subject,
|
|
})
|
|
if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
|
|
return nil, err
|
|
}
|
|
alreadyCreatedFromIssuer = err == nil || user.IsErrUserStatusError(err)
|
|
|
|
// If the user exists but is disabled/locked, return early — don't update their profile or sync avatar.
|
|
// HandleCallback will reject the auth attempt.
|
|
if alreadyCreatedFromIssuer && user.IsErrUserStatusError(err) {
|
|
return u, nil
|
|
}
|
|
|
|
if !alreadyCreatedFromIssuer && (provider.EmailFallback || provider.UsernameFallback) {
|
|
|
|
// try finding the user on fallback mappingproperties
|
|
|
|
searchUser := &user.User{
|
|
Issuer: user.IssuerLocal,
|
|
}
|
|
if provider.UsernameFallback {
|
|
// Match oidc subject on username as each is unique identifier in its own referential
|
|
// Discouraged if multiple account providers are used.
|
|
searchUser.Username = idToken.Subject
|
|
}
|
|
if provider.EmailFallback {
|
|
// Used alone, allow for someone to connect from various provider to the same account
|
|
// Discouraged for untrusted provider where someone can set email without verification
|
|
// Note : mapping on email prevent from auto-updating user email
|
|
searchUser.Email = cl.Email
|
|
}
|
|
|
|
// Check if the user exists for the given fallback matching options
|
|
u, err = user.GetUserWithEmail(s, searchUser)
|
|
if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
|
|
return nil, err
|
|
}
|
|
fallbackMatchFound = err == nil || user.IsErrUserStatusError(err)
|
|
|
|
// Same as above: disabled/locked user found via fallback — return early.
|
|
if fallbackMatchFound && user.IsErrUserStatusError(err) {
|
|
return u, nil
|
|
}
|
|
}
|
|
|
|
if !alreadyCreatedFromIssuer && !fallbackMatchFound {
|
|
|
|
// If no user exists, create one with the preferred username if it is not already taken
|
|
uu := &user.User{
|
|
Username: strings.ReplaceAll(cl.PreferredUsername, " ", "-"),
|
|
Email: cl.Email,
|
|
Name: cl.Name,
|
|
Status: user.StatusActive,
|
|
Issuer: idToken.Issuer,
|
|
Subject: idToken.Subject,
|
|
ExtraSettingsLinks: cl.ExtraSettingsLinks,
|
|
}
|
|
|
|
u, err = auth.CreateUserWithRandomUsername(s, uu)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else if alreadyCreatedFromIssuer {
|
|
|
|
// try updating user.Name and/or user.Email if necessary
|
|
if cl.Email != u.Email {
|
|
u.Email = cl.Email
|
|
}
|
|
if cl.Name != u.Name {
|
|
u.Name = cl.Name
|
|
}
|
|
|
|
u.ExtraSettingsLinks = cl.ExtraSettingsLinks
|
|
|
|
u, err = user.UpdateUser(s, u, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Try sync avatar if available
|
|
err = syncUserAvatarFromOpenID(s, u, cl.Picture)
|
|
if err != nil {
|
|
log.Errorf("Error syncing avatar for user %s: %v", u.Username, err)
|
|
}
|
|
|
|
return u, nil
|
|
}
|
|
|
|
// mergeClaims combines claims from token and userinfo based on the ForceUserInfo setting
|
|
// cl represents the claims from the token, cl2 represents the claims from userinfo
|
|
func mergeClaims(cl *claims, cl2 *claims, forceUserInfo bool) error {
|
|
if (forceUserInfo && cl2.Email != "") || cl.Email == "" {
|
|
cl.Email = cl2.Email
|
|
}
|
|
|
|
if (forceUserInfo && cl2.Name != "") || cl.Name == "" {
|
|
cl.Name = cl2.Name
|
|
}
|
|
|
|
if (forceUserInfo && cl2.PreferredUsername != "") || cl.PreferredUsername == "" {
|
|
cl.PreferredUsername = cl2.PreferredUsername
|
|
}
|
|
|
|
if cl.PreferredUsername == "" && cl2.Nickname != "" {
|
|
cl.PreferredUsername = cl2.Nickname
|
|
}
|
|
|
|
if (forceUserInfo && cl2.Picture != "") || cl.Picture == "" {
|
|
cl.Picture = cl2.Picture
|
|
}
|
|
|
|
if (forceUserInfo && len(cl2.VikunjaGroups) > 0) || len(cl.VikunjaGroups) == 0 {
|
|
cl.VikunjaGroups = cl2.VikunjaGroups
|
|
}
|
|
|
|
if (forceUserInfo && len(cl2.ExtraSettingsLinks) > 0) || len(cl.ExtraSettingsLinks) == 0 {
|
|
cl.ExtraSettingsLinks = cl2.ExtraSettingsLinks
|
|
}
|
|
|
|
if cl.Email == "" {
|
|
return &user.ErrNoOpenIDEmailProvided{}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func getClaims(provider *Provider, oauth2Token *oauth2.Token, idToken *oidc.IDToken) (*claims, error) {
|
|
|
|
cl := &claims{}
|
|
err := idToken.Claims(cl)
|
|
if err != nil {
|
|
log.Errorf("Error getting token claims for provider %s: %v", provider.Name, err)
|
|
return nil, err
|
|
}
|
|
|
|
if provider.ForceUserInfo || cl.Email == "" || cl.Name == "" || cl.PreferredUsername == "" || cl.Picture == "" {
|
|
info, err := provider.openIDProvider.UserInfo(context.Background(), provider.Oauth2Config.TokenSource(context.Background(), oauth2Token))
|
|
if err != nil {
|
|
log.Errorf("Error getting userinfo for provider %s: %v", provider.Name, err)
|
|
return nil, err
|
|
}
|
|
|
|
cl2 := &claims{}
|
|
err = info.Claims(cl2)
|
|
if err != nil {
|
|
log.Errorf("Error parsing userinfo claims for provider %s: %v", provider.Name, err)
|
|
return nil, err
|
|
}
|
|
|
|
err = mergeClaims(cl, cl2, provider.ForceUserInfo)
|
|
if err != nil {
|
|
if user.IsErrNoEmailProvided(err) {
|
|
log.Errorf("Claim does not contain an email address for provider %s", provider.Name)
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
}
|
|
return cl, nil
|
|
}
|
|
|
|
func getProviderAndOidcTokens(c *echo.Context) (*Provider, *oauth2.Token, *oidc.IDToken, error) {
|
|
|
|
cb := &Callback{}
|
|
if err := c.Bind(cb); err != nil {
|
|
return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Bad data"}
|
|
}
|
|
|
|
// Check if the provider exists
|
|
providerKey := c.Param("provider")
|
|
provider, err := GetProvider(providerKey)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if provider == nil {
|
|
return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Provider does not exist"}
|
|
}
|
|
|
|
log.Debugf("Trying to authenticate user using provider: %s", provider.Key)
|
|
|
|
provider.Oauth2Config.RedirectURL = cb.RedirectURL
|
|
// Parse the access & ID token
|
|
oauth2Token, err := provider.Oauth2Config.Exchange(context.Background(), cb.Code)
|
|
if err != nil {
|
|
var rerr *oauth2.RetrieveError
|
|
if errors.As(err, &rerr) {
|
|
|
|
details := make(map[string]interface{})
|
|
if err := json.Unmarshal(rerr.Body, &details); err != nil {
|
|
log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err)
|
|
log.Debugf("Raw token value is %s", rerr.Body)
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
log.Errorf("Error retrieving token: %s", err)
|
|
log.Debugf("Raw token value is %s", rerr.Body)
|
|
return nil, nil, nil, &models.ErrOpenIDBadRequestWithDetails{
|
|
Message: "Could not authenticate against third party.",
|
|
Details: details,
|
|
}
|
|
}
|
|
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
// Extract the ID Token from OAuth2 token.
|
|
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
|
if !ok {
|
|
log.Debugf("Could not get id_token, raw token is %v", oauth2Token)
|
|
return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Missing token"}
|
|
}
|
|
|
|
verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID})
|
|
|
|
// Parse and verify ID Token payload.
|
|
idToken, err := verifier.Verify(context.Background(), rawIDToken)
|
|
if err != nil {
|
|
log.Errorf("Error verifying token for provider %s: %v", provider.Name, err)
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
return provider, oauth2Token, idToken, nil
|
|
}
|