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
40 changes: 24 additions & 16 deletions src/Capacitor.Cli.Core/Auth/AuthModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down Expand Up @@ -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
Expand Down
92 changes: 62 additions & 30 deletions src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

Expand Down Expand Up @@ -43,7 +43,7 @@ public static async Task<int> 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)
};
}
Expand Down Expand Up @@ -296,7 +296,7 @@ static async Task RespondCallbackAsync(HttpListenerContext ctx, CallbackResult c
}

public static async Task<int> 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;
Expand Down Expand Up @@ -350,7 +350,7 @@ public static async Task<int> 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;
Expand Down Expand Up @@ -460,30 +460,36 @@ static async Task<int> HandleGitHubLogin(string serverUrl, AuthDiscoveryResponse
return await RunDeviceFlowAsync(clientId);
}

static async Task<int> HandleAuth0Login(AuthDiscoveryResponse config) {
return await LoginAsync(config.Auth0Domain!, config.ClientId!, config.Audience ?? "");
}
static Task<int> HandleWorkOSLogin(AuthDiscoveryResponse config) =>
LoginWorkOSAsync(config.AuthKitDomain, config.ClientId!, config.OrganizationId);

const string WorkOSApiBase = "https://api.workos.com";

/// <summary>
/// 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.
/// </summary>
static async Task<int> LoginAsync(string auth0Domain, string clientId, string audience) {
static async Task<int> 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...");
Expand All @@ -495,7 +501,31 @@ static async Task<int> 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"];

Expand Down Expand Up @@ -530,13 +560,12 @@ static async Task<int> 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<string, string> {
["grant_type"] = "authorization_code",
["client_id"] = clientId,
["code"] = code,
["redirect_uri"] = redirectUri,
["code_verifier"] = verifier
}
)
Expand All @@ -548,25 +577,28 @@ static async Task<int> 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<string>();
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
}
);
Expand Down
Loading
Loading