diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index d9e462e56..d79a607a5 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -13197,6 +13197,43 @@ paths:
summary: Returns a compliant nodeinfo response to node info queries.
tags:
- nodeinfo
+ /oauth/revoke:
+ post:
+ consumes:
+ - multipart/form-data
+ operationId: oauthTokenRevoke
+ parameters:
+ - description: The client ID, obtained during app registration.
+ in: formData
+ name: client_id
+ required: true
+ type: string
+ - description: The client secret, obtained during app registration.
+ in: formData
+ name: client_secret
+ required: true
+ type: string
+ - description: The previously obtained token, to be invalidated.
+ in: formData
+ name: token
+ required: true
+ type: string
+ produces:
+ - application/json
+ 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
+ summary: Revoke an access token to make it no longer valid for use.
+ tags:
+ - oauth
/readyz:
get:
description: If GtS is not ready, 500 Internal Error will be returned, and an error will be logged (but not returned to the caller, to avoid leaking internals).
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 .
+
+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 .
+
+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,
+ )
+}
diff --git a/web/source/settings/lib/query/login/index.ts b/web/source/settings/lib/query/login/index.ts
index e3b3b94a1..dc85e9efd 100644
--- a/web/source/settings/lib/query/login/index.ts
+++ b/web/source/settings/lib/query/login/index.ts
@@ -182,7 +182,48 @@ const extended = gtsApi.injectEndpoints({
},
}),
logout: build.mutation({
- queryFn: (_arg, api) => {
+ async queryFn(_arg, api, _extraOpts, fetchWithBQ) {
+ const state = api.getState() as RootState;
+ const loginState = state.login;
+
+ // Try to log out politely by revoking
+ // our access token. First fetch app,
+ // then token, then post to /oauth/revoke.
+
+ const app = loginState.app;
+ if (app === undefined) {
+ // This should never happen.
+ throw "trying to log out with undefined app";
+ }
+
+ let token = loginState.token;
+ if (token === undefined) {
+ // This should never happen.
+ throw "trying to log out with undefined token";
+ }
+
+ // Trim "Bearer " from stored token
+ // to get just the access token part.
+ token = token.substring(7);
+
+ // Try to revoke the token. If we fail, just
+ // log the error and clear our state anyway.
+ const invalidateResult = await fetchWithBQ({
+ method: "POST",
+ url: "/oauth/revoke",
+ body: {
+ token: token,
+ client_id: app.client_id,
+ client_secret: app.client_secret,
+ },
+ asForm: true,
+ });
+ if (invalidateResult.error) {
+ // eslint-disable-next-line no-console
+ console.error("error logging out: ", invalidateResult.error);
+ }
+
+ // Clear our state.
api.dispatch(oauthRemove());
return { data: null };
},