Files
vikunja/pkg/modules/auth/openid/openid_test.go
kolaente d291e3effe test(auth): add failing unit tests for OIDC TOTP enforcement
Covers the four states the OIDC TOTP gate must handle: user without
TOTP, TOTP enabled with missing passcode, invalid passcode, and
valid passcode. The helper function under test does not exist yet,
so the package currently fails to compile.

Refs GHSA-8jvc-mcx6-r4cg
2026-04-09 17:25:47 +00:00

521 lines
16 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 (
"testing"
"time"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetOrCreateUser(t *testing.T) {
t.Run("new user", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
Email: "test@example.com",
PreferredUsername: "someUserWhoDoesNotExistYet",
}
provider := &Provider{}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "12345"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "users", map[string]interface{}{
"id": u.ID,
"email": cl.Email,
"username": "someUserWhoDoesNotExistYet",
}, false)
})
t.Run("new user, no username provided", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
Email: "test@example.com",
PreferredUsername: "",
}
provider := &Provider{}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "12345"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
assert.NotEmpty(t, u.Username)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "users", map[string]interface{}{
"id": u.ID,
"email": cl.Email,
}, false)
})
t.Run("new user, no email address", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
Email: "",
}
provider := &Provider{}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "12345"}
_, err := getOrCreateUser(s, cl, provider, idToken)
require.Error(t, err)
})
t.Run("existing user, different email address", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
Email: "other-email-address@some.service.com",
}
provider := &Provider{}
idToken := &oidc.IDToken{Issuer: "https://some.service.com", Subject: "12345"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "users", map[string]interface{}{
"id": u.ID,
"email": cl.Email,
}, false)
})
t.Run("existing user, non existing team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
team := "new sso team"
oidcID := "47404"
cl := &claims{
Email: "other-email-address@some.service.com",
VikunjaGroups: []map[string]interface{}{
{"name": team, "oidcID": oidcID},
},
}
provider := &Provider{Name: "Vikunja Login"}
idToken := &oidc.IDToken{Issuer: "https://some.service.com", Subject: "12345"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
teamData := getTeamDataFromToken(cl.VikunjaGroups, nil)
require.NoError(t, err)
err = models.SyncExternalTeamsForUser(s, u, teamData, "https://some.issuer", provider.Name)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "users", map[string]interface{}{
"id": u.ID,
"email": cl.Email,
}, false)
db.AssertExists(t, "teams", map[string]interface{}{
"name": team + " (" + provider.Name + ")",
"external_id": oidcID,
"is_public": false,
}, false)
})
t.Run("Update IsPublic flag for existing team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
team := "testteam15"
oidcID := "15"
cl := &claims{
Email: "other-email-address@some.service.com",
VikunjaGroups: []map[string]interface{}{
{"name": team, "oidcID": oidcID, "isPublic": true},
},
}
provider := &Provider{Name: "Vikunja Login"}
idToken := &oidc.IDToken{Issuer: "https://some.service.com", Subject: "12345"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
teamData := getTeamDataFromToken(cl.VikunjaGroups, nil)
err = models.SyncExternalTeamsForUser(s, u, teamData, "https://some.issuer", provider.Name)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "teams", map[string]interface{}{
"name": team + " (" + provider.Name + ")",
"external_id": oidcID,
"is_public": true,
}, false)
})
t.Run("existing user, assign to existing team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
team := "testteam14"
oidcID := "14"
cl := &claims{
Email: "other-email-address@some.service.com",
VikunjaGroups: []map[string]interface{}{
{"name": team, "oidcID": oidcID},
},
}
u := &user.User{ID: 10}
teamData := getTeamDataFromToken(cl.VikunjaGroups, nil)
err := models.SyncExternalTeamsForUser(s, u, teamData, "https://some.issuer", "Vikunja Login")
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "team_members", map[string]interface{}{
"user_id": u.ID,
}, false)
})
t.Run("existing user, remove from existing team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
Email: "other-email-address@some.service.com",
VikunjaGroups: []map[string]interface{}{},
}
u := &user.User{ID: 10}
teamData := getTeamDataFromToken(cl.VikunjaGroups, nil)
err := models.SyncExternalTeamsForUser(s, u, teamData, "https://some.issuer", "Vikunja Login")
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertMissing(t, "team_members", map[string]interface{}{
"team_id": 14,
"user_id": u.ID,
})
db.AssertMissing(t, "team_members", map[string]interface{}{
"team_id": 15,
"user_id": u.ID,
})
// This team is not external and should not be touched
db.AssertExists(t, "team_members", map[string]interface{}{
"team_id": 13,
"user_id": u.ID,
}, false)
})
t.Run("ProviderFallback: Match to existing local user on username", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{}
provider := &Provider{
UsernameFallback: true,
}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "user11"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
assert.Equal(t, idToken.Subject, u.Username, "subject match username")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
})
t.Run("ProviderFallback: Match to existing local user on email", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
Email: "user11@example.com",
}
provider := &Provider{
EmailFallback: true,
}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "user11"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
assert.Equal(t, cl.Email, u.Email, "email should match")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
})
t.Run("ProviderFallback: Match to existing local user on username and email", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
Email: "user11@example.com",
}
provider := &Provider{
UsernameFallback: true,
EmailFallback: true,
}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "user11"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
assert.Equal(t, cl.Email, u.Email, "email should match")
assert.Equal(t, idToken.Subject, u.Username, "subject match username")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
})
}
// TestMergeClaims tests the mergeClaims function with different configurations including forceUserInfo
func TestMergeClaims(t *testing.T) {
t.Run("ForceUserInfo enabled - should use userinfo values", func(t *testing.T) {
// Setup token claims
tokenClaims := &claims{
Email: "token-email@example.com",
Name: "Token Name",
PreferredUsername: "token_username",
}
// Setup userinfo claims
userinfoClaims := &claims{
Email: "userinfo-email@example.com",
Name: "UserInfo Name",
PreferredUsername: "userinfo_username",
}
// Test with ForceUserInfo enabled
err := mergeClaims(tokenClaims, userinfoClaims, true)
require.NoError(t, err)
// Verify userinfo data was used
assert.Equal(t, "userinfo-email@example.com", tokenClaims.Email)
assert.Equal(t, "UserInfo Name", tokenClaims.Name)
assert.Equal(t, "userinfo_username", tokenClaims.PreferredUsername)
})
t.Run("ForceUserInfo disabled - should use token values if present", func(t *testing.T) {
// Setup token claims with all values
tokenClaims := &claims{
Email: "token-email@example.com",
Name: "Token Name",
PreferredUsername: "token_username",
}
// Setup userinfo claims
userinfoClaims := &claims{
Email: "userinfo-email@example.com",
Name: "UserInfo Name",
PreferredUsername: "userinfo_username",
}
// Test with ForceUserInfo disabled
err := mergeClaims(tokenClaims, userinfoClaims, false)
require.NoError(t, err)
// Verify token data was preserved
assert.Equal(t, "token-email@example.com", tokenClaims.Email)
assert.Equal(t, "Token Name", tokenClaims.Name)
assert.Equal(t, "token_username", tokenClaims.PreferredUsername)
})
t.Run("Missing values - should use userinfo when token is missing values", func(t *testing.T) {
// Setup token claims with missing values
tokenClaims := &claims{
Email: "token-email@example.com",
// Missing Name and PreferredUsername
}
// Setup userinfo claims
userinfoClaims := &claims{
Email: "userinfo-email@example.com",
Name: "UserInfo Name",
PreferredUsername: "userinfo_username",
}
// Test with ForceUserInfo disabled, but missing values in token
err := mergeClaims(tokenClaims, userinfoClaims, false)
require.NoError(t, err)
// Verify token email was kept, but missing fields were filled from userinfo
assert.Equal(t, "token-email@example.com", tokenClaims.Email)
assert.Equal(t, "UserInfo Name", tokenClaims.Name)
assert.Equal(t, "userinfo_username", tokenClaims.PreferredUsername)
})
t.Run("Use nickname when preferred_username is missing", func(t *testing.T) {
// Setup token claims with missing preferred_username
tokenClaims := &claims{
Email: "token-email@example.com",
Name: "Token Name",
// Missing PreferredUsername
}
// Setup userinfo claims with nickname but no preferred_username
userinfoClaims := &claims{
Email: "userinfo-email@example.com",
Name: "UserInfo Name",
Nickname: "userinfo_nickname",
// Missing PreferredUsername to test fallback to nickname
}
// Test with ForceUserInfo disabled
err := mergeClaims(tokenClaims, userinfoClaims, false)
require.NoError(t, err)
// Verify nickname was used for preferred_username
assert.Equal(t, "userinfo_nickname", tokenClaims.PreferredUsername)
})
t.Run("Error when email is missing", func(t *testing.T) {
// Setup token claims with missing email
tokenClaims := &claims{
// Missing Email
Name: "Token Name",
PreferredUsername: "token_username",
}
// Setup userinfo claims also with missing email
userinfoClaims := &claims{
// Missing Email
Name: "UserInfo Name",
PreferredUsername: "userinfo_username",
}
// Test with ForceUserInfo disabled
err := mergeClaims(tokenClaims, userinfoClaims, false)
// Verify error is returned for missing email
require.Error(t, err)
var expectedErr *user.ErrNoOpenIDEmailProvided
assert.ErrorAs(t, err, &expectedErr)
})
}
func TestEnforceTOTPIfRequired(t *testing.T) {
// user 10 has TOTP enabled in pkg/db/fixtures/totp.yml with this secret.
const user10Secret = "JBSWY3DPEHPK3PXP"
t.Run("user without TOTP - no passcode required", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// user 1 has a totp row but with enabled=false.
u := &user.User{ID: 1}
err := enforceTOTPIfRequired(s, u, "")
require.NoError(t, err)
})
t.Run("TOTP enabled - missing passcode returns ErrInvalidTOTPPasscode", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
u := &user.User{ID: 10}
err := enforceTOTPIfRequired(s, u, "")
require.Error(t, err)
assert.True(t, user.IsErrInvalidTOTPPasscode(err))
})
t.Run("TOTP enabled - invalid passcode returns ErrInvalidTOTPPasscode", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
u := &user.User{ID: 10}
err := enforceTOTPIfRequired(s, u, "000000")
require.Error(t, err)
assert.True(t, user.IsErrInvalidTOTPPasscode(err))
})
t.Run("TOTP enabled - valid passcode succeeds", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
passcode, err := totp.GenerateCode(user10Secret, time.Now())
require.NoError(t, err)
u := &user.User{ID: 10}
err = enforceTOTPIfRequired(s, u, passcode)
require.NoError(t, err)
})
}
func TestSyncUserAvatarFromOpenID(t *testing.T) {
t.Run("empty picture URL resets openid provider to default", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Use the fixture user that has avatar_provider = "openid"
u, err := user.GetUserByID(s, 19)
require.NoError(t, err)
assert.Equal(t, "openid", u.AvatarProvider, "precondition: user should have openid avatar provider")
err = syncUserAvatarFromOpenID(s, u, "")
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
// Verify the avatar provider was reset to default in the database
db.AssertExists(t, "users", map[string]interface{}{
"id": 19,
"avatar_provider": "default",
}, false)
})
t.Run("empty picture URL does not reset non-openid provider", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Use a regular user (avatar_provider is empty/"default")
u, err := user.GetUserByID(s, 1)
require.NoError(t, err)
err = syncUserAvatarFromOpenID(s, u, "")
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
// Verify the avatar provider was NOT changed to "default" or anything else
s2 := db.NewSession()
defer s2.Close()
updatedUser, err := user.GetUserByID(s2, 1)
require.NoError(t, err)
assert.Empty(t, updatedUser.AvatarProvider, "avatar provider should remain empty for non-openid user")
})
}