Files
egutierrez c441366f89 feat(matrix-mas): 3 helpers for matrix_client_pc (issue 0147)
- mas_oidc_loopback_go_infra: OAuth2 PKCE + loopback HTTP for desktop login
- keyring_token_store_go_infra: persist OAuth tokens in SO keychain
- matrix_client_init_go_infra: init mautrix.Client from access_token + whoami

Plus go.work workspace including matrix_client_pc sub-repo for shared
import path during dev. All 3 fns tagged matrix-mas capability group.

Tests: TestMasOidcLoopback (15 cases), TestKeyringTokenStore (5 cases,
SKIP on headless), TestMatrixClientInit (6 cases) — all green/skip.

Refs: dev/issues/0147-matrix-client-pc-scaffold.md
Refs: dataforge/matrix_client_pc commit f28c2b1
2026-05-24 23:23:49 +02:00

131 lines
5.6 KiB
Markdown

---
name: mas_oidc_loopback
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MasOidcLoopback(cfg MasOidcLoopbackConfig) (*MasOidcLoopbackResult, error)"
description: "Ejecuta el flujo OAuth2 Authorization Code + PKCE contra Matrix Authentication Service (MAS) usando un servidor HTTP loopback en localhost para recibir el callback. Abre el browser del SO (o imprime la URL si OpenBrowser=false), espera el codigo de autorizacion, lo intercambia por tokens y devuelve AccessToken listo para usar como Bearer contra Synapse."
tags: ["matrix", "mas", "oidc", "oauth2", "pkce", "loopback", "client", "infra", "matrix-mas", "auth"]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "context"
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/url"
- "os/exec"
- "runtime"
- "strings"
- "time"
tested: true
tests:
- "state mismatch devuelve error"
- "token endpoint 400 devuelve error con body"
- "timeout sin callback devuelve error"
- "validacion - Issuer vacio"
- "validacion - Issuer sin slash final"
- "validacion - ClientID vacio"
- "validacion - LoopbackPort negativo"
- "scopes nil usa defaults - error es de discovery no de scopes"
test_file_path: "functions/infra/mas_oidc_loopback_test.go"
file_path: "functions/infra/mas_oidc_loopback.go"
params:
- name: Issuer
desc: "URL base del MAS. Debe terminar en '/'. La funcion hace GET a {Issuer}.well-known/openid-configuration para descubrir authorization_endpoint y token_endpoint."
- name: ClientID
desc: "ULID del client registrado en MAS. Para clients publicos (client_auth_method: none) no se necesita client_secret."
- name: Scopes
desc: "Lista de scopes OAuth2 a solicitar. Si nil o vacio, usa defaults: [openid, urn:matrix:org.matrix.msc2967.client:api:*]. Para acceso completo Matrix."
- name: LoopbackPort
desc: "Puerto local para el servidor de callback (debe coincidir con redirect_uri registrado en MAS: http://127.0.0.1:{port}/callback). Si 0, elige un puerto libre dinamicamente."
- name: OpenBrowser
desc: "Si true, abre el browser del SO automaticamente (xdg-open/open/rundll32). Si false, imprime la URL a stdout para apertura manual."
- name: TimeoutSeconds
desc: "Tiempo maximo en segundos esperando el callback del browser. Default 300s si <= 0."
output: "MasOidcLoopbackResult con AccessToken (Bearer para Synapse), RefreshToken, ExpiresIn, TokenType, Scope e IDToken."
---
## Ejemplo
```go
import "fn-registry/functions/infra"
cfg := infra.MasOidcLoopbackConfig{
Issuer: "https://auth-af2f3d.organic-machine.com/",
ClientID: "VDC4XQ2ZKN2TJ0BYVJ54FK7M6Y", // matrix_client_pc client en MAS
Scopes: []string{"openid", "urn:matrix:org.matrix.msc2967.client:api:*"},
LoopbackPort: 8765,
OpenBrowser: true,
TimeoutSeconds: 300,
}
res, err := infra.MasOidcLoopback(cfg)
if err != nil {
log.Fatalf("login failed: %v", err)
}
// res.AccessToken -> Bearer token para requests Synapse
// res.RefreshToken -> guardar para renovacion posterior
fmt.Printf("Logged in. Token expires in %d seconds.\n", res.ExpiresIn)
```
Con OpenBrowser=false (servidor headless o CLI):
```go
cfg := infra.MasOidcLoopbackConfig{
Issuer: "https://auth-af2f3d.organic-machine.com/",
ClientID: "VDC4XQ2ZKN2TJ0BYVJ54FK7M6Y",
LoopbackPort: 8765,
OpenBrowser: false, // imprime la URL a stdout
TimeoutSeconds: 120,
}
res, err := infra.MasOidcLoopback(cfg)
// El usuario copia la URL del stdout y la abre en su browser
```
## Cuando usarla
Cuando una app desktop (Wails, Tauri, CLI Go) necesite autenticar al usuario contra MAS
sin un browser embebido: la funcion gestiona todo el flujo PKCE, arranca el servidor
loopback, espera el callback y devuelve los tokens listos para usar.
Usar antes de cualquier llamada autenticada a la Matrix Client-Server API via Synapse.
## Gotchas
- **LoopbackPort debe coincidir con el redirect_uri registrado en MAS.**
Si el client en MAS tiene `redirect_uris: [http://127.0.0.1:8765/callback]`, el
`LoopbackPort` debe ser `8765`. MAS rechaza con 400 si el redirect_uri no coincide.
Con `LoopbackPort: 0` la funcion elige puerto libre, pero el client en MAS necesitaria
soportar wildcard `http://127.0.0.1:*/callback` (verificar la config del client en MAS).
- **El client en MAS debe ser publico (client_auth_method: none).**
Esta funcion implementa PKCE sin client_secret (RFC 7636). Si el client tiene
`client_secret_basic` o `client_secret_post`, el token endpoint rechazara el
intercambio porque falta el secret. Para clients confidenciales, usar otra funcion
con autenticacion del client.
- **OpenBrowser en servidores headless:**
`xdg-open` en Linux requiere entorno de escritorio. En servidores SSH sin DISPLAY,
usar `OpenBrowser: false` e imprimir la URL para que el operador la abra en su PC.
- **El loopback server muere tras recibir el primer callback.**
No es apto para flujos multi-sesion ni refresh. Para renovar tokens usar el
`RefreshToken` con un helper de token refresh (oauth2_refresh_go_infra).
- **State mismatch indica ataque CSRF o multi-tab.**
Si el callback llega con un state distinto al generado, la funcion aborta con error.
El browser puede mostrar un error si el usuario abre varias pestanas del authorize.
- **Timeout:** si el usuario no completa el login antes de `TimeoutSeconds`, la funcion
devuelve error y el loopback server se cierra. El proceso del browser queda abierto
(el OS no lo mata automaticamente).