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

Skip to content

feat!: resolve agent external auth by template, not config order - #27854

Merged
BobbyHo merged 6 commits into
mainfrom
coder-plat-190
Aug 5, 2026
Merged

feat!: resolve agent external auth by template, not config order#27854
BobbyHo merged 6 commits into
mainfrom
coder-plat-190

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Problem. A template can declare which external auth provider it wants via data "coder_external_auth" { id = "..." }, and that declaration is honored at every stage of the build. It was ignored at runtime. Any git operation going through GIT_ASKPASS supplies only a hostname, never a provider ID, and the handler scanned every provider configured on the deployment and returned whichever matched the hostname last in config order, with no reference to what the requesting workspace's own template declared. Reordering CODER_EXTERNAL_AUTH_<N>_* silently redirected a plain git clone from one OAuth client's token to a completely different one.

Fix. For hostname-only requests, resolve the calling agent's workspace and build before selecting a provider, then narrow candidates to the providers declared by that build's template version. Exactly one match wins regardless of config order. No matching declared provider falls back to today's deployment-wide scan, so a template that declares only a GitHub provider can still clone an unrelated host. Two or more matching declared providers return 409 naming them, rather than picking one arbitrarily: external_auth_providers is stored sorted by ID, so HCL declaration order is already unavailable and no principled tie-break exists.

Requests supplying an explicit provider ID are untouched. Server-side only: no wire protocol, proto, manifest, or database schema change, so already-running agents get the corrected behavior on their next askpass call with no restart.

Refs #23718

Call flow
flowchart TD
    subgraph Push["1. Template import: coder templates push"]
        A1["Terraform extracts coder_external_auth id/optional attrs"]
        A2["CompleteJob(TemplateImport) validates each id<br/>against deployment config"]
        A4["template_versions.external_auth_providers persisted"]
        A1 --> A2 --> A4
    end

    subgraph PreBuild["2. Pre-build and workspace build (unaffected)"]
        B1["User authenticates declared provider(s), exact-ID lookup"]
        B2["Build resolves token by exact ID<br/>(provisionerdserver.go)"]
        A4 --> B1 --> B2
    end

    subgraph Runtime["3. Workspace running: a credential is needed"]
        B2 --> C0{"Caller supplies id or match?"}
        C0 -->|"id (explicit)"| D1["Exact-ID match<br/>UNCHANGED, already deterministic<br/>(coder external-auth access-token)"]
        C0 -->|"match only (GIT_ASKPASS)"| C1["git needs credentials for a hostname<br/>GIT_ASKPASS invoked, unchanged"]
        C1 --> C2["coder gitaskpass sends ExternalAuthRequest{Match: host}<br/>unchanged (cli/gitaskpass.go)"]
        C2 --> C3["workspaceAgentsExternalAuth<br/>(coderd/workspaceagents.go)"]
        C3 --> C4["CHANGED:<br/>1. resolve workspace/build BEFORE matching<br/>2. read that build's declared provider IDs<br/>3. filter: declared AND regex matches host"]
        C4 --> C5{"how many candidates?"}
        C5 -->|"exactly 1"| C6["use it, regardless of config order"]
        C5 -->|"0"| C7["fall back to deployment-wide scan<br/>(unchanged legacy behavior)"]
        C5 -->|"2 or more"| C8["409 naming every matching ID"]
    end

    D1 --> E1["Token returned"]
    C6 --> E1
    C7 --> E1

    style C4 fill:#1f4d2e,stroke:#4caf50,color:#fff
    style C6 fill:#1f4d2e,stroke:#4caf50,color:#fff
    style C8 fill:#1f4d2e,stroke:#4caf50,color:#fff
    style D1 fill:#333,stroke:#888,color:#fff
Loading

Verification

Two test functions were added in coderd/workspaceagents_test.go, and the behavior no unit test can reach was verified against a local dev cluster with two real GitHub OAuth Apps whose regexes both match github.com.

