From d49d17b6243a12609fa39292a4201fbad7a887ec Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Tue, 2 Jun 2026 22:02:32 -0500 Subject: [PATCH] refactor: adopt Duende.IdentityModel.OidcClient for CLI auth (PROT-47) Replace the hand-rolled OAuth code with the certified Duende.IdentityModel.OidcClient library, driving login, refresh, and userinfo through the registry's OIDC discovery document instead of hand-built endpoints and DTOs. - Add Duende.IdentityModel.OidcClient 7.1.0. - Reduce the loopback listener to a thin IBrowser (LoopbackBrowser) and add an OidcClientFactory that targets the registry discovery doc. - Drive login via OidcClient.LoginAsync (PKCE over a loopback redirect, RFC 8252); preserve the --provider shortcut via front-channel identity_provider, plus --no-browser and --timeout. - Drive status refresh/userinfo via RefreshTokenAsync/GetUserInfoAsync. - Slim RegistryClient to the /v1/meta compatibility check; keep the file-based credential store (~/.protostar/credentials.json). - Remove the hand-rolled helpers: Pkce, LoopbackServer, the authorize-URL builder, and the token/userinfo DTOs. - Harden headless support: always echo the sign-in URL and skip the browser launch on a display-less Linux/Unix session (no DISPLAY/WAYLAND_DISPLAY). - Fix stale README wording (the store is the credential file, not the OS keychain). Behavior is preserved; all acceptance scenarios stay green on Windows and Linux. --- README.md | 12 +- src/Protostar.Cli/Auth/AuthConstants.cs | 5 - src/Protostar.Cli/Auth/LoopbackBrowser.cs | 121 ++++++++++++++ src/Protostar.Cli/Auth/LoopbackServer.cs | 106 ------------- src/Protostar.Cli/Auth/OidcClientFactory.cs | 34 ++++ src/Protostar.Cli/Auth/Pkce.cs | 22 --- src/Protostar.Cli/Auth/RegistryClient.cs | 69 +------- .../Commands/Auth/LoginCommand.cs | 148 +++++++----------- .../Commands/Auth/StatusCommand.cs | 25 +-- src/Protostar.Cli/Protostar.Cli.csproj | 1 + 10 files changed, 238 insertions(+), 305 deletions(-) create mode 100644 src/Protostar.Cli/Auth/LoopbackBrowser.cs delete mode 100644 src/Protostar.Cli/Auth/LoopbackServer.cs create mode 100644 src/Protostar.Cli/Auth/OidcClientFactory.cs delete mode 100644 src/Protostar.Cli/Auth/Pkce.cs diff --git a/README.md b/README.md index 044def1..d153713 100644 --- a/README.md +++ b/README.md @@ -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 @@ -108,9 +108,11 @@ By default the registry shows a sign-in chooser so you can pick how to authentic `--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. +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`. diff --git a/src/Protostar.Cli/Auth/AuthConstants.cs b/src/Protostar.Cli/Auth/AuthConstants.cs index 26e04ce..5613f39 100644 --- a/src/Protostar.Cli/Auth/AuthConstants.cs +++ b/src/Protostar.Cli/Auth/AuthConstants.cs @@ -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"; } diff --git a/src/Protostar.Cli/Auth/LoopbackBrowser.cs b/src/Protostar.Cli/Auth/LoopbackBrowser.cs new file mode 100644 index 0000000..cc66dc6 --- /dev/null +++ b/src/Protostar.Cli/Auth/LoopbackBrowser.cs @@ -0,0 +1,121 @@ +using System.Net; +using System.Text; +using Duende.IdentityModel.OidcClient.Browser; +using Spectre.Console; + +namespace Protostar.Cli.Auth; + +/// +/// The 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 --no-browser was passed), waits for the OAuth redirect to +/// /callback, 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. +/// +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; } + + /// The redirect URI OidcClient must advertise; the listener answers on the same port. + public string RedirectUri { get; } + + public async Task 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( + $"

