When JWT group sync is enabled with a restrictive JWTAllowGroups list, the local owner of an embedded-IdP (Dex) deployment can get locked out. The allow-groups check runs account-wide but local password users do not receive external IdP group claims, so they can't satisfy the allowed list. This skips JWT group evaluation for local Dex users so the restriction and JWT group sync continue to apply to external-IdP users as intended.
180 lines
5.7 KiB
Go
180 lines
5.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"hash/crc32"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
"github.com/netbirdio/netbird/shared/auth"
|
|
|
|
"github.com/netbirdio/netbird/base62"
|
|
"github.com/netbirdio/netbird/idp/dex"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
|
)
|
|
|
|
var _ Manager = (*manager)(nil)
|
|
|
|
type Manager interface {
|
|
ValidateAndParseToken(ctx context.Context, value string) (auth.UserAuth, *jwt.Token, error)
|
|
EnsureUserAccessByJWTGroups(ctx context.Context, userAuth auth.UserAuth, token *jwt.Token) (auth.UserAuth, error)
|
|
MarkPATUsed(ctx context.Context, tokenID string) error
|
|
GetPATInfo(ctx context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error)
|
|
}
|
|
|
|
type manager struct {
|
|
store store.Store
|
|
|
|
validator *nbjwt.Validator
|
|
extractor *nbjwt.ClaimsExtractor
|
|
}
|
|
|
|
func NewManager(store store.Store, issuer, audience, keysLocation, userIdClaim string, allAudiences []string, idpRefreshKeys bool, keyFetcher nbjwt.KeyFetcher) Manager {
|
|
var jwtValidator *nbjwt.Validator
|
|
if keyFetcher != nil {
|
|
jwtValidator = nbjwt.NewValidatorWithKeyFetcher(issuer, allAudiences, keyFetcher)
|
|
} else {
|
|
// @note if invalid/missing parameters are sent the validator will instantiate
|
|
// but it will fail when validating and parsing the token
|
|
jwtValidator = nbjwt.NewValidator(
|
|
issuer,
|
|
allAudiences,
|
|
keysLocation,
|
|
idpRefreshKeys,
|
|
)
|
|
}
|
|
|
|
claimsExtractor := nbjwt.NewClaimsExtractor(
|
|
nbjwt.WithAudience(audience),
|
|
nbjwt.WithUserIDClaim(userIdClaim),
|
|
)
|
|
|
|
return &manager{
|
|
store: store,
|
|
validator: jwtValidator,
|
|
extractor: claimsExtractor,
|
|
}
|
|
}
|
|
|
|
func (m *manager) ValidateAndParseToken(ctx context.Context, value string) (auth.UserAuth, *jwt.Token, error) {
|
|
token, err := m.validator.ValidateAndParse(ctx, value)
|
|
if err != nil {
|
|
return auth.UserAuth{}, nil, err
|
|
}
|
|
|
|
userAuth, err := m.extractor.ToUserAuth(token)
|
|
if err != nil {
|
|
return auth.UserAuth{}, nil, err
|
|
}
|
|
return userAuth, token, err
|
|
}
|
|
|
|
func (m *manager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth auth.UserAuth, token *jwt.Token) (auth.UserAuth, error) {
|
|
// Child accounts and PAT-authenticated requests do not use JWT group access checks.
|
|
// Embedded-Dex local users also skip them because local password authentication
|
|
// does not provide external IdP group claims.
|
|
if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) {
|
|
return userAuth, nil
|
|
}
|
|
|
|
settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, userAuth.AccountId)
|
|
if err != nil {
|
|
return userAuth, err
|
|
}
|
|
|
|
// Ensures JWT group synchronization to the management is enabled before,
|
|
// filtering access based on the allowed groups.
|
|
if settings != nil && settings.JWTGroupsEnabled {
|
|
userAuth.Groups = m.extractor.ToGroups(token, settings.JWTGroupsClaimName)
|
|
if allowedGroups := settings.JWTAllowGroups; len(allowedGroups) > 0 {
|
|
if !userHasAllowedGroup(allowedGroups, userAuth.Groups) {
|
|
return userAuth, fmt.Errorf("user does not belong to any of the allowed JWT groups")
|
|
}
|
|
}
|
|
}
|
|
|
|
return userAuth, nil
|
|
}
|
|
|
|
// MarkPATUsed marks a personal access token as used
|
|
func (am *manager) MarkPATUsed(ctx context.Context, tokenID string) error {
|
|
return am.store.MarkPATUsed(ctx, tokenID)
|
|
}
|
|
|
|
// GetPATInfo retrieves user, personal access token, domain, and category details from a personal access token.
|
|
func (am *manager) GetPATInfo(ctx context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error) {
|
|
user, pat, err = am.extractPATFromToken(ctx, token)
|
|
if err != nil {
|
|
return nil, nil, "", "", err
|
|
}
|
|
|
|
domain, category, err = am.store.GetAccountDomainAndCategory(ctx, store.LockingStrengthNone, user.AccountID)
|
|
if err != nil {
|
|
return nil, nil, "", "", err
|
|
}
|
|
|
|
return user, pat, domain, category, nil
|
|
}
|
|
|
|
// extractPATFromToken validates the token structure and retrieves associated User and PAT.
|
|
func (am *manager) extractPATFromToken(ctx context.Context, token string) (*types.User, *types.PersonalAccessToken, error) {
|
|
if len(token) != types.PATLength {
|
|
return nil, nil, fmt.Errorf("PAT has incorrect length")
|
|
}
|
|
|
|
prefix := token[:len(types.PATPrefix)]
|
|
if prefix != types.PATPrefix {
|
|
return nil, nil, fmt.Errorf("PAT has wrong prefix")
|
|
}
|
|
secret := token[len(types.PATPrefix) : len(types.PATPrefix)+types.PATSecretLength]
|
|
encodedChecksum := token[len(types.PATPrefix)+types.PATSecretLength : len(types.PATPrefix)+types.PATSecretLength+types.PATChecksumLength]
|
|
|
|
verificationChecksum, err := base62.Decode(encodedChecksum)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("PAT checksum decoding failed: %w", err)
|
|
}
|
|
|
|
secretChecksum := crc32.ChecksumIEEE([]byte(secret))
|
|
if secretChecksum != verificationChecksum {
|
|
return nil, nil, fmt.Errorf("PAT checksum does not match")
|
|
}
|
|
|
|
hashedToken := sha256.Sum256([]byte(token))
|
|
encodedHashedToken := base64.StdEncoding.EncodeToString(hashedToken[:])
|
|
|
|
var user *types.User
|
|
var pat *types.PersonalAccessToken
|
|
|
|
err = am.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
|
pat, err = transaction.GetPATByHashedToken(ctx, store.LockingStrengthNone, encodedHashedToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
user, err = transaction.GetUserByPATID(ctx, store.LockingStrengthNone, pat.ID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return user, pat, nil
|
|
}
|
|
|
|
// userHasAllowedGroup checks if a user belongs to any of the allowed groups.
|
|
func userHasAllowedGroup(allowedGroups []string, userGroups []string) bool {
|
|
for _, userGroup := range userGroups {
|
|
for _, allowedGroup := range allowedGroups {
|
|
if userGroup == allowedGroup {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|