Skip to content

Commit fb554fe

Browse files
committed
feat(auth): add GitHub Copilot subscription login via device code flow
winharness login --provider copilot runs the OAuth device code flow (print code, poll per RFC 8628 with slow_down handling), exchanges the GitHub token for the short-lived Copilot bearer at copilot_internal/v2/token, extracts the per-credential proxy endpoint as the provider baseUrl, stores the token set under WinHarness:oauth:copilot, and auto-creates the copilot provider entry. GitHubCopilotOAuthFlow registers as the first IOAuthTokenRefresher so chat requests refresh bearers transparently. login status and logout manage stored token sets. Endpoints and headers per ADR-0005, isolated in one file for drift containment.
1 parent 52c339f commit fb554fe

7 files changed

Lines changed: 494 additions & 9 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ dotnet publish .\src\WinHarness.Cli\WinHarness.Cli.csproj -c Release -r win-x64
7979
- `winharness mcp disable --id filesystem`
8080
- `winharness mcp remove --id filesystem`
8181
- `winharness mcp tools`
82+
- `winharness login --provider copilot [--enterprise-domain ghe.example.com]` — GitHub Copilot subscription auth via device code flow (see [Subscription auth](#subscription-auth-oauth))
83+
- `winharness login status` / `winharness logout --provider copilot`
8284
- `winharness credentials set|get|list|delete`
8385

8486
### Sessions
@@ -203,6 +205,12 @@ Set the `WINHARNESS_CONFIG_DIR` environment variable to redirect the entire conf
203205
API keys must be stored in Windows Credential Manager, not configuration files.
204206
WinHarness credential target names must use the `WinHarness:` prefix, for example `WinHarness:openai-main`.
205207

208+
### Subscription auth (OAuth)
209+
210+
`winharness login --provider copilot` signs in with a GitHub Copilot subscription using the OAuth device code flow: visit the printed URL, enter the code, and WinHarness stores the token set in Windows Credential Manager under `WinHarness:oauth:copilot`. The command creates (or updates) a `copilot` provider pointing at your account's Copilot API endpoint; short-lived bearers refresh automatically during chat. Anthropic (Claude Pro/Max) and OpenAI (ChatGPT/Codex) flows are planned — see `docs/adr/ADR-0005-oauth-subscription-providers.md`.
211+
212+
> **Note:** subscription auth rides the unofficial endpoints the vendors ship for their own CLIs. They can change or be revoked at any time (ADR-0005 records this risk acceptance).
213+
206214
Example configuration files are in `samples/`, including `session.example.jsonl` for the persisted session format and a separate `model-capabilities.example.json` shape for model capability metadata.
207215

208216
Create a starter configuration:

docs/design/pi-parity-roadmap.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -138,14 +138,16 @@ rotated tokens last-writer-wins). `ProviderOptions.Auth` block
138138
resolves the token source per provider; not-logged-in errors point at
139139
`winharness login`.
140140

141-
### PR-B2: `winharness login` / `logout` + `/login` `/logout`
142-
143-
- `winharness login --provider copilot|anthropic|openai` and matching REPL slash commands.
144-
- **Device-code UX (Copilot):** print `Visit https://github.com/login/device and enter code XXXX-XXXX`, poll for completion. No browser automation, no local server. Works over SSH.
145-
- **PKCE + loopback UX (Anthropic, OpenAI):** start `HttpListener` on `127.0.0.1:<ephemeral>`, open browser via `Process.Start` with `UseShellExecute = true` (Windows) and print the URL as fallback, receive the code, exchange, store.
146-
- `winharness login status` — list providers, scheme, token expiry.
147-
- `logout` deletes the Credential Manager entries.
148-
- On successful login, offer to auto-create the provider + known models (subscription endpoints have fixed model lists; ship them as static catalogs updated with releases, like pi does).
141+
### PR-B2: `winharness login` / `logout` (DONE — Copilot)
142+
143+
Implemented: `GitHubCopilotOAuthFlow` (device code start, RFC 8628 polling with
144+
slow_down handling, `copilot_internal/v2/token` bearer exchange, proxy-ep →
145+
baseUrl extraction, enterprise domain support) registered as the first
146+
`IOAuthTokenRefresher`; `login --provider copilot` prints the code, polls,
147+
stores the token set, and auto-creates/updates the `copilot` provider entry;
148+
`login status` lists stored OAuth token sets with expiry; `logout` deletes
149+
them. REPL `/login` deferred — the CLI command works while chat is closed,
150+
which covers the core need. Anthropic/OpenAI flows land with PR-B3/PR-B4.
149151

150152
### 4.3 Non-OpenAI-compatible transports (PR-B3, the hard one)
151153

src/WinHarness.Cli/Program.cs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,94 @@ await ConfigFileUpdater.SetRootStringPropertiesAsync(
607607
}
608608
});
609609