Behavior Unit Manual
Declared provider wins over a colliding one yes yes
Outcome independent of deployment config order yes yes
No declared match falls back to the full scan yes yes
Host the template never declared still resolves yes via fallback
Two declared providers matching one host return 409 yes not run
Declared but unauthenticated provider returns its auth URL yes not run
Two templates resolve independently and concurrently yes no
Explicit-ID path unaffected no yes
Running agent corrected with no restart no yes
Declared ID since removed from config falls back no yes
Recomputed per build after a template update no yes

The last three are properties a unit test cannot express: they involve swapping the server binary underneath a live agent, removing deployment configuration, and rebuilding a workspace against a new template version.

Unit test detail

TestWorkspaceAgentsExternalAuthTemplateScoped builds a deployment with two providers sharing a regex, a template declaring one of them, and a seeded token for every provider, so a mis-selection returns a valid token with the wrong identity rather than an error. Subtests:

  • DeclaredProviderLast / DeclaredProviderFirst: the declared provider wins in both config orders. Only the First arm is discriminating, since the pre-change loop had no break and returned the last regex match, which the Last arm happens to agree with.
  • NoDeclaredProvidersFallsBackToFullScan: a template declaring nothing keeps today's behavior exactly, pinning the legacy last-match rule.
  • UnrelatedHostStillResolvesViaFallback: a template declaring only a GitHub provider still resolves a GitLab host.
  • AmbiguousDeclaredSetReturnsError: 409 whose message names both colliding provider IDs.
  • OptionalUnauthenticatedDeclaredProviderReturnsAuthURL: returns the auth URL for the declared provider, not for an unrelated one the user happens to hold a token for.

TestWorkspaceAgentsExternalAuthMultipleTemplates runs two workspaces from two templates, each declaring a different provider, issuing requests concurrently. Each resolves to its own template's provider.

Manual verification detail

