From 9107019523192f1f5db0e81802b08cd1ee124dd6 Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Tue, 2 Jun 2026 00:29:08 -0500 Subject: [PATCH 1/7] feat: add auth login/logout/status commands (PROT-7) --- README.md | 24 +++ src/Protostar.Cli/Auth/ApiCompatibility.cs | 25 +++ src/Protostar.Cli/Auth/AuthConstants.cs | 27 +++ src/Protostar.Cli/Auth/BrowserLauncher.cs | 26 +++ src/Protostar.Cli/Auth/LoopbackServer.cs | 106 ++++++++++++ src/Protostar.Cli/Auth/Pkce.cs | 22 +++ src/Protostar.Cli/Auth/RegistryClient.cs | 102 +++++++++++ src/Protostar.Cli/Auth/RegistryEndpoint.cs | 28 +++ src/Protostar.Cli/Auth/StoredToken.cs | 18 ++ src/Protostar.Cli/Auth/TokenStore.cs | 35 ++++ .../Commands/Auth/AuthSettings.cs | 12 ++ .../Commands/Auth/LoginCommand.cs | 162 ++++++++++++++++++ .../Commands/Auth/LogoutCommand.cs | 41 +++++ .../Commands/Auth/StatusCommand.cs | 91 ++++++++++ src/Protostar.Cli/Program.cs | 12 ++ src/Protostar.Cli/Protostar.Cli.csproj | 1 + .../Features/Auth.feature | 16 ++ 17 files changed, 748 insertions(+) create mode 100644 src/Protostar.Cli/Auth/ApiCompatibility.cs create mode 100644 src/Protostar.Cli/Auth/AuthConstants.cs create mode 100644 src/Protostar.Cli/Auth/BrowserLauncher.cs create mode 100644 src/Protostar.Cli/Auth/LoopbackServer.cs create mode 100644 src/Protostar.Cli/Auth/Pkce.cs create mode 100644 src/Protostar.Cli/Auth/RegistryClient.cs create mode 100644 src/Protostar.Cli/Auth/RegistryEndpoint.cs create mode 100644 src/Protostar.Cli/Auth/StoredToken.cs create mode 100644 src/Protostar.Cli/Auth/TokenStore.cs create mode 100644 src/Protostar.Cli/Commands/Auth/AuthSettings.cs create mode 100644 src/Protostar.Cli/Commands/Auth/LoginCommand.cs create mode 100644 src/Protostar.Cli/Commands/Auth/LogoutCommand.cs create mode 100644 src/Protostar.Cli/Commands/Auth/StatusCommand.cs create mode 100644 test/Protostar.Cli.Acceptance/Features/Auth.feature diff --git a/README.md b/README.md index 2599477..9049353 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,30 @@ $ protostar uninstall # remove it > Startup is currently JIT (self-contained, untrimmed); making the binary lean and fast is tracked > as a separate performance-tuning unit of work. +## Authenticate to the registry + +Sign in so your synced skills are tagged to you. `protostar auth login` opens your browser, +you sign in (the registry federates this to GitHub), and the resulting session is stored in your +OS credential store. The flow uses the OAuth Authorization Code grant with PKCE over a loopback +redirect — no secret is kept on disk. + +```console +$ protostar auth login +Opening your browser to sign in. Complete the sign-in there, then return here. +Signed in to https://registry.example as alice. + +$ protostar auth status +Logged in to https://registry.example as alice. + +$ protostar auth logout +Signed out of https://registry.example. +``` + +Point the CLI at a registry with `--registry ` or the `PROTOSTAR_REGISTRY_URL` environment +variable. Use `--no-browser` on a headless machine to print the sign-in URL instead of opening a +browser. The CLI checks API compatibility with the registry on connect and refuses to proceed +against an unsupported major version. + ## Build from source Requires the .NET 10 SDK. diff --git a/src/Protostar.Cli/Auth/ApiCompatibility.cs b/src/Protostar.Cli/Auth/ApiCompatibility.cs new file mode 100644 index 0000000..8305cdd --- /dev/null +++ b/src/Protostar.Cli/Auth/ApiCompatibility.cs @@ -0,0 +1,25 @@ +namespace Protostar.Cli.Auth; + +/// +/// The CLI/registry compatibility contract. Versions are not lockstepped; instead the registry +/// advertises the API majors it supports at /v1/meta and the CLI checks that the major it +/// speaks is among them before doing anything else. +/// +internal static class ApiCompatibility +{ + /// Returns null when compatible, otherwise a human-readable explanation. + public static string? Check(RegistryMeta? meta) + { + if (meta?.ApiMajors is not { Length: > 0 } majors) + return "The registry did not report a supported API version."; + + if (!majors.Contains(AuthConstants.SupportedApiMajor)) + { + var supported = string.Join(", ", majors.Select(m => $"v{m}")); + return $"This protostar build speaks registry API v{AuthConstants.SupportedApiMajor}, " + + $"but the registry supports {supported}. Update protostar to a compatible version."; + } + + return null; + } +} diff --git a/src/Protostar.Cli/Auth/AuthConstants.cs b/src/Protostar.Cli/Auth/AuthConstants.cs new file mode 100644 index 0000000..d9d97cf --- /dev/null +++ b/src/Protostar.Cli/Auth/AuthConstants.cs @@ -0,0 +1,27 @@ +namespace Protostar.Cli.Auth; + +/// +/// Shared constants for the OAuth loopback flow against the protostar registry. These mirror what +/// the registry seeds for the protostar-cli public client (client id, callback path, scopes). +/// +internal static class AuthConstants +{ + public const string ClientId = "protostar-cli"; + public const string CallbackPath = "/callback"; + + // offline_access yields a refresh token (the client is granted the refresh-token flow). + public const string Scopes = "openid profile email registry offline_access"; + + // The API major this CLI speaks. Checked against the registry's advertised apiMajors on connect. + public const int SupportedApiMajor = 1; + + public const string RegistryEnvVar = "PROTOSTAR_REGISTRY_URL"; + + // Dev default. Override with --registry or PROTOSTAR_REGISTRY_URL until a registry is deployed. + public const string DefaultRegistryUrl = "https://localhost:5099"; + + // Credential storage. Tokens are keyed by the registry's authority (a URI), so logging into + // different registries keeps separate sessions. + public const string CredentialService = "protostar"; + public const string CredentialAccount = "protostar"; +} diff --git a/src/Protostar.Cli/Auth/BrowserLauncher.cs b/src/Protostar.Cli/Auth/BrowserLauncher.cs new file mode 100644 index 0000000..ae799d0 --- /dev/null +++ b/src/Protostar.Cli/Auth/BrowserLauncher.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; + +namespace Protostar.Cli.Auth; + +/// Opens a URL in the user's default browser, cross-platform. Returns false on failure. +internal static class BrowserLauncher +{ + public static bool TryOpen(string url) + { + try + { + if (OperatingSystem.IsWindows()) + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + else if (OperatingSystem.IsMacOS()) + Process.Start("open", url); + else + Process.Start("xdg-open", url); + + return true; + } + catch + { + return false; + } + } +} diff --git a/src/Protostar.Cli/Auth/LoopbackServer.cs b/src/Protostar.Cli/Auth/LoopbackServer.cs new file mode 100644 index 0000000..7731aef --- /dev/null +++ b/src/Protostar.Cli/Auth/LoopbackServer.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Protostar.Cli.Auth; + +/// +/// A one-shot loopback HTTP listener for the OAuth redirect. Binds an ephemeral 127.0.0.1 port, +/// waits for the /callback request, hands back the authorization code (or error), and serves +/// a small "you can close this tab" page. Loopback redirects with a dynamic port are the +/// recommended pattern for native apps (RFC 8252); the registry ignores the port when matching. +/// +internal sealed class LoopbackServer : IDisposable +{ + private readonly HttpListener _listener = new(); + + public LoopbackServer() + { + Port = FreeLoopbackPort(); + RedirectUri = $"http://127.0.0.1:{Port}{AuthConstants.CallbackPath}"; + _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); + _listener.Start(); + } + + public int Port { get; } + + public string RedirectUri { get; } + + public async Task WaitForCallbackAsync(CancellationToken cancellationToken) + { + await using var registration = cancellationToken.Register(() => + { + try { _listener.Stop(); } catch { /* already stopped */ } + }); + + while (true) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync(); + } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + if (!string.Equals(context.Request.Url!.AbsolutePath, AuthConstants.CallbackPath, StringComparison.Ordinal)) + { + await RespondAsync(context, 404, "Not found."); + continue; + } + + var query = ParseQuery(context.Request.Url!.Query); + query.TryGetValue("error", out var error); + + var message = error is not null + ? "protostar sign-in failed. You can close this tab and return to the terminal." + : "protostar sign-in complete. You can close this tab and return to the terminal."; + await RespondAsync(context, 200, message); + + query.TryGetValue("code", out var code); + query.TryGetValue("state", out var state); + query.TryGetValue("error_description", out var description); + return new CallbackResult(code, state, error, description); + } + } + + private static async Task RespondAsync(HttpListenerContext context, int status, string message) + { + var body = Encoding.UTF8.GetBytes( + $"

