gtslib-auth-keyring/gtslib_auth_keyring.go
decentral1se 25747ed033
All checks were successful
continuous-integration/drone/push Build is passing
feat: support passing client func opts
2024-08-01 22:45:36 +02:00

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
}