Improve oauth integration (#21266)

This commit is contained in:
lehmanju 2025-08-05 23:16:49 +02:00 • committed by GitHub
parent 4c73cbe197
commit 11e769ea22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 219 additions and 208 deletions

View file

@ -37,6 +37,12 @@ const preview = {
setup((app) => {
app.config.globalProperties.$hiddenFeatures = () => true;
app.use(setupI18n());
// Mock router-link for Storybook
app.component("router-link", {
props: ["to", "activeClass"],
template: '<a :href="to"><slot /></a>',
});
});
export default preview;

View file

@ -3,7 +3,7 @@ package api
import (
"context"
"io"
"net/http"
"net/url"
"time"
)
@ -193,9 +193,11 @@ type Tariff interface {
// AuthProvider is the ability to provide OAuth authentication through the ui
type AuthProvider interface {
HandleCallback(r *http.Request)
HandleLogout(r *http.Request)
AuthCodeURL(state string) string
Login(state string) string
Logout() error
HandleCallback(responseValues url.Values) error
Authenticated() bool
DisplayName() string
}
// IconDescriber optionally provides an icon

View file

@ -84,7 +84,7 @@ import collector from "@/mixins/collector.ts";
import WelcomeIcons from "./WelcomeIcons.vue";
import { defineComponent, type PropType } from "vue";
import type {
Auth,
AuthProviders,
Battery,
CURRENCY,
Forecast,
@ -133,9 +133,7 @@ export default defineComponent({
bufferStartSoc: Number,
siteTitle: String,
vehicles: Object,
auth: { type: Object as PropType<Auth>, default: () => ({ vehicles: {} }) },
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
currency: { type: String as PropType<CURRENCY> },
statistics: Object,
tariffFeedIn: Number,
@ -195,7 +193,7 @@ export default defineComponent({
return Object.entries(vehicles).map(([name, vehicle]) => ({ name, ...vehicle }));
},
topNavigation() {
return { vehicleLogins: this.auth.vehicles, ...this.collectProps(Navigation) };
return this.collectProps(Navigation);
},
showParkingLot() {
// work in progess

View file

@ -43,9 +43,8 @@ export default defineComponent({
title: String,
},
computed: {
topNavigation() {
const vehicleLogins = store.state.auth ? store.state.auth.vehicles : {};
return { vehicleLogins, ...this.collectProps(Navigation, store.state) };
topNavigation(): any {
return this.collectProps(Navigation, store.state);
},
},
});

View file

@ -8,7 +8,7 @@ export default {
layout: "centered",
},
argTypes: {
vehicleLogins: { control: "object" },
authProviders: { control: "object" },
sponsor: { control: "object" },
},
} as Meta<typeof Navigation>;
@ -24,30 +24,30 @@ const Template: StoryFn<typeof Navigation> = (args) => ({
export const Standard = Template.bind({});
Standard.args = {};
export const VehicleLogins = Template.bind({});
VehicleLogins.args = {
vehicleLogins: {
export const OAuthStatus = Template.bind({});
OAuthStatus.args = {
authProviders: {
"Mercedes EQS": {
authenticated: true,
uri: "https://login-provider-a.test/",
id: "mercedes-eqs-9oqwjdf9oqwjd",
},
"Nissan Leaf Pro": {
authenticated: true,
uri: "https://login-provider-b.test/",
id: "nissan-leaf-pro-9oqwjdf9oqwjd",
},
},
};
export const PendingVehicleLogins = Template.bind({});
PendingVehicleLogins.args = {
vehicleLogins: {
export const PendingOAuthStatus = Template.bind({});
PendingOAuthStatus.args = {
authProviders: {
"Mercedes EQS": {
authenticated: true,
uri: "https://login-provider-a.test/",
id: "mercedes-eqs-9oqwjdf9oqwjd",
},
"Nissan Leaf Pro": {
authenticated: false,
uri: "https://login-provider-b.test/",
id: "nissan-leaf-pro-9oqwjdf9oqwjd",
},
},
};

View file

@ -9,7 +9,7 @@
data-testid="topnavigation-button"
>
<span
v-if="showBadge"
v-if="showRootBadge"
class="position-absolute top-0 start-100 translate-middle p-2 rounded-circle"
:class="badgeClass"
>
@ -61,7 +61,7 @@
<li>
<router-link class="dropdown-item" to="/config" active-class="active">
<span
v-if="showBadge"
v-if="showConfigBadge"
class="d-inline-block p-1 rounded-circle bg-warning rounded-circle"
:class="badgeClass"
></span>
@ -75,9 +75,8 @@
</li>
<li><hr class="dropdown-divider" /></li>
<template v-if="providerLogins.length > 0">
<li><hr class="dropdown-divider" /></li>
<li>
<h6 class="dropdown-header">{{ $t("header.login") }}</h6>
<h6 class="dropdown-header">{{ $t("header.authProviders.title") }}</h6>
</li>
<li v-for="l in providerLogins" :key="l.title">
<button
@ -86,14 +85,13 @@
@click="handleProviderAuthorization(l)"
>
<span
v-if="!l.loggedIn"
class="d-inline-block p-1 rounded-circle border border-light rounded-circle"
:class="badgeClass"
:class="l.authenticated ? 'bg-success' : 'bg-warning'"
></span>
{{ l.title }}
{{ $t(l.loggedIn ? "main.provider.logout" : "main.provider.login") }}
</button>
</li>
<li><hr class="dropdown-divider" /></li>
</template>
<li>
<button type="button" class="dropdown-item" @click="openHelpModal">
@ -136,25 +134,15 @@ import baseAPI from "./baseapi";
import { isApp, sendToApp } from "@/utils/native";
import { isUserConfigError } from "@/utils/fatal";
import { defineComponent, type PropType } from "vue";
import type { FatalError, Sponsor, VehicleLogins } from "@/types/evcc";
import type { FatalError, Sponsor, AuthProviders } from "@/types/evcc";
import type { Provider as Provider } from "./types";
export default defineComponent({
name: "TopNavigation",
mixins: [collector],
props: {
vehicleLogins: {
type: Object as PropType<VehicleLogins>,
default: () => {
return {};
},
},
sponsor: {
type: Object as PropType<Sponsor>,
default: () => {
return {};
},
},
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
forecast: Object,
battery: Array,
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
@ -169,23 +157,23 @@ export default defineComponent({
batteryConfigured() {
return this.battery?.length;
},
logoutCount() {
return this.providerLogins.filter((login) => !login.loggedIn).length;
},
providerLogins(): Provider[] {
return Object.entries(this.vehicleLogins).map(([k, v]) => ({
title: k,
loggedIn: v.authenticated,
loginPath: v.uri + "/login",
logoutPath: v.uri + "/logout",
return Object.entries(this.authProviders).map(([title, { authenticated, id }]) => ({
title,
authenticated,
loginPath: "providerauth/login?id=" + id,
logoutPath: "providerauth/logout?id=" + id,
}));
},
loginRequired() {
return this.logoutCount > 0;
return Object.values(this.authProviders).some((p) => !p.authenticated);
},
showBadge() {
showConfigBadge() {
const userConfigError = isUserConfigError(this.fatal);
return this.loginRequired || this.sponsor.expiresSoon || userConfigError;
return this.sponsor.expiresSoon || userConfigError;
},
showRootBadge() {
return this.loginRequired || this.showConfigBadge;
},
badgeClass() {
if (this.fatal.length > 0) {
@ -218,12 +206,24 @@ export default defineComponent({
},
methods: {
async handleProviderAuthorization(provider: Provider) {
if (!provider.loggedIn) {
baseAPI.post(provider.loginPath).then(function (response) {
const { title, authenticated, loginPath, logoutPath } = provider;
if (!authenticated) {
try {
const response = await baseAPI.get(loginPath);
window.location.href = response.data.loginUri;
});
} catch (error: any) {
console.error(error);
alert(`Failed to login: ${error.response?.data}`);
}
} else {
baseAPI.post(provider.logoutPath);
if (window.confirm(this.$t("header.authProviders.confirmLogout", { title }))) {
try {
await baseAPI.get(logoutPath);
} catch (error: any) {
console.error(error);
alert(`Failed to logout: ${error.response?.data}`);
}
}
}
},
openSettingsModal() {

View file

@ -1,6 +1,6 @@
export interface Provider {
title: string;
loggedIn: boolean;
authenticated: boolean;
loginPath: string;
logoutPath: string;
}

View file

@ -20,11 +20,7 @@ declare global {
}
}
export interface Auth {
vehicles: VehicleLogins;
}
export type VehicleLogins = Record<string, { authenticated: boolean; uri: string }>;
export type AuthProviders = Record<string, { id: string; authenticated: boolean }>;
export interface MqttConfig {
broker: string;
@ -54,7 +50,7 @@ export interface State {
forecast?: Forecast;
currency?: CURRENCY;
fatal?: FatalError[];
auth?: Auth;
providerAuth?: AuthProviders;
version?: string;
battery?: Battery[];
tariffGrid?: number;

View file

@ -314,7 +314,7 @@ func runRoot(cmd *cobra.Command, args []string) {
}()
// allow web access for vehicles
configureAuth(httpd.Router())
configureAuth(httpd.Router(), valueChan)
authObject := auth.New()
if ok, _ := cmd.Flags().GetBool(flagDisableAuth); ok {

View file

@ -37,7 +37,7 @@ import (
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/server/modbus"
"github.com/evcc-io/evcc/server/oauth2redirect"
"github.com/evcc-io/evcc/server/providerauth"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
@ -1117,13 +1117,13 @@ func configureLoadpoints(conf globalconfig.All) error {
}
// configureAuth handles routing for devices. For now only api.AuthProvider related routes
func configureAuth(router *mux.Router) {
auth := router.PathPrefix("/oauth").Subrouter()
func configureAuth(router *mux.Router, paramC chan<- util.Param) {
auth := router.PathPrefix("/providerauth").Subrouter()
auth.Use(handlers.CompressHandler)
auth.Use(handlers.CORS(
handlers.AllowedHeaders([]string{"Content-Type"}),
))
// wire the handler
oauth2redirect.SetupRouter(auth)
providerauth.Setup(auth, paramC)
}

View file

@ -20,4 +20,5 @@ const (
Telemetry = "telemetry"
DemoMode = "demoMode"
AuthDisabled = "authDisabled"
ProviderAuth = "providerAuth"
)

View file

@ -620,10 +620,13 @@
},
"header": {
"about": "Über",
"authProviders": {
"confirmLogout": "Sicher, dass du {title} trennen möchtest?",
"title": "Autorisierungsstatus"
},
"blog": "Blog",
"docs": "Dokumentation",
"github": "GitHub",
"login": "Fahrzeug-Logins",
"logout": "Abmelden",
"nativeSettings": "Server ändern",
"needHelp": "Hilfe benötigt?",

View file

@ -620,10 +620,13 @@
},
"header": {
"about": "About",
"authProviders": {
"confirmLogout": "Are you sure you want to disconnect {title}?",
"title": "Authorization Status"
},
"blog": "Blog",
"docs": "Documentation",
"github": "GitHub",
"login": "Vehicle Logins",
"logout": "Logout",
"nativeSettings": "Change Server",
"needHelp": "Need Help?",

View file

@ -1,20 +1,19 @@
package auth
// TODO
// - configurable redirect uri
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"sync"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/server/oauth2redirect"
"github.com/evcc-io/evcc/server/providerauth"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"golang.org/x/oauth2"
@ -31,7 +30,7 @@ type OAuth struct {
}
var (
oauthMu sync.Mutex
// oauthMu sync.Mutex
identities = make(map[string]*OAuth)
)
@ -43,7 +42,7 @@ func addInstance(subject string, identity *OAuth) {
identities[subject] = identity
}
func init() {
/* func init() {
registry.AddCtx("oauth", NewOauthFromConfig)
}
@ -57,23 +56,24 @@ func NewOauthFromConfig(ctx context.Context, other map[string]any) (Authorizer,
}
return NewOauth(ctx, cc)
}
} */
func NewOauth(ctx context.Context, cc oauth2.Config) (*OAuth, error) {
func NewOauth(ctx context.Context, cc oauth2.Config, instanceName string) (*OAuth, error) {
log := util.NewLogger("oauth-generic")
// generate json string from oauth2 config
bytejson, err := json.Marshal(cc)
if err != nil {
log.ERROR.Printf("error converting oauth config to json: %s", err)
if instanceName == "" {
return nil, errors.New("instance name must not be empty")
}
// generate json string from oauth2 config
bytejson, _ := json.Marshal(cc)
h := sha256.New()
h.Write(bytejson)
sha1_hash := hex.EncodeToString(h.Sum(nil))
fullHash := hex.EncodeToString(h.Sum(nil))
sha256_hash := fullHash[:8]
subject := sha1_hash
subject := instanceName + " (" + sha256_hash + ")"
// reuse instance
if instance := getInstance(subject); instance != nil {
@ -104,7 +104,7 @@ func NewOauth(ctx context.Context, cc oauth2.Config) (*OAuth, error) {
addInstance(o.subject, o)
// register authredirect
oauth2redirect.Register(o, subject)
providerauth.Register(o, subject)
return o, nil
}
@ -119,9 +119,6 @@ func (o *OAuth) Transport(base http.RoundTripper) http.RoundTripper {
// RefreshToken implements oauth.RefreshTokenSource.
func (o *OAuth) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
o.mu.Lock()
defer o.mu.Unlock()
if token.RefreshToken == "" {
return nil, api.ErrMissingToken
}
@ -144,19 +141,9 @@ func (o *OAuth) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
return token, err
}
// AuthCodeURL implements api.AuthProvider.
func (o *OAuth) AuthCodeURL(state string) string {
o.mu.Lock()
defer o.mu.Unlock()
o.cv = oauth2.GenerateVerifier()
return o.cc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv))
}
// HandleCallback implements api.AuthProvider.
func (o *OAuth) HandleCallback(r *http.Request) {
q := r.URL.Query()
code := q.Get("code")
func (o *OAuth) HandleCallback(responseValues url.Values) error {
code := responseValues.Get("code")
o.mu.Lock()
defer o.mu.Unlock()
@ -164,7 +151,7 @@ func (o *OAuth) HandleCallback(r *http.Request) {
token, err := o.cc.Exchange(o.ctx, code, oauth2.VerifierOption(o.cv))
if err != nil {
o.log.ERROR.Printf("error during oauth exchange: %s", err)
return
return err
}
err = settings.SetJson(o.subject, token)
if err != nil {
@ -172,10 +159,20 @@ func (o *OAuth) HandleCallback(r *http.Request) {
}
o.TokenSource = oauth.RefreshTokenSource(token, o)
return nil
}
// HandleLogout implements api.AuthProvider.
func (o *OAuth) HandleLogout(r *http.Request) {
// Login implements api.AuthProvider.
func (o *OAuth) Login(state string) string {
o.mu.Lock()
defer o.mu.Unlock()
o.cv = oauth2.GenerateVerifier()
return o.cc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv))
}
// Logout implements api.AuthProvider.
func (o *OAuth) Logout() error {
o.log.INFO.Printf("removing %s from database", o.subject)
if settings.Exists(o.subject) {
settings.Delete(o.subject)
@ -184,4 +181,20 @@ func (o *OAuth) HandleLogout(r *http.Request) {
o.mu.Lock()
defer o.mu.Unlock()
o.TokenSource = oauth.RefreshTokenSource(nil, o)
return nil
}
// DisplayName implements api.AuthProvider.
func (o *OAuth) DisplayName() string {
return o.subject
}
// Authenticated implements api.AuthProvider.
func (o *OAuth) Authenticated() bool {
// check if token is valid
if token, err := o.TokenSource.Token(); err == nil {
return token.Valid()
} else {
return false
}
}

View file

@ -1,15 +1,18 @@
package oauth2redirect
package providerauth
import (
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/util"
"github.com/gorilla/mux"
)
@ -28,6 +31,11 @@ type Handler struct {
log *util.Logger
}
type AuthProvider struct {
ID string `json:"id"`
Authenticated bool `json:"authenticated"`
}
func init() {
var secret [16]byte
_, err := io.ReadFull(rand.Reader, secret[:])
@ -37,39 +45,74 @@ func init() {
}
instance = &Handler{
mu: sync.Mutex{},
secret: secret[:],
providers: make(map[string]api.AuthProvider),
states: make(map[string]string),
log: util.NewLogger("oauth2redirect"),
log: util.NewLogger("providerauth"),
}
}
// SetupRouter connects the redirect handler to the router
func SetupRouter(router *mux.Router) {
// Setup connects the redirect handler to the router and registers the callback channel
func Setup(router *mux.Router, paramC chan<- util.Param) {
// callback?code=...&state=...
router.Methods(http.MethodGet).Path("/callback").HandlerFunc(instance.handleCallback)
// login?id=...
router.Methods(http.MethodGet).Path("/login").HandlerFunc(instance.handleLogin)
// logout?id=...
router.Methods(http.MethodGet).Path("/logout").HandlerFunc(instance.handleLogout)
ticker := time.NewTicker(10 * time.Second)
go func() {
for range ticker.C {
instance.Publish(paramC)
}
}()
}
func (a *Handler) Publish(paramC chan<- util.Param) {
a.mu.Lock()
defer a.mu.Unlock()
apMap := make(map[string]*AuthProvider)
for id, provider := range a.providers {
ap := &AuthProvider{
ID: url.QueryEscape(id),
Authenticated: provider.Authenticated(),
}
apMap[provider.DisplayName()] = ap
}
val := struct {
AuthProviders map[string]*AuthProvider `json:"authProviders,omitempty"`
}{
AuthProviders: apMap,
}
a.log.DEBUG.Printf("publishing %d auth providers", len(apMap))
// publish the updated auth providers
paramC <- util.Param{Key: keys.ProviderAuth, Val: val}
}
// Register registers a specific AuthProvider. Returns login path as string.
func Register(handler api.AuthProvider, name string) (string, error) {
func Register(handler api.AuthProvider, name string) error {
return instance.register(handler, name)
}
func (a *Handler) register(handler api.AuthProvider, name string) (string, error) {
func (a *Handler) register(handler api.AuthProvider, name string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.providers[name] != nil {
a.log.ERROR.Printf("provider with name %s already registered", name)
return "", errors.New("provider already registered")
return errors.New("provider already registered")
}
a.log.INFO.Printf("registering oauth provider at /oauth/login?id=%s", name)
a.log.INFO.Printf("registering oauth provider: %s", name)
a.providers[name] = handler
return "/oauth/login?id=" + name, nil
return nil
}
func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
@ -82,6 +125,8 @@ func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
a.log.DEBUG.Printf("login request for provider: %s", id)
a.mu.Lock()
defer a.mu.Unlock()
@ -106,14 +151,17 @@ func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
}(encryptedState)
// Build authorization URL
loginURL := provider.AuthCodeURL(encryptedState)
if loginURL == "" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "invalid login URL")
return
}
loginUri := provider.Login(encryptedState)
http.Redirect(w, r, loginURL, http.StatusFound)
responseVal := struct {
LoginUri string `json:"loginUri"`
}{
LoginUri: loginUri,
}
if err := json.NewEncoder(w).Encode(responseVal); err != nil {
a.log.ERROR.Printf("failed to encode login URI response: %v", err)
}
w.WriteHeader(http.StatusFound)
}
func (a *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
@ -137,7 +185,9 @@ func (a *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
}
// Handle logout
provider.HandleLogout(r)
if err := provider.Logout(); err != nil {
a.log.ERROR.Printf("logout for provider %s failed: %v", id, err)
}
http.Redirect(w, r, "/", http.StatusFound)
}
@ -187,7 +237,12 @@ func (a *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
delete(a.states, encryptedState)
// Handle the callback
provider.HandleCallback(r)
if err := provider.HandleCallback(r.URL.Query()); err != nil {
a.log.ERROR.Printf("callback handling for provider %s failed: %v", id, err)
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "callback handling failed")
return
}
http.Redirect(w, r, "/", http.StatusFound)
}

View file

@ -2,25 +2,24 @@ template: volvo-connected
products:
- brand: Volvo
requirements:
evcc: ["skiptest"]
description:
de: |
Für die Nutzung mit EVCC benötigst du einen Volvo Account und einen Volvo Connected Car API Key.
Erstelle dazu auf der [Account Seite](https://developer.volvocars.com/account/) eine neue Applikation und speichere den primären VCC API Key ab.
Veröffentliche nun deine Applikation und wähle unter "Scopes" die Berechtigungen "Connected Vehicle API -> conve:vehicle-relation" + "conve:odometer-status" und "Energy API -> energy:state:read" aus.
Als Redirect URL musst du die URL deiner EVCC Instanz eintragen, zb "https://evcc.example.org/oauth/callback".
Sobald die Applikation erstellt ist, wird sie als "Publication under Review" angezeigt. Das ist nicht weiter schlimm, es funktioniert trotzdem.
Beim Anlegen des Fahrzeugs über die UI wird ein Fehler angezeigt.
Schaue im Log nach der Meldung "registering oauth provider at /oauth/login?..." und öffne den Link "https://evcc.example.org/oauth/login?..." in einem neuen Tab.
Melde dich mit deinem Volvo Account an und erlaube den Zugriff auf die Daten. Ist die Autorisierung erfolgreich, kann das Fahrzeug hinzugefügt werden.
Für die Nutzung mit evcc benötigst du einen Volvo Account und einen Volvo Connected Car API Key.
1. Erstelle dazu auf der [Account Seite](https://developer.volvocars.com/account/) eine neue Applikation und speichere den primären VCC API Key ab.
2. Veröffentliche nun deine Applikation und wähle unter **Scopes** folgende Berechtigungen **Connected Vehicle API:** `conve:vehicle-relation, conve:odometer-status`, **Energy API:** `energy:state:read`
3. Als Redirect URL musst du die URL deiner evcc Instanz eintragen, zb `https://evcc.example.org/providerauth/callback`.
4. Beim Anlegen des Fahrzeugs über die UI wird ein Fehler angezeigt.
5. Öffne einen neuen Tab und gehe auf die evcc Konfigurationsseite. Im Menü oben rechts wird ein Knopf zum Anmelden angezeigt.
6. Melde dich mit deinem Volvo Account an und erlaube den Zugriff auf die Daten. Ist die Autorisierung erfolgreich, kann das Fahrzeug hinzugefügt werden.
en: |
To use with EVCC, you need a Volvo account and a Volvo Connected Car API Key.
To do this, create a new application on the [Account page](https://developer.volvocars.com/account/) and save the primary VCC API key.
Now publish your application and select the permissions "Connected Vehicle API -> conve:vehicle-relation" + "conve:odometer-status" and "Energy API -> energy:state:read" under "Scopes".
You must enter the URL of your EVCC instance as the redirect URL, e.g. "https://evcc.example.org/oauth/callback".
Once the application is created, it will be displayed as "Publication under Review". This is not a problem, it still works.
When adding the vehicle via the UI, an error message is displayed.
Check the log for the message "registering oauth provider at /oauth/login?..." and open the link "https://evcc.example.org/oauth/login?..." in a new tab.
Log in with your Volvo account and allow access to the data. If the authorization is successful, the vehicle can be added.
To use with evcc, you need a Volvo account and a Volvo Connected Car API Key.
1. To do this, create a new application on the [Account page](https://developer.volvocars.com/account/) and save the primary VCC API key.
2. Now publish your application and select the **Scope** permissions **Connected Vehicle API:** `conve:vehicle-relation, conve:odometer-status`, **Energy API:** `energy:state:read`.
3. You must enter the URL of your evcc instance as the redirect URL, e.g. `https://evcc.example.org/providerauth/callback`.
4. When adding the vehicle via the UI, an error message is displayed.
5. Open a new tab and go to the evcc configuration page. A button for logging in will appear in the top right menu.
6. Log in with your Volvo account and allow access to the data. If the authorization is successful, the vehicle can be added.
params:
- preset: vehicle-common
- name: vccapikey
@ -45,8 +44,9 @@ params:
description:
generic: Redirect URI
help:
en: "Redirect URI of your EVCC instance, format: `https://evcc.example.org/oauth/callback`. Must match the redirect URI set in your Volvo Developer App."
de: "Redirect-URI deiner EVCC-Instanz, Format: https://evcc.example.org/oauth/callback. Muss mit der Redirect-URI übereinstimmen, die in deiner Volvo Developer App festgelegt ist."
en: "Redirect URI of your evcc instance. Must match the redirect URI set in your Volvo Developer App."
de: "Redirect-URI deiner evcc-Instanz. Muss mit der Redirect-URI übereinstimmen, die in deiner Volvo Developer App festgelegt ist."
example: "https://evcc.example.org/providerauth/callback"
- name: vin
example: WF0FXX...
- name: accessToken

View file

@ -1,65 +0,0 @@
package util
import "sync"
type AuthCollection struct {
mu sync.Mutex
paramC chan<- Param
vehicles map[string]*AuthProvider
}
func NewAuthCollection(paramC chan<- Param) *AuthCollection {
return &AuthCollection{
paramC: paramC,
vehicles: make(map[string]*AuthProvider),
}
}
func (ac *AuthCollection) Register(baseURI, title string) *AuthProvider {
ap := &AuthProvider{
ac: ac,
Uri: baseURI,
}
ac.mu.Lock()
ac.vehicles[title] = ap
ac.mu.Unlock()
return ap
}
// publish routes and status
func (ac *AuthCollection) Publish() {
ac.mu.Lock()
defer ac.mu.Unlock()
val := struct {
Vehicles map[string]*AuthProvider `json:"vehicles,omitempty"`
}{
Vehicles: ac.vehicles,
}
// TODO registered global key name
ac.paramC <- Param{Key: "auth", Val: val}
}
type AuthProvider struct {
ac *AuthCollection
Uri string `json:"uri"`
Authenticated bool `json:"authenticated"`
}
func (ap *AuthProvider) Handler() chan<- bool {
c := make(chan bool)
go func() {
for auth := range c {
ap.ac.mu.Lock()
ap.Authenticated = auth
ap.ac.mu.Unlock()
ap.ac.Publish()
}
}()
return c
}

View file

@ -44,7 +44,7 @@ func NewVolvoConnectedFromConfig(other map[string]interface{}) (api.Vehicle, err
// create oauth2 config
config := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret, cc.RedirectUri)
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log))
authorizer, err := auth.NewOauth(ctx, *config)
authorizer, err := auth.NewOauth(ctx, *config, cc.embed.GetTitle())
if err != nil {
return nil, err
}