Config UI: add /config/auth verification endpoint (#25433)

This commit is contained in:
andig 2025-11-22 11:27:27 +01:00 • committed by GitHub
parent db0aa5f2ae
commit 33f8d3f13c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 672 additions and 125 deletions

1
.gitignore vendored
View file

@ -18,6 +18,7 @@ __debug_bin*
!package*.json
!evcc.dist.yaml
!tests/**/*.evcc.yaml
!tests/**/*.tpl.yaml
!tsconfig.json
/templates/docs
evcc

View file

@ -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: {

View file

@ -8,6 +8,7 @@
:size="modalSize"
@open="handleOpen"
@close="handleClose"
@visibilitychange="handleVisibilityChange"
>
<form ref="form" class="container mx-0 px-0">
<slot name="pre-content" :values="values"></slot>
@ -44,46 +45,87 @@
<SponsorTokenRequired v-if="sponsorTokenRequired" />
<Markdown v-if="description" :markdown="description" class="my-4" />
<slot name="after-template-info" :values="values"></slot>
<div v-if="authRequired">
<PropertyEntry
v-for="param in authParams"
:id="`${deviceType}Param${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
<p v-if="authError" class="text-danger">{{ authError }}</p>
<div class="d-flex justify-content-end">
<div
v-if="authProviderUrl"
class="d-flex flex-column align-items-end gap-2"
>
<a :href="authProviderUrl" target="_blank" class="btn btn-primary">
{{
$t("config.general.authPerform", {
provider: authProviderDomain,
})
}}
</a>
<small>{{ $t("config.general.authPerformHint") }}</small>
</div>
<button
v-else
class="btn btn-outline-primary"
:disabled="authLoading"
@click.prevent="checkAuthStatus"
>
<span
v-if="authLoading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.authPrepare") }}
</button>
</div>
</div>
<div v-else>
<slot name="after-template-info" :values="values"></slot>
<Modbus
v-if="modbus"
v-model:modbus="values['modbus']"
v-model:id="values['id']"
v-model:host="values['host']"
v-model:port="values['port']"
v-model:device="values['device']"
v-model:baudrate="values['baudrate']"
v-model:comset="values['comset']"
:defaultId="modbus.ID ? Number(modbus.ID) : undefined"
:defaultComset="modbus.Comset"
:defaultBaudrate="modbus.Baudrate"
:defaultPort="modbus.Port"
:capabilities="modbusCapabilities"
/>
<Modbus
v-if="modbus"
v-model:modbus="values['modbus']"
v-model:id="values['id']"
v-model:host="values['host']"
v-model:port="values['port']"
v-model:device="values['device']"
v-model:baudrate="values['baudrate']"
v-model:comset="values['comset']"
:defaultId="modbus.ID ? Number(modbus.ID) : undefined"
:defaultComset="modbus.Comset"
:defaultBaudrate="modbus.Baudrate"
:defaultPort="modbus.Port"
:capabilities="modbusCapabilities"
/>
<PropertyEntry
v-for="param in normalParams"
:id="`${deviceType}Param${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
<PropertyEntry
v-for="param in normalParams"
:id="`${deviceType}Param${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
<PropertyCollapsible>
<template v-if="advancedParams.length" #advanced>
<PropertyEntry
v-for="param in advancedParams"
:id="`${deviceType}Param${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</template>
<template v-if="$slots['collapsible-more']" #more>
<slot name="collapsible-more" :values="values"></slot>
</template>
</PropertyCollapsible>
<PropertyCollapsible>
<template v-if="advancedParams.length" #advanced>
<PropertyEntry
v-for="param in advancedParams"
:id="`${deviceType}Param${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</template>
<template v-if="$slots['collapsible-more']" #more>
<slot name="collapsible-more" :values="values"></slot>
</template>
</PropertyCollapsible>
</div>
</div>
<DeviceModalActions
@ -116,6 +158,7 @@ import TemplateSelector, { type TemplateGroup } from "./TemplateSelector.vue";
import YamlEntry from "./YamlEntry.vue";
import { initialTestState, performTest } from "../utils/test";
import sleep from "@/utils/sleep";
import { extractDomain } from "@/utils/extractDomain";
import { ConfigType } from "@/types/evcc";
import type { DeviceType } from "@/types/evcc";
import {
@ -197,6 +240,10 @@ export default defineComponent({
templateName: null as string | null,
template: null as Template | null,
saving: false,
authOk: false,
authLoading: false,
authError: null as string | null,
authProviderUrl: null as string | null,
succeeded: false,
loadingTemplate: false,
values: { ...this.initialValues } as DeviceValues,
@ -231,6 +278,10 @@ export default defineComponent({
return filtered;
},
authParams() {
const { params = [] } = this.template?.Auth ?? {};
return this.templateParams.filter((p) => 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<string, any>
);
},
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);

View file

@ -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<string, any>): Promise<AuthCheckResponse> {
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<string> {
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,
};
}

View file

@ -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");
}
},
},
});
</script>

View file

@ -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) {

View file

@ -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;

View file

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

View file

@ -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(".");
};

View file

@ -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.",

View file

@ -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.",

View file

@ -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))

75
plugin/auth/demo.go Normal file
View file

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

12
plugin/auth/registry.go Normal file
View file

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

View file

@ -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},

View file

@ -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)

View file

@ -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:

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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();
});
});

View file

@ -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();
}

View file

@ -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
}

View file

@ -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{

View file

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

View file

@ -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
}

View file

@ -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
}

View file

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