Allow public extension installs past SAML enforcement - #14131
Conversation
|
Thanks for your pull request! Unfortunately, it doesn't meet the requirements for review:
Please update your PR to address the above. This PR will be automatically closed in 4 days if these requirements are not met. Full contribution requirements
|
There was a problem hiding this comment.
Pull request overview
Adds anonymous retry support for public extension requests blocked by SAML enforcement.
Changes:
- Wraps the extension HTTP client with SAML fallback behavior.
- Retries bodyless GET requests anonymously after SAML-related 403 responses.
- Adds transport and end-to-end installation tests.
Show a summary per file
| File | Description |
|---|---|
pkg/cmd/factory/default.go |
Wires the fallback into the extension manager. |
pkg/cmd/extension/saml_fallback.go |
Implements the fallback transport. |
pkg/cmd/extension/saml_fallback_test.go |
Tests fallback and installation behavior. |
Review details
Tip
Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Balanced
| // SAML enforcement rejects a valid token even for public extension repos. | ||
| if plainClient, plainErr := f.PlainHttpClient(); plainErr == nil { | ||
| client = extension.WithSAMLFallback(client, plainClient) |
| _, _ = io.Copy(io.Discard, res.Body) | ||
| res.Body.Close() | ||
| return plainRes, nil |
| plainRes, plainErr := t.plain.RoundTrip(pristine) | ||
| if plainErr != nil || plainRes.StatusCode >= http.StatusBadRequest { |
Organization SAML enforcement makes an otherwise valid token return 403 when gh reads release metadata or downloads a release asset for a public extension, so `gh extension install github/gh-net` fails even though the same release is publicly downloadable with no authentication at all. Retry those requests anonymously at the transport layer instead. A successful unauthenticated retry proves the repository is public, so no separate visibility check is needed, and when the retry does not succeed the original authenticated response is returned so private repositories and non-SAML failures keep their existing error messages. Only bodyless GETs are replayed, and the request is snapshotted before the authenticated chain runs so the retry cannot resend the token. Fixes cli#6675 Co-authored-by: Copilot App <[email protected]>
f58173c to
24cdee4
Compare
|
Closing this. I should have read the history more carefully before opening it. The team already decided against this approach in #6675 (comment) — unauthenticated requests to the API are out because of the shared-IP rate limit. That applies to this implementation as much as any other, so it isn't a viable direction however it's written. The more useful thing I found while digging is that this isn't really fixable in the CLI. The 403 comes from SAML enforcement being applied to a public repo, so any client-side workaround is either unauthenticated and rate-limited, or nothing. I've raised it with the team that owns SAML enforcement. Sorry for the churn. |
Fixes #6675
Supersedes #14108, which was auto-closed for size. Per @BagToad's feedback, this rewrite drops from 646 changed lines across 10 files to 433 across 3, and no longer touches
internal/ghcmd/cmd.goorapi/.Description
Organizations can require SAML single sign-on, and until a user authorizes their token for that specific organization GitHub answers API requests for it with a 403 carrying an
X-GitHub-SSOheader. Extensions are often published by organizations a user has no other reason to work with, sogh extension install github/gh-netfails for people who have a perfectly valid token.The frustrating part is that nothing about the request needs authentication. The repository is public and the release is downloadable by anyone, so sending no token at all succeeds where sending a valid one fails.
This adds a round tripper on the extension manager's HTTP client that notices a SAML-enforced 403 and replays the request with no authentication. A successful anonymous response is itself proof that the repository is public, so no extra visibility check is needed. If the anonymous retry does not succeed, the original authenticated response is returned unchanged, so private repositories and non-SAML failures keep exactly the errors they have today.
Because it sits at the transport layer, every extension request is covered - release lookup, pinned tags, script detection, asset download, and upgrades - without any change to
Manager.How did you test this change?
go test ./pkg/cmd/extension/... ./pkg/cmd/factory/...- passedgo test -race ./pkg/cmd/extension/... ./pkg/cmd/factory/...- passedgo test ./...- passed, apart from apkg/cmd/auth/shared/gitcredentialsfailure that reproduces on unmodifiedtrunkon my machine because of a local credential helpergolangci-lint run ./pkg/cmd/extension/... ./pkg/cmd/factory/...-0 issues.TestManager_Install_binary_SAMLFallbackdrives a realManager.Installwith the fallback wired the waypkg/cmd/factorywires it, with every authenticated request SAML rejected, and asserts the manifest and binary are written from the anonymous responses.I also ran the fallback against live github.com, forcing every authenticated request to return a SAML 403:
gh-stack --helpthen ran from the installed binary. As a control,gh extension install github/gh-stackwith no token and isolated config, data, and cache directories also installs successfully, confirming the anonymous path is genuinely sufficient.That live run caught two bugs the unit tests had not:
Authorizationon the request in place, so cloning after the first attempt leaked the token into the anonymous retry. The request is now snapshotted before the authenticated chain runs, and a test asserts the retry carries noAuthorizationheader.>= 300failure check rejected. Only 4xx and 5xx count as failures now, with a regression test.A test also follows that redirect through a real
httptestserver and asserts the second hop reaches the storage host with noAuthorizationheader, while a caller-setAcceptheader does carry across, so the assertion is not vacuous.Key points
X-GitHub-SSOextraction request-scoped, which required touchinginternal/ghcmd/cmd.goand every command's error path. That is replaced by a one-line hook:WithSAMLFallbacktakes anonRecovercallback, andpkg/cmd/factorypasses one that clears the recorded SSO header. This matters becauseghcmd.Mainprints "Authorize in your web browser" whenever that header is set and the command failed, for any error. Without clearing it, a SAML-org user installing an extension that publishes no binary for their platform would see the correct "unsupported for darwin-arm64" message followed by a bogus authorization prompt, because the 403s along the way were recovered rather than fatal.Managerto avoid duplicate lookups. That is unnecessary:go-ghdoes not cache 403s but does cache the successful anonymous response, so the SAML path costs one extra request rather than a doubled one.Notes for reviewers
pkg/cmd/extension/saml_fallback.gois the whole change at 78 lines;pkg/cmd/factory/default.gois the 7-line wiring. Everything else is tests.The judgement call I would most like a second opinion on is putting this at the transport layer at all. It keeps the diff small and covers every extension request without touching
Manager, but it does mean an authentication downgrade happens somewhere a reader of the call site would not see it. I think the doc comment plus the anonymous-cannot-see-more argument carries it, but I would rather be told otherwise now than later.Issue #6675 has the original
github/gh-netreport. #14108 is the prior, larger version of this change and its review discussion.Authorship and follow-up
Who wrote this:
Who answers review comments: