diff --git a/src/Capacitor.Cli.Core/Auth/AuthModels.cs b/src/Capacitor.Cli.Core/Auth/AuthModels.cs index 2d18025d60..b042980b67 100644 --- a/src/Capacitor.Cli.Core/Auth/AuthModels.cs +++ b/src/Capacitor.Cli.Core/Auth/AuthModels.cs @@ -7,15 +7,15 @@ public record AuthDiscoveryResponse { [JsonPropertyName("provider")] public string Provider { get; init; } = ""; - [JsonPropertyName("auth0_domain")] - public string? Auth0Domain { get; init; } + [JsonPropertyName("authkit_domain")] + public string? AuthKitDomain { get; init; } + + [JsonPropertyName("organization_id")] + public string? OrganizationId { get; init; } [JsonPropertyName("client_id")] public string? ClientId { get; init; } - [JsonPropertyName("audience")] - public string? Audience { get; init; } - [JsonPropertyName("github_client_id")] public string? GithubClientId { get; init; } @@ -90,25 +90,33 @@ public record GitHubTokenResponse { public string? Error { get; init; } } -// Auth0: POST /oauth/token -public record Auth0TokenResponse { +// WorkOS: POST /user_management/authenticate (PKCE — no client secret) +public record WorkOSAuthResponse { + [JsonPropertyName("user")] + public WorkOSUserInfo? User { get; init; } + + [JsonPropertyName("organization_id")] + public string? OrganizationId { get; init; } + [JsonPropertyName("access_token")] public string AccessToken { get; init; } = ""; [JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; } +} - [JsonPropertyName("expires_in")] - public int ExpiresIn { get; init; } +public record WorkOSUserInfo { + [JsonPropertyName("id")] + public string Id { get; init; } = ""; - [JsonPropertyName("id_token")] - public string? IdToken { get; init; } -} + [JsonPropertyName("email")] + public string? Email { get; init; } + + [JsonPropertyName("first_name")] + public string? FirstName { get; init; } -// Auth0 ID token JWT payload (decoded from id_token) -public record Auth0IdTokenClaims { - [JsonPropertyName("nickname")] - public string? Nickname { get; init; } + [JsonPropertyName("last_name")] + public string? LastName { get; init; } } // POST /auth/refresh request diff --git a/src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs b/src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs index 7b8ae6c709..b00d018fc7 100644 --- a/src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs +++ b/src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs @@ -11,7 +11,7 @@ namespace Capacitor.Cli.Core.Auth; public static class AuthProvider { public const string GitHubApp = "GitHubApp"; - public const string Auth0 = "Auth0"; + public const string WorkOS = "workos"; public const string None = "None"; } @@ -43,7 +43,7 @@ public static async Task LoginWithDiscoveryAsync(string serverUrl, bool for return config.Provider switch { AuthProvider.None => HandleNoneLogin(), AuthProvider.GitHubApp => await HandleGitHubLogin(serverUrl, config, forceDevice), - AuthProvider.Auth0 => await HandleAuth0Login(config), + AuthProvider.WorkOS => await HandleWorkOSLogin(config), _ => HandleUnknownProvider(config.Provider) }; } @@ -296,7 +296,7 @@ static async Task RespondCallbackAsync(HttpListenerContext ctx, CallbackResult c } public static async Task ExchangeAndSaveAsync(string serverUrl, string githubAccessToken, string provider) { - if (provider is not AuthProvider.GitHubApp and not AuthProvider.Auth0) { + if (provider is not AuthProvider.GitHubApp) { Console.Error.WriteLine($"Error: unknown auth provider '{provider}'"); return 1; @@ -350,7 +350,7 @@ public static async Task ExchangeAndSaveAsync( string provider, string profile ) { - if (provider is not AuthProvider.GitHubApp and not AuthProvider.Auth0) { + if (provider is not AuthProvider.GitHubApp) { Console.Error.WriteLine($"Error: unknown auth provider '{provider}'"); return 1; @@ -460,30 +460,36 @@ static async Task HandleGitHubLogin(string serverUrl, AuthDiscoveryResponse return await RunDeviceFlowAsync(clientId); } - static async Task HandleAuth0Login(AuthDiscoveryResponse config) { - return await LoginAsync(config.Auth0Domain!, config.ClientId!, config.Audience ?? ""); - } + static Task HandleWorkOSLogin(AuthDiscoveryResponse config) => + LoginWorkOSAsync(config.AuthKitDomain, config.ClientId!, config.OrganizationId); + + const string WorkOSApiBase = "https://api.workos.com"; /// - /// Auth0 PKCE login flow (preserved for Auth0 strategy). + /// WorkOS AuthKit authorization-code-with-PKCE login on a 127.0.0.1 loopback listener + /// (WorkOS documents the HTTP loopback exception as 127.0.0.1, not localhost). Authorize + /// on the AuthKit domain (hosted UI; falls back to api.workos.com), org-scoped when known; + /// the token exchange always hits api.workos.com. Public client — no client secret. /// - static async Task LoginAsync(string auth0Domain, string clientId, string audience) { + static async Task LoginWorkOSAsync(string? authKitDomain, string clientId, string? organizationId) { + var authorizeBase = string.IsNullOrEmpty(authKitDomain) ? WorkOSApiBase : $"https://{authKitDomain}"; + var verifier = GenerateCodeVerifier(); var challenge = GenerateCodeChallenge(verifier); var state = GenerateCodeVerifier(); var port = GetAvailablePort(); - var redirectUri = $"http://localhost:{port}/callback"; + var redirectUri = $"http://127.0.0.1:{port}/callback"; using var listener = new HttpListener(); - listener.Prefixes.Add($"http://localhost:{port}/"); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); listener.Start(); - var authUrl = $"https://{auth0Domain}/authorize?" + - $"response_type=code&client_id={Uri.EscapeDataString(clientId)}" + - $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" + - $"&scope={Uri.EscapeDataString("openid profile email offline_access")}" + - $"&audience={Uri.EscapeDataString(audience)}" + - $"&state={Uri.EscapeDataString(state)}" + + var authUrl = $"{authorizeBase}/user_management/authorize?" + + $"response_type=code&client_id={Uri.EscapeDataString(clientId)}" + + $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" + + $"&provider=authkit" + + (string.IsNullOrEmpty(organizationId) ? "" : $"&organization_id={Uri.EscapeDataString(organizationId)}") + + $"&state={Uri.EscapeDataString(state)}" + $"&code_challenge={challenge}&code_challenge_method=S256"; await Console.Out.WriteLineAsync("Opening browser for authentication..."); @@ -495,7 +501,31 @@ static async Task LoginAsync(string auth0Domain, string clientId, string au /* Browser open is best-effort — user can still copy the URL */ } - var context = await listener.GetContextAsync(); + // Bounded wait + ignore non-callback requests (favicon etc.) — mirrors RunGitHubBrowserFlowAsync. + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + + HttpListenerContext context; + + while (true) { + var getContext = listener.GetContextAsync(); + + try { + context = await getContext.WaitAsync(cts.Token); + } catch (OperationCanceledException) { + listener.Stop(); + _ = getContext.ContinueWith(t => _ = t.Exception, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + Console.Error.WriteLine("Timed out waiting for authorization. Re-run `kcap login` to try again."); + + return 1; + } + + if (context.Request.Url?.AbsolutePath == "/callback") break; + + // Ignore favicon and other browser-issued requests that aren't our callback. + context.Response.StatusCode = 404; + context.Response.Close(); + } + var code = context.Request.QueryString["code"]; var returnedState = context.Request.QueryString["state"]; @@ -530,13 +560,12 @@ static async Task LoginAsync(string auth0Domain, string clientId, string au using var http = new HttpClient(); var tokenResponse = await http.PostAsync( - $"https://{auth0Domain}/oauth/token", + $"{WorkOSApiBase}/user_management/authenticate", new FormUrlEncodedContent( new Dictionary { ["grant_type"] = "authorization_code", ["client_id"] = clientId, ["code"] = code, - ["redirect_uri"] = redirectUri, ["code_verifier"] = verifier } ) @@ -548,25 +577,28 @@ static async Task LoginAsync(string auth0Domain, string clientId, string au return 1; } - var json = (await tokenResponse.Content.ReadFromJsonAsync(CapacitorJsonContext.Default.Auth0TokenResponse))!; + var json = (await tokenResponse.Content.ReadFromJsonAsync(CapacitorJsonContext.Default.WorkOSAuthResponse))!; - var username = "unknown"; + // Org gate: a multi-org user must not be "logged in" to the wrong org — every API + // call would then fail the server's org check. Reject before saving tokens. + if (!string.IsNullOrEmpty(organizationId) && !string.Equals(json.OrganizationId, organizationId, StringComparison.Ordinal)) { + Console.Error.WriteLine($"Error: signed in to the wrong WorkOS organization (expected {organizationId}). Re-run `kcap login` and pick the correct organization."); - if (json.IdToken is not null) { - var payload = json.IdToken.Split('.')[1]; - payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); - var claims = JsonSerializer.Deserialize(Convert.FromBase64String(payload), CapacitorJsonContext.Default.Auth0IdTokenClaims); - username = claims?.Nickname ?? "unknown"; + return 1; } + var parts = new List(); + if (!string.IsNullOrEmpty(json.User?.FirstName)) parts.Add(json.User!.FirstName!); + if (!string.IsNullOrEmpty(json.User?.LastName)) parts.Add(json.User!.LastName!); + var username = parts.Count > 0 ? string.Join(' ', parts) : json.User?.Email ?? "unknown"; + await TokenStore.SaveAsync( new() { AccessToken = json.AccessToken, RefreshToken = json.RefreshToken, - ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(json.ExpiresIn), + ExpiresAt = TokenStore.JwtExpiry(json.AccessToken), GitHubUsername = username, - Provider = AuthProvider.Auth0, - Auth0Domain = auth0Domain, + Provider = AuthProvider.WorkOS, ClientId = clientId } ); diff --git a/src/Capacitor.Cli.Core/Auth/TokenStore.cs b/src/Capacitor.Cli.Core/Auth/TokenStore.cs index 848e2857e0..06c9ced212 100644 --- a/src/Capacitor.Cli.Core/Auth/TokenStore.cs +++ b/src/Capacitor.Cli.Core/Auth/TokenStore.cs @@ -19,10 +19,7 @@ public record StoredTokens { public required string GitHubUsername { get; init; } [JsonPropertyName("provider")] - public string Provider { get; init; } = "Auth0"; - - [JsonPropertyName("auth0_domain")] - public string? Auth0Domain { get; init; } + public string Provider { get; init; } = "GitHubApp"; [JsonPropertyName("client_id")] public string? ClientId { get; init; } @@ -134,19 +131,91 @@ static async Task ResolveActiveProfileAsync() { return tokens; } - // Auth0: use refresh token - if (tokens is { Provider: "Auth0", RefreshToken: not null, Auth0Domain: not null, ClientId: not null }) { - return await RefreshAuth0Async(tokens); + var profile = await ResolveActiveProfileAsync(); + + // Both providers rotate/re-issue on refresh, so serialize across processes + // (hooks, watcher, daemon, MCP share one token store) with a profile-scoped + // file lock — otherwise a peer refreshing with the same rotated-out WorkOS + // refresh token would invalidate the session. + if (tokens is { Provider: "workos", RefreshToken: not null, ClientId: not null }) { + return await RefreshWithCrossProcessLockAsync(profile, tokens, RefreshWorkOSAsync); } // GitHub: refresh via server's /auth/refresh endpoint if (tokens.Provider is "GitHubApp") { - return await RefreshGitHubAsync(tokens); + return await RefreshWithCrossProcessLockAsync(profile, tokens, RefreshGitHubAsync); } return null; } + // Profile-scoped cross-process lock. Acquire it, re-read the token under it (a peer + // may have just rotated it), refresh only if still expired, persist, release. If the + // lock can't be acquired within the deadline, fall back to whatever a peer persisted. + static async Task RefreshWithCrossProcessLockAsync( + string profile, + StoredTokens current, + Func> refresh + ) { + // Validate before building the lock path — a profile name with path separators + // must not let the lock file escape TokenDir (matches ProfileTokenPath's guard). + ValidateProfileName(profile); + Directory.CreateDirectory(TokenDir); + var lockPath = Path.Combine(TokenDir, $"{profile}.lock"); + + FileStream? lockStream = null; + var deadline = DateTime.UtcNow.AddSeconds(15); + + while (lockStream is null) { + try { + lockStream = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } catch (IOException) { + if (DateTime.UtcNow >= deadline) { + var latest = await LoadAsync(profile); + + return latest is { IsExpired: false } ? latest : null; + } + + await Task.Delay(100); + } + } + + try { + var latest = await LoadAsync(profile) ?? current; + + return latest.IsExpired ? await refresh(latest) : latest; + } finally { + // Close the stream to release the OS lock, but DON'T delete the file: on Unix a + // waiter can acquire the old inode between dispose and delete, then the unlink lets + // another process create a fresh lock file — splitting the lock and allowing two + // concurrent refreshes. The (tiny, one-per-profile) lock file stays in place. + lockStream.Dispose(); + } + } + + // WorkOS access tokens are JWTs carrying their own `exp`. Read it without signature + // validation (the server validates against JWKS); fall back to a short lifetime. + public static DateTimeOffset JwtExpiry(string accessToken) { + try { + var parts = accessToken.Split('.'); + + if (parts.Length >= 2) { + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); + + using var doc = JsonDocument.Parse(Convert.FromBase64String(payload)); + + if (doc.RootElement.TryGetProperty("exp", out var exp) && exp.TryGetInt64(out var seconds)) { + return DateTimeOffset.FromUnixTimeSeconds(seconds); + } + } + } catch { + // Malformed token — fall through to the conservative default. + } + + return DateTimeOffset.UtcNow.AddMinutes(5); + } + static async Task RefreshGitHubAsync(StoredTokens tokens) { var baseUrl = AppConfig.ResolvedServerUrl ?? Environment.GetEnvironmentVariable("KCAP_URL") ?? "http://localhost:5108"; using var http = new HttpClient(); @@ -183,34 +252,44 @@ static async Task ResolveActiveProfileAsync() { } } - static async Task RefreshAuth0Async(StoredTokens tokens) { - using var http = new HttpClient(); + static async Task RefreshWorkOSAsync(StoredTokens tokens) { + // Mirror RefreshGitHubAsync: network/parse failures return null (caller surfaces + // "run kcap login") rather than throwing out of GetValidTokensAsync and crashing. + try { + using var http = new HttpClient(); + + using var response = await http.PostAsync( + "https://api.workos.com/user_management/authenticate", + new FormUrlEncodedContent( + new Dictionary { + ["grant_type"] = "refresh_token", + ["client_id"] = tokens.ClientId!, + ["refresh_token"] = tokens.RefreshToken! + } + ) + ); - var response = await http.PostAsync( - $"https://{tokens.Auth0Domain}/oauth/token", - new FormUrlEncodedContent( - new Dictionary { - ["grant_type"] = "refresh_token", - ["client_id"] = tokens.ClientId!, - ["refresh_token"] = tokens.RefreshToken! - } - ) - ); + if (!response.IsSuccessStatusCode) { + return null; + } - if (!response.IsSuccessStatusCode) { - return null; - } + var json = await response.Content.ReadFromJsonAsync(CapacitorJsonContext.Default.WorkOSAuthResponse); - var json = (await response.Content.ReadFromJsonAsync(CapacitorJsonContext.Default.Auth0TokenResponse))!; + if (json is null) { + return null; + } - var refreshed = tokens with { - AccessToken = json.AccessToken, - ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(json.ExpiresIn), - RefreshToken = json.RefreshToken ?? tokens.RefreshToken - }; + var refreshed = tokens with { + AccessToken = json.AccessToken, + ExpiresAt = JwtExpiry(json.AccessToken), + RefreshToken = json.RefreshToken ?? tokens.RefreshToken + }; - await SaveAsync(refreshed); + await SaveAsync(refreshed); - return refreshed; + return refreshed; + } catch { + return null; + } } } diff --git a/src/Capacitor.Cli.Core/Models.cs b/src/Capacitor.Cli.Core/Models.cs index 78f47395db..cd729ec0d5 100644 --- a/src/Capacitor.Cli.Core/Models.cs +++ b/src/Capacitor.Cli.Core/Models.cs @@ -541,8 +541,8 @@ public record RepoEntry { [JsonSerializable(typeof(Auth.GitHubDeviceCodeResponse))] [JsonSerializable(typeof(Auth.GitHubTokenResponse))] [JsonSerializable(typeof(Auth.GitHubCodeExchangeRequest))] -[JsonSerializable(typeof(Auth.Auth0TokenResponse))] -[JsonSerializable(typeof(Auth.Auth0IdTokenClaims))] +[JsonSerializable(typeof(Auth.WorkOSAuthResponse))] +[JsonSerializable(typeof(Auth.WorkOSUserInfo))] [JsonSerializable(typeof(Auth.ProxyConfigResponse))] [JsonSerializable(typeof(Auth.DiscoveredTenant[]))] [JsonSerializable(typeof(LaunchAgentCommand))] diff --git a/src/Capacitor.Cli.Core/Resources/help-login.txt b/src/Capacitor.Cli.Core/Resources/help-login.txt index 42cc05b5de..e58f38ec4a 100644 --- a/src/Capacitor.Cli.Core/Resources/help-login.txt +++ b/src/Capacitor.Cli.Core/Resources/help-login.txt @@ -3,7 +3,7 @@ kcap login — Authenticate via OAuth Usage: kcap login [--discover] [--device] Opens the system browser for OAuth authentication. The auth method is -auto-discovered from the server configuration (GitHub App or Auth0). +auto-discovered from the server configuration (GitHub App or WorkOS). For GitHub, the default flow uses a localhost callback with PKCE when the server advertises a code-exchange endpoint — the browser opens straight to diff --git a/src/Capacitor.Cli/Commands/CursorHookCommand.cs b/src/Capacitor.Cli/Commands/CursorHookCommand.cs index 7c921a8ea2..05a9b26f1a 100644 --- a/src/Capacitor.Cli/Commands/CursorHookCommand.cs +++ b/src/Capacitor.Cli/Commands/CursorHookCommand.cs @@ -26,7 +26,7 @@ public static class CursorHookCommand { /// , transcript backfill). /// 2. A hard ceiling around the whole pipeline /// because some paths inside TokenStore (refresh against - /// /auth/refresh or Auth0's token endpoint) don't honour a + /// /auth/refresh or WorkOS's token endpoint) don't honour a /// and would otherwise sit on the /// default 100 s timeout. If the hard /// ceiling fires we abandon the inner task — the process exits 0 diff --git a/src/Capacitor.Cli/Commands/McpSessionsServer.cs b/src/Capacitor.Cli/Commands/McpSessionsServer.cs index 9fc8a8438e..1ff9d41eb7 100644 --- a/src/Capacitor.Cli/Commands/McpSessionsServer.cs +++ b/src/Capacitor.Cli/Commands/McpSessionsServer.cs @@ -143,7 +143,7 @@ internal static async Task HandleToolCallAsync( /// for the lifetime of the agent session, so a cached token /// that was valid at startup may have expired by the time a tool call is made. On 401 /// we ask for a fresh token (which triggers - /// the refresh flow for Auth0 / GitHubApp), update the client's Authorization + /// the refresh flow for WorkOS / GitHubApp), update the client's Authorization /// header, and retry the same request once. If refresh fails (genuinely not logged in /// or refresh-token expired), the original 401 is returned and the caller surfaces the /// friendly "Not logged in" message. diff --git a/test/Capacitor.Cli.Tests.Unit/JwtExpiryTests.cs b/test/Capacitor.Cli.Tests.Unit/JwtExpiryTests.cs new file mode 100644 index 0000000000..35bc235848 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/JwtExpiryTests.cs @@ -0,0 +1,31 @@ +using System.Text; +using Capacitor.Cli.Core.Auth; + +namespace Capacitor.Cli.Tests.Unit; + +public class JwtExpiryTests { + static string TokenWithExp(long exp) { + var json = $"{{\"exp\":{exp}}}"; + var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + return $"header.{b64}.signature"; + } + + [Test] + public async Task Reads_exp_claim_from_workos_access_token() { + var exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); + var result = TokenStore.JwtExpiry(TokenWithExp(exp)); + + await Assert.That(result.ToUnixTimeSeconds()).IsEqualTo(exp); + } + + [Test] + public async Task Malformed_token_falls_back_to_short_lifetime() { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var result = TokenStore.JwtExpiry("not-a-jwt").ToUnixTimeSeconds(); + + // ~5 minute conservative default. + await Assert.That(result).IsGreaterThanOrEqualTo(now + 240); + await Assert.That(result).IsLessThanOrEqualTo(now + 360); + } +}