diff --git a/.gitignore b/.gitignore index 17b7c1607..43c4eff00 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ __debug_bin* !package*.json !evcc.dist.yaml !tests/**/*.evcc.yaml +!tests/**/*.tpl.yaml !tsconfig.json /templates/docs evcc diff --git a/assets/js/api.ts b/assets/js/api.ts index 9b3753821..86fc9ddb4 100644 --- a/assets/js/api.ts +++ b/assets/js/api.ts @@ -19,6 +19,7 @@ function customParamsSerializer(params: { [key: string]: any }) { .join("&"); } +// general api client const api = axios.create({ baseURL: base + "api/", headers: { @@ -27,32 +28,35 @@ const api = axios.create({ paramsSerializer: customParamsSerializer, }); -// global error handling -api.interceptors.response.use( - (response) => response, - (error) => { - // handle unauthorized errors - if (error.response?.status === 401) { - openLoginModal(); - return Promise.reject(error); - } - - const message = [`${error.message}.`]; - if (error.response?.data?.error) { - message.push(`${error.response.data.error}.`); - } - if (error.config) { - const method = error.config.method.toUpperCase(); - const url = error.request.responseURL; - message.push(`${method} ${url}`); - } - window.app.raise({ message }); +const errorInterceptor = (error: any) => { + // handle unauthorized errors + if (error.response?.status === 401) { + openLoginModal(); return Promise.reject(error); } -); + + const message = [`${error.message}.`]; + if (error.response?.data?.error) { + message.push(`${error.response.data.error}.`); + } + if (error.config) { + const method = error.config.method.toUpperCase(); + const url = error.request.responseURL; + message.push(`${method} ${url}`); + } + window.app.raise({ message }); + return Promise.reject(error); +}; +api.interceptors.response.use((response) => response, errorInterceptor); export default api; +// api client for calling non `/api` prefixed routes (e.g. auth provider) +export const baseApi = axios.create({ + baseURL: base, +}); +baseApi.interceptors.response.use((response) => response, errorInterceptor); + export const i18n = axios.create({ baseURL: base + "i18n/", headers: { diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index f2050945e..2f216e07d 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -8,6 +8,7 @@ :size="modalSize" @open="handleOpen" @close="handleClose" + @visibilitychange="handleVisibilityChange" >
@@ -44,46 +45,87 @@ - +
+ +

{{ authError }}

