Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Allow public extension installs past SAML enforcement - #14131

Closed
loganrosen wants to merge 1 commit into
cli:trunkfrom
loganrosen:loganrosen-fix-extension-saml-install
Closed

Allow public extension installs past SAML enforcement#14131
loganrosen wants to merge 1 commit into
cli:trunkfrom
loganrosen:loganrosen-fix-extension-saml-install

Conversation

@loganrosen

@loganrosen loganrosen commented Aug 11, 2026

Copy link
Copy Markdown

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.go or api/.

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-SSO header. Extensions are often published by organizations a user has no other reason to work with, so gh extension install github/gh-net fails 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/... - passed
  • go test -race ./pkg/cmd/extension/... ./pkg/cmd/factory/... - passed
  • go test ./... - passed, apart from a pkg/cmd/auth/shared/gitcredentials failure that reproduces on unmodified trunk on my machine because of a local credential helper
  • golangci-lint run ./pkg/cmd/extension/... ./pkg/cmd/factory/... - 0 issues.

TestManager_Install_binary_SAMLFallback drives a real Manager.Install with the fallback wired the way pkg/cmd/factory wires 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:

authed request REJECTED with SAML 403: https://api.github.com/repos/github/gh-stack/releases/latest
anonymous retry:                       https://api.github.com/repos/github/gh-stack/releases/latest
authed request REJECTED with SAML 403: https://api.github.com/repos/github/gh-stack/releases/latest
anonymous retry:                       https://api.github.com/repos/github/gh-stack/releases/latest
authed request REJECTED with SAML 403: https://api.github.com/repos/github/gh-stack/releases/assets/494339447
anonymous retry:                       https://api.github.com/repos/github/gh-stack/releases/assets/494339447
authed request REJECTED with SAML 403: https://release-assets.githubusercontent.com/...
anonymous retry:                       https://release-assets.githubusercontent.com/...
installed .../extensions/gh-stack/gh-stack (23776960 bytes)

gh-stack --help then ran from the installed binary. As a control, gh extension install github/gh-stack with 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:

  1. The authenticated transport chain sets Authorization on 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 no Authorization header.
  2. Release asset downloads answer with a 302, which an initial >= 300 failure check rejected. Only 4xx and 5xx count as failures now, with a regression test.

A test also follows that redirect through a real httptest server and asserts the second hop reaches the storage host with no Authorization header, while a caller-set Accept header does carry across, so the assertion is not vacuous.

Key points

  • Only bodyless GETs are replayed, so a mutating request can never be resent.
  • The fallback is scoped to the extension manager's client, so no other command changes behavior.
  • The previous version of this change also made X-GitHub-SSO extraction request-scoped, which required touching internal/ghcmd/cmd.go and every command's error path. That is replaced by a one-line hook: WithSAMLFallback takes an onRecover callback, and pkg/cmd/factory passes one that clears the recorded SSO header. This matters because ghcmd.Main prints "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.
  • The fallback is deliberately narrow: it is only sound because an anonymous request cannot observe private data, so preferring the anonymous response can never widen what the user sees. That is stated in the doc comment so it is not reused for endpoints whose response body varies by authentication level.
  • The previous version also threaded the resolved release and client through Manager to avoid duplicate lookups. That is unnecessary: go-gh does 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.go is the whole change at 78 lines; pkg/cmd/factory/default.go is 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-net report. #14108 is the prior, larger version of this change and its review discussion.

Authorship and follow-up

Who wrote this:

  • A human wrote it.
  • An agent wrote it under close human direction.
  • An agent wrote it independently, and no human has guided the implementation beyond the initial prompt.

Who answers review comments:

  • @username will read and reply directly. Name the account.
  • An agent will draft replies and @loganrosen will read them before they are posted.
  • Nobody has explicitly committed to replying.

@loganrosen
loganrosen requested a review from a team as a code owner August 11, 2026 16:07
@loganrosen
loganrosen requested review from sergiou87 and a balanced review from Copilot August 11, 2026 16:07
@github-actions github-actions Bot added external pull request originating outside of the CLI core team needs-triage needs to be reviewed unmet-requirements and removed needs-triage needs to be reviewed labels Aug 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your pull request! Unfortunately, it doesn't meet the requirements for review:

  • None of the referenced issues have the help wanted label

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
  1. Include a detailed description of what this PR does
  2. Link to an issue with the help wanted label (use Fixes #123 or Closes #123)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread pkg/cmd/factory/default.go Outdated
Comment on lines +286 to +288
// SAML enforcement rejects a valid token even for public extension repos.
if plainClient, plainErr := f.PlainHttpClient(); plainErr == nil {
client = extension.WithSAMLFallback(client, plainClient)
Comment on lines +53 to +55
_, _ = io.Copy(io.Discard, res.Body)
res.Body.Close()
return plainRes, nil
Comment on lines +43 to +44
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]>
@loganrosen
loganrosen force-pushed the loganrosen-fix-extension-saml-install branch from f58173c to 24cdee4 Compare August 11, 2026 16:20
@loganrosen

Copy link
Copy Markdown
Author

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.

@loganrosen loganrosen closed this Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external pull request originating outside of the CLI core team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SAML enforcement prohibits installing public extension

3 participants