Authenticate marketplace API requests via RFC 9728 PRM negotiation (Entra ID / GitHub)
- Dominant language
- TypeScript
- Stars
- 193k
- Forks
- 42.4k
- PR merge metrics
- PR metrics pending
Description
Parent: #280376 — _Enable VS Code sign-in with Microsoft Entra ID to connect to Private Marketplace._
Builds on: PR #325331 (provider-aware marketplace access + server-enforced eligibility).
## Summary
#280376 made Private Marketplace access **provider-aware** (GitHub default + a new Microsoft/Entra path) and added **server-enforced eligibility** for the Entra path. #280376 does **not** authenticate the marketplace API requests themselves — it assumes the service index (`GET /api/v1`) is open.
This change adds the missing negotiation layer: when a deployment **enables index authentication**, the service index becomes `[Authorize]`-gated and an unauthenticated probe returns `401 + WWW-Authenticate`. The client must discover the deployment's Protected Resource Metadata (RFC 9728), acquire a **resource-bound bearer token** (RFC 8707), retry the index, and attach that token to **every** protected marketplace request (extension query, asset download, eligibility).
This is a net-new negotiation layer, stacked on #280376, and is **not** blocking for Entra-only deployments — which is why it ships separately.
## Goal
When the marketplace index is `[Authorize]`-gated (probe returns `401`), negotiate a resource-bound bearer token via PRM discovery and attach it to all marketplace API requests. When auth is disabled (probe returns `200`, PRM absent), behavior is **identical to today** — no `Authorization` header is sent.
## Background: the negotiation contract
A deployment enforces **exactly one** scheme — **Entra ID XOR GitHub**, never both.
**Auth DISABLED** (unchanged from #280376):
- `GET /api/v1` → `200`; PRM document absent; no `Authorization` header sent.
- GitHub `checkAccess()` still runs client-side as today.
**Auth ENABLED:**
1. **Probe:** `GET /api/v1` → `401` with a `Bearer` challenge carrying a `resource_metadata` URL.
2. **PRM fetch:** `GET ` (i.e. `/.well-known/oauth-protected-resource`, always open). Read `resource`, a **single-entry** `authorization_servers`, `bearer_methods_supported`, and `scopes_supported`.
3. **Scheme confirmation:** confirm the PRM's single advertised AS matches the `authProvider` policy.
4. **Token acquisition (RFC 8707 audience binding):**
- **Entra:** `getSessions('microsoft', { resource: prmResource, scopes: prmScopes })` — tenant-issued token.
- **GitHub:** exchange the GitHub session token the client already holds at the deployment's **embedded Authorization Server** (RFC 8693 token exchange, `resource = prmResource`) for a first-party, audience-bound token. The raw GitHub token is **never** sent to the resource server (token passthrough is prohibited); no second GitHub sign-in is required.
5. **Retry + attach:** retry the index with `Authorization: Bearer `; attach the token to all subsequent marketplace API requests.
**Eligibility remains scheme-specific:**
- **Entra/MSA** — the token is POSTed to `/eligibility` (server-side VSS/Entra check) **and** used for API authentication.
- **GitHub** — eligibility is determined **client-side** via `checkAccess()` (unchanged); the token is used for API authentication **only** and is **not** POSTed to `/eligibility`.
## Scope
### In scope
| # | Change | Notes |
|---|--------|-------|
| 1 | `parseWwwAuthenticate(headers: string[])` RFC 7235 parser | New helper; extract `resource_metadata`, `realm`, `scope` from the `Bearer` challenge. Handle multiple challenges, quoted/unquoted params, and folded headers. |
| 2 | Rework `discoverEligibilityUrl()` → 2-phase probe/retry returning `{ eligibilityUrl, token }` | `GET /api/v1` → `200` (open) or `401` (gated). |
| 3 | PRM fetch (RFC 9728) at `/.well-known/oauth-protected-resource` | Read `resource`, `authorization_servers`, `scopes_supported`; confirm advertised AS matches policy. |
| 4 | Resource-scoped `getSessions` (RFC 8707) | Reuse the `IAuthenticationWwwAuthenticateRequest` overload of `getSessions`. |
| 5 | GitHub auth-ENABLED path + RFC 8693 token exchange | `handleGitHubAccess(url, probeToken?)`; client-side `checkAccess()` retained; no `/eligibility` POST. |
| 6 | Thread bearer token to index / extensionquery / asset endpoints | Store the probe token; attach `Authorization: Bearer` on all protected marketplace requests. |
| 7 | `onDidChangeSessions('github')` invalidation for the auth-enabled path | Revoke/recompute on session change. |
| 8 | Tests (see matrix) | Auth enabled/disabled, PRM parse, token exchange, header attach/omission. |
### Out of scope (already delivered in PR1)
- Provider routing / `getEffectiveAuthProvider()`, `authProvider` setting + policy, the product flag gate.
- Entra server-side eligibility POST, cache scoping (`authProvider`+`accountId`+`serviceUrl`), 401/403 split, validation-epoch race guard, `Unreachable`/`Misconfigured` states.
### Explicitly deferred / not in this issue
- Arbitrary `sessions[0]` multi-account selection (separate design discussion).
- Service-URL port normalization for the same-origin token guard.
## Security requirements (carry forward from PR1, extended)
- Bearer token is sent only to an **HTTPS, exact same-origin** target as the admin-configured service index (`isSafeTokenTarget`); PRM-advertised `resource` must match that origin.
- Token-bearing requests **never follow redirects**.
- Raw GitHub tokens are **never** forwarded to the resource server — only the exchanged first-party token.
- `401` (missing/expired/wrong-audience) → `RequiresSignIn`, never cached; `403` (identity accepted, forbidden) → `AccessDenied`, cached ineligible.
## Files to change
- `src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts` — 2-phase probe/retry, `parseWwwAuthenticate()`, PRM fetch, RFC 8707 resource-scoped `getSessions()`, RFC 8693 GitHub exchange, token threading to all API requests.
- `src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts` — PRM/challenge types as needed.
- Test file: `.../test/electron-browser/extensionGalleryManifestService.test.ts`.
## Server contract this depends on
When auth is enabled, the deployment must: (1) return `401 + WWW-Authenticate` (with `resource_metadata`) on unauthenticated `GET /api/v1`; (2) serve an open PRM document at `GET /.well-known/oauth-protected-resource` with `resource`, `authorization_servers`, `bearer_methods_supported`, `scopes_supported`; (3) on authenticated `GET /api/v1`, include the `EligibilityService` resource. When auth is disabled, `GET /api/v1` returns `200` and PRM is absent. `CanonicalBaseUri` defines the PRM `resource` and expected token audience. (See `docs/design/ApiAuthentication.md` in the PrivateMarketplace repo.)
## Test matrix
| # | Scenario | Expected |
|---|----------|----------|
| 1 | GitHub, auth DISABLED (index `200`, no PRM), eligible | `Available`; client-side check; **no** token on API calls |
| 2 | GitHub, auth ENABLED (index `401`), no session | `RequiresSignIn` |
| 3 | GitHub, auth ENABLED (index `401`), session + `checkAccess()` passes | `Available`; result cached; resource-scoped token stored & attached; **no** `/eligibility` POST |
| 4 | Entra, auth ENABLED (index `401`), session acquired, eligible | Index retried with bearer → `200`; `EligibilityService` read; `POST /eligibility` → eligible; token attached |
| 5 | Entra, auth DISABLED (index `200`, no PRM) | Falls back to GitHub client-side auth; no token sent |
| 6 | `parseWwwAuthenticate()` unit tests | Multiple challenges, quoted/unquoted params, missing `resource_metadata` |
| 7 | Bearer header **present** when auth ENABLED | `Authorization: Bearer` on extension-query request (resource-scoped) |
| 8 | Bearer header **absent** when auth DISABLED | No `Authorization` header on extension-query request |
| 9 | `onDidChangeSessions('github')` invalidation | Cached verdict recomputed after session change |
## Acceptance criteria
- [ ] Auth-disabled deployments behave exactly as today (no `Authorization` header, GitHub client-side check unchanged).
- [ ] Auth-enabled deployments negotiate PRM, acquire a resource-bound token, and attach it to every protected request.
- [ ] GitHub uses RFC 8693 exchange; the raw GitHub token never reaches the resource server.
- [ ] Entra continues to POST the token to `/eligibility`; GitHub never does.
- [ ] Security guards from PR1 (same-origin, no-redirect, 401/403 split, cache scoping) hold on the new code paths.
- [ ] `npm run typecheck-client`, the gallery unit suite, and `npm run valid-layers-check` pass.
## Effort / risk
~3–5 days for the parser and negotiation layer; low–medium risk. The GitHub server-side validation mechanism (embedded AS, RFC 8693) and the challenge format are finalized. Not blocking for Entra-only deployments.
## Rollout note
A client that understands closed-index-`401` + PRM discovery **must ship and propagate to all users before the server enables index authentication.** The admin controls both the deployment and the minimum required VS Code version via enterprise update policy.
Contributor guide
Assessment
This issue has not been assessed yet.