OIDC: local HTTP server instead of Wails bindings
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:
@@ -19,6 +19,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
@@ -105,69 +106,64 @@ func (c *ConfigService) DisableAutoStart() error {
|
|||||||
return os.Remove(path)
|
return os.Remove(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ConfigService) StartOIDCLogin(apiUrl string) (string, error) {
|
// ---- Local OIDC server (runs alongside the webview) ----
|
||||||
apiUrl = strings.TrimRight(apiUrl, "/")
|
|
||||||
|
type oidcSession struct {
|
||||||
|
apiUrl string
|
||||||
|
verifier string
|
||||||
|
state string
|
||||||
|
ch chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
oidcSessionsMu sync.Mutex
|
||||||
|
oidcSessions = make(map[string]*oidcSession)
|
||||||
|
)
|
||||||
|
|
||||||
|
func startOIDCServer() *http.Server {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
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)
|
oidcCfg, err := fetchOIDCConfig(apiUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("OIDC config: %w", err)
|
http.Error(w, fmt.Sprintf("OIDC config: %v", err), http.StatusServiceUnavailable)
|
||||||
}
|
return
|
||||||
|
|
||||||
verifier, challenge, err := pkceParams()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifier, challenge, _ := pkceParams()
|
||||||
state := randomString(32)
|
state := randomString(32)
|
||||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", oidcCallbackPort)
|
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort)
|
||||||
|
|
||||||
type result struct {
|
sessionID := randomString(16)
|
||||||
token string
|
ch := make(chan string, 1)
|
||||||
err error
|
oidcSessionsMu.Lock()
|
||||||
}
|
oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
|
||||||
done := make(chan result, 1)
|
oidcSessionsMu.Unlock()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
gotState := r.URL.Query().Get("state")
|
|
||||||
|
|
||||||
if gotState != state {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("State mismatch."))
|
|
||||||
done <- result{err: fmt.Errorf("state mismatch")}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := exchangeCode(apiUrl, code, verifier, redirectURI)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
fmt.Fprintf(w, "Token exchange failed: %v", err)
|
|
||||||
done <- result{err: err}
|
|
||||||
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}
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := &http.Server{Handler: mux}
|
|
||||||
go srv.Serve(listener)
|
|
||||||
defer func() {
|
|
||||||
srv.Close()
|
|
||||||
listener.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
authURL := fmt.Sprintf("%s/authorize/?%s",
|
authURL := fmt.Sprintf("%s/authorize/?%s",
|
||||||
strings.TrimRight(oidcCfg.AuthorizationEndpoint, "/"),
|
strings.TrimRight(oidcCfg.AuthorizationEndpoint, "/"),
|
||||||
@@ -182,20 +178,145 @@ h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
|||||||
}.Encode(),
|
}.Encode(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := exec.Command("open", authURL).Start(); err != nil {
|
exec.Command("open", authURL).Start()
|
||||||
return "", fmt.Errorf("open browser: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case r := <-done:
|
case token := <-ch:
|
||||||
if r.err != nil {
|
if token != "" {
|
||||||
return "", r.err
|
c := &ConfigService{}
|
||||||
|
c.SaveConfig(apiUrl, token)
|
||||||
}
|
}
|
||||||
c.SaveConfig(apiUrl, r.token)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
return r.token, nil
|
json.NewEncoder(w).Encode(map[string]string{"token": token})
|
||||||
case <-time.After(5 * time.Minute):
|
case <-time.After(5 * time.Minute):
|
||||||
return "", fmt.Errorf("login timed out")
|
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")
|
||||||
|
|
||||||
|
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("Invalid state."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
session.ch <- ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`))
|
||||||
|
session.ch <- token
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
type oidcConfig struct {
|
||||||
@@ -263,62 +384,6 @@ func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) {
|
|||||||
return tokens.AccessToken, nil
|
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 ----
|
// ---- Notifications ----
|
||||||
|
|
||||||
type dashboardSummary struct {
|
type dashboardSummary struct {
|
||||||
@@ -441,6 +506,9 @@ func checkUpdates() {
|
|||||||
// ---- Main ----
|
// ---- Main ----
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
oidcSrv := startOIDCServer()
|
||||||
|
defer oidcSrv.Close()
|
||||||
|
|
||||||
distFS, err := fs.Sub(assets, "frontend/dist")
|
distFS, err := fs.Sub(assets, "frontend/dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("embedded assets: %v", err)
|
log.Fatalf("embedded assets: %v", err)
|
||||||
@@ -493,7 +561,7 @@ func main() {
|
|||||||
Height: height,
|
Height: height,
|
||||||
MinWidth: minWidth,
|
MinWidth: minWidth,
|
||||||
MinHeight: minHeight,
|
MinHeight: minHeight,
|
||||||
URL: "/",
|
URL: "/?desktop=1",
|
||||||
})
|
})
|
||||||
|
|
||||||
if ws != nil {
|
if ws != nil {
|
||||||
|
|||||||
@@ -68,22 +68,26 @@
|
|||||||
oidcLoggingIn = true
|
oidcLoggingIn = true
|
||||||
setConfig({ apiUrl: apiUrl.trim(), token: '' })
|
setConfig({ apiUrl: apiUrl.trim(), token: '' })
|
||||||
|
|
||||||
const wails = (window as any).wails
|
const isDesktop = new URLSearchParams(location.search).has('desktop')
|
||||||
if (wails?.Call?.ByName) {
|
|
||||||
|
if (isDesktop) {
|
||||||
try {
|
try {
|
||||||
const token = await wails.Call.ByName('StartOIDCLogin', apiUrl.trim())
|
const resp = await fetch(`http://127.0.0.1:18901/oidc/login?apiUrl=${encodeURIComponent(apiUrl.trim())}`)
|
||||||
if (token) {
|
const data = await resp.json()
|
||||||
setConfig({ apiUrl: apiUrl.trim(), token })
|
if (data.token) {
|
||||||
initConfig({ apiUrl: apiUrl.trim(), token })
|
setConfig({ apiUrl: apiUrl.trim(), token: data.token })
|
||||||
|
initConfig({ apiUrl: apiUrl.trim(), token: data.token })
|
||||||
|
oidcLoggingIn = false
|
||||||
onConnected()
|
onConnected()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
error = data.error || 'Login failed'
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e?.message || e || 'OIDC login failed'
|
error = e.message || 'Could not reach OIDC service'
|
||||||
|
}
|
||||||
oidcLoggingIn = false
|
oidcLoggingIn = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await startLogin()
|
await startLogin()
|
||||||
|
|||||||
Reference in New Issue
Block a user