mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-30 22:02:25 -05:00 
			
		
		
		
	[feature] Implement /oauth/revoke for token revocation
This commit is contained in:
		
					parent
					
						
							
								b1a4d54c14
							
						
					
				
			
			
				commit
				
					
						5aa85fe760
					
				
			
		
					 8 changed files with 522 additions and 9 deletions
				
			
		|  | @ -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). | ||||||
|  |  | ||||||
|  | @ -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) | ||||||
|  |  | ||||||
|  | @ -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
									
								
							
							
						
						
									
										133
									
								
								internal/api/auth/revoke.go
									
										
									
									
									
										Normal 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{}{}) | ||||||
|  | } | ||||||
							
								
								
									
										199
									
								
								internal/api/auth/revoke_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								internal/api/auth/revoke_test.go
									
										
									
									
									
										Normal 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)) | ||||||
|  | } | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  | 	) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -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 }; | ||||||
| 			}, | 			}, | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue