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