feat: init
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
decentral1se 2024-07-31 18:29:57 +02:00
commit f867bf493e
Signed by: decentral1se
GPG Key ID: 03789458B3D0C410
6 changed files with 751 additions and 0 deletions

8
.drone.yml Normal file
View File

@ -0,0 +1,8 @@
---
kind: pipeline
name: gtslib-auth-keyring
steps:
- name: build
image: golang:1.21
commands:
- go build -v ./...

15
LICENSE Normal file
View File

@ -0,0 +1,15 @@
gtslib-auth-keyring: GoToSocial client auth with keyring support
Copyright (C) 2024 decentral1se
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
You should have received a copy of the GNU Affero General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>.

32
README.md Normal file
View File

@ -0,0 +1,32 @@
# gtslib-auth-keyring
[![Build Status](https://build.coopcloud.tech/api/badges/decentral1se/gtslib-auth-keyring/status.svg?ref=refs/heads/main)](https://build.coopcloud.tech/decentral1se/gtslib-auth-keyring)
> [GoToSocial](https://gotosocial.org) client auth with keyring support
**ALPHA SOFTWARE** just like GoToSocial. Uses [`go-keyring`](https://github.com/zalando/go-keyring).
```go
import (
"log"
auth "git.coopcloud.tech/decentral1se/gtslib-auth-keyring"
)
func main() {
if err := auth.Login("foo@bar.zone"); err != nil {
log.Fatal(err)
}
}
```
## ACK
Made possible by the good work of [`slurp`](https://github.com/VyrCossont/slurp).
## License
<a href="https://git.coopcloud.tech/decentral1se/gtslib-auth-keyring/src/branch/main/LICENSE">
<img src="https://www.gnu.org/graphics/agplv3-with-text-162x68.png" />
</a>

45
go.mod Normal file
View File

@ -0,0 +1,45 @@
module git.coopcloud.tech/decentral1se/gtslib-auth-keyring
go 1.21.1
require (
git.coopcloud.tech/decentral1se/gtslib v0.0.0-20240731154340-f0c5a903cfbb
github.com/adrg/xdg v0.5.0
github.com/go-openapi/runtime v0.28.0
github.com/go-openapi/strfmt v0.23.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pkg/errors v0.9.1
github.com/zalando/go-keyring v0.2.5
golang.org/x/time v0.5.0
webfinger.net/go/webfinger v0.1.0
)
require (
github.com/alessio/shellescape v1.4.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect
github.com/go-openapi/errors v0.22.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/loads v0.22.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/validate v0.24.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

99
go.sum Normal file
View File

@ -0,0 +1,99 @@
git.coopcloud.tech/decentral1se/gtslib v0.0.0-20240731154340-f0c5a903cfbb h1:3wXXHqJENmkn/4B0fK8PZ2vjOMTC5K0Fp1Ca5sKROHQ=
git.coopcloud.tech/decentral1se/gtslib v0.0.0-20240731154340-f0c5a903cfbb/go.mod h1:isFkvbxjltfWUlzdVETbupmKLZvDf5XviBkgd2/+A5M=
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w=
github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco=
github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs=
github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ=
github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
webfinger.net/go/webfinger v0.1.0 h1:e/J18UgjFE8+ZbKxzKm4+gv4ehidNnF6hcbHwS3K63U=
webfinger.net/go/webfinger v0.1.0/go.mod h1:+najbdnIKfnKo68tU2TF+AXm8/MOqLYXqx22j8Xw7FM=

552
gtslib_auth_keyring.go Normal file
View File

@ -0,0 +1,552 @@
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 }
// 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) 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
}
client := clientForInstance(instance)
clientID, clientSecret, err := ensureAppCredentials(instance, client)
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) (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)
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) (*models.Application, error) {
resp, err := client.Apps.AppCreate(
&apps.AppCreateParams{
ClientName: "gtslib",
RedirectURIs: oauthRedirect,
Scopes: Ptr(oauthScopes),
Website: Ptr("https://foo.com/gtslib"),
},
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
}