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")
}