Skip to content
Merged
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: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,22 @@ jobs:
run: dotnet restore

- name: Build
# Override PackageOutputPath so the auto-generated .nupkg lands in
# ./artifacts instead of the Windows-path the csprojs hardcode.
run: dotnet build --configuration Release --no-restore -p:PackageOutputPath=${{ github.workspace }}/artifacts
run: dotnet build --configuration Release --no-restore

- name: Test
run: dotnet test --configuration Release --no-build --verbosity normal

- name: Pack
# Pack each provider explicitly. The csprojs no longer auto-pack on
# build, so this step is the single point at which nupkgs / snupkgs
# are produced for downstream consumption (publish job + uploaded
# artifact). --no-build is safe because the build step above ran in
# the same Release configuration.
run: |
dotnet pack src/NextIteration.SpectreConsole.Auth.Providers.Adobe --configuration Release --no-build --output ${{ github.workspace }}/artifacts
dotnet pack src/NextIteration.SpectreConsole.Auth.Providers.Airtable --configuration Release --no-build --output ${{ github.workspace }}/artifacts
dotnet pack src/NextIteration.SpectreConsole.Auth.Providers.SoftwareOne --configuration Release --no-build --output ${{ github.workspace }}/artifacts

- name: Upload package artifacts
uses: actions/upload-artifact@v6
with:
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@ and each package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [0.2.1 / 0.2.1 / 0.3.1] — 2026-05-03

_Adobe → 0.2.1, Airtable → 0.2.1, SoftwareOne → 0.3.1. Coordinated patch release driven by an external security review._

### Security
- **Reject plain `http` for credential-bearing endpoints.** The Adobe and SoftwareOne collectors and authentication services now require `https` for the IMS URL, the Adobe API base URL, and the SoftwareOne API base URL. `http` is accepted only when the host is a loopback address, so local mock servers and proxies still work during development. The same check runs in the authentication services on every call, so a hand-edited keystore that downgrades a stored credential to `http` is rejected before any request is sent. This closes the specific risk that the SoftwareOne collector ships the API token in the URL query (`eq(token,'…')`) — over plain `http` that token would otherwise traverse the network in cleartext and land in any intermediate access log.
- **Sanitise response bodies before they reach exception messages.** When the SoftwareOne lookup or the Adobe IMS exchange returns a non-success status, the error path used to inline the raw response body verbatim. Both providers now truncate the body to 512 characters before constructing the exception, and the SoftwareOne path additionally redacts the literal token value out of the body — defending against a misbehaving upstream proxy that echoes the request URL (which carries the token in the query string) into an error page that would otherwise reach exception aggregators and log files.
- Airtable carries no URL prompt and made no IMS-style call, so its 0.2.1 picks up the cross-cutting DI / packaging fixes only.

### Changed
- **Register the `IAuthenticationService<TCredential, TToken>` interface mapping** for all three providers. Previously only the concrete `XxxAuthenticationService` was registered with DI, so consumers depending on the abstraction got a runtime resolution failure. The interface registration forwards to the same singleton instance, so this is purely additive — existing consumers depending on the concrete type are unaffected.
- **Core library reference bumped from `[0.5.0,1.0.0)` to `[0.6.1,1.0.0)`.** Picks up the latest core release published to nuget.org. The cap stays on the next major so a breaking 1.0 of the core package doesn't auto-flow into provider consumers — bump the upper bound deliberately when validating against the next major.

