From 5664a4bf29623c645a1f554b5ef575131352f8e4 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 13 Jul 2026 23:52:08 +0200 Subject: [PATCH] OIDC: local HTTP server instead of Wails bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Wails runtime isn't reliably loading for IPC calls. Replace the binding-based StartOIDCLogin with a local HTTP server on 127.0.0.1:18901: - /oidc/login?apiUrl=... — opens system browser, waits for token - /oidc/callback — Authentik redirect target, exchanges code - /oidc/config?apiUrl=... — fetches OIDC provider config - SPA detects desktop via ?desktop=1 URL param - SPA calls localhost directly via fetch() instead of Wails IPC --- cmd/desktop/main.go | 316 ++++++++++++++++++++++-------------- web/src/pages/Config.svelte | 22 ++- 2 files changed, 205 insertions(+), 133 deletions(-) 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 {