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
This commit is contained in:
egutierrez
2026-05-24 23:23:49 +02:00
parent bd9f0d8437
commit d128ad89ac
12 changed files with 2079 additions and 9 deletions
+109
View File
@@ -0,0 +1,109 @@
---
name: keyring_token_store
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: |
type KeyringTokenStore struct { Service string }
func NewKeyringTokenStore(service string) *KeyringTokenStore
func (s *KeyringTokenStore) Save(account string, t Token) error
func (s *KeyringTokenStore) Load(account string) (*Token, error)
func (s *KeyringTokenStore) Delete(account string) error
var ErrNotFound = errors.New("token not found in keyring")
description: "Persiste tokens OAuth/OIDC entre arranques usando el keyring del SO (Secret Service en Linux, Keychain en macOS, Credential Manager en Windows). Serializa Token a JSON cifrado at-rest por el OS."
tags: [security, keyring, tokens, oauth, persistence, infra, matrix-mas]
params:
- name: service
desc: "Namespace estable del keyring para esta app. Ej: 'fn_registry.matrix_client_pc'. No compartir entre apps."
- name: account
desc: "Identificador unico del token. Ej: user_id '@egutierrez:matrix-af2f3d.organic-machine.com'."
- name: Token.AccessToken
desc: "Access token OAuth/OIDC. Campo obligatorio."
- name: Token.RefreshToken
desc: "Refresh token. Omitido en JSON si vacio."
- name: Token.ExpiresAt
desc: "Momento de expiracion del access token. Zero = nunca expira."
- name: Token.UserID
desc: "Identificador del usuario, ej. '@user:homeserver'. Obligatorio."
- name: Token.DeviceID
desc: "Device ID asignado por el homeserver Matrix."
- name: Token.HomeserverURL
desc: "URL base del homeserver Matrix. Ej: 'https://matrix-af2f3d.organic-machine.com'."
- name: Token.Issuer
desc: "URL del emisor OIDC/MAS. Requerido para flows OIDC."
- name: Token.ClientID
desc: "Client ID usado en el flow MAS/OIDC."
output: "Save/Delete retornan error envuelto con contexto. Load retorna *Token o ErrNotFound (chequear con errors.Is)."
uses_functions: []
uses_types: []
returns: []
returns_optional: true
error_type: "error_go_core"
imports:
- "encoding/json"
- "errors"
- "fmt"
- "time"
- "github.com/zalando/go-keyring"
tested: true
tests:
- "Save then Load returns matching token"
- "Load nonexistent returns ErrNotFound"
- "Save then Delete then Load returns ErrNotFound"
- "Delete nonexistent is idempotent"
- "Save twice overwrites with second token"
test_file_path: "functions/infra/keyring_token_store_test.go"
file_path: "functions/infra/keyring_token_store.go"
---
## Ejemplo
```go
store := NewKeyringTokenStore("fn_registry.matrix_client_pc")
tok := Token{
AccessToken: "mxat_abc123...",
RefreshToken: "mxrt_xyz789...",
ExpiresAt: time.Now().Add(time.Hour),
UserID: "@egutierrez:matrix-af2f3d.organic-machine.com",
DeviceID: "ABCDEF1234",
HomeserverURL: "https://matrix-af2f3d.organic-machine.com",
Issuer: "https://auth-af2f3d.organic-machine.com/",
ClientID: "VDC4XQ2ZKN2TJ0BYVJ54FK7M6Y",
}
if err := store.Save(tok.UserID, tok); err != nil {
log.Fatalf("save token: %v", err)
}
// En otro arranque de la app:
got, err := store.Load("@egutierrez:matrix-af2f3d.organic-machine.com")
if errors.Is(err, ErrNotFound) {
// primer login — arrancar flujo OAuth
}
// Logout o revocacion:
_ = store.Delete(tok.UserID)
```
## Cuando usarla
Usar cuando una app desktop Go necesite persistir tokens OAuth/OIDC entre arranques sin escribirlos en disco en texto plano. Ideal para matrix_client_pc (tokens MAS + Matrix), admin_panel, cualquier CLI Go con login interactivo. Usar en el path de callback OAuth justo despues de recibir el token, y en el arranque para recuperarlo antes de pedir credenciales.
## Gotchas
- **Linux requiere D-Bus session activa.** En servidores headless sin GUI, `keyring.Set` falla. En contenedores Docker sin Secret Service (GNOME Keyring / KWallet): NO funciona salvo montaje de socket D-Bus. Para apps que corren en servidor headless, usar un fallback de archivo cifrado (fuera del scope de esta funcion).
- **Windows Credential Manager** tiene limite de ~2500 chars por entrada. Tokens JWT largos (base64 > 2KB) pueden cortarse. Mantener tokens < 2KB o comprimir/dividir.
- **macOS Keychain** vincula el entry al binario que lo creo. Si el ejecutable cambia de firma (recompilacion con nuevo cert), el sistema pide permiso al usuario otra vez. Es comportamiento esperado de macOS, no un bug.
- **Snap / Flatpak** en Linux pueden bloquear el acceso a Secret Service por sandboxing. Documentar en el README de apps distribuidas via estos formatos.
- El JSON serializado almacena el AccessToken en texto plano dentro del entry del keyring. El keyring del SO cifra at-rest, pero no usar esto para secretos de alta seguridad (claves privadas, PINs bancarios, etc.).
- **Nunca loggear** el valor de AccessToken/RefreshToken. Los errores solo incluyen la descripcion del fallo, nunca el valor.
- `List()` no esta implementada en v0.1.0 porque `go-keyring` no expone listado de accounts. TODO: implementar via index local en `os.UserConfigDir()/service/accounts.json` si se necesita en el futuro.
## Notas
Depende de `github.com/zalando/go-keyring` (v0.2.x). En Linux usa `github.com/godbus/dbus/v5` para comunicarse con Secret Service. En Windows usa `github.com/danieljoos/wincred`. Ambas dependencias se agregan transitivamente.
El campo `Token.ExpiresAt` se redondea a segundos al serializar/deserializar con `time.Time` en JSON. El caller debe comparar con `time.Now().Before(t.ExpiresAt)` para saber si el token ha expirado.