### Fixed
- **Stop auto-packing on every build.** The csprojs previously set `GeneratePackageOnBuild=true` with a hardcoded Windows-only `PackageOutputPath` (`C:\nuget-local\`). On non-Windows hosts that path was interpreted as a project-relative directory called `C:\nuget-local\`; on every host the auto-pack ran during `dotnet test`, which is wasteful and meant ordinary local development was producing release nupkgs as a side effect. Both properties are removed; CI now invokes `dotnet pack` explicitly per provider.

### Migration notes
- Consumer apps need no source changes. The DI registration is purely additive; the `https`-only enforcement only rejects URLs you should not have been using to begin with.
- If you were relying on the auto-generated nupkgs landing in the project tree from a local build, switch to `dotnet pack <project> --output ./artifacts` instead.

---

## SoftwareOne — [0.3.0] — 2026-04-18

_Applies to `NextIteration.SpectreConsole.Auth.Providers.SoftwareOne` only. Adobe and Airtable remain at 0.2.0._
Expand Down Expand Up @@ -96,6 +118,7 @@ _SoftwareOne Marketplace API token — pass-through._
- Per-package NuGet metadata: MIT license expression, SourceLink, deterministic builds, embedded symbols, snupkg, capped version ranges for cross-package dependencies.
- GitHub Actions CI with per-package tag-triggered publishing (`adobe-v*` → publishes Adobe only, etc.).

[0.2.1 / 0.2.1 / 0.3.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth.Providers/releases
[0.2.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth.Providers/releases
[0.1.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth.Providers/releases
[0.1.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth.Providers/releases
43 changes: 43 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<Project>

<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!--
Hard-fail if a csproj declares <PackageReference Version="…" />
alongside CPM. Catches drift if someone adds a per-project pin
by accident — without this, MSBuild silently ignores the inline
version and uses the CPM one, which is the bug we're trying to
make impossible.
-->
<CentralPackageVersionOverrideEnabled>false</CentralPackageVersionOverrideEnabled>
</PropertyGroup>

<!-- Runtime dependencies (shipped in provider packages). -->
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.5" />
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
<!--
Capped range: accept any patch/minor in the 0.x line, but
prevent silent upgrade to 1.0.0 where the ICredentialCollector /
ICredentialSummaryProvider contracts could break. Bump the upper
bound deliberately after validating against the next major.
-->
<PackageVersion Include="NextIteration.SpectreConsole.Auth" Version="[0.6.1,1.0.0)" />
</ItemGroup>

<!-- Build / source-link tooling (PrivateAssets in csprojs). -->
<ItemGroup>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
</ItemGroup>

<!-- Test-only dependencies. -->
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ public sealed class AdobeAuthenticationService : IAuthenticationService<AdobeCre

private const string DefaultScopes = "openid,AdobeID,read_organizations";

// Hard cap on the response-body slice we surface in exceptions.
// Keeps useful "invalid_client"-style payloads visible while
// bounding the size that lands in logs / aggregators.
internal const int ErrorBodyMaxChars = 512;

private readonly ICredentialManager _credentialManager;
private readonly IHttpClientFactory _httpClientFactory;

Expand Down Expand Up @@ -94,9 +99,13 @@ public async Task<AdobeToken> AuthenticateAsync(AdobeCredential credential)
{
// Include the IMS response body in the error so callers can
// see e.g. {"error":"invalid_client","error_description":"..."}
// rather than just a bare status code.
// rather than just a bare status code — but truncate so an
// upstream proxy that echoes the full request can't bloat
// logs (and bound the unlikely case of credential material
// making it back into the error body).
var safeBody = TruncateErrorBody(responseBody);
throw new HttpRequestException(
$"Adobe IMS token request failed: {(int)response.StatusCode} {response.StatusCode}. Body: {responseBody}");
$"Adobe IMS token request failed: {(int)response.StatusCode} {response.StatusCode}. Body: {safeBody}");
}

var dto = JsonSerializer.Deserialize<AdobeTokenDto>(responseBody)
Expand Down Expand Up @@ -151,6 +160,45 @@ private static void ValidateCredential(AdobeCredential credential)
$"{nameof(AdobeCredential.Environment)} is required and must not be whitespace.",
nameof(credential));
}

// The collector enforces https-or-loopback on input, but a
// hand-edited keystore could downgrade either URL to plain
// http. Re-check before sending the client secret over the
// wire — refusing here closes the gap.
RequireSecureUrl(credential.ImsUrl, nameof(AdobeCredential.ImsUrl));
RequireSecureUrl(credential.BaseUrl, nameof(AdobeCredential.BaseUrl));

static void RequireSecureUrl(Uri url, string fieldName)
{
if (url.Scheme == Uri.UriSchemeHttps)
{
return;
}

if (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)
{
return;
}

throw new ArgumentException(
$"{fieldName} must use https (http is only accepted for loopback addresses).",
fieldName);
}
}

/// <summary>
/// Truncate the IMS error body to <see cref="ErrorBodyMaxChars"/>
/// before it lands in an exception message. Bounds the size of
/// anything that gets logged downstream.
/// </summary>
internal static string TruncateErrorBody(string body)
{
if (string.IsNullOrEmpty(body) || body.Length <= ErrorBodyMaxChars)
{
return body;
}

return string.Concat(body.AsSpan(0, ErrorBodyMaxChars), "… [truncated]");
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public sealed class AdobeCredentialCollector : ICredentialCollector
var imsUrlInput = await AnsiConsole.PromptAsync(
new TextPrompt<string>("Enter IMS URL:")
.DefaultValue(DefaultImsUrl)
.Validate(ValidateHttpUrl)).ConfigureAwait(false);
.Validate(ValidateSecureUrl)).ConfigureAwait(false);

var apiKey = await AnsiConsole.PromptAsync(
new TextPrompt<string>("Enter API Key (OAuth2 client_id):")
Expand All @@ -43,7 +43,7 @@ public sealed class AdobeCredentialCollector : ICredentialCollector
var baseUrlInput = await AnsiConsole.PromptAsync(
new TextPrompt<string>("Enter Base URL:")
.DefaultValue(DefaultBaseUrl)
.Validate(ValidateHttpUrl)).ConfigureAwait(false);
.Validate(ValidateSecureUrl)).ConfigureAwait(false);

var environment = await AnsiConsole.PromptAsync(
new SelectionPrompt<string>()
Expand All @@ -62,11 +62,32 @@ public sealed class AdobeCredentialCollector : ICredentialCollector
return (JsonSerializer.Serialize(credential, AdobeCredential.JsonOptions), credential.Environment);
}

private static ValidationResult ValidateHttpUrl(string value)
=> Uri.TryCreate(value, UriKind.Absolute, out var parsed)
&& (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps)
? ValidationResult.Success()
: ValidationResult.Error("Must be a valid absolute http(s) URL");
/// <summary>
/// Accept the URL only if it's an absolute https URI, or an http
/// loopback (so devs can point the collector at a local mock or
/// proxy without compromising the OAuth2 client secret over the
/// wire in real deployments).
/// </summary>
internal static ValidationResult ValidateSecureUrl(string value)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var parsed))
{
return ValidationResult.Error("Must be a valid absolute http(s) URL");
}

if (parsed.Scheme == Uri.UriSchemeHttps)
{
return ValidationResult.Success();
}

if (parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback)
{
return ValidationResult.Success();
}

return ValidationResult.Error(
"Must use https. http is only accepted for loopback addresses (the OAuth2 client secret is POSTed to this URL and must not traverse the network in cleartext).");
}

private static Func<string, ValidationResult> ValidateNonEmpty(string fieldName)
=> value => string.IsNullOrWhiteSpace(value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@

<PropertyGroup>
<PackageId>NextIteration.SpectreConsole.Auth.Providers.Adobe</PackageId>
<Version>0.2.0</Version>
<Version>0.2.1</Version>
<Description>Adobe VIP Marketplace credential provider for NextIteration.SpectreConsole.Auth. Ships AdobeCredential, AdobeToken, AdobeAuthenticationService (OAuth2 client-credentials against Adobe IMS), and the Spectre.Console collector that drives the accounts-add prompt.</Description>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageOutputPath>C:\nuget-local\</PackageOutputPath>
<IncludeBuildOutput>true</IncludeBuildOutput>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
Expand All @@ -34,17 +32,16 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
<PackageReference Include="Spectre.Console" Version="0.55.2" />
<!--
Capped range: accept any patch/minor in the 0.x line, but
prevent silent upgrade to 1.0.0 where the ICredentialCollector /
ICredentialSummaryProvider contracts could break. Bump the upper
bound deliberately after validating against the next major.
-->
<PackageReference Include="NextIteration.SpectreConsole.Auth" Version="[0.5.0,1.0.0)" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
<InternalsVisibleTo Include="NextIteration.SpectreConsole.Auth.Providers.Adobe.Tests" />
</ItemGroup>

<ItemGroup>
<!-- Versions live in /Directory.Packages.props (CPM). -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Spectre.Console" />
<PackageReference Include="NextIteration.SpectreConsole.Auth" />
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="icon.png" Pack="true" PackagePath="" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using NextIteration.SpectreConsole.Auth.Commands;
using NextIteration.SpectreConsole.Auth.Services;

namespace NextIteration.SpectreConsole.Auth.Providers.Adobe
{
Expand All @@ -11,11 +12,19 @@ public static class ServiceCollectionExtensions
/// <summary>
/// Registers <see cref="AdobeAuthenticationService"/> and the Adobe
/// <see cref="ICredentialCollector"/> so it appears in the
/// <c>accounts add</c> provider-selection prompt.
/// <c>accounts add</c> provider-selection prompt. The auth service
/// is also registered against the
/// <see cref="IAuthenticationService{TCredential,TToken}"/> abstraction
/// so consumers that depend on the interface (rather than the concrete
/// type) can resolve it.
/// </summary>
public static IServiceCollection AddAdobeAuthProvider(this IServiceCollection services)
{
services.AddSingleton<AdobeAuthenticationService>();
// Forward the interface registration to the concrete singleton so
// both resolution shapes return the same instance.
services.AddSingleton<IAuthenticationService<AdobeCredential, AdobeToken>>(
sp => sp.GetRequiredService<AdobeAuthenticationService>());
services.AddSingleton<ICredentialCollector, AdobeCredentialCollector>();
services.AddSingleton<ICredentialSummaryProvider, AdobeCredentialSummaryProvider>();
return services;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@

<PropertyGroup>
<PackageId>NextIteration.SpectreConsole.Auth.Providers.Airtable</PackageId>
<Version>0.2.0</Version>
<Version>0.2.1</Version>
<Description>Airtable credential provider for NextIteration.SpectreConsole.Auth. Ships AirtableCredential, AirtableToken, AirtableAuthenticationService (pass-through personal access token), and the Spectre.Console collector that drives the accounts-add prompt.</Description>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageOutputPath>C:\nuget-local\</PackageOutputPath>
<IncludeBuildOutput>true</IncludeBuildOutput>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
Expand All @@ -34,16 +32,11 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Spectre.Console" Version="0.55.2" />
<!--
Capped range: accept any patch/minor in the 0.x line, but
prevent silent upgrade to 1.0.0 where the ICredentialCollector /
ICredentialSummaryProvider contracts could break. Bump the upper
bound deliberately after validating against the next major.
-->
<PackageReference Include="NextIteration.SpectreConsole.Auth" Version="[0.5.0,1.0.0)" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
<!-- Versions live in /Directory.Packages.props (CPM). -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Spectre.Console" />
<PackageReference Include="NextIteration.SpectreConsole.Auth" />
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="icon.png" Pack="true" PackagePath="" />
</ItemGroup>
Expand Down
Loading