{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 int FreeLoopbackPort() + { + var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } +} diff --git a/src/Protostar.Cli/Auth/LoopbackServer.cs b/src/Protostar.Cli/Auth/LoopbackServer.cs deleted file mode 100644 index 7731aef..0000000 --- a/src/Protostar.Cli/Auth/LoopbackServer.cs +++ /dev/null @@ -1,106 +0,0 @@ -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/OidcClientFactory.cs b/src/Protostar.Cli/Auth/OidcClientFactory.cs new file mode 100644 index 0000000..f422152 --- /dev/null +++ b/src/Protostar.Cli/Auth/OidcClientFactory.cs @@ -0,0 +1,34 @@ +using Duende.IdentityModel.OidcClient; + +namespace Protostar.Cli.Auth; + +/// +/// Builds the the auth commands drive against a registry. The client speaks +/// OIDC against the registry's discovery document (/.well-known/openid-configuration), so the +/// authorize/token/userinfo endpoints are discovered rather than hand-built. +/// +internal static class OidcClientFactory +{ + /// + /// Creates a client for the given registry. Pass a for interactive + /// login (it supplies the loopback redirect URI); omit it for refresh/userinfo-only use. + /// + 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); + } +} diff --git a/src/Protostar.Cli/Auth/Pkce.cs b/src/Protostar.Cli/Auth/Pkce.cs deleted file mode 100644 index e5f8aa4..0000000 --- a/src/Protostar.Cli/Auth/Pkce.cs +++ /dev/null @@ -1,22 +0,0 @@ -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 index 37ad1d1..657f3e4 100644 --- a/src/Protostar.Cli/Auth/RegistryClient.cs +++ b/src/Protostar.Cli/Auth/RegistryClient.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; @@ -6,8 +5,10 @@ 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). +/// Thin HTTP client for the registry's metadata endpoint. The OAuth/OIDC mechanics (authorize, +/// token, userinfo, refresh) are handled by OidcClient against the discovery document; this client +/// only covers the protostar-specific /v1/meta compatibility check. 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 { @@ -23,47 +24,6 @@ internal sealed class RegistryClient(Uri registry) : IDisposable : 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) @@ -99,24 +59,3 @@ internal sealed record RegistryMeta [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/Commands/Auth/LoginCommand.cs b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs index 672f00f..d157c25 100644 --- a/src/Protostar.Cli/Commands/Auth/LoginCommand.cs +++ b/src/Protostar.Cli/Commands/Auth/LoginCommand.cs @@ -1,4 +1,6 @@ using System.ComponentModel; +using Duende.IdentityModel.Client; +using Duende.IdentityModel.OidcClient; using Protostar.Cli.Auth; using Spectre.Console; using Spectre.Console.Cli; @@ -6,9 +8,9 @@ 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). +/// protostar auth login — authenticates to the registry with OidcClient (Authorization Code + +/// PKCE over a loopback redirect, RFC 8252), then stores the resulting tokens in the file-based +/// credential store. The actual sign-in happens in the browser (the registry federates it to GitHub). /// internal sealed class LoginCommand : AsyncCommand { @@ -43,109 +45,92 @@ private static async Task RunAsync(Settings settings, CancellationToken can 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; + using (var client = new RegistryClient(registry)) + { + 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; + } } - 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 browser = new LoopbackBrowser(openBrowser: !settings.NoBrowser); + var oidc = OidcClientFactory.Create(registry, browser); - 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 + var request = new LoginRequest(); + // A provider hint lets the registry skip its chooser and forward straight to that provider. + if (!string.IsNullOrWhiteSpace(settings.Provider)) { - 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); + request.FrontChannelExtraParameters = new Parameters + { + new KeyValuePair("identity_provider", settings.Provider.Trim()), + }; } using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellation); timeout.CancelAfter(TimeSpan.FromSeconds(Math.Max(30, settings.TimeoutSeconds))); - CallbackResult callback; + LoginResult result; try { - callback = await loopback.WaitForCallbackAsync(timeout.Token); + result = await oidc.LoginAsync(request, 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)) + catch (HttpRequestException ex) { - AnsiConsole.MarkupLine("[red]The registry did not return an authorization code.[/]"); + AnsiConsole.MarkupLine($"[red]Could not reach the registry at[/] [grey]{Markup.Escape(registry.ToString())}[/]: {Markup.Escape(ex.Message)}"); return 1; } - var token = await client.ExchangeCodeAsync(callback.Code, verifier, loopback.RedirectUri, cancellation); - if (!token.IsSuccess) + if (result.IsError) { - var detail = token.ErrorDescription ?? token.Error ?? "the registry rejected the token request."; - AnsiConsole.MarkupLine($"[red]Token exchange failed:[/] {Markup.Escape(detail)}"); + var detail = result.ErrorDescription is { Length: > 0 } d ? d : result.Error ?? "the registry rejected the sign-in."; + AnsiConsole.MarkupLine($"[red]Sign-in failed:[/] {Markup.Escape(detail)}"); return 1; } - var info = await client.GetUserInfoAsync(token.AccessToken!, cancellation); - var login = info?.PreferredUsername ?? info?.GitHubLogin; + // With LoadProfile enabled, OidcClient has already merged the userinfo claims (including the + // custom github_login) onto the principal, so no extra round trip is needed. + var user = result.User; + var login = user?.FindFirst("preferred_username")?.Value ?? user?.FindFirst("github_login")?.Value; 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, + AccessToken = result.AccessToken!, + RefreshToken = result.RefreshToken, + ExpiresAtUtc = result.AccessTokenExpiration, + Subject = user?.FindFirst("sub")?.Value, Login = login, - Name = info?.Name, + Name = user?.FindFirst("name")?.Value, }); if (!saved) { - AnsiConsole.MarkupLine("[red]Signed in, but could not save the session to the OS credential store.[/]"); + AnsiConsole.MarkupLine("[red]Signed in, but could not save the session to the credential store.[/]"); return 1; } @@ -153,25 +138,4 @@ private static async Task RunAsync(Settings settings, CancellationToken can 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/StatusCommand.cs b/src/Protostar.Cli/Commands/Auth/StatusCommand.cs index 215f381..e2790a1 100644 --- a/src/Protostar.Cli/Commands/Auth/StatusCommand.cs +++ b/src/Protostar.Cli/Commands/Auth/StatusCommand.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Protostar.Cli.Auth; using Spectre.Console; using Spectre.Console.Cli; @@ -39,21 +40,21 @@ private static async Task RunAsync(Settings settings, CancellationToken can return 0; } - using var client = new RegistryClient(registry); + var oidc = OidcClientFactory.Create(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) + var refreshed = await oidc.RefreshTokenAsync(refreshToken, backChannelParameters: null, scope: null, cancellation); + if (!refreshed.IsError && !string.IsNullOrEmpty(refreshed.AccessToken)) { stored = stored with { - AccessToken = refreshed.AccessToken!, - RefreshToken = refreshed.RefreshToken ?? stored.RefreshToken, - ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(refreshed.ExpiresIn), + AccessToken = refreshed.AccessToken, + RefreshToken = string.IsNullOrEmpty(refreshed.RefreshToken) ? stored.RefreshToken : refreshed.RefreshToken, + ExpiresAtUtc = refreshed.AccessTokenExpiration, }; store.Save(stored); } @@ -64,19 +65,23 @@ private static async Task RunAsync(Settings settings, CancellationToken can } } - UserInfo? info = null; + IEnumerable? claims = null; try { - info = await client.GetUserInfoAsync(stored.AccessToken, cancellation); + var info = await oidc.GetUserInfoAsync(stored.AccessToken, cancellation); + if (!info.IsError) + claims = info.Claims; } catch (HttpRequestException) { // Registry unreachable; show the stored identity instead. } - var login = info?.PreferredUsername ?? info?.GitHubLogin ?? stored.Login ?? "(unknown)"; + var verifiedLogin = claims?.FirstOrDefault(c => c.Type == "preferred_username")?.Value + ?? claims?.FirstOrDefault(c => c.Type == "github_login")?.Value; + var login = verifiedLogin ?? stored.Login ?? "(unknown)"; - if (info is not null) + if (claims is not null) { AnsiConsole.MarkupLine($"[green]Logged in[/] to [grey]{Markup.Escape(authority)}[/] as [aqua]{Markup.Escape(login)}[/]."); } diff --git a/src/Protostar.Cli/Protostar.Cli.csproj b/src/Protostar.Cli/Protostar.Cli.csproj index 8d44eda..4326bac 100644 --- a/src/Protostar.Cli/Protostar.Cli.csproj +++ b/src/Protostar.Cli/Protostar.Cli.csproj @@ -13,6 +13,7 @@ +