Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` (e.g. `--provider github`) to skip the chooser and go straight to that provider.

Point the CLI at a registry with `--registry <url>` 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.
Expand Down
25 changes: 25 additions & 0 deletions src/Protostar.Cli/Auth/ApiCompatibility.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Protostar.Cli.Auth;

/// <summary>
/// The CLI/registry compatibility contract. Versions are not lockstepped; instead the registry
/// advertises the API majors it supports at <c>/v1/meta</c> and the CLI checks that the major it
/// speaks is among them before doing anything else.
/// </summary>
internal static class ApiCompatibility
{
/// <summary>Returns null when compatible, otherwise a human-readable explanation.</summary>
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;
}
}
28 changes: 28 additions & 0 deletions src/Protostar.Cli/Auth/AuthConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Protostar.Cli.Auth;

/// <summary>
/// Shared constants for the OAuth loopback flow against the protostar registry. These mirror what
/// the registry seeds for the <c>protostar-cli</c> public client (client id, callback path, scopes).
/// </summary>
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";
}
26 changes: 26 additions & 0 deletions src/Protostar.Cli/Auth/BrowserLauncher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Diagnostics;

namespace Protostar.Cli.Auth;

/// <summary>Opens a URL in the user's default browser, cross-platform. Returns false on failure.</summary>
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;
}
}
}
59 changes: 59 additions & 0 deletions src/Protostar.Cli/Auth/CredentialFile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Text.Json;

namespace Protostar.Cli.Auth;

/// <summary>
/// The on-disk credential store: a single JSON file under <see cref="ProtostarPaths.ConfigDir"/>,
/// 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.
/// </summary>
internal sealed class CredentialFile
{
public Dictionary<string, StoredToken> 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<CredentialFile>(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);
}
}
106 changes: 106 additions & 0 deletions src/Protostar.Cli/Auth/LoopbackServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace Protostar.Cli.Auth;

/// <summary>
/// A one-shot loopback HTTP listener for the OAuth redirect. Binds an ephemeral 127.0.0.1 port,
/// waits for the <c>/callback</c> 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.
/// </summary>
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<CallbackResult> 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(
$"<!doctype html><html><body style=\"font-family:sans-serif;padding:2rem\"><p>{message}</p></body></html>");
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<string, string> ParseQuery(string query)
{
var result = new Dictionary<string, string>(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);
22 changes: 22 additions & 0 deletions src/Protostar.Cli/Auth/Pkce.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Security.Cryptography;
using System.Text;

namespace Protostar.Cli.Auth;

/// <summary>
/// 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.
/// </summary>
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('/', '_');
}
23 changes: 23 additions & 0 deletions src/Protostar.Cli/Auth/ProtostarPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Protostar.Cli.Auth;

/// <summary>
/// Resolves protostar's per-user config directory. Defaults to <c>~/.protostar</c> (the
/// <c>.aws</c>/<c>.kube</c> convention), overridable with <c>PROTOSTAR_CONFIG_DIR</c> so tests and
/// power users can redirect it away from the real home.
/// </summary>
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");
}
Loading
Loading