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
+153 -33
View File
@@ -2083,39 +2083,10 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
// new token to be issued!
listen := query.Has("listen")
var externalAuthConfig *externalauth.Config
for _, extAuth := range api.ExternalAuthConfigs {
if extAuth.ID == id {
externalAuthConfig = extAuth
break
}
if match == "" || extAuth.Regex == nil {
continue
}
matches := extAuth.Regex.MatchString(match)
if !matches {
continue
}
externalAuthConfig = extAuth
}
if externalAuthConfig == nil {
detail := "External auth provider not found."
if len(api.ExternalAuthConfigs) > 0 {
regexURLs := make([]string, 0, len(api.ExternalAuthConfigs))
for _, extAuth := range api.ExternalAuthConfigs {
if extAuth.Regex == nil {
continue
}
regexURLs = append(regexURLs, fmt.Sprintf("%s=%q", extAuth.ID, extAuth.Regex.String()))
}
detail = fmt.Sprintf("The configured external auth provider have regex filters that do not match the url. Provider url regex: %s", strings.Join(regexURLs, ","))
}
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("No matching external auth provider found in Coder for the url %q.", match),
Detail: detail,
})
return
}
// Resolve the calling agent's own workspace/build before selecting a
// provider below, so the match-only (GIT_ASKPASS) path can be scoped to
// this workspace's own template-declared providers instead of scanning
// every provider configured on the deployment.
workspaceAgent := httpmw.WorkspaceAgent(r)
// We must get the workspace to get the owner ID!
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
@@ -2143,6 +2114,83 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
return
}
var externalAuthConfig *externalauth.Config
if id != "" {
// Explicit ID path: exact match only. Deterministic, and deliberately
// unaffected by the template-scoped narrowing below.
for _, extAuth := range api.ExternalAuthConfigs {
if extAuth.ID == id {
externalAuthConfig = extAuth
break
}
}
} else {
// match-only path (GIT_ASKPASS supplies a hostname, never an ID):
// narrow to the workspace's own template-declared providers first.
// Only fall back to the full deployment-wide scan when every declared
// provider is configured and none of them match this hostname (e.g. a
// host the template never declared). Report an error rather than guess
// when several declared providers match, or when the declaration set is
// stale.
//
// Both errors use 404 so `coder gitaskpass` warns and defers to git's
// own credential behavior instead of failing the git operation.
declared, err := api.workspaceAgentsExternalAuthDeclaredCandidates(ctx, build, match)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to resolve the workspace's template-declared external auth providers.",
Detail: err.Error(),
})
return
}
switch {
case len(declared.matchedIDs) > 1:
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("Multiple external auth providers declared by this workspace's template match %q: %s.", match, strings.Join(declared.matchedIDs, ", ")),
Detail: "Coder cannot tell which of them to use. Request a token with an explicit provider ID instead, using `coder external-auth access-token <id>`.",
})
return
case declared.config != nil:
externalAuthConfig = declared.config
case len(declared.missingIDs) > 0:
// A declared provider that the deployment no longer configures
// leaves no way to tell whether it was the one meant to serve this
// hostname, so another provider's token could silently stand in for
// it. Refuse instead, which keeps the template scoping above intact
// even while a template and the deployment config disagree.
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("This workspace's template declares external auth provider(s) that this deployment no longer configures: %s.", strings.Join(declared.missingIDs, ", ")),
Detail: "Coder will not substitute a different provider's token. Restore that provider's configuration, or update the template to declare a configured provider.",
})
return
default:
for _, extAuth := range api.ExternalAuthConfigs {
if extAuth.Regex == nil || !extAuth.Regex.MatchString(match) {
continue
}
externalAuthConfig = extAuth
}
}
}
if externalAuthConfig == nil {
detail := "External auth provider not found."
if len(api.ExternalAuthConfigs) > 0 {
regexURLs := make([]string, 0, len(api.ExternalAuthConfigs))
for _, extAuth := range api.ExternalAuthConfigs {
if extAuth.Regex == nil {
continue
}
regexURLs = append(regexURLs, fmt.Sprintf("%s=%q", extAuth.ID, extAuth.Regex.String()))
}
detail = fmt.Sprintf("The configured external auth provider have regex filters that do not match the url. Provider url regex: %s", strings.Join(regexURLs, ","))
}
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("No matching external auth provider found in Coder for the url %q.", match),
Detail: detail,
})
return
}
// Pre-check if the caller can read the external auth links for the owner of the
// workspace. Do this up front because a sql.ErrNoRows is expected if the user is
// in the flow of authenticating. If no row is present, the auth check is delayed
@@ -2239,6 +2287,78 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// declaredExternalAuthCandidates describes how a workspace template's declared
// external auth providers relate to one hostname-only (GIT_ASKPASS) request.
type declaredExternalAuthCandidates struct {
// config is the provider to use, set only when exactly one declared and
// configured provider matches the hostname.
config *externalauth.Config
// matchedIDs holds the ID of every declared and configured provider whose
// regex matches the hostname, in declaration order.
matchedIDs []string
// missingIDs holds the ID of every declared provider that the deployment
// does not configure.
missingIDs []string
}
// workspaceAgentsExternalAuthDeclaredCandidates resolves which configured
// external auth provider should service a hostname-only (GIT_ASKPASS) request,
// scoped to the given build's template version's declared providers. The zero
// value means the template declares no providers at all.
//
// config is set only when exactly one declared provider matched, so callers
// should distinguish four outcomes:
// - several matchedIDs: report the collision rather than guess between them.
// - config set: use it.
// - no match but some missingIDs: the declaration set is stale, so report
// that instead of substituting a provider the template never declared.
// - no match and nothing missing: the template's providers simply do not
// serve this hostname, so fall back to a deployment-wide scan.
func (api *API) workspaceAgentsExternalAuthDeclaredCandidates(ctx context.Context, build database.WorkspaceBuild, match string) (declaredExternalAuthCandidates, error) {
// Template reads authorize through the template's ACL, which the owner may
// no longer have. The version ID is server-derived from the agent's token.
//nolint:gocritic // Agent needs system access to read its own template version's declared providers.
sysCtx := dbauthz.AsSystemRestricted(ctx)
templateVersion, err := api.Database.GetTemplateVersionByID(sysCtx, build.TemplateVersionID)
if err != nil {
return declaredExternalAuthCandidates{}, xerrors.Errorf("get template version: %w", err)
}
var declared []database.ExternalAuthProvider
if err := json.Unmarshal(templateVersion.ExternalAuthProviders, &declared); err != nil {
return declaredExternalAuthCandidates{}, xerrors.Errorf("unmarshal template version external auth providers: %w", err)
}
var (
candidates []*externalauth.Config
out declaredExternalAuthCandidates
)
for _, provider := range declared {
idx := slices.IndexFunc(api.ExternalAuthConfigs, func(extAuth *externalauth.Config) bool {
return extAuth.ID == provider.ID
})
if idx < 0 {
out.missingIDs = append(out.missingIDs, provider.ID)
continue
}
extAuth := api.ExternalAuthConfigs[idx]
if extAuth.Regex == nil || !extAuth.Regex.MatchString(match) {
continue
}
candidates = append(candidates, extAuth)
}
for _, candidate := range candidates {
out.matchedIDs = append(out.matchedIDs, candidate.ID)
}
// Only a single match is actionable. Leave config nil when several match so
// the caller reports the collision instead of picking one arbitrarily.
if len(candidates) == 1 {
out.config = candidates[0]
}
return out, nil
}
func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.ResponseWriter, previous *database.ExternalAuthLink, externalAuthConfig *externalauth.Config, workspace database.Workspace, gitRef chatGitRef) {
// Since we're ticking frequently and this sign-in operation is rare,
// we are OK with polling to avoid the complexity of pubsub.
+331
View File
@@ -3876,3 +3876,334 @@ func TestWorkspaceAgentsExternalAuthExpiresAt(t *testing.T) {
"ExpiresAt should be zero when the token has no expiry")
})
}
// fakeExternalAuthConfig builds a minimal, network-free external auth
// provider config: RefreshToken short-circuits because the seeded link's
// AccessToken already matches what the fake OAuth2 config would return, and
// ValidateURL is omitted so the token is always treated as valid.
func fakeExternalAuthConfig(id, token string, regex *regexp.Regexp) *externalauth.Config {
return &externalauth.Config{
InstrumentedOAuth2Config: &testutil.OAuth2Config{
Token: &oauth2.Token{
AccessToken: token,
RefreshToken: "refresh-" + id,
Expiry: dbtime.Now().Add(24 * time.Hour),
},
},
ID: id,
Regex: regex,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
RefreshGroup: new(singleflight.Group),
}
}
// TestWorkspaceAgentsExternalAuthTemplateScoped covers PLAT-190: when a
// GIT_ASKPASS-style request supplies only a hostname (never an ID), the
// server must prefer the requesting workspace's own template-declared
// providers over a blind, order-dependent scan of every deployment-configured
// provider.
func TestWorkspaceAgentsExternalAuthTemplateScoped(t *testing.T) {
t.Parallel()
const (
matchHost = "https://github.com"
idBroad = "provider-broad"
idDot = "provider-dotfiles"
idOther = "provider-other"
)
githubRegex := regexp.MustCompile(`^(https?://)?github\.com(/.*)?$`)
gitlabRegex := regexp.MustCompile(`^(https?://)?gitlab\.com(/.*)?$`)
// setup creates a deployment with the given providers (in the given
// order), a workspace built from a template version declaring
// declaredIDs, and seeds a valid ExternalAuthLink for every provider in
// linkProviderIDs so any of them could be returned if selection picked
// the wrong one. Providers omitted from linkProviderIDs are left
// unauthenticated (no link), to exercise the authenticate-URL flow.
setup := func(t *testing.T, providers []*externalauth.Config, declaredIDs, linkProviderIDs []string) (agentClient *agentsdk.Client) {
t.Helper()
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
ExternalAuthConfigs: providers,
})
first := coderdtest.CreateFirstUser(t, client)
_, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
declared, err := json.Marshal(func() []database.ExternalAuthProvider {
out := make([]database.ExternalAuthProvider, len(declaredIDs))
for i, id := range declaredIDs {
out[i] = database.ExternalAuthProvider{ID: id}
}
return out
}())
require.NoError(t, err)
tv := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{
OrganizationID: first.OrganizationID,
CreatedBy: first.UserID,
}).Do()
err = db.UpdateTemplateVersionExternalAuthProvidersByJobID(dbauthz.AsProvisionerd(context.Background()), database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams{
JobID: tv.TemplateVersion.JobID,
ExternalAuthProviders: declared,
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
for _, id := range linkProviderIDs {
dbgen.ExternalAuthLink(t, db, database.ExternalAuthLink{
ProviderID: id,
UserID: user.ID,
OAuthAccessToken: id + "-token",
OAuthExpiry: dbtime.Now().Add(24 * time.Hour),
})
}
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: first.OrganizationID,
OwnerID: user.ID,
TemplateID: tv.Template.ID,
}).Seed(database.WorkspaceBuild{
TemplateVersionID: tv.TemplateVersion.ID,
}).WithAgent().Do()
return agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken))
}
// A template declaring only idDot must always resolve to
// idDot's token for a host both providers match, regardless of which
// order the two providers are configured in deployment-wide.
for _, tc := range []struct {
name string
order []string
}{
{"DeclaredProviderLast", []string{idBroad, idDot}},
{"DeclaredProviderFirst", []string{idDot, idBroad}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
providers := make([]*externalauth.Config, len(tc.order))
for i, id := range tc.order {
providers[i] = fakeExternalAuthConfig(id, id+"-token", githubRegex)
}
agentClient := setup(t, providers, []string{idDot}, []string{idBroad, idDot})
resp, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{Match: matchHost})
require.NoError(t, err)
require.Equal(t, idDot+"-token", resp.AccessToken,
"must resolve to the template-declared provider regardless of deployment config order")
})
}
// A template declaring no providers at all falls back to today's
// existing full-deployment scan (unchanged, potentially ambiguous
// behavior in that specific case remains explicitly out of scope).
t.Run("NoDeclaredProvidersFallsBackToFullScan", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
providers := []*externalauth.Config{
fakeExternalAuthConfig(idBroad, idBroad+"-token", githubRegex),
fakeExternalAuthConfig(idDot, idDot+"-token", githubRegex),
}
agentClient := setup(t, providers, nil, []string{idBroad, idDot})
resp, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{Match: matchHost})
require.NoError(t, err)
// Legacy behavior: last matching entry in deployment config order wins.
require.Equal(t, idDot+"-token", resp.AccessToken)
})
// A template declaring only a provider for an unrelated host must
// still resolve a genuinely different host via the fallback scan,
// rather than losing access to hosts the template never mentioned.
t.Run("UnrelatedHostStillResolvesViaFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
providers := []*externalauth.Config{
fakeExternalAuthConfig(idDot, idDot+"-token", githubRegex),
fakeExternalAuthConfig(idOther, idOther+"-token", gitlabRegex),
}
// Template only declares idDot (for github.com); idOther (gitlab.com)
// is never declared, but must still work for its own host.
agentClient := setup(t, providers, []string{idDot}, []string{idDot, idOther})
resp, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{Match: "https://gitlab.com"})
require.NoError(t, err)
require.Equal(t, idOther+"-token", resp.AccessToken)
})
// When several of the template's own declared providers match a
// hostname, the server must return a clear error rather than silently
// pick one. 404 specifically, so `coder gitaskpass` warns and defers to
// git's own credential behavior.
t.Run("AmbiguousDeclaredSetReturnsError", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
providers := []*externalauth.Config{
fakeExternalAuthConfig(idBroad, idBroad+"-token", githubRegex),
fakeExternalAuthConfig(idDot, idDot+"-token", githubRegex),
}
agentClient := setup(t, providers, []string{idBroad, idDot}, []string{idBroad, idDot})
_, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{Match: matchHost})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, idBroad)
require.Contains(t, sdkErr.Message, idDot)
})
// A declared provider that the deployment no longer configures must not
// let another provider for the same host stand in for it, even though
// that provider would satisfy a deployment-wide scan.
t.Run("MissingDeclaredProviderDoesNotFallBack", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
// Only idBroad is configured. The template declares idDot, which an
// administrator has since removed from the deployment config.
providers := []*externalauth.Config{
fakeExternalAuthConfig(idBroad, idBroad+"-token", githubRegex),
}
agentClient := setup(t, providers, []string{idDot}, []string{idBroad})
_, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{Match: matchHost})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, idDot,
"should name the declared provider the deployment no longer configures")
require.NotContains(t, sdkErr.Message, idBroad,
"must not offer an undeclared provider as a substitute")
})
// A stale declaration for one host must not block an unambiguous
// declared match for a different host. Only the fallback is withheld.
t.Run("MissingDeclaredProviderDoesNotBlockOtherDeclaredMatch", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
providers := []*externalauth.Config{
fakeExternalAuthConfig(idOther, idOther+"-token", gitlabRegex),
}
// idDot (github.com) is declared but no longer configured, while
// idOther (gitlab.com) is both declared and configured.
agentClient := setup(t, providers, []string{idDot, idOther}, []string{idOther})
resp, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{Match: "https://gitlab.com"})
require.NoError(t, err)
require.Equal(t, idOther+"-token", resp.AccessToken)
})
// Once narrowed to a single declared candidate, the existing
// authenticate-URL flow must still work unchanged for a provider the
// owner has not yet authenticated with.
t.Run("OptionalUnauthenticatedDeclaredProviderReturnsAuthURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
providers := []*externalauth.Config{
fakeExternalAuthConfig(idBroad, idBroad+"-token", githubRegex),
fakeExternalAuthConfig(idDot, idDot+"-token", githubRegex),
}
// idDot is declared and matches the hostname, but has no seeded link.
agentClient := setup(t, providers, []string{idDot}, []string{idBroad})
resp, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{Match: matchHost})
require.NoError(t, err)
require.Empty(t, resp.AccessToken)
require.Contains(t, resp.URL, "/external-auth/"+idDot,
"should prompt for the declared provider specifically, not idBroad")
})
}
// TestWorkspaceAgentsExternalAuthMultipleTemplates covers the headline
// scenario from PLAT-190: two workspaces, built from two different
// templates that each declare a different single provider, must each
// independently resolve to their own template's provider - never each
// other's - regardless of deployment config order or concurrent activity.
func TestWorkspaceAgentsExternalAuthMultipleTemplates(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
const (
matchHost = "https://github.com"
id1 = "provider-1"
id2 = "provider-2"
)
githubRegex := regexp.MustCompile(`^(https?://)?github\.com(/.*)?$`)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
ExternalAuthConfigs: []*externalauth.Config{
fakeExternalAuthConfig(id1, id1+"-token", githubRegex),
fakeExternalAuthConfig(id2, id2+"-token", githubRegex),
},
})
first := coderdtest.CreateFirstUser(t, client)
_, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
declare := func(t *testing.T, id string) dbfake.TemplateVersionResponse {
t.Helper()
declared, err := json.Marshal([]database.ExternalAuthProvider{{ID: id}})
require.NoError(t, err)
tv := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{
OrganizationID: first.OrganizationID,
CreatedBy: first.UserID,
}).Do()
err = db.UpdateTemplateVersionExternalAuthProvidersByJobID(dbauthz.AsProvisionerd(context.Background()), database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams{
JobID: tv.TemplateVersion.JobID,
ExternalAuthProviders: declared,
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
return tv
}
tv1 := declare(t, id1)
tv2 := declare(t, id2)
dbgen.ExternalAuthLink(t, db, database.ExternalAuthLink{
ProviderID: id1, UserID: user.ID, OAuthAccessToken: id1 + "-token", OAuthExpiry: dbtime.Now().Add(24 * time.Hour),
})
dbgen.ExternalAuthLink(t, db, database.ExternalAuthLink{
ProviderID: id2, UserID: user.ID, OAuthAccessToken: id2 + "-token", OAuthExpiry: dbtime.Now().Add(24 * time.Hour),
})
build := func(t *testing.T, tv dbfake.TemplateVersionResponse) *agentsdk.Client {
t.Helper()
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: first.OrganizationID,
OwnerID: user.ID,
TemplateID: tv.Template.ID,
}).Seed(database.WorkspaceBuild{
TemplateVersionID: tv.TemplateVersion.ID,
}).WithAgent().Do()
return agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken))
}
agent1 := build(t, tv1)
agent2 := build(t, tv2)
var wg sync.WaitGroup
var resp1, resp2 agentsdk.ExternalAuthResponse
var err1, err2 error
wg.Add(2)
go func() {
defer wg.Done()
resp1, err1 = agent1.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{Match: matchHost})
}()
go func() {
defer wg.Done()
resp2, err2 = agent2.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{Match: matchHost})
}()
wg.Wait()
require.NoError(t, err1)
require.NoError(t, err2)
require.Equal(t, id1+"-token", resp1.AccessToken, "workspace built from template 1 must always get provider 1's token")
require.Equal(t, id2+"-token", resp2.AccessToken, "workspace built from template 2 must always get provider 2's token")
}
+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