commit f867bf493e9924d22ee80d3c3f082aa3a431841a Author: decentral1se Date: Wed Jul 31 18:29:57 2024 +0200 feat: init diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..6a73401 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,8 @@ +--- +kind: pipeline +name: gtslib-auth-keyring +steps: + - name: build + image: golang:1.21 + commands: + - go build -v ./... diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1fc29a8 --- /dev/null +++ b/LICENSE @@ -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 . diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa2a1ad --- /dev/null +++ b/README.md @@ -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 + + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8366a00 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1c443d9 --- /dev/null +++ b/go.sum @@ -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= diff --git a/gtslib_auth_keyring.go b/gtslib_auth_keyring.go new file mode 100644 index 0000000..c6a43c4 --- /dev/null +++ b/gtslib_auth_keyring.go @@ -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 +}