Files
matrix_client_pc/matrix_service.go
T
egutierrez f28c2b121e feat: login MAS OIDC end-to-end (issue 0147)
Backend Go:
- MatrixService with Login/GetSession/Logout bindings
- Uses 3 registry helpers via go.work:
  - mas_oidc_loopback_go_infra (PKCE flow)
  - keyring_token_store_go_infra (SO keychain)
  - matrix_client_init_go_infra (mautrix client)
- whoami helper to discover user_id+device_id pre-init

Frontend React+Vite+TS:
- Mantine v7 + @tabler/icons-react
- LoginScreen with 'Sign in with Matrix' button
- HomeScreen with profile card + logout
- Dark theme violet accent
- @mantine/notifications wired

Wails config:
- Switched to pnpm (workspace: protocol)
- Bindings auto-generated for MatrixService
- 1280x800 default window

Build: 68MB linux/amd64 binary in build/bin/
2026-05-24 23:23:35 +02:00

152 lines
4.2 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
"fn-registry/functions/infra"
)
// Constants are operator-configurable later via settings UI. Hardcoded for issue 0147 MVP.
const (
homeserverURL = "https://matrix-af2f3d.organic-machine.com"
masIssuer = "https://auth-af2f3d.organic-machine.com/"
masClientID = "VDC4XQ2ZKN2TJ0BYVJ54FK7M6Y"
loopbackPort = 8765
keyringServiceName = "fn_registry.matrix_client_pc"
oidcTimeoutSeconds = 300
)
var defaultScopes = []string{
"openid",
"urn:matrix:org.matrix.msc2967.client:api:*",
}
// MatrixService is bound to the Wails frontend.
type MatrixService struct {
ctx context.Context
mu sync.Mutex
store *infra.KeyringTokenStore
}
func NewMatrixService() *MatrixService {
return &MatrixService{
store: infra.NewKeyringTokenStore(keyringServiceName),
}
}
func (s *MatrixService) SetContext(ctx context.Context) {
s.ctx = ctx
}
// SessionView is the safe-to-send JSON for the frontend (no tokens).
type SessionView struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
HomeserverURL string `json:"homeserver_url"`
HasToken bool `json:"has_token"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// Login launches the OAuth2 PKCE flow against MAS. Blocks until completion or timeout.
// Returns the user_id of the authenticated session.
func (s *MatrixService) Login() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
cfg := infra.MasOidcLoopbackConfig{
Issuer: masIssuer,
ClientID: masClientID,
Scopes: defaultScopes,
LoopbackPort: loopbackPort,
OpenBrowser: true,
TimeoutSeconds: oidcTimeoutSeconds,
}
res, err := infra.MasOidcLoopback(cfg)
if err != nil {
return "", fmt.Errorf("oidc: %w", err)
}
// Initialize Matrix client to discover user_id + device_id via whoami.
tmpStore := tempStoreDir()
clientCfg := infra.MatrixClientInitConfig{
HomeserverURL: homeserverURL,
// UserID is unknown until whoami. mautrix-go requires it pre-set, but we'll
// use Whoami via the Wails service directly. As shortcut: parse id_token if present.
// For v0.1.0 use a placeholder + Whoami after; mautrix accepts empty UserID, then
// updates after whoami call.
UserID: "",
AccessToken: res.AccessToken,
StoreDir: tmpStore,
EnableCrypto: false,
}
// Pre-fetch user_id by hitting /whoami directly (mautrix requires UserID at NewClient).
userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken)
if err != nil {
return "", fmt.Errorf("whoami: %w", err)
}
clientCfg.UserID = userID
clientCfg.DeviceID = deviceID
clientCfg.StoreDir = userStoreDir(userID)
if _, err := infra.MatrixClientInit(clientCfg); err != nil {
return "", fmt.Errorf("matrix init: %w", err)
}
tok := infra.Token{
AccessToken: res.AccessToken,
RefreshToken: res.RefreshToken,
UserID: userID,
DeviceID: deviceID,
HomeserverURL: homeserverURL,
Issuer: masIssuer,
ClientID: masClientID,
}
if res.ExpiresIn > 0 {
tok.ExpiresAt = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)
}
if err := s.store.Save(userID, tok); err != nil {
return "", fmt.Errorf("keyring save: %w", err)
}
return userID, nil
}
// GetSession returns the persisted session for the given user_id (or last-known if empty).
func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
if userID == "" {
// v0.1.0: no multi-account index. Frontend must pass the user_id once known.
return nil, errors.New("user_id required (v0.1.0 multi-account index TODO)")
}
tok, err := s.store.Load(userID)
if err != nil {
if errors.Is(err, infra.ErrNotFound) {
return nil, nil
}
return nil, fmt.Errorf("keyring load: %w", err)
}
view := &SessionView{
UserID: tok.UserID,
DeviceID: tok.DeviceID,
HomeserverURL: tok.HomeserverURL,
HasToken: tok.AccessToken != "",
}
if !tok.ExpiresAt.IsZero() {
view.ExpiresAt = tok.ExpiresAt.Format(time.RFC3339)
}
return view, nil
}
// Logout deletes the persisted token for the given user_id.
func (s *MatrixService) Logout(userID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if userID == "" {
return errors.New("user_id required")
}
return s.store.Delete(userID)
}