{message}

"); + context.Response.StatusCode = status; + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.ContentLength64 = body.Length; + await context.Response.OutputStream.WriteAsync(body); + context.Response.Close(); + } + + private static Dictionary ParseQuery(string query) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var separator = pair.IndexOf('='); + var key = Uri.UnescapeDataString(separator < 0 ? pair : pair[..separator]); + var value = separator < 0 ? string.Empty : Uri.UnescapeDataString(pair[(separator + 1)..]); + result[key] = value; + } + + return result; + } + + private static int FreeLoopbackPort() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + public void Dispose() => ((IDisposable)_listener).Dispose(); +} + +internal sealed record CallbackResult(string? Code, string? State, string? Error, string? ErrorDescription); diff --git a/src/Protostar.Cli/Auth/Pkce.cs b/src/Protostar.Cli/Auth/Pkce.cs new file mode 100644 index 0000000..e5f8aa4 --- /dev/null +++ b/src/Protostar.Cli/Auth/Pkce.cs @@ -0,0 +1,22 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Protostar.Cli.Auth; + +/// +/// PKCE (RFC 7636) helpers. A public client can't keep a secret, so each login generates a random +/// verifier and sends only its SHA-256 challenge up front; the verifier is revealed at token +/// exchange, proving the same client started and finished the flow. +/// +internal static class Pkce +{ + public static string CreateVerifier() => Base64Url(RandomNumberGenerator.GetBytes(32)); + + public static string CreateState() => Base64Url(RandomNumberGenerator.GetBytes(16)); + + public static string Challenge(string verifier) => + Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/src/Protostar.Cli/Auth/RegistryClient.cs b/src/Protostar.Cli/Auth/RegistryClient.cs new file mode 100644 index 0000000..b6d78dd --- /dev/null +++ b/src/Protostar.Cli/Auth/RegistryClient.cs @@ -0,0 +1,102 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Protostar.Cli.Auth; + +/// +/// Thin HTTP client for the registry's OAuth + metadata endpoints. TLS is validated normally, so +/// the registry must present a trusted certificate (the ASP.NET Core dev cert locally). +/// +internal sealed class RegistryClient(Uri registry) : IDisposable +{ + private readonly HttpClient _http = new() { BaseAddress = registry, Timeout = TimeSpan.FromSeconds(30) }; + + public Uri Registry { get; } = registry; + + public async Task GetMetaAsync(CancellationToken cancellationToken) + { + using var response = await _http.GetAsync("/v1/meta", cancellationToken); + return response.IsSuccessStatusCode + ? await response.Content.ReadFromJsonAsync(AuthJson.Default, cancellationToken) + : null; + } + + public Task ExchangeCodeAsync(string code, string codeVerifier, string redirectUri, CancellationToken cancellationToken) => + PostTokenAsync(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = redirectUri, + ["client_id"] = AuthConstants.ClientId, + ["code_verifier"] = codeVerifier, + }, cancellationToken); + + public Task RefreshAsync(string refreshToken, CancellationToken cancellationToken) => + PostTokenAsync(new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = AuthConstants.ClientId, + }, cancellationToken); + + public async Task GetUserInfoAsync(string accessToken, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + using var response = await _http.SendAsync(request, cancellationToken); + return response.IsSuccessStatusCode + ? await response.Content.ReadFromJsonAsync(AuthJson.Default, cancellationToken) + : null; + } + + private async Task PostTokenAsync(Dictionary form, CancellationToken cancellationToken) + { + using var content = new FormUrlEncodedContent(form); + using var response = await _http.PostAsync("/connect/token", content, cancellationToken); + + var payload = await response.Content.ReadFromJsonAsync(AuthJson.Default, cancellationToken) + ?? new TokenResponse { Error = "invalid_response", ErrorDescription = "The registry returned no token payload." }; + return payload; + } + + public void Dispose() => _http.Dispose(); +} + +internal static class AuthJson +{ + public static readonly JsonSerializerOptions Default = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; +} + +internal sealed record RegistryMeta +{ + [JsonPropertyName("service")] public string? Service { get; init; } + [JsonPropertyName("version")] public string? Version { get; init; } + [JsonPropertyName("apiMajors")] public int[]? ApiMajors { get; init; } +} + +internal sealed record TokenResponse +{ + [JsonPropertyName("access_token")] public string? AccessToken { get; init; } + [JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; } + [JsonPropertyName("id_token")] public string? IdToken { get; init; } + [JsonPropertyName("expires_in")] public int ExpiresIn { get; init; } + [JsonPropertyName("error")] public string? Error { get; init; } + [JsonPropertyName("error_description")] public string? ErrorDescription { get; init; } + + [JsonIgnore] + public bool IsSuccess => string.IsNullOrEmpty(Error) && !string.IsNullOrEmpty(AccessToken); +} + +internal sealed record UserInfo +{ + [JsonPropertyName("sub")] public string? Sub { get; init; } + [JsonPropertyName("preferred_username")] public string? PreferredUsername { get; init; } + [JsonPropertyName("name")] public string? Name { get; init; } + [JsonPropertyName("github_login")] public string? GitHubLogin { get; init; } +} diff --git a/src/Protostar.Cli/Auth/RegistryEndpoint.cs b/src/Protostar.Cli/Auth/RegistryEndpoint.cs new file mode 100644 index 0000000..04a14c2 --- /dev/null +++ b/src/Protostar.Cli/Auth/RegistryEndpoint.cs @@ -0,0 +1,28 @@ +namespace Protostar.Cli.Auth; + +/// +/// Resolves which registry the CLI talks to: an explicit --registry value wins, then the +/// PROTOSTAR_REGISTRY_URL environment variable, then the built-in dev default. +/// +internal static class RegistryEndpoint +{ + public static Uri Resolve(string? option) + { + var raw = !string.IsNullOrWhiteSpace(option) + ? option! + : Environment.GetEnvironmentVariable(AuthConstants.RegistryEnvVar) is { Length: > 0 } env + ? env + : AuthConstants.DefaultRegistryUrl; + + if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + throw new FormatException($"Invalid registry URL '{raw}'. Use an absolute http(s) URL."); + } + + return uri; + } + + /// Stable credential key for a registry: scheme://host:port, no trailing slash. + public static string CredentialKey(Uri registry) => registry.GetLeftPart(UriPartial.Authority); +} diff --git a/src/Protostar.Cli/Auth/StoredToken.cs b/src/Protostar.Cli/Auth/StoredToken.cs new file mode 100644 index 0000000..67eadf2 --- /dev/null +++ b/src/Protostar.Cli/Auth/StoredToken.cs @@ -0,0 +1,18 @@ +namespace Protostar.Cli.Auth; + +/// +/// The persisted session for one registry, stored as JSON in the OS credential store. +/// +internal sealed record StoredToken +{ + public required string Registry { get; init; } + public required string AccessToken { get; init; } + public string? RefreshToken { get; init; } + public DateTimeOffset ExpiresAtUtc { get; init; } + public string? Subject { get; init; } + public string? Login { get; init; } + public string? Name { get; init; } + + /// True once the access token is within 30s of expiry (treat as needing refresh). + public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAtUtc.AddSeconds(-30); +} diff --git a/src/Protostar.Cli/Auth/TokenStore.cs b/src/Protostar.Cli/Auth/TokenStore.cs new file mode 100644 index 0000000..3ab4b65 --- /dev/null +++ b/src/Protostar.Cli/Auth/TokenStore.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using GitCredentialManager; + +namespace Protostar.Cli.Auth; + +/// +/// Reads and writes sessions in the OS credential store (Windows +/// Credential Manager / macOS Keychain / Linux Secret Service) via Devlooped.CredentialManager. +/// +internal sealed class TokenStore +{ + private readonly ICredentialStore _store = CredentialManager.Create(AuthConstants.CredentialService); + + public void Save(StoredToken token) + { + var json = JsonSerializer.Serialize(token, AuthJson.Default); + _store.AddOrUpdate( + RegistryEndpoint.CredentialKey(new Uri(token.Registry)), + AuthConstants.CredentialAccount, + json); + } + + public StoredToken? Load(Uri registry) + { + var secret = _store.Get(RegistryEndpoint.CredentialKey(registry), AuthConstants.CredentialAccount)?.Password; + if (string.IsNullOrEmpty(secret)) + return null; + + try { return JsonSerializer.Deserialize(secret, AuthJson.Default); } + catch (JsonException) { return null; } + } + + public void Delete(Uri registry) => + _store.Remove(RegistryEndpoint.CredentialKey(registry), AuthConstants.CredentialAccount); +} diff --git a/src/Protostar.Cli/Commands/Auth/AuthSettings.cs b/src/Protostar.Cli/Commands/Auth/AuthSettings.cs new file mode 100644 index 0000000..0081cf5 --- /dev/null +++ b/src/Protostar.Cli/Commands/Auth/AuthSettings.cs @@ -0,0 +1,12 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace Protostar.Cli.Commands.Auth; + +/// Options shared by every protostar auth command. +internal abstract class AuthSettings : CommandSettings +{ + [CommandOption("--registry ")] + [Description("Registry base URL. Defaults to $PROTOSTAR_REGISTRY_URL, then the built-in dev default.")] + public string? Registry { get; init; } +} diff --git a/src/Protostar.Cli/Commands/Auth/LoginCommand.cs b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs new file mode 100644 index 0000000..8d69bb3 --- /dev/null +++ b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs @@ -0,0 +1,162 @@ +using System.ComponentModel; +using Protostar.Cli.Auth; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Protostar.Cli.Commands.Auth; + +/// +/// protostar auth login — authenticates to the registry via the OAuth Authorization Code +/// flow with PKCE over a loopback redirect, then stores the resulting tokens in the OS credential +/// store. The actual sign-in happens in the browser (the registry federates it to GitHub). +/// +internal sealed class LoginCommand : Command +{ + public sealed class Settings : AuthSettings + { + [CommandOption("--no-browser")] + [Description("Print the sign-in URL instead of opening a browser automatically.")] + public bool NoBrowser { get; init; } + + [CommandOption("--timeout ")] + [Description("How long to wait for the browser sign-in to complete (default 300).")] + public int TimeoutSeconds { get; init; } = 300; + } + + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) => + RunAsync(settings, cancellation).GetAwaiter().GetResult(); + + private static async Task RunAsync(Settings settings, CancellationToken cancellation) + { + Uri registry; + try + { + registry = RegistryEndpoint.Resolve(settings.Registry); + } + catch (FormatException ex) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]"); + return 1; + } + + using var client = new RegistryClient(registry); + + // Fail fast on an unreachable or incompatible registry before opening a browser. + RegistryMeta? meta; + try + { + meta = await client.GetMetaAsync(cancellation); + } + catch (HttpRequestException ex) + { + AnsiConsole.MarkupLine($"[red]Could not reach the registry at[/] [grey]{Markup.Escape(registry.ToString())}[/]: {Markup.Escape(ex.Message)}"); + return 1; + } + + if (meta is null) + { + AnsiConsole.MarkupLine($"[red]The registry at[/] [grey]{Markup.Escape(registry.ToString())}[/] [red]did not respond to /v1/meta.[/]"); + return 1; + } + + if (ApiCompatibility.Check(meta) is { } incompatibility) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(incompatibility)}[/]"); + return 1; + } + + var verifier = Pkce.CreateVerifier(); + var challenge = Pkce.Challenge(verifier); + var state = Pkce.CreateState(); + + using var loopback = new LoopbackServer(); + var authorizeUrl = BuildAuthorizeUrl(registry, loopback.RedirectUri, challenge, state); + + if (!settings.NoBrowser && BrowserLauncher.TryOpen(authorizeUrl)) + { + AnsiConsole.MarkupLine("Opening your browser to sign in. Complete the sign-in there, then return here."); + } + else + { + AnsiConsole.MarkupLine("Open this URL to sign in:"); + // Write the URL raw (not through Spectre) so it is never word-wrapped and stays copy-pasteable. + Console.WriteLine(authorizeUrl); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Max(30, settings.TimeoutSeconds))); + + CallbackResult callback; + try + { + callback = await loopback.WaitForCallbackAsync(timeout.Token); + } + catch (OperationCanceledException) + { + AnsiConsole.MarkupLine("[red]Timed out waiting for the browser sign-in.[/]"); + return 1; + } + + if (callback.Error is not null) + { + var detail = callback.ErrorDescription is { Length: > 0 } d ? $": {d}" : "."; + AnsiConsole.MarkupLine($"[red]Sign-in failed ({Markup.Escape(callback.Error)}){Markup.Escape(detail)}[/]"); + return 1; + } + + if (!string.Equals(callback.State, state, StringComparison.Ordinal)) + { + AnsiConsole.MarkupLine("[red]State mismatch on the sign-in response (possible CSRF). Aborting.[/]"); + return 1; + } + + if (string.IsNullOrEmpty(callback.Code)) + { + AnsiConsole.MarkupLine("[red]The registry did not return an authorization code.[/]"); + return 1; + } + + var token = await client.ExchangeCodeAsync(callback.Code, verifier, loopback.RedirectUri, cancellation); + if (!token.IsSuccess) + { + var detail = token.ErrorDescription ?? token.Error ?? "the registry rejected the token request."; + AnsiConsole.MarkupLine($"[red]Token exchange failed:[/] {Markup.Escape(detail)}"); + return 1; + } + + var info = await client.GetUserInfoAsync(token.AccessToken!, cancellation); + var login = info?.PreferredUsername ?? info?.GitHubLogin; + + new TokenStore().Save(new StoredToken + { + Registry = RegistryEndpoint.CredentialKey(registry), + AccessToken = token.AccessToken!, + RefreshToken = token.RefreshToken, + ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(token.ExpiresIn), + Subject = info?.Sub, + Login = login, + Name = info?.Name, + }); + + var who = login is { Length: > 0 } ? $" as [aqua]{Markup.Escape(login)}[/]" : string.Empty; + AnsiConsole.MarkupLine($"[green]Signed in[/] to [grey]{Markup.Escape(registry.GetLeftPart(UriPartial.Authority))}[/]{who}."); + return 0; + } + + private static string BuildAuthorizeUrl(Uri registry, string redirectUri, string challenge, string state) + { + var query = new Dictionary + { + ["response_type"] = "code", + ["client_id"] = AuthConstants.ClientId, + ["redirect_uri"] = redirectUri, + ["scope"] = AuthConstants.Scopes, + ["code_challenge"] = challenge, + ["code_challenge_method"] = "S256", + ["state"] = state, + }; + + var encoded = string.Join('&', query.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); + return new Uri(registry, "/connect/authorize") + "?" + encoded; + } +} diff --git a/src/Protostar.Cli/Commands/Auth/LogoutCommand.cs b/src/Protostar.Cli/Commands/Auth/LogoutCommand.cs new file mode 100644 index 0000000..5d3011e --- /dev/null +++ b/src/Protostar.Cli/Commands/Auth/LogoutCommand.cs @@ -0,0 +1,41 @@ +using Protostar.Cli.Auth; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Protostar.Cli.Commands.Auth; + +/// +/// protostar auth logout — removes the stored session for the registry from the OS +/// credential store. +/// +internal sealed class LogoutCommand : Command +{ + public sealed class Settings : AuthSettings; + + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) + { + Uri registry; + try + { + registry = RegistryEndpoint.Resolve(settings.Registry); + } + catch (FormatException ex) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]"); + return 1; + } + + var authority = registry.GetLeftPart(UriPartial.Authority); + var store = new TokenStore(); + + if (store.Load(registry) is null) + { + AnsiConsole.MarkupLine($"Not logged in to [grey]{Markup.Escape(authority)}[/]."); + return 0; + } + + store.Delete(registry); + AnsiConsole.MarkupLine($"[green]Signed out[/] of [grey]{Markup.Escape(authority)}[/]."); + return 0; + } +} diff --git a/src/Protostar.Cli/Commands/Auth/StatusCommand.cs b/src/Protostar.Cli/Commands/Auth/StatusCommand.cs new file mode 100644 index 0000000..b910a77 --- /dev/null +++ b/src/Protostar.Cli/Commands/Auth/StatusCommand.cs @@ -0,0 +1,91 @@ +using Protostar.Cli.Auth; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Protostar.Cli.Commands.Auth; + +/// +/// protostar auth status — reports whether there is a stored session for the registry and, +/// if reachable, verifies it by calling the userinfo endpoint (refreshing an expired access token +/// when possible). Works offline: with no stored session it simply reports "Not logged in". +/// +internal sealed class StatusCommand : Command +{ + public sealed class Settings : AuthSettings; + + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) => + RunAsync(settings, cancellation).GetAwaiter().GetResult(); + + private static async Task RunAsync(Settings settings, CancellationToken cancellation) + { + Uri registry; + try + { + registry = RegistryEndpoint.Resolve(settings.Registry); + } + catch (FormatException ex) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]"); + return 1; + } + + var authority = registry.GetLeftPart(UriPartial.Authority); + var store = new TokenStore(); + var stored = store.Load(registry); + + if (stored is null) + { + AnsiConsole.MarkupLine($"Not logged in to [grey]{Markup.Escape(authority)}[/]."); + return 0; + } + + using var client = new RegistryClient(registry); + + // Refresh a stale access token if we can, so a verified status survives short sessions. + if (stored.IsExpired && stored.RefreshToken is { Length: > 0 } refreshToken) + { + try + { + var refreshed = await client.RefreshAsync(refreshToken, cancellation); + if (refreshed.IsSuccess) + { + stored = stored with + { + AccessToken = refreshed.AccessToken!, + RefreshToken = refreshed.RefreshToken ?? stored.RefreshToken, + ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(refreshed.ExpiresIn), + }; + store.Save(stored); + } + } + catch (HttpRequestException) + { + // Fall through to the offline view below. + } + } + + UserInfo? info = null; + try + { + info = await client.GetUserInfoAsync(stored.AccessToken, cancellation); + } + catch (HttpRequestException) + { + // Registry unreachable; show the stored identity instead. + } + + var login = info?.PreferredUsername ?? info?.GitHubLogin ?? stored.Login ?? "(unknown)"; + + if (info is not null) + { + AnsiConsole.MarkupLine($"[green]Logged in[/] to [grey]{Markup.Escape(authority)}[/] as [aqua]{Markup.Escape(login)}[/]."); + } + else + { + AnsiConsole.MarkupLine($"[yellow]Logged in[/] to [grey]{Markup.Escape(authority)}[/] as [aqua]{Markup.Escape(login)}[/] " + + "[grey](could not verify with the registry; it may be unreachable or the session expired).[/]"); + } + + return 0; + } +} diff --git a/src/Protostar.Cli/Program.cs b/src/Protostar.Cli/Program.cs index 860ee90..271eb64 100644 --- a/src/Protostar.Cli/Program.cs +++ b/src/Protostar.Cli/Program.cs @@ -1,5 +1,6 @@ using Protostar.Cli; using Protostar.Cli.Commands; +using Protostar.Cli.Commands.Auth; using Spectre.Console.Cli; // Spectre.Console.Cli command app. `protostar` runs DefaultCommand; `--version`/`-v` and `--help` @@ -14,6 +15,17 @@ .WithDescription("Install protostar to a per-user directory and add it to PATH."); config.AddCommand("uninstall") .WithDescription("Remove an installed protostar binary."); + + config.AddBranch("auth", auth => + { + auth.SetDescription("Authenticate to the protostar registry."); + auth.AddCommand("login") + .WithDescription("Sign in to the registry via your browser and store the session."); + auth.AddCommand("logout") + .WithDescription("Remove the stored session for the registry."); + auth.AddCommand("status") + .WithDescription("Show whether you are signed in to the registry."); + }); }); return app.Run(args); diff --git a/src/Protostar.Cli/Protostar.Cli.csproj b/src/Protostar.Cli/Protostar.Cli.csproj index 11f646c..95445bc 100644 --- a/src/Protostar.Cli/Protostar.Cli.csproj +++ b/src/Protostar.Cli/Protostar.Cli.csproj @@ -13,6 +13,7 @@ + diff --git a/test/Protostar.Cli.Acceptance/Features/Auth.feature b/test/Protostar.Cli.Acceptance/Features/Auth.feature new file mode 100644 index 0000000..a0b375c --- /dev/null +++ b/test/Protostar.Cli.Acceptance/Features/Auth.feature @@ -0,0 +1,16 @@ +Feature: Authenticate to the registry + As an operator + I want protostar auth commands + So that I can sign in to the registry as an identified user + + Scenario: auth --help lists the subcommands + When I run protostar with "auth --help" + Then the exit code is 0 + And the output contains "login" + And the output contains "logout" + And the output contains "status" + + Scenario: status reports not logged in when there is no stored session + When I run protostar with "auth status --registry https://unused.invalid" + Then the exit code is 0 + And the output contains "Not logged in" From 44b024d57b2b4f887164b17daa7d18dfa4c43df4 Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Tue, 2 Jun 2026 00:31:48 -0500 Subject: [PATCH 2/7] fix: degrade gracefully when the OS credential store is unavailable --- src/Protostar.Cli/Auth/TokenStore.cs | 58 ++++++++++++++----- .../Commands/Auth/LoginCommand.cs | 8 ++- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/Protostar.Cli/Auth/TokenStore.cs b/src/Protostar.Cli/Auth/TokenStore.cs index 3ab4b65..bb31131 100644 --- a/src/Protostar.Cli/Auth/TokenStore.cs +++ b/src/Protostar.Cli/Auth/TokenStore.cs @@ -6,30 +6,62 @@ namespace Protostar.Cli.Auth; /// /// Reads and writes sessions in the OS credential store (Windows /// Credential Manager / macOS Keychain / Linux Secret Service) via Devlooped.CredentialManager. +/// Degrades gracefully when no backend is available (e.g. a headless Linux box with no Secret +/// Service): reads report "no session" rather than throwing, and returns false. /// internal sealed class TokenStore { - private readonly ICredentialStore _store = CredentialManager.Create(AuthConstants.CredentialService); + private readonly Lazy _store = new(CreateStore); - public void Save(StoredToken token) + private static ICredentialStore? CreateStore() { - var json = JsonSerializer.Serialize(token, AuthJson.Default); - _store.AddOrUpdate( - RegistryEndpoint.CredentialKey(new Uri(token.Registry)), - AuthConstants.CredentialAccount, - json); + try { return CredentialManager.Create(AuthConstants.CredentialService); } + catch { return null; } + } + + /// Persists the session. Returns false if the credential backend is unavailable. + public bool Save(StoredToken token) + { + if (_store.Value is not { } store) + return false; + + try + { + store.AddOrUpdate( + RegistryEndpoint.CredentialKey(new Uri(token.Registry)), + AuthConstants.CredentialAccount, + JsonSerializer.Serialize(token, AuthJson.Default)); + return true; + } + catch + { + return false; + } } public StoredToken? Load(Uri registry) { - var secret = _store.Get(RegistryEndpoint.CredentialKey(registry), AuthConstants.CredentialAccount)?.Password; - if (string.IsNullOrEmpty(secret)) + if (_store.Value is not { } store) return null; - try { return JsonSerializer.Deserialize(secret, AuthJson.Default); } - catch (JsonException) { return null; } + try + { + var secret = store.Get(RegistryEndpoint.CredentialKey(registry), AuthConstants.CredentialAccount)?.Password; + return string.IsNullOrEmpty(secret) ? null : JsonSerializer.Deserialize(secret, AuthJson.Default); + } + catch + { + // Backend unavailable or unreadable payload: treat as no stored session. + return null; + } } - public void Delete(Uri registry) => - _store.Remove(RegistryEndpoint.CredentialKey(registry), AuthConstants.CredentialAccount); + public void Delete(Uri registry) + { + if (_store.Value is not { } store) + return; + + try { store.Remove(RegistryEndpoint.CredentialKey(registry), AuthConstants.CredentialAccount); } + catch { /* backend unavailable or nothing to remove */ } + } } diff --git a/src/Protostar.Cli/Commands/Auth/LoginCommand.cs b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs index 8d69bb3..411de8f 100644 --- a/src/Protostar.Cli/Commands/Auth/LoginCommand.cs +++ b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs @@ -127,7 +127,7 @@ private static async Task RunAsync(Settings settings, CancellationToken can var info = await client.GetUserInfoAsync(token.AccessToken!, cancellation); var login = info?.PreferredUsername ?? info?.GitHubLogin; - new TokenStore().Save(new StoredToken + var saved = new TokenStore().Save(new StoredToken { Registry = RegistryEndpoint.CredentialKey(registry), AccessToken = token.AccessToken!, @@ -138,6 +138,12 @@ private static async Task RunAsync(Settings settings, CancellationToken can Name = info?.Name, }); + if (!saved) + { + AnsiConsole.MarkupLine("[red]Signed in, but could not save the session to the OS credential store.[/]"); + return 1; + } + var who = login is { Length: > 0 } ? $" as [aqua]{Markup.Escape(login)}[/]" : string.Empty; AnsiConsole.MarkupLine($"[green]Signed in[/] to [grey]{Markup.Escape(registry.GetLeftPart(UriPartial.Authority))}[/]{who}."); return 0; From 2343a103086b653be16658891b8282e1ec196a22 Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Tue, 2 Jun 2026 00:48:15 -0500 Subject: [PATCH 3/7] feat: add --provider to skip the registry sign-in chooser --- README.md | 3 +++ src/Protostar.Cli/Commands/Auth/LoginCommand.cs | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9049353..5d1fd81 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,9 @@ $ protostar auth logout Signed out of https://registry.example. ``` +By default the registry shows a sign-in chooser so you can pick how to authenticate. Pass +`--provider ` (e.g. `--provider github`) to skip the chooser and go straight to that provider. + Point the CLI at a registry with `--registry ` or the `PROTOSTAR_REGISTRY_URL` environment variable. Use `--no-browser` on a headless machine to print the sign-in URL instead of opening a browser. The CLI checks API compatibility with the registry on connect and refuses to proceed diff --git a/src/Protostar.Cli/Commands/Auth/LoginCommand.cs b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs index 411de8f..6ba064b 100644 --- a/src/Protostar.Cli/Commands/Auth/LoginCommand.cs +++ b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs @@ -14,6 +14,10 @@ internal sealed class LoginCommand : Command { public sealed class Settings : AuthSettings { + [CommandOption("--provider ")] + [Description("Skip the registry's sign-in chooser and go straight to this provider (e.g. github).")] + public string? Provider { get; init; } + [CommandOption("--no-browser")] [Description("Print the sign-in URL instead of opening a browser automatically.")] public bool NoBrowser { get; init; } @@ -70,7 +74,7 @@ private static async Task RunAsync(Settings settings, CancellationToken can var state = Pkce.CreateState(); using var loopback = new LoopbackServer(); - var authorizeUrl = BuildAuthorizeUrl(registry, loopback.RedirectUri, challenge, state); + var authorizeUrl = BuildAuthorizeUrl(registry, loopback.RedirectUri, challenge, state, settings.Provider); if (!settings.NoBrowser && BrowserLauncher.TryOpen(authorizeUrl)) { @@ -149,7 +153,7 @@ private static async Task RunAsync(Settings settings, CancellationToken can return 0; } - private static string BuildAuthorizeUrl(Uri registry, string redirectUri, string challenge, string state) + private static string BuildAuthorizeUrl(Uri registry, string redirectUri, string challenge, string state, string? provider) { var query = new Dictionary { @@ -162,6 +166,10 @@ private static string BuildAuthorizeUrl(Uri registry, string redirectUri, string ["state"] = state, }; + // A provider hint lets the registry skip its chooser and forward straight to that provider. + if (!string.IsNullOrWhiteSpace(provider)) + query["identity_provider"] = provider.Trim(); + var encoded = string.Join('&', query.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); return new Uri(registry, "/connect/authorize") + "?" + encoded; } From 76b714edeafd8cef866fc671bedd9d334372db9b Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Tue, 2 Jun 2026 09:48:47 -0500 Subject: [PATCH 4/7] fix: friendly error when --registry points at a non-registry (non-JSON) URL --- src/Protostar.Cli/Auth/RegistryClient.cs | 30 +++++++++++++++---- .../Commands/Auth/LoginCommand.cs | 3 +- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/Protostar.Cli/Auth/RegistryClient.cs b/src/Protostar.Cli/Auth/RegistryClient.cs index b6d78dd..37ad1d1 100644 --- a/src/Protostar.Cli/Auth/RegistryClient.cs +++ b/src/Protostar.Cli/Auth/RegistryClient.cs @@ -19,7 +19,7 @@ internal sealed class RegistryClient(Uri registry) : IDisposable { using var response = await _http.GetAsync("/v1/meta", cancellationToken); return response.IsSuccessStatusCode - ? await response.Content.ReadFromJsonAsync(AuthJson.Default, cancellationToken) + ? await ReadJsonAsync(response, cancellationToken) : null; } @@ -47,7 +47,7 @@ public Task RefreshAsync(string refreshToken, CancellationToken c request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); using var response = await _http.SendAsync(request, cancellationToken); return response.IsSuccessStatusCode - ? await response.Content.ReadFromJsonAsync(AuthJson.Default, cancellationToken) + ? await ReadJsonAsync(response, cancellationToken) : null; } @@ -56,9 +56,29 @@ private async Task PostTokenAsync(Dictionary form using var content = new FormUrlEncodedContent(form); using var response = await _http.PostAsync("/connect/token", content, cancellationToken); - var payload = await response.Content.ReadFromJsonAsync(AuthJson.Default, cancellationToken) - ?? new TokenResponse { Error = "invalid_response", ErrorDescription = "The registry returned no token payload." }; - return payload; + return await ReadJsonAsync(response, cancellationToken) + ?? new TokenResponse + { + Error = "invalid_response", + ErrorDescription = "The registry returned an unexpected (non-JSON) response.", + }; + } + + // Parses a JSON body, returning default when the response isn't JSON (e.g. an HTML error page + // from pointing at the wrong URL) instead of throwing a cryptic parser exception. + private static async Task ReadJsonAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentType?.MediaType is not "application/json") + return default; + + try + { + return await response.Content.ReadFromJsonAsync(AuthJson.Default, cancellationToken); + } + catch (JsonException) + { + return default; + } } public void Dispose() => _http.Dispose(); diff --git a/src/Protostar.Cli/Commands/Auth/LoginCommand.cs b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs index 6ba064b..bbb2d9e 100644 --- a/src/Protostar.Cli/Commands/Auth/LoginCommand.cs +++ b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs @@ -59,7 +59,8 @@ private static async Task RunAsync(Settings settings, CancellationToken can if (meta is null) { - AnsiConsole.MarkupLine($"[red]The registry at[/] [grey]{Markup.Escape(registry.ToString())}[/] [red]did not respond to /v1/meta.[/]"); + AnsiConsole.MarkupLine($"[red]{Markup.Escape(registry.GetLeftPart(UriPartial.Authority))} is not a protostar registry[/] (no valid /v1/meta response)."); + AnsiConsole.MarkupLine("[grey]If the registry runs under .NET Aspire, use the 'api' resource URL from the dashboard, not the dashboard URL.[/]"); return 1; } From 7e13cff58efaec2c7d6cb961a3749105a6cc3f05 Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Tue, 2 Jun 2026 10:35:43 -0500 Subject: [PATCH 5/7] feat: default the registry URL to the pinned dev port (7443) --- src/Protostar.Cli/Auth/AuthConstants.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Protostar.Cli/Auth/AuthConstants.cs b/src/Protostar.Cli/Auth/AuthConstants.cs index d9d97cf..26e04ce 100644 --- a/src/Protostar.Cli/Auth/AuthConstants.cs +++ b/src/Protostar.Cli/Auth/AuthConstants.cs @@ -17,8 +17,9 @@ internal static class AuthConstants public const string RegistryEnvVar = "PROTOSTAR_REGISTRY_URL"; - // Dev default. Override with --registry or PROTOSTAR_REGISTRY_URL until a registry is deployed. - public const string DefaultRegistryUrl = "https://localhost:5099"; + // Dev default: the registry's pinned local Aspire port. Override with --registry or + // PROTOSTAR_REGISTRY_URL (this becomes the deployed URL once a registry is hosted). + public const string DefaultRegistryUrl = "https://localhost:7443"; // Credential storage. Tokens are keyed by the registry's authority (a URI), so logging into // different registries keeps separate sessions. From 65e2cc131ede4b0b0d79c9bbded6b99feadd99fd Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Tue, 2 Jun 2026 11:08:27 -0500 Subject: [PATCH 6/7] feat: store credentials in ~/.protostar/credentials.json instead of the OS keychain The Windows Credential Manager caps a credential blob at 2560 bytes, which our JWT access token + refresh token exceeds, so saves failed. Switch to an owner-only file store (0600 on Unix, profile ACL on Windows), overridable via PROTOSTAR_CONFIG_DIR. Drops the Devlooped.CredentialManager dependency. --- README.md | 3 + src/Protostar.Cli/Auth/CredentialFile.cs | 59 ++++++++++ src/Protostar.Cli/Auth/ProtostarPaths.cs | 23 ++++ src/Protostar.Cli/Auth/StoredToken.cs | 5 +- src/Protostar.Cli/Auth/TokenStore.cs | 59 ++++------ src/Protostar.Cli/Protostar.Cli.csproj | 5 +- .../CredentialStoreTests.cs | 101 ++++++++++++++++++ .../Support/CliRunner.cs | 10 ++ 8 files changed, 222 insertions(+), 43 deletions(-) create mode 100644 src/Protostar.Cli/Auth/CredentialFile.cs create mode 100644 src/Protostar.Cli/Auth/ProtostarPaths.cs create mode 100644 test/Protostar.Cli.Acceptance/CredentialStoreTests.cs diff --git a/README.md b/README.md index 5d1fd81..c6a3f0b 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ variable. Use `--no-browser` on a headless machine to print the sign-in URL inst browser. The CLI checks API compatibility with the registry on connect and refuses to proceed against an unsupported major version. +Sessions are stored in `~/.protostar/credentials.json` (owner-only permissions: `0600` on +Unix, the per-user profile ACL on Windows). Override the directory with `PROTOSTAR_CONFIG_DIR`. + ## Build from source Requires the .NET 10 SDK. diff --git a/src/Protostar.Cli/Auth/CredentialFile.cs b/src/Protostar.Cli/Auth/CredentialFile.cs new file mode 100644 index 0000000..3ed7cf6 --- /dev/null +++ b/src/Protostar.Cli/Auth/CredentialFile.cs @@ -0,0 +1,59 @@ +using System.Text.Json; + +namespace Protostar.Cli.Auth; + +/// +/// The on-disk credential store: a single JSON file under , +/// holding one session per registry (keyed by registry authority). Written atomically (temp file + +/// move) and locked down to the owner (0600 file, 0700 dir) on Unix; on Windows it relies on the +/// per-user profile ACL. Tokens are plaintext at rest, protected by filesystem permissions. +/// +internal sealed class CredentialFile +{ + public Dictionary Registries { get; set; } = new(StringComparer.Ordinal); + + public static CredentialFile Read() + { + var path = ProtostarPaths.CredentialsFile(); + if (!File.Exists(path)) + return new CredentialFile(); + + try + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, AuthJson.Default) ?? new CredentialFile(); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + // A missing/corrupt/unreadable file is treated as no stored sessions. + return new CredentialFile(); + } + } + + public void Write() + { + var dir = ProtostarPaths.ConfigDir(); + Directory.CreateDirectory(dir); + RestrictToOwner(dir, isDirectory: true); + + var path = ProtostarPaths.CredentialsFile(); + var temp = path + ".tmp-" + Guid.NewGuid().ToString("N"); + + File.WriteAllText(temp, JsonSerializer.Serialize(this, AuthJson.Default)); + RestrictToOwner(temp, isDirectory: false); + + // Atomic replace so a crash mid-write can never leave a corrupt store. + File.Move(temp, path, overwrite: true); + } + + private static void RestrictToOwner(string path, bool isDirectory) + { + if (OperatingSystem.IsWindows()) + return; // Protected by the per-user profile ACL. + + var mode = isDirectory + ? UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + : UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(path, mode); + } +} diff --git a/src/Protostar.Cli/Auth/ProtostarPaths.cs b/src/Protostar.Cli/Auth/ProtostarPaths.cs new file mode 100644 index 0000000..0b61138 --- /dev/null +++ b/src/Protostar.Cli/Auth/ProtostarPaths.cs @@ -0,0 +1,23 @@ +namespace Protostar.Cli.Auth; + +/// +/// Resolves protostar's per-user config directory. Defaults to ~/.protostar (the +/// .aws/.kube convention), overridable with PROTOSTAR_CONFIG_DIR so tests and +/// power users can redirect it away from the real home. +/// +internal static class ProtostarPaths +{ + public const string ConfigDirEnvVar = "PROTOSTAR_CONFIG_DIR"; + + public static string ConfigDir() + { + var overridden = Environment.GetEnvironmentVariable(ConfigDirEnvVar); + if (!string.IsNullOrWhiteSpace(overridden)) + return overridden; + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".protostar"); + } + + public static string CredentialsFile() => Path.Combine(ConfigDir(), "credentials.json"); +} diff --git a/src/Protostar.Cli/Auth/StoredToken.cs b/src/Protostar.Cli/Auth/StoredToken.cs index 67eadf2..3e9b153 100644 --- a/src/Protostar.Cli/Auth/StoredToken.cs +++ b/src/Protostar.Cli/Auth/StoredToken.cs @@ -1,7 +1,9 @@ +using System.Text.Json.Serialization; + namespace Protostar.Cli.Auth; /// -/// The persisted session for one registry, stored as JSON in the OS credential store. +/// The persisted session for one registry, stored as JSON in ~/.protostar/credentials.json. /// internal sealed record StoredToken { @@ -14,5 +16,6 @@ internal sealed record StoredToken public string? Name { get; init; } /// True once the access token is within 30s of expiry (treat as needing refresh). + [JsonIgnore] public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAtUtc.AddSeconds(-30); } diff --git a/src/Protostar.Cli/Auth/TokenStore.cs b/src/Protostar.Cli/Auth/TokenStore.cs index bb31131..5c129ec 100644 --- a/src/Protostar.Cli/Auth/TokenStore.cs +++ b/src/Protostar.Cli/Auth/TokenStore.cs @@ -1,67 +1,44 @@ -using System.Text.Json; -using GitCredentialManager; - namespace Protostar.Cli.Auth; /// -/// Reads and writes sessions in the OS credential store (Windows -/// Credential Manager / macOS Keychain / Linux Secret Service) via Devlooped.CredentialManager. -/// Degrades gracefully when no backend is available (e.g. a headless Linux box with no Secret -/// Service): reads report "no session" rather than throwing, and returns false. +/// Reads and writes sessions in the on-disk +/// (~/.protostar/credentials.json), one per registry. Degrades gracefully when the file +/// can't be written: returns false rather than throwing. /// internal sealed class TokenStore { - private readonly Lazy _store = new(CreateStore); - - private static ICredentialStore? CreateStore() - { - try { return CredentialManager.Create(AuthConstants.CredentialService); } - catch { return null; } - } - - /// Persists the session. Returns false if the credential backend is unavailable. + /// Persists the session. Returns false if the store can't be written. public bool Save(StoredToken token) { - if (_store.Value is not { } store) - return false; - try { - store.AddOrUpdate( - RegistryEndpoint.CredentialKey(new Uri(token.Registry)), - AuthConstants.CredentialAccount, - JsonSerializer.Serialize(token, AuthJson.Default)); + var file = CredentialFile.Read(); + file.Registries[Key(new Uri(token.Registry))] = token; + file.Write(); return true; } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) { return false; } } - public StoredToken? Load(Uri registry) - { - if (_store.Value is not { } store) - return null; + public StoredToken? Load(Uri registry) => + CredentialFile.Read().Registries.TryGetValue(Key(registry), out var token) ? token : null; + public void Delete(Uri registry) + { try { - var secret = store.Get(RegistryEndpoint.CredentialKey(registry), AuthConstants.CredentialAccount)?.Password; - return string.IsNullOrEmpty(secret) ? null : JsonSerializer.Deserialize(secret, AuthJson.Default); + var file = CredentialFile.Read(); + if (file.Registries.Remove(Key(registry))) + file.Write(); } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - // Backend unavailable or unreadable payload: treat as no stored session. - return null; + // Nothing persisted / unwritable: treat as already gone. } } - public void Delete(Uri registry) - { - if (_store.Value is not { } store) - return; - - try { store.Remove(RegistryEndpoint.CredentialKey(registry), AuthConstants.CredentialAccount); } - catch { /* backend unavailable or nothing to remove */ } - } + private static string Key(Uri registry) => RegistryEndpoint.CredentialKey(registry); } diff --git a/src/Protostar.Cli/Protostar.Cli.csproj b/src/Protostar.Cli/Protostar.Cli.csproj index 95445bc..8d44eda 100644 --- a/src/Protostar.Cli/Protostar.Cli.csproj +++ b/src/Protostar.Cli/Protostar.Cli.csproj @@ -13,9 +13,12 @@ - + + + + diff --git a/test/Protostar.Cli.Acceptance/CredentialStoreTests.cs b/test/Protostar.Cli.Acceptance/CredentialStoreTests.cs new file mode 100644 index 0000000..2f43453 --- /dev/null +++ b/test/Protostar.Cli.Acceptance/CredentialStoreTests.cs @@ -0,0 +1,101 @@ +using Protostar.Cli.Auth; +using Xunit; + +namespace Protostar.Cli.Acceptance; + +/// +/// In-process tests for the file-based credential store. Each test redirects +/// PROTOSTAR_CONFIG_DIR at a throwaway temp folder, so nothing touches the real +/// ~/.protostar. Tests within a class run sequentially in xUnit, so the process-global env +/// var is safe here. +/// +public sealed class CredentialStoreTests : IDisposable +{ + private readonly string _dir; + private readonly string? _previous; + + public CredentialStoreTests() + { + _dir = Path.Combine(Path.GetTempPath(), "protostar-credtest-" + Guid.NewGuid().ToString("N")); + _previous = Environment.GetEnvironmentVariable(ProtostarPaths.ConfigDirEnvVar); + Environment.SetEnvironmentVariable(ProtostarPaths.ConfigDirEnvVar, _dir); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(ProtostarPaths.ConfigDirEnvVar, _previous); + try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ } + } + + [Fact] + public void Save_then_load_round_trips_a_large_token() + { + var store = new TokenStore(); + var registry = new Uri("https://localhost:7443"); + var token = new StoredToken + { + Registry = "https://localhost:7443", + // Deliberately larger than the Windows Credential Manager blob limit (2560 bytes) that + // the old OS-keychain store choked on. + AccessToken = new string('a', 4000), + RefreshToken = "refresh-xyz", + ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1), + Login = "alice", + Name = "Alice", + Subject = "user-1", + }; + + Assert.True(store.Save(token)); + + var loaded = store.Load(registry); + Assert.NotNull(loaded); + Assert.Equal(token.AccessToken, loaded!.AccessToken); + Assert.Equal("alice", loaded.Login); + Assert.True(File.Exists(ProtostarPaths.CredentialsFile())); + } + + [Fact] + public void Delete_removes_the_session() + { + var store = new TokenStore(); + var registry = new Uri("https://localhost:7443"); + store.Save(new StoredToken + { + Registry = "https://localhost:7443", + AccessToken = "t", + ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1), + }); + + store.Delete(registry); + + Assert.Null(store.Load(registry)); + } + + [Fact] + public void Sessions_for_different_registries_are_independent() + { + var store = new TokenStore(); + store.Save(new StoredToken { Registry = "https://a.example", AccessToken = "ta", ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1), Login = "a" }); + store.Save(new StoredToken { Registry = "https://b.example", AccessToken = "tb", ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1), Login = "b" }); + + Assert.Equal("a", store.Load(new Uri("https://a.example"))!.Login); + Assert.Equal("b", store.Load(new Uri("https://b.example"))!.Login); + } + + [Fact] + public void Credentials_file_is_owner_only_on_unix() + { + if (OperatingSystem.IsWindows()) + return; // Windows relies on the per-user profile ACL, not Unix file modes. + + new TokenStore().Save(new StoredToken + { + Registry = "https://localhost:7443", + AccessToken = "t", + ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1), + }); + + var mode = File.GetUnixFileMode(ProtostarPaths.CredentialsFile()); + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode); + } +} diff --git a/test/Protostar.Cli.Acceptance/Support/CliRunner.cs b/test/Protostar.Cli.Acceptance/Support/CliRunner.cs index 8c61ba8..2cdb512 100644 --- a/test/Protostar.Cli.Acceptance/Support/CliRunner.cs +++ b/test/Protostar.Cli.Acceptance/Support/CliRunner.cs @@ -11,6 +11,15 @@ public static class CliRunner { private static readonly Lazy LazyBinary = new(ResolveBinary); + // Redirect protostar's config dir at a throwaway temp folder so acceptance runs never read or + // write the real ~/.protostar (credentials, etc.). + private static readonly Lazy LazyConfigDir = new(() => + { + var dir = Path.Combine(Path.GetTempPath(), "protostar-accept-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + }); + /// Absolute path to the protostar binary under test. public static string BinaryPath => LazyBinary.Value; @@ -18,6 +27,7 @@ public static async Task RunAsync(IEnumerable arg { return await CliWrap.Cli.Wrap(BinaryPath) .WithArguments(args) + .WithEnvironmentVariables(env => env.Set("PROTOSTAR_CONFIG_DIR", LazyConfigDir.Value).Build()) // Non-zero exits are expected in some scenarios; assert on them rather than throwing. .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(); From 4fa26f4889cd802a4724b8c4813c5824a8fb1a23 Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Tue, 2 Jun 2026 17:04:19 -0500 Subject: [PATCH 7/7] refactor: use AsyncCommand for the async auth commands (PR review) Login and Status do genuinely async work; AsyncCommand drops the sync-over-async GetAwaiter().GetResult() hop and cancellation flows through ExecuteAsync. Logout stays a sync Command (file IO only). --- src/Protostar.Cli/Commands/Auth/LoginCommand.cs | 6 +++--- src/Protostar.Cli/Commands/Auth/StatusCommand.cs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Protostar.Cli/Commands/Auth/LoginCommand.cs b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs index bbb2d9e..672f00f 100644 --- a/src/Protostar.Cli/Commands/Auth/LoginCommand.cs +++ b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs @@ -10,7 +10,7 @@ namespace Protostar.Cli.Commands.Auth; /// flow with PKCE over a loopback redirect, then stores the resulting tokens in the OS credential /// store. The actual sign-in happens in the browser (the registry federates it to GitHub). /// -internal sealed class LoginCommand : Command +internal sealed class LoginCommand : AsyncCommand { public sealed class Settings : AuthSettings { @@ -27,8 +27,8 @@ public sealed class Settings : AuthSettings public int TimeoutSeconds { get; init; } = 300; } - protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) => - RunAsync(settings, cancellation).GetAwaiter().GetResult(); + protected override Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellation) => + RunAsync(settings, cancellation); private static async Task RunAsync(Settings settings, CancellationToken cancellation) { diff --git a/src/Protostar.Cli/Commands/Auth/StatusCommand.cs b/src/Protostar.Cli/Commands/Auth/StatusCommand.cs index b910a77..215f381 100644 --- a/src/Protostar.Cli/Commands/Auth/StatusCommand.cs +++ b/src/Protostar.Cli/Commands/Auth/StatusCommand.cs @@ -9,12 +9,12 @@ namespace Protostar.Cli.Commands.Auth; /// if reachable, verifies it by calling the userinfo endpoint (refreshing an expired access token /// when possible). Works offline: with no stored session it simply reports "Not logged in". /// -internal sealed class StatusCommand : Command +internal sealed class StatusCommand : AsyncCommand { public sealed class Settings : AuthSettings; - protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) => - RunAsync(settings, cancellation).GetAwaiter().GetResult(); + protected override Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellation) => + RunAsync(settings, cancellation); private static async Task RunAsync(Settings settings, CancellationToken cancellation) {