package gtslib_auth_keyring import ( "bufio" "context" "encoding/json" "log/slog" "net/http" neturl "net/url" "os" "path/filepath" "strings" "github.com/adrg/xdg" "github.com/go-openapi/runtime" httptransport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" "github.com/pkg/browser" "github.com/pkg/errors" "github.com/zalando/go-keyring" "golang.org/x/time/rate" "webfinger.net/go/webfinger" apiclient "git.coopcloud.tech/decentral1se/gtslib/client" "git.coopcloud.tech/decentral1se/gtslib/client/apps" "git.coopcloud.tech/decentral1se/gtslib/models" ) func Ptr[T any](v T) *T { return &v } // Config is a client configuration. type Config struct { Name string // client name Website string // client website } // Option with operates on a client config. type Option func(c *Config) // NewConfig creates a new client config. func NewConfig(opts ...Option) *Config { conf := &Config{ Name: "gtslib-auth-keyring", Website: "https://doesnt.exist/gtslib-auth-keyring", } for _, optFunc := range opts { optFunc(conf) } return conf } // WithName sets a client name. func WithName(name string) Option { return func(c *Config) { c.Name = name } } // WithWebsite sets a client website. func WithWebsite(website string) Option { return func(c *Config) { c.Website = website } } // Prefs stores persisted preferences. type Prefs struct { // Instances is a map of instance names to instance preferences. Instances map[string]PrefsInstance `json:"instances,omitempty"` // Users is a map of user usernames@domains to instance data. Users map[string]PrefsUser `json:"users,omitempty"` // DefaultUser is the username@domain of the last user we successfully // authenticated as, if there is one. DefaultUser string `json:"default_user,omitempty"` } // PrefsInstance stores preferences for a given instance. OAuth2 app secrets // are stored in the system keychain. type PrefsInstance struct { // ClientID is the OAuth2 client ID on this instance. ClientID string `json:"client_id"` } // PrefsUser stores preferences for a given user. User access tokens are stored // in the system keychain. type PrefsUser struct { // Instance is the name of the instance that the user is on. Instance string `json:"instance"` } // prefsDir is the path to the directory containing all preference files. var prefsDir string // prefsPath is the path to the file within that directory that stores all of our prefs. var prefsPath string func init() { prefsDir = filepath.Join(xdg.ConfigHome, "gtslib.auth.keyring") prefsPath = filepath.Join(prefsDir, "prefs.json") } // LoadPrefs returns preferences from disk or an empty prefs object if there // are none stored or accessible. func LoadPrefs() (*Prefs, error) { f, err := os.Open(prefsPath) if err != nil { return &Prefs{ Instances: map[string]PrefsInstance{}, Users: map[string]PrefsUser{}, }, nil } defer func() { _ = f.Close() }() var prefs Prefs err = json.NewDecoder(f).Decode(&prefs) if err != nil { return nil, errors.WithStack(err) } if prefs.Instances == nil { prefs.Instances = map[string]PrefsInstance{} } if prefs.Users == nil { prefs.Users = map[string]PrefsUser{} } return &prefs, nil } // SavePrefs creates on-disk preferences or overwrites existing ones. func SavePrefs(prefs *Prefs) error { err := os.MkdirAll(prefsDir, 0o755) if err != nil { return errors.WithStack(err) } f, err := os.Create(prefsPath) if err != nil { return errors.WithStack(err) } defer func() { _ = f.Close() }() encoder := json.NewEncoder(f) encoder.SetIndent("", " ") encoder.SetEscapeHTML(false) err = encoder.Encode(prefs) if err != nil { return errors.WithStack(err) } return nil } // PrefNotFound represents an error in which no preference is found. var PrefNotFound = errors.New("preference value not found") // getPrefValue retrieves a preference value. func getPrefValue(get func(prefs *Prefs) (string, bool)) (string, error) { prefs, err := LoadPrefs() if err != nil { return "", err } value, exists := get(prefs) if !exists { return "", errors.WithStack(PrefNotFound) } return value, nil } // setPrefValue sets a preference value. func setPrefValue(set func(prefs *Prefs)) error { prefs, err := LoadPrefs() if err != nil { return err } set(prefs) err = SavePrefs(prefs) if err != nil { return err } return nil } // GetDefaultUser retrieves the default user. func GetDefaultUser() (string, error) { return getPrefValue(func(prefs *Prefs) (string, bool) { if prefs.DefaultUser == "" { return "", false } return prefs.DefaultUser, true }) } // SetDefaultUser sets the default user. func SetDefaultUser(user string) error { return setPrefValue(func(prefs *Prefs) { prefs.DefaultUser = user }) } // GetInstanceClientID retrieves the instance client ID. func GetInstanceClientID(instance string) (string, error) { return getPrefValue(func(prefs *Prefs) (string, bool) { prefsInstance, exists := prefs.Instances[instance] if !exists { return "", false } return prefsInstance.ClientID, true }) } // SetInstanceClientID sets the instance client ID. func SetInstanceClientID(instance string, clientID string) error { return setPrefValue(func(prefs *Prefs) { prefsInstance := prefs.Instances[instance] prefsInstance.ClientID = clientID prefs.Instances[instance] = prefsInstance }) } // GetUserInstance gets the user instance. func GetUserInstance(user string) (string, error) { return getPrefValue(func(prefs *Prefs) (string, bool) { prefsUser, exists := prefs.Users[user] if !exists { return "", false } return prefsUser.Instance, true }) } // SetUserInstance sets the user instance. func SetUserInstance(user string, instance string) error { return setPrefValue(func(prefs *Prefs) { prefsUser := prefs.Users[user] prefsUser.Instance = instance prefs.Users[user] = prefsUser }) } // Client is a GtS API client with attached authentication credentials and rate // limiter. Credentials may be no-op. type Client struct { Client *apiclient.GoToSocialSwaggerDocumentation Auth runtime.ClientAuthInfoWriter limiter *rate.Limiter ctx context.Context } func (c *Client) Wait() error { if err := c.limiter.Wait(c.ctx); err != nil { return errors.WithStack(err) } return nil } // NewAuthClient creates a new authentication client. func NewAuthClient(user string) (*Client, error) { var err error if user == "" { user, err = GetDefaultUser() if err != nil { slog.Error("no user provided, couldn't get default user from prefs (did you log in first?)") return nil, err } } instance, err := GetUserInstance(user) if err != nil { slog.Error("couldn't get user's instance from prefs (did you log in first?)", "user", user) return nil, err } accessToken, err := keyring.Get(keyringServiceAccessToken, user) if err != nil { slog.Error("couldn't find user's access token (did you log in first?)", "user", user) return nil, err } return &Client{ Client: clientForInstance(instance), Auth: httptransport.BearerToken(accessToken), limiter: rate.NewLimiter(1.0, 300), ctx: context.Background(), }, nil } const ( keyringServiceAccessToken = "gtslib.auth.keyring.access-token" keyringServiceClientSecret = "gtslib.auth.keyring.client-secret" ) const ( oauthRedirect = "urn:ietf:wg:oauth:2.0:oob" oauthScopes = "read write" ) // Login authenticates the user and saves the credentials in the system // keychain. func Login(user string, opts ...Option) error { var err error if user == "" { user, err = GetDefaultUser() if err != nil { slog.Error("no user provided, couldn't get default user from prefs (have you logged in before?)") return err } } if user == "" { return errors.WithStack(errors.New("a user is required")) } if !strings.ContainsRune(user, '@') { return errors.WithStack(errors.New("a fully qualified user with a domain is required")) } if user[0] == '@' { return errors.WithStack(errors.New("take the leading @ off the user and try again")) } if _, err := keyring.Get(keyringServiceAccessToken, user); err == nil { slog.Warn("already logged in, will log in again", "user", user) } instance, err := ensureInstance(user) if err != nil { slog.Error("couldn't get user's instance", "user", user, "error", err) return err } conf := NewConfig(opts...) client := clientForInstance(instance) clientID, clientSecret, err := ensureAppCredentials(instance, client, conf) if err != nil { slog.Error("OAuth2 app setup failed", "user", user, "instance", instance, "error", err) return err } code := promptForOAuthCode(instance, clientID) accessToken, err := exchangeCodeForToken(instance, clientID, clientSecret, code) if err != nil { slog.Error("couldn't exchange OAuth2 authorization code for access token", "user", user, "instance", instance, "error", err) return err } err = keyring.Set(keyringServiceAccessToken, user, accessToken) if err != nil { slog.Error("couldn't set access token in keychain", "user", user, "instance", instance, "error", err) return err } err = SetDefaultUser(user) if err != nil { slog.Error("couldn't set default user in prefs", "user", user, "instance", instance, "error", err) return err } slog.Info("login successful", "user", user, "instance", instance) return nil } // ensureInstance finds a user's instance or retrieves a previously cached // instance for them. func ensureInstance(user string) (string, error) { if instance, err := GetUserInstance(user); err == nil { return instance, nil } instance, err := findInstance(user) if err != nil { slog.Error("WebFinger lookup failed", "user", user, "error", err) return "", err } err = SetUserInstance(user, instance) if err != nil { slog.Error("couldn't set instance in prefs", "user", user, "instance", instance, "error", err) return "", err } return instance, nil } // findInstance does a WebFinger lookup to find the domain of the instance API // for a given user. func findInstance(user string) (string, error) { webfingerClient := webfinger.NewClient(nil) jrd, err := webfingerClient.Lookup(user, nil) if err != nil { return "", err } var href string for _, link := range jrd.Links { if link.Rel == "self" && link.Type == "application/activity+json" { href = link.Href break } } if href == "" { return "", errors.New("no link with rel=\"self\" and type=\"application/activity+json\"") } url, err := neturl.Parse(href) if err != nil { return "", err } if url.Scheme != "https" || !(url.Port() == "" || url.Port() == "443") || url.Hostname() == "" { return "", errors.New("unexpected URL format") } return url.Hostname(), nil } // clientForInstance retrieves the client for a specific instance. func clientForInstance(instance string) *apiclient.GoToSocialSwaggerDocumentation { return apiclient.New(httptransport.New(instance, "", []string{"https"}), strfmt.Default) } // ensureAppCredentials retrieves or creates and stores app credentials. func ensureAppCredentials( instance string, client *apiclient.GoToSocialSwaggerDocumentation, conf *Config) (string, string, error) { shouldCreateNewApp := false clientID, err := GetInstanceClientID(instance) if clientID == "" || errors.Is(err, keyring.ErrNotFound) { shouldCreateNewApp = true } else if err != nil { slog.Error("couldn't get client ID from prefs", "instance", instance, "error", err) return "", "", err } clientSecret, err := keyring.Get(keyringServiceClientSecret, instance) if clientSecret == "" || errors.Is(err, keyring.ErrNotFound) { shouldCreateNewApp = true } else if err != nil { slog.Error("couldn't get client secret from keychain", "instance", instance, "error", err) return "", "", err } if !shouldCreateNewApp { return clientID, clientSecret, nil } app, err := createApp(client, conf) if err != nil { slog.Error("couldn't create OAuth2 app", "instance", instance, "error", err) return "", "", err } clientID = app.ClientID clientSecret = app.ClientSecret err = SetInstanceClientID(instance, clientID) if err != nil { slog.Error("couldn't set client ID in prefs", "instance", instance, "error", err) return "", "", err } err = keyring.Set(keyringServiceClientSecret, instance, clientSecret) if err != nil { slog.Error("couldn't set client secret in keychain", "instance", instance, "error", err) return "", "", err } return clientID, clientSecret, nil } // createApp registers a new OAuth2 application. func createApp( client *apiclient.GoToSocialSwaggerDocumentation, conf *Config) (*models.Application, error) { resp, err := client.Apps.AppCreate( &apps.AppCreateParams{ ClientName: conf.Name, RedirectURIs: oauthRedirect, Scopes: Ptr(oauthScopes), Website: Ptr(conf.Website), }, func(op *runtime.ClientOperation) { op.ConsumesMediaTypes = []string{"application/x-www-form-urlencoded"} }, ) if err != nil { return nil, err } return resp.GetPayload(), nil } // promptForOAuthCode prompts for an OAuth code. func promptForOAuthCode(instance string, clientID string) string { oauthAuthorizeURL := (&neturl.URL{ Scheme: "https", Host: instance, Path: "/oauth/authorize", RawQuery: neturl.Values{ "response_type": []string{"code"}, "client_id": []string{clientID}, "redirect_uri": []string{oauthRedirect}, "scope": []string{oauthScopes}, }.Encode(), }).String() err := browser.OpenURL(oauthAuthorizeURL) if err != nil { slog.Warn("couldn't open browser to authorize", "error", err) print("Please open this URL in your browser:", oauthAuthorizeURL) } print("Enter authorization code: ") scanner := bufio.NewScanner(os.Stdin) scanner.Scan() code := strings.TrimSpace(scanner.Text()) return code } type oauthTokenOK struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` CreatedAt int64 `json:"created_at"` } type oauthTokenError struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` } // exchangeCodeForToken exchanges an authorization code for an access token. func exchangeCodeForToken(instance string, clientID string, clientSecret string, code string) (string, error) { oauthTokenURL := (&neturl.URL{ Scheme: "https", Host: instance, Path: "/oauth/token", }).String() // TODO: add this to GtS Swagger doc resp, err := http.Post(oauthTokenURL, "application/x-www-form-urlencoded", strings.NewReader(neturl.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{clientID}, "client_secret": []string{clientSecret}, "redirect_uri": []string{oauthRedirect}, "scope": []string{oauthScopes}, }.Encode())) if err != nil { slog.Error("call to OAuth2 token endpoint failed", "instance", instance, "error", err) return "", err } if resp.StatusCode != http.StatusOK { var payload oauthTokenError err = json.NewDecoder(resp.Body).Decode(&payload) if err != nil { slog.Error("couldn't decode OAuth2 token endpoint error response", "instance", instance, "error", err) return "", err } return "", errors.WithStack(errors.New(payload.ErrorDescription)) } var payload oauthTokenOK err = json.NewDecoder(resp.Body).Decode(&payload) if err != nil { slog.Error("couldn't decode OAuth2 token endpoint success response", "instance", instance, "error", err) return "", err } if payload.TokenType != "Bearer" { err = errors.WithStack(errors.New("unknown access token type")) slog.Error("unexpected response from OAuth2 token endpoint", "instance", instance, "token_type", payload.TokenType) return "", err } if payload.Scope != oauthScopes { err = errors.WithStack(errors.New("scopes are not what we asked for")) slog.Error("unexpected response from OAuth2 token endpoint", "instance", instance, "scopes", payload.Scope) return "", err } return payload.AccessToken, nil }