microsoft / microsoft/vscode

Authenticate marketplace API requests via RFC 9728 PRM negotiation (Entra ID / GitHub)

Open
#325,412 0 comments 0 reactions 1 assignee Claimed by @sandy081 View on 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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.