]> source.dussan.org Git - gitea.git/commitdiff
Make oauth2 code clear. Move oauth2 provider code to their own packages/files (#32148)
authorLunny Xiao <xiaolunwen@gmail.com>
Wed, 2 Oct 2024 00:03:19 +0000 (08:03 +0800)
committerGitHub <noreply@github.com>
Wed, 2 Oct 2024 00:03:19 +0000 (08:03 +0800)
Fix #30266
Replace #31533

13 files changed:
routers/init.go
routers/web/auth/oauth.go
routers/web/auth/oauth2_provider.go [new file with mode: 0644]
routers/web/auth/oauth_test.go
services/auth/oauth2.go
services/auth/source/oauth2/init.go
services/auth/source/oauth2/jwtsigningkey.go [deleted file]
services/auth/source/oauth2/token.go [deleted file]
services/oauth2_provider/access_token.go [new file with mode: 0644]
services/oauth2_provider/init.go [new file with mode: 0644]
services/oauth2_provider/jwtsigningkey.go [new file with mode: 0644]
services/oauth2_provider/token.go [new file with mode: 0644]
tests/integration/oauth_test.go

index fe80dfd2cdd85639f4292dcea9e0b740766327e2..2091f5967acacb3d7a1b62595078f9d3b8bc078a 100644 (file)
@@ -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)
index c61a0a6240432de035cbbe451fef5fed15000db6..ccbb3bebf1f5c7aebc00c2f62f0d834c20704561 100644 (file)
 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(`<a href="%s">@%s</a>`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)))
-       } else {
-               ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)))
-       }
-       ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("<strong>" + html.EscapeString(form.RedirectURI) + "</strong>")
-       // 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 (file)
index 0000000..29827b0
--- /dev/null
@@ -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(`<a href="%s">@%s</a>`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)))
+       } else {
+               ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)))
+       }
+       ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("<strong>" + html.EscapeString(form.RedirectURI) + "</strong>")
+       // 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)
+}
index 4339d9d1ebae8a9f03dc701497f737e0d6b6ad0e..78af97fa9c6693fa86102d214c77a4325759302b 100644 (file)
@@ -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)
 
