Skip to content

Commit efafb9f

Browse files
[CmdPal] Add OAuth sign-in sample and authentication spec
Phase 4 of the built-in Command Palette authentication feature. Sample (SamplePagesExtension): - SampleOAuthPage: entry page that offers a demo sign-in and shows a graceful message when the host does not support authorization. - OAuthSignInCommand: runs Authorization Code + PKCE via the Toolkit OAuthClient (secretless public client, loopback redirect, Duende public demo defaults), optionally persists with CredentialManagerTokenStore, then calls ExtensionHost.GoToPageAsync to navigate to the signed-in page. - SampleSignedInPage: landing page rendering non-sensitive session facts only (never the raw token). - Registered the entry page in SamplesListPage. Docs: - src/modules/cmdpal/doc/authentication.md: feature spec covering the host-broker vs Toolkit split, a mermaid sequence diagram, the SDK contract, capability detection, the security model, token-storage guidance, and a bring-your-own-provider GitHub example. The sample is illustrative. Running it needs a real identity provider and an interactive browser sign-in, so the live flow was not runtime verified. Co-authored-by: Copilot App <[email protected]> Copilot-Session: 2ee31cb3-848f-43ba-ac48-f4e4485baa33
1 parent e042e0d commit efafb9f

5 files changed

Lines changed: 492 additions & 0 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# Command Palette authentication
2+
3+
Command Palette can broker interactive OAuth sign-in for extensions. The host owns
4+
the risky, shared parts of the redirect (opening the browser, allocating and
5+
hosting the `redirect_uri`, generating and validating `state`, capturing the
6+
redirect, and re-foregrounding the palette). The extension owns the parts that
7+
must stay private to it (PKCE, the token exchange, and any token storage). Command
8+
Palette never sees or stores third-party tokens.
9+
10+
This document describes what shipped across the built-in auth work: the host
11+
broker, the SDK contract, and the `Microsoft.CommandPalette.Extensions.Toolkit`
12+
helpers that most extensions should use.
13+
14+
## Overview and motivation
15+
16+
Extensions frequently need to call an authenticated API. Before this feature each
17+
extension had to stand up its own loopback listener or register a custom URI
18+
scheme, handle `state`, and re-focus the palette after the browser round trip.
19+
That is easy to get subtly wrong in ways that are security relevant.
20+
21+
The built-in flow gives every extension a single, hardened redirect broker:
22+
23+
- The host opens the system browser, allocates the `redirect_uri`, injects a
24+
cryptographically random `state`, captures the redirect, validates `state`, and
25+
returns the captured query parameters to the extension that started the flow.
26+
- The extension keeps the PKCE verifier and performs the token exchange in its own
27+
process. The host only ever handles the front-channel redirect, never the tokens.
28+
29+
## Architecture
30+
31+
There are two cooperating pieces.
32+
33+
1. Host broker (`IExtensionHost2.RequestAuthorizationAsync`). Runs inside Command
34+
Palette. It opens the browser, hosts the `redirect_uri`, routes `state`,
35+
captures the redirect, and re-foregrounds the palette. It hands back an
36+
`IAuthorizationResult` with the exact `redirect_uri` it used and the captured
37+
response parameters (with `state` already validated and stripped).
38+
2. Toolkit `OAuthClient`. Runs inside the extension process. It generates the PKCE
39+
verifier and challenge, builds the authorization parameters, calls the host
40+
broker, and then exchanges the returned code at the provider token endpoint. It
41+
returns an `OAuthToken`.
42+
43+
```mermaid
44+
sequenceDiagram
45+
autonumber
46+
participant Ext as Extension (OAuthClient)
47+
participant Host as Command Palette host (IExtensionHost2)
48+
participant Browser as System browser
49+
participant Idp as Identity provider
50+
51+
Ext->>Ext: Generate PKCE verifier and S256 challenge
52+
Ext->>Host: RequestAuthorizationAsync(request)
53+
Note right of Host: Host allocates redirect_uri<br/>and a random state
54+
Host->>Browser: Open authorization endpoint<br/>(client_id, scope, code_challenge, state, redirect_uri)
55+
Browser->>Idp: User authenticates and consents
56+
Idp-->>Browser: Redirect to redirect_uri?code=...&state=...
57+
Browser-->>Host: Redirect captured
58+
Host->>Host: Validate state, strip it, re-foreground palette
59+
Host-->>Ext: IAuthorizationResult (RedirectUri, code, ...)
60+
Ext->>Idp: POST token endpoint<br/>(code, code_verifier, redirect_uri, client_id)
61+
Idp-->>Ext: access_token (and optional refresh_token, id_token)
62+
Ext->>Ext: Optionally persist via ITokenStore
63+
Ext->>Host: GoToPageAsync(signedInPage, Push)
64+
Host-->>Ext: Navigates the palette to the signed-in page
65+
```
66+
67+
## SDK contract
68+
69+
The contract lives in `Microsoft.CommandPalette.Extensions.idl` and is surfaced to
70+
extensions through the Toolkit.
71+
72+
### `IExtensionHost2`
73+
74+
`IExtensionHost2` extends `IExtensionHost`. A host that implements it supports both
75+
authorization and host-driven navigation.
76+
77+
- `IAsyncOperation<IAuthorizationResult> RequestAuthorizationAsync(IAuthorizationRequest request)`
78+
runs the interactive flow. The returned operation is cancelable.
79+
- `IAsyncAction GoToPageAsync(ICommand page, NavigationMode navigationMode)`
80+
navigates the palette to one of the extension's live page objects. This is how a
81+
sign-in command sends the user to a signed-in landing page.
82+
83+
### `IAuthorizationRequest`
84+
85+
What the extension supplies to start a flow:
86+
87+
- `DisplayName`: friendly name shown in the "waiting to sign in" status.
88+
- `AuthorizationEndpoint`: the provider authorize URL.
89+
- `Parameters`: query parameters appended to the authorize URL. Do not include
90+
`redirect_uri` or `state`; the host injects both.
91+
- `RedirectKind`: `AuthorizationRedirectKind.Loopback` or `CustomScheme`.
92+
- `TimeoutSeconds`: how long to wait for the redirect. `0` means the host default
93+
(60 seconds). The host caps this at 300 seconds.
94+
95+
### `IAuthorizationResult`
96+
97+
What the host hands back:
98+
99+
- `IsSuccessful`: whether the redirect was captured.
100+
- `RedirectUri`: the exact `redirect_uri` the host used. RFC 6749 requires this to
101+
be sent to the token endpoint during code exchange, so `OAuthClient` forwards it.
102+
- `ResponseParameters`: the captured query parameters (for example `code`). The
103+
host has already validated and removed `state`.
104+
- `Error`: populated when `IsSuccessful` is false (provider error, timeout, user
105+
cancellation, or a broker failure).
106+
107+
### `AuthorizationRedirectKind`
108+
109+
- `Loopback` (`0`): `http://127.0.0.1:{ephemeral-port}/`, RFC 8252 loopback
110+
redirection. This has the broadest provider support and is the default.
111+
- `CustomScheme` (`1`): `x-cmdpal://auth/callback`, using Command Palette's
112+
registered protocol. It auto-foregrounds the palette, but only works with
113+
providers that allow custom-scheme redirect URIs.
114+
115+
### `NavigationMode`
116+
117+
Used by `GoToPageAsync`:
118+
119+
- `Push`: push the target page onto the navigation stack (the default).
120+
- `GoBack`: go back one page, then navigate to the target page.
121+
- `GoHome`: go back to the home page, then navigate to the target page.
122+
123+
## Capability detection
124+
125+
Not every installed Command Palette is new enough to broker auth. The Toolkit
126+
exposes two static flags on `ExtensionHost`:
127+
128+
- `ExtensionHost.SupportsAuthorization`
129+
- `ExtensionHost.SupportsNavigation`
130+
131+
Both are true only when the connected host implements `IExtensionHost2`. Check the
132+
relevant flag before offering a sign-in action. If you call
133+
`ExtensionHost.RequestAuthorizationAsync` or `ExtensionHost.GoToPageAsync` against
134+
an older host, the Toolkit throws `NotSupportedException`. Prefer the capability
135+
check so you can show a graceful message instead of surfacing an exception.
136+
137+
```csharp
138+
if (!ExtensionHost.SupportsAuthorization)
139+
{
140+
// Show a "sign-in is not available, please update" message.
141+
return;
142+
}
143+
```
144+
145+
## Security model
146+
147+
The broker is designed so that a mistake in an extension cannot leak tokens through
148+
the host, and so that the front-channel redirect is hard to spoof.
149+
150+
- PKCE is required. `OAuthClient` always sends `code_challenge` with
151+
`code_challenge_method=S256`. The verifier never leaves the extension process.
152+
- Public clients only. The sample and the recommended pattern use no client secret.
153+
Do not ship a secret in an extension; treat every extension as a public client.
154+
- `state` is host-owned, random, and single use. The host generates it, matches it
155+
on the redirect, and strips it before returning, so extensions never handle it.
156+
- `redirect_uri` binding. The host returns the exact `redirect_uri` it used and the
157+
extension must send that same value to the token endpoint (RFC 6749). `OAuthClient`
158+
does this for you.
159+
- Loopback is bound to `127.0.0.1` only. The host listens on the loopback interface
160+
with an ephemeral port, per RFC 8252.
161+
- No token storage in the host. Command Palette only brokers the front-channel
162+
redirect. Tokens are exchanged and, if desired, stored entirely inside the
163+
extension process.
164+
- Timeout caps. `TimeoutSeconds` defaults to 60 seconds and the host caps it at 300
165+
seconds, so a stuck flow cannot wait forever.
166+
167+
Do not log or display the authorization code, the access or refresh token, or the
168+
PKCE verifier. Surface only generic status on failure.
169+
170+
## Token storage guidance
171+
172+
Persisting tokens is optional and always happens inside the extension process. The
173+
Toolkit provides:
174+
175+
- `ITokenStore`: a small `Retrieve` / `Save` / `Remove` abstraction keyed by a
176+
string.
177+
- `CredentialManagerTokenStore`: an `ITokenStore` backed by the Windows Credential
178+
Manager (`PasswordVault`). Tokens are encrypted at rest per user and require the
179+
extension to run as a packaged app, which Command Palette extensions do. Use a
180+
distinct namespace per provider or account so keys do not collide.
181+
182+
Caveat: `PasswordVault` limits the size of a stored secret to a few kilobytes. That
183+
is fine for typical access and refresh tokens, but very large JWTs can exceed it, so
184+
guard `Save` in a try/catch and treat storage as best effort.
185+
186+
`OAuthToken.IsExpired(skew)` helps you decide when to refresh. Use
187+
`OAuthClient.RefreshAsync(refreshToken)` when the provider issued a refresh token
188+
(request the `offline_access` scope if the provider needs it).
189+
190+
## Bring your own provider
191+
192+
The sample defaults to the Duende IdentityServer public demo because it is a
193+
secretless, PKCE-friendly test provider. To target your own provider, register a
194+
public (native or desktop) OAuth client and swap the endpoints, client id, and
195+
scopes.
196+
197+
### GitHub example
198+
199+
GitHub supports the Authorization Code flow and works well with the loopback
200+
redirect.
201+
202+
1. Create an OAuth app at GitHub Developer settings. GitHub requires a registered
203+
callback URL and does not support custom-scheme redirect URIs, so use
204+
`AuthorizationRedirectKind.Loopback`. Register a loopback callback such as
205+
`http://127.0.0.1/` (GitHub matches on the host and path, and the broker uses an
206+
ephemeral port).
207+
2. Configure the client:
208+
209+
```csharp
210+
var github = new OAuthClient
211+
{
212+
ClientId = "<your Client ID>",
213+
AuthorizationEndpoint = "https://github.com/login/oauth/authorize",
214+
TokenEndpoint = "https://github.com/login/oauth/access_token",
215+
Scopes = ["read:user"],
216+
RedirectKind = AuthorizationRedirectKind.Loopback,
217+
DisplayName = "My extension",
218+
};
219+
220+
var token = await github.AuthorizeAsync();
221+
```
222+
223+
GitHub's token endpoint defaults to a form-encoded response; `OAuthClient` sends
224+
`Accept: application/json` so it receives JSON. Note that GitHub personal OAuth apps
225+
do not issue refresh tokens by default. If you need refresh tokens, use a GitHub App
226+
with expiring user tokens, which follows the same Authorization Code shape.
227+
228+
Confirm your provider's exact endpoints, supported redirect types, and scope names
229+
from its own documentation before shipping.
230+
231+
## Try the sample
232+
233+
`SamplePagesExtension` includes a runnable illustration under
234+
`Samples` -> `Sample: OAuth sign-in`. It:
235+
236+
1. Checks `ExtensionHost.SupportsAuthorization` and shows a graceful message on
237+
older hosts.
238+
2. Runs `OAuthClient.AuthorizeAsync` against the Duende public demo.
239+
3. Optionally persists the token with `CredentialManagerTokenStore`.
240+
4. Calls `ExtensionHost.GoToPageAsync` to navigate to a signed-in landing page that
241+
shows non-sensitive session facts only.
242+
243+
The sample is illustrative. Running it opens a real browser and needs an
244+
interactive sign-in against a real identity provider, so it cannot be exercised
245+
headlessly.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Threading.Tasks;
7+
using Microsoft.CommandPalette.Extensions;
8+
using Microsoft.CommandPalette.Extensions.Toolkit;
9+
10+
namespace SamplePagesExtension;
11+
12+
/// <summary>
13+
/// Demonstrates the built-in Command Palette authorization flow. On invoke it
14+
/// runs an OAuth 2.0 Authorization Code + PKCE sign-in through the host redirect
15+
/// broker (via the Toolkit <see cref="OAuthClient"/>), optionally persists the
16+
/// token in the Windows Credential Manager, and then asks the host to navigate to
17+
/// a signed-in landing page.
18+
/// </summary>
19+
/// <remarks>
20+
/// This sample is illustrative. Running it requires a real identity provider and
21+
/// an interactive browser sign-in, so it cannot be exercised headlessly. The
22+
/// defaults below target the Duende IdentityServer public demo, which is a
23+
/// well-known, secretless, PKCE-friendly test provider. Swap the constants for
24+
/// your own registered public client to bring your own provider.
25+
/// </remarks>
26+
internal sealed partial class OAuthSignInCommand : InvokableCommand
27+
{
28+
// Demo identity provider values. These are safe to ship because the flow uses
29+
// PKCE with a public client (there is no client secret). Replace them with your
30+
// own registered client to target a different provider.
31+
private const string DemoClientId = "interactive.public";
32+
private const string DemoAuthorizationEndpoint = "https://demo.duendesoftware.com/connect/authorize";
33+
private const string DemoTokenEndpoint = "https://demo.duendesoftware.com/connect/token";
34+
private const string DemoScopes = "openid profile";
35+
36+
// A distinct namespace so the optional token store does not collide with other
37+
// extensions that also persist tokens.
38+
private const string TokenStoreNamespace = "SamplePagesExtension.OAuthSample";
39+
private const string TokenStoreKey = "demo.duende";
40+
41+
public OAuthSignInCommand()
42+
{
43+
Name = "Sign in";
44+
Icon = new IconInfo("\uE8FA"); // Permissions
45+
}
46+
47+
public override ICommandResult Invoke()
48+
{
49+
// Older Command Palette hosts do not implement the authorization broker.
50+
// Fail politely instead of throwing NotSupportedException at the user.
51+
if (!ExtensionHost.SupportsAuthorization)
52+
{
53+
return CommandResult.ShowToast(
54+
"This build of Command Palette does not support sign-in. Update Command Palette to try the built-in authorization flow.");
55+
}
56+
57+
// The interactive flow waits for a browser round trip, so run it off the
58+
// invoke thread. On success the host drives navigation to the signed-in
59+
// page; on failure we surface a short status toast.
60+
_ = SignInAsync();
61+
62+
// Keep the entry page open while the browser sign-in happens.
63+
return CommandResult.KeepOpen();
64+
}
65+
66+
private static async Task SignInAsync()
67+
{
68+
try
69+
{
70+
var client = new OAuthClient
71+
{
72+
ClientId = DemoClientId,
73+
AuthorizationEndpoint = DemoAuthorizationEndpoint,
74+
TokenEndpoint = DemoTokenEndpoint,
75+
Scopes = DemoScopes.Split(' ', StringSplitOptions.RemoveEmptyEntries),
76+
RedirectKind = AuthorizationRedirectKind.Loopback,
77+
DisplayName = "Command Palette OAuth sample",
78+
};
79+
80+
// Runs PKCE generation, the host-brokered redirect, and the token
81+
// exchange. Everything sensitive stays inside this extension process.
82+
var token = await client.AuthorizeAsync().ConfigureAwait(false);
83+
84+
TryPersist(token);
85+
86+
// Phase 3 host-driven navigation: move Command Palette to a signed-in
87+
// landing page that shows non-sensitive facts about the session.
88+
await ExtensionHost.GoToPageAsync(new SampleSignedInPage(token), NavigationMode.Push).ConfigureAwait(false);
89+
}
90+
catch (Exception ex)
91+
{
92+
// Never surface the authorization code, token, or PKCE verifier. Keep
93+
// the user-facing message generic and only log the exception type.
94+
new ToastStatusMessage("Sign-in did not complete. See the Command Palette logs for details.").Show();
95+
ExtensionHost.LogMessage($"OAuth sample sign-in failed: {ex.GetType().Name}");
96+
}
97+
}
98+
99+
private static void TryPersist(OAuthToken token)
100+
{
101+
// Persisting the token is optional. Guard it so a storage failure (for
102+
// example an oversized token that exceeds the vault limit) does not break
103+
// the demo.
104+
try
105+
{
106+
var store = new CredentialManagerTokenStore(TokenStoreNamespace);
107+
store.Save(TokenStoreKey, token);
108+
}
109+
catch (Exception ex)
110+
{
111+
ExtensionHost.LogMessage($"OAuth sample could not persist the token: {ex.GetType().Name}");
112+
}
113+
}
114+
}

0 commit comments

Comments
 (0)