Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion core/Repository/Credentials/NuGetRepositoryCredentials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,20 @@ public void ValidateCredentials(IList<ICredential> 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}\")");
}
}
}

Expand All @@ -63,6 +73,9 @@ public async Task SyncCredentials(IList<ICredential> 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);
Expand Down
15 changes: 14 additions & 1 deletion core/Repository/Credentials/PortalRepositoryCredentials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,20 @@ public IEnumerable<ICredential> GetDerivedCredentials(IList<ICredential> 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
Expand Down
3 changes: 3 additions & 0 deletions core/Services/RepositoryAuthStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,9 @@ public async Task<CmfAuthFile> Save(IList<ICredential> 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);
}
}
Expand Down
137 changes: 137 additions & 0 deletions tests/Specs/RepositoryCredentials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Exception>()
.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<Exception>()
.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()
{
Expand Down Expand Up @@ -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<ICredential>
{
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<Exception>()
.WithMessage("*username*")
.Which.Should().NotBeOfType<ArgumentNullException>();
}

[Fact]
public void NuGetRepositoryCredentials_ValidateCredentials_MissingPassword_ShouldThrow()
{
// Arrange
var nuget = new NuGetRepositoryCredentials(new MockFileSystem());
var creds = new List<ICredential>
{
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<Exception>().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<ICredential>
{
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<Exception>()).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<ArgumentNullException>();
}

[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")]
Expand Down
Loading