diff --git a/README.md b/README.md index 2599477..c6a3f0b 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,36 @@ $ 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. +``` + +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 +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/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..26e04ce --- /dev/null +++ b/src/Protostar.Cli/Auth/AuthConstants.cs @@ -0,0 +1,28 @@ +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: 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. + 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/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/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/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/RegistryClient.cs b/src/Protostar.Cli/Auth/RegistryClient.cs new file mode 100644 index 0000000..37ad1d1 --- /dev/null +++ b/src/Protostar.Cli/Auth/RegistryClient.cs @@ -0,0 +1,122 @@ +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 ReadJsonAsync(response, 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 ReadJsonAsync(response, 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); + + 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(); +} + +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..3e9b153 --- /dev/null +++ b/src/Protostar.Cli/Auth/StoredToken.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Protostar.Cli.Auth; + +/// +/// The persisted session for one registry, stored as JSON in ~/.protostar/credentials.json. +/// +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). + [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 new file mode 100644 index 0000000..5c129ec --- /dev/null +++ b/src/Protostar.Cli/Auth/TokenStore.cs @@ -0,0 +1,44 @@ +namespace Protostar.Cli.Auth; + +/// +/// 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 +{ + /// Persists the session. Returns false if the store can't be written. + public bool Save(StoredToken token) + { + try + { + var file = CredentialFile.Read(); + file.Registries[Key(new Uri(token.Registry))] = token; + file.Write(); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + return false; + } + } + + public StoredToken? Load(Uri registry) => + CredentialFile.Read().Registries.TryGetValue(Key(registry), out var token) ? token : null; + + public void Delete(Uri registry) + { + try + { + var file = CredentialFile.Read(); + if (file.Registries.Remove(Key(registry))) + file.Write(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Nothing persisted / unwritable: treat as already gone. + } + } + + private static string Key(Uri registry) => RegistryEndpoint.CredentialKey(registry); +} 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..672f00f --- /dev/null +++ b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs @@ -0,0 +1,177 @@ +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 : AsyncCommand +{ + 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; } + + [CommandOption("--timeout ")] + [Description("How long to wait for the browser sign-in to complete (default 300).")] + public int TimeoutSeconds { get; init; } = 300; + } + + protected override Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellation) => + RunAsync(settings, cancellation); + + 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]{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; + } + + 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, settings.Provider); + + 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; + + var saved = 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, + }); + + 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; + } + + private static string BuildAuthorizeUrl(Uri registry, string redirectUri, string challenge, string state, string? provider) + { + 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, + }; + + // 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; + } +} 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..215f381 --- /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 : AsyncCommand +{ + public sealed class Settings : AuthSettings; + + protected override Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellation) => + RunAsync(settings, cancellation); + + 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..8d44eda 100644 --- a/src/Protostar.Cli/Protostar.Cli.csproj +++ b/src/Protostar.Cli/Protostar.Cli.csproj @@ -17,4 +17,8 @@ + + + + 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/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" 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();