// Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package auth import ( go_context "context" "crypto/sha256" "encoding/base64" "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/util" "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" remote_service "code.gitea.io/gitea/services/remote" user_service "code.gitea.io/gitea/services/user" "code.forgejo.org/go-chi/binding" "github.com/golang-jwt/jwt/v5" "github.com/markbates/goth" "github.com/markbates/goth/gothic" "github.com/markbates/goth/providers/fitbit" "github.com/markbates/goth/providers/openidConnect" "github.com/markbates/goth/providers/zoom" 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") { onlyPublicGroups := ifOnlyPublicGroups(grant.Scope) groups, err := getOAuthGroupsForUser(ctx, user, onlyPublicGroups) 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,omitempty"` } func ifOnlyPublicGroups(scopes string) bool { scopes = strings.ReplaceAll(scopes, ",", " ") scopesList := strings.Fields(scopes) for _, scope := range scopesList { if scope == "all" || scope == "read:organization" || scope == "read:admin" { return false } } return true } // 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), } var token string if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" { auths := strings.Fields(auHead) if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") { token = auths[1] } } _, grantScopes := auth_service.CheckOAuthAccessToken(ctx, token) onlyPublicGroups := ifOnlyPublicGroups(grantScopes) groups, err := getOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups) 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, onlyPublicGroups bool) ([]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 { if setting.OAuth2.EnableAdditionalGrantScopes { if onlyPublicGroups { public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID) if !public && err == nil { continue } } } 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, err) return } // Redirect if user already granted access and the application is confidential. // I.e. always require authorization for public clients as recommended by RFC 6749 Section 10.2 if app.ConfidentialClient && grant != nil { code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod) if err != nil { handleServerError(ctx, form.State, form.RedirectURI, err) return } redirect, err := code.GenerateRedirectURI(form.State) if err != nil { handleServerError(ctx, form.State, form.RedirectURI, err) 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, err) log.Error(err.Error()) return } err = ctx.Session.Set("redirect_uri", form.RedirectURI) if err != nil { handleServerError(ctx, form.State, form.RedirectURI, err) log.Error(err.Error()) return } err = ctx.Session.Set("state", form.State) if err != nil { handleServerError(ctx, form.State, form.RedirectURI, err) 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, err) 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, err) return } redirect, err := code.GenerateRedirectURI(form.State) if err != nil { handleServerError(ctx, form.State, form.RedirectURI, err) 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, err error) { log.Error("OAuth server error: %v", err) 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.Params(":provider") authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) if err != nil { ctx.ServerError("SignIn", err) return } redirectTo := ctx.FormString("redirect_to") if len(redirectTo) > 0 { middleware.SetRedirectToCookie(ctx.Resp, redirectTo) } // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) if err == nil && user != nil { // we got the user without going through the whole OAuth2 authentication flow again handleOAuth2SignIn(ctx, authSource, user, gothUser) return } codeChallenge, err := generateCodeChallenge(ctx, provider) if err != nil { ctx.ServerError("SignIn", fmt.Errorf("could not generate code_challenge: %w", err)) return } if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil { if strings.Contains(err.Error(), "no provider for ") { if err = oauth2.ResetOAuth2(ctx); err != nil { ctx.ServerError("SignIn", err) return } if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil { ctx.ServerError("SignIn", err) } return } ctx.ServerError("SignIn", err) } // redirect is done in oauth2.Auth } // SignInOAuthCallback handles the callback from the given provider func SignInOAuthCallback(ctx *context.Context) { provider := ctx.Params(":provider") if ctx.Req.FormValue("error") != "" { var errorKeyValues []string for k, vv := range ctx.Req.Form { for _, v := range vv { errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v))) } } sort.Strings(errorKeyValues) ctx.Flash.Error(strings.Join(errorKeyValues, "
"), true) } // first look if the provider is still active authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) if err != nil { ctx.ServerError("SignIn", err) return } if authSource == nil { ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider")) return } u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) if err != nil { if user_model.IsErrUserProhibitLogin(err) { uplerr := err.(user_model.ErrUserProhibitLogin) log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err) ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") ctx.HTML(http.StatusOK, "user/auth/prohibit_login") return } if callbackErr, ok := err.(errCallback); ok { log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description) switch callbackErr.Code { case "access_denied": ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied")) case "temporarily_unavailable": ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable")) default: ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error")) } ctx.Redirect(setting.AppSubURL + "/user/login") return } if err, ok := err.(*go_oauth2.RetrieveError); ok { ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true) } ctx.ServerError("UserSignIn", err) return } if u == nil { if ctx.Doer != nil { // attach user to already logged in user err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser) if err != nil { ctx.ServerError("UserLinkAccount", err) return } ctx.Redirect(setting.AppSubURL + "/user/settings/security") return } else if !setting.Service.AllowOnlyInternalRegistration && setting.OAuth2Client.EnableAutoRegistration { // create new user with details from oauth2 provider if gothUser.UserID == "" { log.Error("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name) if authSource.IsOAuth2() && authSource.Cfg.(*oauth2.Source).Provider == "openidConnect" { log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields") } err = fmt.Errorf("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name) ctx.ServerError("CreateUser", err) return } var missingFields []string if gothUser.Email == "" { missingFields = append(missingFields, "email") } if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" { missingFields = append(missingFields, "nickname") } if len(missingFields) > 0 { // we don't have enough information to create an account automatically, // so we prompt the user for the remaining bits log.Trace("OAuth2 Provider %s returned empty or missing fields: %s, prompting the user for them", authSource.Name, missingFields) showLinkingLogin(ctx, gothUser) return } uname, err := getUserName(&gothUser) if err != nil { ctx.ServerError("UserSignIn", err) return } u = &user_model.User{ Name: uname, FullName: gothUser.Name, Email: gothUser.Email, LoginType: auth.OAuth2, LoginSource: authSource.ID, LoginName: gothUser.UserID, } overwriteDefault := &user_model.CreateUserOverwriteOptions{ IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm), } source := authSource.Cfg.(*oauth2.Source) isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser) u.IsAdmin = isAdmin.ValueOrDefault(false) u.IsRestricted = isRestricted.ValueOrDefault(false) if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { // error already handled return } if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { ctx.ServerError("SyncGroupsToTeams", err) return } } else { // no existing user is found, request attach or new account showLinkingLogin(ctx, gothUser) return } } handleOAuth2SignIn(ctx, authSource, u, gothUser) } func claimValueToStringSet(claimValue any) container.Set[string] { var groups []string switch rawGroup := claimValue.(type) { case []string: groups = rawGroup case []any: for _, group := range rawGroup { groups = append(groups, fmt.Sprintf("%s", group)) } default: str := fmt.Sprintf("%s", rawGroup) groups = strings.Split(str, ",") } return container.SetOf(groups...) } func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error { if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) if err != nil { return err } groups := getClaimedGroups(source, gothUser) if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { return err } } return nil } func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] { groupClaims, has := gothUser.RawData[source.GroupClaimName] if !has { return nil } return claimValueToStringSet(groupClaims) } func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) { groups := getClaimedGroups(source, gothUser) if source.AdminGroup != "" { isAdmin = optional.Some(groups.Contains(source.AdminGroup)) } if source.RestrictedGroup != "" { isRestricted = optional.Some(groups.Contains(source.RestrictedGroup)) } return isAdmin, isRestricted } func showLinkingLogin(ctx *context.Context, gothUser goth.User) { if err := updateSession(ctx, nil, map[string]any{ "linkAccountGothUser": gothUser, }); err != nil { ctx.ServerError("updateSession", err) return } ctx.Redirect(setting.AppSubURL + "/user/link_account") } func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) { if setting.OAuth2Client.UpdateAvatar && len(url) > 0 { resp, err := http.Get(url) if err == nil { defer func() { _ = resp.Body.Close() }() } // ignore any error if err == nil && resp.StatusCode == http.StatusOK { data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1)) if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize { _ = user_service.UploadAvatar(ctx, u, data) } } } } func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) needs2FA := false if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA { _, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { ctx.ServerError("UserSignIn", err) return } needs2FA = err == nil } oauth2Source := source.Cfg.(*oauth2.Source) groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap) if err != nil { ctx.ServerError("UnmarshalGroupTeamMapping", err) return } groups := getClaimedGroups(oauth2Source, &gothUser) opts := &user_service.UpdateOptions{} // Reactivate user if they are deactivated if !u.IsActive { opts.IsActive = optional.Some(true) } // Update GroupClaims opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { ctx.ServerError("SyncGroupsToTeams", err) return } } if err := externalaccount.EnsureLinkExternalToUser(ctx, u, gothUser); err != nil { ctx.ServerError("EnsureLinkExternalToUser", err) return } // If this user is enrolled in 2FA and this source doesn't override it, // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. if !needs2FA { // Register last login opts.SetLastLogin = true if err := user_service.UpdateUser(ctx, u, opts); err != nil { ctx.ServerError("UpdateUser", err) return } if err := updateSession(ctx, nil, map[string]any{ "uid": u.ID, }); err != nil { ctx.ServerError("updateSession", err) return } // Clear whatever CSRF cookie has right now, force to generate a new one ctx.Csrf.DeleteCookie(ctx) if err := resetLocale(ctx, u); err != nil { ctx.ServerError("resetLocale", err) return } if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { middleware.DeleteRedirectToCookie(ctx.Resp) ctx.RedirectToFirst(redirectTo) return } ctx.Redirect(setting.AppSubURL + "/") return } if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() { if err := user_service.UpdateUser(ctx, u, opts); err != nil { ctx.ServerError("UpdateUser", err) return } } if err := updateSession(ctx, nil, map[string]any{ // User needs to use 2FA, save data and redirect to 2FA page. "twofaUid": u.ID, "twofaRemember": false, }); err != nil { ctx.ServerError("updateSession", err) return } // If WebAuthn is enrolled -> Redirect to WebAuthn instead regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) if err == nil && len(regs) > 0 { ctx.Redirect(setting.AppSubURL + "/user/webauthn") return } ctx.Redirect(setting.AppSubURL + "/user/two_factor") } // generateCodeChallenge stores a code verifier in the session and returns a S256 code challenge for PKCE func generateCodeChallenge(ctx *context.Context, provider string) (codeChallenge string, err error) { // the `code_verifier` is only forwarded by specific providers // https://codeberg.org/forgejo/forgejo/issues/4033 p, ok := goth.GetProviders()[provider] if !ok { return "", nil } switch p.(type) { default: return "", nil case *openidConnect.Provider, *fitbit.Provider, *zoom.Provider: // those providers forward the `code_verifier` // a code_challenge can be generated } codeVerifier, err := util.CryptoRandomString(43) // 256/log2(62) = 256 bits of entropy (each char having log2(62) of randomness) if err != nil { return "", err } if err = ctx.Session.Set("CodeVerifier", codeVerifier); err != nil { return "", err } return encodeCodeChallenge(codeVerifier) } func encodeCodeChallenge(codeVerifier string) (string, error) { hasher := sha256.New() _, err := io.WriteString(hasher, codeVerifier) codeChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) return codeChallenge, err } // OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful // login the user func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { gothUser, err := oAuth2FetchUser(ctx, authSource, request, response) if err != nil { return nil, goth.User{}, err } if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil { return nil, goth.User{}, err } u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser) return u, gothUser, err } func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) { oauth2Source := authSource.Cfg.(*oauth2.Source) // Make sure that the response is not an error response. errorName := request.FormValue("error") if len(errorName) > 0 { errorDescription := request.FormValue("error_description") // Delete the goth session err := gothic.Logout(response, request) if err != nil { return goth.User{}, err } return goth.User{}, errCallback{ Code: errorName, Description: errorDescription, } } // Proceed to authenticate through goth. codeVerifier, _ := ctx.Session.Get("CodeVerifier").(string) _ = ctx.Session.Delete("CodeVerifier") gothUser, err := oauth2Source.Callback(request, response, codeVerifier) if err != nil { if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") { log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) } return goth.User{}, err } if oauth2Source.RequiredClaimName != "" { claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] if !has { return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} } if oauth2Source.RequiredClaimValue != "" { groups := claimValueToStringSet(claimInterface) if !groups.Contains(oauth2Source.RequiredClaimValue) { return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} } } } return gothUser, nil } func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) { user := &user_model.User{ LoginName: gothUser.UserID, LoginType: auth.OAuth2, LoginSource: authSource.ID, } hasUser, err := user_model.GetUser(ctx, user) if err != nil { return nil, err } if hasUser { return user, nil } log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType) // search in external linked users externalLoginUser := &user_model.ExternalLoginUser{ ExternalID: gothUser.UserID, LoginSourceID: authSource.ID, } hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser) if err != nil { return nil, err } if hasUser { user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID) return user, err } // no user found to login return nil, nil }