feat: persist OAuth2/OIDC sign-in via IdP re-validation (#12321)

The session cookie has no Max-Age, so it is lost when the browser closes. The password flow compensates via a "Remember me" checkbox issuing an LTA cookie; OAuth2/OIDC sign-in had no such UI.

Issuing a regular LTA cookie after an OAuth callback would skip the IdP for LOGIN_REMEMBER_DAYS. Instead, this introduces a separate LongTermAuthorizationSSO purpose: the cookie is opt-in via the existing "Remember me" checkbox, and when presented without a session, autoSignIn redirects through the IdP with OIDC prompt=none for silent re-auth. On login_required / interaction_required / consent_required / account_selection_required
we transparently fall back to interactive sign-in.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12321
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Erwan Leboucher 2026-05-15 02:31:20 +02:00 committed by Gusted
commit 0211c1eace
11 changed files with 392 additions and 56 deletions

View file

@ -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"
@ -40,6 +44,7 @@ type AuthorizationToken struct {
LookupKey string `xorm:"INDEX UNIQUE"`
HashedValidator string
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
}

View file

@ -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
}

View file

@ -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

View file

@ -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())

View file

@ -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,20 +63,14 @@ 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 {
return false, nil
}
if u != nil {
isSucceed = true
if err := updateSession(ctx, nil, map[string]any{
// Set session IDs
"uid": u.ID,
}); err != nil {
if err := updateSession(ctx, nil, map[string]any{"uid": u.ID}); err != nil {
return false, fmt.Errorf("unable to updateSession: %w", err)
}
@ -87,6 +81,36 @@ func autoSignIn(ctx *context.Context) (bool, error) {
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
ctx.Redirect(fmt.Sprintf("%s/user/oauth2/%s?prompt=none", setting.AppSubURL, url.PathEscape(source.Name)))
return true, nil
}
func resetLocale(ctx *context.Context, u *user_model.User) error {
// Language setting of the user overwrites the one previously set
// If the user does not have a locale set, we save the current one.
@ -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

View file

@ -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,7 +1367,14 @@ 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{
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)
@ -1406,10 +1449,13 @@ 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.
if err := updateSession(ctx,
[]string{"oauth_signin_silent"},
map[string]any{
"twofaUid": u.ID,
"twofaRemember": false,
"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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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)()