610+
app.Add("login", async (string provider, string? enterpriseDomain = null, CancellationToken cancellationToken = default) =>
611+
{
612+
if (!string.Equals(provider, "copilot", StringComparison.OrdinalIgnoreCase))
613+
{
614+
throw new InvalidOperationException($"OAuth provider '{provider}' is not supported yet. Available: copilot.");
615+
}
616+
617+
ICredentialStore store = host.Services.GetRequiredService<ICredentialStore>();
618+
ConfigStore configStore = host.Services.GetRequiredService<ConfigStore>();
619+
using HttpClient http = new();
620+
GitHubCopilotOAuthFlow flow = new(http, enterpriseDomain ?? "github.com");
621+
622+
CopilotDeviceCode device = await flow.StartDeviceFlowAsync(cancellationToken).ConfigureAwait(false);
623+
AnsiConsole.MarkupLine($"Visit [bold blue]{Markup.Escape(device.VerificationUri)}[/] and enter code [bold]{Markup.Escape(device.UserCode)}[/]");
624+
AnsiConsole.MarkupLine("[dim]Waiting for authorization… (Ctrl+C to cancel)[/]");
625+
626+
string githubToken = await flow.PollForGitHubTokenAsync(device, cancellationToken).ConfigureAwait(false);
627+
OAuthTokenSet tokens = await flow.ExchangeForBearerAsync(githubToken, cancellationToken).ConfigureAwait(false);
628+
629+
const string providerId = "copilot";
630+
await store.SetSecretAsync(
631+
OAuthCredentialNames.ForProvider(providerId),
632+
JsonSerializer.Serialize(tokens, WinHarnessJsonSerializerContext.Default.OAuthTokenSet),
633+
cancellationToken).ConfigureAwait(false);
634+
635+
// Create or update the provider entry pointing at the token's proxy endpoint.
636+
WinHarnessOptions current = await configStore.LoadAsync(cancellationToken).ConfigureAwait(false);
637+
ProviderOptions? existing = current.Providers.FirstOrDefault(candidate =>
638+
string.Equals(candidate.Id, providerId, StringComparison.OrdinalIgnoreCase));
639+
if (existing is null)
640+
{
641+
existing = new ProviderOptions { Id = providerId, Kind = "openai-compatible" };
642+
current.Providers.Add(existing);
643+
}
644+
645+
existing.BaseUrl = tokens.BaseUrl ?? GitHubCopilotOAuthFlow.DefaultBaseUrl;
646+
existing.Auth = new ProviderAuthOptions { Scheme = "oauth", OAuthProvider = "copilot" };
647+
if (existing.Models.Count == 0)
648+
{
649+
existing.Models.Add(new ModelOptions
650+
{
651+
Id = "gpt-4o",
652+
ProviderModelId = "gpt-4o",
653+
Capabilities = new ProviderCapabilities(
654+
Streaming: true, ToolCalling: true, Vision: true,
655+
PromptCaching: false, StructuredOutput: true, Reasoning: false),
656+
ContextWindow = 128_000
657+
});
658+
}
659+
660+
await configStore.SaveAsync(current, cancellationToken).ConfigureAwait(false);
661+
AnsiConsole.MarkupLine($"[green]Logged in.[/] Provider '{providerId}' configured at {Markup.Escape(existing.BaseUrl)}.");
662+
AnsiConsole.MarkupLine("[dim]Discover more models with: winharness models discover --provider-id copilot[/]");
663+
});
664+
665+
app.Add("login status", async (CancellationToken cancellationToken) =>
666+
{
667+
ICredentialStore store = host.Services.GetRequiredService<ICredentialStore>();
668+
IReadOnlyList<string> names = await store.ListTargetNamesAsync(cancellationToken).ConfigureAwait(false);
669+
bool any = false;
670+
foreach (string name in names.Where(static name => name.StartsWith("WinHarness:oauth:", StringComparison.Ordinal)))
671+
{
672+
any = true;
673+
string providerId = name["WinHarness:oauth:".Length..];
674+
string? secret = await store.GetSecretAsync(name, cancellationToken).ConfigureAwait(false);
675+
string expiry = "unknown";
676+
if (secret is not null &&
677+
JsonSerializer.Deserialize(secret, WinHarnessJsonSerializerContext.Default.OAuthTokenSet) is { } tokens)
678+
{
679+
expiry = tokens.ExpiresAt?.ToString("u", CultureInfo.InvariantCulture) ?? "no expiry";
680+
}
681+
682+
Console.WriteLine($"{providerId}\toauth\texpires {expiry}");
683+
}
684+
685+
if (!any)
686+
{
687+
Console.WriteLine("No OAuth logins stored.");
688+
}
689+
});
690+
691+
app.Add("logout", async (string provider, CancellationToken cancellationToken) =>
692+
{
693+
ICredentialStore store = host.Services.GetRequiredService<ICredentialStore>();
694+
await store.DeleteSecretAsync(OAuthCredentialNames.ForProvider(provider), cancellationToken).ConfigureAwait(false);
695+
Console.WriteLine($"OAuth tokens for '{provider}' deleted.");
696+
});
697+
610698
app.Add("credentials set", async (string targetName, string secret, CancellationToken cancellationToken) =>
611699
{
612700
CliValidation.ValidateCredentialTargetName(targetName);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace WinHarness.Providers;
5+
6+
/// <summary>
7+
/// Device-code response from GitHub's device authorization endpoint.
8+
/// </summary>
9+
public sealed record CopilotDeviceCode(
10+
[property: JsonPropertyName("device_code")] string DeviceCode,
11+
[property: JsonPropertyName("user_code")] string UserCode,
12+
[property: JsonPropertyName("verification_uri")] string VerificationUri,
13+
[property: JsonPropertyName("interval")] int? Interval,
14+
[property: JsonPropertyName("expires_in")] int ExpiresIn);
15+
16+
/// <summary>
17+
/// Access-token polling response (success or RFC 8628 error).
18+
/// </summary>
19+
public sealed record CopilotAccessTokenResponse(
20+
[property: JsonPropertyName("access_token")] string? AccessToken,
21+
[property: JsonPropertyName("error")] string? Error,
22+
[property: JsonPropertyName("error_description")] string? ErrorDescription);
23+
24+
/// <summary>
25+
/// Short-lived Copilot bearer from the internal token exchange endpoint.
26+
/// </summary>
27+
public sealed record CopilotBearerResponse(
28+
[property: JsonPropertyName("token")] string? Token,
29+
[property: JsonPropertyName("expires_at")] long? ExpiresAt);
30+
31+
/// <summary>
32+
/// Source-generated JSON contracts for the Copilot OAuth flow.
33+
/// </summary>
34+
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
35+
[JsonSerializable(typeof(CopilotDeviceCode))]
36+
[JsonSerializable(typeof(CopilotAccessTokenResponse))]
37+
[JsonSerializable(typeof(CopilotBearerResponse))]
38+
public sealed partial class CopilotJsonContext : JsonSerializerContext;
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
using System.Text.Json.Serialization.Metadata;
4+
5+
namespace WinHarness.Providers;
6+
7+
/// <summary>
8+
/// GitHub Copilot OAuth device-code flow and short-lived bearer exchange.
9+
/// Endpoints, client id, and required headers verified against pi's shipping
10+
/// implementation (ADR-0005). All endpoint knowledge lives here so vendor
11+
/// drift is a single-file fix.
12+
/// </summary>
13+
public sealed class GitHubCopilotOAuthFlow : IOAuthTokenRefresher
14+
{
15+
// VS Code Copilot Chat client id, stored base64-obfuscated per ADR-0005.
16+
private static readonly string ClientId =
17+
System.Text.Encoding.UTF8.GetString(Convert.FromBase64String("SXYxLmI1MDdhMDhjODdlY2ZlOTg="));
18+
19+
private static readonly Dictionary<string, string> CopilotHeaders = new(StringComparer.Ordinal)
20+
{
21+
["User-Agent"] = "GitHubCopilotChat/0.35.0",
22+
["Editor-Version"] = "vscode/1.107.0",
23+
["Editor-Plugin-Version"] = "copilot-chat/0.35.0",
24+
["Copilot-Integration-Id"] = "vscode-chat",
25+
};
26+
27+
/// <summary>Fallback base URL when the bearer carries no proxy endpoint.</summary>
28+
public const string DefaultBaseUrl = "https://api.individual.githubcopilot.com";
29+
30+
private readonly HttpClient _http;
31+
private readonly string _domain;
32+
33+
/// <summary>Creates the flow against github.com or an enterprise domain.</summary>
34+
public GitHubCopilotOAuthFlow(HttpClient http, string domain = "github.com")
35+
{
36+
_http = http;
37+
_domain = domain;
38+
}
39+
40+
/// <inheritdoc />
41+
public string OAuthProviderId => "copilot";
42+
43+
/// <summary>
44+
/// Starts the device flow: returns the user code and verification URI to
45+
/// display, plus polling parameters.
46+
/// </summary>
47+
public async ValueTask<CopilotDeviceCode> StartDeviceFlowAsync(CancellationToken cancellationToken)
48+
{
49+
using HttpRequestMessage request = new(HttpMethod.Post, $"https://{_domain}/login/device/code");
50+
request.Headers.TryAddWithoutValidation("Accept", "application/json");
51+
request.Headers.TryAddWithoutValidation("User-Agent", CopilotHeaders["User-Agent"]);
52+
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
53+
{
54+
["client_id"] = ClientId,
55+
["scope"] = "read:user",
56+
});
57+
58+
CopilotDeviceCode device = await SendAsync(
59+
request,
60+
CopilotJsonContext.Default.CopilotDeviceCode,
61+
cancellationToken).ConfigureAwait(false);
62+
if (string.IsNullOrEmpty(device.DeviceCode) || string.IsNullOrEmpty(device.UserCode) ||
63+
!Uri.TryCreate(device.VerificationUri, UriKind.Absolute, out Uri? uri) ||
64+
(uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
65+
{
66+
throw new InvalidOperationException("Invalid device code response from GitHub.");
67+
}
68+
69+
return device;
70+
}
71+
72+
/// <summary>
73+
/// Polls the access-token endpoint until the user authorizes (RFC 8628:
74+
/// default 5s interval, +5s on slow_down, deadline from expires_in).
75+
/// Returns the long-lived GitHub token (the "refresh" credential).
76+
/// </summary>
77+
public async ValueTask<string> PollForGitHubTokenAsync(CopilotDeviceCode device, CancellationToken cancellationToken)
78+
{
79+
TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, device.Interval ?? 5));
80+
DateTimeOffset deadline = DateTimeOffset.UtcNow.AddSeconds(device.ExpiresIn);
81+
82+
while (DateTimeOffset.UtcNow < deadline)
83+
{
84+
cancellationToken.ThrowIfCancellationRequested();
85+
86+
using HttpRequestMessage request = new(HttpMethod.Post, $"https://{_domain}/login/oauth/access_token");
87+
request.Headers.TryAddWithoutValidation("Accept", "application/json");
88+
request.Headers.TryAddWithoutValidation("User-Agent", CopilotHeaders["User-Agent"]);
89+
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
90+
{
91+
["client_id"] = ClientId,
92+
["device_code"] = device.DeviceCode,
93+
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
94+
});
95+
96+
CopilotAccessTokenResponse response = await SendAsync(
97+
request,
98+
CopilotJsonContext.Default.CopilotAccessTokenResponse,
99+
cancellationToken).ConfigureAwait(false);
100+
101+
if (!string.IsNullOrEmpty(response.AccessToken))
102+
{
103+
return response.AccessToken;
104+
}
105+
106+
switch (response.Error)
107+
{
108+
case "authorization_pending":
109+
break;
110+
case "slow_down":
111+
interval += TimeSpan.FromSeconds(5);
112+
break;
113+
default:
114+
throw new InvalidOperationException(
115+
$"Device flow failed: {response.Error}{(string.IsNullOrEmpty(response.ErrorDescription) ? "" : $": {response.ErrorDescription}")}");
116+
}
117+
118+
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
119+
}
120+
121+
throw new InvalidOperationException("Device flow timed out before the code was authorized.");
122+
}
123+
124+
/// <inheritdoc />
125+
public async ValueTask<OAuthTokenSet> RefreshAsync(OAuthTokenSet current, CancellationToken cancellationToken)
126+
{
127+
if (string.IsNullOrEmpty(current.RefreshToken))
128+
{
129+
throw new InvalidOperationException("No GitHub token stored; run 'winharness login --provider copilot' again.");
130+
}
131+
132+
return await ExchangeForBearerAsync(current.RefreshToken, cancellationToken).ConfigureAwait(false);
133+
}
134+
135+
/// <summary>
136+
/// Exchanges the long-lived GitHub token for a short-lived Copilot bearer.
137+
/// The bearer embeds a proxy endpoint that becomes the chat base URL.
138+
/// </summary>
139+
public async ValueTask<OAuthTokenSet> ExchangeForBearerAsync(string githubToken, CancellationToken cancellationToken)
140+
{
141+
using HttpRequestMessage request = new(HttpMethod.Get, $"https://api.{_domain}/copilot_internal/v2/token");
142+
request.Headers.TryAddWithoutValidation("Accept", "application/json");
143+
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {githubToken}");
144+
foreach ((string name, string value) in CopilotHeaders)
145+
{
146+
request.Headers.TryAddWithoutValidation(name, value);
147+
}
148+
149+
CopilotBearerResponse bearer = await SendAsync(
150+
request,
151+
CopilotJsonContext.Default.CopilotBearerResponse,
152+
cancellationToken).ConfigureAwait(false);
153+
if (string.IsNullOrEmpty(bearer.Token) || bearer.ExpiresAt is null)
154+
{
155+
throw new InvalidOperationException("Invalid Copilot token response.");
156+
}
157+
158+
return new OAuthTokenSet(
159+
AccessToken: bearer.Token,
160+
RefreshToken: githubToken,
161+
ExpiresAt: DateTimeOffset.FromUnixTimeSeconds(bearer.ExpiresAt.Value),
162+
Scopes: "read:user",
163+
BaseUrl: ExtractBaseUrl(bearer.Token));
164+
}
165+
166+
/// <summary>
167+
/// Extracts the API base URL from the bearer's embedded proxy endpoint
168+
/// (format: "tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;...").
169+
/// </summary>
170+
public static string ExtractBaseUrl(string bearerToken)
171+
{
172+
foreach (string part in bearerToken.Split(';'))
173+
{
174+
if (part.StartsWith("proxy-ep=", StringComparison.Ordinal))
175+
{
176+
string host = part["proxy-ep=".Length..];
177+
if (host.StartsWith("proxy.", StringComparison.Ordinal))
178+
{
179+
host = "api." + host["proxy.".Length..];
180+
}
181+
182+
return $"https://{host}";
183+
}
184+
}
185+
186+
return DefaultBaseUrl;
187+
}
188+
189+
private async ValueTask<T> SendAsync<T>(
190+
HttpRequestMessage request,
191+
JsonTypeInfo<T> typeInfo,
192+
CancellationToken cancellationToken)
193+
{
194+
using HttpResponseMessage response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
195+
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
196+
if (!response.IsSuccessStatusCode)
197+
{
198+
throw new InvalidOperationException($"{(int)response.StatusCode} {response.ReasonPhrase}: {body}");
199+
}
200+
201+
return JsonSerializer.Deserialize(body, typeInfo)
202+
?? throw new InvalidOperationException("Empty response from GitHub.");
203+
}
204+
}

src/WinHarness.Providers/ProviderServiceCollectionExtensions.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ public static class ProviderServiceCollectionExtensions
1313
public static IServiceCollection AddWinHarnessProviders(this IServiceCollection services)
1414
{
1515
services.AddSingleton<IModelCapabilityRegistry, ConfigurationModelCapabilityRegistry>();
16-
services.AddSingleton<IProviderFactory, OpenAiCompatibleProviderFactory>();
16+
services.AddSingleton<IOAuthTokenRefresher>(static _ => new GitHubCopilotOAuthFlow(new HttpClient()));
17+
services.AddSingleton<IProviderFactory>(static provider => new OpenAiCompatibleProviderFactory(
18+
provider.GetRequiredService<Configuration.WinHarnessOptions>(),
19+
provider.GetRequiredService<Platform.ICredentialStore>(),
20+
provider.GetServices<IOAuthTokenRefresher>()));
1721
services.AddSingleton<IModelCatalog, OpenAiCompatibleModelCatalog>();
1822
services.AddSingleton<IModelCapabilityInferrer, ModelCapabilityInferrer>();
1923
services.AddSingleton<IOpenRouterModelCatalog, OpenRouterModelCatalog>();

0 commit comments

Comments
 (0)