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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ $ protostar uninstall # remove it

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.
local credential file (see below). The flow uses the OAuth Authorization Code grant with PKCE over a
loopback redirect, driven by a certified OIDC client library, so no secret is kept on disk.

```console
$ protostar auth login
Expand All @@ -108,9 +108,11 @@ By default the registry shows a sign-in chooser so you can pick how to authentic
`--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.
variable. On a display-less Linux session the CLI detects there is no browser and prints the sign-in
URL automatically; `--no-browser` forces that behavior anywhere. Either way the printed URL still
redirects to a loopback address, so the browser must run on the same host as the CLI (open the URL in
a local browser, or forward the callback port over SSH). 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`.
Expand Down
5 changes: 0 additions & 5 deletions src/Protostar.Cli/Auth/AuthConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,4 @@ internal static class AuthConstants
// 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";
}
121 changes: 121 additions & 0 deletions src/Protostar.Cli/Auth/LoopbackBrowser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System.Net;
using System.Text;
using Duende.IdentityModel.OidcClient.Browser;
using Spectre.Console;

namespace Protostar.Cli.Auth;

/// <summary>
/// The <see cref="IBrowser"/> OidcClient drives for the native-app loopback flow (RFC 8252). It binds
/// the ephemeral 127.0.0.1 port chosen at construction, opens the system browser at the authorize URL
/// (or prints it when <c>--no-browser</c> was passed), waits for the OAuth redirect to
/// <c>/callback</c>, serves a "you can close this tab" page, and hands the full callback URL back to
/// OidcClient for the PKCE code exchange. OpenIddict ignores the port when matching the registered
/// loopback redirect URI, so any free port works.
/// </summary>
internal sealed class LoopbackBrowser : IBrowser
{
private readonly bool _openBrowser;

public LoopbackBrowser(bool openBrowser)
{
_openBrowser = openBrowser;
Port = FreeLoopbackPort();
RedirectUri = $"http://127.0.0.1:{Port}{AuthConstants.CallbackPath}";
}

public int Port { get; }

/// <summary>The redirect URI OidcClient must advertise; the listener answers on the same port.</summary>
public string RedirectUri { get; }

public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default)
{
using var listener = new HttpListener();
listener.Prefixes.Add($"http://127.0.0.1:{Port}/");
listener.Start();

// Skip the launch attempt on a display-less session: xdg-open can "succeed" with no browser
// actually shown, which would otherwise hide the URL and strand a headless user.
var opened = _openBrowser && !IsHeadless() && BrowserLauncher.TryOpen(options.StartUrl);

AnsiConsole.MarkupLine(opened
? "Opening your browser to sign in. If it doesn't open, use the URL below, then return here."
: "Open this URL to sign in:");

// Always print the URL raw (not through Spectre) so it is never word-wrapped, stays
// copy-pasteable, and is available even when we did open a browser.
Console.WriteLine(options.StartUrl);

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)
{
return new BrowserResult
{
ResultType = BrowserResultType.Timeout,
Error = "Timed out waiting for the browser sign-in.",
};
}

if (!string.Equals(context.Request.Url!.AbsolutePath, AuthConstants.CallbackPath, StringComparison.Ordinal))
{
await RespondAsync(context, 404, "Not found.");
continue;
}

var failed = context.Request.Url!.Query.Contains("error=", StringComparison.Ordinal);
await RespondAsync(context, 200, failed
? "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.");

return new BrowserResult
{
ResultType = BrowserResultType.Success,
Response = context.Request.Url!.AbsoluteUri,
};
}
}

// True when no GUI session is available to open a browser. Only the xdg-open family (anything
// that isn't Windows or macOS) depends on an X11/Wayland display; on Windows/macOS the system
// launcher works without these variables, so they are never treated as headless here.
private static bool IsHeadless()
{
if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS())
return false;

return string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY"))
&& string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"));
}

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 int FreeLoopbackPort()
{
var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return port;
}
}
106 changes: 0 additions & 106 deletions src/Protostar.Cli/Auth/LoopbackServer.cs

This file was deleted.

34 changes: 34 additions & 0 deletions src/Protostar.Cli/Auth/OidcClientFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Duende.IdentityModel.OidcClient;

namespace Protostar.Cli.Auth;

/// <summary>
/// Builds the <see cref="OidcClient"/> the auth commands drive against a registry. The client speaks
/// OIDC against the registry's discovery document (<c>/.well-known/openid-configuration</c>), so the
/// authorize/token/userinfo endpoints are discovered rather than hand-built.
/// </summary>
internal static class OidcClientFactory
{
/// <summary>
/// Creates a client for the given registry. Pass a <paramref name="browser"/> for interactive
/// login (it supplies the loopback redirect URI); omit it for refresh/userinfo-only use.
/// </summary>
public static OidcClient Create(Uri registry, LoopbackBrowser? browser = null)
{
var options = new OidcClientOptions
{
Authority = registry.GetLeftPart(UriPartial.Authority),
ClientId = AuthConstants.ClientId,
Scope = AuthConstants.Scopes,
Browser = browser,
RedirectUri = browser?.RedirectUri ?? string.Empty,

// The registry advertises a plain authorize endpoint (no PAR), and a userinfo call after
// login keeps the profile claims (incl. github_login) on the returned principal.
DisablePushedAuthorization = true,
LoadProfile = true,
};

return new OidcClient(options);
}
}
22 changes: 0 additions & 22 deletions src/Protostar.Cli/Auth/Pkce.cs

This file was deleted.

Loading
Loading