diff --git a/routers/init.go b/routers/init.go
index fe80dfd2cdd85..2091f5967acac 100644
--- a/routers/init.go
+++ b/routers/init.go
@@ -47,6 +47,7 @@ import (
 	markup_service "code.gitea.io/gitea/services/markup"
 	repo_migrations "code.gitea.io/gitea/services/migrations"
 	mirror_service "code.gitea.io/gitea/services/mirror"
+	"code.gitea.io/gitea/services/oauth2_provider"
 	pull_service "code.gitea.io/gitea/services/pull"
 	release_service "code.gitea.io/gitea/services/release"
 	repo_service "code.gitea.io/gitea/services/repository"
@@ -144,7 +145,7 @@ func InitWebInstalled(ctx context.Context) {
 	log.Info("ORM engine initialization successful!")
 	mustInit(system.Init)
 	mustInitCtx(ctx, oauth2.Init)
-
+	mustInitCtx(ctx, oauth2_provider.Init)
 	mustInit(release_service.Init)
 
 	mustInitCtx(ctx, models.Init)
diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go
index c61a0a6240432..ccbb3bebf1f5c 100644
--- a/routers/web/auth/oauth.go
+++ b/routers/web/auth/oauth.go
@@ -4,878 +4,34 @@
 package auth
 
 import (
-	go_context "context"
 	"errors"
 	"fmt"
 	"html"
-	"html/template"
 	"io"
 	"net/http"
-	"net/url"
 	"sort"
 	"strings"
 
 	"code.gitea.io/gitea/models/auth"
-	org_model "code.gitea.io/gitea/models/organization"
 	user_model "code.gitea.io/gitea/models/user"
 	auth_module "code.gitea.io/gitea/modules/auth"
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/container"
-	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/optional"
 	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/timeutil"
-	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/modules/web/middleware"
-	auth_service "code.gitea.io/gitea/services/auth"
 	source_service "code.gitea.io/gitea/services/auth/source"
 	"code.gitea.io/gitea/services/auth/source/oauth2"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/externalaccount"
-	"code.gitea.io/gitea/services/forms"
 	user_service "code.gitea.io/gitea/services/user"
 
-	"gitea.com/go-chi/binding"
-	"github.com/golang-jwt/jwt/v5"
 	"github.com/markbates/goth"
 	"github.com/markbates/goth/gothic"
 	go_oauth2 "golang.org/x/oauth2"
 )
 
-const (
-	tplGrantAccess base.TplName = "user/auth/grant"
-	tplGrantError  base.TplName = "user/auth/grant_error"
-)
-
-// TODO move error and responses to SDK or models
-
-// AuthorizeErrorCode represents an error code specified in RFC 6749
-// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
-type AuthorizeErrorCode string
-
-const (
-	// ErrorCodeInvalidRequest represents the according error in RFC 6749
-	ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
-	// ErrorCodeUnauthorizedClient represents the according error in RFC 6749
-	ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
-	// ErrorCodeAccessDenied represents the according error in RFC 6749
-	ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
-	// ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
-	ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
-	// ErrorCodeInvalidScope represents the according error in RFC 6749
-	ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
-	// ErrorCodeServerError represents the according error in RFC 6749
-	ErrorCodeServerError AuthorizeErrorCode = "server_error"
-	// ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
-	ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
-)
-
-// AuthorizeError represents an error type specified in RFC 6749
-// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
-type AuthorizeError struct {
-	ErrorCode        AuthorizeErrorCode `json:"error" form:"error"`
-	ErrorDescription string
-	State            string
-}
-
-// Error returns the error message
-func (err AuthorizeError) Error() string {
-	return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
-}
-
-// AccessTokenErrorCode represents an error code specified in RFC 6749
-// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
-type AccessTokenErrorCode string
-
-const (
-	// AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
-	AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
-	// AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
-	AccessTokenErrorCodeInvalidClient = "invalid_client"
-	// AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
-	AccessTokenErrorCodeInvalidGrant = "invalid_grant"
-	// AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
-	AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
-	// AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
-	AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
-	// AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
-	AccessTokenErrorCodeInvalidScope = "invalid_scope"
-)
-
-// AccessTokenError represents an error response specified in RFC 6749
-// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
-type AccessTokenError struct {
-	ErrorCode        AccessTokenErrorCode `json:"error" form:"error"`
-	ErrorDescription string               `json:"error_description"`
-}
-
-// Error returns the error message
-func (err AccessTokenError) Error() string {
-	return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
-}
-
-// errCallback represents a oauth2 callback error
-type errCallback struct {
-	Code        string
-	Description string
-}
-
-func (err errCallback) Error() string {
-	return err.Description
-}
-
-// TokenType specifies the kind of token
-type TokenType string
-
-const (
-	// TokenTypeBearer represents a token type specified in RFC 6749
-	TokenTypeBearer TokenType = "bearer"
-	// TokenTypeMAC represents a token type specified in RFC 6749
-	TokenTypeMAC = "mac"
-)
-
-// AccessTokenResponse represents a successful access token response
-// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
-type AccessTokenResponse struct {
-	AccessToken  string    `json:"access_token"`
-	TokenType    TokenType `json:"token_type"`
-	ExpiresIn    int64     `json:"expires_in"`
-	RefreshToken string    `json:"refresh_token"`
-	IDToken      string    `json:"id_token,omitempty"`
-}
-
-func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
-	if setting.OAuth2.InvalidateRefreshTokens {
-		if err := grant.IncreaseCounter(ctx); err != nil {
-			return nil, &AccessTokenError{
-				ErrorCode:        AccessTokenErrorCodeInvalidGrant,
-				ErrorDescription: "cannot increase the grant counter",
-			}
-		}
-	}
-	// generate access token to access the API
-	expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime)
-	accessToken := &oauth2.Token{
-		GrantID: grant.ID,
-		Type:    oauth2.TypeAccessToken,
-		RegisteredClaims: jwt.RegisteredClaims{
-			ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
-		},
-	}
-	signedAccessToken, err := accessToken.SignToken(serverKey)
-	if err != nil {
-		return nil, &AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-			ErrorDescription: "cannot sign token",
-		}
-	}
-
-	// generate refresh token to request an access token after it expired later
-	refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime()
-	refreshToken := &oauth2.Token{
-		GrantID: grant.ID,
-		Counter: grant.Counter,
-		Type:    oauth2.TypeRefreshToken,
-		RegisteredClaims: jwt.RegisteredClaims{
-			ExpiresAt: jwt.NewNumericDate(refreshExpirationDate),
-		},
-	}
-	signedRefreshToken, err := refreshToken.SignToken(serverKey)
-	if err != nil {
-		return nil, &AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-			ErrorDescription: "cannot sign token",
-		}
-	}
-
-	// generate OpenID Connect id_token
-	signedIDToken := ""
-	if grant.ScopeContains("openid") {
-		app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
-		if err != nil {
-			return nil, &AccessTokenError{
-				ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-				ErrorDescription: "cannot find application",
-			}
-		}
-		user, err := user_model.GetUserByID(ctx, grant.UserID)
-		if err != nil {
-			if user_model.IsErrUserNotExist(err) {
-				return nil, &AccessTokenError{
-					ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-					ErrorDescription: "cannot find user",
-				}
-			}
-			log.Error("Error loading user: %v", err)
-			return nil, &AccessTokenError{
-				ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-				ErrorDescription: "server error",
-			}
-		}
-
-		idToken := &oauth2.OIDCToken{
-			RegisteredClaims: jwt.RegisteredClaims{
-				ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
-				Issuer:    setting.AppURL,
-				Audience:  []string{app.ClientID},
-				Subject:   fmt.Sprint(grant.UserID),
-			},
-			Nonce: grant.Nonce,
-		}
-		if grant.ScopeContains("profile") {
-			idToken.Name = user.GetDisplayName()
-			idToken.PreferredUsername = user.Name
-			idToken.Profile = user.HTMLURL()
-			idToken.Picture = user.AvatarLink(ctx)
-			idToken.Website = user.Website
-			idToken.Locale = user.Language
-			idToken.UpdatedAt = user.UpdatedUnix
-		}
-		if grant.ScopeContains("email") {
-			idToken.Email = user.Email
-			idToken.EmailVerified = user.IsActive
-		}
-		if grant.ScopeContains("groups") {
-			groups, err := getOAuthGroupsForUser(ctx, user)
-			if err != nil {
-				log.Error("Error getting groups: %v", err)
-				return nil, &AccessTokenError{
-					ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-					ErrorDescription: "server error",
-				}
-			}
-			idToken.Groups = groups
-		}
-
-		signedIDToken, err = idToken.SignToken(clientKey)
-		if err != nil {
-			return nil, &AccessTokenError{
-				ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-				ErrorDescription: "cannot sign token",
-			}
-		}
-	}
-
-	return &AccessTokenResponse{
-		AccessToken:  signedAccessToken,
-		TokenType:    TokenTypeBearer,
-		ExpiresIn:    setting.OAuth2.AccessTokenExpirationTime,
-		RefreshToken: signedRefreshToken,
-		IDToken:      signedIDToken,
-	}, nil
-}
-
-type userInfoResponse struct {
-	Sub      string   `json:"sub"`
-	Name     string   `json:"name"`
-	Username string   `json:"preferred_username"`
-	Email    string   `json:"email"`
-	Picture  string   `json:"picture"`
-	Groups   []string `json:"groups"`
-}
-
-// InfoOAuth manages request for userinfo endpoint
-func InfoOAuth(ctx *context.Context) {
-	if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() {
-		ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`)
-		ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
-		return
-	}
-
-	response := &userInfoResponse{
-		Sub:      fmt.Sprint(ctx.Doer.ID),
-		Name:     ctx.Doer.FullName,
-		Username: ctx.Doer.Name,
-		Email:    ctx.Doer.Email,
-		Picture:  ctx.Doer.AvatarLink(ctx),
-	}
-
-	groups, err := getOAuthGroupsForUser(ctx, ctx.Doer)
-	if err != nil {
-		ctx.ServerError("Oauth groups for user", err)
-		return
-	}
-	response.Groups = groups
-
-	ctx.JSON(http.StatusOK, response)
-}
-
-// returns a list of "org" and "org:team" strings,
-// that the given user is a part of.
-func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User) ([]string, error) {
-	orgs, err := org_model.GetUserOrgsList(ctx, user)
-	if err != nil {
-		return nil, fmt.Errorf("GetUserOrgList: %w", err)
-	}
-
-	var groups []string
-	for _, org := range orgs {
-		groups = append(groups, org.Name)
-		teams, err := org.LoadTeams(ctx)
-		if err != nil {
-			return nil, fmt.Errorf("LoadTeams: %w", err)
-		}
-		for _, team := range teams {
-			if team.IsMember(ctx, user.ID) {
-				groups = append(groups, org.Name+":"+team.LowerName)
-			}
-		}
-	}
-	return groups, nil
-}
-
-func parseBasicAuth(ctx *context.Context) (username, password string, err error) {
-	authHeader := ctx.Req.Header.Get("Authorization")
-	if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
-		return base.BasicAuthDecode(authData)
-	}
-	return "", "", errors.New("invalid basic authentication")
-}
-
-// IntrospectOAuth introspects an oauth token
-func IntrospectOAuth(ctx *context.Context) {
-	clientIDValid := false
-	if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil {
-		app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
-		if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
-			// this is likely a database error; log it and respond without details
-			log.Error("Error retrieving client_id: %v", err)
-			ctx.Error(http.StatusInternalServerError)
-			return
-		}
-		clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
-	}
-	if !clientIDValid {
-		ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`)
-		ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
-		return
-	}
-
-	var response struct {
-		Active   bool   `json:"active"`
-		Scope    string `json:"scope,omitempty"`
-		Username string `json:"username,omitempty"`
-		jwt.RegisteredClaims
-	}
-
-	form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
-	token, err := oauth2.ParseToken(form.Token, oauth2.DefaultSigningKey)
-	if err == nil {
-		grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
-		if err == nil && grant != nil {
-			app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
-			if err == nil && app != nil {
-				response.Active = true
-				response.Scope = grant.Scope
-				response.Issuer = setting.AppURL
-				response.Audience = []string{app.ClientID}
-				response.Subject = fmt.Sprint(grant.UserID)
-			}
-			if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil {
-				response.Username = user.Name
-			}
-		}
-	}
-
-	ctx.JSON(http.StatusOK, response)
-}
-
-// AuthorizeOAuth manages authorize requests
-func AuthorizeOAuth(ctx *context.Context) {
-	form := web.GetForm(ctx).(*forms.AuthorizationForm)
-	errs := binding.Errors{}
-	errs = form.Validate(ctx.Req, errs)
-	if len(errs) > 0 {
-		errstring := ""
-		for _, e := range errs {
-			errstring += e.Error() + "\n"
-		}
-		ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring))
-		return
-	}
-
-	app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
-	if err != nil {
-		if auth.IsErrOauthClientIDInvalid(err) {
-			handleAuthorizeError(ctx, AuthorizeError{
-				ErrorCode:        ErrorCodeUnauthorizedClient,
-				ErrorDescription: "Client ID not registered",
-				State:            form.State,
-			}, "")
-			return
-		}
-		ctx.ServerError("GetOAuth2ApplicationByClientID", err)
-		return
-	}
-
-	var user *user_model.User
-	if app.UID != 0 {
-		user, err = user_model.GetUserByID(ctx, app.UID)
-		if err != nil {
-			ctx.ServerError("GetUserByID", err)
-			return
-		}
-	}
-
-	if !app.ContainsRedirectURI(form.RedirectURI) {
-		handleAuthorizeError(ctx, AuthorizeError{
-			ErrorCode:        ErrorCodeInvalidRequest,
-			ErrorDescription: "Unregistered Redirect URI",
-			State:            form.State,
-		}, "")
-		return
-	}
-
-	if form.ResponseType != "code" {
-		handleAuthorizeError(ctx, AuthorizeError{
-			ErrorCode:        ErrorCodeUnsupportedResponseType,
-			ErrorDescription: "Only code response type is supported.",
-			State:            form.State,
-		}, form.RedirectURI)
-		return
-	}
-
-	// pkce support
-	switch form.CodeChallengeMethod {
-	case "S256":
-	case "plain":
-		if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
-			handleAuthorizeError(ctx, AuthorizeError{
-				ErrorCode:        ErrorCodeServerError,
-				ErrorDescription: "cannot set code challenge method",
-				State:            form.State,
-			}, form.RedirectURI)
-			return
-		}
-		if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil {
-			handleAuthorizeError(ctx, AuthorizeError{
-				ErrorCode:        ErrorCodeServerError,
-				ErrorDescription: "cannot set code challenge",
-				State:            form.State,
-			}, form.RedirectURI)
-			return
-		}
-		// Here we're just going to try to release the session early
-		if err := ctx.Session.Release(); err != nil {
-			// we'll tolerate errors here as they *should* get saved elsewhere
-			log.Error("Unable to save changes to the session: %v", err)
-		}
-	case "":
-		// "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
-		// https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
-		if !app.ConfidentialClient {
-			// "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
-			// https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
-			handleAuthorizeError(ctx, AuthorizeError{
-				ErrorCode:        ErrorCodeInvalidRequest,
-				ErrorDescription: "PKCE is required for public clients",
-				State:            form.State,
-			}, form.RedirectURI)
-			return
-		}
-	default:
-		// "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
-		// https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
-		handleAuthorizeError(ctx, AuthorizeError{
-			ErrorCode:        ErrorCodeInvalidRequest,
-			ErrorDescription: "unsupported code challenge method",
-			State:            form.State,
-		}, form.RedirectURI)
-		return
-	}
-
-	grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
-	if err != nil {
-		handleServerError(ctx, form.State, form.RedirectURI)
-		return
-	}
-
-	// Redirect if user already granted access and the application is confidential or trusted otherwise
-	// I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2
-	if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil {
-		code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
-		if err != nil {
-			handleServerError(ctx, form.State, form.RedirectURI)
-			return
-		}
-		redirect, err := code.GenerateRedirectURI(form.State)
-		if err != nil {
-			handleServerError(ctx, form.State, form.RedirectURI)
-			return
-		}
-		// Update nonce to reflect the new session
-		if len(form.Nonce) > 0 {
-			err := grant.SetNonce(ctx, form.Nonce)
-			if err != nil {
-				log.Error("Unable to update nonce: %v", err)
-			}
-		}
-		ctx.Redirect(redirect.String())
-		return
-	}
-
-	// show authorize page to grant access
-	ctx.Data["Application"] = app
-	ctx.Data["RedirectURI"] = form.RedirectURI
-	ctx.Data["State"] = form.State
-	ctx.Data["Scope"] = form.Scope
-	ctx.Data["Nonce"] = form.Nonce
-	if user != nil {
-		ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`@%s`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)))
-	} else {
-		ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`%s`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)))
-	}
-	ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("" + html.EscapeString(form.RedirectURI) + "")
-	// TODO document SESSION <=> FORM
-	err = ctx.Session.Set("client_id", app.ClientID)
-	if err != nil {
-		handleServerError(ctx, form.State, form.RedirectURI)
-		log.Error(err.Error())
-		return
-	}
-	err = ctx.Session.Set("redirect_uri", form.RedirectURI)
-	if err != nil {
-		handleServerError(ctx, form.State, form.RedirectURI)
-		log.Error(err.Error())
-		return
-	}
-	err = ctx.Session.Set("state", form.State)
-	if err != nil {
-		handleServerError(ctx, form.State, form.RedirectURI)
-		log.Error(err.Error())
-		return
-	}
-	// Here we're just going to try to release the session early
-	if err := ctx.Session.Release(); err != nil {
-		// we'll tolerate errors here as they *should* get saved elsewhere
-		log.Error("Unable to save changes to the session: %v", err)
-	}
-	ctx.HTML(http.StatusOK, tplGrantAccess)
-}
-
-// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
-func GrantApplicationOAuth(ctx *context.Context) {
-	form := web.GetForm(ctx).(*forms.GrantApplicationForm)
-	if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
-		ctx.Session.Get("redirect_uri") != form.RedirectURI {
-		ctx.Error(http.StatusBadRequest)
-		return
-	}
-
-	if !form.Granted {
-		handleAuthorizeError(ctx, AuthorizeError{
-			State:            form.State,
-			ErrorDescription: "the request is denied",
-			ErrorCode:        ErrorCodeAccessDenied,
-		}, form.RedirectURI)
-		return
-	}
-
-	app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
-	if err != nil {
-		ctx.ServerError("GetOAuth2ApplicationByClientID", err)
-		return
-	}
-	grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
-	if err != nil {
-		handleServerError(ctx, form.State, form.RedirectURI)
-		return
-	}
-	if grant == nil {
-		grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope)
-		if err != nil {
-			handleAuthorizeError(ctx, AuthorizeError{
-				State:            form.State,
-				ErrorDescription: "cannot create grant for user",
-				ErrorCode:        ErrorCodeServerError,
-			}, form.RedirectURI)
-			return
-		}
-	} else if grant.Scope != form.Scope {
-		handleAuthorizeError(ctx, AuthorizeError{
-			State:            form.State,
-			ErrorDescription: "a grant exists with different scope",
-			ErrorCode:        ErrorCodeServerError,
-		}, form.RedirectURI)
-		return
-	}
-
-	if len(form.Nonce) > 0 {
-		err := grant.SetNonce(ctx, form.Nonce)
-		if err != nil {
-			log.Error("Unable to update nonce: %v", err)
-		}
-	}
-
-	var codeChallenge, codeChallengeMethod string
-	codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string)
-	codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string)
-
-	code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod)
-	if err != nil {
-		handleServerError(ctx, form.State, form.RedirectURI)
-		return
-	}
-	redirect, err := code.GenerateRedirectURI(form.State)
-	if err != nil {
-		handleServerError(ctx, form.State, form.RedirectURI)
-		return
-	}
-	ctx.Redirect(redirect.String(), http.StatusSeeOther)
-}
-
-// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
-func OIDCWellKnown(ctx *context.Context) {
-	ctx.Data["SigningKey"] = oauth2.DefaultSigningKey
-	ctx.JSONTemplate("user/auth/oidc_wellknown")
-}
-
-// OIDCKeys generates the JSON Web Key Set
-func OIDCKeys(ctx *context.Context) {
-	jwk, err := oauth2.DefaultSigningKey.ToJWK()
-	if err != nil {
-		log.Error("Error converting signing key to JWK: %v", err)
-		ctx.Error(http.StatusInternalServerError)
-		return
-	}
-
-	jwk["use"] = "sig"
-
-	jwks := map[string][]map[string]string{
-		"keys": {
-			jwk,
-		},
-	}
-
-	ctx.Resp.Header().Set("Content-Type", "application/json")
-	enc := json.NewEncoder(ctx.Resp)
-	if err := enc.Encode(jwks); err != nil {
-		log.Error("Failed to encode representation as json. Error: %v", err)
-	}
-}
-
-// AccessTokenOAuth manages all access token requests by the client
-func AccessTokenOAuth(ctx *context.Context) {
-	form := *web.GetForm(ctx).(*forms.AccessTokenForm)
-	// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
-	if form.ClientID == "" || form.ClientSecret == "" {
-		authHeader := ctx.Req.Header.Get("Authorization")
-		if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
-			clientID, clientSecret, err := base.BasicAuthDecode(authData)
-			if err != nil {
-				handleAccessTokenError(ctx, AccessTokenError{
-					ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-					ErrorDescription: "cannot parse basic auth header",
-				})
-				return
-			}
-			// validate that any fields present in the form match the Basic auth header
-			if form.ClientID != "" && form.ClientID != clientID {
-				handleAccessTokenError(ctx, AccessTokenError{
-					ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-					ErrorDescription: "client_id in request body inconsistent with Authorization header",
-				})
-				return
-			}
-			form.ClientID = clientID
-			if form.ClientSecret != "" && form.ClientSecret != clientSecret {
-				handleAccessTokenError(ctx, AccessTokenError{
-					ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-					ErrorDescription: "client_secret in request body inconsistent with Authorization header",
-				})
-				return
-			}
-			form.ClientSecret = clientSecret
-		}
-	}
-
-	serverKey := oauth2.DefaultSigningKey
-	clientKey := serverKey
-	if serverKey.IsSymmetric() {
-		var err error
-		clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
-		if err != nil {
-			handleAccessTokenError(ctx, AccessTokenError{
-				ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-				ErrorDescription: "Error creating signing key",
-			})
-			return
-		}
-	}
-
-	switch form.GrantType {
-	case "refresh_token":
-		handleRefreshToken(ctx, form, serverKey, clientKey)
-	case "authorization_code":
-		handleAuthorizationCode(ctx, form, serverKey, clientKey)
-	default:
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeUnsupportedGrantType,
-			ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
-		})
-	}
-}
-
-func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
-	app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
-	if err != nil {
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeInvalidClient,
-			ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID),
-		})
-		return
-	}
-	// "The authorization server MUST ... require client authentication for confidential clients"
-	// https://datatracker.ietf.org/doc/html/rfc6749#section-6
-	if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
-		errorDescription := "invalid client secret"
-		if form.ClientSecret == "" {
-			errorDescription = "invalid empty client secret"
-		}
-		// "invalid_client ... Client authentication failed"
-		// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeInvalidClient,
-			ErrorDescription: errorDescription,
-		})
-		return
-	}
-
-	token, err := oauth2.ParseToken(form.RefreshToken, serverKey)
-	if err != nil {
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeUnauthorizedClient,
-			ErrorDescription: "unable to parse refresh token",
-		})
-		return
-	}
-	// get grant before increasing counter
-	grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
-	if err != nil || grant == nil {
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeInvalidGrant,
-			ErrorDescription: "grant does not exist",
-		})
-		return
-	}
-
-	// check if token got already used
-	if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeUnauthorizedClient,
-			ErrorDescription: "token was already used",
-		})
-		log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
-		return
-	}
-	accessToken, tokenErr := newAccessTokenResponse(ctx, grant, serverKey, clientKey)
-	if tokenErr != nil {
-		handleAccessTokenError(ctx, *tokenErr)
-		return
-	}
-	ctx.JSON(http.StatusOK, accessToken)
-}
-
-func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
-	app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
-	if err != nil {
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeInvalidClient,
-			ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
-		})
-		return
-	}
-	if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
-		errorDescription := "invalid client secret"
-		if form.ClientSecret == "" {
-			errorDescription = "invalid empty client secret"
-		}
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeUnauthorizedClient,
-			ErrorDescription: errorDescription,
-		})
-		return
-	}
-	if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeUnauthorizedClient,
-			ErrorDescription: "unexpected redirect URI",
-		})
-		return
-	}
-	authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
-	if err != nil || authorizationCode == nil {
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeUnauthorizedClient,
-			ErrorDescription: "client is not authorized",
-		})
-		return
-	}
-	// check if code verifier authorizes the client, PKCE support
-	if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeUnauthorizedClient,
-			ErrorDescription: "failed PKCE code challenge",
-		})
-		return
-	}
-	// check if granted for this application
-	if authorizationCode.Grant.ApplicationID != app.ID {
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeInvalidGrant,
-			ErrorDescription: "invalid grant",
-		})
-		return
-	}
-	// remove token from database to deny duplicate usage
-	if err := authorizationCode.Invalidate(ctx); err != nil {
-		handleAccessTokenError(ctx, AccessTokenError{
-			ErrorCode:        AccessTokenErrorCodeInvalidRequest,
-			ErrorDescription: "cannot proceed your request",
-		})
-	}
-	resp, tokenErr := newAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
-	if tokenErr != nil {
-		handleAccessTokenError(ctx, *tokenErr)
-		return
-	}
-	// send successful response
-	ctx.JSON(http.StatusOK, resp)
-}
-
-func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) {
-	ctx.JSON(http.StatusBadRequest, acErr)
-}
-
-func handleServerError(ctx *context.Context, state, redirectURI string) {
-	handleAuthorizeError(ctx, AuthorizeError{
-		ErrorCode:        ErrorCodeServerError,
-		ErrorDescription: "A server error occurred",
-		State:            state,
-	}, redirectURI)
-}
-
-func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
-	if redirectURI == "" {
-		log.Warn("Authorization failed: %v", authErr.ErrorDescription)
-		ctx.Data["Error"] = authErr
-		ctx.HTML(http.StatusBadRequest, tplGrantError)
-		return
-	}
-	redirect, err := url.Parse(redirectURI)
-	if err != nil {
-		ctx.ServerError("url.Parse", err)
-		return
-	}
-	q := redirect.Query()
-	q.Set("error", string(authErr.ErrorCode))
-	q.Set("error_description", authErr.ErrorDescription)
-	q.Set("state", authErr.State)
-	redirect.RawQuery = q.Encode()
-	ctx.Redirect(redirect.String(), http.StatusSeeOther)
-}
-
 // SignInOAuth handles the OAuth2 login buttons
 func SignInOAuth(ctx *context.Context) {
 	provider := ctx.PathParam(":provider")
diff --git a/routers/web/auth/oauth2_provider.go b/routers/web/auth/oauth2_provider.go
new file mode 100644
index 0000000000000..29827b062de84
--- /dev/null
+++ b/routers/web/auth/oauth2_provider.go
@@ -0,0 +1,666 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+	"errors"
+	"fmt"
+	"html"
+	"html/template"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"code.gitea.io/gitea/models/auth"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/json"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/web"
+	auth_service "code.gitea.io/gitea/services/auth"
+	"code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/forms"
+	"code.gitea.io/gitea/services/oauth2_provider"
+
+	"gitea.com/go-chi/binding"
+	jwt "github.com/golang-jwt/jwt/v5"
+)
+
+const (
+	tplGrantAccess base.TplName = "user/auth/grant"
+	tplGrantError  base.TplName = "user/auth/grant_error"
+)
+
+// TODO move error and responses to SDK or models
+
+// AuthorizeErrorCode represents an error code specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
+type AuthorizeErrorCode string
+
+const (
+	// ErrorCodeInvalidRequest represents the according error in RFC 6749
+	ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
+	// ErrorCodeUnauthorizedClient represents the according error in RFC 6749
+	ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
+	// ErrorCodeAccessDenied represents the according error in RFC 6749
+	ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
+	// ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
+	ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
+	// ErrorCodeInvalidScope represents the according error in RFC 6749
+	ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
+	// ErrorCodeServerError represents the according error in RFC 6749
+	ErrorCodeServerError AuthorizeErrorCode = "server_error"
+	// ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
+	ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
+)
+
+// AuthorizeError represents an error type specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
+type AuthorizeError struct {
+	ErrorCode        AuthorizeErrorCode `json:"error" form:"error"`
+	ErrorDescription string
+	State            string
+}
+
+// Error returns the error message
+func (err AuthorizeError) Error() string {
+	return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
+}
+
+// errCallback represents a oauth2 callback error
+type errCallback struct {
+	Code        string
+	Description string
+}
+
+func (err errCallback) Error() string {
+	return err.Description
+}
+
+type userInfoResponse struct {
+	Sub      string   `json:"sub"`
+	Name     string   `json:"name"`
+	Username string   `json:"preferred_username"`
+	Email    string   `json:"email"`
+	Picture  string   `json:"picture"`
+	Groups   []string `json:"groups"`
+}
+
+// InfoOAuth manages request for userinfo endpoint
+func InfoOAuth(ctx *context.Context) {
+	if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() {
+		ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`)
+		ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
+		return
+	}
+
+	response := &userInfoResponse{
+		Sub:      fmt.Sprint(ctx.Doer.ID),
+		Name:     ctx.Doer.FullName,
+		Username: ctx.Doer.Name,
+		Email:    ctx.Doer.Email,
+		Picture:  ctx.Doer.AvatarLink(ctx),
+	}
+
+	groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
+	if err != nil {
+		ctx.ServerError("Oauth groups for user", err)
+		return
+	}
+	response.Groups = groups
+
+	ctx.JSON(http.StatusOK, response)
+}
+
+func parseBasicAuth(ctx *context.Context) (username, password string, err error) {
+	authHeader := ctx.Req.Header.Get("Authorization")
+	if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
+		return base.BasicAuthDecode(authData)
+	}
+	return "", "", errors.New("invalid basic authentication")
+}
+
+// IntrospectOAuth introspects an oauth token
+func IntrospectOAuth(ctx *context.Context) {
+	clientIDValid := false
+	if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil {
+		app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
+		if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
+			// this is likely a database error; log it and respond without details
+			log.Error("Error retrieving client_id: %v", err)
+			ctx.Error(http.StatusInternalServerError)
+			return
+		}
+		clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
+	}
+	if !clientIDValid {
+		ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`)
+		ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
+		return
+	}
+
+	var response struct {
+		Active   bool   `json:"active"`
+		Scope    string `json:"scope,omitempty"`
+		Username string `json:"username,omitempty"`
+		jwt.RegisteredClaims
+	}
+
+	form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
+	token, err := oauth2_provider.ParseToken(form.Token, oauth2_provider.DefaultSigningKey)
+	if err == nil {
+		grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
+		if err == nil && grant != nil {
+			app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
+			if err == nil && app != nil {
+				response.Active = true
+				response.Scope = grant.Scope
+				response.Issuer = setting.AppURL
+				response.Audience = []string{app.ClientID}
+				response.Subject = fmt.Sprint(grant.UserID)
+			}
+			if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil {
+				response.Username = user.Name
+			}
+		}
+	}
+
+	ctx.JSON(http.StatusOK, response)
+}
+
+// AuthorizeOAuth manages authorize requests
+func AuthorizeOAuth(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.AuthorizationForm)
+	errs := binding.Errors{}
+	errs = form.Validate(ctx.Req, errs)
+	if len(errs) > 0 {
+		errstring := ""
+		for _, e := range errs {
+			errstring += e.Error() + "\n"
+		}
+		ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring))
+		return
+	}
+
+	app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+	if err != nil {
+		if auth.IsErrOauthClientIDInvalid(err) {
+			handleAuthorizeError(ctx, AuthorizeError{
+				ErrorCode:        ErrorCodeUnauthorizedClient,
+				ErrorDescription: "Client ID not registered",
+				State:            form.State,
+			}, "")
+			return
+		}
+		ctx.ServerError("GetOAuth2ApplicationByClientID", err)
+		return
+	}
+
+	var user *user_model.User
+	if app.UID != 0 {
+		user, err = user_model.GetUserByID(ctx, app.UID)
+		if err != nil {
+			ctx.ServerError("GetUserByID", err)
+			return
+		}
+	}
+
+	if !app.ContainsRedirectURI(form.RedirectURI) {
+		handleAuthorizeError(ctx, AuthorizeError{
+			ErrorCode:        ErrorCodeInvalidRequest,
+			ErrorDescription: "Unregistered Redirect URI",
+			State:            form.State,
+		}, "")
+		return
+	}
+
+	if form.ResponseType != "code" {
+		handleAuthorizeError(ctx, AuthorizeError{
+			ErrorCode:        ErrorCodeUnsupportedResponseType,
+			ErrorDescription: "Only code response type is supported.",
+			State:            form.State,
+		}, form.RedirectURI)
+		return
+	}
+
+	// pkce support
+	switch form.CodeChallengeMethod {
+	case "S256":
+	case "plain":
+		if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
+			handleAuthorizeError(ctx, AuthorizeError{
+				ErrorCode:        ErrorCodeServerError,
+				ErrorDescription: "cannot set code challenge method",
+				State:            form.State,
+			}, form.RedirectURI)
+			return
+		}
+		if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil {
+			handleAuthorizeError(ctx, AuthorizeError{
+				ErrorCode:        ErrorCodeServerError,
+				ErrorDescription: "cannot set code challenge",
+				State:            form.State,
+			}, form.RedirectURI)
+			return
+		}
+		// Here we're just going to try to release the session early
+		if err := ctx.Session.Release(); err != nil {
+			// we'll tolerate errors here as they *should* get saved elsewhere
+			log.Error("Unable to save changes to the session: %v", err)
+		}
+	case "":
+		// "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
+		// https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
+		if !app.ConfidentialClient {
+			// "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
+			// https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
+			handleAuthorizeError(ctx, AuthorizeError{
+				ErrorCode:        ErrorCodeInvalidRequest,
+				ErrorDescription: "PKCE is required for public clients",
+				State:            form.State,
+			}, form.RedirectURI)
+			return
+		}
+	default:
+		// "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
+		// https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
+		handleAuthorizeError(ctx, AuthorizeError{
+			ErrorCode:        ErrorCodeInvalidRequest,
+			ErrorDescription: "unsupported code challenge method",
+			State:            form.State,
+		}, form.RedirectURI)
+		return
+	}
+
+	grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
+	if err != nil {
+		handleServerError(ctx, form.State, form.RedirectURI)
+		return
+	}
+
+	// Redirect if user already granted access and the application is confidential or trusted otherwise
+	// I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2
+	if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil {
+		code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
+		if err != nil {
+			handleServerError(ctx, form.State, form.RedirectURI)
+			return
+		}
+		redirect, err := code.GenerateRedirectURI(form.State)
+		if err != nil {
+			handleServerError(ctx, form.State, form.RedirectURI)
+			return
+		}
+		// Update nonce to reflect the new session
+		if len(form.Nonce) > 0 {
+			err := grant.SetNonce(ctx, form.Nonce)
+			if err != nil {
+				log.Error("Unable to update nonce: %v", err)
+			}
+		}
+		ctx.Redirect(redirect.String())
+		return
+	}
+
+	// show authorize page to grant access
+	ctx.Data["Application"] = app
+	ctx.Data["RedirectURI"] = form.RedirectURI
+	ctx.Data["State"] = form.State
+	ctx.Data["Scope"] = form.Scope
+	ctx.Data["Nonce"] = form.Nonce
+	if user != nil {
+		ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`@%s`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)))
+	} else {
+		ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`%s`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)))
+	}
+	ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("" + html.EscapeString(form.RedirectURI) + "")
+	// TODO document SESSION <=> FORM
+	err = ctx.Session.Set("client_id", app.ClientID)
+	if err != nil {
+		handleServerError(ctx, form.State, form.RedirectURI)
+		log.Error(err.Error())
+		return
+	}
+	err = ctx.Session.Set("redirect_uri", form.RedirectURI)
+	if err != nil {
+		handleServerError(ctx, form.State, form.RedirectURI)
+		log.Error(err.Error())
+		return
+	}
+	err = ctx.Session.Set("state", form.State)
+	if err != nil {
+		handleServerError(ctx, form.State, form.RedirectURI)
+		log.Error(err.Error())
+		return
+	}
+	// Here we're just going to try to release the session early
+	if err := ctx.Session.Release(); err != nil {
+		// we'll tolerate errors here as they *should* get saved elsewhere
+		log.Error("Unable to save changes to the session: %v", err)
+	}
+	ctx.HTML(http.StatusOK, tplGrantAccess)
+}
+
+// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
+func GrantApplicationOAuth(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.GrantApplicationForm)
+	if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
+		ctx.Session.Get("redirect_uri") != form.RedirectURI {
+		ctx.Error(http.StatusBadRequest)
+		return
+	}
+
+	if !form.Granted {
+		handleAuthorizeError(ctx, AuthorizeError{
+			State:            form.State,
+			ErrorDescription: "the request is denied",
+			ErrorCode:        ErrorCodeAccessDenied,
+		}, form.RedirectURI)
+		return
+	}
+
+	app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+	if err != nil {
+		ctx.ServerError("GetOAuth2ApplicationByClientID", err)
+		return
+	}
+	grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
+	if err != nil {
+		handleServerError(ctx, form.State, form.RedirectURI)
+		return
+	}
+	if grant == nil {
+		grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope)
+		if err != nil {
+			handleAuthorizeError(ctx, AuthorizeError{
+				State:            form.State,
+				ErrorDescription: "cannot create grant for user",
+				ErrorCode:        ErrorCodeServerError,
+			}, form.RedirectURI)
+			return
+		}
+	} else if grant.Scope != form.Scope {
+		handleAuthorizeError(ctx, AuthorizeError{
+			State:            form.State,
+			ErrorDescription: "a grant exists with different scope",
+			ErrorCode:        ErrorCodeServerError,
+		}, form.RedirectURI)
+		return
+	}
+
+	if len(form.Nonce) > 0 {
+		err := grant.SetNonce(ctx, form.Nonce)
+		if err != nil {
+			log.Error("Unable to update nonce: %v", err)
+		}
+	}
+
+	var codeChallenge, codeChallengeMethod string
+	codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string)
+	codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string)
+
+	code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod)
+	if err != nil {
+		handleServerError(ctx, form.State, form.RedirectURI)
+		return
+	}
+	redirect, err := code.GenerateRedirectURI(form.State)
+	if err != nil {
+		handleServerError(ctx, form.State, form.RedirectURI)
+		return
+	}
+	ctx.Redirect(redirect.String(), http.StatusSeeOther)
+}
+
+// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
+func OIDCWellKnown(ctx *context.Context) {
+	ctx.Data["SigningKey"] = oauth2_provider.DefaultSigningKey
+	ctx.JSONTemplate("user/auth/oidc_wellknown")
+}
+
+// OIDCKeys generates the JSON Web Key Set
+func OIDCKeys(ctx *context.Context) {
+	jwk, err := oauth2_provider.DefaultSigningKey.ToJWK()
+	if err != nil {
+		log.Error("Error converting signing key to JWK: %v", err)
+		ctx.Error(http.StatusInternalServerError)
+		return
+	}
+
+	jwk["use"] = "sig"
+
+	jwks := map[string][]map[string]string{
+		"keys": {
+			jwk,
+		},
+	}
+
+	ctx.Resp.Header().Set("Content-Type", "application/json")
+	enc := json.NewEncoder(ctx.Resp)
+	if err := enc.Encode(jwks); err != nil {
+		log.Error("Failed to encode representation as json. Error: %v", err)
+	}
+}
+
+// AccessTokenOAuth manages all access token requests by the client
+func AccessTokenOAuth(ctx *context.Context) {
+	form := *web.GetForm(ctx).(*forms.AccessTokenForm)
+	// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
+	if form.ClientID == "" || form.ClientSecret == "" {
+		authHeader := ctx.Req.Header.Get("Authorization")
+		if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
+			clientID, clientSecret, err := base.BasicAuthDecode(authData)
+			if err != nil {
+				handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+					ErrorCode:        oauth2_provider.AccessTokenErrorCodeInvalidRequest,
+					ErrorDescription: "cannot parse basic auth header",
+				})
+				return
+			}
+			// validate that any fields present in the form match the Basic auth header
+			if form.ClientID != "" && form.ClientID != clientID {
+				handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+					ErrorCode:        oauth2_provider.AccessTokenErrorCodeInvalidRequest,
+					ErrorDescription: "client_id in request body inconsistent with Authorization header",
+				})
+				return
+			}
+			form.ClientID = clientID
+			if form.ClientSecret != "" && form.ClientSecret != clientSecret {
+				handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+					ErrorCode:        oauth2_provider.AccessTokenErrorCodeInvalidRequest,
+					ErrorDescription: "client_secret in request body inconsistent with Authorization header",
+				})
+				return
+			}
+			form.ClientSecret = clientSecret
+		}
+	}
+
+	serverKey := oauth2_provider.DefaultSigningKey
+	clientKey := serverKey
+	if serverKey.IsSymmetric() {
+		var err error
+		clientKey, err = oauth2_provider.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
+		if err != nil {
+			handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+				ErrorCode:        oauth2_provider.AccessTokenErrorCodeInvalidRequest,
+				ErrorDescription: "Error creating signing key",
+			})
+			return
+		}
+	}
+
+	switch form.GrantType {
+	case "refresh_token":
+		handleRefreshToken(ctx, form, serverKey, clientKey)
+	case "authorization_code":
+		handleAuthorizationCode(ctx, form, serverKey, clientKey)
+	default:
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeUnsupportedGrantType,
+			ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
+		})
+	}
+}
+
+func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2_provider.JWTSigningKey) {
+	app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+	if err != nil {
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeInvalidClient,
+			ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID),
+		})
+		return
+	}
+	// "The authorization server MUST ... require client authentication for confidential clients"
+	// https://datatracker.ietf.org/doc/html/rfc6749#section-6
+	if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
+		errorDescription := "invalid client secret"
+		if form.ClientSecret == "" {
+			errorDescription = "invalid empty client secret"
+		}
+		// "invalid_client ... Client authentication failed"
+		// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeInvalidClient,
+			ErrorDescription: errorDescription,
+		})
+		return
+	}
+
+	token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
+	if err != nil {
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
+			ErrorDescription: "unable to parse refresh token",
+		})
+		return
+	}
+	// get grant before increasing counter
+	grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
+	if err != nil || grant == nil {
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeInvalidGrant,
+			ErrorDescription: "grant does not exist",
+		})
+		return
+	}
+
+	// check if token got already used
+	if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
+			ErrorDescription: "token was already used",
+		})
+		log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
+		return
+	}
+	accessToken, tokenErr := oauth2_provider.NewAccessTokenResponse(ctx, grant, serverKey, clientKey)
+	if tokenErr != nil {
+		handleAccessTokenError(ctx, *tokenErr)
+		return
+	}
+	ctx.JSON(http.StatusOK, accessToken)
+}
+
+func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2_provider.JWTSigningKey) {
+	app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+	if err != nil {
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeInvalidClient,
+			ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
+		})
+		return
+	}
+	if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
+		errorDescription := "invalid client secret"
+		if form.ClientSecret == "" {
+			errorDescription = "invalid empty client secret"
+		}
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
+			ErrorDescription: errorDescription,
+		})
+		return
+	}
+	if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
+			ErrorDescription: "unexpected redirect URI",
+		})
+		return
+	}
+	authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
+	if err != nil || authorizationCode == nil {
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
+			ErrorDescription: "client is not authorized",
+		})
+		return
+	}
+	// check if code verifier authorizes the client, PKCE support
+	if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
+			ErrorDescription: "failed PKCE code challenge",
+		})
+		return
+	}
+	// check if granted for this application
+	if authorizationCode.Grant.ApplicationID != app.ID {
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeInvalidGrant,
+			ErrorDescription: "invalid grant",
+		})
+		return
+	}
+	// remove token from database to deny duplicate usage
+	if err := authorizationCode.Invalidate(ctx); err != nil {
+		handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+			ErrorCode:        oauth2_provider.AccessTokenErrorCodeInvalidRequest,
+			ErrorDescription: "cannot proceed your request",
+		})
+	}
+	resp, tokenErr := oauth2_provider.NewAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
+	if tokenErr != nil {
+		handleAccessTokenError(ctx, *tokenErr)
+		return
+	}
+	// send successful response
+	ctx.JSON(http.StatusOK, resp)
+}
+
+func handleAccessTokenError(ctx *context.Context, acErr oauth2_provider.AccessTokenError) {
+	ctx.JSON(http.StatusBadRequest, acErr)
+}
+
+func handleServerError(ctx *context.Context, state, redirectURI string) {
+	handleAuthorizeError(ctx, AuthorizeError{
+		ErrorCode:        ErrorCodeServerError,
+		ErrorDescription: "A server error occurred",
+		State:            state,
+	}, redirectURI)
+}
+
+func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
+	if redirectURI == "" {
+		log.Warn("Authorization failed: %v", authErr.ErrorDescription)
+		ctx.Data["Error"] = authErr
+		ctx.HTML(http.StatusBadRequest, tplGrantError)
+		return
+	}
+	redirect, err := url.Parse(redirectURI)
+	if err != nil {
+		ctx.ServerError("url.Parse", err)
+		return
+	}
+	q := redirect.Query()
+	q.Set("error", string(authErr.ErrorCode))
+	q.Set("error_description", authErr.ErrorDescription)
+	q.Set("state", authErr.State)
+	redirect.RawQuery = q.Encode()
+	ctx.Redirect(redirect.String(), http.StatusSeeOther)
+}
diff --git a/routers/web/auth/oauth_test.go b/routers/web/auth/oauth_test.go
index 4339d9d1ebae8..78af97fa9c669 100644
--- a/routers/web/auth/oauth_test.go
+++ b/routers/web/auth/oauth_test.go
@@ -11,22 +11,22 @@ import (
 	"code.gitea.io/gitea/models/unittest"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/services/auth/source/oauth2"
+	"code.gitea.io/gitea/services/oauth2_provider"
 
 	"github.com/golang-jwt/jwt/v5"
 	"github.com/stretchr/testify/assert"
 )
 
-func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2.OIDCToken {
-	signingKey, err := oauth2.CreateJWTSigningKey("HS256", make([]byte, 32))
+func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2_provider.OIDCToken {
+	signingKey, err := oauth2_provider.CreateJWTSigningKey("HS256", make([]byte, 32))
 	assert.NoError(t, err)
 	assert.NotNil(t, signingKey)
 
-	response, terr := newAccessTokenResponse(db.DefaultContext, grant, signingKey, signingKey)
+	response, terr := oauth2_provider.NewAccessTokenResponse(db.DefaultContext, grant, signingKey, signingKey)
 	assert.Nil(t, terr)
 	assert.NotNil(t, response)
 
-	parsedToken, err := jwt.ParseWithClaims(response.IDToken, &oauth2.OIDCToken{}, func(token *jwt.Token) (any, error) {
+	parsedToken, err := jwt.ParseWithClaims(response.IDToken, &oauth2_provider.OIDCToken{}, func(token *jwt.Token) (any, error) {
 		assert.NotNil(t, token.Method)
 		assert.Equal(t, signingKey.SigningMethod().Alg(), token.Method.Alg())
 		return signingKey.VerifyKey(), nil
@@ -34,7 +34,7 @@ func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2.OIDCToke
 	assert.NoError(t, err)
 	assert.True(t, parsedToken.Valid)
 
-	oidcToken, ok := parsedToken.Claims.(*oauth2.OIDCToken)
+	oidcToken, ok := parsedToken.Claims.(*oauth2_provider.OIDCToken)
 	assert.True(t, ok)
 	assert.NotNil(t, oidcToken)
 
diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go
index 46d8510143675..523998a634522 100644
--- a/services/auth/oauth2.go
+++ b/services/auth/oauth2.go
@@ -17,7 +17,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/web/middleware"
-	"code.gitea.io/gitea/services/auth/source/oauth2"
+	"code.gitea.io/gitea/services/oauth2_provider"
 )
 
 // Ensure the struct implements the interface.
@@ -31,7 +31,7 @@ func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
 	if !strings.Contains(accessToken, ".") {
 		return 0
 	}
-	token, err := oauth2.ParseToken(accessToken, oauth2.DefaultSigningKey)
+	token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
 	if err != nil {
 		log.Trace("oauth2.ParseToken: %v", err)
 		return 0
@@ -40,7 +40,7 @@ func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
 	if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
 		return 0
 	}
-	if token.Type != oauth2.TypeAccessToken {
+	if token.Kind != oauth2_provider.KindAccessToken {
 		return 0
 	}
 	if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
diff --git a/services/auth/source/oauth2/init.go b/services/auth/source/oauth2/init.go
index 5c2568154863c..313f375281b2c 100644
--- a/services/auth/source/oauth2/init.go
+++ b/services/auth/source/oauth2/init.go
@@ -30,10 +30,6 @@ const ProviderHeaderKey = "gitea-oauth2-provider"
 
 // Init initializes the oauth source
 func Init(ctx context.Context) error {
-	if err := InitSigningKey(); err != nil {
-		return err
-	}
-
 	// Lock our mutex
 	gothRWMutex.Lock()
 
diff --git a/services/oauth2_provider/access_token.go b/services/oauth2_provider/access_token.go
new file mode 100644
index 0000000000000..00c960caf2ee7
--- /dev/null
+++ b/services/oauth2_provider/access_token.go
@@ -0,0 +1,214 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2_provider //nolint
+
+import (
+	"context"
+	"fmt"
+
+	auth "code.gitea.io/gitea/models/auth"
+	org_model "code.gitea.io/gitea/models/organization"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// AccessTokenErrorCode represents an error code specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+type AccessTokenErrorCode string
+
+const (
+	// AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
+	AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
+	// AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
+	AccessTokenErrorCodeInvalidClient = "invalid_client"
+	// AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
+	AccessTokenErrorCodeInvalidGrant = "invalid_grant"
+	// AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
+	AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
+	// AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
+	AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
+	// AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
+	AccessTokenErrorCodeInvalidScope = "invalid_scope"
+)
+
+// AccessTokenError represents an error response specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+type AccessTokenError struct {
+	ErrorCode        AccessTokenErrorCode `json:"error" form:"error"`
+	ErrorDescription string               `json:"error_description"`
+}
+
+// Error returns the error message
+func (err AccessTokenError) Error() string {
+	return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
+}
+
+// TokenType specifies the kind of token
+type TokenType string
+
+const (
+	// TokenTypeBearer represents a token type specified in RFC 6749
+	TokenTypeBearer TokenType = "bearer"
+	// TokenTypeMAC represents a token type specified in RFC 6749
+	TokenTypeMAC = "mac"
+)
+
+// AccessTokenResponse represents a successful access token response
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
+type AccessTokenResponse struct {
+	AccessToken  string    `json:"access_token"`
+	TokenType    TokenType `json:"token_type"`
+	ExpiresIn    int64     `json:"expires_in"`
+	RefreshToken string    `json:"refresh_token"`
+	IDToken      string    `json:"id_token,omitempty"`
+}
+
+func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
+	if setting.OAuth2.InvalidateRefreshTokens {
+		if err := grant.IncreaseCounter(ctx); err != nil {
+			return nil, &AccessTokenError{
+				ErrorCode:        AccessTokenErrorCodeInvalidGrant,
+				ErrorDescription: "cannot increase the grant counter",
+			}
+		}
+	}
+	// generate access token to access the API
+	expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime)
+	accessToken := &Token{
+		GrantID: grant.ID,
+		Kind:    KindAccessToken,
+		RegisteredClaims: jwt.RegisteredClaims{
+			ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
+		},
+	}
+	signedAccessToken, err := accessToken.SignToken(serverKey)
+	if err != nil {
+		return nil, &AccessTokenError{
+			ErrorCode:        AccessTokenErrorCodeInvalidRequest,
+			ErrorDescription: "cannot sign token",
+		}
+	}
+
+	// generate refresh token to request an access token after it expired later
+	refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime()
+	refreshToken := &Token{
+		GrantID: grant.ID,
+		Counter: grant.Counter,
+		Kind:    KindRefreshToken,
+		RegisteredClaims: jwt.RegisteredClaims{
+			ExpiresAt: jwt.NewNumericDate(refreshExpirationDate),
+		},
+	}
+	signedRefreshToken, err := refreshToken.SignToken(serverKey)
+	if err != nil {
+		return nil, &AccessTokenError{
+			ErrorCode:        AccessTokenErrorCodeInvalidRequest,
+			ErrorDescription: "cannot sign token",
+		}
+	}
+
+	// generate OpenID Connect id_token
+	signedIDToken := ""
+	if grant.ScopeContains("openid") {
+		app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
+		if err != nil {
+			return nil, &AccessTokenError{
+				ErrorCode:        AccessTokenErrorCodeInvalidRequest,
+				ErrorDescription: "cannot find application",
+			}
+		}
+		user, err := user_model.GetUserByID(ctx, grant.UserID)
+		if err != nil {
+			if user_model.IsErrUserNotExist(err) {
+				return nil, &AccessTokenError{
+					ErrorCode:        AccessTokenErrorCodeInvalidRequest,
+					ErrorDescription: "cannot find user",
+				}
+			}
+			log.Error("Error loading user: %v", err)
+			return nil, &AccessTokenError{
+				ErrorCode:        AccessTokenErrorCodeInvalidRequest,
+				ErrorDescription: "server error",
+			}
+		}
+
+		idToken := &OIDCToken{
+			RegisteredClaims: jwt.RegisteredClaims{
+				ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
+				Issuer:    setting.AppURL,
+				Audience:  []string{app.ClientID},
+				Subject:   fmt.Sprint(grant.UserID),
+			},
+			Nonce: grant.Nonce,
+		}
+		if grant.ScopeContains("profile") {
+			idToken.Name = user.GetDisplayName()
+			idToken.PreferredUsername = user.Name
+			idToken.Profile = user.HTMLURL()
+			idToken.Picture = user.AvatarLink(ctx)
+			idToken.Website = user.Website
+			idToken.Locale = user.Language
+			idToken.UpdatedAt = user.UpdatedUnix
+		}
+		if grant.ScopeContains("email") {
+			idToken.Email = user.Email
+			idToken.EmailVerified = user.IsActive
+		}
+		if grant.ScopeContains("groups") {
+			groups, err := GetOAuthGroupsForUser(ctx, user)
+			if err != nil {
+				log.Error("Error getting groups: %v", err)
+				return nil, &AccessTokenError{
+					ErrorCode:        AccessTokenErrorCodeInvalidRequest,
+					ErrorDescription: "server error",
+				}
+			}
+			idToken.Groups = groups
+		}
+
+		signedIDToken, err = idToken.SignToken(clientKey)
+		if err != nil {
+			return nil, &AccessTokenError{
+				ErrorCode:        AccessTokenErrorCodeInvalidRequest,
+				ErrorDescription: "cannot sign token",
+			}
+		}
+	}
+
+	return &AccessTokenResponse{
+		AccessToken:  signedAccessToken,
+		TokenType:    TokenTypeBearer,
+		ExpiresIn:    setting.OAuth2.AccessTokenExpirationTime,
+		RefreshToken: signedRefreshToken,
+		IDToken:      signedIDToken,
+	}, nil
+}
+
+// returns a list of "org" and "org:team" strings,
+// that the given user is a part of.
+func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User) ([]string, error) {
+	orgs, err := org_model.GetUserOrgsList(ctx, user)
+	if err != nil {
+		return nil, fmt.Errorf("GetUserOrgList: %w", err)
+	}
+
+	var groups []string
+	for _, org := range orgs {
+		groups = append(groups, org.Name)
+		teams, err := org.LoadTeams(ctx)
+		if err != nil {
+			return nil, fmt.Errorf("LoadTeams: %w", err)
+		}
+		for _, team := range teams {
+			if team.IsMember(ctx, user.ID) {
+				groups = append(groups, org.Name+":"+team.LowerName)
+			}
+		}
+	}
+	return groups, nil
+}
diff --git a/services/oauth2_provider/init.go b/services/oauth2_provider/init.go
new file mode 100644
index 0000000000000..e5958099a6b3c
--- /dev/null
+++ b/services/oauth2_provider/init.go
@@ -0,0 +1,19 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2_provider //nolint
+
+import (
+	"context"
+
+	"code.gitea.io/gitea/modules/setting"
+)
+
+// Init initializes the oauth source
+func Init(ctx context.Context) error {
+	if !setting.OAuth2.Enabled {
+		return nil
+	}
+
+	return InitSigningKey()
+}
diff --git a/services/auth/source/oauth2/jwtsigningkey.go b/services/oauth2_provider/jwtsigningkey.go
similarity index 99%
rename from services/auth/source/oauth2/jwtsigningkey.go
rename to services/oauth2_provider/jwtsigningkey.go
index 070fffe60f7fb..6c668db463574 100644
--- a/services/auth/source/oauth2/jwtsigningkey.go
+++ b/services/oauth2_provider/jwtsigningkey.go
@@ -1,7 +1,7 @@
 // Copyright 2021 The Gitea Authors. All rights reserved.
 // SPDX-License-Identifier: MIT
 
-package oauth2
+package oauth2_provider //nolint
 
 import (
 	"crypto/ecdsa"
diff --git a/services/auth/source/oauth2/token.go b/services/oauth2_provider/token.go
similarity index 83%
rename from services/auth/source/oauth2/token.go
rename to services/oauth2_provider/token.go
index 3405619d3fa59..b71b11906e5ed 100644
--- a/services/auth/source/oauth2/token.go
+++ b/services/oauth2_provider/token.go
@@ -1,7 +1,7 @@
 // Copyright 2021 The Gitea Authors. All rights reserved.
 // SPDX-License-Identifier: MIT
 
-package oauth2
+package oauth2_provider //nolint
 
 import (
 	"fmt"
@@ -12,29 +12,22 @@ import (
 	"github.com/golang-jwt/jwt/v5"
 )
 
-// ___________     __
-// \__    ___/___ |  | __ ____   ____
-//   |    | /  _ \|  |/ // __ \ /    \
-//   |    |(  <_> )    <\  ___/|   |  \
-//   |____| \____/|__|_ \\___  >___|  /
-//                     \/    \/     \/
-
 // Token represents an Oauth grant
 
-// TokenType represents the type of token for an oauth application
-type TokenType int
+// TokenKind represents the type of token for an oauth application
+type TokenKind int
 
 const (
-	// TypeAccessToken is a token with short lifetime to access the api
-	TypeAccessToken TokenType = 0
-	// TypeRefreshToken is token with long lifetime to refresh access tokens obtained by the client
-	TypeRefreshToken = iota
+	// KindAccessToken is a token with short lifetime to access the api
+	KindAccessToken TokenKind = 0
+	// KindRefreshToken is token with long lifetime to refresh access tokens obtained by the client
+	KindRefreshToken = iota
 )
 
 // Token represents a JWT token used to authenticate a client
 type Token struct {
 	GrantID int64     `json:"gnt"`
-	Type    TokenType `json:"tt"`
+	Kind    TokenKind `json:"tt"`
 	Counter int64     `json:"cnt,omitempty"`
 	jwt.RegisteredClaims
 }
diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go
index b1acf90d147da..b32d365b04d15 100644
--- a/tests/integration/oauth_test.go
+++ b/tests/integration/oauth_test.go
@@ -11,7 +11,7 @@ import (
 
 	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/routers/web/auth"
+	oauth2_provider "code.gitea.io/gitea/services/oauth2_provider"
 	"code.gitea.io/gitea/tests"
 
 	"github.com/stretchr/testify/assert"
@@ -177,7 +177,7 @@ func TestAccessTokenExchangeWithoutPKCE(t *testing.T) {
 		"code":          "authcode",
 	})
 	resp := MakeRequest(t, req, http.StatusBadRequest)
-	parsedError := new(auth.AccessTokenError)
+	parsedError := new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
 	assert.Equal(t, "failed PKCE code challenge", parsedError.ErrorDescription)
@@ -195,7 +195,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
 		"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
 	})
 	resp := MakeRequest(t, req, http.StatusBadRequest)
-	parsedError := new(auth.AccessTokenError)
+	parsedError := new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
 	assert.Equal(t, "cannot load client with client id: '???'", parsedError.ErrorDescription)
@@ -210,7 +210,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
 		"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
 	})
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError = new(auth.AccessTokenError)
+	parsedError = new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
 	assert.Equal(t, "invalid client secret", parsedError.ErrorDescription)
@@ -225,7 +225,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
 		"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
 	})
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError = new(auth.AccessTokenError)
+	parsedError = new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
 	assert.Equal(t, "unexpected redirect URI", parsedError.ErrorDescription)
