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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- 'adobe-v*'
- 'airtable-v*'
- 'softwareone-v*'
- 'github-v*'
pull_request:
branches: [ main ]

Expand Down Expand Up @@ -45,6 +46,7 @@ jobs:
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
dotnet pack src/NextIteration.SpectreConsole.Auth.Providers.GitHub --configuration Release --no-build --output ${{ github.workspace }}/artifacts

- name: Upload package artifacts
uses: actions/upload-artifact@v6
Expand All @@ -70,7 +72,7 @@ jobs:
publish:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/adobe-v') || startsWith(github.ref, 'refs/tags/airtable-v') || startsWith(github.ref, 'refs/tags/softwareone-v')
if: startsWith(github.ref, 'refs/tags/adobe-v') || startsWith(github.ref, 'refs/tags/airtable-v') || startsWith(github.ref, 'refs/tags/softwareone-v') || startsWith(github.ref, 'refs/tags/github-v')

permissions:
# Required for NuGet trusted publishing: lets the job request a
Expand Down Expand Up @@ -103,6 +105,7 @@ jobs:
adobe-v*) echo "pkg=NextIteration.SpectreConsole.Auth.Providers.Adobe" >> "$GITHUB_OUTPUT" ;;
airtable-v*) echo "pkg=NextIteration.SpectreConsole.Auth.Providers.Airtable" >> "$GITHUB_OUTPUT" ;;
softwareone-v*) echo "pkg=NextIteration.SpectreConsole.Auth.Providers.SoftwareOne" >> "$GITHUB_OUTPUT" ;;
github-v*) echo "pkg=NextIteration.SpectreConsole.Auth.Providers.GitHub" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::Unrecognised tag prefix: $ref"; exit 1 ;;
esac

Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,31 @@ and each package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## GitHub — [0.1.0] — 2026-06-20

_Initial release of `NextIteration.SpectreConsole.Auth.Providers.GitHub`._

### Added
- **GitHub provider with OAuth device flow.** Ships `GitHubCredential`,
`GitHubToken`, `GitHubAuthenticationService`, the `accounts add` collector,
and the `accounts list` summary provider. The collector runs the OAuth device
flow — the same flow `gh auth login` uses by default — prompting for the GitHub
host, the OAuth App client id, and the requested scopes, then polling the token
endpoint (honouring the server `interval`, `slow_down` back-off, and
`authorization_pending`) until the user authorises in the browser. Once a token
is obtained it is validated and enriched via `GET /user`.
- **Configurable host.** Defaults to `github.com`; entering a GitHub Enterprise
Server host derives the matching web (`https://{host}/`) and REST API
(`https://{host}/api/v3/`) base URLs.
- **Token refresh.** For OAuth Apps that issue expiring user tokens, an expired
access token is refreshed via `grant_type=refresh_token` before use (a 30-second
clock-skew buffer guards the expiry check). Classic non-expiring tokens are
passed straight through. Note: a refreshed token is not yet persisted back to
the keystore — see the package README.
- Multi-targets `net8.0` and `net10.0`, in line with the other providers.

---

## [0.3.0 / 0.3.0 / 0.4.0] — 2026-06-20

