From 694a49058951de31cca4ea061e2c08d44e712612 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Sat, 11 Jun 2022 10:39:39 +0200
Subject: [PATCH] [feature] Add `created_at` and `error_description` to
 `/oauth/token` endpoint (#645)

* start fiddling about with oauth server

* start returning more helpful errors from oauth

* test helpful(ish) token errors

* add missing license header
---
 cmd/gotosocial/action/server/server.go     |   2 +-
 cmd/gotosocial/action/testrig/testrig.go   |   2 +-
 internal/api/client/auth/auth.go           |   5 +-
 internal/api/client/auth/auth_test.go      |  12 +-
 internal/api/client/auth/authorize.go      |   2 +-
 internal/api/client/auth/authorize_test.go |   2 +-
 internal/api/client/auth/token.go          |  69 +++++--
 internal/api/client/auth/token_test.go     | 215 +++++++++++++++++++++
 internal/api/errorhandling.go              |  27 +++
 internal/oauth/errors.go                   |  26 +++
 internal/oauth/server.go                   |  24 ++-
 internal/processing/oauth.go               |  35 ++++
 internal/processing/processor.go           |   3 +
 testrig/testmodels.go                      |  17 ++
 14 files changed, 411 insertions(+), 30 deletions(-)
 create mode 100644 internal/api/client/auth/token_test.go
 create mode 100644 internal/oauth/errors.go
 create mode 100644 internal/processing/oauth.go

diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go
index dd41f7708..abb373426 100644
--- a/cmd/gotosocial/action/server/server.go
+++ b/cmd/gotosocial/action/server/server.go
@@ -165,7 +165,7 @@ var Start action.GTSAction = func(ctx context.Context) error {
 	}
 
 	// build client api modules
-	authModule := auth.New(dbService, oauthServer, idp, processor)
+	authModule := auth.New(dbService, idp, processor)
 	accountModule := account.New(processor)
 	instanceModule := instance.New(processor)
 	appsModule := app.New(processor)
diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go
index cb0557771..8fc87de34 100644
--- a/cmd/gotosocial/action/testrig/testrig.go
+++ b/cmd/gotosocial/action/testrig/testrig.go
@@ -108,7 +108,7 @@ var Start action.GTSAction = func(ctx context.Context) error {
 	}
 
 	// build client api modules
-	authModule := auth.New(dbService, oauthServer, idp, processor)
+	authModule := auth.New(dbService, idp, processor)
 	accountModule := account.New(processor)
 	instanceModule := instance.New(processor)
 	appsModule := app.New(processor)
diff --git a/internal/api/client/auth/auth.go b/internal/api/client/auth/auth.go
index 10d374838..a097f8004 100644
--- a/internal/api/client/auth/auth.go
+++ b/internal/api/client/auth/auth.go
@@ -23,7 +23,6 @@ import (
 
 	"github.com/superseriousbusiness/gotosocial/internal/api"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"github.com/superseriousbusiness/gotosocial/internal/oidc"
 	"github.com/superseriousbusiness/gotosocial/internal/processing"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
@@ -68,16 +67,14 @@ const (
 // Module implements the ClientAPIModule interface for
 type Module struct {
 	db        db.DB
-	server    oauth.Server
 	idp       oidc.IDP
 	processor processing.Processor
 }
 
 // New returns a new auth module
-func New(db db.DB, server oauth.Server, idp oidc.IDP, processor processing.Processor) api.ClientModule {
+func New(db db.DB, idp oidc.IDP, processor processing.Processor) api.ClientModule {
 	return &Module{
 		db:        db,
-		server:    server,
 		idp:       idp,
 		processor: processor,
 	}
diff --git a/internal/api/client/auth/auth_test.go b/internal/api/client/auth/auth_test.go
index f222f714f..6a7c6ab99 100644
--- a/internal/api/client/auth/auth_test.go
+++ b/internal/api/client/auth/auth_test.go
@@ -19,6 +19,7 @@
 package auth_test
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"net/http/httptest"
@@ -99,7 +100,7 @@ func (suite *AuthStandardTestSuite) SetupTest() {
 	if err != nil {
 		panic(err)
 	}
-	suite.authModule = auth.New(suite.db, suite.oauthServer, suite.idp, suite.processor).(*auth.Module)
+	suite.authModule = auth.New(suite.db, suite.idp, suite.processor).(*auth.Module)
 	testrig.StandardDBSetup(suite.db, suite.testAccounts)
 }
 
@@ -107,7 +108,7 @@ func (suite *AuthStandardTestSuite) TearDownTest() {
 	testrig.StandardDBTeardown(suite.db)
 }
 
-func (suite *AuthStandardTestSuite) newContext(requestMethod string, requestPath string) (*gin.Context, *httptest.ResponseRecorder) {
+func (suite *AuthStandardTestSuite) newContext(requestMethod string, requestPath string, requestBody []byte, bodyContentType string) (*gin.Context, *httptest.ResponseRecorder) {
 	// create the recorder and gin test context
 	recorder := httptest.NewRecorder()
 	ctx, engine := gin.CreateTestContext(recorder)
@@ -120,9 +121,14 @@ func (suite *AuthStandardTestSuite) newContext(requestMethod string, requestPath
 	host := config.GetHost()
 	baseURI := fmt.Sprintf("%s://%s", protocol, host)
 	requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath)
-	ctx.Request = httptest.NewRequest(requestMethod, requestURI, nil) // the endpoint we're hitting
+
+	ctx.Request = httptest.NewRequest(requestMethod, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting
 	ctx.Request.Header.Set("accept", "text/html")
 
+	if bodyContentType != "" {
+		ctx.Request.Header.Set("Content-Type", bodyContentType)
+	}
+
 	// trigger the session middleware on the context
 	store := memstore.NewStore(make([]byte, 32), make([]byte, 32))
 	store.Options(router.SessionOptions())
diff --git a/internal/api/client/auth/authorize.go b/internal/api/client/auth/authorize.go
index 6f96484a8..233dacfd2 100644
--- a/internal/api/client/auth/authorize.go
+++ b/internal/api/client/auth/authorize.go
@@ -246,7 +246,7 @@ func (m *Module) AuthorizePOSTHandler(c *gin.Context) {
 		sessionUserID:       {userID},
 	}
 
-	if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil {
+	if err := m.processor.OAuthHandleAuthorizeRequest(c.Writer, c.Request); err != nil {
 		api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error(), helpfulAdvice), m.processor.InstanceGet)
 	}
 }
diff --git a/internal/api/client/auth/authorize_test.go b/internal/api/client/auth/authorize_test.go
index f9c1eceb6..35b995e70 100644
--- a/internal/api/client/auth/authorize_test.go
+++ b/internal/api/client/auth/authorize_test.go
@@ -69,7 +69,7 @@ func (suite *AuthAuthorizeTestSuite) TestAccountAuthorizeHandler() {
 	}
 
 	doTest := func(testCase authorizeHandlerTestCase) {
-		ctx, recorder := suite.newContext(http.MethodGet, auth.OauthAuthorizePath)
+		ctx, recorder := suite.newContext(http.MethodGet, auth.OauthAuthorizePath, nil, "")
 
 		user := suite.testUsers["unconfirmed_account"]
 		account := suite.testAccounts["unconfirmed_account"]
diff --git a/internal/api/client/auth/token.go b/internal/api/client/auth/token.go
index 34fb62940..fbbd08404 100644
--- a/internal/api/client/auth/token.go
+++ b/internal/api/client/auth/token.go
@@ -19,20 +19,22 @@
 package auth
 
 import (
+	"net/http"
 	"net/url"
 
 	"github.com/superseriousbusiness/gotosocial/internal/api"
 	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 
 	"github.com/gin-gonic/gin"
 )
 
-type tokenBody struct {
+type tokenRequestForm struct {
+	GrantType    *string `form:"grant_type" json:"grant_type" xml:"grant_type"`
+	Code         *string `form:"code" json:"code" xml:"code"`
+	RedirectURI  *string `form:"redirect_uri" json:"redirect_uri" xml:"redirect_uri"`
 	ClientID     *string `form:"client_id" json:"client_id" xml:"client_id"`
 	ClientSecret *string `form:"client_secret" json:"client_secret" xml:"client_secret"`
-	Code         *string `form:"code" json:"code" xml:"code"`
-	GrantType    *string `form:"grant_type" json:"grant_type" xml:"grant_type"`
-	RedirectURI  *string `form:"redirect_uri" json:"redirect_uri" xml:"redirect_uri"`
 	Scope        *string `form:"scope" json:"scope" xml:"scope"`
 }
 
@@ -44,35 +46,70 @@ func (m *Module) TokenPOSTHandler(c *gin.Context) {
 		return
 	}
 
-	form := &tokenBody{}
+	help := []string{}
+
+	form := &tokenRequestForm{}
 	if err := c.ShouldBind(form); err != nil {
-		api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, helpfulAdvice), m.processor.InstanceGet)
+		api.OAuthErrorHandler(c, gtserror.NewErrorBadRequest(oauth.InvalidRequest(), err.Error()))
 		return
 	}
 
 	c.Request.Form = url.Values{}
+
+	var grantType string
+	if form.GrantType != nil {
+		grantType = *form.GrantType
+		c.Request.Form.Set("grant_type", grantType)
+	} else {
+		help = append(help, "grant_type was not set in the token request form, but must be set to authorization_code or client_credentials")
+	}
+
 	if form.ClientID != nil {
 		c.Request.Form.Set("client_id", *form.ClientID)
+	} else {
+		help = append(help, "client_id was not set in the token request form")
 	}
+
 	if form.ClientSecret != nil {
 		c.Request.Form.Set("client_secret", *form.ClientSecret)
+	} else {
+		help = append(help, "client_secret was not set in the token request form")
 	}
-	if form.Code != nil {
-		c.Request.Form.Set("code", *form.Code)
-	}
-	if form.GrantType != nil {
-		c.Request.Form.Set("grant_type", *form.GrantType)
-	}
+
 	if form.RedirectURI != nil {
 		c.Request.Form.Set("redirect_uri", *form.RedirectURI)
+	} else {
+		help = append(help, "redirect_uri was not set in the token request form")
 	}
+
+	var code string
+	if form.Code != nil {
+		if grantType != "authorization_code" {
+			help = append(help, "a code was provided in the token request form, but grant_type was not set to authorization_code")
+		} else {
+			code = *form.Code
+			c.Request.Form.Set("code", code)
+		}
+	} else if grantType == "authorization_code" {
+		help = append(help, "code was not set in the token request form, but must be set since grant_type is authorization_code")
+	}
+
 	if form.Scope != nil {
 		c.Request.Form.Set("scope", *form.Scope)
 	}
 
-	// pass the writer and request into the oauth server handler, which will
-	// take care of writing the oauth token into the response etc
-	if err := m.server.HandleTokenRequest(c.Writer, c.Request); err != nil {
-		api.ErrorHandler(c, gtserror.NewErrorInternalError(err, helpfulAdvice), m.processor.InstanceGet)
+	if len(help) != 0 {
+		api.OAuthErrorHandler(c, gtserror.NewErrorBadRequest(oauth.InvalidRequest(), help...))
+		return
 	}
+
+	token, errWithCode := m.processor.OAuthHandleTokenRequest(c.Request)
+	if errWithCode != nil {
+		api.OAuthErrorHandler(c, errWithCode)
+		return
+	}
+
+	c.Header("Cache-Control", "no-store")
+	c.Header("Pragma", "no-cache")
+	c.JSON(http.StatusOK, token)
 }
diff --git a/internal/api/client/auth/token_test.go b/internal/api/client/auth/token_test.go
new file mode 100644
index 000000000..50bbd6918
--- /dev/null
+++ b/internal/api/client/auth/token_test.go
@@ -0,0 +1,215 @@
+/*
+   GoToSocial
+   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+   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 (
+	"context"
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/suite"
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type TokenTestSuite struct {
+	AuthStandardTestSuite
+}
+
+func (suite *TokenTestSuite) TestPOSTTokenEmptyForm() {
+	ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", []byte{}, "")
+	ctx.Request.Header.Set("accept", "application/json")
+
+	suite.authModule.TokenPOSTHandler(ctx)
+
+	suite.Equal(http.StatusBadRequest, recorder.Code)
+
+	result := recorder.Result()
+	defer result.Body.Close()
+
+	b, err := ioutil.ReadAll(result.Body)
+	suite.NoError(err)
+
+	suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: grant_type was not set in the token request form, but must be set to authorization_code or client_credentials: client_id was not set in the token request form: client_secret was not set in the token request form: redirect_uri was not set in the token request form"}`, string(b))
+}
+
+func (suite *TokenTestSuite) TestRetrieveClientCredentialsOK() {
+	testClient := suite.testClients["local_account_1"]
+
+	requestBody, w, err := testrig.CreateMultipartFormData(
+		"", "",
+		map[string]string{
+			"grant_type":    "client_credentials",
+			"client_id":     testClient.ID,
+			"client_secret": testClient.Secret,
+			"redirect_uri":  "http://localhost:8080",
+		})
+	if err != nil {
+		panic(err)
+	}
+	bodyBytes := requestBody.Bytes()
+
+	ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
+	ctx.Request.Header.Set("accept", "application/json")
+
+	suite.authModule.TokenPOSTHandler(ctx)
+
+	suite.Equal(http.StatusOK, recorder.Code)
+
+	result := recorder.Result()
+	defer result.Body.Close()
+
+	b, err := ioutil.ReadAll(result.Body)
+	suite.NoError(err)
+
+	t := &apimodel.Token{}
+	err = json.Unmarshal(b, t)
+	suite.NoError(err)
+
+	suite.Equal("Bearer", t.TokenType)
+	suite.NotEmpty(t.AccessToken)
+	suite.NotEmpty(t.CreatedAt)
+	suite.WithinDuration(time.Now(), time.Unix(t.CreatedAt, 0), 1*time.Minute)
+
+	// there should be a token in the database now too
+	dbToken := &gtsmodel.Token{}
+	err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "access", Value: t.AccessToken}}, dbToken)
+	suite.NoError(err)
+	suite.NotNil(dbToken)
+}
+
+func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeOK() {
+	testClient := suite.testClients["local_account_1"]
+	testUserAuthorizationToken := suite.testTokens["local_account_1_user_authorization_token"]
+
+	requestBody, w, err := testrig.CreateMultipartFormData(
+		"", "",
+		map[string]string{
+			"grant_type":    "authorization_code",
+			"client_id":     testClient.ID,
+			"client_secret": testClient.Secret,
+			"redirect_uri":  "http://localhost:8080",
+			"code":          testUserAuthorizationToken.Code,
+		})
+	if err != nil {
+		panic(err)
+	}
+	bodyBytes := requestBody.Bytes()
+
+	ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
+	ctx.Request.Header.Set("accept", "application/json")
+
+	suite.authModule.TokenPOSTHandler(ctx)
+
+	suite.Equal(http.StatusOK, recorder.Code)
+
+	result := recorder.Result()
+	defer result.Body.Close()
+
+	b, err := ioutil.ReadAll(result.Body)
+	suite.NoError(err)
+
+	t := &apimodel.Token{}
+	err = json.Unmarshal(b, t)
+	suite.NoError(err)
+
+	suite.Equal("Bearer", t.TokenType)
+	suite.NotEmpty(t.AccessToken)
+	suite.NotEmpty(t.CreatedAt)
+	suite.WithinDuration(time.Now(), time.Unix(t.CreatedAt, 0), 1*time.Minute)
+
+	dbToken := &gtsmodel.Token{}
+	err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "access", Value: t.AccessToken}}, dbToken)
+	suite.NoError(err)
+	suite.NotNil(dbToken)
+}
+
+func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeNoCode() {
+	testClient := suite.testClients["local_account_1"]
+
+	requestBody, w, err := testrig.CreateMultipartFormData(
+		"", "",
+		map[string]string{
+			"grant_type":    "authorization_code",
+			"client_id":     testClient.ID,
+			"client_secret": testClient.Secret,
+			"redirect_uri":  "http://localhost:8080",
+		})
+	if err != nil {
+		panic(err)
+	}
+	bodyBytes := requestBody.Bytes()
+
+	ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
+	ctx.Request.Header.Set("accept", "application/json")
+
+	suite.authModule.TokenPOSTHandler(ctx)
+
+	suite.Equal(http.StatusBadRequest, recorder.Code)
+
+	result := recorder.Result()
+	defer result.Body.Close()
+
+	b, err := ioutil.ReadAll(result.Body)
+	suite.NoError(err)
+
+	suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: code was not set in the token request form, but must be set since grant_type is authorization_code"}`, string(b))
+}
+
+func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeWrongGrantType() {
+	testClient := suite.testClients["local_account_1"]
+
+	requestBody, w, err := testrig.CreateMultipartFormData(
+		"", "",
+		map[string]string{
+			"grant_type":    "client_credentials",
+			"client_id":     testClient.ID,
+			"client_secret": testClient.Secret,
+			"redirect_uri":  "http://localhost:8080",
+			"code":          "peepeepoopoo",
+		})
+	if err != nil {
+		panic(err)
+	}
+	bodyBytes := requestBody.Bytes()
+
+	ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
+	ctx.Request.Header.Set("accept", "application/json")
+
+	suite.authModule.TokenPOSTHandler(ctx)
+
+	suite.Equal(http.StatusBadRequest, recorder.Code)
+
+	result := recorder.Result()
+	defer result.Body.Close()
+
+	b, err := ioutil.ReadAll(result.Body)
+	suite.NoError(err)
+
+	suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: a code was provided in the token request form, but grant_type was not set to authorization_code"}`, string(b))
+}
+
+func TestTokenTestSuite(t *testing.T) {
+	suite.Run(t, &TokenTestSuite{})
+}
diff --git a/internal/api/errorhandling.go b/internal/api/errorhandling.go
index 57659f83c..59b58bcc3 100644
--- a/internal/api/errorhandling.go
+++ b/internal/api/errorhandling.go
@@ -125,3 +125,30 @@ func ErrorHandler(c *gin.Context, errWithCode gtserror.WithCode, instanceGet fun
 		genericErrorHandler(c, instanceGet, accept, errWithCode)
 	}
 }
+
+// OAuthErrorHandler is a lot like ErrorHandler, but it specifically returns errors
+// that are compatible with https://datatracker.ietf.org/doc/html/rfc6749#section-5.2,
+// but serializing errWithCode.Error() in the 'error' field, and putting any help text
+// from the error in the 'error_description' field. This means you should be careful not
+// to pass any detailed errors (that might contain sensitive information) into the
+// errWithCode.Error() field, since the client will see this. Use your noggin!
+func OAuthErrorHandler(c *gin.Context, errWithCode gtserror.WithCode) {
+	l := logrus.WithFields(logrus.Fields{
+		"path":  c.Request.URL.Path,
+		"error": errWithCode.Error(),
+		"help":  errWithCode.Safe(),
+	})
+
+	statusCode := errWithCode.Code()
+
+	if statusCode == http.StatusInternalServerError {
+		l.Error("Internal Server Error")
+	} else {
+		l.Debug("handling OAuth error")
+	}
+
+	c.JSON(statusCode, gin.H{
+		"error":             errWithCode.Error(),
+		"error_description": errWithCode.Safe(),
+	})
+}
diff --git a/internal/oauth/errors.go b/internal/oauth/errors.go
new file mode 100644
index 000000000..25278bdc9
--- /dev/null
+++ b/internal/oauth/errors.go
@@ -0,0 +1,26 @@
+/*
+   GoToSocial
+   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+   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 oauth
+
+import "github.com/superseriousbusiness/oauth2/v4/errors"
+
+// InvalidRequest returns an oauth spec compliant 'invalid_request' error.
+func InvalidRequest() error {
+	return errors.New("invalid_request")
+}
diff --git a/internal/oauth/server.go b/internal/oauth/server.go
index bfe615832..4dcc41ceb 100644
--- a/internal/oauth/server.go
+++ b/internal/oauth/server.go
@@ -25,6 +25,7 @@ import (
 
 	"github.com/sirupsen/logrus"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
 	"github.com/superseriousbusiness/oauth2/v4"
 	"github.com/superseriousbusiness/oauth2/v4/errors"
 	"github.com/superseriousbusiness/oauth2/v4/manage"
@@ -52,7 +53,7 @@ const (
 
 // Server wraps some oauth2 server functions in an interface, exposing only what is needed
 type Server interface {
-	HandleTokenRequest(w http.ResponseWriter, r *http.Request) error
+	HandleTokenRequest(r *http.Request) (map[string]interface{}, gtserror.WithCode)
 	HandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) error
 	ValidationBearerToken(r *http.Request) (oauth2.TokenInfo, error)
 	GenerateUserAccessToken(ctx context.Context, ti oauth2.TokenInfo, clientSecret string, userID string) (accessToken oauth2.TokenInfo, err error)
@@ -116,8 +117,25 @@ func New(ctx context.Context, database db.Basic) Server {
 }
 
 // HandleTokenRequest wraps the oauth2 library's HandleTokenRequest function
-func (s *s) HandleTokenRequest(w http.ResponseWriter, r *http.Request) error {
-	return s.server.HandleTokenRequest(w, r)
+func (s *s) HandleTokenRequest(r *http.Request) (map[string]interface{}, gtserror.WithCode) {
+	ctx := r.Context()
+
+	gt, tgr, err := s.server.ValidationTokenRequest(r)
+	if err != nil {
+		help := fmt.Sprintf("could not validate token request: %s", err)
+		return nil, gtserror.NewErrorBadRequest(err, help)
+	}
+
+	ti, err := s.server.GetAccessToken(ctx, gt, tgr)
+	if err != nil {
+		help := fmt.Sprintf("could not get access token: %s", err)
+		return nil, gtserror.NewErrorBadRequest(err, help)
+	}
+
+	data := s.server.GetTokenData(ti)
+	data["created_at"] = ti.GetAccessCreateAt().Unix()
+
+	return data, nil
 }
 
 // HandleAuthorizeRequest wraps the oauth2 library's HandleAuthorizeRequest function
diff --git a/internal/processing/oauth.go b/internal/processing/oauth.go
new file mode 100644
index 000000000..9c974f76e
--- /dev/null
+++ b/internal/processing/oauth.go
@@ -0,0 +1,35 @@
+/*
+   GoToSocial
+   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+   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 processing
+
+import (
+	"net/http"
+
+	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
+)
+
+func (p *processor) OAuthHandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) error {
+	// todo: some kind of metrics stuff here
+	return p.oauthServer.HandleAuthorizeRequest(w, r)
+}
+
+func (p *processor) OAuthHandleTokenRequest(r *http.Request) (map[string]interface{}, gtserror.WithCode) {
+	// todo: some kind of metrics stuff here
+	return p.oauthServer.HandleTokenRequest(r)
+}
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index a7a1c22e0..3afc25196 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -152,6 +152,9 @@ type Processor interface {
 	// NotificationsGet
 	NotificationsGet(ctx context.Context, authed *oauth.Auth, limit int, maxID string, sinceID string) (*apimodel.TimelineResponse, gtserror.WithCode)
 
+	OAuthHandleTokenRequest(r *http.Request) (map[string]interface{}, gtserror.WithCode)
+	OAuthHandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) error
+
 	// SearchGet performs a search with the given params, resolving/dereferencing remotely as desired
 	SearchGet(ctx context.Context, authed *oauth.Auth, searchQuery *apimodel.SearchQuery) (*apimodel.SearchResult, gtserror.WithCode)
 
diff --git a/testrig/testmodels.go b/testrig/testmodels.go
index ee601e053..b74ada751 100644
--- a/testrig/testmodels.go
+++ b/testrig/testmodels.go
@@ -56,6 +56,23 @@ func NewTestTokens() map[string]*gtsmodel.Token {
 			AccessCreateAt:  time.Now(),
 			AccessExpiresAt: time.Now().Add(72 * time.Hour),
 		},
+		"local_account_1_client_application_token": {
+			ID:              "01P9SVWS9J3SPHZQ3KCMBEN70N",
+			ClientID:        "01F8MGV8AC3NGSJW0FE8W1BV70",
+			RedirectURI:     "http://localhost:8080",
+			Access:          "ZTK1MWMWZDGTMGMXOS0ZY2UXLWI5ZWETMWEZYZZIYTLHMZI4",
+			AccessCreateAt:  TimeMustParse("2022-06-10T15:22:08Z"),
+			AccessExpiresAt: TimeMustParse("2050-01-01T15:22:08Z"),
+		},
+		"local_account_1_user_authorization_token": {
+			ID:            "01G574M2VTV66YZBC9AZ7HY3FV",
+			ClientID:      "01F8MGV8AC3NGSJW0FE8W1BV70",
+			UserID:        "01F8MGVGPHQ2D3P3X0454H54Z5",
+			RedirectURI:   "http://localhost:8080",
+			Code:          "ZJYYMZQ0MTQTZTU1NC0ZNJK4LWE2ZWITYTM1MDHHOTAXNJHL",
+			CodeCreateAt:  TimeMustParse("2022-06-10T15:22:08Z"),
+			CodeExpiresAt: TimeMustParse("2050-01-01T15:22:08Z"),
+		},
 		"local_account_2": {
 			ID:              "01F8MGVVM1EDVYET710J27XY5R",
 			ClientID:        "01F8MGW47HN8ZXNHNZ7E47CDMQ",