36a485ea26
Backend extends MatrixService with Start()/Stop()/ListRooms()/LoadTimeline()/
SendText()/SendMarkdown(). On login the service initialises the crypto store
(cryptohelper, Olm/Megolm via goolm build tag) and a sync loop that fans
events out through Wails events ("matrix:event", "matrix:error"). Pickle
key is 32 random bytes hex-encoded in the OS keyring alongside the access
token, so the crypto SQLite store survives restarts.
Vendors 4 fresh helpers from fn_registry/functions/infra/:
matrix_crypto_init.go (//go:build goolm || libolm)
matrix_sync_service.go
matrix_message_send.go
matrix_room_list.go
Plus the existing 3 (mas_oidc_loopback, keyring_token_store, matrix_client_init).
go-sqlite3 driver pulled explicitly via sqlite_driver.go.
Frontend rewires HomeScreen as a 3-zone AppShell (sidebar / timeline /
composer). useMatrixRooms polls + reacts to the sync stream; useMatrixTimeline
loads the last 50 events of the selected room and appends live ones. New
components: RoomList, Timeline, EventBubble, Composer. Composer supports
plain text (default) and a markdown toggle; Enter sends, Shift+Enter newline.
wails.json now passes "build:tags": "goolm" by default. Tested with
wails build -tags goolm on linux/amd64 and windows/amd64.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
383 lines
12 KiB
Go
383 lines
12 KiB
Go
package infra
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// MasOidcLoopbackConfig configura el flujo OAuth2 PKCE con loopback HTTP
|
|
// contra Matrix Authentication Service (MAS).
|
|
type MasOidcLoopbackConfig struct {
|
|
// Issuer es la URL base del MAS. Debe terminar en "/".
|
|
// La funcion hace GET a {Issuer}.well-known/openid-configuration para descubrir endpoints.
|
|
Issuer string
|
|
|
|
// ClientID es el ULID del client registrado en MAS.
|
|
// El client debe tener client_auth_method: none (public client PKCE).
|
|
ClientID string
|
|
|
|
// Scopes a solicitar. Si vacio usa ["openid", "urn:matrix:org.matrix.msc2967.client:api:*"].
|
|
Scopes []string
|
|
|
|
// LoopbackPort es el puerto local donde escucha el callback.
|
|
// Debe coincidir con el redirect_uri registrado en MAS (http://127.0.0.1:{port}/callback).
|
|
// Si 0, elige un puerto libre dinamicamente.
|
|
LoopbackPort int
|
|
|
|
// OpenBrowser abre el browser del SO automaticamente si es true.
|
|
// Si false, imprime la URL a stdout y espera que el caller la abra.
|
|
OpenBrowser bool
|
|
|
|
// TimeoutSeconds es el tiempo maximo esperando el callback. Default 300.
|
|
TimeoutSeconds int
|
|
}
|
|
|
|
// MasOidcLoopbackResult contiene los tokens devueltos por MAS tras el intercambio.
|
|
type MasOidcLoopbackResult struct {
|
|
// AccessToken es el Bearer token para usar contra Synapse.
|
|
AccessToken string `json:"access_token"`
|
|
|
|
// RefreshToken permite renovar el access token sin re-autenticar.
|
|
RefreshToken string `json:"refresh_token"`
|
|
|
|
// ExpiresIn es el tiempo de vida del access token en segundos.
|
|
ExpiresIn int `json:"expires_in"`
|
|
|
|
// TokenType es el tipo de token, normalmente "Bearer".
|
|
TokenType string `json:"token_type"`
|
|
|
|
// Scope es la lista de scopes concedidos (space-separated).
|
|
Scope string `json:"scope"`
|
|
|
|
// IDToken es el JWT de identidad OIDC (puede estar vacio si no se pidio openid).
|
|
IDToken string `json:"id_token,omitempty"`
|
|
}
|
|
|
|
// oidcDiscovery es la respuesta de .well-known/openid-configuration.
|
|
type oidcDiscovery struct {
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
}
|
|
|
|
// MasOidcLoopback ejecuta el flujo OAuth2 Authorization Code + PKCE contra MAS
|
|
// usando un servidor HTTP loopback para recibir el callback.
|
|
//
|
|
// Flujo:
|
|
// 1. Discovery de endpoints via .well-known/openid-configuration.
|
|
// 2. Generacion de code_verifier/challenge PKCE y state anti-CSRF.
|
|
// 3. Arranque de servidor loopback en 127.0.0.1:{LoopbackPort}.
|
|
// 4. Apertura del browser (o impresion de URL si OpenBrowser=false).
|
|
// 5. Espera del callback con el authorization code.
|
|
// 6. Intercambio del code por tokens via POST al token_endpoint.
|
|
// 7. Devolucion de MasOidcLoopbackResult.
|
|
func MasOidcLoopback(cfg MasOidcLoopbackConfig) (*MasOidcLoopbackResult, error) {
|
|
// 1. Validar inputs
|
|
if cfg.Issuer == "" {
|
|
return nil, fmt.Errorf("mas_oidc_loopback: Issuer no puede estar vacio")
|
|
}
|
|
if !strings.HasSuffix(cfg.Issuer, "/") {
|
|
return nil, fmt.Errorf("mas_oidc_loopback: Issuer debe terminar en '/' (got %q)", cfg.Issuer)
|
|
}
|
|
if cfg.ClientID == "" {
|
|
return nil, fmt.Errorf("mas_oidc_loopback: ClientID no puede estar vacio")
|
|
}
|
|
if cfg.LoopbackPort < 0 {
|
|
return nil, fmt.Errorf("mas_oidc_loopback: LoopbackPort debe ser >= 0")
|
|
}
|
|
|
|
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
|
|
if cfg.TimeoutSeconds <= 0 {
|
|
timeout = 300 * time.Second
|
|
}
|
|
|
|
scopes := cfg.Scopes
|
|
if len(scopes) == 0 {
|
|
scopes = []string{"openid", "urn:matrix:org.matrix.msc2967.client:api:*"}
|
|
}
|
|
|
|
// 2. Discovery OIDC
|
|
discovery, err := masOidcDiscover(cfg.Issuer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mas_oidc_loopback: discovery failed: %w", err)
|
|
}
|
|
|
|
// 3. PKCE: code_verifier + code_challenge
|
|
verifier, challenge, err := masOidcPKCE()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mas_oidc_loopback: pkce generation failed: %w", err)
|
|
}
|
|
|
|
// 4. State anti-CSRF
|
|
state, err := masOidcRandomBase64URL(32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mas_oidc_loopback: state generation failed: %w", err)
|
|
}
|
|
|
|
// 5. Arrancar loopback server
|
|
listener, port, err := masOidcStartListener(cfg.LoopbackPort)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mas_oidc_loopback: no se pudo abrir puerto loopback: %w", err)
|
|
}
|
|
|
|
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
|
|
|
|
// Canal para recibir el code o error desde el handler HTTP
|
|
codeCh := make(chan string, 1)
|
|
errCh := make(chan error, 1)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
|
|
// Validar state anti-CSRF
|
|
if q.Get("state") != state {
|
|
errCh <- fmt.Errorf("mas_oidc_loopback: state mismatch (posible CSRF) — esperado %q, recibido %q", state, q.Get("state"))
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte("<html><body><h2>Error: state mismatch. Por favor cierra esta ventana.</h2></body></html>"))
|
|
return
|
|
}
|
|
|
|
// Verificar error del proveedor
|
|
if errParam := q.Get("error"); errParam != "" {
|
|
desc := q.Get("error_description")
|
|
errCh <- fmt.Errorf("mas_oidc_loopback: proveedor devolvio error %q: %s", errParam, desc)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(fmt.Sprintf("<html><body><h2>Error de autorizacion: %s</h2></body></html>", desc)))
|
|
return
|
|
}
|
|
|
|
code := q.Get("code")
|
|
if code == "" {
|
|
errCh <- fmt.Errorf("mas_oidc_loopback: callback sin 'code'")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte("<html><body><h2>Error: no se recibio authorization code.</h2></body></html>"))
|
|
return
|
|
}
|
|
|
|
// Responder al browser con mensaje de exito
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head><meta charset="utf-8"><title>Login completo</title></head>
|
|
<body style="font-family:sans-serif;text-align:center;padding:3em;">
|
|
<h2>Login completo</h2>
|
|
<p>Puedes cerrar esta ventana y volver a la aplicacion.</p>
|
|
</body>
|
|
</html>`))
|
|
|
|
codeCh <- code
|
|
})
|
|
|
|
srv := &http.Server{Handler: mux}
|
|
|
|
// Arrancar el servidor en goroutine
|
|
srvErrCh := make(chan error, 1)
|
|
go func() {
|
|
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
|
srvErrCh <- err
|
|
}
|
|
}()
|
|
|
|
// 6. Construir URL de autorización
|
|
authURL := masOidcBuildAuthURL(
|
|
discovery.AuthorizationEndpoint,
|
|
cfg.ClientID,
|
|
redirectURI,
|
|
strings.Join(scopes, " "),
|
|
state,
|
|
challenge,
|
|
)
|
|
|
|
// 7. Abrir browser o imprimir URL
|
|
if cfg.OpenBrowser {
|
|
if err := masOidcOpenBrowser(authURL); err != nil {
|
|
// No es fatal: continuamos y el usuario puede abrir manualmente
|
|
fmt.Printf("mas_oidc_loopback: no se pudo abrir el browser automaticamente.\nAbre esta URL manualmente:\n%s\n", authURL)
|
|
}
|
|
} else {
|
|
fmt.Printf("Abre esta URL en tu browser para autenticarte:\n%s\n", authURL)
|
|
}
|
|
|
|
// 8. Esperar callback con timeout
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
var code string
|
|
select {
|
|
case code = <-codeCh:
|
|
// ok
|
|
case callbackErr := <-errCh:
|
|
_ = srv.Shutdown(context.Background())
|
|
return nil, callbackErr
|
|
case <-ctx.Done():
|
|
_ = srv.Shutdown(context.Background())
|
|
return nil, fmt.Errorf("mas_oidc_loopback: timeout esperando callback despues de %v", timeout)
|
|
case srvErr := <-srvErrCh:
|
|
return nil, fmt.Errorf("mas_oidc_loopback: servidor loopback fallo: %w", srvErr)
|
|
}
|
|
|
|
// 9. Shutdown graceful del servidor loopback
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer shutdownCancel()
|
|
_ = srv.Shutdown(shutdownCtx)
|
|
|
|
// 10. Intercambiar code por tokens
|
|
result, err := masOidcExchangeCode(
|
|
discovery.TokenEndpoint,
|
|
cfg.ClientID,
|
|
code,
|
|
redirectURI,
|
|
verifier,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mas_oidc_loopback: token exchange failed: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// masOidcHTTPClient es el cliente HTTP usado por masOidcDiscover y masOidcExchangeCode.
|
|
// Tiene timeout de 15s. Puede ser reemplazado en tests.
|
|
var masOidcHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
|
|
|
// masOidcDiscover obtiene los endpoints OIDC desde .well-known/openid-configuration.
|
|
func masOidcDiscover(issuer string) (*oidcDiscovery, error) {
|
|
discoveryURL := issuer + ".well-known/openid-configuration"
|
|
resp, err := masOidcHTTPClient.Get(discoveryURL) //nolint:gosec
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GET %s: %w", discoveryURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("discovery HTTP %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var d oidcDiscovery
|
|
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
|
|
return nil, fmt.Errorf("parsing discovery JSON: %w", err)
|
|
}
|
|
if d.AuthorizationEndpoint == "" {
|
|
return nil, fmt.Errorf("discovery: authorization_endpoint vacio")
|
|
}
|
|
if d.TokenEndpoint == "" {
|
|
return nil, fmt.Errorf("discovery: token_endpoint vacio")
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// masOidcPKCE genera un code_verifier aleatorio y su code_challenge SHA256/base64url.
|
|
func masOidcPKCE() (verifier, challenge string, err error) {
|
|
verifier, err = masOidcRandomBase64URL(32) // 32 bytes -> 43 chars base64url
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
h := sha256.Sum256([]byte(verifier))
|
|
challenge = base64.RawURLEncoding.EncodeToString(h[:])
|
|
return verifier, challenge, nil
|
|
}
|
|
|
|
// masOidcRandomBase64URL genera n bytes aleatorios codificados en base64url sin padding.
|
|
func masOidcRandomBase64URL(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// masOidcStartListener abre un listener TCP en 127.0.0.1:{port}.
|
|
// Si port=0, elige un puerto libre y devuelve el puerto asignado.
|
|
func masOidcStartListener(port int) (net.Listener, int, error) {
|
|
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
|
l, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
assignedPort := l.Addr().(*net.TCPAddr).Port
|
|
return l, assignedPort, nil
|
|
}
|
|
|
|
// masOidcBuildAuthURL construye la URL de autorización OAuth2 con PKCE.
|
|
func masOidcBuildAuthURL(authEndpoint, clientID, redirectURI, scope, state, challenge string) string {
|
|
u, _ := url.Parse(authEndpoint)
|
|
q := u.Query()
|
|
q.Set("response_type", "code")
|
|
q.Set("client_id", clientID)
|
|
q.Set("redirect_uri", redirectURI)
|
|
q.Set("scope", scope)
|
|
q.Set("state", state)
|
|
q.Set("code_challenge", challenge)
|
|
q.Set("code_challenge_method", "S256")
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// masOidcOpenBrowser abre la URL en el browser predeterminado del SO.
|
|
func masOidcOpenBrowser(rawURL string) error {
|
|
var cmd *exec.Cmd
|
|
switch runtime.GOOS {
|
|
case "linux":
|
|
cmd = exec.Command("xdg-open", rawURL)
|
|
case "darwin":
|
|
cmd = exec.Command("open", rawURL)
|
|
case "windows":
|
|
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
|
default:
|
|
return fmt.Errorf("plataforma no soportada para abrir browser: %s", runtime.GOOS)
|
|
}
|
|
return cmd.Start()
|
|
}
|
|
|
|
// masOidcExchangeCode intercambia el authorization code por tokens via POST al token_endpoint.
|
|
func masOidcExchangeCode(tokenEndpoint, clientID, code, redirectURI, verifier string) (*MasOidcLoopbackResult, error) {
|
|
formData := url.Values{}
|
|
formData.Set("grant_type", "authorization_code")
|
|
formData.Set("code", code)
|
|
formData.Set("redirect_uri", redirectURI)
|
|
formData.Set("client_id", clientID)
|
|
formData.Set("code_verifier", verifier)
|
|
|
|
resp, err := masOidcHTTPClient.PostForm(tokenEndpoint, formData) //nolint:gosec
|
|
if err != nil {
|
|
return nil, fmt.Errorf("POST %s: %w", tokenEndpoint, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("leyendo respuesta del token endpoint: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("token endpoint HTTP %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result MasOidcLoopbackResult
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("parsing token response JSON: %w", err)
|
|
}
|
|
if result.AccessToken == "" {
|
|
return nil, fmt.Errorf("token response sin access_token: %s", string(body))
|
|
}
|
|
|
|
return &result, nil
|
|
}
|