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

## 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

<details>
<summary><b>Call flow</b></summary>

```mermaid
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
```

</details>

## 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.

<details>
<summary><b>Unit test detail</b></summary>

`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.

</details>

<details>
<summary><b>Manual verification detail</b></summary>

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`.

</details>
This commit is contained in:
Bobby Ho
2026-08-05 13:08:41 -07:00
committed by GitHub
parent 0a79610f7b
commit 97c4031526
4 changed files with 515 additions and 35 deletions
+24 -2
View File
@@ -83,10 +83,17 @@ If no tokens are available, it defaults to SSH authentication.
For Git providers configured with [external authentication](#configuration), Coder can use OAuth tokens for Git operations over HTTPS.
When using SSH URLs (like `git@github.com:organization/repo.git`), Coder uses SSH keys as described in the [SSH Authentication](#ssh-authentication) section instead.
For Git operations over HTTPS, Coder automatically uses the appropriate external auth provider
token based on the repository URL.
For Git operations over HTTPS, Coder automatically injects an external auth provider token.
This works through Git's `GIT_ASKPASS` mechanism, which Coder configures in each workspace.
`GIT_ASKPASS` tells Coder which Git host the operation is for, but never which provider to use.
Coder resolves the provider in two steps:
1. Coder considers only the providers that the workspace's template declares with `data "coder_external_auth"`, and selects the one whose `CODER_EXTERNAL_AUTH_<N>_REGEX` matches the host.
1. If every declared provider is configured and none of them match the host, including when the template declares no providers at all, Coder matches the host against all providers configured on the deployment. This fallback keeps hosts that the template never declares, such as an unrelated Git server, reachable from the workspace.
Because the first step is scoped to the template, two workspaces built from different templates receive their own template's token for the same Git host, regardless of the order the providers appear in the deployment configuration.
To use OAuth tokens for Git authentication over HTTPS:
1. Complete the OAuth authentication flow (**Login with GitHub**, **Login with GitLab**).
@@ -377,3 +384,18 @@ CODER_EXTERNAL_AUTH_1_TOKEN_URL="https://github.example.com/login/oauth/access_t
CODER_EXTERNAL_AUTH_1_REVOKE_URL="https://github.example.com/login/oauth/revoke"
CODER_EXTERNAL_AUTH_1_VALIDATE_URL="https://github.example.com/api/v3/user"
```
### When Coder can't resolve a single provider
When several providers serve the same Git host, HTTPS Git operations resolve the provider from the workspace template's declared providers, as described in [OAuth (external auth)](#oauth-external-auth).
Coder stops in two cases rather than pick a provider the template didn't ask for.
In both, the request fails and `coder gitaskpass` prints a warning and falls back to Git's own credential behavior, so the Git operation prompts for credentials or fails instead of using an unexpected token.
- **Several of the template's declared providers match the host.**
Coder can't tell which one the operation needs, so it returns an HTTP 404 naming each match.
Give the providers non-overlapping `CODER_EXTERNAL_AUTH_<N>_REGEX` values so that only one matches the host, or fetch a token with an explicit provider ID using `coder external-auth access-token <USER_DEFINED_ID>` in your template's startup script.
- **The template declares a provider that the deployment no longer configures, and none of its other declared providers match the host.**
This happens when a provider is renamed or removed after a template started declaring it.
Coder returns an HTTP 404 naming the missing provider instead of falling back to a provider the template never declared.
A provider that the template declares and the deployment still configures keeps serving its own host, so only the hosts that relied on the missing provider are affected.
Restore that provider's configuration, or update the template to declare a provider that the deployment configures.
@@ -38,6 +38,13 @@ By default, the coder agent will configure native `git` authentication via the
`GIT_ASKPASS` environment variable. Meaning, with no additional configuration,
external authentication will work with native `git` commands.
The providers your template declares also determine which token native `git` commands receive.
For HTTPS Git operations, Coder selects from your template's declared providers first, and only matches against every provider configured on the deployment when none of the declared providers serve that host.
If two of your template's declared providers match the same host, Coder refuses the request instead of guessing between them.
Coder also refuses when none of your declared providers match the host and one of them is missing from the deployment's configuration, rather than fall back to a provider your template never declared.
A missing declaration doesn't affect hosts that your other declared providers still serve.
For the full rules, refer to [OAuth (external auth)](../../external-auth/index.md#oauth-external-auth).
To check the auth token being used **from inside a running workspace**, run:
```sh