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) }