From e8b87d0333bd194ebb359203e0bc7ef61e47d574 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 3 Aug 2026 21:39:42 -0700 Subject: [PATCH 1/4] feat(coderd): resolve agent external auth by template, not config order 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__* 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 --- coderd/workspaceagents.go | 154 ++++++++++++++---- coderd/workspaceagents_test.go | 287 +++++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+), 33 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 03791570806..283d198e593 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -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,67 @@ 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. + // Fall back to the full deployment-wide scan when none of them match + // this hostname (e.g. a host the template never declared), and fail + // with a clear error rather than guess when several of the template's + // own declared providers match this hostname. + declaredConfig, matchedProviderIDs, err := api.workspaceAgentsExternalAuthDeclaredCandidate(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 + } + if len(matchedProviderIDs) > 1 { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("Multiple external auth providers declared by this workspace's template match %q: %s.", match, strings.Join(matchedProviderIDs, ", ")), + Detail: "Use a build script with an explicit provider ID (`coder external-auth access-token `) instead of relying on automatic git credential resolution to disambiguate between these providers.", + }) + return + } + externalAuthConfig = declaredConfig + if externalAuthConfig == nil { + 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 +2271,62 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ httpapi.Write(ctx, rw, http.StatusOK, resp) } +// workspaceAgentsExternalAuthDeclaredCandidate 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. +// +// matchedProviderIDs lists the ID of every declared provider whose regex +// matches the hostname. config is set only when exactly one matched, so: +// - one match: config is that provider; the caller should use it. +// - no matches (including when the template declares none): the caller +// should fall back to a deployment-wide scan. +// - several matches: config is nil and the caller should surface an error +// naming matchedProviderIDs rather than guess between them. +func (api *API) workspaceAgentsExternalAuthDeclaredCandidate(ctx context.Context, build database.WorkspaceBuild, match string) (config *externalauth.Config, matchedProviderIDs []string, err 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 nil, nil, xerrors.Errorf("get template version: %w", err) + } + + var declared []database.ExternalAuthProvider + if err := json.Unmarshal(templateVersion.ExternalAuthProviders, &declared); err != nil { + return nil, nil, xerrors.Errorf("unmarshal template version external auth providers: %w", err) + } + if len(declared) == 0 { + return nil, nil, nil + } + declaredIDs := make([]string, len(declared)) + for i, provider := range declared { + declaredIDs[i] = provider.ID + } + + var candidates []*externalauth.Config + for _, extAuth := range api.ExternalAuthConfigs { + if !slices.Contains(declaredIDs, extAuth.ID) { + continue + } + if extAuth.Regex == nil || !extAuth.Regex.MatchString(match) { + continue + } + candidates = append(candidates, extAuth) + } + + matchedProviderIDs = make([]string, 0, len(candidates)) + for _, candidate := range candidates { + matchedProviderIDs = append(matchedProviderIDs, 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 { + return candidates[0], matchedProviderIDs, nil + } + return nil, matchedProviderIDs, 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. diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index cfc4845d516..438c7d655be 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -3876,3 +3876,290 @@ 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. + 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.StatusConflict, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, idBroad) + require.Contains(t, sdkErr.Message, idDot) + }) + + // 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") +} From 5547015447a529ca33c13c1e3386b1183135f3cc Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 5 Aug 2026 09:43:11 -0700 Subject: [PATCH 2/4] fix(coderd): refuse stale external auth declarations, don't fall back 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. --- coderd/workspaceagents.go | 108 +++++++++++++++++++++------------ coderd/workspaceagents_test.go | 48 ++++++++++++++- 2 files changed, 116 insertions(+), 40 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 283d198e593..b6d07297e1e 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2127,11 +2127,15 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ } else { // match-only path (GIT_ASKPASS supplies a hostname, never an ID): // narrow to the workspace's own template-declared providers first. - // Fall back to the full deployment-wide scan when none of them match - // this hostname (e.g. a host the template never declared), and fail - // with a clear error rather than guess when several of the template's - // own declared providers match this hostname. - declaredConfig, matchedProviderIDs, err := api.workspaceAgentsExternalAuthDeclaredCandidate(ctx, build, match) + // 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.", @@ -2139,15 +2143,27 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ }) return } - if len(matchedProviderIDs) > 1 { - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: fmt.Sprintf("Multiple external auth providers declared by this workspace's template match %q: %s.", match, strings.Join(matchedProviderIDs, ", ")), - Detail: "Use a build script with an explicit provider ID (`coder external-auth access-token `) instead of relying on automatic git credential resolution to disambiguate between these providers.", + 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 `.", }) return - } - externalAuthConfig = declaredConfig - if externalAuthConfig == nil { + 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 @@ -2271,60 +2287,76 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ httpapi.Write(ctx, rw, http.StatusOK, resp) } -// workspaceAgentsExternalAuthDeclaredCandidate resolves which configured +// 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. +// scoped to the given build's template version's declared providers. The zero +// value means the template declares no providers at all. // -// matchedProviderIDs lists the ID of every declared provider whose regex -// matches the hostname. config is set only when exactly one matched, so: -// - one match: config is that provider; the caller should use it. -// - no matches (including when the template declares none): the caller -// should fall back to a deployment-wide scan. -// - several matches: config is nil and the caller should surface an error -// naming matchedProviderIDs rather than guess between them. -func (api *API) workspaceAgentsExternalAuthDeclaredCandidate(ctx context.Context, build database.WorkspaceBuild, match string) (config *externalauth.Config, matchedProviderIDs []string, err error) { +// 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 nil, nil, xerrors.Errorf("get template version: %w", err) + return declaredExternalAuthCandidates{}, xerrors.Errorf("get template version: %w", err) } var declared []database.ExternalAuthProvider if err := json.Unmarshal(templateVersion.ExternalAuthProviders, &declared); err != nil { - return nil, nil, xerrors.Errorf("unmarshal template version external auth providers: %w", err) - } - if len(declared) == 0 { - return nil, nil, nil - } - declaredIDs := make([]string, len(declared)) - for i, provider := range declared { - declaredIDs[i] = provider.ID + return declaredExternalAuthCandidates{}, xerrors.Errorf("unmarshal template version external auth providers: %w", err) } - var candidates []*externalauth.Config - for _, extAuth := range api.ExternalAuthConfigs { - if !slices.Contains(declaredIDs, extAuth.ID) { + 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) } - matchedProviderIDs = make([]string, 0, len(candidates)) for _, candidate := range candidates { - matchedProviderIDs = append(matchedProviderIDs, candidate.ID) + 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 { - return candidates[0], matchedProviderIDs, nil + out.config = candidates[0] } - return nil, matchedProviderIDs, nil + return out, nil } func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.ResponseWriter, previous *database.ExternalAuthLink, externalAuthConfig *externalauth.Config, workspace database.Workspace, gitRef chatGitRef) { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 438c7d655be..53792cf78a9 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -4037,7 +4037,8 @@ func TestWorkspaceAgentsExternalAuthTemplateScoped(t *testing.T) { // When several of the template's own declared providers match a // hostname, the server must return a clear error rather than silently - // pick one. + // 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) @@ -4052,11 +4053,54 @@ func TestWorkspaceAgentsExternalAuthTemplateScoped(t *testing.T) { require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusConflict, sdkErr.StatusCode()) + 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. From 853ec50e523f6d3ad1447693831ead93a0a3b6e7 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 5 Aug 2026 10:14:17 -0700 Subject: [PATCH 3/4] docs: document template-scoped external auth provider selection 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. --- docs/admin/external-auth/index.md | 25 +++++++++++++++++-- .../extending-templates/external-auth.md | 5 ++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/admin/external-auth/index.md b/docs/admin/external-auth/index.md index 94adc1e023e..7f2c348fc49 100644 --- a/docs/admin/external-auth/index.md +++ b/docs/admin/external-auth/index.md @@ -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__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,17 @@ 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__REGEX` values so that only one matches the host, or fetch a token with an explicit provider ID using `coder external-auth access-token ` in your template's startup script. +- **The template declares a provider that the deployment no longer configures.** + This happens when a provider is renamed or removed after a template started declaring it. + Coder returns an HTTP 404 naming the missing provider, and doesn't substitute a different provider's token even when another configured provider matches the host. + Restore that provider's configuration, or update the template to declare a provider that the deployment configures. diff --git a/docs/admin/templates/extending-templates/external-auth.md b/docs/admin/templates/extending-templates/external-auth.md index aacad98b878..e80def992c7 100644 --- a/docs/admin/templates/extending-templates/external-auth.md +++ b/docs/admin/templates/extending-templates/external-auth.md @@ -38,6 +38,11 @@ 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 your template declares two providers that serve the same host, or declares one that the deployment no longer configures, Coder refuses the request instead of guessing. +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 From 94a2e091ea36a017d01bc4a3d46a8a5a909fce9f Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 5 Aug 2026 12:55:53 -0700 Subject: [PATCH 4/4] docs: correct when a stale external auth declaration causes a refusal 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. --- docs/admin/external-auth/index.md | 5 +++-- docs/admin/templates/extending-templates/external-auth.md | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/admin/external-auth/index.md b/docs/admin/external-auth/index.md index 7f2c348fc49..a9f3131bb84 100644 --- a/docs/admin/external-auth/index.md +++ b/docs/admin/external-auth/index.md @@ -394,7 +394,8 @@ In both, the request fails and `coder gitaskpass` prints a warning and falls bac - **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__REGEX` values so that only one matches the host, or fetch a token with an explicit provider ID using `coder external-auth access-token ` in your template's startup script. -- **The template declares a provider that the deployment no longer configures.** +- **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, and doesn't substitute a different provider's token even when another configured provider matches the host. + 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. diff --git a/docs/admin/templates/extending-templates/external-auth.md b/docs/admin/templates/extending-templates/external-auth.md index e80def992c7..29190d4f52b 100644 --- a/docs/admin/templates/extending-templates/external-auth.md +++ b/docs/admin/templates/extending-templates/external-auth.md @@ -40,7 +40,9 @@ 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 your template declares two providers that serve the same host, or declares one that the deployment no longer configures, Coder refuses the request instead of guessing. +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: