feat(auth): sso fallback mapping (#3068)

Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/3068
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Marc <marc88@free.fr>
Co-committed-by: Marc <marc88@free.fr>
This commit is contained in:
Marc
2025-03-02 15:21:09 +00:00
committed by konrad
parent b489703d6f
commit f4a0c0ef31
6 changed files with 323 additions and 126 deletions

View File

@@ -48,16 +48,18 @@ type Callback struct {
// 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"`
ClientSecret string `json:"-"`
openIDProvider *oidc.Provider
Oauth2Config *oauth2.Config `json:"-"`
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"`
ClientSecret string `json:"-"`
openIDProvider *oidc.Provider
Oauth2Config *oauth2.Config `json:"-"`
}
type claims struct {
Email string `json:"email"`
@@ -110,112 +112,29 @@ func (p *Provider) Issuer() (issuerURL string, err error) {
// @Failure 500 {object} models.Message "Internal error"
// @Router /auth/openid/{provider}/callback [post]
func HandleCallback(c echo.Context) error {
cb := &Callback{}
if err := c.Bind(cb); err != nil {
return c.JSON(http.StatusBadRequest, models.Message{Message: "Bad data"})
}
// Check if the provider exists
providerKey := c.Param("provider")
provider, err := GetProvider(providerKey)
provider, oauthToken, idToken, err := getProviderAndOidcTokens(c)
if err != nil {
return handler.HandleHTTPError(err)
}
if provider == nil {
return c.JSON(http.StatusBadRequest, models.Message{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)
return handler.HandleHTTPError(err)
}
log.Error(err)
var detailedErr *models.ErrOpenIDBadRequestWithDetails
if errors.As(err, &detailedErr) {
return c.JSON(http.StatusBadRequest, map[string]interface{}{
"message": "Could not authenticate against third party.",
"details": details,
"message": detailedErr.Message,
"details": detailedErr.Details,
})
}
return handler.HandleHTTPError(err)
}
// Extract the ID Token from OAuth2 token.
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
return c.JSON(http.StatusBadRequest, models.Message{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)
cl, err := getClaims(provider, oauthToken, idToken)
if err != nil {
log.Errorf("Error verifying token for provider %s: %v", provider.Name, err)
return handler.HandleHTTPError(err)
}
// Extract custom claims
cl := &claims{}
err = idToken.Claims(cl)
if err != nil {
log.Errorf("Error getting token claims for provider %s: %v", provider.Name, err)
return handler.HandleHTTPError(err)
}
if cl.Email == "" || cl.Name == "" || cl.PreferredUsername == "" {
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 handler.HandleHTTPError(err)
}
cl2 := &claims{}
err = info.Claims(cl2)
if err != nil {
log.Errorf("Error parsing userinfo claims for provider %s: %v", provider.Name, err)
return handler.HandleHTTPError(err)
}
if cl.Email == "" {
cl.Email = cl2.Email
}
if cl.Name == "" {
cl.Name = cl2.Name
}
if cl.PreferredUsername == "" {
cl.PreferredUsername = cl2.PreferredUsername
}
if cl.PreferredUsername == "" && cl2.Nickname != "" {
cl.PreferredUsername = cl2.Nickname
}
if cl.Email == "" {
log.Errorf("Claim does not contain an email address for provider %s", provider.Name)
return handler.HandleHTTPError(&user.ErrNoOpenIDEmailProvided{})
}
}
s := db.NewSession()
defer s.Close()
// Check if we have seen this user before
u, err := getOrCreateUser(s, cl, idToken.Issuer, idToken.Subject)
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)
@@ -403,40 +322,71 @@ func GetOrCreateTeamsByOIDC(s *xorm.Session, teamData []*models.OIDCTeam, u *use
return te, err
}
func getOrCreateUser(s *xorm.Session, cl *claims, issuer, subject string) (u *user.User, err error) {
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
// Check if the user exists for that issuer and subject
u, err = user.GetUserWithEmail(s, &user.User{
Issuer: issuer,
Subject: subject,
Issuer: idToken.Issuer,
Subject: idToken.Subject,
})
if err != nil && !user.IsErrUserDoesNotExist(err) {
return nil, err
}
alreadyCreatedFromIssuer = err == nil // found if no error, not found if we reach it here despite an error
// If no user exists, create one with the preferred username if it is not already taken
if user.IsErrUserDoesNotExist(err) {
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) {
return nil, err
}
fallbackMatchFound = err == nil // found if no error, not found if we reach it here despite an error
}
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: issuer,
Subject: subject,
Issuer: idToken.Issuer,
Subject: idToken.Subject,
}
return auth.CreateUserWithRandomUsername(s, uu)
}
} else if alreadyCreatedFromIssuer {
// If it exists, check if the email address changed and change it if not
if cl.Email != u.Email || cl.Name != u.Name {
// 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, err = user.UpdateUser(s, u, false)
if err != nil {
return nil, err
@@ -445,3 +395,110 @@ func getOrCreateUser(s *xorm.Session, cl *claims, issuer, subject string) (u *us
return
}
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 cl.Email == "" || cl.Name == "" || cl.PreferredUsername == "" {
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
}
if cl.Email == "" {
cl.Email = cl2.Email
}
if cl.Name == "" {
cl.Name = cl2.Name
}
if cl.PreferredUsername == "" {
cl.PreferredUsername = cl2.PreferredUsername
}
if cl.PreferredUsername == "" && cl2.Nickname != "" {
cl.PreferredUsername = cl2.Nickname
}
if cl.Email == "" {
log.Errorf("Claim does not contain an email address for provider %s", provider.Name)
return nil, &user.ErrNoOpenIDEmailProvided{}
}
}
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)
return nil, nil, nil, err
}
log.Error(err)
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 {
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
}