diff options
| author | James Elliott <james-d-elliott@users.noreply.github.com> | 2025-02-22 18:25:42 +1100 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-02-22 07:25:42 +0000 | 
| commit | 9c718b39888bbaafdbc623acd0efd2138b6b8068 (patch) | |
| tree | e189c54c06912763952eeb0ab081466531bd1cb8 /internal | |
| parent | f67097c6cb7fe14ccac071b37d6323e17b377506 (diff) | |
feat(oidc): prompt parameter support (#8080)
This adds formal support for the prompt parameter.
Closes #2596
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
Diffstat (limited to 'internal')
32 files changed, 3945 insertions, 166 deletions
diff --git a/internal/handlers/handler_firstfactor.go b/internal/handlers/handler_firstfactor.go index 12269cad4..bdf51bf34 100644 --- a/internal/handlers/handler_firstfactor.go +++ b/internal/handlers/handler_firstfactor.go @@ -153,7 +153,130 @@ func FirstFactorPOST(delayFunc middlewares.TimingAttackDelayFunc) middlewares.Re  		successful = true  		if bodyJSON.Workflow == workflowOpenIDConnect { -			handleOIDCWorkflowResponse(ctx, &userSession, bodyJSON.TargetURL, bodyJSON.WorkflowID) +			handleOIDCWorkflowResponse(ctx, &userSession, bodyJSON.WorkflowID) +		} else { +			Handle1FAResponse(ctx, bodyJSON.TargetURL, bodyJSON.RequestMethod, userSession.Username, userSession.Groups) +		} +	} +} + +// FirstFactorReauthenticatePOST is a specialized handler which checks the currently logged in users current password +// and updates their last authenticated time. +func FirstFactorReauthenticatePOST(delayFunc middlewares.TimingAttackDelayFunc) middlewares.RequestHandler { +	return func(ctx *middlewares.AutheliaCtx) { +		var successful bool + +		requestTime := time.Now() + +		if delayFunc != nil { +			defer delayFunc(ctx, requestTime, &successful) +		} + +		bodyJSON := bodyFirstFactorReauthenticateRequest{} + +		if err := ctx.ParseBody(&bodyJSON); err != nil { +			ctx.Logger.WithError(err).Errorf(logFmtErrParseRequestBody, regulation.AuthType1FA) + +			respondUnauthorized(ctx, messageAuthenticationFailed) + +			return +		} + +		provider, err := ctx.GetSessionProvider() +		if err != nil { +			ctx.Logger.WithError(err).Error("Failed to get session provider during 1FA attempt") + +			respondUnauthorized(ctx, messageAuthenticationFailed) + +			return +		} + +		userSession, err := provider.GetSession(ctx.RequestCtx) +		if err != nil { +			ctx.Logger.WithError(err).Errorf("Error occurred attempting to load session.") + +			respondUnauthorized(ctx, messageAuthenticationFailed) + +			return +		} + +		if bannedUntil, err := ctx.Providers.Regulator.Regulate(ctx, userSession.Username); err != nil { +			if errors.Is(err, regulation.ErrUserIsBanned) { +				_ = markAuthenticationAttempt(ctx, false, &bannedUntil, userSession.Username, regulation.AuthType1FA, nil) + +				respondUnauthorized(ctx, messageAuthenticationFailed) + +				return +			} + +			ctx.Logger.WithError(err).Errorf(logFmtErrRegulationFail, regulation.AuthType1FA, userSession.Username) + +			respondUnauthorized(ctx, messageAuthenticationFailed) + +			return +		} + +		userPasswordOk, err := ctx.Providers.UserProvider.CheckUserPassword(userSession.Username, bodyJSON.Password) +		if err != nil { +			_ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthType1FA, err) + +			respondUnauthorized(ctx, messageAuthenticationFailed) + +			return +		} + +		if !userPasswordOk { +			_ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthType1FA, nil) + +			respondUnauthorized(ctx, messageAuthenticationFailed) + +			return +		} + +		if err = markAuthenticationAttempt(ctx, true, nil, userSession.Username, regulation.AuthType1FA, nil); err != nil { +			respondUnauthorized(ctx, messageAuthenticationFailed) + +			return +		} + +		if err = ctx.RegenerateSession(); err != nil { +			ctx.Logger.WithError(err).Errorf(logFmtErrSessionRegenerate, regulation.AuthType1FA, userSession.Username) + +			respondUnauthorized(ctx, messageAuthenticationFailed) + +			return +		} + +		// Get the details of the given user from the user provider. +		userDetails, err := ctx.Providers.UserProvider.GetDetails(userSession.Username) +		if err != nil { +			ctx.Logger.WithError(err).Errorf(logFmtErrObtainProfileDetails, regulation.AuthType1FA, userSession.Username) + +			respondUnauthorized(ctx, messageAuthenticationFailed) + +			return +		} + +		ctx.Logger.Tracef(logFmtTraceProfileDetails, userSession.Username, userDetails.Groups, userDetails.Emails) + +		userSession.SetOneFactorReauthenticate(ctx.Clock.Now(), userDetails) + +		if ctx.Configuration.AuthenticationBackend.RefreshInterval.Update() { +			userSession.RefreshTTL = ctx.Clock.Now().Add(ctx.Configuration.AuthenticationBackend.RefreshInterval.Value()) +		} + +		if err = ctx.SaveSession(userSession); err != nil { +			ctx.Logger.WithError(err).Errorf(logFmtErrSessionSave, "updated profile", regulation.AuthType1FA, logFmtActionAuthentication, userSession.Username) + +			respondUnauthorized(ctx, messageAuthenticationFailed) + +			return +		} + +		successful = true + +		if bodyJSON.Workflow == workflowOpenIDConnect { +			handleOIDCWorkflowResponse(ctx, &userSession, bodyJSON.WorkflowID)  		} else {  			Handle1FAResponse(ctx, bodyJSON.TargetURL, bodyJSON.RequestMethod, userSession.Username, userSession.Groups)  		} diff --git a/internal/handlers/handler_firstfactor_test.go b/internal/handlers/handler_firstfactor_test.go index 1ab273318..492b27274 100644 --- a/internal/handlers/handler_firstfactor_test.go +++ b/internal/handlers/handler_firstfactor_test.go @@ -1,10 +1,13 @@  package handlers  import ( +	"database/sql"  	"fmt"  	"net/url"  	"testing" +	"time" +	"github.com/google/uuid"  	"github.com/stretchr/testify/assert"  	"github.com/stretchr/testify/suite"  	"github.com/valyala/fasthttp" @@ -15,6 +18,7 @@ import (  	"github.com/authelia/authelia/v4/internal/configuration/schema"  	"github.com/authelia/authelia/v4/internal/mocks"  	"github.com/authelia/authelia/v4/internal/model" +	"github.com/authelia/authelia/v4/internal/oidc"  	"github.com/authelia/authelia/v4/internal/regulation"  ) @@ -36,7 +40,7 @@ func (s *FirstFactorSuite) TestShouldFailIfBodyIsNil() {  	FirstFactorPOST(nil)(s.mock.Ctx)  	// No body. -	AssertLogEntryMessageAndError(s.T(), s.mock.Hook.LastEntry(), "Failed to parse 1FA request body", "unable to parse body: unexpected end of JSON input") +	s.mock.AssertLastLogMessage(s.T(), "Failed to parse 1FA request body", "unable to parse body: unexpected end of JSON input")  	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")  } @@ -47,7 +51,7 @@ func (s *FirstFactorSuite) TestShouldFailIfBodyIsInBadFormat() {  	}`)  	FirstFactorPOST(nil)(s.mock.Ctx) -	AssertLogEntryMessageAndError(s.T(), s.mock.Hook.LastEntry(), "Failed to parse 1FA request body", "unable to validate body: password: non zero value required") +	s.mock.AssertLastLogMessage(s.T(), "Failed to parse 1FA request body", "unable to validate body: password: non zero value required")  	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")  } @@ -59,13 +63,13 @@ func (s *FirstFactorSuite) TestShouldFailIfUserProviderCheckPasswordFail() {  	s.mock.UserProviderMock.  		EXPECT(). -		CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).  		Return(false, fmt.Errorf("failed"))  	s.mock.StorageMock.  		EXPECT().  		AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{ -			Username:   "test", +			Username:   testValue,  			Successful: false,  			Banned:     false,  			Time:       s.mock.Clock.Now(), @@ -80,7 +84,7 @@ func (s *FirstFactorSuite) TestShouldFailIfUserProviderCheckPasswordFail() {  	}`)  	FirstFactorPOST(nil)(s.mock.Ctx) -	AssertLogEntryMessageAndError(s.T(), s.mock.Hook.LastEntry(), "Unsuccessful 1FA authentication attempt by user 'test'", "failed") +	s.mock.AssertLastLogMessage(s.T(), "Unsuccessful 1FA authentication attempt by user 'test'", "failed")  	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")  } @@ -93,13 +97,13 @@ func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsNotMarkedWhenProviderC  	s.mock.UserProviderMock.  		EXPECT(). -		CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).  		Return(false, fmt.Errorf("invalid credentials"))  	s.mock.StorageMock.  		EXPECT().  		AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{ -			Username:   "test", +			Username:   testValue,  			Successful: false,  			Banned:     false,  			Time:       s.mock.Clock.Now(), @@ -116,6 +120,76 @@ func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsNotMarkedWhenProviderC  	FirstFactorPOST(nil)(s.mock.Ctx)  } +func (s *FirstFactorSuite) TestShouldCheckUserNotBanned() { +	s.mock.Ctx.Providers.Regulator = regulation.NewRegulator(schema.Regulation{MaxRetries: 2}, s.mock.StorageMock, &s.mock.Clock) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"keepMeLoggedIn": true +	}`) + +	gomock.InOrder( +		s.mock.UserProviderMock.EXPECT().GetDetails(testValue).Return(&authentication.UserDetails{Username: testValue}, nil), +		s.mock.StorageMock.EXPECT(). +			LoadAuthenticationLogs(gomock.Eq(s.mock.Ctx), testValue, gomock.Any(), gomock.Any(), gomock.Any()). +			Return(nil, nil), + +		s.mock.UserProviderMock. +			EXPECT(). +			CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")). +			Return(false, fmt.Errorf("invalid credentials")), + +		s.mock.StorageMock. +			EXPECT(). +			AppendAuthenticationLog(gomock.Eq(s.mock.Ctx), gomock.Eq(model.AuthenticationAttempt{ +				Username:   testValue, +				Successful: false, +				Banned:     false, +				Time:       s.mock.Clock.Now(), +				Type:       regulation.AuthType1FA, +				RemoteIP:   model.NewNullIPFromString("0.0.0.0"), +			}))) +	FirstFactorPOST(nil)(s.mock.Ctx) +} + +func (s *FirstFactorSuite) TestShouldCheckBannedUser() { +	s.mock.Ctx.Providers.Regulator = regulation.NewRegulator(schema.Regulation{MaxRetries: 2, FindTime: time.Hour, BanTime: time.Hour}, s.mock.StorageMock, &s.mock.Clock) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"keepMeLoggedIn": true +	}`) + +	gomock.InOrder( +		s.mock.UserProviderMock.EXPECT().GetDetails(testValue).Return(&authentication.UserDetails{Username: testValue}, nil), +		s.mock.StorageMock.EXPECT(). +			LoadAuthenticationLogs(gomock.Eq(s.mock.Ctx), testValue, gomock.Any(), gomock.Any(), gomock.Any()). +			Return([]model.AuthenticationAttempt{ +				{Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)}, +				{Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)}, +				{Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)}, +				{Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)}, +			}, nil), + +		s.mock.StorageMock. +			EXPECT(). +			AppendAuthenticationLog(gomock.Eq(s.mock.Ctx), gomock.Eq(model.AuthenticationAttempt{ +				Username:   testValue, +				Successful: false, +				Banned:     true, +				Time:       s.mock.Clock.Now(), +				Type:       regulation.AuthType1FA, +				RemoteIP:   model.NewNullIPFromString("0.0.0.0"), +			}))) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	s.mock.AssertLastLogMessage(s.T(), "Unsuccessful 1FA authentication attempt by user 'test' and they are banned until 2013-02-03 00:59:59 +0000 UTC", "") +	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.") +} +  func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsMarkedWhenInvalidCredentials() {  	s.mock.UserProviderMock.  		EXPECT(). @@ -124,13 +198,13 @@ func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsMarkedWhenInvalidCrede  	s.mock.UserProviderMock.  		EXPECT(). -		CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).  		Return(false, nil)  	s.mock.StorageMock.  		EXPECT().  		AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{ -			Username:   "test", +			Username:   testValue,  			Successful: false,  			Banned:     false,  			Time:       s.mock.Clock.Now(), @@ -150,7 +224,7 @@ func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsMarkedWhenInvalidCrede  func (s *FirstFactorSuite) TestShouldFailIfUserProviderGetDetailsFail() {  	s.mock.UserProviderMock.  		EXPECT(). -		GetDetails(gomock.Eq("test")). +		GetDetails(gomock.Eq(testValue)).  		Return(nil, fmt.Errorf("failed"))  	s.mock.Ctx.Request.SetBodyString(`{ @@ -158,6 +232,7 @@ func (s *FirstFactorSuite) TestShouldFailIfUserProviderGetDetailsFail() {  		"password": "hello",  		"keepMeLoggedIn": true  	}`) +  	FirstFactorPOST(nil)(s.mock.Ctx)  	AssertLogEntryMessageAndError(s.T(), s.mock.Hook.LastEntry(), "Error occurred getting details for user with username input 'test' which usually indicates they do not exist", "failed") @@ -172,7 +247,7 @@ func (s *FirstFactorSuite) TestShouldFailIfAuthenticationMarkFail() {  	s.mock.UserProviderMock.  		EXPECT(). -		CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).  		Return(true, nil)  	s.mock.StorageMock. @@ -187,21 +262,21 @@ func (s *FirstFactorSuite) TestShouldFailIfAuthenticationMarkFail() {  	}`)  	FirstFactorPOST(nil)(s.mock.Ctx) -	AssertLogEntryMessageAndError(s.T(), s.mock.Hook.LastEntry(), "Unable to mark 1FA authentication attempt by user 'test'", "failed") +	s.mock.AssertLastLogMessage(s.T(), "Unable to mark 1FA authentication attempt by user 'test'", "failed")  	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")  }  func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeChecked() {  	s.mock.UserProviderMock.  		EXPECT(). -		CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).  		Return(true, nil)  	s.mock.UserProviderMock.  		EXPECT(). -		GetDetails(gomock.Eq("test")). +		GetDetails(gomock.Eq(testValue)).  		Return(&authentication.UserDetails{ -			Username: "test", +			Username: testValue,  			Emails:   []string{"test@example.com"},  			Groups:   []string{"dev", "admins"},  		}, nil) @@ -225,7 +300,7 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeChecked() {  	userSession, err := s.mock.Ctx.GetSession()  	s.Assert().NoError(err) -	assert.Equal(s.T(), "test", userSession.Username) +	assert.Equal(s.T(), testValue, userSession.Username)  	assert.Equal(s.T(), true, userSession.KeepMeLoggedIn)  	assert.Equal(s.T(), authentication.OneFactor, userSession.AuthenticationLevel)  	assert.Equal(s.T(), []string{"test@example.com"}, userSession.Emails) @@ -235,14 +310,14 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeChecked() {  func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() {  	s.mock.UserProviderMock.  		EXPECT(). -		CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).  		Return(true, nil)  	s.mock.UserProviderMock.  		EXPECT(). -		GetDetails(gomock.Eq("test")). +		GetDetails(gomock.Eq(testValue)).  		Return(&authentication.UserDetails{ -			Username: "test", +			Username: testValue,  			Emails:   []string{"test@example.com"},  			Groups:   []string{"dev", "admins"},  		}, nil) @@ -267,7 +342,7 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() {  	userSession, err := s.mock.Ctx.GetSession()  	s.Assert().NoError(err) -	assert.Equal(s.T(), "test", userSession.Username) +	assert.Equal(s.T(), testValue, userSession.Username)  	assert.Equal(s.T(), false, userSession.KeepMeLoggedIn)  	assert.Equal(s.T(), authentication.OneFactor, userSession.AuthenticationLevel)  	assert.Equal(s.T(), []string{"test@example.com"}, userSession.Emails) @@ -319,7 +394,7 @@ func (s *FirstFactorSuite) TestShouldSaveUsernameFromAuthenticationBackendInSess  	s.mock.UserProviderMock.  		EXPECT(). -		GetDetails(gomock.Eq("test")). +		GetDetails(gomock.Eq(testValue)).  		Return(&authentication.UserDetails{  			// This is the name in authentication backend, in some setups the binding is  			// case insensitive but the user ID in session must match the user in LDAP @@ -376,14 +451,14 @@ func (s *FirstFactorRedirectionSuite) SetupTest() {  	s.mock.UserProviderMock.  		EXPECT(). -		CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).  		Return(true, nil)  	s.mock.UserProviderMock.  		EXPECT(). -		GetDetails(gomock.Eq("test")). +		GetDetails(gomock.Eq(testValue)).  		Return(&authentication.UserDetails{ -			Username: "test", +			Username: testValue,  			Emails:   []string{"test@example.com"},  			Groups:   []string{"dev", "admins"},  		}, nil) @@ -505,7 +580,1120 @@ func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenUnsafeTargetURLProvi  	s.mock.Assert200OK(s.T(), nil)  } +func (s *FirstFactorRedirectionSuite) TestShouldReplyWhenBadTargetURL() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"targetURL": "#https://23kjnm412jk3" +	}`) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.") +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyTwoFactorOK() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"targetURL": "https://two-factor.example.com" +	}`) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200OK(s.T(), nil) +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyTwoTwoFactorUnsafe() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"targetURL": "https://unsafe-domain.com" +	}`) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200OK(s.T(), nil) +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyTwoTwoFactorSafe() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"targetURL": "https://test.example.com" +	}`) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "https://test.example.com"}) +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectCantParseUUID() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "aaaaaaaaaaaaaaaaaaaaaaaaaaa-9107-4067-8d31-407ca59eb69c" +	}`) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.") +	s.mock.AssertLastLogMessage(s.T(), "unable to parse consent session challenge id 'aaaaaaaaaaaaaaaaaaaaaaaaaaa-9107-4067-8d31-407ca59eb69c': invalid UUID length: 55", "") +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectCantGetConsentSession() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c" +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))). +			Return(nil, fmt.Errorf("failed to obtain")), +	) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.") +	s.mock.AssertLastLogMessage(s.T(), "unable to load consent session by challenge id 'd1ba0ad8-9107-4067-8d31-407ca59eb69c': failed to obtain", "") +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectConsentSessionAlreadyResponded() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c" +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))). +			Return(&model.OAuth2ConsentSession{RespondedAt: sql.NullTime{Valid: true, Time: time.Now().Add(-time.Minute)}}, nil), +	) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.") +	s.mock.AssertLastLogMessage(s.T(), "consent has already been responded to 'd1ba0ad8-9107-4067-8d31-407ca59eb69c'", "") +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectCantGetClient() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) + +	s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(&schema.Configuration{IdentityProviders: schema.IdentityProviders{OIDC: &schema.IdentityProvidersOpenIDConnect{}}}, s.mock.StorageMock, s.mock.Ctx.Providers.Templates) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c" +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))). +			Return(&model.OAuth2ConsentSession{ClientID: "abc"}, nil), +	) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.") +	s.mock.AssertLastLogMessage(s.T(), "unable to get client for client with id 'd1ba0ad8-9107-4067-8d31-407ca59eb69c' with consent challenge id '00000000-0000-0000-0000-000000000000': invalid_client", "") +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectNoOpaqueID() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) + +	config := &schema.Configuration{ +		IdentityProviders: schema.IdentityProviders{ +			OIDC: &schema.IdentityProvidersOpenIDConnect{ +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID: "abc", +					}, +				}, +			}, +		}, +	} + +	s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c" +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))). +			Return(&model.OAuth2ConsentSession{ClientID: "abc"}, nil), +		s.mock.StorageMock.EXPECT(). +			LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")). +			Return(nil, fmt.Errorf("bad identifier")), +	) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.") +	s.mock.AssertLastLogMessage(s.T(), "unable to determine consent subject for client with id 'abc' with consent challenge id '00000000-0000-0000-0000-000000000000': bad identifier", "") +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectNoOpaqueIDCreateError() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) + +	config := &schema.Configuration{ +		IdentityProviders: schema.IdentityProviders{ +			OIDC: &schema.IdentityProvidersOpenIDConnect{ +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID: "abc", +					}, +				}, +			}, +		}, +	} + +	s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c" +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))). +			Return(&model.OAuth2ConsentSession{ClientID: "abc"}, nil), +		s.mock.StorageMock.EXPECT(). +			LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")). +			Return(nil, nil), +		s.mock.StorageMock.EXPECT(). +			SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(fmt.Errorf("oops")), +	) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.") +	s.mock.AssertLastLogMessage(s.T(), "unable to determine consent subject for client with id 'abc' with consent challenge id '00000000-0000-0000-0000-000000000000': oops", "") +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectNoOpaqueIDCreateSaveConsentError() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) + +	config := &schema.Configuration{ +		IdentityProviders: schema.IdentityProviders{ +			OIDC: &schema.IdentityProvidersOpenIDConnect{ +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID: "abc", +					}, +				}, +			}, +		}, +	} + +	s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c" +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))). +			Return(&model.OAuth2ConsentSession{ClientID: "abc"}, nil), +		s.mock.StorageMock.EXPECT(). +			LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")). +			Return(nil, nil), +		s.mock.StorageMock.EXPECT(). +			SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(nil), +		s.mock.StorageMock.EXPECT(). +			SaveOAuth2ConsentSessionSubject(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(fmt.Errorf("bad id")), +	) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.") +	s.mock.AssertLastLogMessage(s.T(), "unable to update consent subject for client with id 'abc' with consent challenge id '00000000-0000-0000-0000-000000000000': bad id", "") +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectFormRequiresLogin() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) + +	config := &schema.Configuration{ +		IdentityProviders: schema.IdentityProviders{ +			OIDC: &schema.IdentityProvidersOpenIDConnect{ +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID: "abc", +					}, +				}, +			}, +		}, +	} + +	s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c" +	}`) + +	form := url.Values{ +		"max_age": []string{"0"}, +	} + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))). +			Return(&model.OAuth2ConsentSession{ClientID: "abc", Form: form.Encode()}, nil), +		s.mock.StorageMock.EXPECT(). +			LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")). +			Return(nil, nil), +		s.mock.StorageMock.EXPECT(). +			SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(nil), +		s.mock.StorageMock.EXPECT(). +			SaveOAuth2ConsentSessionSubject(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(nil), +	) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "http://example.com/consent/openid/login?workflow=openid_connect&workflow_id=d1ba0ad8-9107-4067-8d31-407ca59eb69c"}) +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectFormRequiresLoginBadForm() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) + +	config := &schema.Configuration{ +		IdentityProviders: schema.IdentityProviders{ +			OIDC: &schema.IdentityProvidersOpenIDConnect{ +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID: "abc", +					}, +				}, +			}, +		}, +	} + +	s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c" +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))). +			Return(&model.OAuth2ConsentSession{ClientID: "abc", Form: "1238y12978y189gb128g1287g12807g128702g38172%1"}, nil), +		s.mock.StorageMock.EXPECT(). +			LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")). +			Return(nil, nil), +		s.mock.StorageMock.EXPECT(). +			SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(nil), +		s.mock.StorageMock.EXPECT(). +			SaveOAuth2ConsentSessionSubject(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(nil), +	) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.") +	s.mock.AssertLastLogMessage(s.T(), "unable to get authorization form values from consent session with challenge id '00000000-0000-0000-0000-000000000000': invalid URL escape \"%1\"", "") +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectNeeds2FA() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) + +	config := &schema.Configuration{ +		IdentityProviders: schema.IdentityProviders{ +			OIDC: &schema.IdentityProvidersOpenIDConnect{ +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:                  "abc", +						AuthorizationPolicy: "two_factor", +					}, +				}, +			}, +		}, +	} + +	s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c" +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))). +			Return(&model.OAuth2ConsentSession{ClientID: "abc", Form: ""}, nil), +		s.mock.StorageMock.EXPECT(). +			LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")). +			Return(nil, nil), +		s.mock.StorageMock.EXPECT(). +			SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(nil), +		s.mock.StorageMock.EXPECT(). +			SaveOAuth2ConsentSessionSubject(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(nil), +	) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200OK(s.T(), nil) +	s.mock.AssertLastLogMessage(s.T(), "OpenID Connect client 'abc' requires 2FA, cannot be redirected yet", "") +} + +func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectNeeds1FA() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"two-factor.example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) + +	config := &schema.Configuration{ +		IdentityProviders: schema.IdentityProviders{ +			OIDC: &schema.IdentityProvidersOpenIDConnect{ +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:                  "abc", +						AuthorizationPolicy: "one_factor", +					}, +				}, +			}, +		}, +	} + +	s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"requestMethod": "GET", +		"keepMeLoggedIn": false, +		"workflow": "openid_connect", +		"workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c" +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))). +			Return(&model.OAuth2ConsentSession{ClientID: "abc", Form: "grant_type=authorization_code"}, nil), +		s.mock.StorageMock.EXPECT(). +			LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")). +			Return(nil, nil), +		s.mock.StorageMock.EXPECT(). +			SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(nil), +		s.mock.StorageMock.EXPECT(). +			SaveOAuth2ConsentSessionSubject(gomock.Eq(s.mock.Ctx), gomock.Any()). +			Return(nil), +	) + +	FirstFactorPOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "http://example.com/api/oidc/authorization?consent_id=d1ba0ad8-9107-4067-8d31-407ca59eb69c&grant_type=authorization_code"}) +} + +type FirstFactorReauthenticateSuite struct { +	suite.Suite + +	mock *mocks.MockAutheliaCtx +} + +func (s *FirstFactorReauthenticateSuite) SetupTest() { +	s.mock = mocks.NewMockAutheliaCtx(s.T()) + +	session, err := s.mock.Ctx.GetSession() + +	s.Require().NoError(err) + +	session.Username = testValue +	session.AuthenticationLevel = authentication.OneFactor + +	s.Require().NoError(s.mock.Ctx.SaveSession(session)) +} + +func (s *FirstFactorReauthenticateSuite) TearDownTest() { +	s.mock.Close() +} + +func (s *FirstFactorReauthenticateSuite) TestShouldFailIfBodyIsNil() { +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	// No body. +	s.mock.AssertLastLogMessage(s.T(), "Failed to parse 1FA request body", "unable to parse body: unexpected end of JSON input") +	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.") +} + +func (s *FirstFactorReauthenticateSuite) TestShouldFailIfBodyIsInBadFormat() { +	// Missing password. +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test" +	}`) +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	s.mock.AssertLastLogMessage(s.T(), "Failed to parse 1FA request body", "unable to validate body: password: non zero value required") +	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.") +} + +func (s *FirstFactorReauthenticateSuite) TestShouldFailIfUserProviderCheckPasswordFail() { +	s.mock.UserProviderMock. +		EXPECT(). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")). +		Return(false, fmt.Errorf("failed")) + +	s.mock.StorageMock. +		EXPECT(). +		AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{ +			Username:   testValue, +			Successful: false, +			Banned:     false, +			Time:       s.mock.Clock.Now(), +			Type:       regulation.AuthType1FA, +			RemoteIP:   model.NewNullIPFromString("0.0.0.0"), +		})) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"password": "hello" +	}`) +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	s.mock.AssertLastLogMessage(s.T(), "Unsuccessful 1FA authentication attempt by user 'test'", "failed") + +	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.") +} + +func (s *FirstFactorReauthenticateSuite) TestShouldCheckAuthenticationIsNotMarkedWhenProviderCheckPasswordError() { +	s.mock.UserProviderMock. +		EXPECT(). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")). +		Return(false, fmt.Errorf("invalid credentials")) + +	s.mock.StorageMock. +		EXPECT(). +		AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{ +			Username:   testValue, +			Successful: false, +			Banned:     false, +			Time:       s.mock.Clock.Now(), +			Type:       regulation.AuthType1FA, +			RemoteIP:   model.NewNullIPFromString("0.0.0.0"), +		})) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"password": "hello" +	}`) + +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) +} + +func (s *FirstFactorReauthenticateSuite) TestShouldCheckUserNotBanned() { +	s.mock.Ctx.Providers.Regulator = regulation.NewRegulator(schema.Regulation{MaxRetries: 2}, s.mock.StorageMock, &s.mock.Clock) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"keepMeLoggedIn": true +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadAuthenticationLogs(gomock.Eq(s.mock.Ctx), testValue, gomock.Any(), gomock.Any(), gomock.Any()). +			Return(nil, nil), + +		s.mock.UserProviderMock. +			EXPECT(). +			CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")). +			Return(false, fmt.Errorf("invalid credentials")), + +		s.mock.StorageMock. +			EXPECT(). +			AppendAuthenticationLog(gomock.Eq(s.mock.Ctx), gomock.Eq(model.AuthenticationAttempt{ +				Username:   testValue, +				Successful: false, +				Banned:     false, +				Time:       s.mock.Clock.Now(), +				Type:       regulation.AuthType1FA, +				RemoteIP:   model.NewNullIPFromString("0.0.0.0"), +			}))) + +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) +} + +func (s *FirstFactorReauthenticateSuite) TestShouldCheckBannedUser() { +	s.mock.Ctx.Providers.Regulator = regulation.NewRegulator(schema.Regulation{MaxRetries: 2, FindTime: time.Hour, BanTime: time.Hour}, s.mock.StorageMock, &s.mock.Clock) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"username": "test", +		"password": "hello", +		"keepMeLoggedIn": true +	}`) + +	gomock.InOrder( +		s.mock.StorageMock.EXPECT(). +			LoadAuthenticationLogs(gomock.Eq(s.mock.Ctx), testValue, gomock.Any(), gomock.Any(), gomock.Any()). +			Return([]model.AuthenticationAttempt{ +				{Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)}, +				{Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)}, +				{Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)}, +				{Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)}, +			}, nil), + +		s.mock.StorageMock. +			EXPECT(). +			AppendAuthenticationLog(gomock.Eq(s.mock.Ctx), gomock.Eq(model.AuthenticationAttempt{ +				Username:   testValue, +				Successful: false, +				Banned:     true, +				Time:       s.mock.Clock.Now(), +				Type:       regulation.AuthType1FA, +				RemoteIP:   model.NewNullIPFromString("0.0.0.0"), +			}))) + +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	s.mock.AssertLastLogMessage(s.T(), "Unsuccessful 1FA authentication attempt by user 'test' and they are banned until 2013-02-03 00:59:59 +0000 UTC", "") +	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.") +} + +func (s *FirstFactorReauthenticateSuite) TestShouldCheckAuthenticationIsMarkedWhenInvalidCredentials() { +	s.mock.UserProviderMock. +		EXPECT(). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")). +		Return(false, nil) + +	s.mock.StorageMock. +		EXPECT(). +		AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{ +			Username:   testValue, +			Successful: false, +			Banned:     false, +			Time:       s.mock.Clock.Now(), +			Type:       regulation.AuthType1FA, +			RemoteIP:   model.NewNullIPFromString("0.0.0.0"), +		})) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"password": "hello" +	}`) + +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) +} + +func (s *FirstFactorReauthenticateSuite) TestShouldFailIfUserProviderGetDetailsFail() { +	s.mock.UserProviderMock. +		EXPECT(). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")). +		Return(true, nil) + +	s.mock.StorageMock. +		EXPECT(). +		AppendAuthenticationLog(s.mock.Ctx, gomock.Any()). +		Return(nil) + +	s.mock.UserProviderMock. +		EXPECT(). +		GetDetails(gomock.Eq(testValue)). +		Return(nil, fmt.Errorf("failed")) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"password": "hello" +	}`) +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	s.mock.AssertLastLogMessage(s.T(), "Could not obtain profile details during 1FA authentication for user 'test'", "failed") +	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.") +} + +func (s *FirstFactorReauthenticateSuite) TestShouldFailIfAuthenticationMarkFail() { +	s.mock.UserProviderMock. +		EXPECT(). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")). +		Return(true, nil) + +	s.mock.StorageMock. +		EXPECT(). +		AppendAuthenticationLog(s.mock.Ctx, gomock.Any()). +		Return(fmt.Errorf("failed")) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"password": "hello" +	}`) +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	s.mock.AssertLastLogMessage(s.T(), "Unable to mark 1FA authentication attempt by user 'test'", "failed") +	s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.") +} + +func (s *FirstFactorReauthenticateSuite) TestShouldSaveUsernameFromAuthenticationBackendInSession() { +	s.mock.UserProviderMock. +		EXPECT(). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")). +		Return(true, nil) + +	s.mock.UserProviderMock. +		EXPECT(). +		GetDetails(gomock.Eq(testValue)). +		Return(&authentication.UserDetails{ +			// This is the name in authentication backend, in some setups the binding is +			// case insensitive but the user ID in session must match the user in LDAP +			// for the other modules of Authelia to be coherent. +			Username: "Test", +			Emails:   []string{"test@example.com"}, +			Groups:   []string{"dev", "admins"}, +		}, nil) + +	s.mock.StorageMock. +		EXPECT(). +		AppendAuthenticationLog(s.mock.Ctx, gomock.Any()). +		Return(nil) + +	s.mock.Ctx.Request.SetBodyString(`{ +		"password": "hello" +	}`) +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	assert.Equal(s.T(), fasthttp.StatusOK, s.mock.Ctx.Response.StatusCode()) +	assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body()) + +	userSession, err := s.mock.Ctx.GetSession() +	s.Assert().NoError(err) + +	assert.Equal(s.T(), "Test", userSession.Username) +	assert.Equal(s.T(), false, userSession.KeepMeLoggedIn) +	assert.Equal(s.T(), authentication.OneFactor, userSession.AuthenticationLevel) +	assert.Equal(s.T(), []string{"test@example.com"}, userSession.Emails) +	assert.Equal(s.T(), []string{"dev", "admins"}, userSession.Groups) +} + +type FirstFactorReauthenticateRedirectionSuite struct { +	suite.Suite + +	mock *mocks.MockAutheliaCtx +} + +func (s *FirstFactorReauthenticateRedirectionSuite) SetupTest() { +	s.mock = mocks.NewMockAutheliaCtx(s.T()) + +	session, err := s.mock.Ctx.GetSession() + +	s.Require().NoError(err) + +	session.Username = testValue +	session.AuthenticationLevel = authentication.OneFactor + +	s.Require().NoError(s.mock.Ctx.SaveSession(session)) + +	s.mock.Ctx.Configuration.Session.Cookies[0].DefaultRedirectionURL = &url.URL{Scheme: "https", Host: "default.local"} +	s.mock.Ctx.Configuration.AccessControl.DefaultPolicy = testBypass +	s.mock.Ctx.Configuration.AccessControl.Rules = []schema.AccessControlRule{ +		{ +			Domains: []string{"default.local"}, +			Policy:  "one_factor", +		}, +	} +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&s.mock.Ctx.Configuration) + +	s.mock.UserProviderMock. +		EXPECT(). +		CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")). +		Return(true, nil) + +	s.mock.UserProviderMock. +		EXPECT(). +		GetDetails(gomock.Eq(testValue)). +		Return(&authentication.UserDetails{ +			Username: testValue, +			Emails:   []string{"test@example.com"}, +			Groups:   []string{"dev", "admins"}, +		}, nil) + +	s.mock.StorageMock. +		EXPECT(). +		AppendAuthenticationLog(s.mock.Ctx, gomock.Any()). +		Return(nil) +} + +func (s *FirstFactorReauthenticateRedirectionSuite) TearDownTest() { +	s.mock.Close() +} + +// When: +// +//	1/ the target url is unknown +//	2/ two_factor is disabled (no policy is set to two_factor) +//	3/ default_redirect_url is provided +// +// Then: +// +//	the user should be redirected to the default url. +func (s *FirstFactorReauthenticateRedirectionSuite) TestShouldRedirectToDefaultURLWhenNoTargetURLProvidedAndTwoFactorDisabled() { +	s.mock.Ctx.Request.SetBodyString(`{ +		"password": "hello" +	}`) +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "https://www.example.com"}) +} + +// When: +// +//	1/ the target url is unsafe +//	2/ two_factor is disabled (no policy is set to two_factor) +//	3/ default_redirect_url is provided +// +// Then: +// +//	the user should be redirected to the default url. +func (s *FirstFactorReauthenticateRedirectionSuite) TestShouldRedirectToDefaultURLWhenURLIsUnsafeAndTwoFactorDisabled() { +	s.mock.Ctx.Request.SetBodyString(`{ +		"password": "hello", +		"targetURL": "http://notsafe.local" +	}`) + +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "https://www.example.com"}) +} + +// When: +// +//	1/ two_factor is enabled (default policy) +// +// Then: +// +//	the user should receive 200 without redirection URL. +func (s *FirstFactorReauthenticateRedirectionSuite) TestShouldReply200WhenNoTargetURLProvidedAndTwoFactorEnabled() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "two_factor", +		}, +	}) +	s.mock.Ctx.Request.SetBodyString(`{ +		"password": "hello" +	}`) + +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200OK(s.T(), nil) +} + +// When: +// +//	1/ two_factor is enabled (some rule) +// +// Then: +// +//	the user should receive 200 without redirection URL. +func (s *FirstFactorReauthenticateRedirectionSuite) TestShouldReply200WhenUnsafeTargetURLProvidedAndTwoFactorEnabled() { +	s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ +		AccessControl: schema.AccessControl{ +			DefaultPolicy: "one_factor", +			Rules: []schema.AccessControlRule{ +				{ +					Domains: []string{"test.example.com"}, +					Policy:  "one_factor", +				}, +				{ +					Domains: []string{"example.com"}, +					Policy:  "two_factor", +				}, +			}, +		}}) +	s.mock.Ctx.Request.SetBodyString(`{ +		"password": "hello" +	}`) + +	FirstFactorReauthenticatePOST(nil)(s.mock.Ctx) + +	// Respond with 200. +	s.mock.Assert200OK(s.T(), nil) +} +  func TestFirstFactorSuite(t *testing.T) {  	suite.Run(t, new(FirstFactorSuite))  	suite.Run(t, new(FirstFactorRedirectionSuite))  } + +func TestFirstFactorReauthenticateSuite(t *testing.T) { +	suite.Run(t, new(FirstFactorReauthenticateSuite)) +	suite.Run(t, new(FirstFactorReauthenticateRedirectionSuite)) +} + +const ( +	testValue = "test" +) diff --git a/internal/handlers/handler_oidc_authorization.go b/internal/handlers/handler_oidc_authorization.go index d661559e0..2099b9af1 100644 --- a/internal/handlers/handler_oidc_authorization.go +++ b/internal/handlers/handler_oidc_authorization.go @@ -4,12 +4,10 @@ import (  	"errors"  	"net/http"  	"net/url" -	"time"  	oauthelia2 "authelia.com/provider/oauth2"  	"github.com/authelia/authelia/v4/internal/authentication" -	"github.com/authelia/authelia/v4/internal/authorization"  	"github.com/authelia/authelia/v4/internal/middlewares"  	"github.com/authelia/authelia/v4/internal/model"  	"github.com/authelia/authelia/v4/internal/oidc" @@ -27,18 +25,13 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr  		responder oauthelia2.AuthorizeResponder  		client    oidc.Client  		policy    oidc.ClientAuthorizationPolicy -		authTime  time.Time -		issuer    *url.URL  		err       error  	)  	requester, err = ctx.Providers.OpenIDConnect.NewAuthorizeRequest(ctx, r) +	if requester == nil { +		err = oauthelia2.ErrServerError.WithDebug("The requester was nil.") -	if requester != nil && requester.GetResponseMode() == oidc.ResponseModeFormPost { -		ctx.SetUserValue(middlewares.UserValueKeyOpenIDConnectResponseModeFormPost, true) -	} - -	if err != nil {  		ctx.Logger.Errorf("Authorization Request failed with error: %s", oauthelia2.ErrorToDebugRFC6749Error(err))  		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, err) @@ -50,6 +43,14 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr  		ctx.SetUserValue(middlewares.UserValueKeyOpenIDConnectResponseModeFormPost, true)  	} +	if err != nil { +		ctx.Logger.Errorf("Authorization Request failed with error: %s", oauthelia2.ErrorToDebugRFC6749Error(err)) + +		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, err) + +		return +	} +  	clientID := requester.GetClient().GetID()  	ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' is being processed", requester.GetID(), clientID) @@ -79,6 +80,7 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr  	}  	var ( +		issuer      *url.URL  		details     *authentication.UserDetails  		userSession session.UserSession  		consent     *model.OAuth2ConsentSession @@ -127,17 +129,9 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr  	extraClaims := oidcGrantRequests(requester, consent, details) -	if authTime, err = userSession.AuthenticatedTime(client.GetAuthorizationPolicyRequiredLevel(authorization.Subject{Username: details.Username, Groups: details.Groups, IP: ctx.RemoteIP()})); err != nil { -		ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' using policy '%s' could not be processed: error occurred checking authentication time: %+v", requester.GetID(), client.GetID(), policy.Name, err) +	ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' was successfully processed, proceeding to build Authorization Response", requester.GetID(), clientID) -		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oauthelia2.ErrServerError.WithHint("Could not obtain the authentication time.")) - -		return -	} - -	ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' using policy '%s' was successfully processed for user '%s' with groups: %+v, proceeding to build Authorization Response", requester.GetID(), clientID, policy.Name, userSession.Username, userSession.Groups) - -	session := oidc.NewSessionWithAuthorizeRequest(ctx, issuer, ctx.Providers.OpenIDConnect.KeyManager.GetKeyID(ctx, client.GetIDTokenSignedResponseKeyID(), client.GetIDTokenSignedResponseAlg()), details.Username, userSession.AuthenticationMethodRefs.MarshalRFC8176(), extraClaims, authTime, consent, requester) +	session := oidc.NewSessionWithAuthorizeRequest(ctx, issuer, ctx.Providers.OpenIDConnect.KeyManager.GetKeyID(ctx, client.GetIDTokenSignedResponseKeyID(), client.GetIDTokenSignedResponseAlg()), details.Username, userSession.AuthenticationMethodRefs.MarshalRFC8176(), extraClaims, userSession.LastAuthenticatedTime(), consent, requester)  	ctx.Logger.Tracef("Authorization Request with id '%s' on client with id '%s' using policy '%s' creating session for Authorization Response for subject '%s' with username '%s' with groups: %+v and claims: %+v",  		requester.GetID(), session.ClientID, policy.Name, session.Subject, session.Username, userSession.Groups, session.Claims) @@ -160,8 +154,6 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr  		return  	} -	responder.GetParameters().Set(oidc.FormParameterIssuer, issuer.String()) -  	ctx.Providers.OpenIDConnect.WriteAuthorizeResponse(ctx, rw, requester, responder)  } diff --git a/internal/handlers/handler_oidc_authorization_consent.go b/internal/handlers/handler_oidc_authorization_consent.go index 9bea500b8..44d13cfba 100644 --- a/internal/handlers/handler_oidc_authorization_consent.go +++ b/internal/handlers/handler_oidc_authorization_consent.go @@ -78,10 +78,28 @@ func handleOIDCAuthorizationConsent(ctx *middlewares.AutheliaCtx, issuer *url.UR  	return handler(ctx, issuer, client, userSession, subject, rw, r, requester)  } -func handleOIDCAuthorizationConsentNotAuthenticated(ctx *middlewares.AutheliaCtx, issuer *url.URL, _ oidc.Client, +func handleOIDCAuthorizationConsentNotAuthenticated(ctx *middlewares.AutheliaCtx, issuer *url.URL, client oidc.Client,  	_ session.UserSession, _ uuid.UUID,  	rw http.ResponseWriter, r *http.Request, requester oauthelia2.AuthorizeRequester) (consent *model.OAuth2ConsentSession, handled bool) { -	redirectionURL := handleOIDCAuthorizationConsentGetRedirectionURL(ctx, issuer, nil, requester, r.Form) +	var err error + +	if consent, err = handleOpenIDConnectNewConsentSession(uuid.UUID{}, requester, ctx.Providers.OpenIDConnect.GetPushedAuthorizeRequestURIPrefix(ctx)); err != nil { +		ctx.Logger.Errorf(logFmtErrConsentGenerateError, requester.GetID(), client.GetID(), client.GetConsentPolicy(), "generating", err) + +		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oidc.ErrConsentCouldNotGenerate) + +		return nil, true +	} + +	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSession(ctx, consent); err != nil { +		ctx.Logger.Errorf(logFmtErrConsentGenerateError, requester.GetID(), client.GetID(), client.GetConsentPolicy(), "saving", err) + +		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oidc.ErrConsentCouldNotSave) + +		return nil, true +	} + +	redirectionURL := handleOIDCAuthorizationConsentGetRedirectionURL(ctx, issuer, consent)  	handleOIDCPushedAuthorizeConsent(ctx, requester, r.Form) @@ -115,7 +133,7 @@ func handleOIDCAuthorizationConsentGenerate(ctx *middlewares.AutheliaCtx, issuer  		return nil, true  	} -	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSession(ctx, *consent); err != nil { +	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSession(ctx, consent); err != nil {  		ctx.Logger.Errorf(logFmtErrConsentGenerateError, requester.GetID(), client.GetID(), client.GetConsentPolicy(), "saving", err)  		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oidc.ErrConsentCouldNotSave) @@ -123,6 +141,14 @@ func handleOIDCAuthorizationConsentGenerate(ctx *middlewares.AutheliaCtx, issuer  		return nil, true  	} +	if oidc.RequesterRequiresLogin(requester, consent.RequestedAt, userSession.LastAuthenticatedTime()) { +		handleOIDCAuthorizationConsentPromptLoginRedirect(ctx, issuer, client, userSession, rw, r, requester, consent) + +		return nil, true +	} else { +		ctx.Logger.WithFields(map[string]any{"requested_at": consent.RequestedAt, "authenticated_at": userSession.LastAuthenticatedTime(), "prompt": requester.GetRequestForm().Get("prompt")}).Debugf("Authorization Request with id '%s' on client with id '%s' is not being redirected for reauthentication", requester.GetID(), client.GetID()) +	} +  	handleOIDCAuthorizationConsentRedirect(ctx, issuer, consent, client, userSession, rw, r, requester)  	return consent, true @@ -134,7 +160,7 @@ func handleOIDCAuthorizationConsentRedirect(ctx *middlewares.AutheliaCtx, issuer  	if client.IsAuthenticationLevelSufficient(userSession.AuthenticationLevel, authorization.Subject{Username: userSession.Username, Groups: userSession.Groups, IP: ctx.RemoteIP()}) {  		location, _ = url.ParseRequestURI(issuer.String()) -		location.Path = path.Join(location.Path, oidc.EndpointPathConsent) +		location.Path = path.Join(location.Path, oidc.EndpointPathConsentDecision)  		query := location.Query()  		query.Set(queryArgID, consent.ChallengeID.String()) @@ -143,7 +169,7 @@ func handleOIDCAuthorizationConsentRedirect(ctx *middlewares.AutheliaCtx, issuer  		ctx.Logger.Debugf(logFmtDbgConsentAuthenticationSufficiency, requester.GetID(), client.GetID(), client.GetConsentPolicy(), userSession.AuthenticationLevel.String(), "sufficient", client.GetAuthorizationPolicyRequiredLevel(authorization.Subject{Username: userSession.Username, Groups: userSession.Groups, IP: ctx.RemoteIP()}))  	} else { -		location = handleOIDCAuthorizationConsentGetRedirectionURL(ctx, issuer, consent, requester, r.Form) +		location = handleOIDCAuthorizationConsentGetRedirectionURL(ctx, issuer, consent)  		ctx.Logger.Debugf(logFmtDbgConsentAuthenticationSufficiency, requester.GetID(), client.GetID(), client.GetConsentPolicy(), userSession.AuthenticationLevel.String(), "insufficient", client.GetAuthorizationPolicyRequiredLevel(authorization.Subject{Username: userSession.Username, Groups: userSession.Groups, IP: ctx.RemoteIP()}))  	} @@ -176,7 +202,23 @@ func handleOIDCPushedAuthorizeConsent(ctx *middlewares.AutheliaCtx, requester oa  	}  } -func handleOIDCAuthorizationConsentGetRedirectionURL(_ *middlewares.AutheliaCtx, issuer *url.URL, consent *model.OAuth2ConsentSession, requester oauthelia2.AuthorizeRequester, form url.Values) (redirectURL *url.URL) { +func handleOIDCAuthorizationConsentPromptLoginRedirect(ctx *middlewares.AutheliaCtx, issuer *url.URL, client oidc.Client, userSession session.UserSession, rw http.ResponseWriter, r *http.Request, requester oauthelia2.AuthorizeRequester, consent *model.OAuth2ConsentSession) { +	ctx.Logger.WithFields(map[string]any{"requested_at": consent.RequestedAt, "authenticated_at": userSession.LastAuthenticatedTime()}).Debugf("Authorization Request with id '%s' on client with id '%s' is being redirected for reauthentication: prompt type login was requested", requester.GetID(), client.GetID()) + +	handleOIDCPushedAuthorizeConsent(ctx, requester, r.Form) + +	redirectionURL := issuer.JoinPath(oidc.EndpointPathConsentLogin) + +	query := redirectionURL.Query() +	query.Set(queryArgWorkflow, workflowOpenIDConnect) +	query.Set(queryArgWorkflowID, consent.ChallengeID.String()) + +	redirectionURL.RawQuery = query.Encode() + +	http.Redirect(rw, r, redirectionURL.String(), http.StatusFound) +} + +func handleOIDCAuthorizationConsentGetRedirectionURL(_ *middlewares.AutheliaCtx, issuer *url.URL, consent *model.OAuth2ConsentSession) (redirectURL *url.URL) {  	iss := issuer.String()  	if !strings.HasSuffix(iss, "/") { @@ -187,23 +229,7 @@ func handleOIDCAuthorizationConsentGetRedirectionURL(_ *middlewares.AutheliaCtx,  	query := redirectURL.Query()  	query.Set(queryArgWorkflow, workflowOpenIDConnect) - -	switch { -	case consent != nil: -		query.Set(queryArgWorkflowID, consent.ChallengeID.String()) -	case form != nil: -		rd, _ := url.ParseRequestURI(iss) -		rd.Path = path.Join(rd.Path, oidc.EndpointPathAuthorization) -		rd.RawQuery = form.Encode() - -		query.Set(queryArgRD, rd.String()) -	case requester != nil: -		rd, _ := url.ParseRequestURI(iss) -		rd.Path = path.Join(rd.Path, oidc.EndpointPathAuthorization) -		rd.RawQuery = requester.GetRequestForm().Encode() - -		query.Set(queryArgRD, rd.String()) -	} +	query.Set(queryArgWorkflowID, consent.ChallengeID.String())  	redirectURL.RawQuery = query.Encode() @@ -246,7 +272,7 @@ func verifyOIDCUserAuthorizedForConsent(ctx *middlewares.AutheliaCtx, client oid  		consent.Subject = model.NullUUID(subject) -		if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionSubject(ctx, *consent); err != nil { +		if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionSubject(ctx, consent); err != nil {  			return fmt.Errorf("failed to update the consent subject: %w", err)  		}  	} diff --git a/internal/handlers/handler_oidc_authorization_consent_explicit.go b/internal/handlers/handler_oidc_authorization_consent_explicit.go index d211ac5d0..24a5dd79c 100644 --- a/internal/handlers/handler_oidc_authorization_consent_explicit.go +++ b/internal/handlers/handler_oidc_authorization_consent_explicit.go @@ -70,6 +70,12 @@ func handleOIDCAuthorizationConsentModeExplicitWithID(ctx *middlewares.AutheliaC  		return nil, true  	} +	if oidc.RequesterRequiresLogin(requester, consent.RequestedAt, userSession.LastAuthenticatedTime()) { +		handleOIDCAuthorizationConsentPromptLoginRedirect(ctx, issuer, client, userSession, rw, r, requester, consent) + +		return nil, true +	} +  	if !consent.CanGrant() {  		ctx.Logger.Errorf(logFmtErrConsentCantGrant, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, "explicit") diff --git a/internal/handlers/handler_oidc_authorization_consent_implicit.go b/internal/handlers/handler_oidc_authorization_consent_implicit.go index cd1516005..308c4fbb5 100644 --- a/internal/handlers/handler_oidc_authorization_consent_implicit.go +++ b/internal/handlers/handler_oidc_authorization_consent_implicit.go @@ -37,9 +37,9 @@ func handleOIDCAuthorizationConsentModeImplicit(ctx *middlewares.AutheliaCtx, is  	}  } -func handleOIDCAuthorizationConsentModeImplicitWithID(ctx *middlewares.AutheliaCtx, _ *url.URL, client oidc.Client, +func handleOIDCAuthorizationConsentModeImplicitWithID(ctx *middlewares.AutheliaCtx, issuer *url.URL, client oidc.Client,  	userSession session.UserSession, subject uuid.UUID, consentID uuid.UUID, -	rw http.ResponseWriter, _ *http.Request, requester oauthelia2.AuthorizeRequester) (consent *model.OAuth2ConsentSession, handled bool) { +	rw http.ResponseWriter, r *http.Request, requester oauthelia2.AuthorizeRequester) (consent *model.OAuth2ConsentSession, handled bool) {  	var (  		err error  	) @@ -68,6 +68,14 @@ func handleOIDCAuthorizationConsentModeImplicitWithID(ctx *middlewares.AutheliaC  		return nil, true  	} +	if oidc.RequesterRequiresLogin(requester, consent.RequestedAt, userSession.LastAuthenticatedTime()) { +		handleOIDCAuthorizationConsentPromptLoginRedirect(ctx, issuer, client, userSession, rw, r, requester, consent) + +		return nil, true +	} else { +		ctx.Logger.WithFields(map[string]any{"requested_at": consent.RequestedAt, "authenticated_at": userSession.LastAuthenticatedTime(), "prompt": requester.GetRequestForm().Get("prompt")}).Debugf("Authorization Request with id '%s' on client with id '%s' is not being redirected for reauthentication", requester.GetID(), client.GetID()) +	} +  	if !consent.CanGrant() {  		ctx.Logger.Errorf(logFmtErrConsentCantGrant, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, "implicit") @@ -78,7 +86,7 @@ func handleOIDCAuthorizationConsentModeImplicitWithID(ctx *middlewares.AutheliaC  	consent.Grant() -	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, *consent, false); err != nil { +	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, consent, false); err != nil {  		ctx.Logger.Errorf(logFmtErrConsentSaveSessionResponse, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, err)  		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oidc.ErrConsentCouldNotSave) @@ -89,9 +97,9 @@ func handleOIDCAuthorizationConsentModeImplicitWithID(ctx *middlewares.AutheliaC  	return consent, false  } -func handleOIDCAuthorizationConsentModeImplicitWithoutID(ctx *middlewares.AutheliaCtx, _ *url.URL, client oidc.Client, -	_ session.UserSession, subject uuid.UUID, -	rw http.ResponseWriter, _ *http.Request, requester oauthelia2.AuthorizeRequester) (consent *model.OAuth2ConsentSession, handled bool) { +func handleOIDCAuthorizationConsentModeImplicitWithoutID(ctx *middlewares.AutheliaCtx, issuer *url.URL, client oidc.Client, +	userSession session.UserSession, subject uuid.UUID, +	rw http.ResponseWriter, r *http.Request, requester oauthelia2.AuthorizeRequester) (consent *model.OAuth2ConsentSession, handled bool) {  	var (  		err error  	) @@ -104,7 +112,7 @@ func handleOIDCAuthorizationConsentModeImplicitWithoutID(ctx *middlewares.Authel  		return nil, true  	} -	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSession(ctx, *consent); err != nil { +	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSession(ctx, consent); err != nil {  		ctx.Logger.Errorf(logFmtErrConsentSaveSession, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, err)  		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oidc.ErrConsentCouldNotSave) @@ -112,17 +120,27 @@ func handleOIDCAuthorizationConsentModeImplicitWithoutID(ctx *middlewares.Authel  		return nil, true  	} -	if consent, err = ctx.Providers.StorageProvider.LoadOAuth2ConsentSessionByChallengeID(ctx, consent.ChallengeID); err != nil { -		ctx.Logger.Errorf(logFmtErrConsentSaveSession, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, err) +	challenge := consent.ChallengeID + +	if consent, err = ctx.Providers.StorageProvider.LoadOAuth2ConsentSessionByChallengeID(ctx, challenge); err != nil { +		ctx.Logger.Errorf(logFmtErrConsentSaveSession, requester.GetID(), client.GetID(), client.GetConsentPolicy(), challenge, err)  		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oidc.ErrConsentCouldNotSave)  		return nil, true  	} +	if oidc.RequesterRequiresLogin(requester, consent.RequestedAt, userSession.LastAuthenticatedTime()) { +		handleOIDCAuthorizationConsentPromptLoginRedirect(ctx, issuer, client, userSession, rw, r, requester, consent) + +		return nil, true +	} else { +		ctx.Logger.WithFields(map[string]any{"requested_at": consent.RequestedAt, "authenticated_at": userSession.LastAuthenticatedTime(), "prompt": requester.GetRequestForm().Get("prompt")}).Debugf("Authorization Request with id '%s' on client with id '%s' is not being redirected for reauthentication", requester.GetID(), client.GetID()) +	} +  	consent.Grant() -	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, *consent, false); err != nil { +	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, consent, false); err != nil {  		ctx.Logger.Errorf(logFmtErrConsentSaveSessionResponse, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, err)  		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oidc.ErrConsentCouldNotSave) diff --git a/internal/handlers/handler_oidc_authorization_consent_pre_configured.go b/internal/handlers/handler_oidc_authorization_consent_pre_configured.go index 873098938..91d67d65a 100644 --- a/internal/handlers/handler_oidc_authorization_consent_pre_configured.go +++ b/internal/handlers/handler_oidc_authorization_consent_pre_configured.go @@ -75,6 +75,12 @@ func handleOIDCAuthorizationConsentModePreConfiguredWithID(ctx *middlewares.Auth  		return nil, true  	} +	if oidc.RequesterRequiresLogin(requester, consent.RequestedAt, userSession.LastAuthenticatedTime()) { +		handleOIDCAuthorizationConsentPromptLoginRedirect(ctx, issuer, client, userSession, rw, r, requester, consent) + +		return nil, true +	} +  	if !consent.CanGrant() {  		ctx.Logger.Errorf(logFmtErrConsentCantGrantPreConf, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID) @@ -96,7 +102,7 @@ func handleOIDCAuthorizationConsentModePreConfiguredWithID(ctx *middlewares.Auth  		consent.PreConfiguration = sql.NullInt64{Int64: config.ID, Valid: true} -		if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, *consent, false); err != nil { +		if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, consent, false); err != nil {  			ctx.Logger.Errorf(logFmtErrConsentSaveSessionResponse, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, err)  			ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oidc.ErrConsentCouldNotSave) @@ -168,7 +174,7 @@ func handleOIDCAuthorizationConsentModePreConfiguredWithoutID(ctx *middlewares.A  		return nil, true  	} -	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSession(ctx, *consent); err != nil { +	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSession(ctx, consent); err != nil {  		ctx.Logger.Errorf(logFmtErrConsentSaveSession, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, err)  		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oidc.ErrConsentCouldNotSave) @@ -184,11 +190,19 @@ func handleOIDCAuthorizationConsentModePreConfiguredWithoutID(ctx *middlewares.A  		return nil, true  	} +	if oidc.RequesterRequiresLogin(requester, consent.RequestedAt, userSession.LastAuthenticatedTime()) { +		handleOIDCAuthorizationConsentPromptLoginRedirect(ctx, issuer, client, userSession, rw, r, requester, consent) + +		return nil, true +	} else { +		ctx.Logger.WithFields(map[string]any{"requested_at": consent.RequestedAt, "authenticated_at": userSession.LastAuthenticatedTime(), "prompt": requester.GetRequestForm().Get("prompt")}).Debugf("Authorization Request with id '%s' on client with id '%s' is not being redirected for reauthentication", requester.GetID(), client.GetID()) +	} +  	consent.Grant()  	consent.PreConfiguration = sql.NullInt64{Int64: config.ID, Valid: true} -	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, *consent, false); err != nil { +	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, consent, false); err != nil {  		ctx.Logger.Errorf(logFmtErrConsentSaveSessionResponse, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, err)  		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oidc.ErrConsentCouldNotSave) diff --git a/internal/handlers/handler_oidc_authorization_consent_test.go b/internal/handlers/handler_oidc_authorization_consent_test.go new file mode 100644 index 000000000..ec7f6e872 --- /dev/null +++ b/internal/handlers/handler_oidc_authorization_consent_test.go @@ -0,0 +1,1404 @@ +package handlers + +import ( +	"database/sql" +	"fmt" +	"net/http/httptest" +	"net/url" +	"regexp" +	"testing" +	"time" + +	oauthelia2 "authelia.com/provider/oauth2" +	"github.com/google/uuid" +	"github.com/stretchr/testify/assert" +	"github.com/stretchr/testify/require" +	"go.uber.org/mock/gomock" + +	"github.com/authelia/authelia/v4/internal/configuration/schema" +	"github.com/authelia/authelia/v4/internal/mocks" +	"github.com/authelia/authelia/v4/internal/model" +	"github.com/authelia/authelia/v4/internal/oidc" +	"github.com/authelia/authelia/v4/internal/session" +) + +func TestHandleOIDCAuthorizationConsentGenerate(t *testing.T) { +	mustParseURI := func(t *testing.T, in string) *url.URL { +		result, err := url.Parse(in) +		require.NoError(t, err) + +		return result +	} + +	clientTest := &oidc.RegisteredClient{ +		ID: testValue, +	} + +	sub := uuid.MustParse("e79b6494-8852-4439-860c-159f2cba83dc") + +	testCases := []struct { +		name        string +		issuer      *url.URL +		client      oidc.Client +		userSession session.UserSession +		subject     uuid.UUID +		requester   oauthelia2.AuthorizeRequester +		expected    *model.OAuth2ConsentSession +		handled     bool +		setup       func(t *testing.T, mock *mocks.MockAutheliaCtx) +		expect      func(t *testing.T, mock *mocks.MockAutheliaCtx) +	}{ +		{ +			name:        "ShouldHandleDBError", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +				}, +			}, +			expected: (*model.OAuth2ConsentSession)(nil), +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(fmt.Errorf("invalid")), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred saving consent: invalid$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleQueryArgsError", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +				}, +			}, +			expected: (*model.OAuth2ConsentSession)(nil), +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.Ctx.QueryArgs().Add(queryArgConsentID, "abc") +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred generating consent: consent id value was present when it should be absent$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandlePromptLoginNotRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: &model.OAuth2ConsentSession{ +				ClientID:    "test", +				Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +				Form:        "prompt=login", +				RequestedAt: time.Unix(1000000, 0), +			}, +			handled: true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleMaxAgeNotRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterMaximumAge: []string{"10"}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: &model.OAuth2ConsentSession{ +				ClientID:    "test", +				Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +				Form:        "max_age=10", +				RequestedAt: time.Unix(1000000, 0), +			}, +			handled: true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandlePromptLoginRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleMaxAgeRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 0, SecondFactorAuthnTimestamp: 0}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterMaximumAge: []string{"10"}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			mock := mocks.NewMockAutheliaCtx(t) + +			defer mock.Close() + +			mock.Ctx.Clock = &mock.Clock +			mock.Clock.Set(time.Unix(1000000, 0)) + +			config := &schema.Configuration{ +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						Clients: []schema.IdentityProvidersOpenIDConnectClient{ +							{ +								ID: "abc", +							}, +						}, +					}, +				}, +			} + +			mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, mock.StorageMock, mock.Ctx.Providers.Templates) + +			rw := httptest.NewRecorder() + +			if tc.setup != nil { +				tc.setup(t, mock) +			} + +			consent, handled := handleOIDCAuthorizationConsentGenerate(mock.Ctx, tc.issuer, tc.client, tc.userSession, tc.subject, rw, httptest.NewRequest("GET", "https://example.com", nil), tc.requester) + +			assert.Equal(t, tc.handled, handled) + +			if tc.expected == nil { +				assert.Nil(t, consent) +			} else { +				require.NotNil(t, consent) +				assert.Equal(t, tc.expected.ClientID, consent.ClientID) +				assert.Equal(t, tc.expected.Subject, consent.Subject) +				assert.Equal(t, tc.expected.Granted, consent.Granted) +				assert.Equal(t, tc.expected.Authorized, consent.Authorized) +				assert.Equal(t, tc.expected.RequestedAt, consent.RequestedAt) +				assert.Equal(t, tc.expected.RespondedAt, consent.RespondedAt) +				assert.Equal(t, tc.expected.Form, consent.Form) +				assert.Equal(t, tc.expected.RequestedScopes, consent.RequestedScopes) +				assert.Equal(t, tc.expected.RequestedAudience, consent.RequestedAudience) +				assert.Equal(t, tc.expected.GrantedScopes, consent.GrantedScopes) +				assert.Equal(t, tc.expected.GrantedAudience, consent.GrantedAudience) +				assert.Equal(t, tc.expected.PreConfiguration, consent.PreConfiguration) +			} + +			if tc.expect != nil { +				tc.expect(t, mock) +			} +		}) +	} +} + +func TestHandleOIDCAuthorizationConsentNotAuthenticated(t *testing.T) { +	mustParseURI := func(t *testing.T, in string) *url.URL { +		result, err := url.Parse(in) +		require.NoError(t, err) + +		return result +	} + +	clientTest := &oidc.RegisteredClient{ +		ID: testValue, +	} + +	sub := uuid.MustParse("e79b6494-8852-4439-860c-159f2cba83dc") + +	testCases := []struct { +		name        string +		issuer      *url.URL +		client      oidc.Client +		userSession session.UserSession +		subject     uuid.UUID +		requester   oauthelia2.AuthorizeRequester +		expected    *model.OAuth2ConsentSession +		handled     bool +		setup       func(t *testing.T, mock *mocks.MockAutheliaCtx) +		expect      func(t *testing.T, mock *mocks.MockAutheliaCtx) +	}{ +		{ +			name:        "ShouldHandleDBError", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +				}, +			}, +			expected: (*model.OAuth2ConsentSession)(nil), +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(fmt.Errorf("invalid")), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred saving consent: invalid$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandlePromptLoginRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleMaxAgeRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 0, SecondFactorAuthnTimestamp: 0}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterMaximumAge: []string{"10"}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			mock := mocks.NewMockAutheliaCtx(t) + +			defer mock.Close() + +			mock.Ctx.Clock = &mock.Clock +			mock.Clock.Set(time.Unix(1000000, 0)) + +			config := &schema.Configuration{ +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						Clients: []schema.IdentityProvidersOpenIDConnectClient{ +							{ +								ID: "abc", +							}, +						}, +					}, +				}, +			} + +			mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, mock.StorageMock, mock.Ctx.Providers.Templates) + +			rw := httptest.NewRecorder() + +			if tc.setup != nil { +				tc.setup(t, mock) +			} + +			consent, handled := handleOIDCAuthorizationConsentNotAuthenticated(mock.Ctx, tc.issuer, tc.client, tc.userSession, tc.subject, rw, httptest.NewRequest("GET", "https://example.com", nil), tc.requester) + +			assert.Equal(t, tc.handled, handled) + +			if tc.expected == nil { +				assert.Nil(t, consent) +			} else { +				require.NotNil(t, consent) +				assert.Equal(t, tc.expected.ClientID, consent.ClientID) +				assert.Equal(t, tc.expected.Subject, consent.Subject) +				assert.Equal(t, tc.expected.Granted, consent.Granted) +				assert.Equal(t, tc.expected.Authorized, consent.Authorized) +				assert.Equal(t, tc.expected.RequestedAt, consent.RequestedAt) +				assert.Equal(t, tc.expected.RespondedAt, consent.RespondedAt) +				assert.Equal(t, tc.expected.Form, consent.Form) +				assert.Equal(t, tc.expected.RequestedScopes, consent.RequestedScopes) +				assert.Equal(t, tc.expected.RequestedAudience, consent.RequestedAudience) +				assert.Equal(t, tc.expected.GrantedScopes, consent.GrantedScopes) +				assert.Equal(t, tc.expected.GrantedAudience, consent.GrantedAudience) +				assert.Equal(t, tc.expected.PreConfiguration, consent.PreConfiguration) +			} + +			if tc.expect != nil { +				tc.expect(t, mock) +			} +		}) +	} +} + +func TestHandleOIDCAuthorizationConsentModeImplicitWithoutID(t *testing.T) { +	mustParseURI := func(t *testing.T, in string) *url.URL { +		result, err := url.Parse(in) +		require.NoError(t, err) + +		return result +	} + +	clientTest := &oidc.RegisteredClient{ +		ID: testValue, +	} + +	sub := uuid.MustParse("e79b6494-8852-4439-860c-159f2cba83dc") + +	testCases := []struct { +		name        string +		issuer      *url.URL +		client      oidc.Client +		userSession session.UserSession +		subject     uuid.UUID +		requester   oauthelia2.AuthorizeRequester +		expected    *model.OAuth2ConsentSession +		handled     bool +		setup       func(t *testing.T, mock *mocks.MockAutheliaCtx) +		expect      func(t *testing.T, mock *mocks.MockAutheliaCtx) +	}{ +		{ +			name:        "ShouldHandleDBError", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(fmt.Errorf("invalid")), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred performing consent for consent session with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}': error occurred saving consent session: invalid$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleLoadSessionFromDBError", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil, fmt.Errorf("bad")), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred performing consent for consent session with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}': error occurred saving consent session: bad$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginNotRequiredErrorSave", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(&model.OAuth2ConsentSession{ +							ID:          1, +							ClientID:    "test", +							Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +							Form:        "prompt=login", +							RequestedAt: time.Unix(1000000, 0), +						}, nil), +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSessionResponse(gomock.Eq(mock.Ctx), gomock.Any(), gomock.Eq(false)). +						Return(fmt.Errorf("bad conn")), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred performing consent for consent session with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}': error occurred saving consent session response: bad conn$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginNotRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: &model.OAuth2ConsentSession{ +				ClientID:    "test", +				Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +				Form:        "prompt=login", +				RequestedAt: time.Unix(1000000, 0), +			}, +			handled: false, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(&model.OAuth2ConsentSession{ +							ID:          1, +							ClientID:    "test", +							Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +							Form:        "prompt=login", +							RequestedAt: time.Unix(1000000, 0), +						}, nil), +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSessionResponse(gomock.Eq(mock.Ctx), gomock.Any(), gomock.Eq(false)). +						Return(nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleFormMaxAgeNotRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterMaximumAge: []string{"10"}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: &model.OAuth2ConsentSession{ +				ClientID:    "test", +				Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +				Form:        "max_age=10", +				RequestedAt: time.Unix(1000000, 0), +			}, +			handled: false, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(&model.OAuth2ConsentSession{ +							ID:          1, +							ClientID:    "test", +							Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +							Form:        "max_age=10", +							RequestedAt: time.Unix(1000000, 0), +						}, nil), +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSessionResponse(gomock.Eq(mock.Ctx), gomock.Any(), gomock.Eq(false)). +						Return(nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandlePromptLoginRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(&model.OAuth2ConsentSession{ +							ID:          1, +							ClientID:    "test", +							Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +							Form:        "prompt=login", +							RequestedAt: time.Unix(1000000, 0), +						}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleMaxAgeRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 0, SecondFactorAuthnTimestamp: 0}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterMaximumAge: []string{"10"}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSession(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(nil), +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Any()). +						Return(&model.OAuth2ConsentSession{ +							ID:          1, +							ClientID:    "test", +							Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +							Form:        "max_age=10", +							RequestedAt: time.Unix(1000000, 0), +						}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			mock := mocks.NewMockAutheliaCtx(t) + +			defer mock.Close() + +			mock.Ctx.Clock = &mock.Clock +			mock.Clock.Set(time.Unix(1000000, 0)) + +			config := &schema.Configuration{ +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						Clients: []schema.IdentityProvidersOpenIDConnectClient{ +							{ +								ID: "abc", +							}, +						}, +					}, +				}, +			} + +			mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, mock.StorageMock, mock.Ctx.Providers.Templates) + +			rw := httptest.NewRecorder() + +			if tc.setup != nil { +				tc.setup(t, mock) +			} + +			consent, handled := handleOIDCAuthorizationConsentModeImplicitWithoutID(mock.Ctx, tc.issuer, tc.client, tc.userSession, tc.subject, rw, httptest.NewRequest("GET", "https://example.com", nil), tc.requester) + +			assert.Equal(t, tc.handled, handled) + +			if tc.expected == nil { +				assert.Nil(t, consent) +			} else { +				require.NotNil(t, consent) + +				if tc.expected.ID != 0 { +					assert.Equal(t, tc.expected.ID, consent.ID) +				} + +				assert.Equal(t, tc.expected.ClientID, consent.ClientID) +				assert.Equal(t, tc.expected.Subject, consent.Subject) +				assert.Equal(t, tc.expected.Granted, consent.Granted) +				assert.Equal(t, tc.expected.Authorized, consent.Authorized) +				assert.Equal(t, tc.expected.RequestedAt, consent.RequestedAt) +				assert.Equal(t, tc.expected.RespondedAt, consent.RespondedAt) +				assert.Equal(t, tc.expected.Form, consent.Form) +				assert.Equal(t, tc.expected.RequestedScopes, consent.RequestedScopes) +				assert.Equal(t, tc.expected.RequestedAudience, consent.RequestedAudience) +				assert.Equal(t, tc.expected.GrantedScopes, consent.GrantedScopes) +				assert.Equal(t, tc.expected.GrantedAudience, consent.GrantedAudience) +				assert.Equal(t, tc.expected.PreConfiguration, consent.PreConfiguration) +			} + +			if tc.expect != nil { +				tc.expect(t, mock) +			} +		}) +	} +} + +func TestHandleOIDCAuthorizationConsentModeImplicitWithID(t *testing.T) { +	mustParseURI := func(t *testing.T, in string) *url.URL { +		result, err := url.Parse(in) +		require.NoError(t, err) + +		return result +	} + +	clientTest := &oidc.RegisteredClient{ +		ID: testValue, +	} + +	challenge := uuid.MustParse("11303e1f-f8af-436a-9a72-c7361bfc9f37") +	sub := uuid.MustParse("e79b6494-8852-4439-860c-159f2cba83dc") + +	testCases := []struct { +		name        string +		issuer      *url.URL +		client      oidc.Client +		userSession session.UserSession +		subject     uuid.UUID +		requester   oauthelia2.AuthorizeRequester +		expected    *model.OAuth2ConsentSession +		handled     bool +		setup       func(t *testing.T, mock *mocks.MockAutheliaCtx) +		expect      func(t *testing.T, mock *mocks.MockAutheliaCtx) +	}{ +		{ +			name:        "ShouldHandleDBError", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(nil, fmt.Errorf("error in db")), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred performing consent for consent session with id '11303e1f-f8af-436a-9a72-c7361bfc9f37': error occurred while loading session: error in db$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleLoadSessionWrongID", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ChallengeID: challenge, Subject: uuid.NullUUID{UUID: challenge, Valid: true}}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred performing consent for consent session with id '11303e1f-f8af-436a-9a72-c7361bfc9f37': user 'test' with subject 'e79b6494-8852-4439-860c-159f2cba83dc' is not authorized to consent for subject '11303e1f-f8af-436a-9a72-c7361bfc9f37'$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginNotRequiredErrorSave", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ChallengeID: challenge, Subject: uuid.NullUUID{UUID: sub, Valid: true}}, nil), +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSessionResponse(gomock.Eq(mock.Ctx), gomock.Any(), gomock.Eq(false)). +						Return(fmt.Errorf("bad conn")), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred performing consent for consent session with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}': error occurred saving consent session response: bad conn$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginNotRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: &model.OAuth2ConsentSession{ +				ID:          40, +				ClientID:    "test", +				Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +				Form:        "prompt=login", +				RequestedAt: time.Unix(1000000, 0), +			}, +			handled: false, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ID: 40, ChallengeID: challenge, ClientID: "test", Subject: uuid.NullUUID{UUID: sub, Valid: true}, Form: "prompt=login", RequestedAt: time.Unix(1000000, 0)}, nil), +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSessionResponse(gomock.Eq(mock.Ctx), gomock.Any(), gomock.Eq(false)). +						Return(nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 100000, SecondFactorAuthnTimestamp: 100000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ID: 40, ChallengeID: challenge, ClientID: "test", Subject: uuid.NullUUID{UUID: sub, Valid: true}, Form: "prompt=login", RequestedAt: time.Unix(1000000, 0)}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleFormMaxAgeRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 100000, SecondFactorAuthnTimestamp: 100000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterMaximumAge: []string{"10"}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ID: 40, ChallengeID: challenge, ClientID: "test", Subject: uuid.NullUUID{UUID: sub, Valid: true}, Form: "max_age=10", RequestedAt: time.Unix(1000000, 0)}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleFormMaxAgeNotRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterMaximumAge: []string{"10"}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: &model.OAuth2ConsentSession{ +				ID:          40, +				ClientID:    "test", +				Subject:     uuid.NullUUID{UUID: sub, Valid: true}, +				Form:        "max_age=10", +				RequestedAt: time.Unix(1000000, 0), +			}, +			handled: false, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ID: 40, ChallengeID: challenge, ClientID: "test", Subject: uuid.NullUUID{UUID: sub, Valid: true}, Form: "max_age=10", RequestedAt: time.Unix(1000000, 0)}, nil), +					mock.StorageMock.EXPECT(). +						SaveOAuth2ConsentSessionResponse(gomock.Eq(mock.Ctx), gomock.Any(), gomock.Eq(false)). +						Return(nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			mock := mocks.NewMockAutheliaCtx(t) + +			defer mock.Close() + +			mock.Ctx.Clock = &mock.Clock +			mock.Clock.Set(time.Unix(1000000, 0)) + +			config := &schema.Configuration{ +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						Clients: []schema.IdentityProvidersOpenIDConnectClient{ +							{ +								ID: "abc", +							}, +						}, +					}, +				}, +			} + +			mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, mock.StorageMock, mock.Ctx.Providers.Templates) + +			rw := httptest.NewRecorder() + +			if tc.setup != nil { +				tc.setup(t, mock) +			} + +			consent, handled := handleOIDCAuthorizationConsentModeImplicitWithID(mock.Ctx, tc.issuer, tc.client, tc.userSession, tc.subject, challenge, rw, httptest.NewRequest("GET", "https://example.com", nil), tc.requester) + +			assert.Equal(t, tc.handled, handled) + +			if tc.expected == nil { +				assert.Nil(t, consent) +			} else { +				require.NotNil(t, consent) + +				if tc.expected.ID != 0 { +					assert.Equal(t, tc.expected.ID, consent.ID) +				} + +				assert.Equal(t, tc.expected.ClientID, consent.ClientID) +				assert.Equal(t, tc.expected.Subject, consent.Subject) +				assert.Equal(t, tc.expected.Granted, consent.Granted) +				assert.Equal(t, tc.expected.Authorized, consent.Authorized) +				assert.Equal(t, tc.expected.RequestedAt, consent.RequestedAt) +				assert.Equal(t, tc.expected.RespondedAt, consent.RespondedAt) +				assert.Equal(t, tc.expected.Form, consent.Form) +				assert.Equal(t, tc.expected.RequestedScopes, consent.RequestedScopes) +				assert.Equal(t, tc.expected.RequestedAudience, consent.RequestedAudience) +				assert.Equal(t, tc.expected.GrantedScopes, consent.GrantedScopes) +				assert.Equal(t, tc.expected.GrantedAudience, consent.GrantedAudience) +				assert.Equal(t, tc.expected.PreConfiguration, consent.PreConfiguration) +			} + +			if tc.expect != nil { +				tc.expect(t, mock) +			} +		}) +	} +} + +func TestHandleOIDCAuthorizationConsentModeExplicitWithID(t *testing.T) { +	mustParseURI := func(t *testing.T, in string) *url.URL { +		result, err := url.Parse(in) +		require.NoError(t, err) + +		return result +	} + +	clientTest := &oidc.RegisteredClient{ +		ID: testValue, +	} + +	challenge := uuid.MustParse("11303e1f-f8af-436a-9a72-c7361bfc9f37") +	sub := uuid.MustParse("e79b6494-8852-4439-860c-159f2cba83dc") + +	testCases := []struct { +		name        string +		issuer      *url.URL +		client      oidc.Client +		userSession session.UserSession +		subject     uuid.UUID +		challenge   uuid.UUID +		requester   oauthelia2.AuthorizeRequester +		expected    *model.OAuth2ConsentSession +		handled     bool +		setup       func(t *testing.T, mock *mocks.MockAutheliaCtx) +		expect      func(t *testing.T, mock *mocks.MockAutheliaCtx) +	}{ +		{ +			name:        "ShouldHandleDBError", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{}, +			subject:     sub, +			challenge:   challenge, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(nil, fmt.Errorf("error in db")), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred performing consent for consent session with id '11303e1f-f8af-436a-9a72-c7361bfc9f37': error occurred while loading session: error in db$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleLoadSessionWrongID", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			challenge:   challenge, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ChallengeID: challenge, Subject: uuid.NullUUID{UUID: challenge, Valid: true}}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred performing consent for consent session with id '11303e1f-f8af-436a-9a72-c7361bfc9f37': user 'test' with subject 'e79b6494-8852-4439-860c-159f2cba83dc' is not authorized to consent for subject '11303e1f-f8af-436a-9a72-c7361bfc9f37'$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginNotRequiredAuthorized", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			challenge:   challenge, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: &model.OAuth2ConsentSession{ID: 44, ChallengeID: challenge, Subject: uuid.NullUUID{UUID: sub, Valid: true}, Authorized: true, RespondedAt: sql.NullTime{Time: time.Unix(1000000, 0), Valid: true}}, +			handled:  false, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ID: 44, ChallengeID: challenge, Subject: uuid.NullUUID{UUID: sub, Valid: true}, Authorized: true, RespondedAt: sql.NullTime{Time: time.Unix(1000000, 0), Valid: true}}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginNotRequiredNotResponded", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			challenge:   challenge, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ID: 44, ChallengeID: challenge, Subject: uuid.NullUUID{UUID: sub, Valid: true}}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginNotRequiredNotRejected", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			challenge:   challenge, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ID: 44, ChallengeID: challenge, Subject: uuid.NullUUID{UUID: sub, Valid: true}, RespondedAt: sql.NullTime{Time: time.Unix(1000000, 0), Valid: true}}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred performing consent for consent session with id '11303e1f-f8af-436a-9a72-c7361bfc9f37': the user explicitly rejected this consent session$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginNotRequiredCantGrant", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			challenge:   challenge, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ID: 44, Granted: true, ChallengeID: challenge, Subject: uuid.NullUUID{UUID: sub, Valid: true}, RespondedAt: sql.NullTime{Time: time.Unix(1000000, 0), Valid: true}}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: error occurred performing consent for consent session with id '11303e1f-f8af-436a-9a72-c7361bfc9f37': the session does not appear to be valid for explicit consent: either the subject is null, the consent has already been granted, or the consent session is a pre-configured session$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginNilChallenge", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000}, +			subject:     sub, +			challenge:   uuid.Nil, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup:    func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'explicit' could not be processed: the consent id had a zero value$`), nil) +			}, +		}, +		{ +			name:        "ShouldHandleFormPromptLoginRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 100000, SecondFactorAuthnTimestamp: 100000}, +			subject:     sub, +			challenge:   challenge, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterPrompt: []string{oidc.PromptLogin}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ID: 40, ChallengeID: challenge, ClientID: "test", Subject: uuid.NullUUID{UUID: sub, Valid: true}, Form: "prompt=login", RequestedAt: time.Unix(1000000, 0)}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +		{ +			name:        "ShouldHandleFormMaxAgeRequired", +			issuer:      mustParseURI(t, "https://auth.example.com"), +			client:      clientTest, +			userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 100000, SecondFactorAuthnTimestamp: 100000}, +			subject:     sub, +			challenge:   challenge, +			requester: &oauthelia2.AuthorizeRequest{ +				Request: oauthelia2.Request{ +					Client: clientTest, +					Form: url.Values{ +						oidc.FormParameterMaximumAge: []string{"10"}, +					}, +					RequestedAt: time.Unix(1000000, 0), +				}, +			}, +			expected: nil, +			handled:  true, +			setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) { +				gomock.InOrder( +					mock.StorageMock.EXPECT(). +						LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)). +						Return(&model.OAuth2ConsentSession{ID: 40, ChallengeID: challenge, ClientID: "test", Subject: uuid.NullUUID{UUID: sub, Valid: true}, Form: "max_age=10", RequestedAt: time.Unix(1000000, 0)}, nil), +				) +			}, +			expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {}, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			mock := mocks.NewMockAutheliaCtx(t) + +			defer mock.Close() + +			mock.Ctx.Clock = &mock.Clock +			mock.Clock.Set(time.Unix(1000000, 0)) + +			config := &schema.Configuration{ +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						Clients: []schema.IdentityProvidersOpenIDConnectClient{ +							{ +								ID: "abc", +							}, +						}, +					}, +				}, +			} + +			mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, mock.StorageMock, mock.Ctx.Providers.Templates) + +			rw := httptest.NewRecorder() + +			if tc.setup != nil { +				tc.setup(t, mock) +			} + +			consent, handled := handleOIDCAuthorizationConsentModeExplicitWithID(mock.Ctx, tc.issuer, tc.client, tc.userSession, tc.subject, tc.challenge, rw, httptest.NewRequest("GET", "https://example.com", nil), tc.requester) + +			assert.Equal(t, tc.handled, handled) + +			if tc.expected == nil { +				assert.Nil(t, consent) +			} else { +				require.NotNil(t, consent) + +				if tc.expected.ID != 0 { +					assert.Equal(t, tc.expected.ID, consent.ID) +				} + +				assert.Equal(t, tc.expected.ClientID, consent.ClientID) +				assert.Equal(t, tc.expected.Subject, consent.Subject) +				assert.Equal(t, tc.expected.Granted, consent.Granted) +				assert.Equal(t, tc.expected.Authorized, consent.Authorized) +				assert.Equal(t, tc.expected.RequestedAt, consent.RequestedAt) +				assert.Equal(t, tc.expected.RespondedAt, consent.RespondedAt) +				assert.Equal(t, tc.expected.Form, consent.Form) +				assert.Equal(t, tc.expected.RequestedScopes, consent.RequestedScopes) +				assert.Equal(t, tc.expected.RequestedAudience, consent.RequestedAudience) +				assert.Equal(t, tc.expected.GrantedScopes, consent.GrantedScopes) +				assert.Equal(t, tc.expected.GrantedAudience, consent.GrantedAudience) +				assert.Equal(t, tc.expected.PreConfiguration, consent.PreConfiguration) +			} + +			if tc.expect != nil { +				tc.expect(t, mock) +			} +		}) +	} +} diff --git a/internal/handlers/handler_oidc_consent.go b/internal/handlers/handler_oidc_consent.go index 6609829bf..32931e914 100644 --- a/internal/handlers/handler_oidc_consent.go +++ b/internal/handlers/handler_oidc_consent.go @@ -127,7 +127,7 @@ func OpenIDConnectConsentPOST(ctx *middlewares.AutheliaCtx) {  		}  	} -	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, *consent, bodyJSON.Consent); err != nil { +	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, consent, bodyJSON.Consent); err != nil {  		ctx.Logger.Errorf("Failed to save the consent session response to the database: %+v", err)  		ctx.SetJSONError(messageOperationFailed) diff --git a/internal/handlers/handler_sign_duo.go b/internal/handlers/handler_sign_duo.go index 5e0c81a9d..95961f6bd 100644 --- a/internal/handlers/handler_sign_duo.go +++ b/internal/handlers/handler_sign_duo.go @@ -274,7 +274,7 @@ func HandleAllow(ctx *middlewares.AutheliaCtx, userSession *session.UserSession,  	}  	if bodyJSON.Workflow == workflowOpenIDConnect { -		handleOIDCWorkflowResponse(ctx, userSession, bodyJSON.TargetURL, bodyJSON.WorkflowID) +		handleOIDCWorkflowResponse(ctx, userSession, bodyJSON.WorkflowID)  	} else {  		Handle2FAResponse(ctx, bodyJSON.TargetURL)  	} diff --git a/internal/handlers/handler_sign_totp.go b/internal/handlers/handler_sign_totp.go index ef38d2623..badc0d754 100644 --- a/internal/handlers/handler_sign_totp.go +++ b/internal/handlers/handler_sign_totp.go @@ -211,7 +211,7 @@ func TimeBasedOneTimePasswordPOST(ctx *middlewares.AutheliaCtx) {  	}  	if bodyJSON.Workflow == workflowOpenIDConnect { -		handleOIDCWorkflowResponse(ctx, &userSession, bodyJSON.TargetURL, bodyJSON.WorkflowID) +		handleOIDCWorkflowResponse(ctx, &userSession, bodyJSON.WorkflowID)  	} else {  		Handle2FAResponse(ctx, bodyJSON.TargetURL)  	} diff --git a/internal/handlers/handler_sign_webauthn.go b/internal/handlers/handler_sign_webauthn.go index c624a3bcd..bb2c61d57 100644 --- a/internal/handlers/handler_sign_webauthn.go +++ b/internal/handlers/handler_sign_webauthn.go @@ -267,7 +267,7 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {  		assertionResponse.Response.AuthenticatorData.Flags.HasUserVerified())  	if bodyJSON.Workflow == workflowOpenIDConnect { -		handleOIDCWorkflowResponse(ctx, &userSession, bodyJSON.TargetURL, bodyJSON.WorkflowID) +		handleOIDCWorkflowResponse(ctx, &userSession, bodyJSON.WorkflowID)  	} else {  		Handle2FAResponse(ctx, bodyJSON.TargetURL)  	} diff --git a/internal/handlers/response.go b/internal/handlers/response.go index f4112fc6f..107aaf5b3 100644 --- a/internal/handlers/response.go +++ b/internal/handlers/response.go @@ -3,7 +3,6 @@ package handlers  import (  	"fmt"  	"net/url" -	"path"  	"time"  	"github.com/google/uuid" @@ -129,51 +128,7 @@ func Handle2FAResponse(ctx *middlewares.AutheliaCtx, targetURI string) {  	ctx.ReplyOK()  } -// handleOIDCWorkflowResponse handle the redirection upon authentication in the OIDC workflow. -func handleOIDCWorkflowResponse(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, targetURI, workflowID string) { -	switch { -	case len(workflowID) != 0: -		handleOIDCWorkflowResponseWithID(ctx, userSession, workflowID) -	case len(targetURI) != 0: -		handleOIDCWorkflowResponseWithTargetURL(ctx, userSession, targetURI) -	default: -		ctx.Error(fmt.Errorf("invalid post data: must contain either a target url or a workflow id"), messageAuthenticationFailed) -	} -} - -func handleOIDCWorkflowResponseWithTargetURL(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, targetURI string) { -	var ( -		issuerURL *url.URL -		targetURL *url.URL -		err       error -	) - -	if targetURL, err = url.ParseRequestURI(targetURI); err != nil { -		ctx.Error(fmt.Errorf("unable to parse target URL '%s': %w", targetURI, err), messageAuthenticationFailed) - -		return -	} - -	issuerURL = ctx.RootURL() - -	if targetURL.Host != issuerURL.Host { -		ctx.Error(fmt.Errorf("unable to redirect to '%s': target host '%s' does not match expected issuer host '%s'", targetURL, targetURL.Host, issuerURL.Host), messageAuthenticationFailed) - -		return -	} - -	if userSession.IsAnonymous() { -		ctx.Error(fmt.Errorf("unable to redirect to '%s': user is anonymous", targetURL), messageAuthenticationFailed) - -		return -	} - -	if err = ctx.SetJSONBody(redirectResponse{Redirect: targetURL.String()}); err != nil { -		ctx.Logger.Errorf("Unable to set default redirection URL in body: %s", err) -	} -} - -func handleOIDCWorkflowResponseWithID(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, id string) { +func handleOIDCWorkflowResponse(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, id string) {  	var (  		workflowID uuid.UUID  		client     oidc.Client @@ -194,7 +149,7 @@ func handleOIDCWorkflowResponseWithID(ctx *middlewares.AutheliaCtx, userSession  	}  	if consent.Responded() { -		ctx.Error(fmt.Errorf("consent has already been responded to '%s': %w", id, err), messageAuthenticationFailed) +		ctx.Error(fmt.Errorf("consent has already been responded to '%s'", id), messageAuthenticationFailed)  		return  	} @@ -211,26 +166,58 @@ func handleOIDCWorkflowResponseWithID(ctx *middlewares.AutheliaCtx, userSession  		return  	} -	level := client.GetAuthorizationPolicyRequiredLevel(authorization.Subject{Username: userSession.Username, Groups: userSession.Groups, IP: ctx.RemoteIP()}) +	if !consent.Subject.Valid { +		if consent.Subject.UUID, err = ctx.Providers.OpenIDConnect.GetSubject(ctx, client.GetSectorIdentifierURI(), userSession.Username); err != nil { +			ctx.Error(fmt.Errorf("unable to determine consent subject for client with id '%s' with consent challenge id '%s': %w", client.GetID(), consent.ChallengeID, err), messageAuthenticationFailed) -	switch { -	case authorization.IsAuthLevelSufficient(userSession.AuthenticationLevel, level), level == authorization.Denied: -		var ( -			targetURL *url.URL -			form      url.Values -		) +			return +		} -		targetURL = ctx.RootURL() +		consent.Subject.Valid = true -		if form, err = consent.GetForm(); err != nil { -			ctx.Error(fmt.Errorf("unable to get authorization form values from consent session with challenge id '%s': %w", consent.ChallengeID, err), messageAuthenticationFailed) +		if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionSubject(ctx, consent); err != nil { +			ctx.Error(fmt.Errorf("unable to update consent subject for client with id '%s' with consent challenge id '%s': %w", client.GetID(), consent.ChallengeID, err), messageAuthenticationFailed)  			return  		} +	} -		form.Set(queryArgConsentID, workflowID.String()) +	var ( +		issuer *url.URL +		form   url.Values +	) + +	issuer = ctx.RootURL() + +	if form, err = consent.GetForm(); err != nil { +		ctx.Error(fmt.Errorf("unable to get authorization form values from consent session with challenge id '%s': %w", consent.ChallengeID, err), messageAuthenticationFailed) + +		return +	} + +	if oidc.RequestFormRequiresLogin(form, consent.RequestedAt, userSession.LastAuthenticatedTime()) { +		targetURL := issuer.JoinPath(oidc.EndpointPathConsentLogin) + +		query := targetURL.Query() +		query.Set(queryArgWorkflow, workflowOpenIDConnect) +		query.Set(queryArgWorkflowID, workflowID.String()) -		targetURL.Path = path.Join(targetURL.Path, oidc.EndpointPathAuthorization) +		targetURL.RawQuery = query.Encode() + +		if err = ctx.SetJSONBody(redirectResponse{Redirect: targetURL.String()}); err != nil { +			ctx.Logger.Errorf("Unable to set default redirection URL in body: %s", err) +		} + +		return +	} + +	level := client.GetAuthorizationPolicyRequiredLevel(authorization.Subject{Username: userSession.Username, Groups: userSession.Groups, IP: ctx.RemoteIP()}) + +	switch { +	case authorization.IsAuthLevelSufficient(userSession.AuthenticationLevel, level), level == authorization.Denied: +		targetURL := issuer.JoinPath(oidc.EndpointPathAuthorization) + +		form.Set(queryArgConsentID, workflowID.String())  		targetURL.RawQuery = form.Encode()  		if err = ctx.SetJSONBody(redirectResponse{Redirect: targetURL.String()}); err != nil { diff --git a/internal/handlers/types.go b/internal/handlers/types.go index 399d4066a..377b158b9 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -94,15 +94,24 @@ type bodyPreferred2FAMethod struct {  type bodyFirstFactorRequest struct {  	Username       string `json:"username" valid:"required"`  	Password       string `json:"password" valid:"required"` -	TargetURL      string `json:"targetURL"`  	Workflow       string `json:"workflow"`  	WorkflowID     string `json:"workflowID"` +	TargetURL      string `json:"targetURL"`  	RequestMethod  string `json:"requestMethod"`  	KeepMeLoggedIn *bool  `json:"keepMeLoggedIn"`  	// KeepMeLoggedIn: Cannot require this field because of https://github.com/asaskevich/govalidator/pull/329  	// TODO(c.michaud): add required validation once the above PR is merged.  } +// bodyFirstFactorRequest represents the JSON body received by the endpoint. +type bodyFirstFactorReauthenticateRequest struct { +	Password      string `json:"password" valid:"required"` +	Workflow      string `json:"workflow"` +	WorkflowID    string `json:"workflowID"` +	TargetURL     string `json:"targetURL"` +	RequestMethod string `json:"requestMethod"` +} +  // checkURIWithinDomainRequestBody represents the JSON body received by the endpoint checking if an URI is within  // the configured domain.  type checkURIWithinDomainRequestBody struct { diff --git a/internal/mocks/authelia_ctx.go b/internal/mocks/authelia_ctx.go index fb7f4d9ec..c9f53fd79 100644 --- a/internal/mocks/authelia_ctx.go +++ b/internal/mocks/authelia_ctx.go @@ -4,6 +4,7 @@ import (  	"encoding/json"  	"fmt"  	"net/url" +	"regexp"  	"testing"  	"time" @@ -303,6 +304,56 @@ func (m *MockAutheliaCtx) Assert200OK(t *testing.T, data interface{}) {  	assert.Equal(t, string(b), string(m.Ctx.Response.Body()))  } +func (m *MockAutheliaCtx) AssertLastLogMessageRegexp(t *testing.T, message, err *regexp.Regexp) { +	entry := m.Hook.LastEntry() + +	require.NotNil(t, entry) + +	if message != nil { +		assert.Regexp(t, message, entry.Message) +	} + +	v, ok := entry.Data["error"] + +	if err == nil { +		assert.False(t, ok) +		assert.Nil(t, v) +	} else { +		assert.True(t, ok) +		require.NotNil(t, v) + +		theErr, ok := v.(error) +		assert.True(t, ok) +		require.NotNil(t, theErr) + +		assert.Regexp(t, err, theErr.Error()) +	} +} + +func (m *MockAutheliaCtx) AssertLastLogMessage(t *testing.T, message, err string) { +	entry := m.Hook.LastEntry() + +	require.NotNil(t, entry) + +	assert.Equal(t, message, entry.Message) + +	v, ok := entry.Data["error"] + +	if err == "" { +		assert.False(t, ok) +		assert.Nil(t, v) +	} else { +		assert.True(t, ok) +		require.NotNil(t, v) + +		theErr, ok := v.(error) +		assert.True(t, ok) +		require.NotNil(t, theErr) + +		assert.EqualError(t, theErr, err) +	} +} +  // GetResponseData retrieves a response from the service.  func (m *MockAutheliaCtx) GetResponseData(t *testing.T, data interface{}) {  	okResponse := middlewares.OKResponse{} diff --git a/internal/mocks/storage.go b/internal/mocks/storage.go index f3ac04a59..fe70f4339 100644 --- a/internal/mocks/storage.go +++ b/internal/mocks/storage.go @@ -717,7 +717,7 @@ func (mr *MockStorageMockRecorder) SaveOAuth2ConsentPreConfiguration(ctx, config  }  // SaveOAuth2ConsentSession mocks base method. -func (m *MockStorage) SaveOAuth2ConsentSession(ctx context.Context, consent model.OAuth2ConsentSession) error { +func (m *MockStorage) SaveOAuth2ConsentSession(ctx context.Context, consent *model.OAuth2ConsentSession) error {  	m.ctrl.T.Helper()  	ret := m.ctrl.Call(m, "SaveOAuth2ConsentSession", ctx, consent)  	ret0, _ := ret[0].(error) @@ -745,7 +745,7 @@ func (mr *MockStorageMockRecorder) SaveOAuth2ConsentSessionGranted(ctx, id any)  }  // SaveOAuth2ConsentSessionResponse mocks base method. -func (m *MockStorage) SaveOAuth2ConsentSessionResponse(ctx context.Context, consent model.OAuth2ConsentSession, rejection bool) error { +func (m *MockStorage) SaveOAuth2ConsentSessionResponse(ctx context.Context, consent *model.OAuth2ConsentSession, rejection bool) error {  	m.ctrl.T.Helper()  	ret := m.ctrl.Call(m, "SaveOAuth2ConsentSessionResponse", ctx, consent, rejection)  	ret0, _ := ret[0].(error) @@ -759,7 +759,7 @@ func (mr *MockStorageMockRecorder) SaveOAuth2ConsentSessionResponse(ctx, consent  }  // SaveOAuth2ConsentSessionSubject mocks base method. -func (m *MockStorage) SaveOAuth2ConsentSessionSubject(ctx context.Context, consent model.OAuth2ConsentSession) error { +func (m *MockStorage) SaveOAuth2ConsentSessionSubject(ctx context.Context, consent *model.OAuth2ConsentSession) error {  	m.ctrl.T.Helper()  	ret := m.ctrl.Call(m, "SaveOAuth2ConsentSessionSubject", ctx, consent)  	ret0, _ := ret[0].(error) diff --git a/internal/oidc/const.go b/internal/oidc/const.go index 9b9e66ec6..d0e483295 100644 --- a/internal/oidc/const.go +++ b/internal/oidc/const.go @@ -173,6 +173,7 @@ const (  	FormParameterScope        = valueScope  	FormParameterIssuer       = valueIss  	FormParameterPrompt       = "prompt" +	FormParameterMaximumAge   = "max_age"  )  const ( @@ -212,7 +213,10 @@ const (  // Paths.  const ( -	EndpointPathConsent                           = "/consent" +	EndpointPathConsent         = "/consent/openid" +	EndpointPathConsentDecision = EndpointPathConsent + "/decision" +	EndpointPathConsentLogin    = EndpointPathConsent + "/login" +  	EndpointPathWellKnownOpenIDConfiguration      = "/.well-known/openid-configuration"  	EndpointPathWellKnownOAuthAuthorizationServer = "/.well-known/oauth-authorization-server"  	EndpointPathJWKs                              = "/jwks.json" diff --git a/internal/oidc/session.go b/internal/oidc/session.go index de0b7c53f..b747c5437 100644 --- a/internal/oidc/session.go +++ b/internal/oidc/session.go @@ -84,7 +84,7 @@ type Session struct {  }  // GetChallengeID returns the challenge id. -func (s *Session) GetChallengeID() uuid.NullUUID { +func (s *Session) GetChallengeID() (challenge uuid.NullUUID) {  	return s.ChallengeID  } @@ -157,7 +157,7 @@ func (s *Session) GetJWTClaims() jwt.JWTClaimsContainer {  }  // GetIDTokenClaims returns the *jwt.IDTokenClaims for this session. -func (s *Session) GetIDTokenClaims() *jwt.IDTokenClaims { +func (s *Session) GetIDTokenClaims() (claims *jwt.IDTokenClaims) {  	if s.DefaultSession == nil {  		return nil  	} diff --git a/internal/oidc/types.go b/internal/oidc/types.go index 7db01b671..387405d23 100644 --- a/internal/oidc/types.go +++ b/internal/oidc/types.go @@ -141,6 +141,9 @@ type Client interface {  	GetRequirePushedAuthorizationRequests() (enforce bool) +	GetJSONWebKeys() (jwks *jose.JSONWebKeySet) +	GetJSONWebKeysURI() (uri string) +  	GetEnforcePKCE() (enforce bool)  	GetEnforcePKCEChallengeMethod() (enforce bool)  	GetPKCEChallengeMethod() (method string) diff --git a/internal/oidc/util.go b/internal/oidc/util.go index 26382dce0..3b3b22d12 100644 --- a/internal/oidc/util.go +++ b/internal/oidc/util.go @@ -6,6 +6,7 @@ import (  	"net/http"  	"net/url"  	"sort" +	"strconv"  	"strings"  	"time" @@ -368,6 +369,45 @@ func IsMaybeSignedJWT(value string) (is bool) {  	return strings.Count(value, ".") == 2  } +// RequesterRequiresLogin returns true if the oauthelia2.Requester requires the user to authenticate again. +func RequesterRequiresLogin(requester oauthelia2.Requester, requested, authenticated time.Time) (required bool) { +	if requester == nil { +		return false +	} + +	if _, ok := requester.(oauthelia2.DeviceAuthorizeRequester); ok { +		return false +	} + +	return RequestFormRequiresLogin(requester.GetRequestForm(), requested, authenticated) +} + +// RequestFormRequiresLogin returns true if the form requires the user to authenticate again. +func RequestFormRequiresLogin(form url.Values, requested, authenticated time.Time) (required bool) { +	if form.Has(FormParameterPrompt) { +		if oauthelia2.Arguments(oauthelia2.RemoveEmpty(strings.Split(form.Get(FormParameterPrompt), " "))).Has(PromptLogin) && authenticated.Before(requested) { +			return true +		} +	} + +	if form.Has(FormParameterMaximumAge) { +		value := form.Get(FormParameterMaximumAge) + +		var ( +			age int64 +			err error +		) + +		if age, err = strconv.ParseInt(value, 10, 64); err != nil { +			age = 0 +		} + +		return age == 0 || authenticated.IsZero() || requested.IsZero() || authenticated.Add(time.Duration(age)*time.Second).Before(requested) +	} + +	return false +} +  func ValidateSectorIdentifierURI(ctx ClientContext, cache map[string][]string, sectorURI *url.URL, redirectURIs []string) (err error) {  	var (  		sectorRedirectURIs []string diff --git a/internal/oidc/util_blackbox_test.go b/internal/oidc/util_blackbox_test.go index ca110897c..619cbf87a 100644 --- a/internal/oidc/util_blackbox_test.go +++ b/internal/oidc/util_blackbox_test.go @@ -163,6 +163,115 @@ func TestGetLangFromRequester(t *testing.T) {  	}  } +func TestRequesterRequiresLogin(t *testing.T) { +	testCases := []struct { +		name                     string +		have                     oauthelia2.Requester +		requested, authenticated int64 +		expected                 bool +	}{ +		{ +			name: "ShouldNotRequireWithoutPrompt", +			have: &oauthelia2.Request{ +				Form: url.Values{}, +			}, +			requested:     0, +			authenticated: 0, +			expected:      false, +		}, +		{ +			name:          "ShouldHandleNilForm", +			have:          &oauthelia2.Request{}, +			requested:     0, +			authenticated: 0, +			expected:      false, +		}, +		{ +			name:          "ShouldHandleNil", +			have:          nil, +			requested:     0, +			authenticated: 0, +			expected:      false, +		}, +		{ +			name: "ShouldNotRequireWithPromptNone", +			have: &oauthelia2.Request{ +				Form: url.Values{oidc.FormParameterPrompt: []string{oidc.PromptNone}}, +			}, +			requested:     0, +			authenticated: 0, +			expected:      false, +		}, +		{ +			name: "ShouldNotRequireWithPromptNonePastAuthenticated", +			have: &oauthelia2.Request{ +				Form: url.Values{oidc.FormParameterPrompt: []string{oidc.PromptNone}}, +			}, +			requested:     100000, +			authenticated: 0, +			expected:      false, +		}, +		{ +			name: "ShouldNotRequireWithPromptLogin", +			have: &oauthelia2.Request{ +				Form: url.Values{oidc.FormParameterPrompt: []string{oidc.PromptLogin}}, +			}, +			requested:     0, +			authenticated: 0, +			expected:      false, +		}, +		{ +			name: "ShouldRequireWithPromptLoginPastAuthenticated", +			have: &oauthelia2.Request{ +				Form: url.Values{oidc.FormParameterPrompt: []string{oidc.PromptLogin}}, +			}, +			requested:     100000, +			authenticated: 0, +			expected:      true, +		}, +		{ +			name: "ShouldNotRequireWithMaxAge", +			have: &oauthelia2.Request{ +				Form: url.Values{oidc.FormParameterMaximumAge: []string{"100"}}, +			}, +			requested:     0, +			authenticated: 0, +			expected:      false, +		}, +		{ +			name: "ShouldRequireWithMaxAgePastAuthenticated", +			have: &oauthelia2.Request{ +				Form: url.Values{oidc.FormParameterMaximumAge: []string{"100"}}, +			}, +			requested:     100000, +			authenticated: 0, +			expected:      true, +		}, +		{ +			name: "ShouldRequireWithMaxAgePastAuthenticatedInvalid", +			have: &oauthelia2.Request{ +				Form: url.Values{oidc.FormParameterMaximumAge: []string{"not100"}}, +			}, +			requested:     1, +			authenticated: 0, +			expected:      true, +		}, +		{ +			name:          "ShouldHandleDeviceCode", +			have:          &oauthelia2.DeviceAuthorizeRequest{}, +			requested:     0, +			authenticated: 0, +			expected:      false, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			assert.Equal(t, tc.expected, oidc.RequesterRequiresLogin(tc.have, time.Unix(tc.requested, 0), time.Unix(tc.authenticated, 0))) +		}) +	} +} +  type TestGetLangRequester struct {  } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 8de19c571..8e16872d6 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -252,6 +252,7 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)  	delayFunc := middlewares.TimingAttackDelay(10, 250, 85, time.Second, true)  	r.POST("/api/firstfactor", middlewareAPI(handlers.FirstFactorPOST(delayFunc))) +	r.POST("/api/firstfactor/reauthenticate", middleware1FA(handlers.FirstFactorReauthenticatePOST(delayFunc)))  	r.POST("/api/logout", middlewareAPI(handlers.LogoutPOST))  	// Only register endpoints if forgot password is not disabled. diff --git a/internal/session/user_session.go b/internal/session/user_session.go index 958f25957..4c0f7ae90 100644 --- a/internal/session/user_session.go +++ b/internal/session/user_session.go @@ -24,12 +24,17 @@ func (s *UserSession) IsAnonymous() bool {  // SetOneFactor sets the 1FA AMR's and expected property values for one factor authentication.  func (s *UserSession) SetOneFactor(now time.Time, details *authentication.UserDetails, keepMeLoggedIn bool) { -	s.FirstFactorAuthnTimestamp = now.Unix() -	s.LastActivity = now.Unix()  	s.AuthenticationLevel = authentication.OneFactor  	s.KeepMeLoggedIn = keepMeLoggedIn +	s.SetOneFactorReauthenticate(now, details) +} + +func (s *UserSession) SetOneFactorReauthenticate(now time.Time, details *authentication.UserDetails) { +	s.FirstFactorAuthnTimestamp = now.Unix() +	s.LastActivity = now.Unix() +  	s.Username = details.Username  	s.DisplayName = details.DisplayName  	s.Groups = details.Groups @@ -72,18 +77,34 @@ func (s *UserSession) SetTwoFactorWebAuthn(now time.Time, hardware, userPresence  	s.WebAuthn = nil  } +func (s *UserSession) GetFirstFactorAuthn() time.Time { +	return time.Unix(s.FirstFactorAuthnTimestamp, 0).UTC() +} + +func (s *UserSession) GetSecondFactorAuthn() time.Time { +	return time.Unix(s.SecondFactorAuthnTimestamp, 0).UTC() +} +  // AuthenticatedTime returns the unix timestamp this session authenticated successfully at the given level.  func (s *UserSession) AuthenticatedTime(level authorization.Level) (authenticatedTime time.Time, err error) {  	switch level {  	case authorization.OneFactor: -		return time.Unix(s.FirstFactorAuthnTimestamp, 0).UTC(), nil +		return s.GetFirstFactorAuthn(), nil  	case authorization.TwoFactor: -		return time.Unix(s.SecondFactorAuthnTimestamp, 0).UTC(), nil +		return s.GetSecondFactorAuthn(), nil  	default:  		return time.Unix(0, 0).UTC(), errors.New("invalid authorization level")  	}  } +func (s *UserSession) LastAuthenticatedTime() (authenticated time.Time) { +	if s.FirstFactorAuthnTimestamp > s.SecondFactorAuthnTimestamp { +		return s.GetFirstFactorAuthn() +	} + +	return s.GetSecondFactorAuthn() +} +  // Identity value of the user session.  func (s *UserSession) Identity() Identity {  	identity := Identity{ diff --git a/internal/storage/migrations/mysql/V0016.OAuth2ConsentSubjectNULL.down.sql b/internal/storage/migrations/mysql/V0016.OAuth2ConsentSubjectNULL.down.sql new file mode 100644 index 000000000..e2f6ee5e0 --- /dev/null +++ b/internal/storage/migrations/mysql/V0016.OAuth2ConsentSubjectNULL.down.sql @@ -0,0 +1,5 @@ +DELETE FROM oauth2_consent_session +WHERE subject IS NULL; + +ALTER TABLE oauth2_consent_session +    MODIFY subject CHAR(36) NOT NULL; diff --git a/internal/storage/migrations/mysql/V0016.OAuth2ConsentSubjectNULL.up.sql b/internal/storage/migrations/mysql/V0016.OAuth2ConsentSubjectNULL.up.sql new file mode 100644 index 000000000..fe97e2beb --- /dev/null +++ b/internal/storage/migrations/mysql/V0016.OAuth2ConsentSubjectNULL.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE oauth2_consent_session +    MODIFY subject CHAR(36) NULL DEFAULT NULL; diff --git a/internal/storage/migrations/postgres/V0016.OAuth2ConsentSubjectNULL.down.sql b/internal/storage/migrations/postgres/V0016.OAuth2ConsentSubjectNULL.down.sql new file mode 100644 index 000000000..7e99449ec --- /dev/null +++ b/internal/storage/migrations/postgres/V0016.OAuth2ConsentSubjectNULL.down.sql @@ -0,0 +1,6 @@ +DELETE FROM oauth2_consent_session +WHERE subject IS NULL; + +ALTER TABLE oauth2_consent_session +    ALTER COLUMN subject SET NOT NULL, +    ALTER COLUMN subject DROP DEFAULT; diff --git a/internal/storage/migrations/postgres/V0016.OAuth2ConsentSubjectNULL.up.sql b/internal/storage/migrations/postgres/V0016.OAuth2ConsentSubjectNULL.up.sql new file mode 100644 index 000000000..c45544d39 --- /dev/null +++ b/internal/storage/migrations/postgres/V0016.OAuth2ConsentSubjectNULL.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE oauth2_consent_session +    ALTER COLUMN subject DROP NOT NULL, +    ALTER COLUMN subject SET DEFAULT NULL; diff --git a/internal/storage/migrations/sqlite/V0016.OAuth2ConsentSubjectNULL.down.sql b/internal/storage/migrations/sqlite/V0016.OAuth2ConsentSubjectNULL.down.sql new file mode 100644 index 000000000..76196c01c --- /dev/null +++ b/internal/storage/migrations/sqlite/V0016.OAuth2ConsentSubjectNULL.down.sql @@ -0,0 +1,382 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE IF NOT EXISTS _bkp_DOWN_V0016_oauth2_consent_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    subject CHAR(36) NULL DEFAULT NULL, +    authorized BOOLEAN NOT NULL DEFAULT FALSE, +    granted BOOLEAN NOT NULL DEFAULT FALSE, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    responded_at DATETIME NULL DEFAULT NULL, +    form_data TEXT NOT NULL, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    preconfiguration INTEGER NULL DEFAULT NULL +); + +INSERT INTO _bkp_DOWN_V0016_oauth2_consent_session (challenge_id, client_id, subject, authorized, granted, requested_at, responded_at, form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, preconfiguration) +SELECT challenge_id, client_id, subject, authorized, granted, requested_at, responded_at, form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, preconfiguration +FROM oauth2_consent_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_consent_session; + +CREATE TABLE IF NOT EXISTS _bkp_DOWN_V0016_oauth2_authorization_code_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL +); + +INSERT INTO _bkp_DOWN_V0016_oauth2_authorization_code_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM oauth2_authorization_code_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_authorization_code_session; + +CREATE TABLE IF NOT EXISTS _bkp_DOWN_V0016_oauth2_access_token_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NULL DEFAULT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL +); + +INSERT INTO _bkp_DOWN_V0016_oauth2_access_token_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM oauth2_access_token_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_access_token_session; + +CREATE TABLE IF NOT EXISTS _bkp_DOWN_V0016_oauth2_refresh_token_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL +); + +INSERT INTO _bkp_DOWN_V0016_oauth2_refresh_token_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM oauth2_refresh_token_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_refresh_token_session; + +CREATE TABLE IF NOT EXISTS _bkp_DOWN_V0016_oauth2_pkce_request_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL +); + +INSERT INTO _bkp_DOWN_V0016_oauth2_pkce_request_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM oauth2_pkce_request_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_pkce_request_session; + +CREATE TABLE IF NOT EXISTS _bkp_DOWN_V0016_oauth2_openid_connect_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL +); + +INSERT INTO _bkp_DOWN_V0016_oauth2_openid_connect_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM oauth2_openid_connect_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_openid_connect_session; + +DROP INDEX IF EXISTS oauth2_consent_session_challenge_id_key; + +CREATE TABLE IF NOT EXISTS oauth2_consent_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    authorized BOOLEAN NOT NULL DEFAULT FALSE, +    granted BOOLEAN NOT NULL DEFAULT FALSE, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    responded_at DATETIME NULL DEFAULT NULL, +    form_data TEXT NOT NULL, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    preconfiguration INTEGER NULL DEFAULT NULL, +    CONSTRAINT oauth2_consent_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT, +    CONSTRAINT oauth2_consent_session_preconfiguration_fkey +        FOREIGN KEY (preconfiguration) +            REFERENCES oauth2_consent_preconfiguration (id) ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE UNIQUE INDEX oauth2_consent_session_challenge_id_key ON oauth2_consent_session (challenge_id); + +INSERT INTO oauth2_consent_session (challenge_id, client_id, subject, authorized, granted, requested_at, responded_at, form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, preconfiguration) +SELECT challenge_id, client_id, subject, authorized, granted, requested_at, responded_at, form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, preconfiguration +FROM _bkp_DOWN_V0016_oauth2_consent_session +WHERE subject IS NOT NULL +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_DOWN_V0016_oauth2_consent_session; + +DROP INDEX IF EXISTS oauth2_authorization_code_session_request_id_idx; +DROP INDEX IF EXISTS oauth2_authorization_code_session_client_id_idx; +DROP INDEX IF EXISTS oauth2_authorization_code_session_client_id_subject_idx; + +CREATE TABLE IF NOT EXISTS oauth2_authorization_code_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL, +    CONSTRAINT oauth2_authorization_code_session_challenge_id_fkey +        FOREIGN KEY (challenge_id) +            REFERENCES oauth2_consent_session (challenge_id) ON UPDATE CASCADE ON DELETE CASCADE, +    CONSTRAINT oauth2_authorization_code_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE INDEX oauth2_authorization_code_session_request_id_idx ON oauth2_authorization_code_session (request_id); +CREATE INDEX oauth2_authorization_code_session_client_id_idx ON oauth2_authorization_code_session (client_id); +CREATE INDEX oauth2_authorization_code_session_client_id_subject_idx ON oauth2_authorization_code_session (client_id, subject); + +INSERT INTO oauth2_authorization_code_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM _bkp_DOWN_V0016_oauth2_authorization_code_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_DOWN_V0016_oauth2_authorization_code_session; + +DROP INDEX IF EXISTS oauth2_access_token_session_request_id_idx; +DROP INDEX IF EXISTS oauth2_access_token_session_client_id_idx; +DROP INDEX IF EXISTS oauth2_access_token_session_client_id_subject_idx; + +CREATE TABLE IF NOT EXISTS oauth2_access_token_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NULL DEFAULT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(768) NOT NULL, +    subject CHAR(36) NULL DEFAULT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL, +    CONSTRAINT oauth2_access_token_session_challenge_id_fkey +        FOREIGN KEY (challenge_id) +            REFERENCES oauth2_consent_session (challenge_id) ON UPDATE CASCADE ON DELETE CASCADE, +    CONSTRAINT oauth2_access_token_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE INDEX oauth2_access_token_session_request_id_idx ON oauth2_access_token_session (request_id); +CREATE INDEX oauth2_access_token_session_client_id_idx ON oauth2_access_token_session (client_id); +CREATE INDEX oauth2_access_token_session_client_id_subject_idx ON oauth2_access_token_session (client_id, subject); + +INSERT INTO oauth2_access_token_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM _bkp_DOWN_V0016_oauth2_access_token_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_DOWN_V0016_oauth2_access_token_session; + +DROP INDEX IF EXISTS oauth2_refresh_token_session_request_id_idx; +DROP INDEX IF EXISTS oauth2_refresh_token_session_client_id_idx; +DROP INDEX IF EXISTS oauth2_refresh_token_session_client_id_subject_idx; + +CREATE TABLE IF NOT EXISTS oauth2_refresh_token_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL, +    CONSTRAINT oauth2_refresh_token_session_challenge_id_fkey +        FOREIGN KEY (challenge_id) +            REFERENCES oauth2_consent_session (challenge_id) ON UPDATE CASCADE ON DELETE CASCADE, +    CONSTRAINT oauth2_refresh_token_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE INDEX oauth2_refresh_token_session_request_id_idx ON oauth2_refresh_token_session (request_id); +CREATE INDEX oauth2_refresh_token_session_client_id_idx ON oauth2_refresh_token_session (client_id); +CREATE INDEX oauth2_refresh_token_session_client_id_subject_idx ON oauth2_refresh_token_session (client_id, subject); + +INSERT INTO oauth2_refresh_token_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM _bkp_DOWN_V0016_oauth2_refresh_token_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_DOWN_V0016_oauth2_refresh_token_session; + +DROP INDEX IF EXISTS oauth2_pkce_request_session_request_id_idx; +DROP INDEX IF EXISTS oauth2_pkce_request_session_client_id_idx; +DROP INDEX IF EXISTS oauth2_pkce_request_session_client_id_subject_idx; + +CREATE TABLE IF NOT EXISTS oauth2_pkce_request_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL, +    CONSTRAINT oauth2_pkce_request_session_challenge_id_fkey +        FOREIGN KEY (challenge_id) +            REFERENCES oauth2_consent_session (challenge_id) ON UPDATE CASCADE ON DELETE CASCADE, +    CONSTRAINT oauth2_pkce_request_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE INDEX oauth2_pkce_request_session_request_id_idx ON oauth2_pkce_request_session (request_id); +CREATE INDEX oauth2_pkce_request_session_client_id_idx ON oauth2_pkce_request_session (client_id); +CREATE INDEX oauth2_pkce_request_session_client_id_subject_idx ON oauth2_pkce_request_session (client_id, subject); + +INSERT INTO oauth2_pkce_request_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM _bkp_DOWN_V0016_oauth2_pkce_request_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_DOWN_V0016_oauth2_pkce_request_session; + +DROP INDEX IF EXISTS oauth2_openid_connect_session_request_id_idx; +DROP INDEX IF EXISTS oauth2_openid_connect_session_client_id_idx; +DROP INDEX IF EXISTS oauth2_openid_connect_session_client_id_subject_idx; + +CREATE TABLE IF NOT EXISTS oauth2_openid_connect_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL, +    CONSTRAINT oauth2_openid_connect_session_challenge_id_fkey +        FOREIGN KEY (challenge_id) +            REFERENCES oauth2_consent_session (challenge_id) ON UPDATE CASCADE ON DELETE CASCADE, +    CONSTRAINT oauth2_openid_connect_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE INDEX oauth2_openid_connect_session_request_id_idx ON oauth2_openid_connect_session (request_id); +CREATE INDEX oauth2_openid_connect_session_client_id_idx ON oauth2_openid_connect_session (client_id); +CREATE INDEX oauth2_openid_connect_session_client_id_subject_idx ON oauth2_openid_connect_session (client_id, subject); + +INSERT INTO oauth2_openid_connect_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM _bkp_DOWN_V0016_oauth2_openid_connect_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_DOWN_V0016_oauth2_openid_connect_session; + +PRAGMA foreign_keys=on; diff --git a/internal/storage/migrations/sqlite/V0016.OAuth2ConsentSubjectNULL.up.sql b/internal/storage/migrations/sqlite/V0016.OAuth2ConsentSubjectNULL.up.sql new file mode 100644 index 000000000..633ed83e8 --- /dev/null +++ b/internal/storage/migrations/sqlite/V0016.OAuth2ConsentSubjectNULL.up.sql @@ -0,0 +1,381 @@ +PRAGMA foreign_keys=off; + +CREATE TABLE IF NOT EXISTS _bkp_UP_V0016_oauth2_consent_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    authorized BOOLEAN NOT NULL DEFAULT FALSE, +    granted BOOLEAN NOT NULL DEFAULT FALSE, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    responded_at DATETIME NULL DEFAULT NULL, +    form_data TEXT NOT NULL, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    preconfiguration INTEGER NULL DEFAULT NULL +); + +INSERT INTO _bkp_UP_V0016_oauth2_consent_session (challenge_id, client_id, subject, authorized, granted, requested_at, responded_at, form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, preconfiguration) +SELECT challenge_id, client_id, subject, authorized, granted, requested_at, responded_at, form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, preconfiguration +FROM oauth2_consent_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_consent_session; + +CREATE TABLE IF NOT EXISTS _bkp_UP_V0016_oauth2_authorization_code_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL +); + +INSERT INTO _bkp_UP_V0016_oauth2_authorization_code_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM oauth2_authorization_code_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_authorization_code_session; + +CREATE TABLE IF NOT EXISTS _bkp_UP_V0016_oauth2_access_token_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NULL DEFAULT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NULL DEFAULT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL +); + +INSERT INTO _bkp_UP_V0016_oauth2_access_token_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM oauth2_access_token_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_access_token_session; + +CREATE TABLE IF NOT EXISTS _bkp_UP_V0016_oauth2_refresh_token_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL +); + +INSERT INTO _bkp_UP_V0016_oauth2_refresh_token_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM oauth2_refresh_token_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_refresh_token_session; + +CREATE TABLE IF NOT EXISTS _bkp_UP_V0016_oauth2_pkce_request_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL +); + +INSERT INTO _bkp_UP_V0016_oauth2_pkce_request_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM oauth2_pkce_request_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_pkce_request_session; + +CREATE TABLE IF NOT EXISTS _bkp_UP_V0016_oauth2_openid_connect_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL +); + +INSERT INTO _bkp_UP_V0016_oauth2_openid_connect_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM oauth2_openid_connect_session +ORDER BY id; + +DROP TABLE IF EXISTS oauth2_openid_connect_session; + +DROP INDEX IF EXISTS oauth2_consent_session_challenge_id_key; + +CREATE TABLE IF NOT EXISTS oauth2_consent_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    subject CHAR(36) NULL DEFAULT NULL, +    authorized BOOLEAN NOT NULL DEFAULT FALSE, +    granted BOOLEAN NOT NULL DEFAULT FALSE, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    responded_at DATETIME NULL DEFAULT NULL, +    form_data TEXT NOT NULL, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    preconfiguration INTEGER NULL DEFAULT NULL, +    CONSTRAINT oauth2_consent_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT, +    CONSTRAINT oauth2_consent_session_preconfiguration_fkey +        FOREIGN KEY (preconfiguration) +            REFERENCES oauth2_consent_preconfiguration (id) ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE UNIQUE INDEX oauth2_consent_session_challenge_id_key ON oauth2_consent_session (challenge_id); + +INSERT INTO oauth2_consent_session (challenge_id, client_id, subject, authorized, granted, requested_at, responded_at, form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, preconfiguration) +SELECT challenge_id, client_id, subject, authorized, granted, requested_at, responded_at, form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, preconfiguration +FROM _bkp_UP_V0016_oauth2_consent_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_UP_V0016_oauth2_consent_session; + +DROP INDEX IF EXISTS oauth2_authorization_code_session_request_id_idx; +DROP INDEX IF EXISTS oauth2_authorization_code_session_client_id_idx; +DROP INDEX IF EXISTS oauth2_authorization_code_session_client_id_subject_idx; + +CREATE TABLE IF NOT EXISTS oauth2_authorization_code_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL, +    CONSTRAINT oauth2_authorization_code_session_challenge_id_fkey +        FOREIGN KEY (challenge_id) +            REFERENCES oauth2_consent_session (challenge_id) ON UPDATE CASCADE ON DELETE CASCADE, +    CONSTRAINT oauth2_authorization_code_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE INDEX oauth2_authorization_code_session_request_id_idx ON oauth2_authorization_code_session (request_id); +CREATE INDEX oauth2_authorization_code_session_client_id_idx ON oauth2_authorization_code_session (client_id); +CREATE INDEX oauth2_authorization_code_session_client_id_subject_idx ON oauth2_authorization_code_session (client_id, subject); + +INSERT INTO oauth2_authorization_code_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM _bkp_UP_V0016_oauth2_authorization_code_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_UP_V0016_oauth2_authorization_code_session; + +DROP INDEX IF EXISTS oauth2_access_token_session_request_id_idx; +DROP INDEX IF EXISTS oauth2_access_token_session_client_id_idx; +DROP INDEX IF EXISTS oauth2_access_token_session_client_id_subject_idx; + +CREATE TABLE IF NOT EXISTS oauth2_access_token_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NULL DEFAULT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(768) NOT NULL, +    subject CHAR(36) NULL DEFAULT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL, +    CONSTRAINT oauth2_access_token_session_challenge_id_fkey +        FOREIGN KEY (challenge_id) +            REFERENCES oauth2_consent_session (challenge_id) ON UPDATE CASCADE ON DELETE CASCADE, +    CONSTRAINT oauth2_access_token_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE INDEX oauth2_access_token_session_request_id_idx ON oauth2_access_token_session (request_id); +CREATE INDEX oauth2_access_token_session_client_id_idx ON oauth2_access_token_session (client_id); +CREATE INDEX oauth2_access_token_session_client_id_subject_idx ON oauth2_access_token_session (client_id, subject); + +INSERT INTO oauth2_access_token_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM _bkp_UP_V0016_oauth2_access_token_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_UP_V0016_oauth2_access_token_session; + +DROP INDEX IF EXISTS oauth2_refresh_token_session_request_id_idx; +DROP INDEX IF EXISTS oauth2_refresh_token_session_client_id_idx; +DROP INDEX IF EXISTS oauth2_refresh_token_session_client_id_subject_idx; + +CREATE TABLE IF NOT EXISTS oauth2_refresh_token_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL, +    CONSTRAINT oauth2_refresh_token_session_challenge_id_fkey +        FOREIGN KEY (challenge_id) +            REFERENCES oauth2_consent_session (challenge_id) ON UPDATE CASCADE ON DELETE CASCADE, +    CONSTRAINT oauth2_refresh_token_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE INDEX oauth2_refresh_token_session_request_id_idx ON oauth2_refresh_token_session (request_id); +CREATE INDEX oauth2_refresh_token_session_client_id_idx ON oauth2_refresh_token_session (client_id); +CREATE INDEX oauth2_refresh_token_session_client_id_subject_idx ON oauth2_refresh_token_session (client_id, subject); + +INSERT INTO oauth2_refresh_token_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM _bkp_UP_V0016_oauth2_refresh_token_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_UP_V0016_oauth2_refresh_token_session; + +DROP INDEX IF EXISTS oauth2_pkce_request_session_request_id_idx; +DROP INDEX IF EXISTS oauth2_pkce_request_session_client_id_idx; +DROP INDEX IF EXISTS oauth2_pkce_request_session_client_id_subject_idx; + +CREATE TABLE IF NOT EXISTS oauth2_pkce_request_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL, +    CONSTRAINT oauth2_pkce_request_session_challenge_id_fkey +        FOREIGN KEY (challenge_id) +            REFERENCES oauth2_consent_session (challenge_id) ON UPDATE CASCADE ON DELETE CASCADE, +    CONSTRAINT oauth2_pkce_request_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE INDEX oauth2_pkce_request_session_request_id_idx ON oauth2_pkce_request_session (request_id); +CREATE INDEX oauth2_pkce_request_session_client_id_idx ON oauth2_pkce_request_session (client_id); +CREATE INDEX oauth2_pkce_request_session_client_id_subject_idx ON oauth2_pkce_request_session (client_id, subject); + +INSERT INTO oauth2_pkce_request_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM _bkp_UP_V0016_oauth2_pkce_request_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_UP_V0016_oauth2_pkce_request_session; + +DROP INDEX IF EXISTS oauth2_openid_connect_session_request_id_idx; +DROP INDEX IF EXISTS oauth2_openid_connect_session_client_id_idx; +DROP INDEX IF EXISTS oauth2_openid_connect_session_client_id_subject_idx; + +CREATE TABLE IF NOT EXISTS oauth2_openid_connect_session ( +    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +    challenge_id CHAR(36) NOT NULL, +    request_id VARCHAR(40) NOT NULL, +    client_id VARCHAR(255) NOT NULL, +    signature VARCHAR(255) NOT NULL, +    subject CHAR(36) NOT NULL, +    requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +    requested_scopes TEXT NOT NULL, +    granted_scopes TEXT NOT NULL, +    requested_audience TEXT NULL DEFAULT '', +    granted_audience TEXT NULL DEFAULT '', +    active BOOLEAN NOT NULL DEFAULT FALSE, +    revoked BOOLEAN NOT NULL DEFAULT FALSE, +    form_data TEXT NOT NULL, +    session_data BLOB NOT NULL, +    CONSTRAINT oauth2_openid_connect_session_challenge_id_fkey +        FOREIGN KEY (challenge_id) +            REFERENCES oauth2_consent_session (challenge_id) ON UPDATE CASCADE ON DELETE CASCADE, +    CONSTRAINT oauth2_openid_connect_session_subject_fkey +        FOREIGN KEY (subject) +            REFERENCES user_opaque_identifier (identifier) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE INDEX oauth2_openid_connect_session_request_id_idx ON oauth2_openid_connect_session (request_id); +CREATE INDEX oauth2_openid_connect_session_client_id_idx ON oauth2_openid_connect_session (client_id); +CREATE INDEX oauth2_openid_connect_session_client_id_subject_idx ON oauth2_openid_connect_session (client_id, subject); + +INSERT INTO oauth2_openid_connect_session (challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data) +SELECT challenge_id, request_id, client_id, signature, subject, requested_at, requested_scopes, granted_scopes, requested_audience, granted_audience, active, revoked, form_data, session_data +FROM _bkp_UP_V0016_oauth2_openid_connect_session +ORDER BY id; + +DROP TABLE IF EXISTS _bkp_UP_V0016_oauth2_openid_connect_session; + +PRAGMA foreign_keys=on; diff --git a/internal/storage/migrations_test.go b/internal/storage/migrations_test.go index 7cfedace2..ab47238da 100644 --- a/internal/storage/migrations_test.go +++ b/internal/storage/migrations_test.go @@ -11,7 +11,7 @@ import (  const (  	// This is the latest schema version for the purpose of tests. -	LatestVersion = 15 +	LatestVersion = 16  )  func TestShouldObtainCorrectMigrations(t *testing.T) { diff --git a/internal/storage/provider.go b/internal/storage/provider.go index 0c461ccdc..b30bf45b5 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -197,13 +197,13 @@ type Provider interface {  	*/  	// SaveOAuth2ConsentSession inserts an OAuth2.0 consent session to the storage provider. -	SaveOAuth2ConsentSession(ctx context.Context, consent model.OAuth2ConsentSession) (err error) +	SaveOAuth2ConsentSession(ctx context.Context, consent *model.OAuth2ConsentSession) (err error)  	// SaveOAuth2ConsentSessionSubject updates an OAuth2.0 consent session in the storage provider with the subject. -	SaveOAuth2ConsentSessionSubject(ctx context.Context, consent model.OAuth2ConsentSession) (err error) +	SaveOAuth2ConsentSessionSubject(ctx context.Context, consent *model.OAuth2ConsentSession) (err error)  	// SaveOAuth2ConsentSessionResponse updates an OAuth2.0 consent session in the storage provider with the response. -	SaveOAuth2ConsentSessionResponse(ctx context.Context, consent model.OAuth2ConsentSession, rejection bool) (err error) +	SaveOAuth2ConsentSessionResponse(ctx context.Context, consent *model.OAuth2ConsentSession, rejection bool) (err error)  	// SaveOAuth2ConsentSessionGranted updates an OAuth2.0 consent session in the storage provider recording that it  	// has been granted by the authorization endpoint. @@ -232,7 +232,7 @@ type Provider interface {  	// DeactivateOAuth2SessionByRequestID marks an OAuth2.0 session as inactive in the storage provider.  	DeactivateOAuth2SessionByRequestID(ctx context.Context, sessionType OAuth2SessionType, requestID string) (err error) -	// LoadOAuth2Session saves an OAuth2.0 session from the storage provider. +	// LoadOAuth2Session loads an OAuth2.0 session from the storage provider.  	LoadOAuth2Session(ctx context.Context, sessionType OAuth2SessionType, signature string) (session *model.OAuth2Session, err error)  	/* diff --git a/internal/storage/sql_provider.go b/internal/storage/sql_provider.go index 21c84cfe2..b5e212a28 100644 --- a/internal/storage/sql_provider.go +++ b/internal/storage/sql_provider.go @@ -1010,7 +1010,7 @@ func (p *SQLProvider) LoadOAuth2ConsentPreConfigurations(ctx context.Context, cl  }  // SaveOAuth2ConsentSession inserts an OAuth2.0 consent session to the storage provider. -func (p *SQLProvider) SaveOAuth2ConsentSession(ctx context.Context, consent model.OAuth2ConsentSession) (err error) { +func (p *SQLProvider) SaveOAuth2ConsentSession(ctx context.Context, consent *model.OAuth2ConsentSession) (err error) {  	if _, err = p.db.ExecContext(ctx, p.sqlInsertOAuth2ConsentSession,  		consent.ChallengeID, consent.ClientID, consent.Subject, consent.Authorized, consent.Granted,  		consent.RequestedAt, consent.RespondedAt, consent.Form, @@ -1022,7 +1022,7 @@ func (p *SQLProvider) SaveOAuth2ConsentSession(ctx context.Context, consent mode  }  // SaveOAuth2ConsentSessionSubject updates an OAuth2.0 consent session in the storage provider with the subject. -func (p *SQLProvider) SaveOAuth2ConsentSessionSubject(ctx context.Context, consent model.OAuth2ConsentSession) (err error) { +func (p *SQLProvider) SaveOAuth2ConsentSessionSubject(ctx context.Context, consent *model.OAuth2ConsentSession) (err error) {  	if _, err = p.db.ExecContext(ctx, p.sqlUpdateOAuth2ConsentSessionSubject, consent.Subject, consent.ID); err != nil {  		return fmt.Errorf("error updating oauth2 consent session subject with id '%d' and challenge id '%s' for subject '%s': %w", consent.ID, consent.ChallengeID, consent.Subject.UUID, err)  	} @@ -1031,7 +1031,7 @@ func (p *SQLProvider) SaveOAuth2ConsentSessionSubject(ctx context.Context, conse  }  // SaveOAuth2ConsentSessionResponse updates an OAuth2.0 consent session in the storage provider with the response. -func (p *SQLProvider) SaveOAuth2ConsentSessionResponse(ctx context.Context, consent model.OAuth2ConsentSession, authorized bool) (err error) { +func (p *SQLProvider) SaveOAuth2ConsentSessionResponse(ctx context.Context, consent *model.OAuth2ConsentSession, authorized bool) (err error) {  	if _, err = p.db.ExecContext(ctx, p.sqlUpdateOAuth2ConsentSessionResponse, authorized, consent.GrantedScopes, consent.GrantedAudience, consent.PreConfiguration, consent.ID); err != nil {  		return fmt.Errorf("error updating oauth2 consent session (authorized  '%t') with id '%d' and challenge id '%s' for subject '%s': %w", authorized, consent.ID, consent.ChallengeID, consent.Subject.UUID, err)  	} @@ -1090,7 +1090,7 @@ func (p *SQLProvider) SaveOAuth2Session(ctx context.Context, sessionType OAuth2S  		session.Active, session.Revoked, session.Form, session.Session)  	if err != nil { -		return fmt.Errorf("error inserting oauth2 %s session data with signature '%s' for subject '%s' and request id '%s' and challenge id '%s': %w", sessionType, session.Signature, session.Subject.String, session.RequestID, session.ChallengeID.UUID, err) +		return fmt.Errorf("error inserting oauth2 %s session with signature '%s' for subject '%s' and request id '%s' and challenge id '%s': %w", sessionType, session.Signature, session.Subject.String, session.RequestID, session.ChallengeID.UUID, err)  	}  	return nil @@ -1335,3 +1335,7 @@ func (p *SQLProvider) LoadAuthenticationLogs(ctx context.Context, username strin  	return attempts, nil  } + +var ( +	_ Provider = (*SQLProvider)(nil) +)  | 
