diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5de0374..e96bf3a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,6 +10,7 @@ on:
- 'adobe-v*'
- 'airtable-v*'
- 'softwareone-v*'
+ - 'github-v*'
pull_request:
branches: [ main ]
@@ -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
@@ -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
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00e85f6..5cb9a8b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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._
diff --git a/NextIteration.SpectreConsole.Auth.Providers.slnx b/NextIteration.SpectreConsole.Auth.Providers.slnx
index 96af2f6..90d2210 100644
--- a/NextIteration.SpectreConsole.Auth.Providers.slnx
+++ b/NextIteration.SpectreConsole.Auth.Providers.slnx
@@ -2,7 +2,9 @@
+
+
diff --git a/README.md b/README.md
index c20d4eb..150dca1 100644
--- a/README.md
+++ b/README.md
@@ -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).
@@ -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 =>
@@ -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:
@@ -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.
---
diff --git a/RELEASING.md b/RELEASING.md
index bf68fe2..8ff4dfd 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -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 `` part must match the `` property in the corresponding `.csproj`. CI does not check this; mismatching them will push whatever version the csproj says.
diff --git a/design/icons/NextIteration.SpectreConsole.Auth.Providers.GitHub.svg b/design/icons/NextIteration.SpectreConsole.Auth.Providers.GitHub.svg
new file mode 100644
index 0000000..c7ba59c
--- /dev/null
+++ b/design/icons/NextIteration.SpectreConsole.Auth.Providers.GitHub.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubAuthenticationService.cs b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubAuthenticationService.cs
new file mode 100644
index 0000000..bece74b
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubAuthenticationService.cs
@@ -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
+{
+ ///
+ /// Projects the selected into a
+ /// . 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
+ /// POST {WebBaseUrl}login/oauth/access_token (grant_type=refresh_token)
+ /// before the token is returned.
+ ///
+ ///
+ /// The refreshed access token is not 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 for its lifetime.
+ /// Consumers must register IHttpClientFactory (services.AddHttpClient()).
+ ///
+ public sealed class GitHubAuthenticationService : IAuthenticationService
+ {
+ ///
+ /// Named HttpClient identity used for the refresh call. Shares the
+ /// collector's name so a single AddHttpClient configuration
+ /// covers both.
+ ///
+ 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 _now;
+
+ /// DI constructor.
+ public GitHubAuthenticationService(
+ ICredentialManager credentialManager,
+ IHttpClientFactory httpClientFactory)
+ : this(credentialManager, httpClientFactory, static () => DateTimeOffset.UtcNow)
+ {
+ }
+
+ /// Test seam: lets the refresh path compute expiry against an injected clock.
+ internal GitHubAuthenticationService(
+ ICredentialManager credentialManager,
+ IHttpClientFactory httpClientFactory,
+ Func now)
+ {
+ ArgumentNullException.ThrowIfNull(credentialManager);
+ ArgumentNullException.ThrowIfNull(httpClientFactory);
+ ArgumentNullException.ThrowIfNull(now);
+
+ _credentialManager = credentialManager;
+ _httpClientFactory = httpClientFactory;
+ _now = now;
+ }
+
+ ///
+ public async Task 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(credentialJson, GitHubCredential.JsonOptions)
+ ?? throw new InvalidOperationException($"Failed to deserialize {GitHubCredential.ProviderName} credential.");
+
+ return await AuthenticateAsync(credential).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task 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,
+ };
+ }
+
+ ///
+ public Task ValidateTokenAsync(GitHubToken token)
+ {
+ ArgumentNullException.ThrowIfNull(token);
+ return Task.FromResult(!token.IsExpired);
+ }
+
+ private async Task 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
+ {
+ ["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(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,
+ };
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+ }
+}
diff --git a/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredential.cs b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredential.cs
new file mode 100644
index 0000000..9fc0473
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredential.cs
@@ -0,0 +1,109 @@
+using NextIteration.SpectreConsole.Auth.Credentials;
+using System.Text.Json;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub
+{
+ ///
+ /// GitHub credential captured via the OAuth device flow — the same
+ /// flow gh auth login uses by default. Carries the user access token
+ /// obtained after the user authorised the device in their browser, plus the
+ /// OAuth App and host URLs needed to refresh the
+ /// token later, and (for display) the authenticated user's login/name.
+ ///
+ ///
+ ///
+ /// The and
+ /// fields are only populated when the OAuth App has opted in to
+ /// expiring user tokens. For a classic (non-expiring) OAuth App they
+ /// are and the access token is used as-is forever (a
+ /// revoked token surfaces as a 401 on first use).
+ ///
+ ///
+ /// and are populated by
+ /// at add-time via a
+ /// GET {ApiBaseUrl}user call once the token is obtained — they
+ /// confirm "authenticated as X" and drive the accounts list display.
+ ///
+ ///
+ public sealed class GitHubCredential : ICredential
+ {
+ private const string GitHubProviderName = "GitHub";
+
+ ///
+ /// Options matching the on-disk keystore format for this credential:
+ /// camelCase property names, indented for human readability. Exposed so
+ /// consumers (and tests) that round-trip the credential stay consistent
+ /// with the collector's serialization.
+ ///
+ public static JsonSerializerOptions JsonOptions { get; } = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ };
+
+ ///
+ public static string ProviderName => GitHubProviderName;
+
+ /// OAuth App client id the device flow was run against.
+ public required string ClientId { get; init; }
+
+ /// The user access token issued by the device flow.
+ public required string AccessToken { get; init; }
+
+ ///
+ /// Refresh token, when the OAuth App issues expiring tokens; otherwise
+ /// .
+ ///
+ public string? RefreshToken { get; init; }
+
+ ///
+ /// Absolute expiry of , when the OAuth App
+ /// issues expiring tokens; otherwise (the token
+ /// does not expire on its own).
+ ///
+ public DateTimeOffset? AccessTokenExpiresAt { get; init; }
+
+ /// Space-delimited scopes the token was granted.
+ public required string Scopes { get; init; }
+
+ ///
+ /// Web host base URL used for the device-flow and refresh endpoints
+ /// (e.g. https://github.com/, or https://ghe.example.com/
+ /// for GitHub Enterprise Server).
+ ///
+ public required Uri WebBaseUrl { get; init; }
+
+ ///
+ /// REST API base URL the token targets (e.g. https://api.github.com/,
+ /// or https://ghe.example.com/api/v3/ for GitHub Enterprise Server).
+ ///
+ public required Uri ApiBaseUrl { get; init; }
+
+ /// Login (handle) of the authenticated user.
+ public required string Login { get; init; }
+
+ /// Display name of the authenticated user, when set on their profile.
+ public string? Name { get; init; }
+
+ ///
+ public required string Environment { get; init; }
+
+ ///
+ public static List SupportedEnvironments => GetSupportedEnvironments();
+
+ private static List GetSupportedEnvironments() => [.. Enum.GetNames()];
+
+ ///
+ /// Environments the GitHub provider distinguishes. Not prompted —
+ /// derived from the host the credential was created against.
+ ///
+ public enum Environments
+ {
+ /// Public github.com.
+ GitHubCom,
+
+ /// A GitHub Enterprise Server instance.
+ Enterprise
+ }
+ }
+}
diff --git a/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredentialCollector.cs b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredentialCollector.cs
new file mode 100644
index 0000000..0644379
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredentialCollector.cs
@@ -0,0 +1,371 @@
+using Spectre.Console;
+using NextIteration.SpectreConsole.Auth.Commands;
+using System.Net.Http.Headers;
+using System.Text.Json;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub
+{
+ ///
+ /// Interactive collector that runs the GitHub OAuth device flow — the
+ /// same flow gh auth login uses by default. Prompts for the GitHub
+ /// host, the OAuth App client id, and the requested scopes; requests a
+ /// device + user code; shows the user the code and verification URL; polls
+ /// until the user authorises (or the code expires); then enriches the
+ /// resulting credential with the authenticated user's identity via
+ /// GET {ApiBaseUrl}user.
+ ///
+ ///
+ ///
+ /// The OAuth App must have device flow enabled in its settings, and
+ /// the user supplies that app's (public) client id — nothing secret is
+ /// prompted or stored beyond the resulting token.
+ ///
+ ///
+ /// Consumers must register IHttpClientFactory in DI
+ /// (services.AddHttpClient()). Registered automatically by
+ /// .
+ ///
+ ///
+ public sealed class GitHubCredentialCollector : ICredentialCollector
+ {
+ ///
+ /// Named HttpClient identity used by the collector. Consumers wishing to
+ /// pre-configure the client (proxy, retry handler, user-agent) can call
+ /// services.AddHttpClient(GitHubCredentialCollector.HttpClientName, …).
+ ///
+ public const string HttpClientName = "GitHub Credential Validator";
+
+ internal const string DefaultHost = "github.com";
+ internal const string DefaultScopes = "repo read:org";
+ internal const string DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code";
+ internal const string UserAgent = "NextIteration.SpectreConsole.Auth.Providers.GitHub";
+
+ // Hard cap on the response-body slice surfaced in exceptions — big
+ // enough to keep useful error payloads, small enough to bound logs.
+ internal const int ErrorBodyMaxChars = 512;
+
+ private static readonly JsonSerializerOptions JsonOptions
+ = new() { PropertyNameCaseInsensitive = true };
+
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly Func _delay;
+ private readonly Func _now;
+
+ /// DI constructor.
+ public GitHubCredentialCollector(IHttpClientFactory httpClientFactory)
+ : this(httpClientFactory, static (ts, ct) => Task.Delay(ts, ct), static () => DateTimeOffset.UtcNow)
+ {
+ }
+
+ ///
+ /// Test seam: lets the polling loop run against an injected delay and
+ /// clock so the device-flow tests don't sleep on the real interval.
+ ///
+ internal GitHubCredentialCollector(
+ IHttpClientFactory httpClientFactory,
+ Func delay,
+ Func now)
+ {
+ ArgumentNullException.ThrowIfNull(httpClientFactory);
+ ArgumentNullException.ThrowIfNull(delay);
+ ArgumentNullException.ThrowIfNull(now);
+
+ _httpClientFactory = httpClientFactory;
+ _delay = delay;
+ _now = now;
+ }
+
+ ///
+ public string ProviderName => GitHubCredential.ProviderName;
+
+ ///
+ public async Task<(string credentialData, string environment)> CollectAsync()
+ {
+ var host = await AnsiConsole.PromptAsync(
+ new TextPrompt("Enter GitHub host:")
+ .DefaultValue(DefaultHost)
+ .Validate(ValidateHost)).ConfigureAwait(false);
+
+ var clientId = await AnsiConsole.PromptAsync(
+ new TextPrompt("Enter OAuth App client id:")
+ .Validate(value => string.IsNullOrWhiteSpace(value)
+ ? ValidationResult.Error("Client id cannot be empty")
+ : ValidationResult.Success())).ConfigureAwait(false);
+
+ var scopes = await AnsiConsole.PromptAsync(
+ new TextPrompt("Enter scopes (space-separated):")
+ .DefaultValue(DefaultScopes)
+ .AllowEmpty()).ConfigureAwait(false);
+
+ var webBaseUrl = DeriveWebBaseUrl(host);
+ var apiBaseUrl = DeriveApiBaseUrl(host);
+ var environment = DeriveEnvironment(host);
+
+ // 1. Ask GitHub for a device + user code.
+ var deviceCode = await RequestDeviceCodeAsync(webBaseUrl, clientId, scopes).ConfigureAwait(false);
+
+ // 2. Tell the user where to go and what to type.
+ AnsiConsole.Write(new Panel(
+ new Markup(
+ $"Open [link]{Markup.Escape(deviceCode.VerificationUri)}[/] and enter the code:\n\n" +
+ $"[bold yellow]{Markup.Escape(deviceCode.UserCode)}[/]"))
+ .Header("GitHub device authorization")
+ .BorderColor(Color.Grey));
+
+ // 3. Poll until the user authorises (or the code expires).
+ var tokenDto = await PollForTokenAsync(webBaseUrl, clientId, deviceCode, CancellationToken.None).ConfigureAwait(false);
+
+ // 4. Enrich with the authenticated user's identity.
+ var accessToken = tokenDto.AccessToken!;
+ var user = await LookupUserAsync(apiBaseUrl, accessToken).ConfigureAwait(false);
+
+ DateTimeOffset? expiresAt = tokenDto.ExpiresIn is { } seconds
+ ? _now() + TimeSpan.FromSeconds(seconds)
+ : null;
+
+ var credential = new GitHubCredential
+ {
+ ClientId = clientId,
+ AccessToken = accessToken,
+ RefreshToken = tokenDto.RefreshToken,
+ AccessTokenExpiresAt = expiresAt,
+ Scopes = string.IsNullOrWhiteSpace(tokenDto.Scope) ? scopes : tokenDto.Scope!,
+ WebBaseUrl = webBaseUrl,
+ ApiBaseUrl = apiBaseUrl,
+ Login = user.Login,
+ Name = user.Name,
+ Environment = environment,
+ };
+
+ AnsiConsole.MarkupLine($"[green]Authenticated as[/] [bold]{Markup.Escape(user.Login)}[/].");
+
+ return (JsonSerializer.Serialize(credential, GitHubCredential.JsonOptions), credential.Environment);
+ }
+
+ ///
+ /// Requests a device + user verification code from
+ /// POST {web}/login/device/code. Throws on any non-success status
+ /// or malformed body.
+ ///
+ internal async Task RequestDeviceCodeAsync(Uri webBaseUrl, string clientId, string scopes)
+ {
+ var client = _httpClientFactory.CreateClient(HttpClientName);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(webBaseUrl, "login/device/code"))
+ {
+ Content = new FormUrlEncodedContent(new Dictionary
+ {
+ ["client_id"] = clientId,
+ ["scope"] = scopes ?? string.Empty,
+ }),
+ };
+ ApplyJsonHeaders(request);
+
+ using var response = await client.SendAsync(request).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(
+ $"GitHub device-code request failed: {(int)response.StatusCode} {response.StatusCode}. Body: {TruncateErrorBody(body)}");
+ }
+
+ return JsonSerializer.Deserialize(body, JsonOptions)
+ ?? throw new InvalidOperationException(
+ "GitHub device-code request returned a success status with a body that did not deserialize.");
+ }
+
+ ///
+ /// Polls POST {web}/login/oauth/access_token until the user
+ /// authorises the device, honouring the server-supplied
+ /// interval, the slow_down back-off, and the
+ /// authorization_pending state. Throws on terminal errors
+ /// (access_denied, expired_token, unexpected) or when the
+ /// device code's lifetime elapses.
+ ///
+ internal async Task PollForTokenAsync(
+ Uri webBaseUrl, string clientId, GitHubDeviceCodeDto deviceCode, CancellationToken cancellationToken)
+ {
+ var interval = TimeSpan.FromSeconds(Math.Max(deviceCode.Interval, 1));
+ var deadline = _now() + TimeSpan.FromSeconds(deviceCode.ExpiresIn);
+
+ while (true)
+ {
+ await _delay(interval, cancellationToken).ConfigureAwait(false);
+
+ var dto = await PostTokenRequestAsync(webBaseUrl, new Dictionary
+ {
+ ["client_id"] = clientId,
+ ["device_code"] = deviceCode.DeviceCode,
+ ["grant_type"] = DeviceCodeGrantType,
+ }).ConfigureAwait(false);
+
+ if (!string.IsNullOrEmpty(dto.AccessToken))
+ {
+ return dto;
+ }
+
+ switch (dto.Error)
+ {
+ case "authorization_pending":
+ break;
+ case "slow_down":
+ interval += TimeSpan.FromSeconds(5);
+ break;
+ case "access_denied":
+ throw new InvalidOperationException(
+ "GitHub device authorization was denied by the user.");
+ case "expired_token":
+ throw new InvalidOperationException(
+ "The GitHub device code expired before authorization completed. Run `accounts add` again.");
+ case null or "":
+ throw new InvalidOperationException(
+ "GitHub token endpoint returned neither an access token nor an error.");
+ default:
+ throw new InvalidOperationException(
+ $"GitHub device authorization failed: {dto.Error}{FormatErrorDescription(dto.ErrorDescription)}");
+ }
+
+ if (_now() >= deadline)
+ {
+ throw new InvalidOperationException(
+ "Timed out waiting for GitHub device authorization. Run `accounts add` again.");
+ }
+ }
+ }
+
+ ///
+ /// Resolves the authenticated user via GET {api}user. Throws on
+ /// any non-success status or malformed body.
+ ///
+ internal async Task LookupUserAsync(Uri apiBaseUrl, string accessToken)
+ {
+ var client = _httpClientFactory.CreateClient(HttpClientName);
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(apiBaseUrl, "user"));
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
+ request.Headers.UserAgent.ParseAdd(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 user lookup failed: {(int)response.StatusCode} {response.StatusCode}. Body: {SanitiseErrorBody(body, accessToken)}");
+ }
+
+ return JsonSerializer.Deserialize(body, JsonOptions)
+ ?? throw new InvalidOperationException(
+ "GitHub user lookup returned a success status with a body that did not deserialize.");
+ }
+
+ private async Task PostTokenRequestAsync(Uri webBaseUrl, Dictionary form)
+ {
+ var client = _httpClientFactory.CreateClient(HttpClientName);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(webBaseUrl, "login/oauth/access_token"))
+ {
+ Content = new FormUrlEncodedContent(form),
+ };
+ ApplyJsonHeaders(request);
+
+ using var response = await client.SendAsync(request).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ // GitHub returns 200 even for the pending/slow_down states (the
+ // error lives in the JSON body), so a non-success status here is a
+ // genuine transport/credential failure rather than a poll state.
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(
+ $"GitHub token request failed: {(int)response.StatusCode} {response.StatusCode}. Body: {TruncateErrorBody(body)}");
+ }
+
+ return JsonSerializer.Deserialize(body, JsonOptions)
+ ?? throw new InvalidOperationException(
+ "GitHub token request returned a success status with a body that did not deserialize.");
+ }
+
+ private static void ApplyJsonHeaders(HttpRequestMessage request)
+ {
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ request.Headers.UserAgent.ParseAdd(UserAgent);
+ }
+
+ ///
+ /// Maps the prompted host to the web base URL used for the device-flow
+ /// and token endpoints.
+ ///
+ internal static Uri DeriveWebBaseUrl(string host)
+ => new($"https://{NormalizeHost(host)}/", UriKind.Absolute);
+
+ ///
+ /// Maps the prompted host to the REST API base URL. github.com uses the
+ /// dedicated api.github.com host; GitHub Enterprise Server uses
+ /// /api/v3/ under the instance host.
+ ///
+ internal static Uri DeriveApiBaseUrl(string host)
+ {
+ var normalized = NormalizeHost(host);
+ return string.Equals(normalized, DefaultHost, StringComparison.OrdinalIgnoreCase)
+ ? new Uri("https://api.github.com/", UriKind.Absolute)
+ : new Uri($"https://{normalized}/api/v3/", UriKind.Absolute);
+ }
+
+ /// Derives the environment label from the host.
+ internal static string DeriveEnvironment(string host)
+ => string.Equals(NormalizeHost(host), DefaultHost, StringComparison.OrdinalIgnoreCase)
+ ? GitHubCredential.Environments.GitHubCom.ToString()
+ : GitHubCredential.Environments.Enterprise.ToString();
+
+ private static string NormalizeHost(string host)
+ => host.Trim().TrimEnd('/');
+
+ internal static ValidationResult ValidateHost(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return ValidationResult.Error("Host cannot be empty");
+ }
+
+ // The host is turned into https://{host}/ — reject anything that
+ // isn't a clean host[:port] (e.g. a pasted scheme or path).
+ return Uri.TryCreate($"https://{NormalizeHost(value)}/", UriKind.Absolute, out _)
+ ? ValidationResult.Success()
+ : ValidationResult.Error("Must be a bare host such as github.com or ghe.example.com");
+ }
+
+ private static string FormatErrorDescription(string? description)
+ => string.IsNullOrWhiteSpace(description) ? string.Empty : $" — {description}";
+
+ internal static string TruncateErrorBody(string body)
+ {
+ if (string.IsNullOrEmpty(body) || body.Length <= ErrorBodyMaxChars)
+ {
+ return body;
+ }
+
+ return string.Concat(body.AsSpan(0, ErrorBodyMaxChars), "… [truncated]");
+ }
+
+ ///
+ /// Redacts the access token from an error body (a misbehaving proxy can
+ /// echo request material) and truncates to .
+ ///
+ internal static string SanitiseErrorBody(string body, string accessToken)
+ {
+ if (string.IsNullOrEmpty(body))
+ {
+ return body;
+ }
+
+ var redacted = string.IsNullOrEmpty(accessToken)
+ ? body
+ : body.Replace(accessToken, "", StringComparison.Ordinal);
+
+ return TruncateErrorBody(redacted);
+ }
+ }
+}
diff --git a/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredentialSummaryProvider.cs b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredentialSummaryProvider.cs
new file mode 100644
index 0000000..f2b2bbe
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredentialSummaryProvider.cs
@@ -0,0 +1,58 @@
+using NextIteration.SpectreConsole.Auth.Commands;
+using System.Text.Json;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub
+{
+ ///
+ /// Projects a into the label/value pairs
+ /// shown by accounts list: the authenticated identity, the granted
+ /// scopes, the API host, and the token as a masked fingerprint.
+ ///
+ public sealed class GitHubCredentialSummaryProvider : ICredentialSummaryProvider
+ {
+ ///
+ public string ProviderName => GitHubCredential.ProviderName;
+
+ ///
+ public IReadOnlyList> GetDisplayFields(string decryptedCredentialJson)
+ {
+ // Defensive: if deserialization fails (corrupt keystore, schema
+ // drift), surface a visible marker instead of throwing into the
+ // Spectre render loop and taking down the list command.
+ GitHubCredential? credential;
+ try
+ {
+ credential = JsonSerializer.Deserialize(decryptedCredentialJson, GitHubCredential.JsonOptions);
+ }
+ catch (JsonException)
+ {
+ return [new("Status", "")];
+ }
+
+ if (credential is null)
+ {
+ return [new("Status", "")];
+ }
+
+ var account = string.IsNullOrWhiteSpace(credential.Name)
+ ? credential.Login
+ : $"{credential.Login} ({credential.Name})";
+
+ return
+ [
+ new("Account", account),
+ new("Scopes", string.IsNullOrWhiteSpace(credential.Scopes) ? "(none)" : credential.Scopes),
+ new("Host", credential.ApiBaseUrl.ToString()),
+ new("Token", Mask(credential.AccessToken)),
+ ];
+ }
+
+ // Tokens are long in practice; short inputs get a fixed four-star mask
+ // so the display never leaks length information.
+ private static string Mask(string value)
+ {
+ if (string.IsNullOrEmpty(value)) return string.Empty;
+ return value.Length <= 10 ? "****" : value[..4] + "..." + value[^4..];
+ }
+ }
+}
diff --git a/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubDtos.cs b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubDtos.cs
new file mode 100644
index 0000000..c88055d
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubDtos.cs
@@ -0,0 +1,72 @@
+using System.Text.Json.Serialization;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub
+{
+ ///
+ /// Response of POST {web}/login/device/code: the device + user
+ /// verification codes and the polling parameters.
+ ///
+ internal sealed class GitHubDeviceCodeDto
+ {
+ [JsonPropertyName("device_code")]
+ public required string DeviceCode { get; init; }
+
+ [JsonPropertyName("user_code")]
+ public required string UserCode { get; init; }
+
+ [JsonPropertyName("verification_uri")]
+ public required string VerificationUri { get; init; }
+
+ [JsonPropertyName("expires_in")]
+ public required int ExpiresIn { get; init; }
+
+ [JsonPropertyName("interval")]
+ public required int Interval { get; init; }
+ }
+
+ ///
+ /// Response of POST {web}/login/oauth/access_token. GitHub returns a
+ /// 200 with an field for the pending/transient
+ /// states (authorization_pending, slow_down) as well as for
+ /// terminal failures, so both the success and error shapes are modelled here.
+ ///
+ internal sealed class GitHubAccessTokenDto
+ {
+ [JsonPropertyName("access_token")]
+ public string? AccessToken { get; init; }
+
+ [JsonPropertyName("token_type")]
+ public string? TokenType { get; init; }
+
+ [JsonPropertyName("scope")]
+ public string? Scope { get; init; }
+
+ [JsonPropertyName("expires_in")]
+ public int? ExpiresIn { get; init; }
+
+ [JsonPropertyName("refresh_token")]
+ public string? RefreshToken { get; init; }
+
+ [JsonPropertyName("error")]
+ public string? Error { get; init; }
+
+ [JsonPropertyName("error_description")]
+ public string? ErrorDescription { get; init; }
+ }
+
+ ///
+ /// Slice of GET {api}user used to enrich the credential with the
+ /// authenticated user's identity.
+ ///
+ internal sealed class GitHubUserDto
+ {
+ [JsonPropertyName("login")]
+ public required string Login { get; init; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ [JsonPropertyName("id")]
+ public long Id { get; init; }
+ }
+}
diff --git a/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubToken.cs b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubToken.cs
new file mode 100644
index 0000000..a17ee6f
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubToken.cs
@@ -0,0 +1,48 @@
+using NextIteration.SpectreConsole.Auth.Tokens;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub
+{
+ ///
+ /// GitHub user access token (bearer). Produced by
+ /// either as a pass-through of the
+ /// stored token or, for an OAuth App that issues expiring tokens, as a
+ /// freshly refreshed token.
+ ///
+ ///
+ /// The token is never serialized to disk — the credential is. When
+ /// is the token does not
+ /// expire on its own (classic OAuth App); a revoked token surfaces as a 401
+ /// on first use, which consumers should handle regardless.
+ ///
+ public sealed class GitHubToken : IToken
+ {
+ ///
+ /// Safety margin applied to so a token that is
+ /// about to expire surfaces as expired before the exact expiry
+ /// boundary, avoiding a check-then-401 race.
+ ///
+ public static readonly TimeSpan ExpiryClockSkew = TimeSpan.FromSeconds(30);
+
+ /// The user access token.
+ public required string AccessToken { get; init; }
+
+ /// REST API base URL the token was issued for.
+ public required Uri BaseUrl { get; init; }
+
+ ///
+ /// Absolute expiry of the token, or for a
+ /// non-expiring (classic OAuth App) token.
+ ///
+ public DateTimeOffset? ExpiresAt { get; init; }
+
+ /// Token scheme used in the Authorization header.
+ public const string TokenType = "Bearer";
+
+ ///
+ public bool IsExpired
+ => ExpiresAt is { } expiry && DateTimeOffset.UtcNow >= expiry - ExpiryClockSkew;
+
+ ///
+ public string GetAuthorizationHeader() => $"Bearer {AccessToken}";
+ }
+}
diff --git a/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/NextIteration.SpectreConsole.Auth.Providers.GitHub.csproj b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/NextIteration.SpectreConsole.Auth.Providers.GitHub.csproj
new file mode 100644
index 0000000..8a24c56
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/NextIteration.SpectreConsole.Auth.Providers.GitHub.csproj
@@ -0,0 +1,49 @@
+
+
+
+ net8.0;net10.0
+ enable
+ enable
+ en
+ true
+ latest
+
+
+
+ NextIteration.SpectreConsole.Auth.Providers.GitHub
+ 0.1.0
+ GitHub credential provider for NextIteration.SpectreConsole.Auth. Ships GitHubCredential, GitHubToken, GitHubAuthenticationService, and the Spectre.Console collector that drives the accounts-add prompt. The collector runs the OAuth device flow (the same flow gh auth login uses), then validates and enriches the credential with the authenticated user's identity.
+ true
+ MIT
+ README.md
+ https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth.Providers
+ https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth.Providers.git
+ git
+ spectre;cli;authentication;credentials;github;oauth;device-flow
+ icon.png
+ © Stuart Meeks
+ true
+ true
+ true
+ true
+ snupkg
+ portable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/README.md b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/README.md
new file mode 100644
index 0000000..b2c2843
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/README.md
@@ -0,0 +1,162 @@
+# NextIteration.SpectreConsole.Auth.Providers.GitHub
+
+[](https://www.nuget.org/packages/NextIteration.SpectreConsole.Auth.Providers.GitHub/)
+[](https://www.nuget.org/packages/NextIteration.SpectreConsole.Auth.Providers.GitHub/)
+[](https://opensource.org/licenses/MIT)
+[](https://dotnet.microsoft.com/)
+[](https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth.Providers/actions/workflows/ci.yml)
+
+GitHub credential provider for [**NextIteration.SpectreConsole.Auth**](https://www.nuget.org/packages/NextIteration.SpectreConsole.Auth).
+
+Drops a ready-to-use `GitHubCredential`, `GitHubToken`, authentication service, a Spectre.Console `accounts add` collector that runs the OAuth **device flow** (the same flow `gh auth login` uses by default), and an `accounts list` display formatter into a CLI that already uses the core auth package.
+
+---
+
+## Install
+
+```bash
+dotnet add package NextIteration.SpectreConsole.Auth.Providers.GitHub
+```
+
+Requires the core package (`NextIteration.SpectreConsole.Auth`) — NuGet pulls it in transitively.
+
+---
+
+## Prerequisites
+
+You need an **OAuth App** with **device flow enabled**:
+
+1. Create an OAuth App under *GitHub → Settings → Developer settings → OAuth Apps* (or your org's settings).
+2. Tick **Enable Device Flow** in the app's settings.
+3. Note its **Client ID** — it's public (not a secret) and is what the collector prompts for.
+
+No client secret is involved: the device flow authenticates the *user*, not the app's secret.
+
+---
+
+## Quick start
+
+```csharp
+using NextIteration.SpectreConsole.Auth;
+using NextIteration.SpectreConsole.Auth.Providers.GitHub;
+
+services.AddCredentialStore(opts =>
+{
+ opts.CredentialsDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".my-cli", "credentials");
+});
+
+services.AddHttpClient(); // IHttpClientFactory is required
+services.AddGitHubAuthProvider();
+
+// ... then hook up the accounts branch in your Spectre.Console configurator
+// (from the core package):
+//
+// config.AddAccountsBranch();
+```
+
+From that point on:
+
+```
+my-cli accounts add # prompts for GitHub (and any other registered providers)
+my-cli accounts list # shows the authenticated user, scopes, host, masked token
+my-cli accounts select
+my-cli accounts delete
+```
+
+Resolving a token in consumer code:
+
+```csharp
+public sealed class GitHubClient(GitHubAuthenticationService auth, IHttpClientFactory http)
+{
+ public async Task GetRepositoriesAsync()
+ {
+ var token = await auth.AuthenticateAsync();
+ using var client = http.CreateClient();
+ client.BaseAddress = token.BaseUrl;
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", token.AccessToken);
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("my-cli/1.0");
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
+ return await client.GetStringAsync("user/repos");
+ }
+}
+```
+
+---
+
+## The `accounts add` flow
+
+The collector runs the OAuth device flow end to end:
+
+1. **Prompts** for the GitHub host (default `github.com`), the OAuth App **client id**, and the requested **scopes** (default `repo read:org`).
+2. **Requests a device code** — `POST {host}/login/device/code`.
+3. **Shows you the user code and verification URL** (e.g. `https://github.com/login/device`). Open it in a browser and enter the code.
+4. **Polls** `POST {host}/login/oauth/access_token` honouring the server `interval`, the `slow_down` back-off, and `authorization_pending`, until you authorise (or the code expires).
+5. **Enriches** the credential — `GET {api}/user` confirms "authenticated as X" and records your login/name for the list display.
+
+---
+
+## What gets stored
+
+Each `accounts add` run serialises a `GitHubCredential` into the encrypted keystore:
+
+| Field | Source | Notes |
+|------------------------|-----------------------|----------------------------------------------------------------------------------|
+| `ClientId` | prompt | The OAuth App's public client id |
+| `AccessToken` | device flow | The user access token |
+| `RefreshToken` | device flow | Only present if the OAuth App issues **expiring** tokens; otherwise `null` |
+| `AccessTokenExpiresAt` | device flow | Only present for expiring tokens; otherwise `null` |
+| `Scopes` | token response | Space-delimited granted scopes |
+| `WebBaseUrl` | derived from host | `https://github.com/` or `https://{host}/` for GitHub Enterprise Server |
+| `ApiBaseUrl` | derived from host | `https://api.github.com/` or `https://{host}/api/v3/` for Enterprise Server |
+| `Login` / `Name` | `GET /user` | The authenticated user's handle and display name |
+| `Environment` | derived from host | `GitHubCom` / `Enterprise` |
+
+`accounts list` shows the account (`login (name)`), the granted scopes, the API host, and the token masked as `xxxx...xxxx`.
+
+---
+
+## Authentication model
+
+- **Classic OAuth App (non-expiring tokens).** The stored access token is used forever; `GitHubAuthenticationService.AuthenticateAsync()` is a straight pass-through. A revoked token surfaces as a `401` on first use.
+- **OAuth App with expiring user tokens.** The device flow also returns a refresh token and expiry. When the stored access token is expired (with a 30-second clock-skew buffer), the auth service refreshes it via `POST {host}/login/oauth/access_token` (`grant_type=refresh_token`) before returning a fresh `GitHubToken`.
+
+### Known limitation
+
+A refreshed access token is **not** written back to the keystore in this version — each authenticate call that needs a refresh performs one. Consumers that authenticate frequently against an expiring app should cache the returned `GitHubToken` for its lifetime. (Persisting the refreshed token would require an update API on the core credential store.)
+
+---
+
+## GitHub Enterprise Server
+
+Enter your instance host (e.g. `ghe.example.com`) at the host prompt. The provider derives the web base (`https://ghe.example.com/`) and the REST API base (`https://ghe.example.com/api/v3/`) automatically, and labels the environment `Enterprise`.
+
+---
+
+## Named HttpClient
+
+The collector and the refresh path resolve an `HttpClient` via `IHttpClientFactory.CreateClient("GitHub Credential Validator")`. Consumers who want to pre-configure it (proxies, retry handlers, custom user-agent) can:
+
+```csharp
+services.AddHttpClient(GitHubCredentialCollector.HttpClientName, c =>
+{
+ c.Timeout = TimeSpan.FromSeconds(30);
+ c.DefaultRequestHeaders.UserAgent.ParseAdd("my-cli/1.0");
+});
+```
+
+The provider does not mutate `BaseAddress` or `DefaultRequestHeaders` on the client — every endpoint is passed as an absolute URI per request, and a `User-Agent` is set per request (GitHub rejects API calls without one).
+
+---
+
+## Supported platforms
+
+Whatever the core package supports (currently Windows, macOS, Linux on .NET 8 and .NET 10).
+
+---
+
+## License
+
+[MIT](../../LICENSE) © Stuart Meeks
diff --git a/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/ServiceCollectionExtensions.cs b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..1450e18
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/ServiceCollectionExtensions.cs
@@ -0,0 +1,38 @@
+using Microsoft.Extensions.DependencyInjection;
+using NextIteration.SpectreConsole.Auth.Commands;
+using NextIteration.SpectreConsole.Auth.Services;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub
+{
+ ///
+ /// DI extensions for wiring the GitHub provider into a NextIteration.SpectreConsole.Auth consumer.
+ ///
+ public static class ServiceCollectionExtensions
+ {
+ ///
+ /// Registers and the GitHub
+ /// so it appears in the
+ /// accounts add provider-selection prompt. The auth service is
+ /// also registered against the
+ /// abstraction
+ /// so consumers that depend on the interface (rather than the concrete
+ /// type) can resolve it.
+ ///
+ ///
+ /// The collector and the auth service's refresh path both use
+ /// IHttpClientFactory, so consumers must also call
+ /// services.AddHttpClient().
+ ///
+ public static IServiceCollection AddGitHubAuthProvider(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ // Forward the interface registration to the concrete singleton so
+ // both resolution shapes return the same instance.
+ services.AddSingleton>(
+ sp => sp.GetRequiredService());
+ services.AddSingleton();
+ services.AddSingleton();
+ return services;
+ }
+ }
+}
diff --git a/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/icon.png b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/icon.png
new file mode 100644
index 0000000..809866f
Binary files /dev/null and b/src/NextIteration.SpectreConsole.Auth.Providers.GitHub/icon.png differ
diff --git a/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/FakeCredentialManager.cs b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/FakeCredentialManager.cs
new file mode 100644
index 0000000..e8e5173
--- /dev/null
+++ b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/FakeCredentialManager.cs
@@ -0,0 +1,35 @@
+using NextIteration.SpectreConsole.Auth.Persistence;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests;
+
+///
+/// Minimal in-memory double. Only
+/// is exercised by the tests in
+/// this project; everything else throws so accidental reliance fails
+/// loudly rather than silently returning defaults.
+///
+internal sealed class FakeCredentialManager : ICredentialManager
+{
+ public string? SelectedCredentialJson { get; set; }
+
+ public Task GetSelectedCredentialAsync(string providerName)
+ => Task.FromResult(SelectedCredentialJson);
+
+ public Task GetCredentialByIdAsync(string providerName, string accountId)
+ => throw new NotSupportedException();
+
+ public Task> ListCredentialsAsync(string providerName)
+ => throw new NotSupportedException();
+
+ public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData)
+ => throw new NotSupportedException();
+
+ public Task DeleteCredentialAsync(string accountId)
+ => throw new NotSupportedException();
+
+ public Task SelectCredentialAsync(string accountId)
+ => throw new NotSupportedException();
+
+ public Task> GetProviderNamesAsync()
+ => throw new NotSupportedException();
+}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubAuthenticationServiceTests.cs b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubAuthenticationServiceTests.cs
new file mode 100644
index 0000000..1af44f5
--- /dev/null
+++ b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubAuthenticationServiceTests.cs
@@ -0,0 +1,159 @@
+using System.Net;
+using System.Text.Json;
+using Xunit;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests;
+
+public sealed class GitHubAuthenticationServiceTests
+{
+ private static readonly DateTimeOffset Now = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ private static GitHubAuthenticationService Service(
+ IHttpClientFactory http,
+ FakeCredentialManager? credentials = null)
+ => new(credentials ?? new FakeCredentialManager(), http, () => Now);
+
+ private static GitHubCredential Credential(
+ DateTimeOffset? expiresAt = null,
+ string? refreshToken = null,
+ string accessToken = "gho_stored")
+ => new()
+ {
+ ClientId = "Iv1.id",
+ AccessToken = accessToken,
+ RefreshToken = refreshToken,
+ AccessTokenExpiresAt = expiresAt,
+ Scopes = "repo",
+ WebBaseUrl = new Uri("https://github.com/"),
+ ApiBaseUrl = new Uri("https://api.github.com/"),
+ Login = "octocat",
+ Environment = "GitHubCom",
+ };
+
+ [Fact]
+ public async Task AuthenticateAsync_PassesThrough_NonExpiringToken()
+ {
+ var svc = Service(StubHttpClientFactory.ReturningJson("{}"));
+
+ var token = await svc.AuthenticateAsync(Credential());
+
+ Assert.Equal("gho_stored", token.AccessToken);
+ Assert.Equal(new Uri("https://api.github.com/"), token.BaseUrl);
+ Assert.Null(token.ExpiresAt);
+ }
+
+ [Fact]
+ public async Task AuthenticateAsync_PassesThrough_WhenExpiredButNoRefreshToken()
+ {
+ var svc = Service(StubHttpClientFactory.ReturningJson("{}"));
+
+ var token = await svc.AuthenticateAsync(Credential(expiresAt: Now - TimeSpan.FromHours(1)));
+
+ // No refresh token => return the stored (stale) token rather than fail.
+ Assert.Equal("gho_stored", token.AccessToken);
+ }
+
+ [Fact]
+ public async Task AuthenticateAsync_PassesThrough_WhenTokenStillValid()
+ {
+ var stub = StubHttpClientFactory.ReturningJson("{}");
+ var svc = Service(stub);
+
+ var token = await svc.AuthenticateAsync(
+ Credential(expiresAt: Now + TimeSpan.FromHours(1), refreshToken: "ghr_x"));
+
+ Assert.Equal("gho_stored", token.AccessToken);
+ // Valid token => no refresh call made.
+ Assert.Empty(stub.Requests);
+ }
+
+ [Fact]
+ public async Task AuthenticateAsync_Refreshes_WhenExpiredWithRefreshToken()
+ {
+ var stub = StubHttpClientFactory.ReturningJson(
+ """{ "access_token": "gho_fresh", "token_type": "bearer", "expires_in": 28800, "refresh_token": "ghr_new" }""");
+ var svc = Service(stub);
+
+ var token = await svc.AuthenticateAsync(
+ Credential(expiresAt: Now - TimeSpan.FromMinutes(1), refreshToken: "ghr_old"));
+
+ Assert.Equal("gho_fresh", token.AccessToken);
+ Assert.Equal(Now + TimeSpan.FromSeconds(28800), token.ExpiresAt);
+
+ Assert.Single(stub.Requests);
+ Assert.Equal(new Uri("https://github.com/login/oauth/access_token"), stub.LastRequest!.RequestUri);
+ Assert.Contains("grant_type=refresh_token", stub.RequestBodies[0], StringComparison.Ordinal);
+ Assert.Contains("refresh_token=ghr_old", stub.RequestBodies[0], StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AuthenticateAsync_Throws_WhenRefreshRejected()
+ {
+ var stub = StubHttpClientFactory.ReturningJson("""{ "error": "bad_refresh_token" }""");
+ var svc = Service(stub);
+
+ var ex = await Assert.ThrowsAsync(
+ () => svc.AuthenticateAsync(Credential(expiresAt: Now - TimeSpan.FromMinutes(1), refreshToken: "ghr_old")));
+ Assert.Contains("refresh", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task AuthenticateAsync_NoSelection_Throws()
+ {
+ var svc = Service(StubHttpClientFactory.ReturningJson("{}"), new FakeCredentialManager { SelectedCredentialJson = null });
+
+ var ex = await Assert.ThrowsAsync(() => svc.AuthenticateAsync());
+ Assert.Contains("No GitHub credential selected", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AuthenticateAsync_ReadsSelectedCredential_FromManager()
+ {
+ var json = JsonSerializer.Serialize(Credential(), GitHubCredential.JsonOptions);
+ var manager = new FakeCredentialManager { SelectedCredentialJson = json };
+ var svc = Service(StubHttpClientFactory.ReturningJson("{}"), manager);
+
+ var token = await svc.AuthenticateAsync();
+
+ Assert.Equal("gho_stored", token.AccessToken);
+ }
+
+ [Fact]
+ public async Task AuthenticateAsync_Throws_OnEmptyAccessToken()
+ {
+ var svc = Service(StubHttpClientFactory.ReturningJson("{}"));
+
+ await Assert.ThrowsAsync(
+ () => svc.AuthenticateAsync(Credential(accessToken: " ")));
+ }
+
+ [Fact]
+ public async Task AuthenticateAsync_Throws_OnInsecureApiUrl()
+ {
+ var credential = new GitHubCredential
+ {
+ ClientId = "id",
+ AccessToken = "gho",
+ Scopes = "repo",
+ WebBaseUrl = new Uri("https://github.com/"),
+ ApiBaseUrl = new Uri("http://api.example.com/"),
+ Login = "octocat",
+ Environment = "Enterprise",
+ };
+ var svc = Service(StubHttpClientFactory.ReturningJson("{}"));
+
+ await Assert.ThrowsAsync(() => svc.AuthenticateAsync(credential));
+ }
+
+ [Fact]
+ public async Task ValidateTokenAsync_ReflectsExpiry()
+ {
+ var svc = Service(StubHttpClientFactory.ReturningJson("{}"));
+
+ var valid = new GitHubToken { AccessToken = "x", BaseUrl = new Uri("https://api.github.com/"), ExpiresAt = null };
+ var expired = new GitHubToken { AccessToken = "x", BaseUrl = new Uri("https://api.github.com/"), ExpiresAt = DateTimeOffset.UtcNow - TimeSpan.FromHours(1) };
+
+ Assert.True(await svc.ValidateTokenAsync(valid));
+ Assert.False(await svc.ValidateTokenAsync(expired));
+ }
+}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialCollectorTests.cs b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialCollectorTests.cs
new file mode 100644
index 0000000..6aa2935
--- /dev/null
+++ b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialCollectorTests.cs
@@ -0,0 +1,235 @@
+using System.Net;
+using Xunit;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests;
+
+public sealed class GitHubCredentialCollectorTests
+{
+ // The interactive CollectAsync flow is driven by Spectre's console prompts
+ // and is not reasonably unit-testable without a full Spectre test-console
+ // harness. The device-flow building blocks (device-code request, polling,
+ // user enrichment) are factored into internal methods and covered here;
+ // the prompt orchestration is left to manual smoke via `accounts add`.
+
+ private static GitHubCredentialCollector Collector(
+ IHttpClientFactory factory,
+ out List delays,
+ DateTimeOffset? start = null)
+ {
+ var now = start ?? DateTimeOffset.UnixEpoch;
+ var captured = new List();
+ delays = captured;
+ return new GitHubCredentialCollector(
+ factory,
+ (ts, _) => { captured.Add(ts); now += ts; return Task.CompletedTask; },
+ () => now);
+ }
+
+ [Fact]
+ public void ProviderName_MatchesCredential()
+ {
+ var collector = new GitHubCredentialCollector(
+ StubHttpClientFactory.ReturningJson("{}"));
+
+ Assert.Equal(GitHubCredential.ProviderName, collector.ProviderName);
+ Assert.Equal("GitHub", collector.ProviderName);
+ }
+
+ [Theory]
+ [InlineData("github.com", "https://github.com/", "https://api.github.com/", "GitHubCom")]
+ [InlineData("GitHub.com", "https://GitHub.com/", "https://api.github.com/", "GitHubCom")]
+ [InlineData("ghe.example.com", "https://ghe.example.com/", "https://ghe.example.com/api/v3/", "Enterprise")]
+ [InlineData("ghe.example.com/", "https://ghe.example.com/", "https://ghe.example.com/api/v3/", "Enterprise")]
+ public void HostDerivation_ProducesWebApiAndEnvironment(string host, string web, string api, string environment)
+ {
+ Assert.Equal(new Uri(web), GitHubCredentialCollector.DeriveWebBaseUrl(host));
+ Assert.Equal(new Uri(api), GitHubCredentialCollector.DeriveApiBaseUrl(host));
+ Assert.Equal(environment, GitHubCredentialCollector.DeriveEnvironment(host));
+ }
+
+ [Theory]
+ [InlineData("github.com", true)]
+ [InlineData("ghe.example.com", true)]
+ [InlineData("ghe.example.com:8443", true)]
+ [InlineData("", false)]
+ [InlineData(" ", false)]
+ public void ValidateHost_AcceptsBareHostsOnly(string host, bool expectedOk)
+ {
+ var result = GitHubCredentialCollector.ValidateHost(host);
+ Assert.Equal(expectedOk, result.Successful);
+ }
+
+ [Fact]
+ public async Task RequestDeviceCodeAsync_ParsesResponse_AndPostsClientIdAndScope()
+ {
+ var stub = StubHttpClientFactory.ReturningJson(
+ """
+ { "device_code": "dc123", "user_code": "WXYZ-1234",
+ "verification_uri": "https://github.com/login/device",
+ "expires_in": 900, "interval": 5 }
+ """);
+ var collector = Collector(stub, out _);
+
+ var device = await collector.RequestDeviceCodeAsync(
+ new Uri("https://github.com/"), "Iv1.clientid", "repo read:org");
+
+ Assert.Equal("dc123", device.DeviceCode);
+ Assert.Equal("WXYZ-1234", device.UserCode);
+ Assert.Equal(900, device.ExpiresIn);
+ Assert.Equal(5, device.Interval);
+
+ Assert.Single(stub.Requests);
+ Assert.Equal(
+ new Uri("https://github.com/login/device/code"),
+ stub.LastRequest!.RequestUri);
+ Assert.Contains("client_id=Iv1.clientid", stub.RequestBodies[0], StringComparison.Ordinal);
+ Assert.Contains("scope=repo", stub.RequestBodies[0], StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task RequestDeviceCodeAsync_Throws_OnNonSuccess()
+ {
+ var stub = StubHttpClientFactory.ReturningJson("nope", HttpStatusCode.BadRequest);
+ var collector = Collector(stub, out _);
+
+ var ex = await Assert.ThrowsAsync(
+ () => collector.RequestDeviceCodeAsync(new Uri("https://github.com/"), "id", "repo"));
+ Assert.Contains("device-code request failed", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task PollForTokenAsync_ContinuesOnPending_ThenReturnsToken()
+ {
+ var stub = StubHttpClientFactory.Sequence(
+ () => StubHttpClientFactory.JsonResponse("""{ "error": "authorization_pending" }"""),
+ () => StubHttpClientFactory.JsonResponse("""{ "error": "authorization_pending" }"""),
+ () => StubHttpClientFactory.JsonResponse("""{ "access_token": "gho_ok", "token_type": "bearer", "scope": "repo" }"""));
+ var collector = Collector(stub, out var delays);
+
+ var device = new GitHubDeviceCodeDto
+ {
+ DeviceCode = "dc",
+ UserCode = "code",
+ VerificationUri = "https://github.com/login/device",
+ ExpiresIn = 900,
+ Interval = 5,
+ };
+
+ var token = await collector.PollForTokenAsync(new Uri("https://github.com/"), "id", device, CancellationToken.None);
+
+ Assert.Equal("gho_ok", token.AccessToken);
+ Assert.Equal(3, stub.Requests.Count);
+ // Three polls => three interval delays, all the base 5s (no slow_down).
+ Assert.Equal([TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)], delays);
+ }
+
+ [Fact]
+ public async Task PollForTokenAsync_AppliesSlowDownBackoff()
+ {
+ var stub = StubHttpClientFactory.Sequence(
+ () => StubHttpClientFactory.JsonResponse("""{ "error": "slow_down" }"""),
+ () => StubHttpClientFactory.JsonResponse("""{ "access_token": "gho_ok", "token_type": "bearer" }"""));
+ var collector = Collector(stub, out var delays);
+
+ var device = new GitHubDeviceCodeDto
+ {
+ DeviceCode = "dc",
+ UserCode = "code",
+ VerificationUri = "https://github.com/login/device",
+ ExpiresIn = 900,
+ Interval = 5,
+ };
+
+ var token = await collector.PollForTokenAsync(new Uri("https://github.com/"), "id", device, CancellationToken.None);
+
+ Assert.Equal("gho_ok", token.AccessToken);
+ // First poll at 5s; after slow_down the interval grows by 5s to 10s.
+ Assert.Equal([TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)], delays);
+ }
+
+ [Fact]
+ public async Task PollForTokenAsync_Throws_OnAccessDenied()
+ {
+ var stub = StubHttpClientFactory.ReturningJson("""{ "error": "access_denied" }""");
+ var collector = Collector(stub, out _);
+
+ var device = NewDevice(expiresIn: 900);
+
+ var ex = await Assert.ThrowsAsync(
+ () => collector.PollForTokenAsync(new Uri("https://github.com/"), "id", device, CancellationToken.None));
+ Assert.Contains("denied", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task PollForTokenAsync_Throws_OnTimeout()
+ {
+ // Always pending; the injected delay advances the clock past expires_in.
+ var stub = StubHttpClientFactory.ReturningJson("""{ "error": "authorization_pending" }""");
+ var collector = Collector(stub, out _);
+
+ var device = NewDevice(expiresIn: 3, interval: 5);
+
+ var ex = await Assert.ThrowsAsync(
+ () => collector.PollForTokenAsync(new Uri("https://github.com/"), "id", device, CancellationToken.None));
+ Assert.Contains("Timed out", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task LookupUserAsync_ParsesUser_AndSendsAuthAndUserAgent()
+ {
+ var stub = StubHttpClientFactory.ReturningJson(
+ """{ "login": "octocat", "name": "The Octocat", "id": 583231 }""");
+ var collector = Collector(stub, out _);
+
+ var user = await collector.LookupUserAsync(new Uri("https://api.github.com/"), "gho_secret");
+
+ Assert.Equal("octocat", user.Login);
+ Assert.Equal("The Octocat", user.Name);
+
+ var request = stub.LastRequest!;
+ Assert.Equal(new Uri("https://api.github.com/user"), request.RequestUri);
+ Assert.Equal("Bearer", request.Headers.Authorization!.Scheme);
+ Assert.Equal("gho_secret", request.Headers.Authorization.Parameter);
+ Assert.Contains(
+ GitHubCredentialCollector.UserAgent,
+ request.Headers.UserAgent.ToString(),
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task LookupUserAsync_Throws_OnNonSuccess_AndRedactsToken()
+ {
+ var stub = StubHttpClientFactory.ReturningJson(
+ """{ "message": "Bad credentials gho_secret" }""", HttpStatusCode.Unauthorized);
+ var collector = Collector(stub, out _);
+
+ var ex = await Assert.ThrowsAsync(
+ () => collector.LookupUserAsync(new Uri("https://api.github.com/"), "gho_secret"));
+
+ Assert.Contains("user lookup failed", ex.Message, StringComparison.Ordinal);
+ Assert.DoesNotContain("gho_secret", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void SanitiseErrorBody_RedactsTokenAndTruncates()
+ {
+ var token = "gho_supersecret";
+ var body = "prefix " + token + " " + new string('x', GitHubCredentialCollector.ErrorBodyMaxChars);
+
+ var safe = GitHubCredentialCollector.SanitiseErrorBody(body, token);
+
+ Assert.DoesNotContain(token, safe, StringComparison.Ordinal);
+ Assert.Contains("", safe, StringComparison.Ordinal);
+ Assert.EndsWith("… [truncated]", safe, StringComparison.Ordinal);
+ }
+
+ private static GitHubDeviceCodeDto NewDevice(int expiresIn, int interval = 5) => new()
+ {
+ DeviceCode = "dc",
+ UserCode = "code",
+ VerificationUri = "https://github.com/login/device",
+ ExpiresIn = expiresIn,
+ Interval = interval,
+ };
+}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialSummaryProviderTests.cs b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialSummaryProviderTests.cs
new file mode 100644
index 0000000..03d4bb5
--- /dev/null
+++ b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialSummaryProviderTests.cs
@@ -0,0 +1,69 @@
+using System.Text.Json;
+using Xunit;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests;
+
+public sealed class GitHubCredentialSummaryProviderTests
+{
+ private static string Serialize(GitHubCredential credential)
+ => JsonSerializer.Serialize(credential, GitHubCredential.JsonOptions);
+
+ private static GitHubCredential Credential(string? name = "The Octocat", string accessToken = "gho_1234567890abcdef")
+ => new()
+ {
+ ClientId = "id",
+ AccessToken = accessToken,
+ Scopes = "repo read:org",
+ WebBaseUrl = new Uri("https://github.com/"),
+ ApiBaseUrl = new Uri("https://api.github.com/"),
+ Login = "octocat",
+ Name = name,
+ Environment = "GitHubCom",
+ };
+
+ [Fact]
+ public void ProviderName_IsGitHub()
+ {
+ Assert.Equal("GitHub", new GitHubCredentialSummaryProvider().ProviderName);
+ }
+
+ [Fact]
+ public void GetDisplayFields_ShowsAccountScopesHostAndMaskedToken()
+ {
+ var fields = new GitHubCredentialSummaryProvider().GetDisplayFields(Serialize(Credential()));
+ var map = fields.ToDictionary(kv => kv.Key, kv => kv.Value);
+
+ Assert.Equal("octocat (The Octocat)", map["Account"]);
+ Assert.Equal("repo read:org", map["Scopes"]);
+ Assert.Equal("https://api.github.com/", map["Host"]);
+ Assert.Equal("gho_...cdef", map["Token"]);
+ }
+
+ [Fact]
+ public void GetDisplayFields_OmitsNameWhenAbsent()
+ {
+ var fields = new GitHubCredentialSummaryProvider().GetDisplayFields(Serialize(Credential(name: null)));
+ var account = fields.Single(kv => kv.Key == "Account").Value;
+
+ Assert.Equal("octocat", account);
+ }
+
+ [Fact]
+ public void GetDisplayFields_MasksShortTokenWithoutLeakingLength()
+ {
+ var fields = new GitHubCredentialSummaryProvider().GetDisplayFields(Serialize(Credential(accessToken: "short")));
+ var token = fields.Single(kv => kv.Key == "Token").Value;
+
+ Assert.Equal("****", token);
+ }
+
+ [Fact]
+ public void GetDisplayFields_ReturnsMarker_OnUnreadableJson()
+ {
+ var fields = new GitHubCredentialSummaryProvider().GetDisplayFields("{ not json");
+
+ var single = Assert.Single(fields);
+ Assert.Equal("Status", single.Key);
+ Assert.Equal("", single.Value);
+ }
+}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialTests.cs b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialTests.cs
new file mode 100644
index 0000000..f3d8c9f
--- /dev/null
+++ b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialTests.cs
@@ -0,0 +1,98 @@
+using System.Text.Json;
+using Xunit;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests;
+
+public sealed class GitHubCredentialTests
+{
+ [Fact]
+ public void ProviderName_IsGitHub()
+ {
+ Assert.Equal("GitHub", GitHubCredential.ProviderName);
+ }
+
+ [Fact]
+ public void SupportedEnvironments_AreEnumNames()
+ {
+ Assert.Equal(["GitHubCom", "Enterprise"], GitHubCredential.SupportedEnvironments);
+ }
+
+ [Fact]
+ public void Roundtrip_PreservesAllFields_IncludingNullableExpiringTokenFields()
+ {
+ var original = new GitHubCredential
+ {
+ ClientId = "Iv1.abc123",
+ AccessToken = "gho_token_value",
+ RefreshToken = "ghr_refresh_value",
+ AccessTokenExpiresAt = new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero),
+ Scopes = "repo read:org",
+ WebBaseUrl = new Uri("https://github.com/"),
+ ApiBaseUrl = new Uri("https://api.github.com/"),
+ Login = "octocat",
+ Name = "The Octocat",
+ Environment = "GitHubCom",
+ };
+
+ var json = JsonSerializer.Serialize(original, GitHubCredential.JsonOptions);
+ var roundtripped = JsonSerializer.Deserialize(json, GitHubCredential.JsonOptions);
+
+ Assert.NotNull(roundtripped);
+ Assert.Equal(original.ClientId, roundtripped!.ClientId);
+ Assert.Equal(original.AccessToken, roundtripped.AccessToken);
+ Assert.Equal(original.RefreshToken, roundtripped.RefreshToken);
+ Assert.Equal(original.AccessTokenExpiresAt, roundtripped.AccessTokenExpiresAt);
+ Assert.Equal(original.Scopes, roundtripped.Scopes);
+ Assert.Equal(original.WebBaseUrl, roundtripped.WebBaseUrl);
+ Assert.Equal(original.ApiBaseUrl, roundtripped.ApiBaseUrl);
+ Assert.Equal(original.Login, roundtripped.Login);
+ Assert.Equal(original.Name, roundtripped.Name);
+ Assert.Equal(original.Environment, roundtripped.Environment);
+ }
+
+ [Fact]
+ public void Roundtrip_NonExpiringToken_LeavesRefreshAndExpiryNull()
+ {
+ var original = new GitHubCredential
+ {
+ ClientId = "Iv1.abc123",
+ AccessToken = "gho_token_value",
+ RefreshToken = null,
+ AccessTokenExpiresAt = null,
+ Scopes = "repo",
+ WebBaseUrl = new Uri("https://github.com/"),
+ ApiBaseUrl = new Uri("https://api.github.com/"),
+ Login = "octocat",
+ Name = null,
+ Environment = "GitHubCom",
+ };
+
+ var json = JsonSerializer.Serialize(original, GitHubCredential.JsonOptions);
+ var roundtripped = JsonSerializer.Deserialize(json, GitHubCredential.JsonOptions);
+
+ Assert.NotNull(roundtripped);
+ Assert.Null(roundtripped!.RefreshToken);
+ Assert.Null(roundtripped.AccessTokenExpiresAt);
+ Assert.Null(roundtripped.Name);
+ }
+
+ [Fact]
+ public void JsonOptions_UseCamelCase()
+ {
+ var credential = new GitHubCredential
+ {
+ ClientId = "id",
+ AccessToken = "token",
+ Scopes = "repo",
+ WebBaseUrl = new Uri("https://github.com/"),
+ ApiBaseUrl = new Uri("https://api.github.com/"),
+ Login = "octocat",
+ Environment = "GitHubCom",
+ };
+
+ var json = JsonSerializer.Serialize(credential, GitHubCredential.JsonOptions);
+
+ Assert.Contains("\"accessToken\"", json, StringComparison.Ordinal);
+ Assert.Contains("\"apiBaseUrl\"", json, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubTokenTests.cs b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubTokenTests.cs
new file mode 100644
index 0000000..8730c38
--- /dev/null
+++ b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubTokenTests.cs
@@ -0,0 +1,72 @@
+using Xunit;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests;
+
+public sealed class GitHubTokenTests
+{
+ [Fact]
+ public void GetAuthorizationHeader_IsBearerToken()
+ {
+ var token = new GitHubToken
+ {
+ AccessToken = "gho_abc",
+ BaseUrl = new Uri("https://api.github.com/"),
+ };
+
+ Assert.Equal("Bearer gho_abc", token.GetAuthorizationHeader());
+ }
+
+ [Fact]
+ public void IsExpired_IsFalse_WhenNoExpirySet()
+ {
+ var token = new GitHubToken
+ {
+ AccessToken = "gho_abc",
+ BaseUrl = new Uri("https://api.github.com/"),
+ ExpiresAt = null,
+ };
+
+ Assert.False(token.IsExpired);
+ }
+
+ [Fact]
+ public void IsExpired_IsTrue_WhenPastExpiryMinusSkew()
+ {
+ var token = new GitHubToken
+ {
+ AccessToken = "gho_abc",
+ BaseUrl = new Uri("https://api.github.com/"),
+ // Already past, well beyond the clock-skew window.
+ ExpiresAt = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(1),
+ };
+
+ Assert.True(token.IsExpired);
+ }
+
+ [Fact]
+ public void IsExpired_IsTrue_WithinClockSkewWindow()
+ {
+ var token = new GitHubToken
+ {
+ AccessToken = "gho_abc",
+ BaseUrl = new Uri("https://api.github.com/"),
+ // Expires in 10s, but the 30s skew window trips it early.
+ ExpiresAt = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10),
+ };
+
+ Assert.True(token.IsExpired);
+ }
+
+ [Fact]
+ public void IsExpired_IsFalse_WhenComfortablyInFuture()
+ {
+ var token = new GitHubToken
+ {
+ AccessToken = "gho_abc",
+ BaseUrl = new Uri("https://api.github.com/"),
+ ExpiresAt = DateTimeOffset.UtcNow + TimeSpan.FromHours(1),
+ };
+
+ Assert.False(token.IsExpired);
+ }
+}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests.csproj b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests.csproj
new file mode 100644
index 0000000..e39782d
--- /dev/null
+++ b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests.csproj
@@ -0,0 +1,43 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ false
+
+ $(NoWarn);CA1707;CA1515;CA2007
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/ServiceCollectionExtensionsTests.cs b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..d54e4c7
--- /dev/null
+++ b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,81 @@
+using Microsoft.Extensions.DependencyInjection;
+using NextIteration.SpectreConsole.Auth.Commands;
+using NextIteration.SpectreConsole.Auth.Persistence;
+using NextIteration.SpectreConsole.Auth.Services;
+using Xunit;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests;
+
+public sealed class ServiceCollectionExtensionsTests
+{
+ [Fact]
+ public void AddGitHubAuthProvider_RegistersAuthenticationService()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddHttpClient();
+
+ services.AddGitHubAuthProvider();
+
+ using var sp = services.BuildServiceProvider();
+ Assert.NotNull(sp.GetService());
+ }
+
+ [Fact]
+ public void AddGitHubAuthProvider_RegistersCollectorOnICredentialCollector()
+ {
+ var services = new ServiceCollection();
+ services.AddHttpClient();
+
+ services.AddGitHubAuthProvider();
+
+ using var sp = services.BuildServiceProvider();
+ var collectors = sp.GetServices().ToList();
+ Assert.Single(collectors);
+ Assert.IsType(collectors[0]);
+ }
+
+ [Fact]
+ public void AddGitHubAuthProvider_RegistersSummaryProviderOnICredentialSummaryProvider()
+ {
+ var services = new ServiceCollection();
+ services.AddHttpClient();
+
+ services.AddGitHubAuthProvider();
+
+ using var sp = services.BuildServiceProvider();
+ var summaries = sp.GetServices().ToList();
+ Assert.Single(summaries);
+ Assert.IsType(summaries[0]);
+ }
+
+ [Fact]
+ public void AddGitHubAuthProvider_RegistersAsSingletons()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddHttpClient();
+
+ services.AddGitHubAuthProvider();
+
+ using var sp = services.BuildServiceProvider();
+ var a = sp.GetRequiredService();
+ var b = sp.GetRequiredService();
+ Assert.Same(a, b);
+ }
+
+ [Fact]
+ public void AddGitHubAuthProvider_InterfaceForwardsToSameSingletonAsConcrete()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddHttpClient();
+
+ services.AddGitHubAuthProvider();
+
+ using var sp = services.BuildServiceProvider();
+ var concrete = sp.GetRequiredService();
+ var viaInterface = sp.GetRequiredService>();
+ Assert.Same(concrete, viaInterface);
+ }
+}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/StubHttpClientFactory.cs b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/StubHttpClientFactory.cs
new file mode 100644
index 0000000..d22c95b
--- /dev/null
+++ b/tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/StubHttpClientFactory.cs
@@ -0,0 +1,66 @@
+using System.Net;
+using System.Text;
+
+namespace NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests;
+
+///
+/// Minimal +
+/// doubles that capture every outgoing request (and its body) and return a
+/// canned response. The GitHub device flow makes several calls in sequence —
+/// device-code, one or more pending polls, success, then /user — so the
+/// stub records the full request list and supports a sequenced responder.
+///
+internal sealed class StubHttpClientFactory : IHttpClientFactory
+{
+ private readonly Func _responder;
+
+ public List Requests { get; } = [];
+ public List RequestBodies { get; } = [];
+ public HttpRequestMessage? LastRequest => Requests.Count > 0 ? Requests[^1] : null;
+
+ public StubHttpClientFactory(Func responder)
+ {
+ _responder = responder;
+ }
+
+ public HttpClient CreateClient(string name) => new(new CapturingHandler(this));
+
+ // Convenience: 200 OK with the given JSON body for every call.
+ public static StubHttpClientFactory ReturningJson(string json, HttpStatusCode status = HttpStatusCode.OK)
+ => new((_, _) => JsonResponse(json, status));
+
+ // Returns each response in order; the last response is reused for any
+ // extra calls beyond the supplied set.
+ public static StubHttpClientFactory Sequence(params Func[] responses)
+ {
+ var index = 0;
+ return new StubHttpClientFactory((_, _) =>
+ {
+ var resolved = responses[Math.Min(index, responses.Length - 1)]();
+ index++;
+ return resolved;
+ });
+ }
+
+ public static HttpResponseMessage JsonResponse(string json, HttpStatusCode status = HttpStatusCode.OK)
+ => new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
+
+ private sealed class CapturingHandler : HttpMessageHandler
+ {
+ private readonly StubHttpClientFactory _owner;
+
+ public CapturingHandler(StubHttpClientFactory owner) => _owner = owner;
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var body = request.Content is null
+ ? null
+ : await request.Content.ReadAsStringAsync(cancellationToken);
+
+ _owner.Requests.Add(request);
+ _owner.RequestBodies.Add(body);
+ return _owner._responder(request, body);
+ }
+ }
+}