Add asymmetric JWT signing (#16010)
* Added asymmetric token signing. * Load signing key from settings. * Added optional kid parameter. * Updated documentation. * Add "kid" to token header.
This commit is contained in:
		
							parent
							
								
									f7cd394680
								
							
						
					
					
						commit
						29695cd6d5
					
				|  | @ -71,7 +71,7 @@ func runGenerateInternalToken(c *cli.Context) error { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func runGenerateLfsJwtSecret(c *cli.Context) error { | func runGenerateLfsJwtSecret(c *cli.Context) error { | ||||||
| 	JWTSecretBase64, err := generate.NewJwtSecret() | 	JWTSecretBase64, err := generate.NewJwtSecretBase64() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -858,7 +858,9 @@ NB: You must have `DISABLE_ROUTER_LOG` set to `false` for this option to take ef | ||||||
| - `ACCESS_TOKEN_EXPIRATION_TIME`: **3600**: Lifetime of an OAuth2 access token in seconds | - `ACCESS_TOKEN_EXPIRATION_TIME`: **3600**: Lifetime of an OAuth2 access token in seconds | ||||||
| - `REFRESH_TOKEN_EXPIRATION_TIME`: **730**: Lifetime of an OAuth2 refresh token in hours | - `REFRESH_TOKEN_EXPIRATION_TIME`: **730**: Lifetime of an OAuth2 refresh token in hours | ||||||
| - `INVALIDATE_REFRESH_TOKENS`: **false**: Check if refresh token has already been used | - `INVALIDATE_REFRESH_TOKENS`: **false**: Check if refresh token has already been used | ||||||
| - `JWT_SECRET`: **\<empty\>**: OAuth2 authentication secret for access and refresh tokens, change this a unique string. | - `JWT_SIGNING_ALGORITHM`: **RS256**: Algorithm used to sign OAuth2 tokens. Valid values: \[`HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`\] | ||||||
|  | - `JWT_SECRET`: **\<empty\>**: OAuth2 authentication secret for access and refresh tokens, change this to a unique string. This setting is only needed if `JWT_SIGNING_ALGORITHM` is set to `HS256`, `HS384` or `HS512`. | ||||||
|  | - `JWT_SIGNING_PRIVATE_KEY_FILE`: **jwt/private.pem**: Private key file path used to sign OAuth2 tokens. The path is relative to `CUSTOM_PATH`. This setting is only needed if `JWT_SIGNING_ALGORITHM` is set to `RS256`, `RS384`, `RS512`, `ES256`, `ES384` or `ES512`. The file must contain a RSA or ECDSA private key in the PKCS8 format. | ||||||
| - `MAX_TOKEN_LENGTH`: **32767**: Maximum length of token/cookie to accept from OAuth2 provider | - `MAX_TOKEN_LENGTH`: **32767**: Maximum length of token/cookie to accept from OAuth2 provider | ||||||
| 
 | 
 | ||||||
| ## i18n (`i18n`) | ## i18n (`i18n`) | ||||||
|  |  | ||||||
|  | @ -23,10 +23,13 @@ Gitea supports acting as an OAuth2 provider to allow third party applications to | ||||||
| 
 | 
 | ||||||
| ## Endpoints | ## Endpoints | ||||||
| 
 | 
 | ||||||
| | Endpoint               | URL                         | | | Endpoint                 | URL                                 | | ||||||
| | ---------------------- | --------------------------- | | | ------------------------ | ----------------------------------- | | ||||||
| | Authorization Endpoint | `/login/oauth/authorize`    | | | OpenID Connect Discovery | `/.well-known/openid-configuration` | | ||||||
| | Access Token Endpoint  | `/login/oauth/access_token` | | | Authorization Endpoint   | `/login/oauth/authorize`            | | ||||||
|  | | Access Token Endpoint    | `/login/oauth/access_token`         | | ||||||
|  | | OpenID Connect UserInfo  | `/login/oauth/userinfo`             | | ||||||
|  | | JSON Web Key Set         | `/login/oauth/keys`                 | | ||||||
| 
 | 
 | ||||||
| ## Supported OAuth2 Grants | ## Supported OAuth2 Grants | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -132,6 +132,9 @@ func GetActiveOAuth2Providers() ([]string, map[string]OAuth2Provider, error) { | ||||||
| 
 | 
 | ||||||
| // InitOAuth2 initialize the OAuth2 lib and register all active OAuth2 providers in the library
 | // InitOAuth2 initialize the OAuth2 lib and register all active OAuth2 providers in the library
 | ||||||
| func InitOAuth2() error { | func InitOAuth2() error { | ||||||
|  | 	if err := oauth2.InitSigningKey(); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
| 	if err := oauth2.Init(x); err != nil { | 	if err := oauth2.Init(x); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -12,8 +12,8 @@ import ( | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
|  | 	"code.gitea.io/gitea/modules/auth/oauth2" | ||||||
| 	"code.gitea.io/gitea/modules/secret" | 	"code.gitea.io/gitea/modules/secret" | ||||||
| 	"code.gitea.io/gitea/modules/setting" |  | ||||||
| 	"code.gitea.io/gitea/modules/timeutil" | 	"code.gitea.io/gitea/modules/timeutil" | ||||||
| 	"code.gitea.io/gitea/modules/util" | 	"code.gitea.io/gitea/modules/util" | ||||||
| 
 | 
 | ||||||
|  | @ -540,10 +540,10 @@ type OAuth2Token struct { | ||||||
| // ParseOAuth2Token parses a singed jwt string
 | // ParseOAuth2Token parses a singed jwt string
 | ||||||
| func ParseOAuth2Token(jwtToken string) (*OAuth2Token, error) { | func ParseOAuth2Token(jwtToken string) (*OAuth2Token, error) { | ||||||
| 	parsedToken, err := jwt.ParseWithClaims(jwtToken, &OAuth2Token{}, func(token *jwt.Token) (interface{}, error) { | 	parsedToken, err := jwt.ParseWithClaims(jwtToken, &OAuth2Token{}, func(token *jwt.Token) (interface{}, error) { | ||||||
| 		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | 		if token.Method == nil || token.Method.Alg() != oauth2.DefaultSigningKey.SigningMethod().Alg() { | ||||||
| 			return nil, fmt.Errorf("unexpected signing algo: %v", token.Header["alg"]) | 			return nil, fmt.Errorf("unexpected signing algo: %v", token.Header["alg"]) | ||||||
| 		} | 		} | ||||||
| 		return setting.OAuth2.JWTSecretBytes, nil | 		return oauth2.DefaultSigningKey.VerifyKey(), nil | ||||||
| 	}) | 	}) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
|  | @ -559,8 +559,9 @@ func ParseOAuth2Token(jwtToken string) (*OAuth2Token, error) { | ||||||
| // SignToken signs the token with the JWT secret
 | // SignToken signs the token with the JWT secret
 | ||||||
| func (token *OAuth2Token) SignToken() (string, error) { | func (token *OAuth2Token) SignToken() (string, error) { | ||||||
| 	token.IssuedAt = time.Now().Unix() | 	token.IssuedAt = time.Now().Unix() | ||||||
| 	jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS512, token) | 	jwtToken := jwt.NewWithClaims(oauth2.DefaultSigningKey.SigningMethod(), token) | ||||||
| 	return jwtToken.SignedString(setting.OAuth2.JWTSecretBytes) | 	oauth2.DefaultSigningKey.PreProcessToken(jwtToken) | ||||||
|  | 	return jwtToken.SignedString(oauth2.DefaultSigningKey.SignKey()) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // OIDCToken represents an OpenID Connect id_token
 | // OIDCToken represents an OpenID Connect id_token
 | ||||||
|  | @ -583,8 +584,9 @@ type OIDCToken struct { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // SignToken signs an id_token with the (symmetric) client secret key
 | // SignToken signs an id_token with the (symmetric) client secret key
 | ||||||
| func (token *OIDCToken) SignToken(clientSecret string) (string, error) { | func (token *OIDCToken) SignToken(signingKey oauth2.JWTSigningKey) (string, error) { | ||||||
| 	token.IssuedAt = time.Now().Unix() | 	token.IssuedAt = time.Now().Unix() | ||||||
| 	jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, token) | 	jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token) | ||||||
| 	return jwtToken.SignedString([]byte(clientSecret)) | 	signingKey.PreProcessToken(jwtToken) | ||||||
|  | 	return jwtToken.SignedString(signingKey.SignKey()) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -0,0 +1,378 @@ | ||||||
|  | // Copyright 2021 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package oauth2 | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"crypto/ecdsa" | ||||||
|  | 	"crypto/elliptic" | ||||||
|  | 	"crypto/rand" | ||||||
|  | 	"crypto/rsa" | ||||||
|  | 	"crypto/sha256" | ||||||
|  | 	"crypto/x509" | ||||||
|  | 	"encoding/base64" | ||||||
|  | 	"encoding/pem" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io/ioutil" | ||||||
|  | 	"math/big" | ||||||
|  | 	"os" | ||||||
|  | 	"path/filepath" | ||||||
|  | 	"strings" | ||||||
|  | 
 | ||||||
|  | 	"code.gitea.io/gitea/modules/generate" | ||||||
|  | 	"code.gitea.io/gitea/modules/log" | ||||||
|  | 	"code.gitea.io/gitea/modules/setting" | ||||||
|  | 	"code.gitea.io/gitea/modules/util" | ||||||
|  | 
 | ||||||
|  | 	"github.com/dgrijalva/jwt-go" | ||||||
|  | 	ini "gopkg.in/ini.v1" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // ErrInvalidAlgorithmType represents an invalid algorithm error.
 | ||||||
|  | type ErrInvalidAlgorithmType struct { | ||||||
|  | 	Algorightm string | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (err ErrInvalidAlgorithmType) Error() string { | ||||||
|  | 	return fmt.Sprintf("JWT signing algorithm is not supported: %s", err.Algorightm) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // JWTSigningKey represents a algorithm/key pair to sign JWTs
 | ||||||
|  | type JWTSigningKey interface { | ||||||
|  | 	IsSymmetric() bool | ||||||
|  | 	SigningMethod() jwt.SigningMethod | ||||||
|  | 	SignKey() interface{} | ||||||
|  | 	VerifyKey() interface{} | ||||||
|  | 	ToJWK() (map[string]string, error) | ||||||
|  | 	PreProcessToken(*jwt.Token) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type hmacSigningKey struct { | ||||||
|  | 	signingMethod jwt.SigningMethod | ||||||
|  | 	secret        []byte | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key hmacSigningKey) IsSymmetric() bool { | ||||||
|  | 	return true | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key hmacSigningKey) SigningMethod() jwt.SigningMethod { | ||||||
|  | 	return key.signingMethod | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key hmacSigningKey) SignKey() interface{} { | ||||||
|  | 	return key.secret | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key hmacSigningKey) VerifyKey() interface{} { | ||||||
|  | 	return key.secret | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key hmacSigningKey) ToJWK() (map[string]string, error) { | ||||||
|  | 	return map[string]string{ | ||||||
|  | 		"kty": "oct", | ||||||
|  | 		"alg": key.SigningMethod().Alg(), | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key hmacSigningKey) PreProcessToken(*jwt.Token) {} | ||||||
|  | 
 | ||||||
|  | type rsaSingingKey struct { | ||||||
|  | 	signingMethod jwt.SigningMethod | ||||||
|  | 	key           *rsa.PrivateKey | ||||||
|  | 	id            string | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func newRSASingingKey(signingMethod jwt.SigningMethod, key *rsa.PrivateKey) (rsaSingingKey, error) { | ||||||
|  | 	kid, err := createPublicKeyFingerprint(key.Public().(*rsa.PublicKey)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return rsaSingingKey{}, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return rsaSingingKey{ | ||||||
|  | 		signingMethod, | ||||||
|  | 		key, | ||||||
|  | 		base64.RawURLEncoding.EncodeToString(kid), | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key rsaSingingKey) IsSymmetric() bool { | ||||||
|  | 	return false | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key rsaSingingKey) SigningMethod() jwt.SigningMethod { | ||||||
|  | 	return key.signingMethod | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key rsaSingingKey) SignKey() interface{} { | ||||||
|  | 	return key.key | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key rsaSingingKey) VerifyKey() interface{} { | ||||||
|  | 	return key.key.Public() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key rsaSingingKey) ToJWK() (map[string]string, error) { | ||||||
|  | 	pubKey := key.key.Public().(*rsa.PublicKey) | ||||||
|  | 
 | ||||||
|  | 	return map[string]string{ | ||||||
|  | 		"kty": "RSA", | ||||||
|  | 		"alg": key.SigningMethod().Alg(), | ||||||
|  | 		"kid": key.id, | ||||||
|  | 		"e":   base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pubKey.E)).Bytes()), | ||||||
|  | 		"n":   base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes()), | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key rsaSingingKey) PreProcessToken(token *jwt.Token) { | ||||||
|  | 	token.Header["kid"] = key.id | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type ecdsaSingingKey struct { | ||||||
|  | 	signingMethod jwt.SigningMethod | ||||||
|  | 	key           *ecdsa.PrivateKey | ||||||
|  | 	id            string | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func newECDSASingingKey(signingMethod jwt.SigningMethod, key *ecdsa.PrivateKey) (ecdsaSingingKey, error) { | ||||||
|  | 	kid, err := createPublicKeyFingerprint(key.Public().(*ecdsa.PublicKey)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return ecdsaSingingKey{}, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return ecdsaSingingKey{ | ||||||
|  | 		signingMethod, | ||||||
|  | 		key, | ||||||
|  | 		base64.RawURLEncoding.EncodeToString(kid), | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key ecdsaSingingKey) IsSymmetric() bool { | ||||||
|  | 	return false | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key ecdsaSingingKey) SigningMethod() jwt.SigningMethod { | ||||||
|  | 	return key.signingMethod | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key ecdsaSingingKey) SignKey() interface{} { | ||||||
|  | 	return key.key | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key ecdsaSingingKey) VerifyKey() interface{} { | ||||||
|  | 	return key.key.Public() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key ecdsaSingingKey) ToJWK() (map[string]string, error) { | ||||||
|  | 	pubKey := key.key.Public().(*ecdsa.PublicKey) | ||||||
|  | 
 | ||||||
|  | 	return map[string]string{ | ||||||
|  | 		"kty": "EC", | ||||||
|  | 		"alg": key.SigningMethod().Alg(), | ||||||
|  | 		"kid": key.id, | ||||||
|  | 		"crv": pubKey.Params().Name, | ||||||
|  | 		"x":   base64.RawURLEncoding.EncodeToString(pubKey.X.Bytes()), | ||||||
|  | 		"y":   base64.RawURLEncoding.EncodeToString(pubKey.Y.Bytes()), | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (key ecdsaSingingKey) PreProcessToken(token *jwt.Token) { | ||||||
|  | 	token.Header["kid"] = key.id | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // createPublicKeyFingerprint creates a fingerprint of the given key.
 | ||||||
|  | // The fingerprint is the sha256 sum of the PKIX structure of the key.
 | ||||||
|  | func createPublicKeyFingerprint(key interface{}) ([]byte, error) { | ||||||
|  | 	bytes, err := x509.MarshalPKIXPublicKey(key) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	checksum := sha256.Sum256(bytes) | ||||||
|  | 
 | ||||||
|  | 	return checksum[:], nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CreateJWTSingingKey creates a signing key from an algorithm / key pair.
 | ||||||
|  | func CreateJWTSingingKey(algorithm string, key interface{}) (JWTSigningKey, error) { | ||||||
|  | 	var signingMethod jwt.SigningMethod | ||||||
|  | 	switch algorithm { | ||||||
|  | 	case "HS256": | ||||||
|  | 		signingMethod = jwt.SigningMethodHS256 | ||||||
|  | 	case "HS384": | ||||||
|  | 		signingMethod = jwt.SigningMethodHS384 | ||||||
|  | 	case "HS512": | ||||||
|  | 		signingMethod = jwt.SigningMethodHS512 | ||||||
|  | 
 | ||||||
|  | 	case "RS256": | ||||||
|  | 		signingMethod = jwt.SigningMethodRS256 | ||||||
|  | 	case "RS384": | ||||||
|  | 		signingMethod = jwt.SigningMethodRS384 | ||||||
|  | 	case "RS512": | ||||||
|  | 		signingMethod = jwt.SigningMethodRS512 | ||||||
|  | 
 | ||||||
|  | 	case "ES256": | ||||||
|  | 		signingMethod = jwt.SigningMethodES256 | ||||||
|  | 	case "ES384": | ||||||
|  | 		signingMethod = jwt.SigningMethodES384 | ||||||
|  | 	case "ES512": | ||||||
|  | 		signingMethod = jwt.SigningMethodES512 | ||||||
|  | 	default: | ||||||
|  | 		return nil, ErrInvalidAlgorithmType{algorithm} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	switch signingMethod.(type) { | ||||||
|  | 	case *jwt.SigningMethodECDSA: | ||||||
|  | 		privateKey, ok := key.(*ecdsa.PrivateKey) | ||||||
|  | 		if !ok { | ||||||
|  | 			return nil, jwt.ErrInvalidKeyType | ||||||
|  | 		} | ||||||
|  | 		return newECDSASingingKey(signingMethod, privateKey) | ||||||
|  | 	case *jwt.SigningMethodRSA: | ||||||
|  | 		privateKey, ok := key.(*rsa.PrivateKey) | ||||||
|  | 		if !ok { | ||||||
|  | 			return nil, jwt.ErrInvalidKeyType | ||||||
|  | 		} | ||||||
|  | 		return newRSASingingKey(signingMethod, privateKey) | ||||||
|  | 	default: | ||||||
|  | 		secret, ok := key.([]byte) | ||||||
|  | 		if !ok { | ||||||
|  | 			return nil, jwt.ErrInvalidKeyType | ||||||
|  | 		} | ||||||
|  | 		return hmacSigningKey{signingMethod, secret}, nil | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // DefaultSigningKey is the default signing key for JWTs.
 | ||||||
|  | var DefaultSigningKey JWTSigningKey | ||||||
|  | 
 | ||||||
|  | // InitSigningKey creates the default signing key from settings or creates a random key.
 | ||||||
|  | func InitSigningKey() error { | ||||||
|  | 	var err error | ||||||
|  | 	var key interface{} | ||||||
|  | 
 | ||||||
|  | 	switch setting.OAuth2.JWTSigningAlgorithm { | ||||||
|  | 	case "HS256": | ||||||
|  | 		fallthrough | ||||||
|  | 	case "HS384": | ||||||
|  | 		fallthrough | ||||||
|  | 	case "HS512": | ||||||
|  | 		key, err = loadOrCreateSymmetricKey() | ||||||
|  | 
 | ||||||
|  | 	case "RS256": | ||||||
|  | 		fallthrough | ||||||
|  | 	case "RS384": | ||||||
|  | 		fallthrough | ||||||
|  | 	case "RS512": | ||||||
|  | 		fallthrough | ||||||
|  | 	case "ES256": | ||||||
|  | 		fallthrough | ||||||
|  | 	case "ES384": | ||||||
|  | 		fallthrough | ||||||
|  | 	case "ES512": | ||||||
|  | 		key, err = loadOrCreateAsymmetricKey() | ||||||
|  | 
 | ||||||
|  | 	default: | ||||||
|  | 		return ErrInvalidAlgorithmType{setting.OAuth2.JWTSigningAlgorithm} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("Error while loading or creating symmetric key: %v", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	signingKey, err := CreateJWTSingingKey(setting.OAuth2.JWTSigningAlgorithm, key) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	DefaultSigningKey = signingKey | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // loadOrCreateSymmetricKey checks if the configured secret is valid.
 | ||||||
|  | // If it is not valid a new secret is created and saved in the configuration file.
 | ||||||
|  | func loadOrCreateSymmetricKey() (interface{}, error) { | ||||||
|  | 	key := make([]byte, 32) | ||||||
|  | 	n, err := base64.RawURLEncoding.Decode(key, []byte(setting.OAuth2.JWTSecretBase64)) | ||||||
|  | 	if err != nil || n != 32 { | ||||||
|  | 		key, err = generate.NewJwtSecret() | ||||||
|  | 		if err != nil { | ||||||
|  | 			log.Fatal("error generating JWT secret: %v", err) | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		setting.CreateOrAppendToCustomConf(func(cfg *ini.File) { | ||||||
|  | 			secretBase64 := base64.RawURLEncoding.EncodeToString(key) | ||||||
|  | 			cfg.Section("oauth2").Key("JWT_SECRET").SetValue(secretBase64) | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return key, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // loadOrCreateAsymmetricKey checks if the configured private key exists.
 | ||||||
|  | // If it does not exist a new random key gets generated and saved on the configured path.
 | ||||||
|  | func loadOrCreateAsymmetricKey() (interface{}, error) { | ||||||
|  | 	keyPath := setting.OAuth2.JWTSigningPrivateKeyFile | ||||||
|  | 
 | ||||||
|  | 	isExist, err := util.IsExist(keyPath) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Fatal("Unable to check if %s exists. Error: %v", keyPath, err) | ||||||
|  | 	} | ||||||
|  | 	if !isExist { | ||||||
|  | 		err := func() error { | ||||||
|  | 			key, err := func() (interface{}, error) { | ||||||
|  | 				if strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS") { | ||||||
|  | 					return rsa.GenerateKey(rand.Reader, 4096) | ||||||
|  | 				} | ||||||
|  | 				return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | ||||||
|  | 			}() | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			bytes, err := x509.MarshalPKCS8PrivateKey(key) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			privateKeyPEM := &pem.Block{Type: "PRIVATE KEY", Bytes: bytes} | ||||||
|  | 
 | ||||||
|  | 			if err := os.MkdirAll(filepath.Dir(keyPath), os.ModePerm); err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 			defer func() { | ||||||
|  | 				if err = f.Close(); err != nil { | ||||||
|  | 					log.Error("Close: %v", err) | ||||||
|  | 				} | ||||||
|  | 			}() | ||||||
|  | 
 | ||||||
|  | 			return pem.Encode(f, privateKeyPEM) | ||||||
|  | 		}() | ||||||
|  | 		if err != nil { | ||||||
|  | 			log.Fatal("Error generating private key: %v", err) | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	bytes, err := ioutil.ReadFile(keyPath) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	block, _ := pem.Decode(bytes) | ||||||
|  | 	if block == nil { | ||||||
|  | 		return nil, fmt.Errorf("no valid PEM data found in %s", keyPath) | ||||||
|  | 	} else if block.Type != "PRIVATE KEY" { | ||||||
|  | 		return nil, fmt.Errorf("expected PRIVATE KEY, got %s in %s", block.Type, keyPath) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return x509.ParsePKCS8PrivateKey(block.Bytes) | ||||||
|  | } | ||||||
|  | @ -38,14 +38,23 @@ func NewInternalToken() (string, error) { | ||||||
| 	return internalToken, nil | 	return internalToken, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // NewJwtSecret generate a new value intended to be used by LFS_JWT_SECRET.
 | // NewJwtSecret generates a new value intended to be used for JWT secrets.
 | ||||||
| func NewJwtSecret() (string, error) { | func NewJwtSecret() ([]byte, error) { | ||||||
| 	JWTSecretBytes := make([]byte, 32) | 	bytes := make([]byte, 32) | ||||||
| 	_, err := io.ReadFull(rand.Reader, JWTSecretBytes) | 	_, err := io.ReadFull(rand.Reader, bytes) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	return bytes, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // NewJwtSecretBase64 generates a new base64 encoded value intended to be used for JWT secrets.
 | ||||||
|  | func NewJwtSecretBase64() (string, error) { | ||||||
|  | 	bytes, err := NewJwtSecret() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return "", err | 		return "", err | ||||||
| 	} | 	} | ||||||
| 	return base64.RawURLEncoding.EncodeToString(JWTSecretBytes), nil | 	return base64.RawURLEncoding.EncodeToString(bytes), nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // NewSecretKey generate a new value intended to be used by SECRET_KEY.
 | // NewSecretKey generate a new value intended to be used by SECRET_KEY.
 | ||||||
|  |  | ||||||
|  | @ -54,7 +54,7 @@ func newLFSService() { | ||||||
| 		n, err := base64.RawURLEncoding.Decode(LFS.JWTSecretBytes, []byte(LFS.JWTSecretBase64)) | 		n, err := base64.RawURLEncoding.Decode(LFS.JWTSecretBytes, []byte(LFS.JWTSecretBase64)) | ||||||
| 
 | 
 | ||||||
| 		if err != nil || n != 32 { | 		if err != nil || n != 32 { | ||||||
| 			LFS.JWTSecretBase64, err = generate.NewJwtSecret() | 			LFS.JWTSecretBase64, err = generate.NewJwtSecretBase64() | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				log.Fatal("Error generating JWT Secret for custom config: %v", err) | 				log.Fatal("Error generating JWT Secret for custom config: %v", err) | ||||||
| 				return | 				return | ||||||
|  |  | ||||||
|  | @ -371,14 +371,17 @@ var ( | ||||||
| 		AccessTokenExpirationTime  int64 | 		AccessTokenExpirationTime  int64 | ||||||
| 		RefreshTokenExpirationTime int64 | 		RefreshTokenExpirationTime int64 | ||||||
| 		InvalidateRefreshTokens    bool | 		InvalidateRefreshTokens    bool | ||||||
| 		JWTSecretBytes             []byte `ini:"-"` | 		JWTSigningAlgorithm        string `ini:"JWT_SIGNING_ALGORITHM"` | ||||||
| 		JWTSecretBase64            string `ini:"JWT_SECRET"` | 		JWTSecretBase64            string `ini:"JWT_SECRET"` | ||||||
|  | 		JWTSigningPrivateKeyFile   string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"` | ||||||
| 		MaxTokenLength             int | 		MaxTokenLength             int | ||||||
| 	}{ | 	}{ | ||||||
| 		Enable:                     true, | 		Enable:                     true, | ||||||
| 		AccessTokenExpirationTime:  3600, | 		AccessTokenExpirationTime:  3600, | ||||||
| 		RefreshTokenExpirationTime: 730, | 		RefreshTokenExpirationTime: 730, | ||||||
| 		InvalidateRefreshTokens:    false, | 		InvalidateRefreshTokens:    false, | ||||||
|  | 		JWTSigningAlgorithm:        "RS256", | ||||||
|  | 		JWTSigningPrivateKeyFile:   "jwt/private.pem", | ||||||
| 		MaxTokenLength:             math.MaxInt16, | 		MaxTokenLength:             math.MaxInt16, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -801,21 +804,8 @@ func NewContext() { | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if OAuth2.Enable { | 	if !filepath.IsAbs(OAuth2.JWTSigningPrivateKeyFile) { | ||||||
| 		OAuth2.JWTSecretBytes = make([]byte, 32) | 		OAuth2.JWTSigningPrivateKeyFile = filepath.Join(CustomPath, OAuth2.JWTSigningPrivateKeyFile) | ||||||
| 		n, err := base64.RawURLEncoding.Decode(OAuth2.JWTSecretBytes, []byte(OAuth2.JWTSecretBase64)) |  | ||||||
| 
 |  | ||||||
| 		if err != nil || n != 32 { |  | ||||||
| 			OAuth2.JWTSecretBase64, err = generate.NewJwtSecret() |  | ||||||
| 			if err != nil { |  | ||||||
| 				log.Fatal("error generating JWT secret: %v", err) |  | ||||||
| 				return |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			CreateOrAppendToCustomConf(func(cfg *ini.File) { |  | ||||||
| 				cfg.Section("oauth2").Key("JWT_SECRET").SetValue(OAuth2.JWTSecretBase64) |  | ||||||
| 			}) |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	sec = Cfg.Section("admin") | 	sec = Cfg.Section("admin") | ||||||
|  |  | ||||||
|  | @ -343,7 +343,7 @@ func SubmitInstall(ctx *context.Context) { | ||||||
| 		cfg.Section("server").Key("LFS_START_SERVER").SetValue("true") | 		cfg.Section("server").Key("LFS_START_SERVER").SetValue("true") | ||||||
| 		cfg.Section("server").Key("LFS_CONTENT_PATH").SetValue(form.LFSRootPath) | 		cfg.Section("server").Key("LFS_CONTENT_PATH").SetValue(form.LFSRootPath) | ||||||
| 		var secretKey string | 		var secretKey string | ||||||
| 		if secretKey, err = generate.NewJwtSecret(); err != nil { | 		if secretKey, err = generate.NewJwtSecretBase64(); err != nil { | ||||||
| 			ctx.RenderWithErr(ctx.Tr("install.lfs_jwt_secret_failed", err), tplInstall, &form) | 			ctx.RenderWithErr(ctx.Tr("install.lfs_jwt_secret_failed", err), tplInstall, &form) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -13,6 +13,7 @@ import ( | ||||||
| 	"strings" | 	"strings" | ||||||
| 
 | 
 | ||||||
| 	"code.gitea.io/gitea/models" | 	"code.gitea.io/gitea/models" | ||||||
|  | 	"code.gitea.io/gitea/modules/auth/oauth2" | ||||||
| 	"code.gitea.io/gitea/modules/base" | 	"code.gitea.io/gitea/modules/base" | ||||||
| 	"code.gitea.io/gitea/modules/context" | 	"code.gitea.io/gitea/modules/context" | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
|  | @ -24,6 +25,7 @@ import ( | ||||||
| 
 | 
 | ||||||
| 	"gitea.com/go-chi/binding" | 	"gitea.com/go-chi/binding" | ||||||
| 	"github.com/dgrijalva/jwt-go" | 	"github.com/dgrijalva/jwt-go" | ||||||
|  | 	jsoniter "github.com/json-iterator/go" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
|  | @ -131,7 +133,7 @@ type AccessTokenResponse struct { | ||||||
| 	IDToken      string    `json:"id_token,omitempty"` | 	IDToken      string    `json:"id_token,omitempty"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func newAccessTokenResponse(grant *models.OAuth2Grant, clientSecret string) (*AccessTokenResponse, *AccessTokenError) { | func newAccessTokenResponse(grant *models.OAuth2Grant, signingKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) { | ||||||
| 	if setting.OAuth2.InvalidateRefreshTokens { | 	if setting.OAuth2.InvalidateRefreshTokens { | ||||||
| 		if err := grant.IncreaseCounter(); err != nil { | 		if err := grant.IncreaseCounter(); err != nil { | ||||||
| 			return nil, &AccessTokenError{ | 			return nil, &AccessTokenError{ | ||||||
|  | @ -223,7 +225,7 @@ func newAccessTokenResponse(grant *models.OAuth2Grant, clientSecret string) (*Ac | ||||||
| 			idToken.EmailVerified = app.User.IsActive | 			idToken.EmailVerified = app.User.IsActive | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		signedIDToken, err = idToken.SignToken(clientSecret) | 		signedIDToken, err = idToken.SignToken(signingKey) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, &AccessTokenError{ | 			return nil, &AccessTokenError{ | ||||||
| 				ErrorCode:        AccessTokenErrorCodeInvalidRequest, | 				ErrorCode:        AccessTokenErrorCodeInvalidRequest, | ||||||
|  | @ -480,12 +482,37 @@ func GrantApplicationOAuth(ctx *context.Context) { | ||||||
| func OIDCWellKnown(ctx *context.Context) { | func OIDCWellKnown(ctx *context.Context) { | ||||||
| 	t := ctx.Render.TemplateLookup("user/auth/oidc_wellknown") | 	t := ctx.Render.TemplateLookup("user/auth/oidc_wellknown") | ||||||
| 	ctx.Resp.Header().Set("Content-Type", "application/json") | 	ctx.Resp.Header().Set("Content-Type", "application/json") | ||||||
|  | 	ctx.Data["SigningKey"] = oauth2.DefaultSigningKey | ||||||
| 	if err := t.Execute(ctx.Resp, ctx.Data); err != nil { | 	if err := t.Execute(ctx.Resp, ctx.Data); err != nil { | ||||||
| 		log.Error("%v", err) | 		log.Error("%v", err) | ||||||
| 		ctx.Error(http.StatusInternalServerError) | 		ctx.Error(http.StatusInternalServerError) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // 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 := jsoniter.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
 | // AccessTokenOAuth manages all access token requests by the client
 | ||||||
| func AccessTokenOAuth(ctx *context.Context) { | func AccessTokenOAuth(ctx *context.Context) { | ||||||
| 	form := *web.GetForm(ctx).(*forms.AccessTokenForm) | 	form := *web.GetForm(ctx).(*forms.AccessTokenForm) | ||||||
|  | @ -513,13 +540,25 @@ func AccessTokenOAuth(ctx *context.Context) { | ||||||
| 			form.ClientSecret = pair[1] | 			form.ClientSecret = pair[1] | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	signingKey := oauth2.DefaultSigningKey | ||||||
|  | 	if signingKey.IsSymmetric() { | ||||||
|  | 		clientKey, err := oauth2.CreateJWTSingingKey(signingKey.SigningMethod().Alg(), []byte(form.ClientSecret)) | ||||||
|  | 		if err != nil { | ||||||
|  | 			handleAccessTokenError(ctx, AccessTokenError{ | ||||||
|  | 				ErrorCode:        AccessTokenErrorCodeInvalidRequest, | ||||||
|  | 				ErrorDescription: "Error creating signing key", | ||||||
|  | 			}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		signingKey = clientKey | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	switch form.GrantType { | 	switch form.GrantType { | ||||||
| 	case "refresh_token": | 	case "refresh_token": | ||||||
| 		handleRefreshToken(ctx, form) | 		handleRefreshToken(ctx, form, signingKey) | ||||||
| 		return |  | ||||||
| 	case "authorization_code": | 	case "authorization_code": | ||||||
| 		handleAuthorizationCode(ctx, form) | 		handleAuthorizationCode(ctx, form, signingKey) | ||||||
| 		return |  | ||||||
| 	default: | 	default: | ||||||
| 		handleAccessTokenError(ctx, AccessTokenError{ | 		handleAccessTokenError(ctx, AccessTokenError{ | ||||||
| 			ErrorCode:        AccessTokenErrorCodeUnsupportedGrantType, | 			ErrorCode:        AccessTokenErrorCodeUnsupportedGrantType, | ||||||
|  | @ -528,7 +567,7 @@ func AccessTokenOAuth(ctx *context.Context) { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm) { | func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, signingKey oauth2.JWTSigningKey) { | ||||||
| 	token, err := models.ParseOAuth2Token(form.RefreshToken) | 	token, err := models.ParseOAuth2Token(form.RefreshToken) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		handleAccessTokenError(ctx, AccessTokenError{ | 		handleAccessTokenError(ctx, AccessTokenError{ | ||||||
|  | @ -556,7 +595,7 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm) { | ||||||
| 		log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID) | 		log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	accessToken, tokenErr := newAccessTokenResponse(grant, form.ClientSecret) | 	accessToken, tokenErr := newAccessTokenResponse(grant, signingKey) | ||||||
| 	if tokenErr != nil { | 	if tokenErr != nil { | ||||||
| 		handleAccessTokenError(ctx, *tokenErr) | 		handleAccessTokenError(ctx, *tokenErr) | ||||||
| 		return | 		return | ||||||
|  | @ -564,7 +603,7 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm) { | ||||||
| 	ctx.JSON(http.StatusOK, accessToken) | 	ctx.JSON(http.StatusOK, accessToken) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm) { | func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, signingKey oauth2.JWTSigningKey) { | ||||||
| 	app, err := models.GetOAuth2ApplicationByClientID(form.ClientID) | 	app, err := models.GetOAuth2ApplicationByClientID(form.ClientID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		handleAccessTokenError(ctx, AccessTokenError{ | 		handleAccessTokenError(ctx, AccessTokenError{ | ||||||
|  | @ -618,7 +657,7 @@ func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm) { | ||||||
| 			ErrorDescription: "cannot proceed your request", | 			ErrorDescription: "cannot proceed your request", | ||||||
| 		}) | 		}) | ||||||
| 	} | 	} | ||||||
| 	resp, tokenErr := newAccessTokenResponse(authorizationCode.Grant, form.ClientSecret) | 	resp, tokenErr := newAccessTokenResponse(authorizationCode.Grant, signingKey) | ||||||
| 	if tokenErr != nil { | 	if tokenErr != nil { | ||||||
| 		handleAccessTokenError(ctx, *tokenErr) | 		handleAccessTokenError(ctx, *tokenErr) | ||||||
| 		return | 		return | ||||||
|  |  | ||||||
|  | @ -295,6 +295,7 @@ func RegisterRoutes(m *web.Route) { | ||||||
| 	}, ignSignInAndCsrf, reqSignIn) | 	}, ignSignInAndCsrf, reqSignIn) | ||||||
| 	m.Get("/login/oauth/userinfo", ignSignInAndCsrf, user.InfoOAuth) | 	m.Get("/login/oauth/userinfo", ignSignInAndCsrf, user.InfoOAuth) | ||||||
| 	m.Post("/login/oauth/access_token", CorsHandler(), bindIgnErr(forms.AccessTokenForm{}), ignSignInAndCsrf, user.AccessTokenOAuth) | 	m.Post("/login/oauth/access_token", CorsHandler(), bindIgnErr(forms.AccessTokenForm{}), ignSignInAndCsrf, user.AccessTokenOAuth) | ||||||
|  | 	m.Get("/login/oauth/keys", ignSignInAndCsrf, user.OIDCKeys) | ||||||
| 
 | 
 | ||||||
| 	m.Group("/user/settings", func() { | 	m.Group("/user/settings", func() { | ||||||
| 		m.Get("", userSetting.Profile) | 		m.Get("", userSetting.Profile) | ||||||
|  |  | ||||||
|  | @ -2,11 +2,18 @@ | ||||||
|     "issuer": "{{AppUrl | JSEscape | Safe}}", |     "issuer": "{{AppUrl | JSEscape | Safe}}", | ||||||
|     "authorization_endpoint": "{{AppUrl | JSEscape | Safe}}login/oauth/authorize", |     "authorization_endpoint": "{{AppUrl | JSEscape | Safe}}login/oauth/authorize", | ||||||
|     "token_endpoint": "{{AppUrl | JSEscape | Safe}}login/oauth/access_token", |     "token_endpoint": "{{AppUrl | JSEscape | Safe}}login/oauth/access_token", | ||||||
|  |     "jwks_uri": "{{AppUrl | JSEscape | Safe}}login/oauth/keys", | ||||||
|     "userinfo_endpoint": "{{AppUrl | JSEscape | Safe}}login/oauth/userinfo", |     "userinfo_endpoint": "{{AppUrl | JSEscape | Safe}}login/oauth/userinfo", | ||||||
|     "response_types_supported": [ |     "response_types_supported": [ | ||||||
|         "code", |         "code", | ||||||
|         "id_token" |         "id_token" | ||||||
|     ], |     ], | ||||||
|  |     "id_token_signing_alg_values_supported": [ | ||||||
|  |         "{{.SigningKey.SigningMethod.Alg | JSEscape | Safe}}" | ||||||
|  |     ], | ||||||
|  |     "subject_types_supported": [ | ||||||
|  |         "public" | ||||||
|  |     ], | ||||||
|     "scopes_supported": [ |     "scopes_supported": [ | ||||||
|         "openid", |         "openid", | ||||||
|         "profile", |         "profile", | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue