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