@@ -240,7 +240,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
 		"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
 	})
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError = new(auth.AccessTokenError)
+	parsedError = new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
 	assert.Equal(t, "client is not authorized", parsedError.ErrorDescription)
@@ -255,7 +255,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
 		"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
 	})
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError = new(auth.AccessTokenError)
+	parsedError = new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "unsupported_grant_type", string(parsedError.ErrorCode))
 	assert.Equal(t, "Only refresh_token or authorization_code grant type is supported", parsedError.ErrorDescription)
@@ -292,7 +292,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) {
 	})
 	req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OmJsYWJsYQ==")
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError := new(auth.AccessTokenError)
+	parsedError := new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
 	assert.Equal(t, "invalid client secret", parsedError.ErrorDescription)
@@ -305,7 +305,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) {
 		"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
 	})
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError = new(auth.AccessTokenError)
+	parsedError = new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
 	assert.Equal(t, "cannot load client with client id: ''", parsedError.ErrorDescription)
@@ -319,7 +319,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) {
 	})
 	req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError = new(auth.AccessTokenError)
+	parsedError = new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "invalid_request", string(parsedError.ErrorCode))
 	assert.Equal(t, "client_id in request body inconsistent with Authorization header", parsedError.ErrorDescription)
@@ -333,7 +333,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) {
 	})
 	req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError = new(auth.AccessTokenError)
