Files
gitea/services/oauth2_provider/access_token.go
T
Shivansh Gargandwxiaoguang f270534e83 fix: validate "scope" for OAuth2, "state" for OpenID (#39642)
1. Fixes: https://github.com/go-gitea/gitea/issues/39641
* The OAuth2 authorize/grant endpoint accepted and persisted arbitrary
scope strings without validation, unlike personal access token creation
which rejects unknown scopes. Normalize and validate form.Scope before
creating or updating a grant, ignore invalid scopes
* `// TODO: at the moment invalid tokens are treated as "all" to avoid
breaking 1.22 behavior` is fixed.
2. To avoid CSRF attack for OpenID endpoint, add a state check

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-12 20:57:41 +00:00

303 lines
10 KiB
Go

// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2_provider
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
auth "gitea.dev/models/auth"
"gitea.dev/models/db"
org_model "gitea.dev/models/organization"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"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"`
Scope string `json:"scope,omitempty"`
}
func GeneralScopesSupported() []string {
return []string{
"openid",
"profile",
"email",
"groups",
}
}
func splitGrantScopes(grantScopes string) (generalScopes []string, accessScope auth.AccessTokenScope) {
generalScopesSupported := GeneralScopesSupported()
var accessScopes []string
hasAccessScopes := false
for scope := range strings.SplitSeq(grantScopes, " ") {
if scope == "" {
continue
}
if slices.Contains(generalScopesSupported, scope) {
generalScopes = append(generalScopes, scope)
continue
}
hasAccessScopes = true
for accessScope := range strings.SplitSeq(scope, ",") {
if normalizedScope, err := auth.AccessTokenScope(accessScope).Normalize(); err == nil {
accessScopes = append(accessScopes, normalizedScope.StringSlice()...)
}
}
}
if !hasAccessScopes {
return generalScopes, ""
}
// Since version 1.22, if no access scope is provided, then "all" access is granted.
//
// OAuth also permits ignoring requested scopes under server policy: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.3
// If after the removal the invalid scopes, there are no valid scopes left, we return no-token-scope to prevent granting "all" access.
accessScope, _ = auth.AccessTokenScope(strings.Join(accessScopes, ",")).Normalize()
if accessScope == "" {
accessScope = auth.AccessTokenScopeNone
}
return generalScopes, accessScope
}
// NormalizeGrantScope retains OIDC scopes and uses no-token-scope to prevent discarded scopes from granting full access.
func NormalizeGrantScope(grantScopes string) string {
generalScopes, accessScope := splitGrantScopes(grantScopes)
return strings.Join(append(generalScopes, accessScope.StringSlice()...), " ")
}
// GrantAdditionalScopes returns valid scopes coming from grant.
func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope {
_, accessScope := splitGrantScopes(grantScopes)
if accessScope == "" {
return auth.AccessTokenScopeAll // grants without access scopes retain the legacy default
}
return accessScope
}
func NewJwtRegisteredClaimsFromUser(clientID string, grantUserID int64, exp *jwt.NumericDate) jwt.RegisteredClaims {
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
// The issuer value returned MUST be identical to the Issuer URL that was used as the prefix to /.well-known/openid-configuration
// to retrieve the configuration information. This MUST also be identical to the "iss" Claim value in ID Tokens issued from this Issuer.
// * https://accounts.google.com/.well-known/openid-configuration
// * https://github.com/login/oauth/.well-known/openid-configuration
issuer := setting.OAuth2.JWTClaimIssuer
if issuer == "" {
issuer = strings.TrimSuffix(setting.AppURL, "/")
}
return jwt.RegisteredClaims{
Issuer: issuer,
Audience: []string{clientID},
Subject: strconv.FormatInt(grantUserID, 10),
ExpiresAt: exp,
}
}
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: NewJwtRegisteredClaimsFromUser(app.ClientID, grant.UserID, jwt.NewNumericDate(expirationDate.AsTime())),
Nonce: grant.Nonce,
}
if grant.ScopeContains("profile") {
idToken.Name = user.DisplayName()
idToken.PreferredUsername = user.Name
idToken.Profile = user.HTMLURL(ctx)
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") {
accessTokenScope := GrantAdditionalScopes(grant.Scope)
// since version 1.22 does not verify if groups should be public-only,
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
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,
Scope: NormalizeGrantScope(grant.Scope),
TokenType: TokenTypeBearer,
ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
RefreshToken: signedRefreshToken,
IDToken: signedIDToken,
}, nil
}
// GetOAuthGroupsForUser 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, onlyPublicGroups bool) ([]string, error) {
orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
UserID: user.ID,
IncludeVisibility: util.Iif(onlyPublicGroups, api.VisibleTypePublic, api.VisibleTypePrivate),
})
if err != nil {
return nil, fmt.Errorf("GetUserOrgList: %w", err)
}
orgTeams, err := org_model.OrgList(orgs).LoadTeams(ctx)
if err != nil {
return nil, fmt.Errorf("LoadTeams: %w", err)
}
var groups []string
for _, org := range orgs {
groups = append(groups, org.Name)
for _, team := range orgTeams[org.ID] {
if team.IsMember(ctx, user.ID) {
groups = append(groups, org.Name+":"+team.LowerName)
}
}
}
return groups, nil
}