// 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" "code.gitea.io/gitea/models/db" 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 := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{ UserID: user.ID, IncludePrivate: true, }) 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 }