All checks were successful
continuous-integration/drone/push Build is passing
594 lines
16 KiB
Go
594 lines
16 KiB
Go
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
|
|
}
|