+
+ + +
+
+
+ - + - + - - - - + + + + +
params.includes(p.Name)); + }, normalParams() { return this.templateParams.filter((p) => !p.Advanced && !p.Deprecated); }, @@ -296,7 +347,7 @@ export default defineComponent({ return !this.isNew; }, showActions() { - return this.templateName || this.showYamlInput; + return (this.templateName && !this.authRequired) || this.showYamlInput; }, showYamlInput() { return this.isYamlInputTypeByValue(this.values.type); @@ -307,6 +358,25 @@ export default defineComponent({ showDeprecatedWarning() { return this.isTypeDeprecated && this.isTypeDeprecated(this.values.type); }, + authRequired() { + return this.template?.Auth && !this.authOk; + }, + authValuesMissing() { + return this.template?.Auth && Object.values(this.authValues).some((value) => !value); + }, + authValues() { + const params = this.template?.Auth?.params ?? []; + return params.reduce( + (acc, param) => { + acc[param] = this.values[param]; + return acc; + }, + {} as Record + ); + }, + authProviderDomain() { + return this.authProviderUrl ? extractDomain(this.authProviderUrl) : null; + }, }, watch: { isModalVisible(visible) { @@ -406,6 +476,7 @@ export default defineComponent({ if (this.onConfigurationLoaded) { this.onConfigurationLoaded(this.values); } + this.checkAuthStatus(); } catch (e) { console.error(e); } @@ -438,11 +509,61 @@ export default defineComponent({ this.$i18n?.locale ); this.applyDefaults(); + this.checkAuthStatus(); } catch (e) { console.error(e); } this.loadingTemplate = false; }, + async checkAuthStatus() { + this.authOk = false; + this.authProviderUrl = null; + + // no auth required + if (!this.template?.Auth) return; + + // trigger browser validation + if (this.$refs["form"]) { + if (!(this.$refs["form"] as HTMLFormElement).reportValidity()) { + return; + } + } + + // validate data + if (this.authValuesMissing) return; + + this.authOk = false; + this.authProviderUrl = null; + const { type } = this.template.Auth; + const values = this.authValues; + this.authLoading = true; + const result = await this.device.checkAuth(type, values); + this.authLoading = false; + if (result.success) { + // login already exists + this.authError = null; + this.authOk = true; + } else if (result.authId) { + // todo, save form field state and restore on callback + await this.performAuthLogin(result.authId); + } else { + // something else failed + this.authError = result.error ?? "unknown error"; + } + }, + async performAuthLogin(authId: string) { + // trigger external login flow + try { + this.authLoading = true; + this.authProviderUrl = await this.device.getAuthProviderUrl(authId); + this.authLoading = false; + } catch (e) { + console.error("performAuthLogin failed", e); + this.authError = (e as any).message; + } finally { + this.authLoading = false; + } + }, async create(force = false) { if (this.test.isUnknown && !force) { const success = await performTest( @@ -542,6 +663,9 @@ export default defineComponent({ handleRemove() { this.remove(); }, + handleVisibilityChange() { + this.checkAuthStatus(); + }, isYamlInputTypeByValue(value: ConfigType): boolean { if (this.isYamlInputType) { return this.isYamlInputType(value); diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index 5de3b4a8f..bff814364 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -1,6 +1,6 @@ import type { DeviceType, MODBUS_COMSET, MeterTemplateUsage } from "@/types/evcc"; import { ConfigType } from "@/types/evcc"; -import api from "@/api"; +import api, { baseApi } from "@/api"; export type Product = { group: string; @@ -10,6 +10,10 @@ export type Product = { export type Template = { Params: TemplateParam[]; + Auth?: { + type: string; + params?: string[]; + }; Requirements: { Description: string; }; @@ -60,6 +64,17 @@ export type ApiData = { [key: string]: any; }; +export type AuthCheckResponse = { + success: boolean; + error?: string; + authId?: string; +}; + +export type ProviderLoginResponse = { + loginUri?: string; + error?: string; +}; + export function handleError(e: any, msg: string) { console.error(e); let message = msg; @@ -142,6 +157,42 @@ export function createDeviceUtils(deviceType: DeviceType) { return response.data; } + async function checkAuth(type: string, values: Record): Promise { + const params = { type, ...values }; + try { + const { status, data = {} } = await api.post(`config/auth`, params, { + validateStatus: (status) => [204, 400].includes(status), + }); + // already set up + if (status === 204) { + return { success: true }; + } + // auth error, user has to perform login + if (status === 400) { + return { success: false, error: data?.error, authId: data?.loginRequired }; + } + } catch (error) { + return { success: false, error: (error as any).message }; + } + return { success: false, error: "unexpected error" }; + } + + async function getAuthProviderUrl(authId: string): Promise { + try { + const url = `providerauth/login?id=${encodeURIComponent(authId)}`; + const { status, data = {} } = await baseApi.get(url, { + validateStatus: (code) => [200, 400].includes(code), + }); + //return "https://test.example.org/auth"; + if (status === 200) { + return data?.loginUri; + } + throw new Error(data?.error ?? "unknown error"); + } catch (error) { + throw new Error((error as Error).message ?? "unknown error"); + } + } + return { test, update, @@ -150,5 +201,7 @@ export function createDeviceUtils(deviceType: DeviceType) { create, loadProducts, loadTemplate, + checkAuth, + getAuthProviderUrl, }; } diff --git a/assets/js/components/Helper/GenericModal.vue b/assets/js/components/Helper/GenericModal.vue index b09531e48..c87ac0c03 100644 --- a/assets/js/components/Helper/GenericModal.vue +++ b/assets/js/components/Helper/GenericModal.vue @@ -51,7 +51,7 @@ export default defineComponent({ size: String, autofocus: { type: Boolean, default: true }, }, - emits: ["open", "opened", "close", "closed"], + emits: ["open", "opened", "close", "closed", "visibilitychange"], data() { return { isModalVisible: false, @@ -83,12 +83,14 @@ export default defineComponent({ this.$refs["modal"]?.addEventListener("shown.bs.modal", this.handleShown); this.$refs["modal"]?.addEventListener("hide.bs.modal", this.handleHide); this.$refs["modal"]?.addEventListener("hidden.bs.modal", this.handleHidden); + document.addEventListener("visibilitychange", this.handleVisibilityChange); }, unmounted() { this.$refs["modal"]?.removeEventListener("show.bs.modal", this.handleShow); this.$refs["modal"]?.removeEventListener("shown.bs.modal", this.handleShown); this.$refs["modal"]?.removeEventListener("hide.bs.modal", this.handleHide); this.$refs["modal"]?.removeEventListener("hidden.bs.modal", this.handleHidden); + document.removeEventListener("visibilitychange", this.handleVisibilityChange); }, methods: { handleShow() { @@ -131,6 +133,11 @@ export default defineComponent({ console.log(this.dataTestid, "> close", modal._isShown); Modal.getOrCreateInstance(modal).hide(); }, + handleVisibilityChange() { + if (document.visibilityState === "visible" && this.isModalVisible) { + this.$emit("visibilitychange"); + } + }, }, }); diff --git a/assets/js/components/Top/Navigation.vue b/assets/js/components/Top/Navigation.vue index 4e5fee340..cbb5edab7 100644 --- a/assets/js/components/Top/Navigation.vue +++ b/assets/js/components/Top/Navigation.vue @@ -136,7 +136,7 @@ import "@h2d2/shopicons/es/regular/menu"; import "@h2d2/shopicons/es/regular/newtab"; import collector from "@/mixins/collector"; import { logout, isLoggedIn, openLoginModal } from "../Auth/auth"; -import baseAPI from "./baseapi"; +import { baseApi } from "@/api"; import { isApp, sendToApp } from "@/utils/native"; import { isUserConfigError } from "@/utils/fatal"; import { defineComponent, type PropType } from "vue"; @@ -219,7 +219,7 @@ export default defineComponent({ const { title, authenticated, loginPath, logoutPath } = provider; if (!authenticated) { try { - const response = await baseAPI.get(loginPath, { + const response = await baseApi.get(loginPath, { validateStatus: (code) => [200, 400].includes(code), }); if (response.status === 200) { @@ -234,7 +234,7 @@ export default defineComponent({ } else { if (window.confirm(this.$t("header.authProviders.confirmLogout", { title }))) { try { - const response = await baseAPI.get(logoutPath, { + const response = await baseApi.get(logoutPath, { validateStatus: (code) => [200, 400, 500].includes(code), }); if (response.status === 200) { diff --git a/assets/js/components/Top/baseapi.ts b/assets/js/components/Top/baseapi.ts deleted file mode 100644 index da1299adc..000000000 --- a/assets/js/components/Top/baseapi.ts +++ /dev/null @@ -1,19 +0,0 @@ -import axios from "axios"; - -const { protocol, hostname, port, pathname } = window.location; - -const baseAPI = axios.create({ - baseURL: protocol + "//" + hostname + (port ? ":" + port : "") + pathname, -}); - -// global error handling -baseAPI.interceptors.response.use( - (response) => response, - (error) => { - const url = error.config.baseURL + error.config.url; - const message = `${error.message}: API request failed ${url}`; - window.app.raise({ message }); - } -); - -export default baseAPI; diff --git a/assets/js/utils/extractDomain.test.ts b/assets/js/utils/extractDomain.test.ts new file mode 100644 index 000000000..36701d2e2 --- /dev/null +++ b/assets/js/utils/extractDomain.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import { extractDomain } from "./extractDomain"; + +describe("extractDomain", () => { + test("extracts domain from URL", () => { + expect(extractDomain("https://login.example.org/secure")).toBe("example.org"); + expect(extractDomain("https://www.example.com/path")).toBe("example.com"); + expect(extractDomain("http://subdomain.example.org")).toBe("example.org"); + }); + + test("returns IPv4 address as-is", () => { + expect(extractDomain("https://192.168.1.1/path")).toBe("192.168.1.1"); + expect(extractDomain("http://10.0.0.1")).toBe("10.0.0.1"); + expect(extractDomain("https://127.0.0.1:8080")).toBe("127.0.0.1"); + }); + + test("returns full IPv6 address (treated as domain)", () => { + // IPv6 addresses are treated as domains, but have no dots so return full address + expect(extractDomain("https://[2001:db8::1]/path")).toBe("2001:db8::1"); + expect(extractDomain("http://[::1]:8080")).toBe("::1"); + expect(extractDomain("https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]")).toBe( + "2001:db8:85a3::8a2e:370:7334" + ); + }); + + test("throws for invalid URL", () => { + expect(() => extractDomain("not-a-url")).toThrow(); + expect(() => extractDomain("")).toThrow(); + }); + + test("handles single-part domains", () => { + expect(extractDomain("https://localhost/path")).toBe("localhost"); + expect(extractDomain("http://local")).toBe("local"); + }); +}); diff --git a/assets/js/utils/extractDomain.ts b/assets/js/utils/extractDomain.ts new file mode 100644 index 000000000..99020fbe9 --- /dev/null +++ b/assets/js/utils/extractDomain.ts @@ -0,0 +1,17 @@ +export const extractDomain = (url: string): string => { + const urlObj = new URL(url); + let hostname = urlObj.hostname; + + // ipv6 + if (hostname.startsWith("[") && hostname.endsWith("]")) { + hostname = hostname.slice(1, -1); + } + + // ipv4 + if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) { + return hostname; + } + + // domain + return hostname.split(".").slice(-2).join("."); +}; diff --git a/i18n/de.json b/i18n/de.json index 976da9bcb..32021a48e 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -158,6 +158,9 @@ }, "general": { "applyAndClose": "Übernehmen & schließen", + "authPerform": "Mit {provider} verbinden", + "authPerformHint": "Öffnet ein neues Tab. Anschließend hier weiter machen.", + "authPrepare": "Verbindung vorbereiten", "cancel": "Abbrechen", "close": "Schließen", "customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.", diff --git a/i18n/en.json b/i18n/en.json index f5640e399..95aff4d89 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -158,6 +158,9 @@ }, "general": { "applyAndClose": "Apply & close", + "authPerform": "Connect with {provider}", + "authPerformHint": "Will open in a new tab. Return here to continue.", + "authPrepare": "Prepare connection", "cancel": "Cancel", "close": "Close", "customHelp": "Create a user-defined device using evcc's plugin system.", diff --git a/plugin/auth/config.go b/plugin/auth/config.go index d1032664d..f51fccd99 100644 --- a/plugin/auth/config.go +++ b/plugin/auth/config.go @@ -5,12 +5,9 @@ import ( "fmt" "strings" - reg "github.com/evcc-io/evcc/util/registry" "golang.org/x/oauth2" ) -var registry = reg.New[oauth2.TokenSource]("auth") - // NewFromConfig creates auth from configuration func NewFromConfig(ctx context.Context, typ string, other map[string]any) (oauth2.TokenSource, error) { factory, err := registry.Get(strings.ToLower(typ)) diff --git a/plugin/auth/demo.go b/plugin/auth/demo.go new file mode 100644 index 000000000..454797671 --- /dev/null +++ b/plugin/auth/demo.go @@ -0,0 +1,75 @@ +package auth + +import ( + "context" + "net/url" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/server/providerauth" + "golang.org/x/oauth2" +) + +func init() { + registry.AddCtx("demo", NewDemoFromConfig) +} + +type demo struct { + token *oauth2.Token +} + +var demoInstance *demo + +func NewDemoFromConfig(_ context.Context, _ map[string]any) (oauth2.TokenSource, error) { + return NewDemo() +} + +func NewDemo() (oauth2.TokenSource, error) { + // reuse instance (similar to oauth.go getInstance pattern) + if demoInstance != nil { + return demoInstance, nil + } + + demoInstance = new(demo) + + if _, err := providerauth.Register("demo", demoInstance); err != nil { + return nil, err + } + + return demoInstance, nil +} + +func (o *demo) Token() (*oauth2.Token, error) { + if o.token == nil { + return nil, api.LoginRequiredError("demo") + } + return o.token, nil +} + +func (o *demo) Login(_ string) (string, error) { + // for demo, immediately authenticate without requiring external flow + o.token = &oauth2.Token{ + AccessToken: "demo-token", + Expiry: time.Now().Add(24 * time.Hour), + } + // TODO use network settings after https://github.com/evcc-io/evcc/pull/25141 + return "http://localhost:7070/providerauth/callback", nil +} + +func (o *demo) Logout() error { + o.token = nil + return nil +} + +func (o *demo) HandleCallback(params url.Values) error { + // no-op: token already set in Login() + return nil +} + +func (o *demo) Authenticated() bool { + return o.token != nil +} + +func (o *demo) DisplayName() string { + return "demo" +} diff --git a/plugin/auth/registry.go b/plugin/auth/registry.go new file mode 100644 index 000000000..63c694370 --- /dev/null +++ b/plugin/auth/registry.go @@ -0,0 +1,12 @@ +package auth + +import ( + reg "github.com/evcc-io/evcc/util/registry" + "golang.org/x/oauth2" +) + +var registry = reg.New[oauth2.TokenSource]("auth") + +func Register(typ string, fun func(map[string]any) (oauth2.TokenSource, error)) { + registry.Add(typ, fun) +} diff --git a/server/http.go b/server/http.go index ae9beeb1a..a6a8f769a 100644 --- a/server/http.go +++ b/server/http.go @@ -273,6 +273,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Par api.Use(ensureAuthHandler(auth)) routes := map[string]route{ + "auth": {"POST", "/auth", 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_metadata_handler.go b/server/http_config_metadata_handler.go index d0312cf3e..22f5c2f09 100644 --- a/server/http_config_metadata_handler.go +++ b/server/http_config_metadata_handler.go @@ -1,10 +1,14 @@ package server import ( + "context" + "encoding/json" "net/http" "slices" "strings" + "github.com/evcc-io/evcc/plugin/auth" + "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/templates" "github.com/gorilla/mux" "github.com/samber/lo" @@ -20,6 +24,38 @@ func getLang(r *http.Request) string { return lang } +// authHandler returns the authorization status +func authHandler(w http.ResponseWriter, r *http.Request) { + var res map[string]any + if err := json.NewDecoder(r.Body).Decode(&res); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + var cc struct { + Type string + Other map[string]any `mapstructure:",remain"` + } + + if err := util.DecodeOther(res, &cc); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + ts, err := auth.NewFromConfig(context.Background(), cc.Type, cc.Other) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + if _, err := ts.Token(); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + // templatesHandler returns the list of templates by class func templatesHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) diff --git a/templates/definition/charger/viessmann.yaml b/templates/definition/charger/viessmann.yaml index 21eb16fb2..db8d867b4 100644 --- a/templates/definition/charger/viessmann.yaml +++ b/templates/definition/charger/viessmann.yaml @@ -148,6 +148,9 @@ params: en: Parameter only exists for historic reasons. Target Temperature can be configured in the ViCare app (not supported by all devices) default: 45 type: int +auth: + type: viessmann + params: [clientid, redirecturi, gateway] render: | type: sgready getmode: diff --git a/templates/definition/vehicle/cardata.yaml b/templates/definition/vehicle/cardata.yaml index 5edfdc6c4..1425dc8e4 100644 --- a/templates/definition/vehicle/cardata.yaml +++ b/templates/definition/vehicle/cardata.yaml @@ -55,6 +55,9 @@ params: help: en: Enable if vehicle sends streaming updates during charging. de: Aktivieren falls das Fahrzeug Streaming Updates während des Ladevorgangs schickt. +auth: + type: cardata + params: [clientid] render: | type: cardata vin: {{ .vin }} diff --git a/templates/definition/vehicle/volvo-connected.yaml b/templates/definition/vehicle/volvo-connected.yaml index b6f6c5ae2..200580485 100644 --- a/templates/definition/vehicle/volvo-connected.yaml +++ b/templates/definition/vehicle/volvo-connected.yaml @@ -58,6 +58,9 @@ params: deprecated: true - name: refreshToken deprecated: true +auth: + type: volvo-connected + params: [clientId, clientSecret, redirectUri] render: | type: volvo-connected vccapikey: {{ .vccapikey }} diff --git a/tests/config-device-auth-demo.tpl.yaml b/tests/config-device-auth-demo.tpl.yaml new file mode 100644 index 000000000..9f2196e10 --- /dev/null +++ b/tests/config-device-auth-demo.tpl.yaml @@ -0,0 +1,34 @@ +template: device-auth-demo +group: generic +products: + - description: + de: Auth Demo Zähler + en: Auth Demo Meter +auth: + type: demo + params: ["region", "token"] +params: + - name: usage + choice: ["grid"] + - name: region + description: + de: Server + en: Server + type: choice + choice: ["EU", "US", "CN"] + - name: token + description: + de: Token + en: Token + - name: power + description: + de: Leistung + en: Power + unit: W + type: int + +render: | + type: custom + power: + source: const + value: {{ .power }} diff --git a/tests/config-device-auth.spec.ts b/tests/config-device-auth.spec.ts new file mode 100644 index 000000000..f46fa704b --- /dev/null +++ b/tests/config-device-auth.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { enableExperimental, expectModalVisible, expectModalHidden } from "./utils"; + +test.use({ baseURL: baseUrl() }); + +const templateFlags = [ + "--disable-auth", + "--template-type", + "meter", + "--template", + "tests/config-device-auth-demo.tpl.yaml", +]; + +test.beforeEach(async () => { + await start(undefined, undefined, templateFlags); +}); +test.afterEach(async () => { + await stop(); +}); + +test.describe("config device auth", async () => { + test("create grid meter with demo auth", async ({ page }) => { + await page.goto("/#/config"); + await enableExperimental(page, true); + + // verify no grid meter exists yet + await expect(page.getByTestId("grid")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Add grid meter" })).toBeVisible(); + + // create a grid meter with auth + await page.getByRole("button", { name: "Add grid meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); + await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); + + // step 1: auth view + await expect(meterModal.getByLabel("Server")).toBeVisible(); + await expect(meterModal.getByLabel("Token")).toBeVisible(); + await expect(meterModal.getByLabel("Power")).not.toBeVisible(); + await expect(meterModal.getByRole("button", { name: "Validate & save" })).not.toBeVisible(); + await expect(meterModal.getByRole("button", { name: "Save" })).not.toBeVisible(); + await meterModal.getByLabel("Server").selectOption("EU"); + await meterModal.getByLabel("Token").fill("test-token-123"); + await meterModal.getByRole("button", { name: "Prepare connection" }).click(); + await expect(meterModal.getByRole("link", { name: "Connect with localhost" })).toBeVisible(); + + // we dont navigate to localhost, just trigger ui update because demo auth state is already established + await page.evaluate(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + // step 2: show regular device form + await expect(meterModal.getByLabel("Server")).toHaveValue("EU"); + await expect(meterModal.getByLabel("Token")).toHaveValue("test-token-123"); + await expect(meterModal.getByLabel("Power")).toBeVisible(); + await meterModal.getByLabel("Power").fill("5000"); + await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); + await meterModal.getByRole("link", { name: "validate" }).click(); + await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + + // verify meter creation + await expect(page.getByTestId("grid")).toBeVisible(); + await expect(page.getByTestId("grid")).toContainText("Grid meter"); + await expect(page.getByTestId("grid")).toContainText(["Power", "5.0 kW"].join("")); + + // re-open meter for editing + await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await expect(meterModal.getByLabel("Server")).toHaveValue("EU"); + await expect(meterModal.getByLabel("Token")).toHaveValue("test-token-123"); + 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(); + await meterModal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(meterModal); + + // restart evcc (demo auth doesn't persist) + await restart(undefined, templateFlags); + await page.reload(); + + // re-open meter for editing after restart, auth status as to be reestablished + await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await expect(meterModal.getByLabel("Server")).toHaveValue("EU"); + await expect(meterModal.getByLabel("Token")).toHaveValue("test-token-123"); + await expect(meterModal.getByLabel("Power")).not.toBeVisible(); + // note: prepare connection step is auto-executed, since all required fields (server, token) are already present + await expect(meterModal.getByRole("link", { name: "Connect with localhost" })).toBeVisible(); + await expect(meterModal.getByRole("button", { name: "Validate & save" })).not.toBeVisible(); + }); +}); diff --git a/tests/evcc.ts b/tests/evcc.ts index e21f1b993..9af5bc45a 100644 --- a/tests/evcc.ts +++ b/tests/evcc.ts @@ -68,7 +68,11 @@ export async function stop(instance?: ChildProcess) { await _clean(); } -export async function restart(config?: string, flags = "--disable-auth", alreadyStopped = false) { +export async function restart( + config?: string, + flags: string | string[] = "--disable-auth", + alreadyStopped = false +) { if (!alreadyStopped) { await _stop(); } diff --git a/util/templates/types.go b/util/templates/types.go index c88ace85e..6fd91de0d 100644 --- a/util/templates/types.go +++ b/util/templates/types.go @@ -286,13 +286,14 @@ func (c CountryCode) IsValid() bool { type TemplateDefinition struct { Template string Deprecated bool `json:"-"` + Auth map[string]any `json:",omitempty"` // OAuth parameters (if required) Group string `json:",omitempty"` // the group this template belongs to, references groupList entries Covers []string `json:",omitempty"` // list of covered outdated template names Products []Product `json:",omitempty"` // list of products this template is compatible with Capabilities []string `json:",omitempty"` Countries []CountryCode `json:",omitempty"` // list of countries supported by this template Requirements Requirements `json:",omitempty"` - Linked []LinkedTemplate `json:",omitempty"` // a list of templates that should be processed as part of the guided setup + Linked []LinkedTemplate `json:",omitempty"` // list of templates that should be processed as part of the guided setup Params []Param `json:",omitempty"` Render string `json:"-"` // rendering template } diff --git a/vehicle/bmw/cardata/api.go b/vehicle/bmw/cardata/api.go index a54026e80..734928134 100644 --- a/vehicle/bmw/cardata/api.go +++ b/vehicle/bmw/cardata/api.go @@ -13,15 +13,6 @@ import ( const ApiURL = "https://api-cardata.bmwgroup.com" -var Config = oauth2.Config{ - Scopes: []string{"authenticate_user", "openid", "cardata:streaming:read", "cardata:api:read"}, - Endpoint: oauth2.Endpoint{ - DeviceAuthURL: "https://customer.bmwgroup.com/gcdm/oauth/device/code", - TokenURL: "https://customer.bmwgroup.com/gcdm/oauth/token", - AuthStyle: oauth2.AuthStyleInParams, - }, -} - // requiredKeys are the necessary data dictionary entities according to // https://mybmwweb-utilities.api.bmw/de-de/utilities/bmw/api/cd/catalogue/file var requiredKeys = []string{ diff --git a/vehicle/bmw/cardata/oauth2.go b/vehicle/bmw/cardata/oauth2.go new file mode 100644 index 000000000..ce617bdfc --- /dev/null +++ b/vehicle/bmw/cardata/oauth2.go @@ -0,0 +1,63 @@ +package cardata + +import ( + "context" + "encoding/json" + + "github.com/evcc-io/evcc/plugin/auth" + "github.com/evcc-io/evcc/util" + "golang.org/x/oauth2" +) + +func init() { + auth.Register("cardata", func(other map[string]any) (oauth2.TokenSource, error) { + var cc struct { + ClientID string + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return NewOAuth(cc.ClientID, "") + }) +} + +func OAuthConfig(clientId string) *oauth2.Config { + return &oauth2.Config{ + ClientID: clientId, + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: "https://customer.bmwgroup.com/gcdm/oauth/device/code", + TokenURL: "https://customer.bmwgroup.com/gcdm/oauth/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{ + "authenticate_user", + "openid", + "cardata:streaming:read", + "cardata:api:read", + }, + } +} + +func NewOAuth(clientId, title string) (oauth2.TokenSource, error) { + oc := OAuthConfig(clientId) + + return auth.NewOauth(context.Background(), "BMW/Mini", title, oc, + auth.WithOauthDeviceFlowOption(), + auth.WithTokenRetrieverOption(func(data string, res *oauth2.Token) error { + var token Token + if err := json.Unmarshal([]byte(data), &token); err != nil { + return err + } + *res = *token.TokenEx() + return nil + }), + auth.WithTokenStorerOption(func(token *oauth2.Token) any { + return Token{ + Token: token, + IdToken: TokenExtra(token, "id_token"), + Gcid: TokenExtra(token, "gcid"), + } + })) +} diff --git a/vehicle/cardata.go b/vehicle/cardata.go index 43dcad10c..a6558435f 100644 --- a/vehicle/cardata.go +++ b/vehicle/cardata.go @@ -2,16 +2,13 @@ package vehicle import ( "context" - "encoding/json" "errors" "slices" "time" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/plugin/auth" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/vehicle/bmw/cardata" - "golang.org/x/oauth2" ) // Cardata is an api.Vehicle implementation for BMW and Mini cars @@ -54,28 +51,9 @@ func NewCardataFromConfig(ctx context.Context, other map[string]any) (api.Vehicl embed: &cc.embed, } - oc := cardata.Config - oc.ClientID = cc.ClientID - log := util.NewLogger("cardata").Redact(cc.ClientID, cc.VIN) - ts, err := auth.NewOauth(context.Background(), "BMW/Mini", cc.embed.GetTitle(), &oc, - auth.WithOauthDeviceFlowOption(), - auth.WithTokenRetrieverOption(func(data string, res *oauth2.Token) error { - var token cardata.Token - if err := json.Unmarshal([]byte(data), &token); err != nil { - return err - } - *res = *token.TokenEx() - return nil - }), - auth.WithTokenStorerOption(func(token *oauth2.Token) any { - return cardata.Token{ - Token: token, - IdToken: cardata.TokenExtra(token, "id_token"), - Gcid: cardata.TokenExtra(token, "gcid"), - } - })) + ts, err := cardata.NewOAuth(cc.ClientID, cc.embed.GetTitle()) if err != nil { return nil, err } diff --git a/vehicle/volvo-connected.go b/vehicle/volvo-connected.go index 99c222dc7..5879fc90f 100644 --- a/vehicle/volvo-connected.go +++ b/vehicle/volvo-connected.go @@ -6,7 +6,6 @@ import ( "time" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/plugin/auth" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/vehicle/volvo/connected" ) @@ -52,8 +51,8 @@ func NewVolvoConnectedFromConfig(ctx context.Context, other map[string]any) (api log := util.NewLogger("volvo-connected").Redact(cc.VIN, cc.Credentials.ID, cc.Credentials.Secret, cc.VccApiKey) - oc := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret, cc.RedirectUri) - ts, err := auth.NewOauth(ctx, "Volvo", cc.embed.GetTitle(), oc) + oc := connected.OAuthConfig(cc.Credentials.ID, cc.Credentials.Secret, cc.RedirectUri) + ts, err := connected.NewOAuth(oc, cc.embed.GetTitle()) if err != nil { return nil, err } diff --git a/vehicle/volvo/connected/oauth2.go b/vehicle/volvo/connected/oauth2.go index 34bfe1c1d..06788c124 100644 --- a/vehicle/volvo/connected/oauth2.go +++ b/vehicle/volvo/connected/oauth2.go @@ -1,15 +1,36 @@ package connected import ( + "context" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/evcc-io/evcc/plugin/auth" + "github.com/evcc-io/evcc/util" "golang.org/x/oauth2" ) -func Oauth2Config(id, secret, redirecturi string) *oauth2.Config { +func init() { + auth.Register("volvo-connected", func(other map[string]any) (oauth2.TokenSource, error) { + var cc struct { + ID, Secret string + RedirectUri string + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + oc := OAuthConfig(cc.ID, cc.Secret, cc.RedirectUri) + + return NewOAuth(oc, "") + }) +} + +func OAuthConfig(id, secret, redirectUri string) *oauth2.Config { return &oauth2.Config{ ClientID: id, ClientSecret: secret, - RedirectURL: redirecturi, + RedirectURL: redirectUri, Endpoint: oauth2.Endpoint{ AuthURL: "https://volvoid.eu.volvocars.com/as/authorization.oauth2", TokenURL: "https://volvoid.eu.volvocars.com/as/token.oauth2", @@ -23,3 +44,7 @@ func Oauth2Config(id, secret, redirecturi string) *oauth2.Config { }, } } + +func NewOAuth(oc *oauth2.Config, title string) (oauth2.TokenSource, error) { + return auth.NewOauth(context.Background(), "Volvo", title, oc) +}