OIDC: local HTTP server instead of Wails bindings
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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
This commit is contained in:
2026-07-13 23:52:08 +02:00
parent ff6608f9ea
commit 5664a4bf29
2 changed files with 205 additions and 133 deletions

View File

@@ -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(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
</head><body><div class="card"><h1>Connected</h1><p class="ok">You can close this window and return to Oikos.</p></div></body></html>`))
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 {