From 1917fa1437c69634be3b91d215a17dcdcb25c5de Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Sat, 20 Jun 2026 02:14:29 +0000 Subject: [PATCH] Add GitHub credential provider (OAuth device flow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds NextIteration.SpectreConsole.Auth.Providers.GitHub, a credential provider whose collector runs the OAuth device flow — the same flow the GitHub CLI uses by default. It prompts for the GitHub host, OAuth App client id, and scopes; requests a device/user code; polls the token endpoint (honouring interval, slow_down, and authorization_pending); then validates and enriches the credential via GET /user. - GitHubCredential / GitHubToken / GitHubAuthenticationService / GitHubCredentialCollector / GitHubCredentialSummaryProvider, plus the AddGitHubAuthProvider DI extension. - Configurable host: defaults to github.com; an Enterprise Server host derives the matching web and /api/v3 base URLs. - Token refresh for OAuth Apps that issue expiring tokens; classic non-expiring tokens pass straight through. - Full xUnit suite (49 tests) with a sequenced HTTP stub and injected clock/delay so the polling and refresh paths are covered without sleeping. - Wires the project into the solution, CI (github-v* tag, pack, publish), the top-level README, CHANGELOG, RELEASING, and a matching package icon. --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 25 ++ ...eration.SpectreConsole.Auth.Providers.slnx | 2 + README.md | 11 +- RELEASING.md | 1 + ...n.SpectreConsole.Auth.Providers.GitHub.svg | 11 + .../GitHubAuthenticationService.cs | 202 ++++++++++ .../GitHubCredential.cs | 109 +++++ .../GitHubCredentialCollector.cs | 371 ++++++++++++++++++ .../GitHubCredentialSummaryProvider.cs | 58 +++ .../GitHubDtos.cs | 72 ++++ .../GitHubToken.cs | 48 +++ ...pectreConsole.Auth.Providers.GitHub.csproj | 49 +++ .../README.md | 162 ++++++++ .../ServiceCollectionExtensions.cs | 38 ++ .../icon.png | Bin 0 -> 18412 bytes .../FakeCredentialManager.cs | 35 ++ .../GitHubAuthenticationServiceTests.cs | 159 ++++++++ .../GitHubCredentialCollectorTests.cs | 235 +++++++++++ .../GitHubCredentialSummaryProviderTests.cs | 69 ++++ .../GitHubCredentialTests.cs | 98 +++++ .../GitHubTokenTests.cs | 72 ++++ ...Console.Auth.Providers.GitHub.Tests.csproj | 43 ++ .../ServiceCollectionExtensionsTests.cs | 81 ++++ .../StubHttpClientFactory.cs | 66 ++++ 25 files changed, 2017 insertions(+), 5 deletions(-) create mode 100644 design/icons/NextIteration.SpectreConsole.Auth.Providers.GitHub.svg create mode 100644 src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubAuthenticationService.cs create mode 100644 src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredential.cs create mode 100644 src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredentialCollector.cs create mode 100644 src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubCredentialSummaryProvider.cs create mode 100644 src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubDtos.cs create mode 100644 src/NextIteration.SpectreConsole.Auth.Providers.GitHub/GitHubToken.cs create mode 100644 src/NextIteration.SpectreConsole.Auth.Providers.GitHub/NextIteration.SpectreConsole.Auth.Providers.GitHub.csproj create mode 100644 src/NextIteration.SpectreConsole.Auth.Providers.GitHub/README.md create mode 100644 src/NextIteration.SpectreConsole.Auth.Providers.GitHub/ServiceCollectionExtensions.cs create mode 100644 src/NextIteration.SpectreConsole.Auth.Providers.GitHub/icon.png create mode 100644 tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/FakeCredentialManager.cs create mode 100644 tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubAuthenticationServiceTests.cs create mode 100644 tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialCollectorTests.cs create mode 100644 tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialSummaryProviderTests.cs create mode 100644 tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubCredentialTests.cs create mode 100644 tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/GitHubTokenTests.cs create mode 100644 tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests.csproj create mode 100644 tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/ServiceCollectionExtensionsTests.cs create mode 100644 tests/NextIteration.SpectreConsole.Auth.Providers.GitHub.Tests/StubHttpClientFactory.cs 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 @@ + + + + + + Next + + GitHub + 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 + +[![NuGet](https://img.shields.io/nuget/v/NextIteration.SpectreConsole.Auth.Providers.GitHub.svg)](https://www.nuget.org/packages/NextIteration.SpectreConsole.Auth.Providers.GitHub/) +[![Downloads](https://img.shields.io/nuget/dt/NextIteration.SpectreConsole.Auth.Providers.GitHub.svg)](https://www.nuget.org/packages/NextIteration.SpectreConsole.Auth.Providers.GitHub/) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +[![.NET](https://img.shields.io/badge/.NET-10.0-purple.svg)](https://dotnet.microsoft.com/) +[![CI](https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth.Providers/actions/workflows/ci.yml/badge.svg)](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 0000000000000000000000000000000000000000..809866fd207e363817898e6a8c78662a86ce2d84 GIT binary patch literal 18412 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�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 JIcj(7{{cOf-Vy)+ literal 0 HcmV?d00001 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); + } + } +}