Config UI: add /config/auth verification endpoint (#25433)
This commit is contained in:
parent
db0aa5f2ae
commit
33f8d3f13c
28 changed files with 672 additions and 125 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,6 +18,7 @@ __debug_bin*
|
|||
!package*.json
|
||||
!evcc.dist.yaml
|
||||
!tests/**/*.evcc.yaml
|
||||
!tests/**/*.tpl.yaml
|
||||
!tsconfig.json
|
||||
/templates/docs
|
||||
evcc
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
35
assets/js/utils/extractDomain.test.ts
Normal file
35
assets/js/utils/extractDomain.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
17
assets/js/utils/extractDomain.ts
Normal file
17
assets/js/utils/extractDomain.ts
Normal 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(".");
|
||||
};
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
75
plugin/auth/demo.go
Normal 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
12
plugin/auth/registry.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
34
tests/config-device-auth-demo.tpl.yaml
Normal file
34
tests/config-device-auth-demo.tpl.yaml
Normal 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 }}
|
||||
94
tests/config-device-auth.spec.ts
Normal file
94
tests/config-device-auth.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
63
vehicle/bmw/cardata/oauth2.go
Normal file
63
vehicle/bmw/cardata/oauth2.go
Normal 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"),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue