diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go
index 2339f99..efbc10d 100644
--- a/cmd/desktop/main.go
+++ b/cmd/desktop/main.go
@@ -1,13 +1,18 @@
package main
import (
+ "crypto/rand"
+ "crypto/sha256"
"embed"
+ "encoding/base64"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
+ "net"
"net/http"
+ "net/url"
"os"
"os/exec"
"os/user"
@@ -33,6 +38,7 @@ const (
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
pollInterval = 30 * time.Second
updateInterval = 6 * time.Hour
+ oidcCallbackPort = 18901
)
type OikosConfig struct {
@@ -43,7 +49,7 @@ type OikosConfig struct {
// ---- ConfigService ----
-type ConfigService struct{ app *application.App }
+type ConfigService struct{}
func (c *ConfigService) Name() string { return "config" }
@@ -95,6 +101,167 @@ func (c *ConfigService) DisableAutoStart() error {
return os.Remove(path)
}
+// StartOIDCLogin opens the system browser for Authentik login and returns
+// the access token. The desktop app hosts a local HTTP server on a fixed
+// port to receive the OIDC callback directly (no copy-paste).
+func (c *ConfigService) StartOIDCLogin(apiUrl string) (string, error) {
+ apiUrl = strings.TrimRight(apiUrl, "/")
+
+ oidcCfg, err := fetchOIDCConfig(apiUrl)
+ if err != nil {
+ return "", fmt.Errorf("OIDC config: %w", err)
+ }
+
+ 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)
+
+ 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(`
Oikos
+
+Connected
You can close this window and return to Oikos.
`))
+
+ 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",
+ 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(),
+ )
+
+ if err := exec.Command("open", authURL).Start(); err != nil {
+ return "", fmt.Errorf("open browser: %w", err)
+ }
+
+ 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")
+ }
+}
+
+type oidcConfig struct {
+ Issuer string `json:"issuer"`
+ ClientID string `json:"client_id"`
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+}
+
+func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) {
+ resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config")
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("server returned %d", resp.StatusCode)
+ }
+ var cfg oidcConfig
+ if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
+ return nil, err
+ }
+ return &cfg, nil
+}
+
+func pkceParams() (verifier, challenge string, _ error) {
+ v := randomString(64)
+ h := sha256.Sum256([]byte(v))
+ return v, base64.RawURLEncoding.EncodeToString(h[:]), nil
+}
+
+func randomString(n int) string {
+ b := make([]byte, n)
+ rand.Read(b)
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) {
+ body, _ := json.Marshal(map[string]string{
+ "grant_type": "authorization_code",
+ "code": code,
+ "code_verifier": verifier,
+ "redirect_uri": redirectURI,
+ })
+
+ resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body)))
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ b, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b))
+ }
+
+ var tokens struct {
+ AccessToken string `json:"access_token"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
+ return "", err
+ }
+ if tokens.AccessToken == "" {
+ return "", fmt.Errorf("no access_token in response")
+ }
+ return tokens.AccessToken, nil
+}
+
// ---- Window persistence ----
type windowState struct {
@@ -338,7 +505,7 @@ func main() {
})
trayMenu.AddSeparator()
trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
- go checkUpdates() // force immediate check on demand
+ go checkUpdates()
})
trayMenu.AddSeparator()
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
@@ -371,12 +538,10 @@ func main() {
systemTray.AttachWindow(window)
systemTray.Run()
- // Register shutdown handler to save window state
app.OnShutdown(func() {
saveWindowState(window)
})
- // Start background goroutines
go pollDashboard(cfg)
go checkUpdates()
diff --git a/web/src/pages/Config.svelte b/web/src/pages/Config.svelte
index 99df6e3..f93ad7f 100644
--- a/web/src/pages/Config.svelte
+++ b/web/src/pages/Config.svelte
@@ -67,6 +67,24 @@
error = ''
oidcLoggingIn = true
setConfig({ apiUrl: apiUrl.trim(), token: '' })
+
+ const wails = (window as any).wails
+ if (wails?.Call?.ByName) {
+ try {
+ const token = await wails.Call.ByName('StartOIDCLogin', apiUrl.trim())
+ if (token) {
+ setConfig({ apiUrl: apiUrl.trim(), token })
+ initConfig({ apiUrl: apiUrl.trim(), token })
+ onConnected()
+ return
+ }
+ } catch (e: any) {
+ error = e?.message || e || 'OIDC login failed'
+ oidcLoggingIn = false
+ return
+ }
+ }
+
try {
await startLogin()
} catch (e: any) {