index 46d851014367517a18051478b9785405620d989b..523998a6345226c92bebf82e3210d9b047502307 100644 (file)
@@ -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()) {
index 5c2568154863ca99728f7671462213037b06b33f..313f375281b2cfa578cff7927d8cc52f2ac5e351 100644 (file)
@@ -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/auth/source/oauth2/jwtsigningkey.go b/services/auth/source/oauth2/jwtsigningkey.go
deleted file mode 100644 (file)
index 070fffe..0000000
+++ /dev/null
@@ -1,404 +0,0 @@
-// Copyright 2021 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package oauth2
-
-import (
-       "crypto/ecdsa"
-       "crypto/ed25519"
-       "crypto/elliptic"
-       "crypto/rand"
-       "crypto/rsa"
-       "crypto/x509"
-       "encoding/base64"
-       "encoding/pem"
-       "fmt"
-       "math/big"
-       "os"
-       "path/filepath"
-       "strings"
-
-       "code.gitea.io/gitea/modules/log"
-       "code.gitea.io/gitea/modules/setting"
-       "code.gitea.io/gitea/modules/util"
-
-       "github.com/golang-jwt/jwt/v5"
-)
-
-// ErrInvalidAlgorithmType represents an invalid algorithm error.
-type ErrInvalidAlgorithmType struct {
-       Algorithm string
-}
-
-func (err ErrInvalidAlgorithmType) Error() string {
-       return fmt.Sprintf("JWT signing algorithm is not supported: %s", err.Algorithm)
-}
-
-// JWTSigningKey represents a algorithm/key pair to sign JWTs
-type JWTSigningKey interface {
-       IsSymmetric() bool
-       SigningMethod() jwt.SigningMethod
-       SignKey() any
-       VerifyKey() any
-       ToJWK() (map[string]string, error)
-       PreProcessToken(*jwt.Token)
-}
-
-type hmacSigningKey struct {
-       signingMethod jwt.SigningMethod
-       secret        []byte
-}
-
-func (key hmacSigningKey) IsSymmetric() bool {
-       return true
-}
-
-func (key hmacSigningKey) SigningMethod() jwt.SigningMethod {
-       return key.signingMethod
-}
-
-func (key hmacSigningKey) SignKey() any {
-       return key.secret
-}
-
-func (key hmacSigningKey) VerifyKey() any {
-       return key.secret
-}
-
-func (key hmacSigningKey) ToJWK() (map[string]string, error) {
-       return map[string]string{
-               "kty": "oct",
-               "alg": key.SigningMethod().Alg(),
-       }, nil
-}
-
-func (key hmacSigningKey) PreProcessToken(*jwt.Token) {}
-
-type rsaSingingKey struct {
-       signingMethod jwt.SigningMethod
-       key           *rsa.PrivateKey
-       id            string
-}
-
-func newRSASingingKey(signingMethod jwt.SigningMethod, key *rsa.PrivateKey) (rsaSingingKey, error) {
-       kid, err := util.CreatePublicKeyFingerprint(key.Public().(*rsa.PublicKey))
-       if err != nil {
-               return rsaSingingKey{}, err
-       }
-
-       return rsaSingingKey{
-               signingMethod,
-               key,
-               base64.RawURLEncoding.EncodeToString(kid),
-       }, nil
-}
-
-func (key rsaSingingKey) IsSymmetric() bool {
-       return false
-}
-
-func (key rsaSingingKey) SigningMethod() jwt.SigningMethod {
-       return key.signingMethod
-}
-
-func (key rsaSingingKey) SignKey() any {
-       return key.key
-}
-
-func (key rsaSingingKey) VerifyKey() any {
-       return key.key.Public()
-}
-
-func (key rsaSingingKey) ToJWK() (map[string]string, error) {
-       pubKey := key.key.Public().(*rsa.PublicKey)
-
-       return map[string]string{
-               "kty": "RSA",
-               "alg": key.SigningMethod().Alg(),
-               "kid": key.id,
-               "e":   base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pubKey.E)).Bytes()),
-               "n":   base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes()),
-       }, nil
-}
-
-func (key rsaSingingKey) PreProcessToken(token *jwt.Token) {
-       token.Header["kid"] = key.id
-}
-
-type eddsaSigningKey struct {
-       signingMethod jwt.SigningMethod
-       key           ed25519.PrivateKey
-       id            string
-}
-
-func newEdDSASingingKey(signingMethod jwt.SigningMethod, key ed25519.PrivateKey) (eddsaSigningKey, error) {
-       kid, err := util.CreatePublicKeyFingerprint(key.Public().(ed25519.PublicKey))
-       if err != nil {
-               return eddsaSigningKey{}, err
-       }
-
-       return eddsaSigningKey{
-               signingMethod,
-               key,
-               base64.RawURLEncoding.EncodeToString(kid),
-       }, nil
-}
-
-func (key eddsaSigningKey) IsSymmetric() bool {
-       return false
-}
-
-func (key eddsaSigningKey) SigningMethod() jwt.SigningMethod {
-       return key.signingMethod
-}
-
-func (key eddsaSigningKey) SignKey() any {
-       return key.key
-}
-
-func (key eddsaSigningKey) VerifyKey() any {
-       return key.key.Public()
-}
-
-func (key eddsaSigningKey) ToJWK() (map[string]string, error) {
-       pubKey := key.key.Public().(ed25519.PublicKey)
-
-       return map[string]string{
-               "alg": key.SigningMethod().Alg(),
-               "kid": key.id,
-               "kty": "OKP",
-               "crv": "Ed25519",
-               "x":   base64.RawURLEncoding.EncodeToString(pubKey),
-       }, nil
-}
-
-func (key eddsaSigningKey) PreProcessToken(token *jwt.Token) {
-       token.Header["kid"] = key.id
-}
-
-type ecdsaSingingKey struct {
-       signingMethod jwt.SigningMethod
-       key           *ecdsa.PrivateKey
-       id            string
-}
-
-func newECDSASingingKey(signingMethod jwt.SigningMethod, key *ecdsa.PrivateKey) (ecdsaSingingKey, error) {
-       kid, err := util.CreatePublicKeyFingerprint(key.Public().(*ecdsa.PublicKey))
-       if err != nil {
-               return ecdsaSingingKey{}, err
-       }
-
-       return ecdsaSingingKey{
-               signingMethod,
-               key,
-               base64.RawURLEncoding.EncodeToString(kid),
-       }, nil
-}
-
-func (key ecdsaSingingKey) IsSymmetric() bool {
-       return false
-}
-
-func (key ecdsaSingingKey) SigningMethod() jwt.SigningMethod {
-       return key.signingMethod
-}
-
-func (key ecdsaSingingKey) SignKey() any {
-       return key.key
-}
-
-func (key ecdsaSingingKey) VerifyKey() any {
-       return key.key.Public()
-}
-
-func (key ecdsaSingingKey) ToJWK() (map[string]string, error) {
-       pubKey := key.key.Public().(*ecdsa.PublicKey)
-
-       return map[string]string{
-               "kty": "EC",
-               "alg": key.SigningMethod().Alg(),
-               "kid": key.id,
-               "crv": pubKey.Params().Name,
-               "x":   base64.RawURLEncoding.EncodeToString(pubKey.X.Bytes()),
-               "y":   base64.RawURLEncoding.EncodeToString(pubKey.Y.Bytes()),
-       }, nil
-}
-
-func (key ecdsaSingingKey) PreProcessToken(token *jwt.Token) {
-       token.Header["kid"] = key.id
-}
-
-// CreateJWTSigningKey creates a signing key from an algorithm / key pair.
-func CreateJWTSigningKey(algorithm string, key any) (JWTSigningKey, error) {
-       var signingMethod jwt.SigningMethod
-       switch algorithm {
-       case "HS256":
-               signingMethod = jwt.SigningMethodHS256
-       case "HS384":
-               signingMethod = jwt.SigningMethodHS384
-       case "HS512":
-               signingMethod = jwt.SigningMethodHS512
-
-       case "RS256":
-               signingMethod = jwt.SigningMethodRS256
-       case "RS384":
-               signingMethod = jwt.SigningMethodRS384
-       case "RS512":
-               signingMethod = jwt.SigningMethodRS512
-
-       case "ES256":
-               signingMethod = jwt.SigningMethodES256
-       case "ES384":
-               signingMethod = jwt.SigningMethodES384
-       case "ES512":
-               signingMethod = jwt.SigningMethodES512
-       case "EdDSA":
-               signingMethod = jwt.SigningMethodEdDSA
-       default:
-               return nil, ErrInvalidAlgorithmType{algorithm}
-       }
-
-       switch signingMethod.(type) {
-       case *jwt.SigningMethodEd25519:
-               privateKey, ok := key.(ed25519.PrivateKey)
-               if !ok {
-                       return nil, jwt.ErrInvalidKeyType
-               }
-               return newEdDSASingingKey(signingMethod, privateKey)
-       case *jwt.SigningMethodECDSA:
-               privateKey, ok := key.(*ecdsa.PrivateKey)
-               if !ok {
-                       return nil, jwt.ErrInvalidKeyType
-               }
-               return newECDSASingingKey(signingMethod, privateKey)
-       case *jwt.SigningMethodRSA:
-               privateKey, ok := key.(*rsa.PrivateKey)
-               if !ok {
-                       return nil, jwt.ErrInvalidKeyType
-               }
-               return newRSASingingKey(signingMethod, privateKey)
-       default:
-               secret, ok := key.([]byte)
-               if !ok {
-                       return nil, jwt.ErrInvalidKeyType
-               }
-               return hmacSigningKey{signingMethod, secret}, nil
-       }
-}
-
-// DefaultSigningKey is the default signing key for JWTs.
-var DefaultSigningKey JWTSigningKey
-
-// InitSigningKey creates the default signing key from settings or creates a random key.
-func InitSigningKey() error {
-       var err error
-       var key any
-
-       switch setting.OAuth2.JWTSigningAlgorithm {
-       case "HS256":
-               fallthrough
-       case "HS384":
-               fallthrough
-       case "HS512":
-               key = setting.GetGeneralTokenSigningSecret()
-       case "RS256":
-               fallthrough
-       case "RS384":
-               fallthrough
-       case "RS512":
-               fallthrough
-       case "ES256":
-               fallthrough
-       case "ES384":
-               fallthrough
-       case "ES512":
-               fallthrough
-       case "EdDSA":
-               key, err = loadOrCreateAsymmetricKey()
-       default:
-               return ErrInvalidAlgorithmType{setting.OAuth2.JWTSigningAlgorithm}
-       }
-
-       if err != nil {
-               return fmt.Errorf("Error while loading or creating JWT key: %w", err)
-       }
-
-       signingKey, err := CreateJWTSigningKey(setting.OAuth2.JWTSigningAlgorithm, key)
-       if err != nil {
-               return err
-       }
-
-       DefaultSigningKey = signingKey
-
-       return nil
-}
-
-// loadOrCreateAsymmetricKey checks if the configured private key exists.
-// If it does not exist a new random key gets generated and saved on the configured path.
-func loadOrCreateAsymmetricKey() (any, error) {
-       keyPath := setting.OAuth2.JWTSigningPrivateKeyFile
-
-       isExist, err := util.IsExist(keyPath)
-       if err != nil {
-               log.Fatal("Unable to check if %s exists. Error: %v", keyPath, err)
-       }
-       if !isExist {
-               err := func() error {
-                       key, err := func() (any, error) {
-                               switch {
-                               case strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS"):
-                                       return rsa.GenerateKey(rand.Reader, 4096)
-                               case setting.OAuth2.JWTSigningAlgorithm == "EdDSA":
-                                       _, pk, err := ed25519.GenerateKey(rand.Reader)
-                                       return pk, err
-                               default:
-                                       return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
-                               }
-                       }()
-                       if err != nil {
-                               return err
-                       }
-
-                       bytes, err := x509.MarshalPKCS8PrivateKey(key)
-                       if err != nil {
-                               return err
-                       }
-
-                       privateKeyPEM := &pem.Block{Type: "PRIVATE KEY", Bytes: bytes}
-
-                       if err := os.MkdirAll(filepath.Dir(keyPath), os.ModePerm); err != nil {
-                               return err
-                       }
-
-                       f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
-                       if err != nil {
-                               return err
-                       }
-                       defer func() {
-                               if err = f.Close(); err != nil {
-                                       log.Error("Close: %v", err)
-                               }
-                       }()
-
-                       return pem.Encode(f, privateKeyPEM)
-               }()
-               if err != nil {
-                       log.Fatal("Error generating private key: %v", err)
-                       return nil, err
-               }
-       }
-
-       bytes, err := os.ReadFile(keyPath)
-       if err != nil {
-               return nil, err
-       }
-
-       block, _ := pem.Decode(bytes)
-       if block == nil {
-               return nil, fmt.Errorf("no valid PEM data found in %s", keyPath)
-       } else if block.Type != "PRIVATE KEY" {
-               return nil, fmt.Errorf("expected PRIVATE KEY, got %s in %s", block.Type, keyPath)
-       }
-
-       return x509.ParsePKCS8PrivateKey(block.Bytes)
-}
diff --git a/services/auth/source/oauth2/token.go b/services/auth/source/oauth2/token.go
deleted file mode 100644 (file)
index 3405619..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright 2021 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package oauth2
-
-import (
-       "fmt"
-       "time"
-
-       "code.gitea.io/gitea/modules/timeutil"
-
-       "github.com/golang-jwt/jwt/v5"
-)
-
-// ___________     __
-// \__    ___/___ |  | __ ____   ____
-//   |    | /  _ \|  |/ // __ \ /    \
-//   |    |(  <_> )    <\  ___/|   |  \
-//   |____| \____/|__|_ \\___  >___|  /
-//                     \/    \/     \/
-
-// Token represents an Oauth grant
-
-// TokenType represents the type of token for an oauth application
-type TokenType 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
-)
-
-// Token represents a JWT token used to authenticate a client
-type Token struct {
-       GrantID int64     `json:"gnt"`
-       Type    TokenType `json:"tt"`
-       Counter int64     `json:"cnt,omitempty"`
-       jwt.RegisteredClaims
-}
-
-// ParseToken parses a signed jwt string
-func ParseToken(jwtToken string, signingKey JWTSigningKey) (*Token, error) {
-       parsedToken, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (any, error) {
-               if token.Method == nil || token.Method.Alg() != signingKey.SigningMethod().Alg() {
-                       return nil, fmt.Errorf("unexpected signing algo: %v", token.Header["alg"])
-               }
-               return signingKey.VerifyKey(), nil
-       })
-       if err != nil {
-               return nil, err
-       }
-       if !parsedToken.Valid {
-               return nil, fmt.Errorf("invalid token")
-       }
-       var token *Token
-       var ok bool
-       if token, ok = parsedToken.Claims.(*Token); !ok || !parsedToken.Valid {
-               return nil, fmt.Errorf("invalid token")
-       }
-       return token, nil
-}
-
-// SignToken signs the token with the JWT secret
-func (token *Token) SignToken(signingKey JWTSigningKey) (string, error) {
-       token.IssuedAt = jwt.NewNumericDate(time.Now())
-       jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token)
-       signingKey.PreProcessToken(jwtToken)
-       return jwtToken.SignedString(signingKey.SignKey())
-}
-
-// OIDCToken represents an OpenID Connect id_token
-type OIDCToken struct {
-       jwt.RegisteredClaims
-       Nonce string `json:"nonce,omitempty"`
-
-       // Scope profile
-       Name              string             `json:"name,omitempty"`
-       PreferredUsername string             `json:"preferred_username,omitempty"`
-       Profile           string             `json:"profile,omitempty"`
-       Picture           string             `json:"picture,omitempty"`
-       Website           string             `json:"website,omitempty"`
-       Locale            string             `json:"locale,omitempty"`
-       UpdatedAt         timeutil.TimeStamp `json:"updated_at,omitempty"`
-
-       // Scope email
-       Email         string `json:"email,omitempty"`
-       EmailVerified bool   `json:"email_verified,omitempty"`
-
-       // Groups are generated by organization and team names
-       Groups []string `json:"groups,omitempty"`
-}
-
-// SignToken signs an id_token with the (symmetric) client secret key
-func (token *OIDCToken) SignToken(signingKey JWTSigningKey) (string, error) {
-       token.IssuedAt = jwt.NewNumericDate(time.Now())
-       jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token)
-       signingKey.PreProcessToken(jwtToken)
-       return jwtToken.SignedString(signingKey.SignKey())
-}
diff --git a/services/oauth2_provider/access_token.go b/services/oauth2_provider/access_token.go
new file mode 100644 (file)
index 0000000..00c960c
--- /dev/null
@@ -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 (file)
index 0000000..e595809
--- /dev/null
@@ -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/oauth2_provider/jwtsigningkey.go b/services/oauth2_provider/jwtsigningkey.go
new file mode 100644 (file)
index 0000000..6c668db
--- /dev/null
@@ -0,0 +1,404 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2_provider //nolint
+
+import (
+       "crypto/ecdsa"
+       "crypto/ed25519"
+       "crypto/elliptic"
+       "crypto/rand"
+       "crypto/rsa"
+       "crypto/x509"
+       "encoding/base64"
+       "encoding/pem"
+       "fmt"
+       "math/big"
+       "os"
+       "path/filepath"
+       "strings"
+
+       "code.gitea.io/gitea/modules/log"
+       "code.gitea.io/gitea/modules/setting"
+       "code.gitea.io/gitea/modules/util"
+
+       "github.com/golang-jwt/jwt/v5"
+)
+
+// ErrInvalidAlgorithmType represents an invalid algorithm error.
+type ErrInvalidAlgorithmType struct {
+       Algorithm string
+}
+
+func (err ErrInvalidAlgorithmType) Error() string {
+       return fmt.Sprintf("JWT signing algorithm is not supported: %s", err.Algorithm)
+}
+
+// JWTSigningKey represents a algorithm/key pair to sign JWTs
+type JWTSigningKey interface {
+       IsSymmetric() bool
+       SigningMethod() jwt.SigningMethod
+       SignKey() any
+       VerifyKey() any
+       ToJWK() (map[string]string, error)
+       PreProcessToken(*jwt.Token)
+}
+
+type hmacSigningKey struct {
+       signingMethod jwt.SigningMethod
+       secret        []byte
+}
+
+func (key hmacSigningKey) IsSymmetric() bool {
+       return true
+}
+
+func (key hmacSigningKey) SigningMethod() jwt.SigningMethod {
+       return key.signingMethod
+}
+
+func (key hmacSigningKey) SignKey() any {
+       return key.secret
+}
+
+func (key hmacSigningKey) VerifyKey() any {
+       return key.secret
+}
+
+func (key hmacSigningKey) ToJWK() (map[string]string, error) {
+       return map[string]string{
+               "kty": "oct",
+               "alg": key.SigningMethod().Alg(),
+       }, nil
+}
+
+func (key hmacSigningKey) PreProcessToken(*jwt.Token) {}
+
+type rsaSingingKey struct {
+       signingMethod jwt.SigningMethod
+       key           *rsa.PrivateKey
+       id            string
+}
+
+func newRSASingingKey(signingMethod jwt.SigningMethod, key *rsa.PrivateKey) (rsaSingingKey, error) {
+       kid, err := util.CreatePublicKeyFingerprint(key.Public().(*rsa.PublicKey))
+       if err != nil {
+               return rsaSingingKey{}, err
+       }
+
+       return rsaSingingKey{
+               signingMethod,
+               key,
+               base64.RawURLEncoding.EncodeToString(kid),
+       }, nil
+}
+
+func (key rsaSingingKey) IsSymmetric() bool {
+       return false
+}
+
+func (key rsaSingingKey) SigningMethod() jwt.SigningMethod {
+       return key.signingMethod
+}
+
+func (key rsaSingingKey) SignKey() any {
+       return key.key
+}
+
+func (key rsaSingingKey) VerifyKey() any {
+       return key.key.Public()
+}
+
+func (key rsaSingingKey) ToJWK() (map[string]string, error) {
+       pubKey := key.key.Public().(*rsa.PublicKey)
+
+       return map[string]string{
+               "kty": "RSA",
+               "alg": key.SigningMethod().Alg(),
+               "kid": key.id,
+               "e":   base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pubKey.E)).Bytes()),
+               "n":   base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes()),
+       }, nil
+}
+
+func (key rsaSingingKey) PreProcessToken(token *jwt.Token) {
+       token.Header["kid"] = key.id
+}
+
+type eddsaSigningKey struct {
+       signingMethod jwt.SigningMethod
+       key           ed25519.PrivateKey
+       id            string
+}
+
+func newEdDSASingingKey(signingMethod jwt.SigningMethod, key ed25519.PrivateKey) (eddsaSigningKey, error) {
+       kid, err := util.CreatePublicKeyFingerprint(key.Public().(ed25519.PublicKey))
+       if err != nil {
+               return eddsaSigningKey{}, err
+       }
+
+       return eddsaSigningKey{
+               signingMethod,
+               key,
+               base64.RawURLEncoding.EncodeToString(kid),
+       }, nil
+}
+
+func (key eddsaSigningKey) IsSymmetric() bool {
+       return false
+}
+
+func (key eddsaSigningKey) SigningMethod() jwt.SigningMethod {
+       return key.signingMethod
+}
+
+func (key eddsaSigningKey) SignKey() any {
+       return key.key
+}
+
+func (key eddsaSigningKey) VerifyKey() any {
+       return key.key.Public()
+}
+
+func (key eddsaSigningKey) ToJWK() (map[string]string, error) {
+       pubKey := key.key.Public().(ed25519.PublicKey)
+
+       return map[string]string{
+               "alg": key.SigningMethod().Alg(),
+               "kid": key.id,
+               "kty": "OKP",
+               "crv": "Ed25519",
+               "x":   base64.RawURLEncoding.EncodeToString(pubKey),
+       }, nil
+}
+
+func (key eddsaSigningKey) PreProcessToken(token *jwt.Token) {
+       token.Header["kid"] = key.id
+}
+
+type ecdsaSingingKey struct {
+       signingMethod jwt.SigningMethod
+       key           *ecdsa.PrivateKey
+       id            string
+}
+
+func newECDSASingingKey(signingMethod jwt.SigningMethod, key *ecdsa.PrivateKey) (ecdsaSingingKey, error) {
+       kid, err := util.CreatePublicKeyFingerprint(key.Public().(*ecdsa.PublicKey))
+       if err != nil {
+               return ecdsaSingingKey{}, err
+       }
+
+       return ecdsaSingingKey{
+               signingMethod,
+               key,
+               base64.RawURLEncoding.EncodeToString(kid),
+       }, nil
+}
+
+func (key ecdsaSingingKey) IsSymmetric() bool {
+       return false
+}
+
+func (key ecdsaSingingKey) SigningMethod() jwt.SigningMethod {
+       return key.signingMethod
+}
+
+func (key ecdsaSingingKey) SignKey() any {
+       return key.key
+}
+
+func (key ecdsaSingingKey) VerifyKey() any {
+       return key.key.Public()
+}
+
+func (key ecdsaSingingKey) ToJWK() (map[string]string, error) {
+       pubKey := key.key.Public().(*ecdsa.PublicKey)
+
+       return map[string]string{
+               "kty": "EC",
+               "alg": key.SigningMethod().Alg(),
+               "kid": key.id,
+               "crv": pubKey.Params().Name,
+               "x":   base64.RawURLEncoding.EncodeToString(pubKey.X.Bytes()),
+               "y":   base64.RawURLEncoding.EncodeToString(pubKey.Y.Bytes()),
+       }, nil
+}
+
+func (key ecdsaSingingKey) PreProcessToken(token *jwt.Token) {
+       token.Header["kid"] = key.id
+}
+
+// CreateJWTSigningKey creates a signing key from an algorithm / key pair.
+func CreateJWTSigningKey(algorithm string, key any) (JWTSigningKey, error) {
+       var signingMethod jwt.SigningMethod
+       switch algorithm {
+       case "HS256":
+               signingMethod = jwt.SigningMethodHS256
+       case "HS384":
+               signingMethod = jwt.SigningMethodHS384
+       case "HS512":
+               signingMethod = jwt.SigningMethodHS512
+
+       case "RS256":
+               signingMethod = jwt.SigningMethodRS256
+       case "RS384":
+               signingMethod = jwt.SigningMethodRS384
+       case "RS512":
+               signingMethod = jwt.SigningMethodRS512
+
+       case "ES256":
+               signingMethod = jwt.SigningMethodES256
+       case "ES384":
+               signingMethod = jwt.SigningMethodES384
+       case "ES512":
+               signingMethod = jwt.SigningMethodES512
+       case "EdDSA":
+               signingMethod = jwt.SigningMethodEdDSA
+       default:
+               return nil, ErrInvalidAlgorithmType{algorithm}
+       }
+
+       switch signingMethod.(type) {
+       case *jwt.SigningMethodEd25519:
+               privateKey, ok := key.(ed25519.PrivateKey)
+               if !ok {
+                       return nil, jwt.ErrInvalidKeyType
+               }
+               return newEdDSASingingKey(signingMethod, privateKey)
+       case *jwt.SigningMethodECDSA:
+               privateKey, ok := key.(*ecdsa.PrivateKey)
+               if !ok {
+                       return nil, jwt.ErrInvalidKeyType
+               }
+               return newECDSASingingKey(signingMethod, privateKey)
+       case *jwt.SigningMethodRSA:
+               privateKey, ok := key.(*rsa.PrivateKey)
+               if !ok {
+                       return nil, jwt.ErrInvalidKeyType
+               }
+               return newRSASingingKey(signingMethod, privateKey)
+       default:
+               secret, ok := key.([]byte)
+               if !ok {
+                       return nil, jwt.ErrInvalidKeyType
+               }
+               return hmacSigningKey{signingMethod, secret}, nil
+       }
+}
+
+// DefaultSigningKey is the default signing key for JWTs.
+var DefaultSigningKey JWTSigningKey
+
+// InitSigningKey creates the default signing key from settings or creates a random key.
+func InitSigningKey() error {
+       var err error
+       var key any
+
+       switch setting.OAuth2.JWTSigningAlgorithm {
+       case "HS256":
+               fallthrough
+       case "HS384":
+               fallthrough
+       case "HS512":
+               key = setting.GetGeneralTokenSigningSecret()
+       case "RS256":
+               fallthrough
+       case "RS384":
+               fallthrough
+       case "RS512":
+               fallthrough
+       case "ES256":
+               fallthrough
+       case "ES384":
+               fallthrough
+       case "ES512":
+               fallthrough
+       case "EdDSA":
+               key, err = loadOrCreateAsymmetricKey()
+       default:
+               return ErrInvalidAlgorithmType{setting.OAuth2.JWTSigningAlgorithm}
+       }
+
+       if err != nil {
+               return fmt.Errorf("Error while loading or creating JWT key: %w", err)
+       }
+
+       signingKey, err := CreateJWTSigningKey(setting.OAuth2.JWTSigningAlgorithm, key)
+       if err != nil {
+               return err
+       }
+
+       DefaultSigningKey = signingKey
+
+       return nil
+}
+
+// loadOrCreateAsymmetricKey checks if the configured private key exists.
+// If it does not exist a new random key gets generated and saved on the configured path.
+func loadOrCreateAsymmetricKey() (any, error) {
+       keyPath := setting.OAuth2.JWTSigningPrivateKeyFile
+
+       isExist, err := util.IsExist(keyPath)
+       if err != nil {
+               log.Fatal("Unable to check if %s exists. Error: %v", keyPath, err)
+       }
+       if !isExist {
+               err := func() error {
+                       key, err := func() (any, error) {
+                               switch {
+                               case strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS"):
+                                       return rsa.GenerateKey(rand.Reader, 4096)
+                               case setting.OAuth2.JWTSigningAlgorithm == "EdDSA":
+                                       _, pk, err := ed25519.GenerateKey(rand.Reader)
+                                       return pk, err
+                               default:
+                                       return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+                               }
+                       }()
+                       if err != nil {
+                               return err
+                       }
+
+                       bytes, err := x509.MarshalPKCS8PrivateKey(key)
+                       if err != nil {
+                               return err
+                       }
+
+                       privateKeyPEM := &pem.Block{Type: "PRIVATE KEY", Bytes: bytes}
+
+                       if err := os.MkdirAll(filepath.Dir(keyPath), os.ModePerm); err != nil {
+                               return err
+                       }
+
+                       f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
+                       if err != nil {
+                               return err
+                       }
+                       defer func() {
+                               if err = f.Close(); err != nil {
+                                       log.Error("Close: %v", err)
+                               }
+                       }()
+
+                       return pem.Encode(f, privateKeyPEM)
+               }()
+               if err != nil {
+                       log.Fatal("Error generating private key: %v", err)
+                       return nil, err
+               }
+       }
+
+       bytes, err := os.ReadFile(keyPath)
+       if err != nil {
+               return nil, err
+       }
+
+       block, _ := pem.Decode(bytes)
+       if block == nil {
+               return nil, fmt.Errorf("no valid PEM data found in %s", keyPath)
+       } else if block.Type != "PRIVATE KEY" {
+               return nil, fmt.Errorf("expected PRIVATE KEY, got %s in %s", block.Type, keyPath)
+       }
+
+       return x509.ParsePKCS8PrivateKey(block.Bytes)
+}
diff --git a/services/oauth2_provider/token.go b/services/oauth2_provider/token.go
new file mode 100644 (file)
index 0000000..b71b119
--- /dev/null
@@ -0,0 +1,93 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2_provider //nolint
+
+import (
+       "fmt"
+       "time"
+
+       "code.gitea.io/gitea/modules/timeutil"
+
+       "github.com/golang-jwt/jwt/v5"
+)
+
+// Token represents an Oauth grant
+
+// TokenKind represents the type of token for an oauth application
+type TokenKind int
+
+const (
+       // 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"`
+       Kind    TokenKind `json:"tt"`
+       Counter int64     `json:"cnt,omitempty"`
+       jwt.RegisteredClaims
+}
+
+// ParseToken parses a signed jwt string
+func ParseToken(jwtToken string, signingKey JWTSigningKey) (*Token, error) {
+       parsedToken, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (any, error) {
+               if token.Method == nil || token.Method.Alg() != signingKey.SigningMethod().Alg() {
+                       return nil, fmt.Errorf("unexpected signing algo: %v", token.Header["alg"])
+               }
+               return signingKey.VerifyKey(), nil
+       })
+       if err != nil {
+               return nil, err
+       }
+       if !parsedToken.Valid {
+               return nil, fmt.Errorf("invalid token")
+       }
+       var token *Token
+       var ok bool
+       if token, ok = parsedToken.Claims.(*Token); !ok || !parsedToken.Valid {
+               return nil, fmt.Errorf("invalid token")
+       }
+       return token, nil
+}
+
+// SignToken signs the token with the JWT secret
+func (token *Token) SignToken(signingKey JWTSigningKey) (string, error) {
+       token.IssuedAt = jwt.NewNumericDate(time.Now())
+       jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token)
+       signingKey.PreProcessToken(jwtToken)
+       return jwtToken.SignedString(signingKey.SignKey())
+}
+
+// OIDCToken represents an OpenID Connect id_token
+type OIDCToken struct {
+       jwt.RegisteredClaims
+       Nonce string `json:"nonce,omitempty"`
+
+       // Scope profile
+       Name              string             `json:"name,omitempty"`
+       PreferredUsername string             `json:"preferred_username,omitempty"`
+       Profile           string             `json:"profile,omitempty"`
+       Picture           string             `json:"picture,omitempty"`
+       Website           string             `json:"website,omitempty"`
+       Locale            string             `json:"locale,omitempty"`
+       UpdatedAt         timeutil.TimeStamp `json:"updated_at,omitempty"`
+
+       // Scope email
+       Email         string `json:"email,omitempty"`
+       EmailVerified bool   `json:"email_verified,omitempty"`
+
+       // Groups are generated by organization and team names
+       Groups []string `json:"groups,omitempty"`
+}
+
+// SignToken signs an id_token with the (symmetric) client secret key
+func (token *OIDCToken) SignToken(signingKey JWTSigningKey) (string, error) {
+       token.IssuedAt = jwt.NewNumericDate(time.Now())
+       jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token)
+       signingKey.PreProcessToken(jwtToken)
+       return jwtToken.SignedString(signingKey.SignKey())
+}
index b1acf90d147da06df8d0a08248bf1f4ab09da115..b32d365b04d15d84266c0d3f5270d910da265e1b 100644 (file)
@@ -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)