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/
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user