Desktop OIDC: open system browser, capture callback on localhost
ConfigService.StartOIDCLogin(): - Fetches OIDC config from the API - Generates PKCE params - Starts local HTTP server on 127.0.0.1:18901 - Opens system browser to Authentik - Captures callback directly (no copy-paste) - Exchanges code for token, saves to keychain - Returns token to SPA → auto-connects Config.svelte detects Wails environment and calls the binding.
This commit is contained in:
@@ -1,13 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/user"
|
"os/user"
|
||||||
@@ -33,6 +38,7 @@ const (
|
|||||||
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
|
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
|
||||||
pollInterval = 30 * time.Second
|
pollInterval = 30 * time.Second
|
||||||
updateInterval = 6 * time.Hour
|
updateInterval = 6 * time.Hour
|
||||||
|
oidcCallbackPort = 18901
|
||||||
)
|
)
|
||||||
|
|
||||||
type OikosConfig struct {
|
type OikosConfig struct {
|
||||||
@@ -43,7 +49,7 @@ type OikosConfig struct {
|
|||||||
|
|
||||||
// ---- ConfigService ----
|
// ---- ConfigService ----
|
||||||
|
|
||||||
type ConfigService struct{ app *application.App }
|
type ConfigService struct{}
|
||||||
|
|
||||||
func (c *ConfigService) Name() string { return "config" }
|
func (c *ConfigService) Name() string { return "config" }
|
||||||
|
|
||||||
@@ -95,6 +101,167 @@ func (c *ConfigService) DisableAutoStart() error {
|
|||||||
return os.Remove(path)
|
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(`<!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",
|
||||||
|
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 ----
|
// ---- Window persistence ----
|
||||||
|
|
||||||
type windowState struct {
|
type windowState struct {
|
||||||
@@ -338,7 +505,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
trayMenu.AddSeparator()
|
trayMenu.AddSeparator()
|
||||||
trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
|
trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
|
||||||
go checkUpdates() // force immediate check on demand
|
go checkUpdates()
|
||||||
})
|
})
|
||||||
trayMenu.AddSeparator()
|
trayMenu.AddSeparator()
|
||||||
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
|
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
|
||||||
@@ -371,12 +538,10 @@ func main() {
|
|||||||
systemTray.AttachWindow(window)
|
systemTray.AttachWindow(window)
|
||||||
systemTray.Run()
|
systemTray.Run()
|
||||||
|
|
||||||
// Register shutdown handler to save window state
|
|
||||||
app.OnShutdown(func() {
|
app.OnShutdown(func() {
|
||||||
saveWindowState(window)
|
saveWindowState(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Start background goroutines
|
|
||||||
go pollDashboard(cfg)
|
go pollDashboard(cfg)
|
||||||
go checkUpdates()
|
go checkUpdates()
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,24 @@
|
|||||||
error = ''
|
error = ''
|
||||||
oidcLoggingIn = true
|
oidcLoggingIn = true
|
||||||
setConfig({ apiUrl: apiUrl.trim(), token: '' })
|
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 {
|
try {
|
||||||
await startLogin()
|
await startLogin()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
Reference in New Issue
Block a user