package agent import ( "encoding/json" "io" "net/http" "net/http/httptest" "strconv" "strings" "testing" ) const ( testAPIToken = "ak-api-token-secret" testOutpostKey = "outpost-key-secret" testTokenPath = "service/authentik/agent-api-token" testDestPath = "kubernetes/namespace/authentik/default/outpost-token" testOutpostName = "k8s-outpost" testTokenIdent = "ak-outpost-k8s-outpost" testVaultClientT = "s.vaulttoken" ) // vaultStub is a KV-v2 stand-in whose per-path behaviour tests can override. type vaultStub struct { readStatus int writeStatus int tokenField string // field name the API token is stored under writes []map[string]string writeCount int } func newVaultStub() *vaultStub { return &vaultStub{readStatus: http.StatusOK, writeStatus: http.StatusOK, tokenField: "token"} } func (v *vaultStub) server(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) { var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) if _, ok := body["secret_id"]; ok { t.Errorf("secret_id must not be sent") } _, _ = io.WriteString(w, `{"auth":{"client_token":"`+testVaultClientT+`"}}`) }) mux.HandleFunc("/v1/kv/data/"+testTokenPath, func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("X-Vault-Token"); got != testVaultClientT { t.Errorf("X-Vault-Token = %q, want %q", got, testVaultClientT) } if v.readStatus != http.StatusOK { w.WriteHeader(v.readStatus) _, _ = io.WriteString(w, `{"errors":["permission denied"]}`) return } _, _ = io.WriteString(w, `{"data":{"data":{"`+v.tokenField+`":"`+testAPIToken+`"},"metadata":{"version":1}}}`) }) mux.HandleFunc("/v1/kv/data/"+testDestPath, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("write method = %s, want POST", r.Method) } if v.writeStatus != http.StatusOK { w.WriteHeader(v.writeStatus) _, _ = io.WriteString(w, `{"errors":["permission denied"]}`) return } var body struct { Data map[string]string `json:"data"` } _ = json.NewDecoder(r.Body).Decode(&body) v.writes = append(v.writes, body.Data) v.writeCount++ _, _ = io.WriteString(w, `{"data":{"version":`+strconv.Itoa(v.writeCount+2)+`}}`) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv } // authentikStub serves the outpost search and view_key endpoints. type authentikStub struct { results string // JSON array body for .results viewKeyStatus int viewKeyBody string sawBearer string sawSearchQuery string } func newAuthentikStub() *authentikStub { return &authentikStub{ results: `{"pk":"1","name":"` + testOutpostName + `","token_identifier":"` + testTokenIdent + `"}`, viewKeyStatus: http.StatusOK, viewKeyBody: `{"key":"` + testOutpostKey + `"}`, } } func (a *authentikStub) server(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("/api/v3/outposts/instances/", func(w http.ResponseWriter, r *http.Request) { a.sawBearer = r.Header.Get("Authorization") a.sawSearchQuery = r.URL.Query().Get("search") _, _ = io.WriteString(w, `{"results":[`+a.results+`]}`) }) mux.HandleFunc("/api/v3/core/tokens/"+testTokenIdent+"/view_key/", func(w http.ResponseWriter, r *http.Request) { if a.viewKeyStatus != http.StatusOK { w.WriteHeader(a.viewKeyStatus) _, _ = io.WriteString(w, `{"detail":"boom"}`) return } _, _ = io.WriteString(w, a.viewKeyBody) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv } func opts(vaultURL, authentikURL string) SeedOutpostOptions { return SeedOutpostOptions{ VaultAddr: vaultURL, RoleID: "role-xyz", AuthentikURL: authentikURL, Outpost: testOutpostName, KVMount: DefaultKVMount, TokenPath: testTokenPath, DestPath: testDestPath, DestKey: DefaultDestKey, } } func TestSeedOutpostHappyPath(t *testing.T) { v := newVaultStub() a := newAuthentikStub() vs, as := v.server(t), a.server(t) res, err := SeedOutpost(opts(vs.URL, as.URL)) if err != nil { t.Fatalf("SeedOutpost: %v", err) } if res.Outpost != testOutpostName { t.Errorf("Outpost = %q, want %q", res.Outpost, testOutpostName) } if res.TokenIdentifier != testTokenIdent { t.Errorf("TokenIdentifier = %q, want %q", res.TokenIdentifier, testTokenIdent) } if res.DestPath != testDestPath || res.KVMount != DefaultKVMount { t.Errorf("dest = %s/%s, want kv/%s", res.KVMount, res.DestPath, testDestPath) } if res.Version != 3 { t.Errorf("Version = %d, want 3", res.Version) } if a.sawBearer != "Bearer "+testAPIToken { t.Errorf("Authorization = %q, want the API token as a bearer", a.sawBearer) } if a.sawSearchQuery != testOutpostName { t.Errorf("search = %q, want %q", a.sawSearchQuery, testOutpostName) } if len(v.writes) != 1 || v.writes[0][DefaultDestKey] != testOutpostKey { t.Fatalf("written data = %v, want {%s: outpost key}", v.writes, DefaultDestKey) } } // Re-running writes a new KV version rather than failing. func TestSeedOutpostIdempotentNewVersion(t *testing.T) { v := newVaultStub() a := newAuthentikStub() vs, as := v.server(t), a.server(t) first, err := SeedOutpost(opts(vs.URL, as.URL)) if err != nil { t.Fatalf("first SeedOutpost: %v", err) } second, err := SeedOutpost(opts(vs.URL, as.URL)) if err != nil { t.Fatalf("second SeedOutpost: %v", err) } if second.Version != first.Version+1 { t.Errorf("versions = %d then %d, want consecutive", first.Version, second.Version) } } // The API token may be stored under api_token instead of token. func TestSeedOutpostAPITokenFallbackField(t *testing.T) { v := newVaultStub() v.tokenField = "api_token" a := newAuthentikStub() vs, as := v.server(t), a.server(t) if _, err := SeedOutpost(opts(vs.URL, as.URL)); err != nil { t.Fatalf("SeedOutpost with api_token field: %v", err) } if a.sawBearer != "Bearer "+testAPIToken { t.Errorf("Authorization = %q, want the api_token value", a.sawBearer) } } func TestSeedOutpostCustomDestKey(t *testing.T) { v := newVaultStub() a := newAuthentikStub() vs, as := v.server(t), a.server(t) o := opts(vs.URL, as.URL) o.DestKey = "outpost-token" if _, err := SeedOutpost(o); err != nil { t.Fatalf("SeedOutpost: %v", err) } if len(v.writes) != 1 || v.writes[0]["outpost-token"] != testOutpostKey { t.Errorf("written data = %v, want the key under outpost-token", v.writes) } } func TestSeedOutpostLoginFailure(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = io.WriteString(w, `{"errors":["invalid role ID"]}`) }) vs := httptest.NewServer(mux) defer vs.Close() a := newAuthentikStub() _, err := SeedOutpost(opts(vs.URL, a.server(t).URL)) if err == nil { t.Fatal("SeedOutpost() = nil, want an approle login error") } if !strings.Contains(err.Error(), "approle login failed") { t.Errorf("error = %v, want it to name the approle login", err) } } func TestSeedOutpostKVReadDenied(t *testing.T) { v := newVaultStub() v.readStatus = http.StatusForbidden a := newAuthentikStub() vs, as := v.server(t), a.server(t) _, err := SeedOutpost(opts(vs.URL, as.URL)) if err == nil { t.Fatal("SeedOutpost() = nil, want a KV read error") } msg := err.Error() if !strings.Contains(msg, testTokenPath) || !strings.Contains(msg, "policy") { t.Errorf("error = %v, want it to name the token path and point at the policy", err) } if strings.Contains(msg, "does not exist") { t.Errorf("error = %v, denied must not be reported as missing", err) } } func TestSeedOutpostKVReadNotFound(t *testing.T) { v := newVaultStub() v.readStatus = http.StatusNotFound a := newAuthentikStub() vs, as := v.server(t), a.server(t) _, err := SeedOutpost(opts(vs.URL, as.URL)) if err == nil { t.Fatal("SeedOutpost() = nil, want a missing-secret error") } if !strings.Contains(err.Error(), "does not exist") { t.Errorf("error = %v, want it to say the secret does not exist", err) } } func TestSeedOutpostMissingTokenField(t *testing.T) { v := newVaultStub() v.tokenField = "password" a := newAuthentikStub() vs, as := v.server(t), a.server(t) _, err := SeedOutpost(opts(vs.URL, as.URL)) if err == nil { t.Fatal("SeedOutpost() = nil, want an error for a secret with no token field") } if !strings.Contains(err.Error(), "api_token") { t.Errorf("error = %v, want it to name the accepted fields", err) } } // A substring hit that is not the exact name must not be accepted. func TestSeedOutpostNotFound(t *testing.T) { v := newVaultStub() a := newAuthentikStub() a.results = `{"pk":"1","name":"` + testOutpostName + `-staging","token_identifier":"other"}` vs, as := v.server(t), a.server(t) _, err := SeedOutpost(opts(vs.URL, as.URL)) if err == nil { t.Fatal("SeedOutpost() = nil, want an outpost-not-found error") } msg := err.Error() if !strings.Contains(msg, "no outpost named") || !strings.Contains(msg, "terraform") { t.Errorf("error = %v, want it to report the missing outpost and mention terraform", err) } if len(v.writes) != 0 { t.Errorf("wrote %v, want no KV write when the outpost is missing", v.writes) } } func TestSeedOutpostViewKeyFailure(t *testing.T) { v := newVaultStub() a := newAuthentikStub() a.viewKeyStatus = http.StatusForbidden vs, as := v.server(t), a.server(t) _, err := SeedOutpost(opts(vs.URL, as.URL)) if err == nil { t.Fatal("SeedOutpost() = nil, want a view_key error") } if !strings.Contains(err.Error(), testTokenIdent) { t.Errorf("error = %v, want it to name the token identifier", err) } if len(v.writes) != 0 { t.Errorf("wrote %v, want no KV write when view_key fails", v.writes) } } func TestSeedOutpostViewKeyEmpty(t *testing.T) { v := newVaultStub() a := newAuthentikStub() a.viewKeyBody = `{}` vs, as := v.server(t), a.server(t) if _, err := SeedOutpost(opts(vs.URL, as.URL)); err == nil { t.Fatal("SeedOutpost() = nil, want an error when view_key returns no key") } } func TestSeedOutpostKVWriteDenied(t *testing.T) { v := newVaultStub() v.writeStatus = http.StatusForbidden a := newAuthentikStub() vs, as := v.server(t), a.server(t) _, err := SeedOutpost(opts(vs.URL, as.URL)) if err == nil { t.Fatal("SeedOutpost() = nil, want a KV write error") } msg := err.Error() if !strings.Contains(msg, testDestPath) || !strings.Contains(msg, "create/update") { t.Errorf("error = %v, want it to name the dest path and the missing capability", err) } } // No failure path may leak the API token or the outpost key into the error. func TestSeedOutpostErrorsNeverLeakSecrets(t *testing.T) { cases := map[string]func(*vaultStub, *authentikStub){ "read denied": func(v *vaultStub, a *authentikStub) { v.readStatus = http.StatusForbidden }, "write denied": func(v *vaultStub, a *authentikStub) { v.writeStatus = http.StatusForbidden }, "view_key fail": func(v *vaultStub, a *authentikStub) { a.viewKeyStatus = http.StatusInternalServerError }, "outpost gone": func(v *vaultStub, a *authentikStub) { a.results = "" }, } for name, mutate := range cases { t.Run(name, func(t *testing.T) { v, a := newVaultStub(), newAuthentikStub() mutate(v, a) vs, as := v.server(t), a.server(t) _, err := SeedOutpost(opts(vs.URL, as.URL)) if err == nil { t.Fatal("SeedOutpost() = nil, want an error") } for _, secret := range []string{testAPIToken, testOutpostKey} { if strings.Contains(err.Error(), secret) { t.Errorf("error %q leaks a secret", err) } } }) } }