Local dev cluster, two GitHub OAuth Apps both defaulting to ^(https?://)?github\.com(/.*)?$, both authorized by the workspace owner so a wrong selection yields a usable token rather than an error. Workspace built from a template declaring only github-dotfiles. Tokens redacted.

Order independence. Same workspace, never rebuilt, config order reversed between runs:

Deployment config order Token returned
[github-broad, github-dotfiles] gho_<dotfiles>
[github-dotfiles, github-broad] gho_<dotfiles>

A/B against the pre-fix binary. Everything held constant except the coderd build, with /api/v2/buildinfo checked on both sides so the comparison rests on verified binary identity. The workspace was never stopped, rebuilt, or re-authorized:

coderd buildinfo Token Honors declaration
pre-fix v2.35.3-devel+11e03cfb3a gho_<broad> no
this branch v2.35.3-devel+e8b87d0333 gho_<dotfiles> yes

This doubles as the demonstration that a coderd-only upgrade corrects behavior on a live agent's next askpass call.

Declared provider removed from config. github-dotfiles deleted from deployment configuration while the workspace's template still declared it. Result: HTTP/2 200 with gho_<broad> via the fallback. No 500, no fail-closed 404. The orphaned external_auth_link row remained in the database throughout and correctly had no effect.

Recomputation after a template update.

Workspace state Build's declared provider Token returned
new version pushed, workspace not updated github-dotfiles gho_<dotfiles>
after coder update github-broad gho_<broad>

The pair is what makes it conclusive: the first rules out following the template's newest version, the second rules out a cached value.

Explicit-ID path. coder external-auth access-token github-broad returned that provider's result even though the template declared only github-dotfiles, and did not substitute the declared provider's already-valid token.

Raw traces were captured with GIT_CURL_VERBOSE=1 git -c credential.helper="" ls-remote <private repo>, reading the unredacted == Info: Server auth using Basic with user '<token>' line. A private repo is required, since a public one never triggers a 401 and therefore never invokes GIT_ASKPASS.

Hostname-only external auth requests, which is what GIT_ASKPASS sends,
scanned every provider configured on the deployment and returned the last
one whose regex matched the hostname. The requesting workspace's own
template-declared provider was never consulted, so reordering
CODER_EXTERNAL_AUTH_<N>_* silently changed which OAuth client's token a
plain git operation received.

Resolve the calling agent's workspace and build before selecting a
provider, then narrow the candidates to the providers declared by that
build's template version. When exactly one of them matches the hostname,
use it regardless of deployment config order.

When none of the declared providers match, fall back to the existing
deployment-wide scan, so a template that declares only a GitHub provider
can still clone an unrelated host. When several declared providers match
the same hostname, return 409 naming them rather than picking one
arbitrarily: external_auth_providers is stored sorted by ID, so HCL
declaration order is unavailable and no principled tie-break exists.

Requests supplying an explicit provider ID are unchanged.

Refs #23718
@linear-code

linear-code Bot commented Aug 4, 2026

Copy link
Copy Markdown

PLAT-190

@BobbyHo
BobbyHo marked this pull request as ready for review August 4, 2026 16:19
@coderagents

coderagents Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Documentation Check

This PR is primarily a server-side bug fix (Refs #23718 / PLAT-190), but it changes the user-observable behavior of a documented, user-facing feature: external auth provider selection for HTTPS git operations. Documentation for that behavior change landed in commit 853ec50e5.

Updates Needed

  • docs/admin/external-auth/index.md (Git Authentication in Workspaces → OAuth) - Now documents that hostname-only (GIT_ASKPASS) requests are resolved from the workspace's template-declared providers first, and that Coder only falls back to a deployment-wide match when every declared provider is configured and none match the host.
  • docs/admin/external-auth/index.md (Multiple External Providers → When Coder can't resolve a single provider) - New section documents the disambiguation behavior: when multiple declared providers match the same host, Coder returns HTTP 404 (and coder gitaskpass falls back to git's own credential behavior) rather than guessing. Recommends non-overlapping regexes or an explicit coder external-auth access-token <id>.
  • docs/admin/external-auth/index.md (Multiple External Providers → When Coder can't resolve a single provider) - Same section documents the stale-declaration case (a template declaring a provider the deployment no longer configures): Coder returns HTTP 404 and will not substitute a different provider's token, with remediation steps.

A cross-reference was also added to docs/admin/templates/extending-templates/external-auth.md pointing at the full rules. The added prose matches the code behavior and introduces no manifest, CLI, or API reference changes.

All previously flagged documentation needs are addressed.


Automated review via Coder Agents

@geokat geokat 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.

Overall LGTM! 👍
One potential bug (found with the help of an agent):

P2: stale template provider declarations can fall back to a different provider’s token. If a template declares github-read, then an administrator removes that configured provider while github-write still matches github.com, the new helper produces no declared candidate. The caller then performs the deployment-wide fallback and can return or prompt for github-write’s token.

That defeats the branch’s intended template-specific isolation, especially when the remaining token has broader privileges. The existing template-auth requirement path treats a declared but missing provider as an error, so this resolver should do the same. The fallback should apply only when the declared, configured providers simply do not match the Git host.

Add a regression case for: declared provider absent from deployment config + another same-host provider present.

Comment thread coderd/workspaceagents.go Outdated
@geokat

geokat commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Oh, and I agree with the doc review comments above 👍

@geokat

geokat commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Just a heads-up that this fix could potentially be a breaking change for deployments that rely on the old behavior.

A template declaring a provider the deployment no longer configures produced no declared candidate, so hostname-only resolution fell through to the deployment-wide scan and could return a different same-host provider's token, chosen by config order.

Report declared-but-unconfigured provider IDs separately and refuse with a 404 naming them, rather than substituting. The fallback now applies only when every declared provider is configured and none of them match the hostname. A stale declaration for one host still leaves a declared, configured provider for another host resolving normally.

Use 404 for both this and the ambiguity error so cli/gitaskpass.go warns and defers to git's own credential handling instead of failing with a raw error, which also keeps behavior correct for agent binaries that predate this change.
@BobbyHo

BobbyHo commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Overall LGTM! 👍 One potential bug (found with the help of an agent):

P2: stale template provider declarations can fall back to a different provider’s token. If a template declares github-read, then an administrator removes that configured provider while github-write still matches github.com, the new helper produces no declared candidate. The caller then performs the deployment-wide fallback and can return or prompt for github-write’s token.
That defeats the branch’s intended template-specific isolation, especially when the remaining token has broader privileges. The existing template-auth requirement path treats a declared but missing provider as an error, so this resolver should do the same. The fallback should apply only when the declared, configured providers simply do not match the Git host.
Add a regression case for: declared provider absent from deployment config + another same-host provider present.

Thank you @geokat for the review. This is a good catch—this was a real issue. I reproduced it end to end before fixing it:

The template declares github-dotfiles.
An admin removes that provider from the deployment config.
github-broad still matches github.com.
Because the request contains only the hostname, Coder returned github-broad’s token, and the fetch succeeded.

$ GIT_CURL_VERBOSE=1 git -c credential.helper="" ls-remote https://github.com/<me>/<private-repo>
<= Recv header: HTTP/2 401
== Info: Server auth using Basic with user 'gho_<github-broad>'
<= Recv header: HTTP/2 200

Fixed in 5547015. The helper now reports declared-but-unconfigured provider IDs separately from matched providers, and the handler refuses the request instead of falling back.

The rule is the conjunction in your second sentence: fallback is allowed if and only if every declared provider is configured and none of them match the hostname.

With the same workspace, configuration, and command after the fix:

$ GIT_TERMINAL_PROMPT=0 git -c credential.helper="" ls-remote https://github.com/<me>/<private-repo>
WARN: Coder was unable to handle this git request. The default git behavior will be used instead.
  | This workspace's template declares external auth provider(s) that this deployment no longer configures: github-dotfiles.
  | Coder will not substitute a different provider's token. Restore that provider's configuration, or update the template to declare a configured provider.
fatal: could not read Username for 'https://github.com': terminal prompts disabled

The external auth docs said Coder picks a provider for HTTPS git operations "based on the repository URL", which no longer describes the behavior. Hostname-only GIT_ASKPASS requests now resolve against the providers the workspace's template declares, falling back to a deployment-wide match only when every declared provider is configured and none of them match the host.

Document the resolution order, and the two cases where Coder refuses rather than guess: several declared providers matching one host, and a declared provider the deployment no longer configures. Both include the remedy.

Add the template-author-facing half to the extending-templates page so template authors learn that what they declare determines which token native git receives.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

@BobbyHo

BobbyHo commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Just a heads-up that this fix could potentially be a breaking change for deployments that rely on the old behavior.

Agreed, and worth being precise about the scope. Two cases turn a previously-succeeding git operation into a failure, both on the hostname-only (GIT_ASKPASS) path. The explicit-ID path (coder external-auth access-token <id>) is unchanged.

  1. A template declares two or more providers that match the same host. Previously Coder silently returned whichever was last in deployment config order; now it returns a 404 naming both.
  2. A template declares a provider the deployment no longer configures. Previously Coder fell back to another provider matching the host; now it refuses. This one comes from your P2 above.

@BobbyHo BobbyHo added the release/breaking This label is applied to PRs to detect breaking changes as part of the release process label Aug 5, 2026
@github-actions github-actions Bot changed the title feat(coderd): resolve agent external auth by template, not config order feat(coderd)!: resolve agent external auth by template, not config order Aug 5, 2026
@BobbyHo BobbyHo changed the title feat(coderd)!: resolve agent external auth by template, not config order feat!: resolve agent external auth by template, not config order Aug 5, 2026

@geokat geokat 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.

All good, just one optional non-blocking documentation nit 👍

Comment thread docs/admin/templates/extending-templates/external-auth.md Outdated
The prose said a template declaring a provider the deployment no longer configures causes Coder to refuse, without the precondition. A single declared and configured provider matching the host is selected before the missing declaration is considered, so the refusal only applies when none of the declared providers match the host.

State the precondition on both pages, and note that a missing declaration leaves hosts served by the template's other declared providers unaffected.
@BobbyHo
BobbyHo merged commit 97c4031 into main Aug 5, 2026
32 checks passed
@BobbyHo
BobbyHo deleted the coder-plat-190 branch August 5, 2026 20:08
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release/breaking This label is applied to PRs to detect breaking changes as part of the release process

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants