|
| 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. |
0 commit comments