feat: chat E2EE MVP - rooms list + timeline + composer + sync (issues 0148+0149+0150)
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>
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user