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("
Puedes cerrar esta ventana y volver a la aplicacion.
`)) 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 }