diff --git a/core/Repository/Credentials/NuGetRepositoryCredentials.cs b/core/Repository/Credentials/NuGetRepositoryCredentials.cs index fa39990c7..5c9589e06 100644 --- a/core/Repository/Credentials/NuGetRepositoryCredentials.cs +++ b/core/Repository/Credentials/NuGetRepositoryCredentials.cs @@ -50,10 +50,20 @@ public void ValidateCredentials(IList credentials) throw new Exception($"Missing mandatory \"key\" value for {RepositoryType} repository \"{credential.Repository}\""); } - if (credential is not BasicCredential) + if (credential is not BasicCredential basicCred) { throw new InvalidAuthTypeException(credential); } + + if (string.IsNullOrEmpty(basicCred.Username)) + { + throw new Exception($"Missing mandatory \"username\" value for {RepositoryType} repository \"{credential.Repository}\" (key=\"{credential.Key}\"). This can happen when deriving credentials from a Portal token whose JWT payload is missing the 'sub' claim."); + } + + if (basicCred.Password == null) + { + throw new Exception($"Missing mandatory \"password\" value for {RepositoryType} repository \"{credential.Repository}\" (key=\"{credential.Key}\")"); + } } } @@ -63,6 +73,9 @@ public async Task SyncCredentials(IList credentials) IFileInfo nugetConfigFile = null; try { + // Validate early to surface a clear error instead of ArgumentNullException from XAttribute when Username/Password is null + ValidateCredentials(credentials); + nugetConfigFile = GetConfigFile(); var config = await LoadConfig(nugetConfigFile); diff --git a/core/Repository/Credentials/PortalRepositoryCredentials.cs b/core/Repository/Credentials/PortalRepositoryCredentials.cs index ad8eb483d..535b5055c 100644 --- a/core/Repository/Credentials/PortalRepositoryCredentials.cs +++ b/core/Repository/Credentials/PortalRepositoryCredentials.cs @@ -124,7 +124,20 @@ public IEnumerable GetDerivedCredentials(IList origina var token = ((BearerCredential)cred).Token; - var username = ParseJwt(token).Subject; + string username; + try + { + username = ParseJwt(token).Subject; + } + catch (Exception ex) + { + throw new Exception($"Failed to derive credentials from Portal token for repository '{cred.Repository}': token is not a valid JWT - {ex.Message}", ex); + } + + if (string.IsNullOrWhiteSpace(username)) + { + throw new Exception($"Failed to derive credentials from Portal token for repository '{cred.Repository}': token payload is missing required 'sub' claim (username). The token may be invalid, not a Portal-issued JWT, or has an unexpected format. Unable to create derived credentials for NuGet/NPM/Docker."); + } // NuGet yield return new BasicCredential diff --git a/core/Services/RepositoryAuthStore.cs b/core/Services/RepositoryAuthStore.cs index fe8a88aa2..5002d12cd 100644 --- a/core/Services/RepositoryAuthStore.cs +++ b/core/Services/RepositoryAuthStore.cs @@ -375,6 +375,9 @@ public async Task Save(IList credentials, bool sync = foreach (var (repoType, repoCredentials) in authFile.Repositories) { + // Validate derived credentials as well before syncing to surface clear errors + // (e.g. missing 'sub' claim from Portal JWT) instead of downstream ArgumentNullException in NuGet sync + GetRepositoryType(repoType).ValidateCredentials(repoCredentials.Credentials); await GetRepositoryType(repoType).SyncCredentials(repoCredentials.Credentials); } } diff --git a/tests/Specs/RepositoryCredentials.cs b/tests/Specs/RepositoryCredentials.cs index 77e8a7332..b4aae2444 100644 --- a/tests/Specs/RepositoryCredentials.cs +++ b/tests/Specs/RepositoryCredentials.cs @@ -721,6 +721,65 @@ public void PortalRepositoryCredentials_GetDerivedCredentialsConsideringReposito ]); } + [Fact] + public void PortalRepositoryCredentials_GetDerivedCredentials_MissingSub_ShouldThrow() + { + // Arrange - token with valid 3-part structure but payload missing 'sub' claim + var portal = new PortalRepositoryCredentials(new MockFileSystem()); + var payloadNoSub = Convert.ToBase64String(Encoding.UTF8.GetBytes( + """ + { + "exp": 9999999999, + "iat": 0 + } + """)); + var tokenNoSub = $"header.{payloadNoSub}.sig"; + + ExecutionContext.Initialize(new MockFileSystem()); + + // Act + var act = () => portal.GetDerivedCredentials([ + new BearerCredential + { + Token = tokenNoSub, + RepositoryType = RepositoryCredentialsType.Portal, + Repository = CmfAuthConstants.PortalRepository, + } + ]).ToList(); + + // Assert - should surface clear error about missing 'sub' instead of downstream NuGet ArgumentNullException + act.Should().Throw() + .WithMessage("*sub*") + .WithMessage("*Failed to derive credentials from Portal token*"); + } + + [Fact] + public void PortalRepositoryCredentials_GetDerivedCredentials_InvalidJwtFormat_ShouldThrow() + { + // Arrange - opaque PAT, not a JWT (no dots) + var portal = new PortalRepositoryCredentials(new MockFileSystem()); + var badToken = "not-a-jwt"; + + ExecutionContext.Initialize(new MockFileSystem()); + + // Act + var act = () => portal.GetDerivedCredentials([ + new BearerCredential + { + Token = badToken, + RepositoryType = RepositoryCredentialsType.Portal, + Repository = CmfAuthConstants.PortalRepository, + } + ]).ToList(); + + // Assert - should surface valid-JWT hint, preserving inner format error + var ex = act.Should().Throw() + .WithMessage("*not a valid JWT*") + .WithMessage("*Failed to derive credentials from Portal token*"); + ex.Which.InnerException.Should().NotBeNull(); + ex.Which.InnerException.Message.Should().Contain("Invalid format JWT token"); + } + [Fact] public async Task NPMRepositoryCredentials_SyncCredentials_NoFileExists() { @@ -928,6 +987,84 @@ await nuget.SyncCredentials([ ); } + [Fact] + public void NuGetRepositoryCredentials_ValidateCredentials_MissingUsername_ShouldThrow() + { + // Arrange + var nuget = new NuGetRepositoryCredentials(new MockFileSystem()); + var creds = new List + { + new BasicCredential + { + RepositoryType = RepositoryCredentialsType.NuGet, + Repository = CmfAuthConstants.NuGetRepository, + Key = CmfAuthConstants.NuGetKey, + Username = null, + Password = "pass" + } + }; + + // Act + var act = () => nuget.ValidateCredentials(creds); + + // Assert - clear message, not ArgumentNullException from XAttribute + act.Should().Throw() + .WithMessage("*username*") + .Which.Should().NotBeOfType(); + } + + [Fact] + public void NuGetRepositoryCredentials_ValidateCredentials_MissingPassword_ShouldThrow() + { + // Arrange + var nuget = new NuGetRepositoryCredentials(new MockFileSystem()); + var creds = new List + { + new BasicCredential + { + RepositoryType = RepositoryCredentialsType.NuGet, + Repository = CmfAuthConstants.NuGetRepository, + Key = CmfAuthConstants.NuGetKey, + Username = "user", + Password = null + } + }; + + // Act + var act = () => nuget.ValidateCredentials(creds); + + // Assert + act.Should().Throw().WithMessage("*password*"); + } + + [Fact] + public async Task NuGetRepositoryCredentials_SyncCredentials_MissingUsername_ShouldThrowClearError() + { + // Arrange - mirrors regression: derived Portal token without 'sub' would previously crash with ArgumentNullException inside XAttribute + var nuget = new NuGetRepositoryCredentials(new MockFileSystem()); + var creds = new List + { + new BasicCredential + { + RepositoryType = RepositoryCredentialsType.NuGet, + Repository = CmfAuthConstants.NuGetRepository, + Key = CmfAuthConstants.NuGetKey, + Username = null, + Password = "pass" + } + }; + + // Act + var act = async () => await nuget.SyncCredentials(creds); + + // Assert - Sync validates early and wraps as "Failed to sync ..." with inner containing username hint, not raw ArgumentNullException + var ex = (await act.Should().ThrowAsync()).Which; + ex.Message.Should().Contain("Failed to sync credentials into NuGet config file"); + ex.InnerException.Should().NotBeNull(); + ex.InnerException.Message.Should().Contain("username"); + ex.InnerException.Should().NotBeOfType(); + } + [Theory] [InlineData("https://api.nuget.org/v3/index.json", "nuget__api_nuget_org_v3_index_json")] [InlineData("https://custom.io/repository/nuget/index.json", "nuget__custom_io_repository_nuget_index_json")]