[feature] Implement /oauth/revoke for token revocation (#3983)

This commit is contained in:
tobi
2025-04-10 16:24:17 +02:00
committed by GitHub
parent b1a4d54c14
commit e032c959e1
8 changed files with 522 additions and 9 deletions

View File

@ -13197,6 +13197,43 @@ paths:
summary: Returns a compliant nodeinfo response to node info queries. summary: Returns a compliant nodeinfo response to node info queries.
tags: tags:
- nodeinfo - 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: /readyz:
get: 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). 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).

View File

@ -46,6 +46,7 @@ const (
OauthFinalizePath = "/finalize" OauthFinalizePath = "/finalize"
OauthOOBTokenPath = "/oob" // #nosec G101 else we get a hardcoded credentials warning OauthOOBTokenPath = "/oob" // #nosec G101 else we get a hardcoded credentials warning
OauthTokenPath = "/token" // #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 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 // 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) { func (m *Module) RouteOAuth(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
attachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler) attachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler)
attachHandler(http.MethodPost, OauthRevokePath, m.TokenRevokePOSTHandler)
attachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler) attachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler)
attachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler) attachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler)
attachHandler(http.MethodPost, OauthFinalizePath, m.FinalizePOSTHandler) attachHandler(http.MethodPost, OauthFinalizePath, m.FinalizePOSTHandler)

View File

@ -107,28 +107,40 @@ func (suite *AuthStandardTestSuite) TearDownTest() {
testrig.StopWorkers(&suite.state) testrig.StopWorkers(&suite.state)
} }
func (suite *AuthStandardTestSuite) newContext(requestMethod string, requestPath string, requestBody []byte, bodyContentType string) (*gin.Context, *httptest.ResponseRecorder) { func (suite *AuthStandardTestSuite) newContext(
// create the recorder and gin test context requestMethod string,
requestPath string,
requestBody []byte,
bodyContentType string,
) (*gin.Context, *httptest.ResponseRecorder) {
// Create the recorder and test context.
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx, engine := testrig.CreateGinTestContext(recorder, nil) ctx, engine := testrig.CreateGinTestContext(recorder, nil)
// load templates into the engine // Load templates into the engine.
testrig.ConfigureTemplatesWithGin(engine, "../../../web/template") testrig.ConfigureTemplatesWithGin(engine, "../../../web/template")
// create the request // Create the request itself.
protocol := config.GetProtocol() protocol := config.GetProtocol()
host := config.GetHost() host := config.GetHost()
baseURI := fmt.Sprintf("%s://%s", protocol, host) baseURI := fmt.Sprintf("%s://%s", protocol, host)
requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) 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 // Transmit appropriate Content-Type.
ctx.Request.Header.Set("accept", "text/html")
if bodyContentType != "" { if bodyContentType != "" {
ctx.Request.Header.Set("Content-Type", 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 := memstore.NewStore(make([]byte, 32), make([]byte, 32))
store.Options(middleware.SessionOptions()) store.Options(middleware.SessionOptions())
sessionMiddleware := sessions.Sessions("gotosocial-localhost", store) sessionMiddleware := sessions.Sessions("gotosocial-localhost", store)

133
internal/api/auth/revoke.go Normal file
View File

@ -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{}{})
}

View File

@ -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))
}

View File

@ -24,6 +24,7 @@ import (
"net/http" "net/http"
"strings" "strings"
errorsv2 "codeberg.org/gruf/go-errors/v2"
"codeberg.org/superseriousbusiness/oauth2/v4" "codeberg.org/superseriousbusiness/oauth2/v4"
oautherr "codeberg.org/superseriousbusiness/oauth2/v4/errors" oautherr "codeberg.org/superseriousbusiness/oauth2/v4/errors"
"codeberg.org/superseriousbusiness/oauth2/v4/manage" "codeberg.org/superseriousbusiness/oauth2/v4/manage"
@ -71,6 +72,7 @@ type Server interface {
ValidationBearerToken(r *http.Request) (oauth2.TokenInfo, error) ValidationBearerToken(r *http.Request) (oauth2.TokenInfo, error)
GenerateUserAccessToken(ctx context.Context, ti oauth2.TokenInfo, clientSecret string, userID string) (accessToken oauth2.TokenInfo, err 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) 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 // 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) { func (s *s) LoadAccessToken(ctx context.Context, access string) (accessToken oauth2.TokenInfo, err error) {
return s.server.Manager.LoadAccessToken(ctx, access) 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
}

View File

@ -18,6 +18,7 @@
package processing package processing
import ( import (
"context"
"net/http" "net/http"
"codeberg.org/superseriousbusiness/oauth2/v4" "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 // todo: some kind of metrics stuff here
return p.oauthServer.ValidationBearerToken(r) 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,
)
}

View File

@ -182,7 +182,48 @@ const extended = gtsApi.injectEndpoints({
}, },
}), }),
logout: build.mutation({ 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()); api.dispatch(oauthRemove());
return { data: null }; return { data: null };
}, },