diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go
index 2c90bac..b2253fb 100644
--- a/cmd/desktop/main.go
+++ b/cmd/desktop/main.go
@@ -19,6 +19,7 @@ import (
"path/filepath"
"runtime"
"strings"
+ "sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
@@ -32,13 +33,13 @@ var assets embed.FS
var iconPNG []byte
const (
- keyringService = "com.hubris.oikos-desktop"
- keyringUser = "oikos"
- version = "0.1.0"
- updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
- pollInterval = 30 * time.Second
- updateInterval = 6 * time.Hour
- oidcCallbackPort = 18901
+ keyringService = "com.hubris.oikos-desktop"
+ keyringUser = "oikos"
+ version = "0.1.0"
+ updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
+ pollInterval = 30 * time.Second
+ updateInterval = 6 * time.Hour
+ oidcCallbackPort = 18901
)
type OikosConfig struct {
@@ -105,97 +106,217 @@ func (c *ConfigService) DisableAutoStart() error {
return os.Remove(path)
}
-func (c *ConfigService) StartOIDCLogin(apiUrl string) (string, error) {
- apiUrl = strings.TrimRight(apiUrl, "/")
+// ---- Local OIDC server (runs alongside the webview) ----
- oidcCfg, err := fetchOIDCConfig(apiUrl)
- if err != nil {
- return "", fmt.Errorf("OIDC config: %w", err)
- }
+type oidcSession struct {
+ apiUrl string
+ verifier string
+ state string
+ ch chan string
+}
- verifier, challenge, err := pkceParams()
- if err != nil {
- return "", err
- }
-
- state := randomString(32)
- redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", oidcCallbackPort)
-
- type result struct {
- token string
- err error
- }
- done := make(chan result, 1)
+var (
+ oidcSessionsMu sync.Mutex
+ oidcSessions = make(map[string]*oidcSession)
+)
+func startOIDCServer() *http.Server {
mux := http.NewServeMux()
- mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
+
+ cors := func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+ if r.Method == "OPTIONS" {
+ w.WriteHeader(http.StatusOK)
+ }
+ }
+
+ h := func(path string, handler func(http.ResponseWriter, *http.Request)) {
+ mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
+ cors(w, r)
+ if r.Method == "OPTIONS" {
+ return
+ }
+ handler(w, r)
+ })
+ }
+
+ h("/oidc/login", func(w http.ResponseWriter, r *http.Request) {
+ apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
+ if apiUrl == "" {
+ http.Error(w, "apiUrl required", http.StatusBadRequest)
+ return
+ }
+
+ oidcCfg, err := fetchOIDCConfig(apiUrl)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("OIDC config: %v", err), http.StatusServiceUnavailable)
+ return
+ }
+
+ verifier, challenge, _ := pkceParams()
+ state := randomString(32)
+ redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort)
+
+ sessionID := randomString(16)
+ ch := make(chan string, 1)
+ oidcSessionsMu.Lock()
+ oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
+ oidcSessionsMu.Unlock()
+
+ authURL := fmt.Sprintf("%s/authorize/?%s",
+ strings.TrimRight(oidcCfg.AuthorizationEndpoint, "/"),
+ url.Values{
+ "response_type": {"code"},
+ "client_id": {oidcCfg.ClientID},
+ "redirect_uri": {redirectURI},
+ "code_challenge": {challenge},
+ "code_challenge_method": {"S256"},
+ "state": {state},
+ "scope": {"openid profile email"},
+ }.Encode(),
+ )
+
+ exec.Command("open", authURL).Start()
+
+ select {
+ case token := <-ch:
+ if token != "" {
+ c := &ConfigService{}
+ c.SaveConfig(apiUrl, token)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"token": token})
+ case <-time.After(5 * time.Minute):
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusRequestTimeout)
+ json.NewEncoder(w).Encode(map[string]string{"error": "login timed out"})
+ }
+ })
+
+ h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
gotState := r.URL.Query().Get("state")
- if gotState != state {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+
+ oidcSessionsMu.Lock()
+ var session *oidcSession
+ var sessionID string
+ for id, s := range oidcSessions {
+ if s.state == gotState {
+ session = s
+ sessionID = id
+ break
+ }
+ }
+ oidcSessionsMu.Unlock()
+
+ if session == nil {
w.WriteHeader(http.StatusBadRequest)
- w.Write([]byte("State mismatch."))
- done <- result{err: fmt.Errorf("state mismatch")}
+ w.Write([]byte("Invalid state."))
return
}
- token, err := exchangeCode(apiUrl, code, verifier, redirectURI)
+ token, err := exchangeCode(
+ session.apiUrl,
+ code, session.verifier,
+ fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort),
+ )
+
+ oidcSessionsMu.Lock()
+ delete(oidcSessions, sessionID)
+ oidcSessionsMu.Unlock()
+
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "Token exchange failed: %v", err)
- done <- result{err: err}
+ session.ch <- ""
return
}
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`
Oikos
Connected
You can close this window and return to Oikos.
`))
-
- done <- result{token: token}
+ session.ch <- token
})
- listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort))
- if err != nil {
- return "", fmt.Errorf("port %d in use: %w", oidcCallbackPort, err)
- }
+ mux.HandleFunc("/oidc/config", func(w http.ResponseWriter, r *http.Request) {
+ apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
+ if apiUrl == "" {
+ http.Error(w, "apiUrl required", http.StatusBadRequest)
+ return
+ }
+ cfg, err := fetchOIDCConfig(apiUrl)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusServiceUnavailable)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(cfg)
+ })
+ listener, _ := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort))
srv := &http.Server{Handler: mux}
go srv.Serve(listener)
- defer func() {
- srv.Close()
- listener.Close()
- }()
+ return srv
+}
- authURL := fmt.Sprintf("%s/authorize/?%s",
- strings.TrimRight(oidcCfg.AuthorizationEndpoint, "/"),
- url.Values{
- "response_type": {"code"},
- "client_id": {oidcCfg.ClientID},
- "redirect_uri": {redirectURI},
- "code_challenge": {challenge},
- "code_challenge_method": {"S256"},
- "state": {state},
- "scope": {"openid profile email"},
- }.Encode(),
- )
+// ---- Window persistence ----
- if err := exec.Command("open", authURL).Start(); err != nil {
- return "", fmt.Errorf("open browser: %w", err)
+type windowState struct {
+ X int `json:"x"`
+ Y int `json:"y"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+}
+
+func windowStatePath() string {
+ usr, _ := user.Current()
+ return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json")
+}
+
+func loadWindowState() *windowState {
+ data, err := os.ReadFile(windowStatePath())
+ if err != nil {
+ return nil
}
-
- select {
- case r := <-done:
- if r.err != nil {
- return "", r.err
- }
- c.SaveConfig(apiUrl, r.token)
- return r.token, nil
- case <-time.After(5 * time.Minute):
- return "", fmt.Errorf("login timed out")
+ var ws windowState
+ if err := json.Unmarshal(data, &ws); err != nil {
+ return nil
}
+ if ws.Width < 200 || ws.Height < 200 {
+ return nil
+ }
+ return &ws
+}
+
+func saveWindowState(w application.Window) {
+ x, y := w.Position()
+ width, height := w.Size()
+ ws := windowState{X: x, Y: y, Width: width, Height: height}
+ data, _ := json.Marshal(ws)
+
+ usr, _ := user.Current()
+ dir := filepath.Join(usr.HomeDir, ".config", "oikos")
+ os.MkdirAll(dir, 0755)
+ os.WriteFile(filepath.Join(dir, "window.json"), data, 0644)
+}
+
+func loadConfig() *OikosConfig {
+ data, err := keyring.Get(keyringService, keyringUser)
+ if err != nil {
+ return nil
+ }
+ var cfg OikosConfig
+ if err := json.Unmarshal([]byte(data), &cfg); err != nil {
+ return nil
+ }
+ cfg.IsDesktop = true
+ return &cfg
}
type oidcConfig struct {
@@ -263,62 +384,6 @@ func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) {
return tokens.AccessToken, nil
}
-// ---- Window persistence ----
-
-type windowState struct {
- X int `json:"x"`
- Y int `json:"y"`
- Width int `json:"width"`
- Height int `json:"height"`
-}
-
-func windowStatePath() string {
- usr, _ := user.Current()
- return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json")
-}
-
-func loadWindowState() *windowState {
- data, err := os.ReadFile(windowStatePath())
- if err != nil {
- return nil
- }
- var ws windowState
- if err := json.Unmarshal(data, &ws); err != nil {
- return nil
- }
- if ws.Width < 200 || ws.Height < 200 {
- return nil
- }
- return &ws
-}
-
-func saveWindowState(w application.Window) {
- x, y := w.Position()
- width, height := w.Size()
- ws := windowState{X: x, Y: y, Width: width, Height: height}
- data, _ := json.Marshal(ws)
-
- usr, _ := user.Current()
- dir := filepath.Join(usr.HomeDir, ".config", "oikos")
- os.MkdirAll(dir, 0755)
- os.WriteFile(filepath.Join(dir, "window.json"), data, 0644)
-}
-
-// ---- Config loading ----
-
-func loadConfig() *OikosConfig {
- data, err := keyring.Get(keyringService, keyringUser)
- if err != nil {
- return nil
- }
- var cfg OikosConfig
- if err := json.Unmarshal([]byte(data), &cfg); err != nil {
- return nil
- }
- cfg.IsDesktop = true
- return &cfg
-}
-
// ---- Notifications ----
type dashboardSummary struct {
@@ -441,6 +506,9 @@ func checkUpdates() {
// ---- Main ----
func main() {
+ oidcSrv := startOIDCServer()
+ defer oidcSrv.Close()
+
distFS, err := fs.Sub(assets, "frontend/dist")
if err != nil {
log.Fatalf("embedded assets: %v", err)
@@ -493,7 +561,7 @@ func main() {
Height: height,
MinWidth: minWidth,
MinHeight: minHeight,
- URL: "/",
+ URL: "/?desktop=1",
})
if ws != nil {
diff --git a/web/src/pages/Config.svelte b/web/src/pages/Config.svelte
index f93ad7f..6959a48 100644
--- a/web/src/pages/Config.svelte
+++ b/web/src/pages/Config.svelte
@@ -68,21 +68,25 @@
oidcLoggingIn = true
setConfig({ apiUrl: apiUrl.trim(), token: '' })
- const wails = (window as any).wails
- if (wails?.Call?.ByName) {
+ const isDesktop = new URLSearchParams(location.search).has('desktop')
+
+ if (isDesktop) {
try {
- const token = await wails.Call.ByName('StartOIDCLogin', apiUrl.trim())
- if (token) {
- setConfig({ apiUrl: apiUrl.trim(), token })
- initConfig({ apiUrl: apiUrl.trim(), token })
+ const resp = await fetch(`http://127.0.0.1:18901/oidc/login?apiUrl=${encodeURIComponent(apiUrl.trim())}`)
+ const data = await resp.json()
+ if (data.token) {
+ setConfig({ apiUrl: apiUrl.trim(), token: data.token })
+ initConfig({ apiUrl: apiUrl.trim(), token: data.token })
+ oidcLoggingIn = false
onConnected()
return
}
+ error = data.error || 'Login failed'
} catch (e: any) {
- error = e?.message || e || 'OIDC login failed'
- oidcLoggingIn = false
- return
+ error = e.message || 'Could not reach OIDC service'
}
+ oidcLoggingIn = false
+ return
}
try {