+	parsedError = new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "invalid_request", string(parsedError.ErrorCode))
 	assert.Equal(t, "client_secret in request body inconsistent with Authorization header", parsedError.ErrorDescription)
@@ -371,7 +371,7 @@ func TestRefreshTokenInvalidation(t *testing.T) {
 		"refresh_token": parsed.RefreshToken,
 	})
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError := new(auth.AccessTokenError)
+	parsedError := new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
 	assert.Equal(t, "invalid empty client secret", parsedError.ErrorDescription)
@@ -384,7 +384,7 @@ func TestRefreshTokenInvalidation(t *testing.T) {
 		"refresh_token": "UNEXPECTED",
 	})
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError = new(auth.AccessTokenError)
+	parsedError = new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
 	assert.Equal(t, "unable to parse refresh token", parsedError.ErrorDescription)
@@ -414,7 +414,7 @@ func TestRefreshTokenInvalidation(t *testing.T) {
 	// repeat request should fail
 	req.Body = io.NopCloser(bytes.NewReader(bs))
 	resp = MakeRequest(t, req, http.StatusBadRequest)
-	parsedError = new(auth.AccessTokenError)
+	parsedError = new(oauth2_provider.AccessTokenError)
 	assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
 	assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
 	assert.Equal(t, "token was already used", parsedError.ErrorDescription)