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