f28c2b121e
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/
152 lines
4.2 KiB
Go
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)
|
|
}
|