diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index 104b45f9a..f855f7967 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -429,7 +429,6 @@ export default defineComponent({ return this.template?.Auth && !this.auth.ok; }, authValuesMissing() { - console.log("authValuesMissing", this.authValues); return this.template?.Auth && Object.values(this.authValues).some((value) => !value); }, authValues() { @@ -628,9 +627,10 @@ export default defineComponent({ if (this.authValuesMissing) return; const { type } = this.template.Auth; - const values = this.authValues; + // include the template name so the backend can resolve masked fields + const values = { ...this.authValues, template: this.templateName }; this.auth.loading = true; - const result = await this.device.checkAuth(type, values); + const result = await this.device.checkAuth(type, values, this.id); this.auth.loading = false; if (result.success) { // login already exists diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index afa1123d8..bf2cd6edb 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -236,10 +236,18 @@ export function createDeviceUtils(deviceType: DeviceType) { return response.data; } - async function checkAuth(type: string, values: Record): Promise { - const params = { type, ...values }; + async function checkAuth( + type: string, + values: Record, + id?: number + ): Promise { + const body = { type, ...values }; + let url = `config/auth`; + if (id !== undefined) { + url += `/${deviceType}/merge/${id}`; + } try { - const { status, data = {} } = await api.post(`config/auth`, params, { + const { status, data = {} } = await api.post(url, body, { validateStatus: (status) => [204, 400].includes(status), }); // already set up diff --git a/plugin/auth/demo.go b/plugin/auth/demo.go index 5aec084da..deaa7cb7a 100644 --- a/plugin/auth/demo.go +++ b/plugin/auth/demo.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "fmt" "net/url" "strings" @@ -32,16 +33,21 @@ func NewDemoFromConfig(_ context.Context, other map[string]any) (oauth2.TokenSou Server string Method string RedirectUri string + Secret string } if err := util.DecodeOther(other, &cc); err != nil { return nil, err } - return NewDemo(cc.Server, cc.Method, cc.RedirectUri) + return NewDemo(cc.Server, cc.Method, cc.RedirectUri, cc.Secret) } -func NewDemo(server string, method string, redirectUri string) (oauth2.TokenSource, error) { +func NewDemo(server, method, redirectUri, secret string) (oauth2.TokenSource, error) { + if secret != "topsecret" { + return nil, errors.New("invalid secret") + } + // reuse instance (similar to oauth.go getInstance pattern) if demoInstance != nil { // update existing instance with new values diff --git a/server/http.go b/server/http.go index 4279605d1..a0c4cfe4f 100644 --- a/server/http.go +++ b/server/http.go @@ -279,6 +279,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti routes := map[string]route{ "auth": {"POST", "/auth", authHandler}, + "authmerged": {"POST", "/auth/{class:[a-z]+}/merge/{id:[0-9.]+}", authHandler}, "templates": {"GET", "/templates/{class:[a-z]+}", templatesHandler}, "products": {"GET", "/products/{class:[a-z]+}", productsHandler}, "devices": {"GET", "/devices/{class:[a-z]+}", devicesConfigHandler}, diff --git a/server/http_config_helper.go b/server/http_config_helper.go index 3930942a1..84f5060f8 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -107,6 +107,7 @@ func templateForConfig(class templates.Class, conf map[string]any) (templates.Te return templates.ByName(class, typ) } +// filterValidTemplateParams removes all configuration properties that are not part of the template definition func filterValidTemplateParams(tmpl *templates.Template, conf map[string]any) map[string]any { res := make(map[string]any) @@ -169,6 +170,32 @@ func mergeMasked(class templates.Class, conf, old map[string]any) (map[string]an }) } +// deviceOther looks up a stored device's `Other` config by class and id. +func deviceOther(class templates.Class, id int) (map[string]any, error) { + name := config.NameForID(id) + switch class { + case templates.Charger: + return deviceOtherFromHandler(name, config.Chargers()) + case templates.Meter: + return deviceOtherFromHandler(name, config.Meters()) + case templates.Vehicle: + return deviceOtherFromHandler(name, config.Vehicles()) + case templates.Tariff: + return deviceOtherFromHandler(name, config.Tariffs()) + case templates.Messenger: + return deviceOtherFromHandler(name, config.Messengers()) + } + return nil, errors.New("unsupported class: " + class.String()) +} + +func deviceOtherFromHandler[T any](name string, h config.Handler[T]) (map[string]any, error) { + dev, err := h.ByName(name) + if err != nil { + return nil, err + } + return dev.Config().Other, nil +} + func startDeviceTimeout() (context.Context, context.CancelFunc, chan struct{}) { done := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) diff --git a/server/http_config_metadata_handler.go b/server/http_config_metadata_handler.go index bcd331093..14eeef9a9 100644 --- a/server/http_config_metadata_handler.go +++ b/server/http_config_metadata_handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "slices" + "strconv" "strings" "github.com/evcc-io/evcc/plugin/auth" @@ -42,6 +43,38 @@ func authHandler(w http.ResponseWriter, r *http.Request) { return } + // when editing existing device, merge masked values with stored config + if vars := mux.Vars(r); vars["class"] != "" && vars["id"] != "" { + id, err := strconv.Atoi(vars["id"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + class, err := templates.ClassString(vars["class"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + old, err := deviceOther(class, id) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + merged, err := mergeMasked(class, cc.Other, old) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + cc.Other = merged + } + + // template is only needed by mergeMasked above; the auth decoder is strict + delete(cc.Other, typeTemplate) + ts, err := auth.NewFromConfig(context.Background(), cc.Type, cc.Other) if err != nil { jsonError(w, http.StatusBadRequest, err) diff --git a/tests/config-device-auth-demo.tpl.yaml b/tests/config-device-auth-demo.tpl.yaml index 46e3395e7..b33aa1fba 100644 --- a/tests/config-device-auth-demo.tpl.yaml +++ b/tests/config-device-auth-demo.tpl.yaml @@ -6,7 +6,7 @@ products: en: Auth Demo Meter auth: type: demo - params: ["server", "method", "redirectUri"] + params: ["server", "method", "redirectUri", "secret"] params: - name: usage choice: ["grid"] @@ -25,6 +25,11 @@ params: generic: Authentication Method choice: ["redirect", "device-code"] required: true + - name: secret + description: + generic: Secret + required: true + mask: true - name: power description: generic: Power diff --git a/tests/config-device-auth.spec.ts b/tests/config-device-auth.spec.ts index 7b1116795..d5b1b7267 100644 --- a/tests/config-device-auth.spec.ts +++ b/tests/config-device-auth.spec.ts @@ -5,6 +5,8 @@ import { simulatorUrl, startSimulator, stopSimulator } from "./simulator"; test.use({ baseURL: baseUrl() }); +const secret = "topsecret"; + const templateFlags = [ "--disable-auth", "--template-type", @@ -51,6 +53,7 @@ test.describe("config device auth", async () => { await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); + await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); // Get the login link and remove target="_blank" to keep it in same page @@ -85,6 +88,7 @@ test.describe("config device auth", async () => { await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); + await meterModal.getByLabel("Secret").fill(secret); // Even though auth is already done, still need to click prepare connection to proceed to device fields await meterModal.getByRole("button", { name: "Prepare connection" }).click(); @@ -109,6 +113,7 @@ test.describe("config device auth", async () => { await expectModalVisible(meterModal); await expect(meterModal.getByLabel("Server")).toHaveValue(simulatorUrl()); await expect(meterModal.getByLabel("Authentication Method")).toHaveValue("redirect"); + await expect(meterModal.getByLabel("Secret")).toHaveValue("***"); await expect(meterModal.getByLabel("Power")).toHaveValue("5000"); await expect(meterModal.getByRole("button", { name: "Prepare connection" })).not.toBeVisible(); await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); @@ -143,6 +148,7 @@ test.describe("config device auth", async () => { await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("device-code"); + await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); // verify device code is displayed @@ -162,6 +168,7 @@ test.describe("config device auth", async () => { await meterModal.getByLabel("Server").fill("invalid-url-without-scheme"); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); + await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); await expect(meterModal).toContainText("server must start with http:// or https://"); @@ -200,6 +207,7 @@ test.describe("config device auth", async () => { await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); + await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); // Get the login link and remove target="_blank" to keep it in same page @@ -236,6 +244,7 @@ test.describe("config device auth", async () => { await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); + await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); // verify connection link is ready but close modal instead of clicking it