summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/auth/auth.go2
-rw-r--r--internal/api/auth/auth_test.go28
-rw-r--r--internal/api/auth/revoke.go133
-rw-r--r--internal/api/auth/revoke_test.go199
-rw-r--r--internal/oauth/server.go74
-rw-r--r--internal/processing/oauth.go15
6 files changed, 443 insertions, 8 deletions
diff --git a/internal/api/auth/auth.go b/internal/api/auth/auth.go
index f9dcb87ea..37c4e864a 100644
--- a/internal/api/auth/auth.go
+++ b/internal/api/auth/auth.go
@@ -46,6 +46,7 @@ const (
OauthFinalizePath = "/finalize"
OauthOOBTokenPath = "/oob" // #nosec G101 else we get a hardcoded credentials warning
OauthTokenPath = "/token" // #nosec G101 else we get a hardcoded credentials warning
+ OauthRevokePath = "/revoke"
/*
params / session keys
@@ -100,6 +101,7 @@ func (m *Module) RouteAuth(attachHandler func(method string, path string, f ...g
// RouteOAuth routes all paths that should have an 'oauth' prefix
func (m *Module) RouteOAuth(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
attachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler)
+ attachHandler(http.MethodPost, OauthRevokePath, m.TokenRevokePOSTHandler)
attachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler)
attachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler)
attachHandler(http.MethodPost, OauthFinalizePath, m.FinalizePOSTHandler)
diff --git a/internal/api/auth/auth_test.go b/internal/api/auth/auth_test.go
index 4b7ea2f5f..af90de2d6 100644
--- a/internal/api/auth/auth_test.go
+++ b/internal/api/auth/auth_test.go
@@ -107,28 +107,40 @@ func (suite *AuthStandardTestSuite) TearDownTest() {
testrig.StopWorkers(&suite.state)
}
-func (suite *AuthStandardTestSuite) newContext(requestMethod string, requestPath string, requestBody []byte, bodyContentType string) (*gin.Context, *httptest.ResponseRecorder) {
- // create the recorder and gin test context
+func (suite *AuthStandardTestSuite) newContext(
+ requestMethod string,
+ requestPath string,
+ requestBody []byte,
+ bodyContentType string,
+) (*gin.Context, *httptest.ResponseRecorder) {
+ // Create the recorder and test context.
recorder := httptest.NewRecorder()
ctx, engine := testrig.CreateGinTestContext(recorder, nil)
- // load templates into the engine
+ // Load templates into the engine.
testrig.ConfigureTemplatesWithGin(engine, "../../../web/template")
- // create the request
+ // Create the request itself.
protocol := config.GetProtocol()
host := config.GetHost()
baseURI := fmt.Sprintf("%s://%s", protocol, host)
requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath)
+ ctx.Request = httptest.NewRequest(
+ requestMethod,
+ requestURI,
+ bytes.NewReader(requestBody),
+ )
- ctx.Request = httptest.NewRequest(requestMethod, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting
- ctx.Request.Header.Set("accept", "text/html")
-
+ // Transmit appropriate Content-Type.
if bodyContentType != "" {
ctx.Request.Header.Set("Content-Type", bodyContentType)
}
- // trigger the session middleware on the context
+ // Accept whatever, so we can use
+ // this to test both HTML and JSON.
+ ctx.Request.Header.Set("accept", "*/*")
+
+ // Trigger the session middleware on the context.
store := memstore.NewStore(make([]byte, 32), make([]byte, 32))
store.Options(middleware.SessionOptions())
sessionMiddleware := sessions.Sessions("gotosocial-localhost", store)
diff --git a/internal/api/auth/revoke.go b/internal/api/auth/revoke.go
new file mode 100644
index 000000000..bb621e5e0
--- /dev/null
+++ b/internal/api/auth/revoke.go
@@ -0,0 +1,133 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 <http://www.gnu.org/licenses/>.
+
+package auth
+
+import (
+ "net/http"
+
+ oautherr "codeberg.org/superseriousbusiness/oauth2/v4/errors"
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+)
+
+// TokenRevokePOSTHandler swagger:operation POST /oauth/revoke oauthTokenRevoke
+//
+// Revoke an access token to make it no longer valid for use.
+//
+// ---
+// tags:
+// - oauth
+//
+// consumes:
+// - multipart/form-data
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: client_id
+// in: formData
+// description: The client ID, obtained during app registration.
+// type: string
+// required: true
+// -
+// name: client_secret
+// in: formData
+// description: The client secret, obtained during app registration.
+// type: string
+// required: true
+// -
+// name: token
+// in: formData
+// description: The previously obtained token, to be invalidated.
+// type: string
+// required: true
+//
+// responses:
+// '200':
+// description: >-
+// OK - If you own the provided token, the API call will provide OK and an empty response `{}`.
+// This operation is idempotent, so calling this API multiple times will still return OK.
+// '400':
+// description: bad request
+// '403':
+// description: >-
+// forbidden - If you provide a token you do not own, the API call will return a 403 error.
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) TokenRevokePOSTHandler(c *gin.Context) {
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ form := &struct {
+ ClientID string `form:"client_id" validate:"required"`
+ ClientSecret string `form:"client_secret" validate:"required"`
+ Token string `form:"token" validate:"required"`
+ }{}
+ if err := c.ShouldBind(form); err != nil {
+ errWithCode := gtserror.NewErrorBadRequest(err, err.Error())
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ if form.Token == "" {
+ errWithCode := gtserror.NewErrorBadRequest(
+ oautherr.ErrInvalidRequest,
+ "token not set",
+ )
+ apiutil.OAuthErrorHandler(c, errWithCode)
+ return
+ }
+
+ if form.ClientID == "" {
+ errWithCode := gtserror.NewErrorBadRequest(
+ oautherr.ErrInvalidRequest,
+ "client_id not set",
+ )
+ apiutil.OAuthErrorHandler(c, errWithCode)
+ return
+ }
+
+ if form.ClientSecret == "" {
+ errWithCode := gtserror.NewErrorBadRequest(
+ oautherr.ErrInvalidRequest,
+ "client_secret not set",
+ )
+ apiutil.OAuthErrorHandler(c, errWithCode)
+ return
+ }
+
+ errWithCode := m.processor.OAuthRevokeAccessToken(
+ c.Request.Context(),
+ form.ClientID,
+ form.ClientSecret,
+ form.Token,
+ )
+ if errWithCode != nil {
+ apiutil.OAuthErrorHandler(c, errWithCode)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, struct{}{})
+}
diff --git a/internal/api/auth/revoke_test.go b/internal/api/auth/revoke_test.go
new file mode 100644
index 000000000..a654ceda5
--- /dev/null
+++ b/internal/api/auth/revoke_test.go
@@ -0,0 +1,199 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 <http://www.gnu.org/licenses/>.
+
+package auth_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type RevokeTestSuite struct {
+ AuthStandardTestSuite
+}
+
+func (suite *RevokeTestSuite) TestRevokeOK() {
+ var (
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ )
+
+ // Prepare request form.
+ requestBody, w, err := testrig.CreateMultipartFormData(
+ nil,
+ map[string][]string{
+ "token": {token.Access},
+ "client_id": {app.ClientID},
+ "client_secret": {app.ClientSecret},
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ // Prepare request ctx.
+ ctx, recorder := suite.newContext(
+ http.MethodPost,
+ "/oauth/revoke",
+ requestBody.Bytes(),
+ w.FormDataContentType(),
+ )
+
+ // Submit the revoke request.
+ suite.authModule.TokenRevokePOSTHandler(ctx)
+
+ // Check response code.
+ // We don't really care about body.
+ suite.Equal(http.StatusOK, recorder.Code)
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ // Ensure token now gone.
+ _, err = suite.state.DB.GetTokenByAccess(
+ context.Background(),
+ token.Access,
+ )
+ suite.ErrorIs(err, db.ErrNoEntries)
+}
+
+func (suite *RevokeTestSuite) TestRevokeWrongSecret() {
+ var (
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ )
+
+ // Prepare request form.
+ requestBody, w, err := testrig.CreateMultipartFormData(
+ nil,
+ map[string][]string{
+ "token": {token.Access},
+ "client_id": {app.ClientID},
+ "client_secret": {"Not the right secret :( :( :("},
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ // Prepare request ctx.
+ ctx, recorder := suite.newContext(
+ http.MethodPost,
+ "/oauth/revoke",
+ requestBody.Bytes(),
+ w.FormDataContentType(),
+ )
+
+ // Submit the revoke request.
+ suite.authModule.TokenRevokePOSTHandler(ctx)
+
+ // Check response code + body.
+ suite.Equal(http.StatusForbidden, recorder.Code)
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ // Read json bytes.
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Indent nicely.
+ dst := bytes.Buffer{}
+ if err := json.Indent(&dst, b, "", " "); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(`{
+ "error": "unauthorized_client",
+ "error_description": "Forbidden: You are not authorized to revoke this token"
+}`, dst.String())
+
+ // Ensure token still there.
+ _, err = suite.state.DB.GetTokenByAccess(
+ context.Background(),
+ token.Access,
+ )
+ suite.NoError(err)
+}
+
+func (suite *RevokeTestSuite) TestRevokeNoClientID() {
+ var (
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ )
+
+ // Prepare request form.
+ requestBody, w, err := testrig.CreateMultipartFormData(
+ nil,
+ map[string][]string{
+ "token": {token.Access},
+ "client_secret": {app.ClientSecret},
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ // Prepare request ctx.
+ ctx, recorder := suite.newContext(
+ http.MethodPost,
+ "/oauth/revoke",
+ requestBody.Bytes(),
+ w.FormDataContentType(),
+ )
+
+ // Submit the revoke request.
+ suite.authModule.TokenRevokePOSTHandler(ctx)
+
+ // Check response code + body.
+ suite.Equal(http.StatusBadRequest, recorder.Code)
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ // Read json bytes.
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Indent nicely.
+ dst := bytes.Buffer{}
+ if err := json.Indent(&dst, b, "", " "); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(`{
+ "error": "invalid_request",
+ "error_description": "Bad Request: client_id not set"
+}`, dst.String())
+
+ // Ensure token still there.
+ _, err = suite.state.DB.GetTokenByAccess(
+ context.Background(),
+ token.Access,
+ )
+ suite.NoError(err)
+}
+
+func TestRevokeTestSuite(t *testing.T) {
+ suite.Run(t, new(RevokeTestSuite))
+}
diff --git a/internal/oauth/server.go b/internal/oauth/server.go
index c0c3c329c..0bc7a3b01 100644
--- a/internal/oauth/server.go
+++ b/internal/oauth/server.go
@@ -24,6 +24,7 @@ import (
"net/http"
"strings"
+ errorsv2 "codeberg.org/gruf/go-errors/v2"
"codeberg.org/superseriousbusiness/oauth2/v4"
oautherr "codeberg.org/superseriousbusiness/oauth2/v4/errors"
"codeberg.org/superseriousbusiness/oauth2/v4/manage"
@@ -71,6 +72,7 @@ type Server interface {
ValidationBearerToken(r *http.Request) (oauth2.TokenInfo, error)
GenerateUserAccessToken(ctx context.Context, ti oauth2.TokenInfo, clientSecret string, userID string) (accessToken oauth2.TokenInfo, err error)
LoadAccessToken(ctx context.Context, access string) (accessToken oauth2.TokenInfo, err error)
+ RevokeAccessToken(ctx context.Context, clientID string, clientSecret string, access string) gtserror.WithCode
}
// s fulfils the Server interface
@@ -338,3 +340,75 @@ func (s *s) GenerateUserAccessToken(ctx context.Context, ti oauth2.TokenInfo, cl
func (s *s) LoadAccessToken(ctx context.Context, access string) (accessToken oauth2.TokenInfo, err error) {
return s.server.Manager.LoadAccessToken(ctx, access)
}
+
+func (s *s) RevokeAccessToken(
+ ctx context.Context,
+ clientID string,
+ clientSecret string,
+ access string,
+) gtserror.WithCode {
+ token, err := s.server.Manager.LoadAccessToken(ctx, access)
+ switch {
+ case err == nil:
+ // Got the token, can
+ // proceed to invalidate.
+
+ case errorsv2.IsV2(
+ err,
+ db.ErrNoEntries,
+ oautherr.ErrExpiredAccessToken,
+ ):
+ // Token already deleted, expired,
+ // or doesn't exist, nothing to do.
+ return nil
+
+ default:
+ // Real error.
+ log.Errorf(ctx, "db error loading access token: %v", err)
+ return gtserror.NewErrorInternalError(
+ oautherr.ErrServerError,
+ "db error loading access token, check logs",
+ )
+ }
+
+ // Ensure token's client ID matches provided client ID.
+ if token.GetClientID() != clientID {
+ log.Debug(ctx, "client id of token does not match provided client_id")
+ return gtserror.NewErrorForbidden(
+ oautherr.ErrUnauthorizedClient,
+ "You are not authorized to revoke this token",
+ )
+ }
+
+ // Get client from the db using provided client ID.
+ client, err := s.server.Manager.GetClient(ctx, clientID)
+ if err != nil {
+ log.Errorf(ctx, "db error loading client: %v", err)
+ return gtserror.NewErrorInternalError(
+ oautherr.ErrServerError,
+ "db error loading client, check logs",
+ )
+ }
+
+ // Ensure requester also knows the client secret,
+ // which confirms that they indeed created the client.
+ if client.GetSecret() != clientSecret {
+ log.Debug(ctx, "secret of client does not match provided client_secret")
+ return gtserror.NewErrorForbidden(
+ oautherr.ErrUnauthorizedClient,
+ "You are not authorized to revoke this token",
+ )
+ }
+
+ // All good, invalidate the token.
+ err = s.server.Manager.RemoveAccessToken(ctx, access)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ log.Errorf(ctx, "db error removing access token: %v", err)
+ return gtserror.NewErrorInternalError(
+ oautherr.ErrServerError,
+ "db error removing access token, check logs",
+ )
+ }
+
+ return nil
+}
diff --git a/internal/processing/oauth.go b/internal/processing/oauth.go
index 6cd7e00cf..d597a6dc6 100644
--- a/internal/processing/oauth.go
+++ b/internal/processing/oauth.go
@@ -18,6 +18,7 @@
package processing
import (
+ "context"
"net/http"
"codeberg.org/superseriousbusiness/oauth2/v4"
@@ -38,3 +39,17 @@ func (p *Processor) OAuthValidateBearerToken(r *http.Request) (oauth2.TokenInfo,
// todo: some kind of metrics stuff here
return p.oauthServer.ValidationBearerToken(r)
}
+
+func (p *Processor) OAuthRevokeAccessToken(
+ ctx context.Context,
+ clientID string,
+ clientSecret string,
+ accessToken string,
+) gtserror.WithCode {
+ return p.oauthServer.RevokeAccessToken(
+ ctx,
+ clientID,
+ clientSecret,
+ accessToken,
+ )
+}