diff --git a/models/auth/auth_token.go b/models/auth/auth_token.go index d01ddbca1e..69dc1f8d94 100644 --- a/models/auth/auth_token.go +++ b/models/auth/auth_token.go @@ -11,6 +11,7 @@ import ( "time" "forgejo.org/models/db" + "forgejo.org/modules/optional" "forgejo.org/modules/timeutil" "forgejo.org/modules/util" ) @@ -21,6 +22,9 @@ var ( // Used to store long term authorization tokens. LongTermAuthorization AuthorizationPurpose = "long_term_authorization" + // Long-term token for OAuth2/OIDC sign-ins; bounces through the IdP rather than auto-logging in. + LongTermAuthorizationSSO AuthorizationPurpose = "long_term_authorization_sso" + // Used to activate a user account. UserActivation AuthorizationPurpose = "user_activation" @@ -39,7 +43,8 @@ type AuthorizationToken struct { UID int64 `xorm:"INDEX REFERENCES(user, id)"` LookupKey string `xorm:"INDEX UNIQUE"` HashedValidator string - Purpose AuthorizationPurpose `xorm:"NOT NULL DEFAULT 'long_term_authorization'"` + Purpose AuthorizationPurpose `xorm:"NOT NULL DEFAULT 'long_term_authorization'"` + LoginSourceID optional.Option[int64] `xorm:"INDEX REFERENCES(login_source, id)"` Expiry timeutil.TimeStamp } @@ -60,7 +65,7 @@ func (authToken *AuthorizationToken) IsExpired() bool { // GenerateAuthToken generates a new authentication token for the given user. // It returns the lookup key and validator values that should be passed to the // user via a long-term cookie. -func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeStamp, purpose AuthorizationPurpose) (lookupKey, validator string, err error) { +func GenerateAuthToken(ctx context.Context, userID int64, loginSourceID optional.Option[int64], expiry timeutil.TimeStamp, purpose AuthorizationPurpose) (lookupKey, validator string, err error) { // Request 64 random bytes. The first 32 bytes will be used for the lookupKey // and the other 32 bytes will be used for the validator. rBytes := util.CryptoRandomBytes(64) @@ -73,6 +78,7 @@ func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeSt LookupKey: lookupKey, HashedValidator: HashValidator(rBytes[32:]), Purpose: purpose, + LoginSourceID: loginSourceID, }) return lookupKey, validator, err } diff --git a/models/forgejo_migrations/v16b_add-login-source-id-to-forgejo-auth-token.go b/models/forgejo_migrations/v16b_add-login-source-id-to-forgejo-auth-token.go new file mode 100644 index 0000000000..3839e4a25b --- /dev/null +++ b/models/forgejo_migrations/v16b_add-login-source-id-to-forgejo-auth-token.go @@ -0,0 +1,25 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations + +import ( + "forgejo.org/modules/optional" + + "xorm.io/xorm" +) + +func init() { + registerMigration(&Migration{ + Description: "add login_source_id column to forgejo_auth_token", + Upgrade: addLoginSourceIDToForgejoAuthToken, + }) +} + +func addLoginSourceIDToForgejoAuthToken(x *xorm.Engine) error { + type ForgejoAuthToken struct { + LoginSourceID optional.Option[int64] `xorm:"INDEX REFERENCES(login_source, id)"` + } + _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ForgejoAuthToken)) + return err +} diff --git a/models/user/user.go b/models/user/user.go index 2c20cd977d..80843cfed9 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -362,7 +362,7 @@ func (u *User) OrganisationLink() string { // GenerateEmailAuthorizationCode generates an activation code based for the user for the specified purpose. // The standard expiry is ActiveCodeLives minutes. func (u *User) GenerateEmailAuthorizationCode(ctx context.Context, purpose auth.AuthorizationPurpose) (string, error) { - lookup, validator, err := auth.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(setting.Service.ActiveCodeLives)*60), purpose) + lookup, validator, err := auth.GenerateAuthToken(ctx, u.ID, optional.None[int64](), timeutil.TimeStampNow().Add(int64(setting.Service.ActiveCodeLives)*60), purpose) if err != nil { return "", err } @@ -923,46 +923,46 @@ func countUsers(ctx context.Context, opts *CountUserFilter) int64 { // VerifyUserActiveCode verifies that the code is valid for the given purpose for this user. // If delete is specified, the token will be deleted. -func VerifyUserAuthorizationToken(ctx context.Context, code string, purpose auth.AuthorizationPurpose) (user *User, deleteToken func() error, err error) { +func VerifyUserAuthorizationToken(ctx context.Context, code string, purpose auth.AuthorizationPurpose) (user *User, authToken *auth.AuthorizationToken, deleteToken func() error, err error) { lookupKey, validator, found := strings.Cut(code, ":") if !found { - return nil, nil, nil + return nil, nil, nil, nil } - authToken, err := auth.FindAuthToken(ctx, lookupKey, purpose) + authToken, err = auth.FindAuthToken(ctx, lookupKey, purpose) if err != nil { if errors.Is(err, util.ErrNotExist) { - return nil, nil, nil + return nil, nil, nil, nil } - return nil, nil, err + return nil, nil, nil, err } if authToken.IsExpired() { - return nil, nil, auth.DeleteAuthToken(ctx, authToken) + return nil, nil, nil, auth.DeleteAuthToken(ctx, authToken) } rawValidator, err := hex.DecodeString(validator) if err != nil { - return nil, nil, err + return nil, nil, nil, err } if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 { - return nil, nil, errors.New("validator doesn't match") + return nil, nil, nil, errors.New("validator doesn't match") } u, err := GetUserByID(ctx, authToken.UID) if err != nil { if IsErrUserNotExist(err) { - return nil, nil, nil + return nil, nil, nil, nil } - return nil, nil, err + return nil, nil, nil, err } deleteToken = func() error { return auth.DeleteAuthToken(ctx, authToken) } - return u, deleteToken, nil + return u, authToken, deleteToken, nil } // ValidateUser check if user is valid to insert / update into database diff --git a/models/user/user_test.go b/models/user/user_test.go index 6da645d672..fd5285d69c 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -982,23 +982,25 @@ func TestVerifyUserAuthorizationToken(t *testing.T) { assert.True(t, ok) t.Run("Wrong purpose", func(t *testing.T) { - u, _, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.PasswordReset) + u, _, _, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.PasswordReset) require.NoError(t, err) assert.Nil(t, u) }) t.Run("No delete", func(t *testing.T) { - u, _, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation) + u, authToken, _, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation) require.NoError(t, err) assert.Equal(t, user.ID, u.ID) + require.NotNil(t, authToken) + assert.False(t, authToken.LoginSourceID.Has()) - authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation) + stored, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation) require.NoError(t, err) - assert.NotNil(t, authToken) + assert.NotNil(t, stored) }) t.Run("Delete", func(t *testing.T) { - u, deleteToken, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation) + u, _, deleteToken, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation) require.NoError(t, err) assert.Equal(t, user.ID, u.ID) require.NoError(t, deleteToken()) diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 97b5ccbe6b..6950f7ff04 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "time" @@ -49,7 +50,6 @@ const ( TplActivate base.TplName = "user/auth/activate" ) -// autoSignIn reads cookie and try to auto-login. func autoSignIn(ctx *context.Context) (bool, error) { isSucceed := false defer func() { @@ -63,27 +63,51 @@ func autoSignIn(ctx *context.Context) (bool, error) { return false, nil } - u, _, err := user_model.VerifyUserAuthorizationToken(ctx, authCookie, auth.LongTermAuthorization) + u, _, _, err := user_model.VerifyUserAuthorizationToken(ctx, authCookie, auth.LongTermAuthorization) if err != nil { return false, fmt.Errorf("VerifyUserAuthorizationToken: %w", err) } + if u != nil { + isSucceed = true + + if err := updateSession(ctx, nil, map[string]any{"uid": u.ID}); err != nil { + return false, fmt.Errorf("unable to updateSession: %w", err) + } + + if err := resetLocale(ctx, u); err != nil { + return false, err + } + + return true, nil + } + + u, authToken, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, authCookie, auth.LongTermAuthorizationSSO) + if err != nil { + return false, fmt.Errorf("VerifyUserAuthorizationToken (SSO): %w", err) + } if u == nil { return false, nil } + hasLoginSource, loginSourceID := authToken.LoginSourceID.Get() + if !hasLoginSource { + return false, nil + } + + source, err := auth.GetSourceByID(ctx, loginSourceID) + if err != nil { + return false, fmt.Errorf("GetSourceByID: %w", err) + } + if !source.IsActive || !source.IsOAuth2() { + return false, nil + } + + if err := deleteToken(); err != nil { + return false, fmt.Errorf("deleteToken: %w", err) + } isSucceed = true - if err := updateSession(ctx, nil, map[string]any{ - // Set session IDs - "uid": u.ID, - }); err != nil { - return false, fmt.Errorf("unable to updateSession: %w", err) - } - - if err := resetLocale(ctx, u); err != nil { - return false, err - } - + ctx.Redirect(fmt.Sprintf("%s/user/oauth2/%s?prompt=none", setting.AppSubURL, url.PathEscape(source.Name))) return true, nil } @@ -122,15 +146,20 @@ func RedirectAfterLogin(ctx *context.Context) { } func CheckAutoLogin(ctx *context.Context) bool { - isSucceed, err := autoSignIn(ctx) // try to auto-login + // redirect_to must be set before autoSignIn so it survives the SSO IdP round-trip. + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } + + isSucceed, err := autoSignIn(ctx) if err != nil { ctx.ServerError("autoSignIn", err) return true } - redirectTo := ctx.FormString("redirect_to") - if len(redirectTo) > 0 { - middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + if ctx.Written() { + return true } if isSucceed { @@ -296,7 +325,19 @@ func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) { func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string { if remember { - if err := ctx.SetLTACookie(u); err != nil { + var err error + if ssoLTA, _ := ctx.Session.Get("twofaSSOLTA").(bool); ssoLTA { + sourceID, _ := ctx.Session.Get("twofaSSOLTASourceID").(int64) + if sourceID == 0 { + // twofaSSOLTASourceID must have been set alongside twofaSSOLTA in handleOAuth2SignIn. + log.Warn("2FA SSO LTA requested for user %d without a source ID; skipping remember-me cookie", u.ID) + } else { + err = ctx.SetSSOLTACookie(u, sourceID) + } + } else { + err = ctx.SetLTACookie(u) + } + if err != nil { ctx.ServerError("GenerateAuthToken", err) return setting.AppSubURL + "/" } @@ -310,6 +351,8 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe "openid_determined_username", "twofaUid", "twofaRemember", + "twofaSSOLTA", + "twofaSSOLTASourceID", "twofaOpenID", "linkAccount", }, map[string]any{ @@ -676,7 +719,7 @@ func Activate(ctx *context.Context) { return } - user, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation) + user, _, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation) if err != nil { ctx.ServerError("VerifyUserAuthorizationToken", err) return @@ -750,7 +793,7 @@ func ActivatePost(ctx *context.Context) { return } - user, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation) + user, _, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation) if err != nil { ctx.ServerError("VerifyUserAuthorizationToken", err) return @@ -840,7 +883,7 @@ func ActivateEmail(ctx *context.Context) { code := ctx.FormString("code") emailStr := ctx.FormString("email") - u, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.EmailActivation(emailStr)) + u, _, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.EmailActivation(emailStr)) if err != nil { ctx.ServerError("VerifyUserAuthorizationToken", err) return diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 828027c0a8..e6d9c18b18 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -128,6 +128,15 @@ func (err errCallback) Error() string { return err.Description } +func isOIDCSilentAuthFailure(code string) bool { + switch code { + // access_denied is non-standard for prompt=none but emitted by Keycloak and some Azure AD configurations. + case "login_required", "interaction_required", "account_selection_required", "consent_required", "access_denied": + return true + } + return false +} + // TokenType specifies the kind of token type TokenType string @@ -955,6 +964,17 @@ func SignInOAuth(ctx *context.Context) { middleware.SetRedirectToCookie(ctx.Resp, redirectTo) } + // Overwrite to avoid leaking the value from a prior attempt. + promptParam := ctx.FormString("prompt") + if err := ctx.Session.Set("oauth_signin_silent", promptParam == "none"); err != nil { + ctx.ServerError("Session.Set", err) + return + } + if err := ctx.Session.Release(); err != nil { + ctx.ServerError("Session.Release", err) + return + } + // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) if err == nil && user != nil { @@ -969,13 +989,13 @@ func SignInOAuth(ctx *context.Context) { return } - if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil { + if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge, promptParam); err != nil { if strings.Contains(err.Error(), "no provider for ") { if err = oauth2.ResetOAuth2(ctx); err != nil { ctx.ServerError("SignIn", err) return } - if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil { + if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge, promptParam); err != nil { ctx.ServerError("SignIn", err) } return @@ -989,6 +1009,22 @@ func SignInOAuth(ctx *context.Context) { func SignInOAuthCallback(ctx *context.Context) { provider := ctx.Params(":provider") + // If the IdP refused our prompt=none silent re-auth, retry interactively rather than surfacing the error. + if isOIDCSilentAuthFailure(ctx.Req.FormValue("error")) { + if silent, _ := ctx.Session.Get("oauth_signin_silent").(bool); silent { + if err := ctx.Session.Delete("oauth_signin_silent"); err != nil { + ctx.ServerError("Session.Delete", err) + return + } + if err := ctx.Session.Release(); err != nil { + ctx.ServerError("Session.Release", err) + return + } + ctx.Redirect(fmt.Sprintf("%s/user/oauth2/%s", setting.AppSubURL, url.PathEscape(provider))) + return + } + } + if ctx.Req.FormValue("error") != "" { var errorKeyValues []string for k, vv := range ctx.Req.Form { @@ -1331,9 +1367,16 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model // If this user is enrolled in 2FA and this source doesn't override it, // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. if !needs2FA { - if err := updateSession(ctx, nil, map[string]any{ - "uid": u.ID, - }); err != nil { + if err := ctx.SetSSOLTACookie(u, source.ID); err != nil { + ctx.ServerError("SetSSOLTACookie", err) + return + } + + if err := updateSession(ctx, + []string{"oauth_signin_silent"}, + map[string]any{ + "uid": u.ID, + }); err != nil { ctx.ServerError("updateSession", err) return } @@ -1406,11 +1449,14 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } } - if err := updateSession(ctx, nil, map[string]any{ - // User needs to use 2FA, save data and redirect to 2FA page. - "twofaUid": u.ID, - "twofaRemember": false, - }); err != nil { + if err := updateSession(ctx, + []string{"oauth_signin_silent"}, + map[string]any{ + "twofaUid": u.ID, + "twofaRemember": true, // OAuth implies remember + "twofaSSOLTA": true, // honored by handleSignInFull to issue the SSO variant + "twofaSSOLTASourceID": source.ID, + }); err != nil { ctx.ServerError("updateSession", err) return } diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index 5d1da59ac5..5e5b08251b 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -116,7 +116,7 @@ func commonResetPassword(ctx *context.Context, shouldDeleteToken bool) (*user_mo } // Fail early, don't frustrate the user - u, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.PasswordReset) + u, _, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.PasswordReset) if err != nil { ctx.ServerError("VerifyUserAuthorizationToken", err) return nil, nil diff --git a/services/auth/source.go b/services/auth/source.go index b13554efde..54bb93a50b 100644 --- a/services/auth/source.go +++ b/services/auth/source.go @@ -37,6 +37,10 @@ func DeleteSource(ctx context.Context, source *auth.Source) error { } } + if _, err := db.GetEngine(ctx).Where("login_source_id = ?", source.ID).Delete(new(auth.AuthorizationToken)); err != nil { + return err + } + _, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source)) return err } diff --git a/services/auth/source/oauth2/source_callout.go b/services/auth/source/oauth2/source_callout.go index f95a80fc19..1cb14b2b60 100644 --- a/services/auth/source/oauth2/source_callout.go +++ b/services/auth/source/oauth2/source_callout.go @@ -11,17 +11,23 @@ import ( "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 { +// Callout redirects request/response pair to authenticate against the provider. +// prompt, if non-empty, is appended as the OIDC `prompt` parameter. +func (source *Source) Callout(request *http.Request, response http.ResponseWriter, codeChallengeS256, prompt string) error { // not sure if goth is thread safe (?) when using multiple providers request.Header.Set(ProviderHeaderKey, source.authSource.Name) - var querySuffix string + extras := url.Values{} if codeChallengeS256 != "" { - querySuffix = "&" + url.Values{ - "code_challenge_method": []string{"S256"}, - "code_challenge": []string{codeChallengeS256}, - }.Encode() + extras.Set("code_challenge_method", "S256") + extras.Set("code_challenge", codeChallengeS256) + } + if prompt != "" { + extras.Set("prompt", prompt) + } + var querySuffix string + if len(extras) > 0 { + querySuffix = "&" + extras.Encode() } // don't use the default gothic begin handler to prevent issues when some error occurs diff --git a/services/context/context_cookie.go b/services/context/context_cookie.go index 4ed2b80439..aa7aac6e3a 100644 --- a/services/context/context_cookie.go +++ b/services/context/context_cookie.go @@ -9,6 +9,7 @@ import ( auth_model "forgejo.org/models/auth" user_model "forgejo.org/models/user" + "forgejo.org/modules/optional" "forgejo.org/modules/setting" "forgejo.org/modules/timeutil" "forgejo.org/modules/web/middleware" @@ -47,7 +48,18 @@ func (ctx *Context) GetSiteCookie(name string) string { // SetLTACookie will generate a LTA token and add it as an cookie. func (ctx *Context) SetLTACookie(u *user_model.User) error { days := 86400 * setting.LogInRememberDays - lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(days)), auth_model.LongTermAuthorization) + lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, optional.None[int64](), timeutil.TimeStampNow().Add(int64(days)), auth_model.LongTermAuthorization) + if err != nil { + return err + } + ctx.SetSiteCookie(setting.CookieRememberName, lookup+":"+validator, days) + return nil +} + +// SetSSOLTACookie sets a long-term-auth cookie bound to the given OAuth2/OIDC source. +func (ctx *Context) SetSSOLTACookie(u *user_model.User, loginSourceID int64) error { + days := 86400 * setting.LogInRememberDays + lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, optional.Some(loginSourceID), timeutil.TimeStampNow().Add(int64(days)), auth_model.LongTermAuthorizationSSO) if err != nil { return err } diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go index d87d725dbd..9dace5def6 100644 --- a/tests/integration/oauth_test.go +++ b/tests/integration/oauth_test.go @@ -7,6 +7,7 @@ import ( "bytes" "crypto/sha256" "encoding/base64" + "errors" "fmt" "io" "net/http" @@ -22,6 +23,7 @@ import ( user_model "forgejo.org/models/user" "forgejo.org/modules/json" "forgejo.org/modules/log" + "forgejo.org/modules/optional" "forgejo.org/modules/setting" api "forgejo.org/modules/structs" "forgejo.org/modules/test" @@ -30,6 +32,7 @@ import ( "forgejo.org/tests" "github.com/markbates/goth" + "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" go_oauth2 "golang.org/x/oauth2" @@ -623,6 +626,195 @@ func TestSignInOAuthCallbackSignIn(t *testing.T) { assert.Greater(t, userAfterLogin.LastLoginUnix, userGitLab.LastLoginUnix) } +func findLTACookie(resp *httptest.ResponseRecorder) *http.Cookie { + for _, c := range resp.Result().Cookies() { + if c.Name == setting.CookieRememberName && c.Value != "" { + return c + } + } + return nil +} + +func loadLTAAuthToken(t *testing.T, cookieValue string) auth_model.AuthorizationToken { + t.Helper() + decoded, err := url.QueryUnescape(cookieValue) + require.NoError(t, err) + lookup, _, ok := strings.Cut(decoded, ":") + require.True(t, ok) + var token auth_model.AuthorizationToken + has, err := db.GetEngine(t.Context()).Where("lookup_key = ?", lookup).Get(&token) + require.NoError(t, err) + require.True(t, has) + return token +} + +func TestSignInOAuthSSOLTACookie(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + gitlabName := "gitlab" + gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + + userGitLabUserID := "5678" + userGitLab := &user_model.User{ + Name: "gitlabuser", + Email: "gitlabuser@example.com", + Passwd: "gitlabuserpassword", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + LoginSource: gitlab.ID, + LoginName: userGitLabUserID, + } + defer createUser(t.Context(), t, userGitLab)() + + mockUser := func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + return goth.User{ + Provider: gitlabName, + UserID: userGitLabUserID, + Email: userGitLab.Email, + }, nil + } + + t.Run("OAuth sign-in issues an SSO LTA cookie bound to the source", func(t *testing.T) { + defer mockCompleteUserAuth(mockUser)() + + session := emptyTestSession(t) + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", gitlabName)) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + c := findLTACookie(resp) + require.NotNil(t, c) + token := loadLTAAuthToken(t, c.Value) + assert.Equal(t, auth_model.LongTermAuthorizationSSO, token.Purpose) + assert.Equal(t, optional.Some(gitlab.ID), token.LoginSourceID) + }) + + t.Run("SSO LTA cookie alone bounces user to IdP with prompt=none", func(t *testing.T) { + defer mockCompleteUserAuth(mockUser)() + session := emptyTestSession(t) + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", gitlabName)) + session.MakeRequest(t, req, http.StatusSeeOther) + require.NotNil(t, session.GetCookie(setting.CookieRememberName)) + + session.SetCookie(&http.Cookie{Name: setting.SessionConfig.CookieName, MaxAge: -1}) + + resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/login"), http.StatusSeeOther) + loc, err := resp.Result().Location() + require.NoError(t, err) + assert.Equal(t, setting.AppSubURL+"/user/oauth2/"+gitlabName, loc.Path) + assert.Equal(t, "none", loc.Query().Get("prompt")) + }) + + t.Run("silent re-auth failure falls back to interactive", func(t *testing.T) { + defer mockCompleteUserAuth(func(http.ResponseWriter, *http.Request) (goth.User, error) { + return goth.User{}, errors.New("not authenticated") + })() + session := emptyTestSession(t) + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s?prompt=none", gitlabName)) + session.MakeRequest(t, req, http.StatusTemporaryRedirect) + + req = NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?error=login_required", gitlabName)) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + loc, err := resp.Result().Location() + require.NoError(t, err) + assert.Equal(t, setting.AppSubURL+"/user/oauth2/"+gitlabName, loc.Path) + assert.Empty(t, loc.Query().Get("prompt")) + }) +} + +func TestSignInOAuthSSOLTACookie_MultipleLinkedSources(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + gitlabName := "gitlab-multi" + gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + + externalID := "9999" + user := &user_model.User{ + Name: "internal-then-linked", + Email: "internal-then-linked@example.com", + Passwd: "passwd", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.Plain, + } + defer createUser(t.Context(), t, user)() + + require.NoError(t, user_model.LinkExternalToUser(db.DefaultContext, user, &user_model.ExternalLoginUser{ + ExternalID: externalID, + UserID: user.ID, + LoginSourceID: gitlab.ID, + })) + + mockUser := func(http.ResponseWriter, *http.Request) (goth.User, error) { + return goth.User{Provider: gitlabName, UserID: externalID, Email: user.Email}, nil + } + + t.Run("LTA bound to the OAuth source", func(t *testing.T) { + defer mockCompleteUserAuth(mockUser)() + + session := emptyTestSession(t) + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", gitlabName)) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + + c := findLTACookie(resp) + require.NotNil(t, c) + token := loadLTAAuthToken(t, c.Value) + assert.Equal(t, auth_model.LongTermAuthorizationSSO, token.Purpose) + assert.Equal(t, optional.Some(gitlab.ID), token.LoginSourceID) + + session.SetCookie(&http.Cookie{Name: setting.SessionConfig.CookieName, MaxAge: -1}) + + resp = session.MakeRequest(t, NewRequest(t, "GET", "/user/login"), http.StatusSeeOther) + loc, err := resp.Result().Location() + require.NoError(t, err) + assert.Equal(t, setting.AppSubURL+"/user/oauth2/"+gitlabName, loc.Path) + assert.Equal(t, "none", loc.Query().Get("prompt")) + }) +} + +func TestSignInOAuthSSOLTACookie_With2FA(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + gitlabName := "gitlab-2fa" + gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + + gitlabUserID := "2fa-user" + user := &user_model.User{ + Name: "oauth-2fa-user", + Email: "oauth-2fa-user@example.com", + Passwd: "passwd", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + LoginSource: gitlab.ID, + LoginName: gitlabUserID, + } + defer createUser(t.Context(), t, user)() + + otpKey, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "forgejo-test", AccountName: user.Name}) + require.NoError(t, err) + require.NoError(t, auth_model.NewTwoFactor(db.DefaultContext, &auth_model.TwoFactor{UID: user.ID}, otpKey.Secret())) + defer unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID}) + + defer mockCompleteUserAuth(func(http.ResponseWriter, *http.Request) (goth.User, error) { + return goth.User{Provider: gitlabName, UserID: gitlabUserID, Email: user.Email}, nil + })() + + session := emptyTestSession(t) + resp := session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", gitlabName)), http.StatusSeeOther) + loc, err := resp.Result().Location() + require.NoError(t, err) + assert.Equal(t, setting.AppSubURL+"/user/two_factor", loc.Path) + require.Nil(t, session.GetCookie(setting.CookieRememberName)) + + passcode, err := totp.GenerateCode(otpKey.Secret(), time.Now()) + require.NoError(t, err) + session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user/two_factor", map[string]string{"passcode": passcode}), http.StatusSeeOther) + + c := session.GetCookie(setting.CookieRememberName) + require.NotNil(t, c) + require.NotEmpty(t, c.Value) + token := loadLTAAuthToken(t, c.Value) + assert.Equal(t, auth_model.LongTermAuthorizationSSO, token.Purpose) + assert.Equal(t, optional.Some(gitlab.ID), token.LoginSourceID) +} + func TestSignInOAuthCallbackWithoutPKCEWhenUnsupported(t *testing.T) { // https://codeberg.org/forgejo/forgejo/issues/4033 defer tests.PrepareTestEnv(t)()