_Adobe → 0.3.0, Airtable → 0.3.0, SoftwareOne → 0.4.0. Coordinated minor release: multi-targeting adds a new `net8.0` target framework. No public API or behaviour changes._
Expand Down
2 changes: 2 additions & 0 deletions NextIteration.SpectreConsole.Auth.Providers.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
<Project Path="src/NextIteration.SpectreConsole.Auth.Providers.Adobe/NextIteration.SpectreConsole.Auth.Providers.Adobe.csproj" />
<Project Path="src/NextIteration.SpectreConsole.Auth.Providers.Airtable/NextIteration.SpectreConsole.Auth.Providers.Airtable.csproj" />
<Project Path="src/NextIteration.SpectreConsole.Auth.Providers.SoftwareOne/NextIteration.SpectreConsole.Auth.Providers.SoftwareOne.csproj" />
<Project Path="src/NextIteration.SpectreConsole.Auth.Providers.GitHub/NextIteration.SpectreConsole.Auth.Providers.GitHub.csproj" />
<Project Path="tests/NextIteration.SpectreConsole.Auth.Providers.Adobe.Tests/NextIteration.SpectreConsole.Auth.Providers.Adobe.Tests.csproj" />
<Project Path="tests/NextIteration.SpectreConsole.Auth.Providers.Airtable.Tests/NextIteration.SpectreConsole.Auth.Providers.Airtable.Tests.csproj" />
<Project Path="tests/NextIteration.SpectreConsole.Auth.Providers.SoftwareOne.Tests/NextIteration.SpectreConsole.Auth.Providers.SoftwareOne.Tests.csproj" />
<Project Path="tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests.csproj" />
</Solution>
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Each package here ships the concrete types for one third-party service: its `ICr
| `NextIteration.SpectreConsole.Auth.Providers.SoftwareOne` | SoftwareOne Marketplace API | Ready |
| `NextIteration.SpectreConsole.Auth.Providers.Adobe` | Adobe VIP Marketplace API (OAuth2 via Adobe IMS) | Ready |
| `NextIteration.SpectreConsole.Auth.Providers.Airtable` | Airtable API (Personal Access Token) | Ready |
| `NextIteration.SpectreConsole.Auth.Providers.GitHub` | GitHub API (OAuth device flow) | Ready |

Each project has its own README with install instructions, the prompts the collector runs, the fields it stores, and the authentication model (refresh vs. pass-through) — see [`src/`](src).

Expand All @@ -36,6 +37,7 @@ A provider package plugs into those extensibility points for one specific servic
using NextIteration.SpectreConsole.Auth;
using NextIteration.SpectreConsole.Auth.Providers.Adobe;
using NextIteration.SpectreConsole.Auth.Providers.Airtable;
using NextIteration.SpectreConsole.Auth.Providers.GitHub;
using NextIteration.SpectreConsole.Auth.Providers.SoftwareOne;

services.AddCredentialStore(opts =>
Expand All @@ -45,14 +47,15 @@ services.AddCredentialStore(opts =>
".my-cli", "credentials");
});

// Adobe's auth service hits Adobe IMS, so it needs IHttpClientFactory.
// The other two are pass-through and don't require it — but registering
// it once is harmless.
// Adobe's auth service hits Adobe IMS, and GitHub runs the OAuth device
// flow + token refresh, so both need IHttpClientFactory. The others are
// pass-through and don't require it — but registering it once is harmless.
services.AddHttpClient();

// One line per provider you want to support:
services.AddAdobeAuthProvider();
services.AddAirtableAuthProvider();
services.AddGitHubAuthProvider();
services.AddSoftwareOneAuthProvider();

// And register the accounts branch against your Spectre.Console configurator:
Expand Down Expand Up @@ -96,7 +99,7 @@ The canonical recipe is one of the existing provider projects under [`src/`](src

## Releasing

Each provider releases independently via a per-package git tag (`adobe-v*`, `airtable-v*`, `softwareone-v*`). CI picks up the tag, builds, tests, and pushes just that package to nuget.org. See [RELEASING.md](RELEASING.md) for the full flow.
Each provider releases independently via a per-package git tag (`adobe-v*`, `airtable-v*`, `softwareone-v*`, `github-v*`). CI picks up the tag, builds, tests, and pushes just that package to nuget.org. See [RELEASING.md](RELEASING.md) for the full flow.

---

Expand Down
1 change: 1 addition & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The three provider packages in this repository version **independently**. Each r
| Adobe | `adobe-v` | `adobe-v0.1.1` | `NextIteration.SpectreConsole.Auth.Providers.Adobe` |
| Airtable | `airtable-v` | `airtable-v0.2.0` | `NextIteration.SpectreConsole.Auth.Providers.Airtable` |
| SoftwareOne | `softwareone-v` | `softwareone-v0.1.5` | `NextIteration.SpectreConsole.Auth.Providers.SoftwareOne` |
| GitHub | `github-v` | `github-v0.1.0` | `NextIteration.SpectreConsole.Auth.Providers.GitHub` |

The `<version>` part must match the `<Version>` property in the corresponding `.csproj`. CI does not check this; mismatching them will push whatever version the csproj says.

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
using NextIteration.SpectreConsole.Auth.Persistence;
using NextIteration.SpectreConsole.Auth.Services;
using System.Net.Http.Headers;
using System.Text.Json;

namespace NextIteration.SpectreConsole.Auth.Providers.GitHub
{
/// <summary>
/// Projects the selected <see cref="GitHubCredential"/> into a
/// <see cref="GitHubToken"/>. For a classic (non-expiring) OAuth App this is
/// a straight pass-through of the stored token. For an OAuth App that issues
/// expiring tokens, an expired access token is refreshed via
/// <c>POST {WebBaseUrl}login/oauth/access_token</c> (<c>grant_type=refresh_token</c>)
/// before the token is returned.
/// </summary>
/// <remarks>
/// The refreshed access token is <b>not</b> written back to the credential
/// store in this version — each authenticate call that needs a refresh
/// performs one. Consumers that authenticate frequently against an expiring
/// app should cache the returned <see cref="GitHubToken"/> for its lifetime.
/// Consumers must register <c>IHttpClientFactory</c> (<c>services.AddHttpClient()</c>).
/// </remarks>
public sealed class GitHubAuthenticationService : IAuthenticationService<GitHubCredential, GitHubToken>
{
/// <summary>
/// Named HttpClient identity used for the refresh call. Shares the
/// collector's name so a single <c>AddHttpClient</c> configuration
/// covers both.
/// </summary>
public const string HttpClientName = GitHubCredentialCollector.HttpClientName;

private const string RefreshGrantType = "refresh_token";

private static readonly JsonSerializerOptions JsonOptions
= new() { PropertyNameCaseInsensitive = true };

private readonly ICredentialManager _credentialManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly Func<DateTimeOffset> _now;

/// <summary>DI constructor.</summary>
public GitHubAuthenticationService(
ICredentialManager credentialManager,
IHttpClientFactory httpClientFactory)
: this(credentialManager, httpClientFactory, static () => DateTimeOffset.UtcNow)
{
}

/// <summary>Test seam: lets the refresh path compute expiry against an injected clock.</summary>
internal GitHubAuthenticationService(
ICredentialManager credentialManager,
IHttpClientFactory httpClientFactory,
Func<DateTimeOffset> now)
{
ArgumentNullException.ThrowIfNull(credentialManager);
ArgumentNullException.ThrowIfNull(httpClientFactory);
ArgumentNullException.ThrowIfNull(now);

_credentialManager = credentialManager;
_httpClientFactory = httpClientFactory;
_now = now;
}

/// <inheritdoc />
public async Task<GitHubToken> AuthenticateAsync()
{
var credentialJson = await _credentialManager
.GetSelectedCredentialAsync(GitHubCredential.ProviderName)
.ConfigureAwait(false);

if (string.IsNullOrEmpty(credentialJson))
{
throw new InvalidOperationException($"No {GitHubCredential.ProviderName} credential selected.");
}

var credential = JsonSerializer.Deserialize<GitHubCredential>(credentialJson, GitHubCredential.JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize {GitHubCredential.ProviderName} credential.");

return await AuthenticateAsync(credential).ConfigureAwait(false);
}

/// <inheritdoc />
public async Task<GitHubToken> AuthenticateAsync(GitHubCredential credential)
{
ArgumentNullException.ThrowIfNull(credential);
ValidateCredential(credential);

var isExpired = credential.AccessTokenExpiresAt is { } expiry
&& _now() >= expiry - GitHubToken.ExpiryClockSkew;

// Refresh only when the token is expired AND we have a refresh token
// to do it with; otherwise pass the stored token straight through.
if (isExpired && !string.IsNullOrEmpty(credential.RefreshToken))
{
return await RefreshAsync(credential).ConfigureAwait(false);
}

return new GitHubToken
{
AccessToken = credential.AccessToken,
BaseUrl = credential.ApiBaseUrl,
ExpiresAt = credential.AccessTokenExpiresAt,
};
}

/// <inheritdoc />
public Task<bool> ValidateTokenAsync(GitHubToken token)
{
ArgumentNullException.ThrowIfNull(token);
return Task.FromResult(!token.IsExpired);
}

private async Task<GitHubToken> RefreshAsync(GitHubCredential credential)
{
var client = _httpClientFactory.CreateClient(HttpClientName);

using var request = new HttpRequestMessage(
HttpMethod.Post, new Uri(credential.WebBaseUrl, "login/oauth/access_token"))
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = credential.ClientId,
["refresh_token"] = credential.RefreshToken!,
["grant_type"] = RefreshGrantType,
}),
};
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.UserAgent.ParseAdd(GitHubCredentialCollector.UserAgent);

using var response = await client.SendAsync(request).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);

if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"GitHub token refresh failed: {(int)response.StatusCode} {response.StatusCode}. Body: {GitHubCredentialCollector.SanitiseErrorBody(body, credential.RefreshToken!)}");
}

var dto = JsonSerializer.Deserialize<GitHubAccessTokenDto>(body, JsonOptions)
?? throw new InvalidOperationException(
"GitHub token refresh returned a success status with a body that did not deserialize.");

if (!string.IsNullOrEmpty(dto.Error) || string.IsNullOrEmpty(dto.AccessToken))
{
throw new InvalidOperationException(
$"GitHub token refresh was rejected: {dto.Error ?? "no access token returned"}. The refresh token may be expired — run `accounts add` again.");
}

DateTimeOffset? expiresAt = dto.ExpiresIn is { } seconds
? _now() + TimeSpan.FromSeconds(seconds)
: null;

return new GitHubToken
{
AccessToken = dto.AccessToken!,
BaseUrl = credential.ApiBaseUrl,
ExpiresAt = expiresAt,
};
}

/// <summary>
/// Guards against stored credentials whose required fields are
/// present-but-empty (e.g. a hand-edited keystore) and against a
/// downgraded API URL. Belt-and-braces: surfaces a clear message rather
/// than shipping an empty bearer token over a cleartext channel.
/// </summary>
private static void ValidateCredential(GitHubCredential credential)
{
RequireNonWhitespace(credential.AccessToken, nameof(GitHubCredential.AccessToken));
RequireNonWhitespace(credential.Environment, nameof(GitHubCredential.Environment));
RequireSecureUrl(credential.ApiBaseUrl, nameof(GitHubCredential.ApiBaseUrl));
RequireSecureUrl(credential.WebBaseUrl, nameof(GitHubCredential.WebBaseUrl));

static void RequireNonWhitespace(string value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException(
$"{fieldName} is required and must not be whitespace.",
fieldName);
}
}

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);
}
}
}
}
Loading