summaryrefslogtreecommitdiffstats
path: root/services/auth/source/oauth2
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /services/auth/source/oauth2
parentInitial commit. (diff)
downloadforgejo-upstream.tar.xz
forgejo-upstream.zip
Adding upstream version 9.0.0.upstream/9.0.0upstreamdebian
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'services/auth/source/oauth2')
-rw-r--r--services/auth/source/oauth2/assert_interface_test.go22
-rw-r--r--services/auth/source/oauth2/init.go86
-rw-r--r--services/auth/source/oauth2/jwtsigningkey.go404
-rw-r--r--services/auth/source/oauth2/main_test.go14
-rw-r--r--services/auth/source/oauth2/providers.go190
-rw-r--r--services/auth/source/oauth2/providers_base.go51
-rw-r--r--services/auth/source/oauth2/providers_custom.go123
-rw-r--r--services/auth/source/oauth2/providers_openid.go58
-rw-r--r--services/auth/source/oauth2/providers_simple.go109
-rw-r--r--services/auth/source/oauth2/providers_test.go62
-rw-r--r--services/auth/source/oauth2/source.go51
-rw-r--r--services/auth/source/oauth2/source_authenticate.go19
-rw-r--r--services/auth/source/oauth2/source_callout.go68
-rw-r--r--services/auth/source/oauth2/source_name.go18
-rw-r--r--services/auth/source/oauth2/source_register.go50
-rw-r--r--services/auth/source/oauth2/source_sync.go114
-rw-r--r--services/auth/source/oauth2/source_sync_test.go101
-rw-r--r--services/auth/source/oauth2/store.go98
-rw-r--r--services/auth/source/oauth2/token.go100
-rw-r--r--services/auth/source/oauth2/urlmapping.go77
20 files changed, 1815 insertions, 0 deletions
diff --git a/services/auth/source/oauth2/assert_interface_test.go b/services/auth/source/oauth2/assert_interface_test.go
new file mode 100644
index 0000000..56fe0e4
--- /dev/null
+++ b/services/auth/source/oauth2/assert_interface_test.go
@@ -0,0 +1,22 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2_test
+
+import (
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+)
+
+// This test file exists to assert that our Source exposes the interfaces that we expect
+// It tightly binds the interfaces and implementation without breaking go import cycles
+
+type sourceInterface interface {
+ auth_model.Config
+ auth_model.SourceSettable
+ auth_model.RegisterableSource
+ auth.PasswordAuthenticator
+}
+
+var _ (sourceInterface) = &oauth2.Source{}
diff --git a/services/auth/source/oauth2/init.go b/services/auth/source/oauth2/init.go
new file mode 100644
index 0000000..5c25681
--- /dev/null
+++ b/services/auth/source/oauth2/init.go
@@ -0,0 +1,86 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "context"
+ "encoding/gob"
+ "net/http"
+ "sync"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/google/uuid"
+ "github.com/gorilla/sessions"
+ "github.com/markbates/goth/gothic"
+)
+
+var gothRWMutex = sync.RWMutex{}
+
+// UsersStoreKey is the key for the store
+const UsersStoreKey = "gitea-oauth2-sessions"
+
+// ProviderHeaderKey is the HTTP header key
+const ProviderHeaderKey = "gitea-oauth2-provider"
+
+// Init initializes the oauth source
+func Init(ctx context.Context) error {
+ if err := InitSigningKey(); err != nil {
+ return err
+ }
+
+ // Lock our mutex
+ gothRWMutex.Lock()
+
+ gob.Register(&sessions.Session{})
+
+ gothic.Store = &SessionsStore{
+ maxLength: int64(setting.OAuth2.MaxTokenLength),
+ }
+
+ gothic.SetState = func(req *http.Request) string {
+ return uuid.New().String()
+ }
+
+ gothic.GetProviderName = func(req *http.Request) (string, error) {
+ return req.Header.Get(ProviderHeaderKey), nil
+ }
+
+ // Unlock our mutex
+ gothRWMutex.Unlock()
+
+ return initOAuth2Sources(ctx)
+}
+
+// ResetOAuth2 clears existing OAuth2 providers and loads them from DB
+func ResetOAuth2(ctx context.Context) error {
+ ClearProviders()
+ return initOAuth2Sources(ctx)
+}
+
+// initOAuth2Sources is used to load and register all active OAuth2 providers
+func initOAuth2Sources(ctx context.Context) error {
+ authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
+ IsActive: optional.Some(true),
+ LoginType: auth.OAuth2,
+ })
+ if err != nil {
+ return err
+ }
+ for _, source := range authSources {
+ oauth2Source, ok := source.Cfg.(*Source)
+ if !ok {
+ continue
+ }
+ err := oauth2Source.RegisterSource()
+ if err != nil {
+ log.Critical("Unable to register source: %s due to Error: %v.", source.Name, err)
+ }
+ }
+ return nil
+}
diff --git a/services/auth/source/oauth2/jwtsigningkey.go b/services/auth/source/oauth2/jwtsigningkey.go
new file mode 100644
index 0000000..070fffe
--- /dev/null
+++ b/services/auth/source/oauth2/jwtsigningkey.go
@@ -0,0 +1,404 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// ErrInvalidAlgorithmType represents an invalid algorithm error.
+type ErrInvalidAlgorithmType struct {
+ Algorithm string
+}
+
+func (err ErrInvalidAlgorithmType) Error() string {
+ return fmt.Sprintf("JWT signing algorithm is not supported: %s", err.Algorithm)
+}
+
+// JWTSigningKey represents a algorithm/key pair to sign JWTs
+type JWTSigningKey interface {
+ IsSymmetric() bool
+ SigningMethod() jwt.SigningMethod
+ SignKey() any
+ VerifyKey() any
+ 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() any {
+ return key.secret
+}
+
+func (key hmacSigningKey) VerifyKey() any {
+ 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 := util.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() any {
+ return key.key
+}
+
+func (key rsaSingingKey) VerifyKey() any {
+ 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 eddsaSigningKey struct {
+ signingMethod jwt.SigningMethod
+ key ed25519.PrivateKey
+ id string
+}
+
+func newEdDSASingingKey(signingMethod jwt.SigningMethod, key ed25519.PrivateKey) (eddsaSigningKey, error) {
+ kid, err := util.CreatePublicKeyFingerprint(key.Public().(ed25519.PublicKey))
+ if err != nil {
+ return eddsaSigningKey{}, err
+ }
+
+ return eddsaSigningKey{
+ signingMethod,
+ key,
+ base64.RawURLEncoding.EncodeToString(kid),
+ }, nil
+}
+
+func (key eddsaSigningKey) IsSymmetric() bool {
+ return false
+}
+
+func (key eddsaSigningKey) SigningMethod() jwt.SigningMethod {
+ return key.signingMethod
+}
+
+func (key eddsaSigningKey) SignKey() any {
+ return key.key
+}
+
+func (key eddsaSigningKey) VerifyKey() any {
+ return key.key.Public()
+}
+
+func (key eddsaSigningKey) ToJWK() (map[string]string, error) {
+ pubKey := key.key.Public().(ed25519.PublicKey)
+
+ return map[string]string{
+ "alg": key.SigningMethod().Alg(),
+ "kid": key.id,
+ "kty": "OKP",
+ "crv": "Ed25519",
+ "x": base64.RawURLEncoding.EncodeToString(pubKey),
+ }, nil
+}
+
+func (key eddsaSigningKey) 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 := util.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() any {
+ return key.key
+}
+
+func (key ecdsaSingingKey) VerifyKey() any {
+ 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
+}
+
+// CreateJWTSigningKey creates a signing key from an algorithm / key pair.
+func CreateJWTSigningKey(algorithm string, key any) (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
+ case "EdDSA":
+ signingMethod = jwt.SigningMethodEdDSA
+ default:
+ return nil, ErrInvalidAlgorithmType{algorithm}
+ }
+
+ switch signingMethod.(type) {
+ case *jwt.SigningMethodEd25519:
+ privateKey, ok := key.(ed25519.PrivateKey)
+ if !ok {
+ return nil, jwt.ErrInvalidKeyType
+ }
+ return newEdDSASingingKey(signingMethod, privateKey)
+ 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 any
+
+ switch setting.OAuth2.JWTSigningAlgorithm {
+ case "HS256":
+ fallthrough
+ case "HS384":
+ fallthrough
+ case "HS512":
+ key = setting.GetGeneralTokenSigningSecret()
+ case "RS256":
+ fallthrough
+ case "RS384":
+ fallthrough
+ case "RS512":
+ fallthrough
+ case "ES256":
+ fallthrough
+ case "ES384":
+ fallthrough
+ case "ES512":
+ fallthrough
+ case "EdDSA":
+ key, err = loadOrCreateAsymmetricKey()
+ default:
+ return ErrInvalidAlgorithmType{setting.OAuth2.JWTSigningAlgorithm}
+ }
+
+ if err != nil {
+ return fmt.Errorf("Error while loading or creating JWT key: %w", err)
+ }
+
+ signingKey, err := CreateJWTSigningKey(setting.OAuth2.JWTSigningAlgorithm, key)
+ if err != nil {
+ return err
+ }
+
+ DefaultSigningKey = signingKey
+
+ return 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() (any, 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() (any, error) {
+ switch {
+ case strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS"):
+ return rsa.GenerateKey(rand.Reader, 4096)
+ case setting.OAuth2.JWTSigningAlgorithm == "EdDSA":
+ _, pk, err := ed25519.GenerateKey(rand.Reader)
+ return pk, err
+ default:
+ 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, 0o600)
+ 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 := os.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)
+}
diff --git a/services/auth/source/oauth2/main_test.go b/services/auth/source/oauth2/main_test.go
new file mode 100644
index 0000000..57c74fd
--- /dev/null
+++ b/services/auth/source/oauth2/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m, &unittest.TestOptions{})
+}
diff --git a/services/auth/source/oauth2/providers.go b/services/auth/source/oauth2/providers.go
new file mode 100644
index 0000000..f2c1bb4
--- /dev/null
+++ b/services/auth/source/oauth2/providers.go
@@ -0,0 +1,190 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html"
+ "html/template"
+ "net/url"
+ "sort"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/markbates/goth"
+)
+
+// Provider is an interface for describing a single OAuth2 provider
+type Provider interface {
+ Name() string
+ DisplayName() string
+ IconHTML(size int) template.HTML
+ CustomURLSettings() *CustomURLSettings
+}
+
+// GothProviderCreator provides a function to create a goth.Provider
+type GothProviderCreator interface {
+ CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error)
+}
+
+// GothProvider is an interface for describing a single OAuth2 provider
+type GothProvider interface {
+ Provider
+ GothProviderCreator
+}
+
+// AuthSourceProvider provides a provider for an AuthSource. Multiple auth sources could use the same registered GothProvider
+// So each auth source should have its own DisplayName and IconHTML for display.
+// The Name is the GothProvider's name, to help to find the GothProvider to sign in.
+// The DisplayName is the auth source config's name, site admin set it on the admin page, the IconURL can also be set there.
+type AuthSourceProvider struct {
+ GothProvider
+ sourceName, iconURL string
+}
+
+func (p *AuthSourceProvider) Name() string {
+ return p.GothProvider.Name()
+}
+
+func (p *AuthSourceProvider) DisplayName() string {
+ return p.sourceName
+}
+
+func (p *AuthSourceProvider) IconHTML(size int) template.HTML {
+ if p.iconURL != "" {
+ img := fmt.Sprintf(`<img class="tw-object-contain tw-mr-2" width="%d" height="%d" src="%s" alt="%s">`,
+ size,
+ size,
+ html.EscapeString(p.iconURL), html.EscapeString(p.DisplayName()),
+ )
+ return template.HTML(img)
+ }
+ return p.GothProvider.IconHTML(size)
+}
+
+// Providers contains the map of registered OAuth2 providers in Gitea (based on goth)
+// key is used to map the OAuth2Provider with the goth provider type (also in AuthSource.OAuth2Config.Provider)
+// value is used to store display data
+var gothProviders = map[string]GothProvider{}
+
+// RegisterGothProvider registers a GothProvider
+func RegisterGothProvider(provider GothProvider) {
+ if _, has := gothProviders[provider.Name()]; has {
+ log.Fatal("Duplicate oauth2provider type provided: %s", provider.Name())
+ }
+ gothProviders[provider.Name()] = provider
+}
+
+// GetSupportedOAuth2Providers returns the map of unconfigured OAuth2 providers
+// key is used as technical name (like in the callbackURL)
+// values to display
+func GetSupportedOAuth2Providers() []Provider {
+ providers := make([]Provider, 0, len(gothProviders))
+
+ for _, provider := range gothProviders {
+ providers = append(providers, provider)
+ }
+ sort.Slice(providers, func(i, j int) bool {
+ return providers[i].Name() < providers[j].Name()
+ })
+ return providers
+}
+
+func CreateProviderFromSource(source *auth.Source) (Provider, error) {
+ oauth2Cfg, ok := source.Cfg.(*Source)
+ if !ok {
+ return nil, fmt.Errorf("invalid OAuth2 source config: %v", oauth2Cfg)
+ }
+ gothProv := gothProviders[oauth2Cfg.Provider]
+ return &AuthSourceProvider{GothProvider: gothProv, sourceName: source.Name, iconURL: oauth2Cfg.IconURL}, nil
+}
+
+// GetOAuth2Providers returns the list of configured OAuth2 providers
+func GetOAuth2Providers(ctx context.Context, isActive optional.Option[bool]) ([]Provider, error) {
+ authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
+ IsActive: isActive,
+ LoginType: auth.OAuth2,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ providers := make([]Provider, 0, len(authSources))
+ for _, source := range authSources {
+ provider, err := CreateProviderFromSource(source)
+ if err != nil {
+ return nil, err
+ }
+ providers = append(providers, provider)
+ }
+
+ sort.Slice(providers, func(i, j int) bool {
+ return providers[i].Name() < providers[j].Name()
+ })
+
+ return providers, nil
+}
+
+// RegisterProviderWithGothic register a OAuth2 provider in goth lib
+func RegisterProviderWithGothic(providerName string, source *Source) error {
+ provider, err := createProvider(providerName, source)
+
+ if err == nil && provider != nil {
+ gothRWMutex.Lock()
+ defer gothRWMutex.Unlock()
+
+ goth.UseProviders(provider)
+ }
+
+ return err
+}
+
+// RemoveProviderFromGothic removes the given OAuth2 provider from the goth lib
+func RemoveProviderFromGothic(providerName string) {
+ gothRWMutex.Lock()
+ defer gothRWMutex.Unlock()
+
+ delete(goth.GetProviders(), providerName)
+}
+
+// ClearProviders clears all OAuth2 providers from the goth lib
+func ClearProviders() {
+ gothRWMutex.Lock()
+ defer gothRWMutex.Unlock()
+
+ goth.ClearProviders()
+}
+
+var ErrAuthSourceNotActivated = errors.New("auth source is not activated")
+
+// used to create different types of goth providers
+func createProvider(providerName string, source *Source) (goth.Provider, error) {
+ callbackURL := setting.AppURL + "user/oauth2/" + url.PathEscape(providerName) + "/callback"
+
+ var provider goth.Provider
+ var err error
+
+ p, ok := gothProviders[source.Provider]
+ if !ok {
+ return nil, ErrAuthSourceNotActivated
+ }
+
+ provider, err = p.CreateGothProvider(providerName, callbackURL, source)
+ if err != nil {
+ return provider, err
+ }
+
+ // always set the name if provider is created so we can support multiple setups of 1 provider
+ if provider != nil {
+ provider.SetName(providerName)
+ }
+
+ return provider, err
+}
diff --git a/services/auth/source/oauth2/providers_base.go b/services/auth/source/oauth2/providers_base.go
new file mode 100644
index 0000000..9d4ab10
--- /dev/null
+++ b/services/auth/source/oauth2/providers_base.go
@@ -0,0 +1,51 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "html/template"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/svg"
+)
+
+// BaseProvider represents a common base for Provider
+type BaseProvider struct {
+ name string
+ displayName string
+}
+
+// Name provides the technical name for this provider
+func (b *BaseProvider) Name() string {
+ return b.name
+}
+
+// DisplayName returns the friendly name for this provider
+func (b *BaseProvider) DisplayName() string {
+ return b.displayName
+}
+
+// IconHTML returns icon HTML for this provider
+func (b *BaseProvider) IconHTML(size int) template.HTML {
+ svgName := "gitea-" + b.name
+ switch b.name {
+ case "gplus":
+ svgName = "gitea-google"
+ case "github":
+ svgName = "octicon-mark-github"
+ }
+ svgHTML := svg.RenderHTML(svgName, size, "tw-mr-2")
+ if svgHTML == "" {
+ log.Error("No SVG icon for oauth2 provider %q", b.name)
+ svgHTML = svg.RenderHTML("gitea-openid", size, "tw-mr-2")
+ }
+ return svgHTML
+}
+
+// CustomURLSettings returns the custom url settings for this provider
+func (b *BaseProvider) CustomURLSettings() *CustomURLSettings {
+ return nil
+}
+
+var _ Provider = &BaseProvider{}
diff --git a/services/auth/source/oauth2/providers_custom.go b/services/auth/source/oauth2/providers_custom.go
new file mode 100644
index 0000000..65cf538
--- /dev/null
+++ b/services/auth/source/oauth2/providers_custom.go
@@ -0,0 +1,123 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/providers/azureadv2"
+ "github.com/markbates/goth/providers/gitea"
+ "github.com/markbates/goth/providers/github"
+ "github.com/markbates/goth/providers/gitlab"
+ "github.com/markbates/goth/providers/mastodon"
+ "github.com/markbates/goth/providers/nextcloud"
+)
+
+// CustomProviderNewFn creates a goth.Provider using a custom url mapping
+type CustomProviderNewFn func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error)
+
+// CustomProvider is a GothProvider that has CustomURL features
+type CustomProvider struct {
+ BaseProvider
+ customURLSettings *CustomURLSettings
+ newFn CustomProviderNewFn
+}
+
+// CustomURLSettings returns the CustomURLSettings for this provider
+func (c *CustomProvider) CustomURLSettings() *CustomURLSettings {
+ return c.customURLSettings
+}
+
+// CreateGothProvider creates a GothProvider from this Provider
+func (c *CustomProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
+ custom := c.customURLSettings.OverrideWith(source.CustomURLMapping)
+
+ return c.newFn(source.ClientID, source.ClientSecret, callbackURL, custom, source.Scopes)
+}
+
+// NewCustomProvider is a constructor function for custom providers
+func NewCustomProvider(name, displayName string, customURLSetting *CustomURLSettings, newFn CustomProviderNewFn) *CustomProvider {
+ return &CustomProvider{
+ BaseProvider: BaseProvider{
+ name: name,
+ displayName: displayName,
+ },
+ customURLSettings: customURLSetting,
+ newFn: newFn,
+ }
+}
+
+var _ GothProvider = &CustomProvider{}
+
+func init() {
+ RegisterGothProvider(NewCustomProvider(
+ "github", "GitHub", &CustomURLSettings{
+ TokenURL: availableAttribute(github.TokenURL),
+ AuthURL: availableAttribute(github.AuthURL),
+ ProfileURL: availableAttribute(github.ProfileURL),
+ EmailURL: availableAttribute(github.EmailURL),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ if setting.OAuth2Client.EnableAutoRegistration {
+ scopes = append(scopes, "user:email")
+ }
+ return github.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, custom.EmailURL, scopes...), nil
+ }))
+
+ RegisterGothProvider(NewCustomProvider(
+ "gitlab", "GitLab", &CustomURLSettings{
+ AuthURL: availableAttribute(gitlab.AuthURL),
+ TokenURL: availableAttribute(gitlab.TokenURL),
+ ProfileURL: availableAttribute(gitlab.ProfileURL),
+ }, func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ scopes = append(scopes, "read_user")
+ return gitlab.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
+ }))
+
+ RegisterGothProvider(NewCustomProvider(
+ "gitea", "Gitea", &CustomURLSettings{
+ TokenURL: requiredAttribute(gitea.TokenURL),
+ AuthURL: requiredAttribute(gitea.AuthURL),
+ ProfileURL: requiredAttribute(gitea.ProfileURL),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ return gitea.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
+ }))
+
+ RegisterGothProvider(NewCustomProvider(
+ "nextcloud", "Nextcloud", &CustomURLSettings{
+ TokenURL: requiredAttribute(nextcloud.TokenURL),
+ AuthURL: requiredAttribute(nextcloud.AuthURL),
+ ProfileURL: requiredAttribute(nextcloud.ProfileURL),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ return nextcloud.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
+ }))
+
+ RegisterGothProvider(NewCustomProvider(
+ "mastodon", "Mastodon", &CustomURLSettings{
+ AuthURL: requiredAttribute(mastodon.InstanceURL),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ return mastodon.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, scopes...), nil
+ }))
+
+ RegisterGothProvider(NewCustomProvider(
+ "azureadv2", "Azure AD v2", &CustomURLSettings{
+ Tenant: requiredAttribute("organizations"),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ azureScopes := make([]azureadv2.ScopeType, len(scopes))
+ for i, scope := range scopes {
+ azureScopes[i] = azureadv2.ScopeType(scope)
+ }
+
+ return azureadv2.New(clientID, secret, callbackURL, azureadv2.ProviderOptions{
+ Tenant: azureadv2.TenantType(custom.Tenant),
+ Scopes: azureScopes,
+ }), nil
+ },
+ ))
+}
diff --git a/services/auth/source/oauth2/providers_openid.go b/services/auth/source/oauth2/providers_openid.go
new file mode 100644
index 0000000..285876d
--- /dev/null
+++ b/services/auth/source/oauth2/providers_openid.go
@@ -0,0 +1,58 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "html/template"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/svg"
+
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/providers/openidConnect"
+)
+
+// OpenIDProvider is a GothProvider for OpenID
+type OpenIDProvider struct{}
+
+// Name provides the technical name for this provider
+func (o *OpenIDProvider) Name() string {
+ return "openidConnect"
+}
+
+// DisplayName returns the friendly name for this provider
+func (o *OpenIDProvider) DisplayName() string {
+ return "OpenID Connect"
+}
+
+// IconHTML returns icon HTML for this provider
+func (o *OpenIDProvider) IconHTML(size int) template.HTML {
+ return svg.RenderHTML("gitea-openid", size, "tw-mr-2")
+}
+
+// CreateGothProvider creates a GothProvider from this Provider
+func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
+ scopes := setting.OAuth2Client.OpenIDConnectScopes
+ if len(scopes) == 0 {
+ scopes = append(scopes, source.Scopes...)
+ }
+
+ provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...)
+ if err != nil {
+ log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
+ }
+ return provider, err
+}
+
+// CustomURLSettings returns the custom url settings for this provider
+func (o *OpenIDProvider) CustomURLSettings() *CustomURLSettings {
+ return nil
+}
+
+var _ GothProvider = &OpenIDProvider{}
+
+func init() {
+ RegisterGothProvider(&OpenIDProvider{})
+}
diff --git a/services/auth/source/oauth2/providers_simple.go b/services/auth/source/oauth2/providers_simple.go
new file mode 100644
index 0000000..e95323a
--- /dev/null
+++ b/services/auth/source/oauth2/providers_simple.go
@@ -0,0 +1,109 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/providers/azuread"
+ "github.com/markbates/goth/providers/bitbucket"
+ "github.com/markbates/goth/providers/discord"
+ "github.com/markbates/goth/providers/dropbox"
+ "github.com/markbates/goth/providers/facebook"
+ "github.com/markbates/goth/providers/google"
+ "github.com/markbates/goth/providers/microsoftonline"
+ "github.com/markbates/goth/providers/twitter"
+ "github.com/markbates/goth/providers/yandex"
+)
+
+// SimpleProviderNewFn create goth.Providers without custom url features
+type SimpleProviderNewFn func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider
+
+// SimpleProvider is a GothProvider which does not have custom url features
+type SimpleProvider struct {
+ BaseProvider
+ scopes []string
+ newFn SimpleProviderNewFn
+}
+
+// CreateGothProvider creates a GothProvider from this Provider
+func (c *SimpleProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
+ scopes := make([]string, len(c.scopes)+len(source.Scopes))
+ copy(scopes, c.scopes)
+ copy(scopes[len(c.scopes):], source.Scopes)
+ return c.newFn(source.ClientID, source.ClientSecret, callbackURL, scopes...), nil
+}
+
+// NewSimpleProvider is a constructor function for simple providers
+func NewSimpleProvider(name, displayName string, scopes []string, newFn SimpleProviderNewFn) *SimpleProvider {
+ return &SimpleProvider{
+ BaseProvider: BaseProvider{
+ name: name,
+ displayName: displayName,
+ },
+ scopes: scopes,
+ newFn: newFn,
+ }
+}
+
+var _ GothProvider = &SimpleProvider{}
+
+func init() {
+ RegisterGothProvider(
+ NewSimpleProvider("bitbucket", "Bitbucket", []string{"account"},
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return bitbucket.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ RegisterGothProvider(
+ NewSimpleProvider("dropbox", "Dropbox", nil,
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return dropbox.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ RegisterGothProvider(NewSimpleProvider("facebook", "Facebook", nil,
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return facebook.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ // named gplus due to legacy gplus -> google migration (Google killed Google+). This ensures old connections still work
+ RegisterGothProvider(NewSimpleProvider("gplus", "Google", []string{"email"},
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ if setting.OAuth2Client.UpdateAvatar || setting.OAuth2Client.EnableAutoRegistration {
+ scopes = append(scopes, "profile")
+ }
+ return google.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ RegisterGothProvider(NewSimpleProvider("twitter", "Twitter", nil,
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return twitter.New(clientKey, secret, callbackURL)
+ }))
+
+ RegisterGothProvider(NewSimpleProvider("discord", "Discord", []string{discord.ScopeIdentify, discord.ScopeEmail},
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return discord.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ // See https://tech.yandex.com/passport/doc/dg/reference/response-docpage/
+ RegisterGothProvider(NewSimpleProvider("yandex", "Yandex", []string{"login:email", "login:info", "login:avatar"},
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return yandex.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ RegisterGothProvider(NewSimpleProvider(
+ "azuread", "Azure AD", nil,
+ func(clientID, secret, callbackURL string, scopes ...string) goth.Provider {
+ return azuread.New(clientID, secret, callbackURL, nil, scopes...)
+ },
+ ))
+
+ RegisterGothProvider(NewSimpleProvider(
+ "microsoftonline", "Microsoft Online", nil,
+ func(clientID, secret, callbackURL string, scopes ...string) goth.Provider {
+ return microsoftonline.New(clientID, secret, callbackURL, scopes...)
+ },
+ ))
+}
diff --git a/services/auth/source/oauth2/providers_test.go b/services/auth/source/oauth2/providers_test.go
new file mode 100644
index 0000000..353816c
--- /dev/null
+++ b/services/auth/source/oauth2/providers_test.go
@@ -0,0 +1,62 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "time"
+
+ "github.com/markbates/goth"
+ "golang.org/x/oauth2"
+)
+
+type fakeProvider struct{}
+
+func (p *fakeProvider) Name() string {
+ return "fake"
+}
+
+func (p *fakeProvider) SetName(name string) {}
+
+func (p *fakeProvider) BeginAuth(state string) (goth.Session, error) {
+ return nil, nil
+}
+
+func (p *fakeProvider) UnmarshalSession(string) (goth.Session, error) {
+ return nil, nil
+}
+
+func (p *fakeProvider) FetchUser(goth.Session) (goth.User, error) {
+ return goth.User{}, nil
+}
+
+func (p *fakeProvider) Debug(bool) {
+}
+
+func (p *fakeProvider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
+ switch refreshToken {
+ case "expired":
+ return nil, &oauth2.RetrieveError{
+ ErrorCode: "invalid_grant",
+ }
+ default:
+ return &oauth2.Token{
+ AccessToken: "token",
+ TokenType: "Bearer",
+ RefreshToken: "refresh",
+ Expiry: time.Now().Add(time.Hour),
+ }, nil
+ }
+}
+
+func (p *fakeProvider) RefreshTokenAvailable() bool {
+ return true
+}
+
+func init() {
+ RegisterGothProvider(
+ NewSimpleProvider("fake", "Fake", []string{"account"},
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return &fakeProvider{}
+ }))
+}
diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go
new file mode 100644
index 0000000..3454c9a
--- /dev/null
+++ b/services/auth/source/oauth2/source.go
@@ -0,0 +1,51 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/json"
+)
+
+// Source holds configuration for the OAuth2 login source.
+type Source struct {
+ Provider string
+ ClientID string
+ ClientSecret string
+ OpenIDConnectAutoDiscoveryURL string
+ CustomURLMapping *CustomURLMapping
+ IconURL string
+
+ Scopes []string
+ RequiredClaimName string
+ RequiredClaimValue string
+ GroupClaimName string
+ AdminGroup string
+ GroupTeamMap string
+ GroupTeamMapRemoval bool
+ RestrictedGroup string
+ SkipLocalTwoFA bool `json:",omitempty"`
+
+ // reference to the authSource
+ authSource *auth.Source
+}
+
+// FromDB fills up an OAuth2Config from serialized format.
+func (source *Source) FromDB(bs []byte) error {
+ return json.UnmarshalHandleDoubleEncode(bs, &source)
+}
+
+// ToDB exports an OAuth2Config to a serialized format.
+func (source *Source) ToDB() ([]byte, error) {
+ return json.Marshal(source)
+}
+
+// SetAuthSource sets the related AuthSource
+func (source *Source) SetAuthSource(authSource *auth.Source) {
+ source.authSource = authSource
+}
+
+func init() {
+ auth.RegisterTypeConfig(auth.OAuth2, &Source{})
+}
diff --git a/services/auth/source/oauth2/source_authenticate.go b/services/auth/source/oauth2/source_authenticate.go
new file mode 100644
index 0000000..bbda35d
--- /dev/null
+++ b/services/auth/source/oauth2/source_authenticate.go
@@ -0,0 +1,19 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "context"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/services/auth/source/db"
+)
+
+// Authenticate falls back to the db authenticator
+func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
+ return db.Authenticate(ctx, user, login, password)
+}
+
+// NB: Oauth2 does not implement LocalTwoFASkipper for password authentication
+// as its password authentication drops to db authentication
diff --git a/services/auth/source/oauth2/source_callout.go b/services/auth/source/oauth2/source_callout.go
new file mode 100644
index 0000000..f95a80f
--- /dev/null
+++ b/services/auth/source/oauth2/source_callout.go
@@ -0,0 +1,68 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "net/http"
+ "net/url"
+
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/gothic"
+)
+
+// Callout redirects request/response pair to authenticate against the provider
+func (source *Source) Callout(request *http.Request, response http.ResponseWriter, codeChallengeS256 string) error {
+ // not sure if goth is thread safe (?) when using multiple providers
+ request.Header.Set(ProviderHeaderKey, source.authSource.Name)
+
+ var querySuffix string
+ if codeChallengeS256 != "" {
+ querySuffix = "&" + url.Values{
+ "code_challenge_method": []string{"S256"},
+ "code_challenge": []string{codeChallengeS256},
+ }.Encode()
+ }
+
+ // don't use the default gothic begin handler to prevent issues when some error occurs
+ // normally the gothic library will write some custom stuff to the response instead of our own nice error page
+ // gothic.BeginAuthHandler(response, request)
+
+ gothRWMutex.RLock()
+ defer gothRWMutex.RUnlock()
+
+ url, err := gothic.GetAuthURL(response, request)
+ if err == nil {
+ // hacky way to set the code_challenge, but no better way until
+ // https://github.com/markbates/goth/issues/516 is resolved
+ http.Redirect(response, request, url+querySuffix, http.StatusTemporaryRedirect)
+ }
+ return err
+}
+
+// Callback handles OAuth callback, resolve to a goth user and send back to original url
+// this will trigger a new authentication request, but because we save it in the session we can use that
+func (source *Source) Callback(request *http.Request, response http.ResponseWriter, codeVerifier string) (goth.User, error) {
+ // not sure if goth is thread safe (?) when using multiple providers
+ request.Header.Set(ProviderHeaderKey, source.authSource.Name)
+
+ if codeVerifier != "" {
+ // hacky way to set the code_verifier...
+ // Will be picked up inside CompleteUserAuth: params := req.URL.Query()
+ // https://github.com/markbates/goth/pull/474/files
+ request = request.Clone(request.Context())
+ q := request.URL.Query()
+ q.Add("code_verifier", codeVerifier)
+ request.URL.RawQuery = q.Encode()
+ }
+
+ gothRWMutex.RLock()
+ defer gothRWMutex.RUnlock()
+
+ user, err := gothic.CompleteUserAuth(response, request)
+ if err != nil {
+ return user, err
+ }
+
+ return user, nil
+}
diff --git a/services/auth/source/oauth2/source_name.go b/services/auth/source/oauth2/source_name.go
new file mode 100644
index 0000000..eee789e
--- /dev/null
+++ b/services/auth/source/oauth2/source_name.go
@@ -0,0 +1,18 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+// Name returns the provider name of this source
+func (source *Source) Name() string {
+ return source.Provider
+}
+
+// DisplayName returns the display name of this source
+func (source *Source) DisplayName() string {
+ provider, has := gothProviders[source.Provider]
+ if !has {
+ return source.Provider
+ }
+ return provider.DisplayName()
+}
diff --git a/services/auth/source/oauth2/source_register.go b/services/auth/source/oauth2/source_register.go
new file mode 100644
index 0000000..82a36ac
--- /dev/null
+++ b/services/auth/source/oauth2/source_register.go
@@ -0,0 +1,50 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "fmt"
+)
+
+// RegisterSource causes an OAuth2 configuration to be registered
+func (source *Source) RegisterSource() error {
+ err := RegisterProviderWithGothic(source.authSource.Name, source)
+ return wrapOpenIDConnectInitializeError(err, source.authSource.Name, source)
+}
+
+// UnregisterSource causes an OAuth2 configuration to be unregistered
+func (source *Source) UnregisterSource() error {
+ RemoveProviderFromGothic(source.authSource.Name)
+ return nil
+}
+
+// ErrOpenIDConnectInitialize represents a "OpenIDConnectInitialize" kind of error.
+type ErrOpenIDConnectInitialize struct {
+ OpenIDConnectAutoDiscoveryURL string
+ ProviderName string
+ Cause error
+}
+
+// IsErrOpenIDConnectInitialize checks if an error is a ExternalLoginUserAlreadyExist.
+func IsErrOpenIDConnectInitialize(err error) bool {
+ _, ok := err.(ErrOpenIDConnectInitialize)
+ return ok
+}
+
+func (err ErrOpenIDConnectInitialize) Error() string {
+ return fmt.Sprintf("Failed to initialize OpenID Connect Provider with name '%s' with url '%s': %v", err.ProviderName, err.OpenIDConnectAutoDiscoveryURL, err.Cause)
+}
+
+func (err ErrOpenIDConnectInitialize) Unwrap() error {
+ return err.Cause
+}
+
+// wrapOpenIDConnectInitializeError is used to wrap the error but this cannot be done in modules/auth/oauth2
+// inside oauth2: import cycle not allowed models -> modules/auth/oauth2 -> models
+func wrapOpenIDConnectInitializeError(err error, providerName string, source *Source) error {
+ if err != nil && source.Provider == "openidConnect" {
+ err = ErrOpenIDConnectInitialize{ProviderName: providerName, OpenIDConnectAutoDiscoveryURL: source.OpenIDConnectAutoDiscoveryURL, Cause: err}
+ }
+ return err
+}
diff --git a/services/auth/source/oauth2/source_sync.go b/services/auth/source/oauth2/source_sync.go
new file mode 100644
index 0000000..5e30313
--- /dev/null
+++ b/services/auth/source/oauth2/source_sync.go
@@ -0,0 +1,114 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "context"
+ "time"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/markbates/goth"
+ "golang.org/x/oauth2"
+)
+
+// Sync causes this OAuth2 source to synchronize its users with the db.
+func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
+ log.Trace("Doing: SyncExternalUsers[%s] %d", source.authSource.Name, source.authSource.ID)
+
+ if !updateExisting {
+ log.Info("SyncExternalUsers[%s] not running since updateExisting is false", source.authSource.Name)
+ return nil
+ }
+
+ provider, err := createProvider(source.authSource.Name, source)
+ if err != nil {
+ return err
+ }
+
+ if !provider.RefreshTokenAvailable() {
+ log.Trace("SyncExternalUsers[%s] provider doesn't support refresh tokens, can't synchronize", source.authSource.Name)
+ return nil
+ }
+
+ opts := user_model.FindExternalUserOptions{
+ HasRefreshToken: true,
+ Expired: true,
+ LoginSourceID: source.authSource.ID,
+ }
+
+ return user_model.IterateExternalLogin(ctx, opts, func(ctx context.Context, u *user_model.ExternalLoginUser) error {
+ return source.refresh(ctx, provider, u)
+ })
+}
+
+func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *user_model.ExternalLoginUser) error {
+ log.Trace("Syncing login_source_id=%d external_id=%s expiration=%s", u.LoginSourceID, u.ExternalID, u.ExpiresAt)
+
+ shouldDisable := false
+
+ token, err := provider.RefreshToken(u.RefreshToken)
+ if err != nil {
+ if err, ok := err.(*oauth2.RetrieveError); ok && err.ErrorCode == "invalid_grant" {
+ // this signals that the token is not valid and the user should be disabled
+ shouldDisable = true
+ } else {
+ return err
+ }
+ }
+
+ user := &user_model.User{
+ LoginName: u.ExternalID,
+ LoginType: auth.OAuth2,
+ LoginSource: u.LoginSourceID,
+ }
+
+ hasUser, err := user_model.GetUser(ctx, user)
+ if err != nil {
+ return err
+ }
+
+ // If the grant is no longer valid, disable the user and
+ // delete local tokens. If the OAuth2 provider still
+ // recognizes them as a valid user, they will be able to login
+ // via their provider and reactivate their account.
+ if shouldDisable {
+ log.Info("SyncExternalUsers[%s] disabling user %d", source.authSource.Name, user.ID)
+
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ if hasUser {
+ user.IsActive = false
+ err := user_model.UpdateUserCols(ctx, user, "is_active")
+ if err != nil {
+ return err
+ }
+ }
+
+ // Delete stored tokens, since they are invalid. This
+ // also provents us from checking this in subsequent runs.
+ u.AccessToken = ""
+ u.RefreshToken = ""
+ u.ExpiresAt = time.Time{}
+
+ return user_model.UpdateExternalUserByExternalID(ctx, u)
+ })
+ }
+
+ // Otherwise, update the tokens
+ u.AccessToken = token.AccessToken
+ u.ExpiresAt = token.Expiry
+
+ // Some providers only update access tokens provide a new
+ // refresh token, so avoid updating it if it's empty
+ if token.RefreshToken != "" {
+ u.RefreshToken = token.RefreshToken
+ }
+
+ err = user_model.UpdateExternalUserByExternalID(ctx, u)
+
+ return err
+}
diff --git a/services/auth/source/oauth2/source_sync_test.go b/services/auth/source/oauth2/source_sync_test.go
new file mode 100644
index 0000000..746df82
--- /dev/null
+++ b/services/auth/source/oauth2/source_sync_test.go
@@ -0,0 +1,101 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "context"
+ "testing"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSource(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ source := &Source{
+ Provider: "fake",
+ authSource: &auth.Source{
+ ID: 12,
+ Type: auth.OAuth2,
+ Name: "fake",
+ IsActive: true,
+ IsSyncEnabled: true,
+ },
+ }
+
+ user := &user_model.User{
+ LoginName: "external",
+ LoginType: auth.OAuth2,
+ LoginSource: source.authSource.ID,
+ Name: "test",
+ Email: "external@example.com",
+ }
+
+ err := user_model.CreateUser(context.Background(), user, &user_model.CreateUserOverwriteOptions{})
+ require.NoError(t, err)
+
+ e := &user_model.ExternalLoginUser{
+ ExternalID: "external",
+ UserID: user.ID,
+ LoginSourceID: user.LoginSource,
+ RefreshToken: "valid",
+ }
+ err = user_model.LinkExternalToUser(context.Background(), user, e)
+ require.NoError(t, err)
+
+ provider, err := createProvider(source.authSource.Name, source)
+ require.NoError(t, err)
+
+ t.Run("refresh", func(t *testing.T) {
+ t.Run("valid", func(t *testing.T) {
+ err := source.refresh(context.Background(), provider, e)
+ require.NoError(t, err)
+
+ e := &user_model.ExternalLoginUser{
+ ExternalID: e.ExternalID,
+ LoginSourceID: e.LoginSourceID,
+ }
+
+ ok, err := user_model.GetExternalLogin(context.Background(), e)
+ require.NoError(t, err)
+ assert.True(t, ok)
+ assert.Equal(t, "refresh", e.RefreshToken)
+ assert.Equal(t, "token", e.AccessToken)
+
+ u, err := user_model.GetUserByID(context.Background(), user.ID)
+ require.NoError(t, err)
+ assert.True(t, u.IsActive)
+ })
+
+ t.Run("expired", func(t *testing.T) {
+ err := source.refresh(context.Background(), provider, &user_model.ExternalLoginUser{
+ ExternalID: "external",
+ UserID: user.ID,
+ LoginSourceID: user.LoginSource,
+ RefreshToken: "expired",
+ })
+ require.NoError(t, err)
+
+ e := &user_model.ExternalLoginUser{
+ ExternalID: e.ExternalID,
+ LoginSourceID: e.LoginSourceID,
+ }
+
+ ok, err := user_model.GetExternalLogin(context.Background(), e)
+ require.NoError(t, err)
+ assert.True(t, ok)
+ assert.Equal(t, "", e.RefreshToken)
+ assert.Equal(t, "", e.AccessToken)
+
+ u, err := user_model.GetUserByID(context.Background(), user.ID)
+ require.NoError(t, err)
+ assert.False(t, u.IsActive)
+ })
+ })
+}
diff --git a/services/auth/source/oauth2/store.go b/services/auth/source/oauth2/store.go
new file mode 100644
index 0000000..e031653
--- /dev/null
+++ b/services/auth/source/oauth2/store.go
@@ -0,0 +1,98 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "encoding/gob"
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/modules/log"
+ session_module "code.gitea.io/gitea/modules/session"
+
+ chiSession "code.forgejo.org/go-chi/session"
+ "github.com/gorilla/sessions"
+)
+
+// SessionsStore creates a gothic store from our session
+type SessionsStore struct {
+ maxLength int64
+}
+
+// Get should return a cached session.
+func (st *SessionsStore) Get(r *http.Request, name string) (*sessions.Session, error) {
+ return st.getOrNew(r, name, false)
+}
+
+// New should create and return a new session.
+//
+// Note that New should never return a nil session, even in the case of
+// an error if using the Registry infrastructure to cache the session.
+func (st *SessionsStore) New(r *http.Request, name string) (*sessions.Session, error) {
+ return st.getOrNew(r, name, true)
+}
+
+// getOrNew gets the session from the chi-session if it exists. Override permits the overriding of an unexpected object.
+func (st *SessionsStore) getOrNew(r *http.Request, name string, override bool) (*sessions.Session, error) {
+ chiStore := chiSession.GetSession(r)
+
+ session := sessions.NewSession(st, name)
+
+ rawData := chiStore.Get(name)
+ if rawData != nil {
+ oldSession, ok := rawData.(*sessions.Session)
+ if ok {
+ session.ID = oldSession.ID
+ session.IsNew = oldSession.IsNew
+ session.Options = oldSession.Options
+ session.Values = oldSession.Values
+
+ return session, nil
+ } else if !override {
+ log.Error("Unexpected object in session at name: %s: %v", name, rawData)
+ return nil, fmt.Errorf("unexpected object in session at name: %s", name)
+ }
+ }
+
+ session.IsNew = override
+ session.ID = chiStore.ID() // Simply copy the session id from the chi store
+
+ return session, chiStore.Set(name, session)
+}
+
+// Save should persist session to the underlying store implementation.
+func (st *SessionsStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
+ chiStore := chiSession.GetSession(r)
+
+ if session.IsNew {
+ _, _ = session_module.RegenerateSession(w, r)
+ session.IsNew = false
+ }
+
+ if err := chiStore.Set(session.Name(), session); err != nil {
+ return err
+ }
+
+ if st.maxLength > 0 {
+ sizeWriter := &sizeWriter{}
+
+ _ = gob.NewEncoder(sizeWriter).Encode(session)
+ if sizeWriter.size > st.maxLength {
+ return fmt.Errorf("encode session: Data too long: %d > %d", sizeWriter.size, st.maxLength)
+ }
+ }
+
+ return chiStore.Release()
+}
+
+type sizeWriter struct {
+ size int64
+}
+
+func (s *sizeWriter) Write(data []byte) (int, error) {
+ s.size += int64(len(data))
+ return len(data), nil
+}
+
+var _ (sessions.Store) = &SessionsStore{}
diff --git a/services/auth/source/oauth2/token.go b/services/auth/source/oauth2/token.go
new file mode 100644
index 0000000..3405619
--- /dev/null
+++ b/services/auth/source/oauth2/token.go
@@ -0,0 +1,100 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "fmt"
+ "time"
+
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// ___________ __
+// \__ ___/___ | | __ ____ ____
+// | | / _ \| |/ // __ \ / \
+// | |( <_> ) <\ ___/| | \
+// |____| \____/|__|_ \\___ >___| /
+// \/ \/ \/
+
+// Token represents an Oauth grant
+
+// TokenType represents the type of token for an oauth application
+type TokenType int
+
+const (
+ // TypeAccessToken is a token with short lifetime to access the api
+ TypeAccessToken TokenType = 0
+ // TypeRefreshToken is token with long lifetime to refresh access tokens obtained by the client
+ TypeRefreshToken = iota
+)
+
+// Token represents a JWT token used to authenticate a client
+type Token struct {
+ GrantID int64 `json:"gnt"`
+ Type TokenType `json:"tt"`
+ Counter int64 `json:"cnt,omitempty"`
+ jwt.RegisteredClaims
+}
+
+// ParseToken parses a signed jwt string
+func ParseToken(jwtToken string, signingKey JWTSigningKey) (*Token, error) {
+ parsedToken, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (any, error) {
+ if token.Method == nil || token.Method.Alg() != signingKey.SigningMethod().Alg() {
+ return nil, fmt.Errorf("unexpected signing algo: %v", token.Header["alg"])
+ }
+ return signingKey.VerifyKey(), nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ if !parsedToken.Valid {
+ return nil, fmt.Errorf("invalid token")
+ }
+ var token *Token
+ var ok bool
+ if token, ok = parsedToken.Claims.(*Token); !ok || !parsedToken.Valid {
+ return nil, fmt.Errorf("invalid token")
+ }
+ return token, nil
+}
+
+// SignToken signs the token with the JWT secret
+func (token *Token) SignToken(signingKey JWTSigningKey) (string, error) {
+ token.IssuedAt = jwt.NewNumericDate(time.Now())
+ jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token)
+ signingKey.PreProcessToken(jwtToken)
+ return jwtToken.SignedString(signingKey.SignKey())
+}
+
+// OIDCToken represents an OpenID Connect id_token
+type OIDCToken struct {
+ jwt.RegisteredClaims
+ Nonce string `json:"nonce,omitempty"`
+
+ // Scope profile
+ Name string `json:"name,omitempty"`
+ PreferredUsername string `json:"preferred_username,omitempty"`
+ Profile string `json:"profile,omitempty"`
+ Picture string `json:"picture,omitempty"`
+ Website string `json:"website,omitempty"`
+ Locale string `json:"locale,omitempty"`
+ UpdatedAt timeutil.TimeStamp `json:"updated_at,omitempty"`
+
+ // Scope email
+ Email string `json:"email,omitempty"`
+ EmailVerified bool `json:"email_verified,omitempty"`
+
+ // Groups are generated by organization and team names
+ Groups []string `json:"groups,omitempty"`
+}
+
+// SignToken signs an id_token with the (symmetric) client secret key
+func (token *OIDCToken) SignToken(signingKey JWTSigningKey) (string, error) {
+ token.IssuedAt = jwt.NewNumericDate(time.Now())
+ jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token)
+ signingKey.PreProcessToken(jwtToken)
+ return jwtToken.SignedString(signingKey.SignKey())
+}
diff --git a/services/auth/source/oauth2/urlmapping.go b/services/auth/source/oauth2/urlmapping.go
new file mode 100644
index 0000000..d0442d5
--- /dev/null
+++ b/services/auth/source/oauth2/urlmapping.go
@@ -0,0 +1,77 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+// CustomURLMapping describes the urls values to use when customizing OAuth2 provider URLs
+type CustomURLMapping struct {
+ AuthURL string `json:",omitempty"`
+ TokenURL string `json:",omitempty"`
+ ProfileURL string `json:",omitempty"`
+ EmailURL string `json:",omitempty"`
+ Tenant string `json:",omitempty"`
+}
+
+// CustomURLSettings describes the urls values and availability to use when customizing OAuth2 provider URLs
+type CustomURLSettings struct {
+ AuthURL Attribute `json:",omitempty"`
+ TokenURL Attribute `json:",omitempty"`
+ ProfileURL Attribute `json:",omitempty"`
+ EmailURL Attribute `json:",omitempty"`
+ Tenant Attribute `json:",omitempty"`
+}
+
+// Attribute describes the availability, and required status for a custom url configuration
+type Attribute struct {
+ Value string
+ Available bool
+ Required bool
+}
+
+func availableAttribute(value string) Attribute {
+ return Attribute{Value: value, Available: true}
+}
+
+func requiredAttribute(value string) Attribute {
+ return Attribute{Value: value, Available: true, Required: true}
+}
+
+// Required is true if any attribute is required
+func (c *CustomURLSettings) Required() bool {
+ if c == nil {
+ return false
+ }
+ if c.AuthURL.Required || c.EmailURL.Required || c.ProfileURL.Required || c.TokenURL.Required || c.Tenant.Required {
+ return true
+ }
+ return false
+}
+
+// OverrideWith copies the current customURLMapping and overrides it with values from the provided mapping
+func (c *CustomURLSettings) OverrideWith(override *CustomURLMapping) *CustomURLMapping {
+ custom := &CustomURLMapping{
+ AuthURL: c.AuthURL.Value,
+ TokenURL: c.TokenURL.Value,
+ ProfileURL: c.ProfileURL.Value,
+ EmailURL: c.EmailURL.Value,
+ Tenant: c.Tenant.Value,
+ }
+ if override != nil {
+ if len(override.AuthURL) > 0 && c.AuthURL.Available {
+ custom.AuthURL = override.AuthURL
+ }
+ if len(override.TokenURL) > 0 && c.TokenURL.Available {
+ custom.TokenURL = override.TokenURL
+ }
+ if len(override.ProfileURL) > 0 && c.ProfileURL.Available {
+ custom.ProfileURL = override.ProfileURL
+ }
+ if len(override.EmailURL) > 0 && c.EmailURL.Available {
+ custom.EmailURL = override.EmailURL
+ }
+ if len(override.Tenant) > 0 && c.Tenant.Available {
+ custom.Tenant = override.Tenant
+ }
+ }
+ return custom
+}