feat: add ability to force email verification (#1837)

This commit is contained in:
Elias Schneider
2026-10-13 22:04:33 +02:00
committed by GitHub
parent eb8ea6bed2
commit 0ffeb170cf
81 changed files with 2310 additions and 263 deletions
+19 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/fosite"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/authz"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -136,6 +137,17 @@ func (h *authorizationHandler) completeInteraction(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// redirectAuthorizationErrors shows the errors of the authorization middleware, such as a disabled account or an unverified email, on the interaction error page
// The middleware refuses these requests before the authorize handler runs, so its error is still unwritten when the chain returns here
func (h *authorizationHandler) redirectAuthorizationErrors(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 || c.Writer.Written() {
return
}
h.redirectToInteractionError(c, c.Errors.Last().Err)
}
func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) {
if rfcErr, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
attrs := []slog.Attr{
@@ -170,12 +182,14 @@ func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.C
func (h *authorizationHandler) redirectToInteractionError(c *gin.Context, err error) {
errorMessage := "An unknown error occurred during the authorization request."
if err, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
if err.HintField != "" {
errorMessage = err.HintField
} else if err.DescriptionField != "" {
errorMessage = err.DescriptionField
if rfcErr, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
if rfcErr.HintField != "" {
errorMessage = rfcErr.HintField
} else if rfcErr.DescriptionField != "" {
errorMessage = rfcErr.DescriptionField
}
} else if appErr, ok := errors.AsType[*apperror.Error](err); ok {
errorMessage = appErr.ClientMessage()
}
query := url.Values{}
@@ -15,6 +15,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/authz"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -34,6 +35,58 @@ type authorizationPARTestCase struct {
malformed bool
}
// fakeSessionAuthenticator signs every request in as a regular user, optionally one who still has to verify their email
type fakeSessionAuthenticator struct {
unverified bool
}
func (fakeSessionAuthenticator) Kind() authz.PrincipalKind { return authz.KindSession }
func (fakeSessionAuthenticator) Present(*gin.Context) bool { return true }
func (a fakeSessionAuthenticator) Authenticate(*gin.Context) (*authz.Principal, error) {
principal := &authz.Principal{Kind: authz.KindSession, UserID: "test-user", Scopes: authz.UserScopes(false, authz.KindSession)}
if a.unverified {
principal.RestrictUnverified()
}
return principal, nil
}
// A browser that the authorization middleware refuses at the authorize endpoint must land on the interaction error page instead of a JSON error
func TestAuthorizationHandlerRedirectsAuthorizationErrors(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := &authorizationHandler{}
serve := func(t *testing.T, authenticator authz.Authenticator) *httptest.ResponseRecorder {
t.Helper()
router := gin.New()
authz.NewMiddleware(authenticator).Router(router.Group("/")).Use(handler.redirectAuthorizationErrors).Optional().
GET("/authorize", authz.AccountAuthorize, func(c *gin.Context) { c.Status(http.StatusNoContent) })
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/authorize", nil))
return recorder
}
t.Run("redirects a user who still has to verify their email", func(t *testing.T) {
recorder := serve(t, fakeSessionAuthenticator{unverified: true})
require.Equal(t, http.StatusFound, recorder.Code)
location, err := url.Parse(recorder.Header().Get("Location"))
require.NoError(t, err)
require.Equal(t, "/interaction/error", location.Path)
require.Equal(t, "You must verify your email address before you can access this service.", location.Query().Get("error"))
})
t.Run("leaves an authorized request to the authorize handler", func(t *testing.T) {
recorder := serve(t, fakeSessionAuthenticator{})
require.Equal(t, http.StatusNoContent, recorder.Code)
require.Empty(t, recorder.Header().Get("Location"))
})
}
func TestAuthorizationHandlerRequiresPAR(t *testing.T) {
gin.SetMode(gin.TestMode)
const fakeURI = parRequestURIPrefix + "missing"
@@ -91,7 +144,7 @@ func testAuthorizationHandlerPAR(t *testing.T, clientType string, tt authorizati
BaseURL: baseURL, TokenBaseURL: baseURL, Secret: []byte("test-secret"),
}, nil)
require.NoError(t, err)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, baseURL, nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil, nil, nil)
handler := newAuthorizationHandler(provider, service)
params := url.Values{
"client_id": {clientID},
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -31,7 +32,7 @@ func (f *fakeAuditLogger) Create(_ context.Context, event auditlogs.Event, _, _,
func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
auditLogger := &fakeAuditLogger{}
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, auditLogger, nil)
const (
userID = "test-user"
@@ -66,7 +67,7 @@ func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
func TestAuthorizationServiceRejectsCustomScopeWithoutResource(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -106,7 +107,7 @@ func TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication(t *test
t.Helper()
db := testutils.NewDatabaseForTest(t)
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, apiAccess)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, apiAccess)
requester := newTestAuthorizeRequesterWithForm("resource-probe", clientID, url.Values{"resource": {resource}})
_, err := service.authorize(t.Context(), authorizeInput{
@@ -140,7 +141,7 @@ func TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication(t *test
func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
auditLogger := &fakeAuditLogger{}
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, auditLogger, nil)
const (
userID = "test-user"
@@ -183,7 +184,7 @@ func TestAuthorizationServiceConsentMergesAudienceQualifiedScopeKeys(t *testing.
apiB = "https://api-b.example.com"
)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, userAccess(map[string][]string{
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, auditLogger, userAccess(map[string][]string{
apiA: {"read"},
apiB: {"read"},
}))
@@ -250,7 +251,7 @@ func TestAuthorizationServiceRequiresConsentForScopelessAPIAccess(t *testing.T)
)
// The client may reach the API but was granted none of its permissions, which is the MCP-style scopeless grant
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, userAccess(map[string][]string{
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, userAccess(map[string][]string{
api: {},
}))
@@ -292,7 +293,7 @@ func TestAuthorizationServiceRequiresConsentForScopelessAPIAccess(t *testing.T)
func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -376,7 +377,7 @@ func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) {
func TestInteractionAPIClassifiesMissingSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
_, err := service.getInteractionSession(t.Context(), "missing-interaction")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
@@ -387,7 +388,7 @@ func TestInteractionAPIClassifiesMissingSession(t *testing.T) {
func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -442,7 +443,7 @@ func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing
func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -481,7 +482,7 @@ func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *
func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -541,7 +542,7 @@ func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testi
func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -574,7 +575,7 @@ func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *te
func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -610,7 +611,7 @@ func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T)
func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirements(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -663,7 +664,7 @@ func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirement
// still be required for that user rather than being inherited from the initiator.
func TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
initiatorID = "initiator-user"
@@ -725,7 +726,7 @@ func TestValidateClientPKCERequirement(t *testing.T) {
// flag is honored for confidential clients (fosite only enforces PKCE for public clients).
func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -761,7 +762,7 @@ func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -826,7 +827,7 @@ func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
clientID = "test-client"
@@ -866,7 +867,7 @@ func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -908,7 +909,7 @@ func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T)
func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -945,7 +946,7 @@ func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded
func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -992,7 +993,7 @@ func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testi
func TestAuthorizationServiceAuthorizeUsesOriginalInteractionRequestTime(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -1143,7 +1144,7 @@ func TestConsentRequired(t *testing.T) {
func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
auditLogger := &fakeAuditLogger{}
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, auditLogger, nil)
const (
userID = "test-user"
@@ -1176,7 +1177,7 @@ func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
// A client with SkipConsent must still show the consent screen when the request explicitly asks for it with prompt=consent
func TestAuthorizationServiceSkipConsentHonorsPromptConsent(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
+30 -1
View File
@@ -9,7 +9,9 @@ import (
"strings"
"github.com/pocket-id/fosite"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
@@ -18,22 +20,40 @@ const (
idTokenType = "id-token"
)
const emailVerificationRequiredHint = "You must verify your email address before you can access this service."
type ClaimsService struct {
db *gorm.DB
customClaims CustomClaimSource
baseURL string
signer TokenSigner
appConfig appconfig.AppConfigResolver
}
func newClaimsService(db *gorm.DB, customClaims CustomClaimSource, baseURL string, signer TokenSigner) *ClaimsService {
func newClaimsService(db *gorm.DB, customClaims CustomClaimSource, baseURL string, signer TokenSigner, appConfig appconfig.AppConfigResolver) *ClaimsService {
return &ClaimsService{
db: db,
customClaims: customClaims,
baseURL: baseURL,
signer: signer,
appConfig: appConfig,
}
}
// isEmailVerificationPending reports whether the user still has to verify their email before tokens can be issued for them
func (s *ClaimsService) isEmailVerificationPending(ctx context.Context, user *model.User) (bool, error) {
if !emailverification.CanBePending(user) {
return false, nil
}
cfg, err := s.appConfig.GetConfig(ctx)
if err != nil {
return false, err
}
return emailverification.IsPending(cfg, user), nil
}
// ValidateUserAccess re-checks, at token-issuance time, that the user behind a grant is
// still allowed to obtain tokens for the client.
func (s *ClaimsService) ValidateUserAccess(ctx context.Context, userID string, client Client) error {
@@ -59,6 +79,15 @@ func (s *ClaimsService) ValidateUserAccess(ctx context.Context, userID string, c
return fosite.ErrInvalidGrant.WithHint("The user account is disabled.")
}
// The authorization middleware already stops these users at the authorize step, but grants issued before the email became unverified, such as refresh tokens, are only checked here
pending, err := s.isEmailVerificationPending(ctx, &user)
if err != nil {
return err
}
if pending {
return fosite.ErrInvalidGrant.WithHint(emailVerificationRequiredHint)
}
if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
}
+48 -4
View File
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -20,7 +21,7 @@ import (
// refresh token.
func TestClaimsServiceValidateUserAccess(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
claimsService := newClaimsService(db, nil, "", nil)
claimsService := newClaimsService(db, nil, "", nil, appconfig.StaticResolver{})
group := model.UserGroup{Base: model.Base{ID: "group-allowed"}, Name: "allowed", FriendlyName: "Allowed"}
require.NoError(t, db.Create(&group).Error)
@@ -71,6 +72,49 @@ func TestClaimsServiceValidateUserAccess(t *testing.T) {
})
}
// requiredEmailVerification returns a configuration resolver that requires users to verify their email
func requiredEmailVerification() appconfig.StaticResolver {
cfg := appconfig.NewTestConfig(nil)
cfg.EmailVerification = appconfig.EmailVerificationRequired
return appconfig.StaticResolver{Config: cfg}
}
// TestClaimsServiceValidateUserAccessEmailVerification makes sure users who still have to verify their email cannot redeem codes or refresh tokens when verification is required
func TestClaimsServiceValidateUserAccessEmailVerification(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
claimsService := newClaimsService(db, nil, "", nil, requiredEmailVerification())
unverifiedUser := model.User{Base: model.Base{ID: "user-unverified"}, Username: "unverified"}
require.NoError(t, db.Create(&unverifiedUser).Error)
verifiedUser := model.User{Base: model.Base{ID: "user-verified"}, Username: "verified", EmailVerified: true}
require.NoError(t, db.Create(&verifiedUser).Error)
unverifiedAdmin := model.User{Base: model.Base{ID: "admin-unverified"}, Username: "admin", IsAdmin: true}
require.NoError(t, db.Create(&unverifiedAdmin).Error)
client := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "client-open"}, Name: "Open"}}
t.Run("unverified user is rejected with invalid_grant", func(t *testing.T) {
err := claimsService.ValidateUserAccess(t.Context(), unverifiedUser.ID, client)
require.ErrorIs(t, err, fosite.ErrInvalidGrant)
require.Contains(t, fosite.ErrorToRFC6749Error(err).GetDescription(), emailVerificationRequiredHint)
})
t.Run("verified user is allowed", func(t *testing.T) {
require.NoError(t, claimsService.ValidateUserAccess(t.Context(), verifiedUser.ID, client))
})
t.Run("unverified admin is allowed", func(t *testing.T) {
require.NoError(t, claimsService.ValidateUserAccess(t.Context(), unverifiedAdmin.ID, client))
})
t.Run("unverified user is allowed when verification is optional", func(t *testing.T) {
cfg := appconfig.NewTestConfig(nil)
cfg.EmailVerification = appconfig.EmailVerificationOptional
optional := newClaimsService(db, nil, "", nil, appconfig.StaticResolver{Config: cfg})
require.NoError(t, optional.ValidateUserAccess(t.Context(), unverifiedUser.ID, client))
})
}
type fakeCustomClaimSource struct {
claims []model.CustomClaim
}
@@ -94,7 +138,7 @@ func TestClaimsServiceGetUserClaims(t *testing.T) {
{Key: "department", Value: "engineering"}, // plain string
{Key: "roles", Value: `["admin","dev"]`}, // JSON document
}}
service := newClaimsService(db, customClaims, baseURL, nil)
service := newClaimsService(db, customClaims, baseURL, nil, appconfig.StaticResolver{})
group := model.UserGroup{Base: model.Base{ID: "group-1"}, Name: "developers", FriendlyName: "Developers"}
require.NoError(t, db.Create(&group).Error)
@@ -164,7 +208,7 @@ func TestClaimsServiceGetUserClaimsKeepsIntegerCustomClaims(t *testing.T) {
{Key: "nested", Value: `{"ids":[1234567890123456789]}`},
{Key: "not_json", Value: "1 2"},
}}
service := newClaimsService(db, customClaims, "", nil)
service := newClaimsService(db, customClaims, "", nil, appconfig.StaticResolver{})
claims, err := service.GetUserClaims(t.Context(), "user-1", []string{"profile"})
require.NoError(t, err)
@@ -184,7 +228,7 @@ func TestClaimsServiceAppliesSigningAlgToIDTokenHeader(t *testing.T) {
for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256(), jwa.RS384(), jwa.ES512()} {
t.Run(alg.String(), func(t *testing.T) {
service := newClaimsService(db, nil, "", algTestSigner{alg: alg})
service := newClaimsService(db, nil, "", algTestSigner{alg: alg}, appconfig.StaticResolver{})
session := NewEmptySession()
session.Subject = "alg-user"
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/pocket-id/fosite"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -164,7 +165,7 @@ func newTestDeviceService(t *testing.T, clientID, userID string, requiresReauthe
}, nil)
require.NoError(t, err)
claimsService := newClaimsService(db, nil, "", nil)
claimsService := newClaimsService(db, nil, "", nil, appconfig.StaticResolver{})
authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{}, apiAccess)
service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db)
+10 -6
View File
@@ -12,6 +12,7 @@ import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/authz"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -56,6 +57,7 @@ type Dependencies struct {
Reauth ReauthenticationTokenConsumer
AuditLog AuditLogger
APIAccess APIAccessProvider
AppConfig appconfig.AppConfigResolver
// CleanupDisabled skips registering the cron jobs that delete expired rows from the database, for example in tests
CleanupDisabled bool
@@ -97,7 +99,7 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
return nil, fmt.Errorf("failed to create OAuth2 provider: %w", err)
}
claimsService := newClaimsService(deps.DB, deps.CustomClaims, deps.Config.BaseURL, deps.Signer)
claimsService := newClaimsService(deps.DB, deps.CustomClaims, deps.Config.BaseURL, deps.Signer, deps.AppConfig)
previewBuilder := newClientPreviewBuilder(claimsService, provider.tokenStrategies)
interactionSessionService := newInteractionSessionService(deps.DB)
authorizationService := newAuthorizationService(deps.DB, interactionSessionService, claimsService, deps.Reauth, deps.AuditLog, deps.APIAccess)
@@ -146,11 +148,13 @@ func (m *Module) RefreshClientMetadata(ctx context.Context, clientID string) (mo
}
func (m *Module) RegisterRoutes(root, api *authz.Router) {
root.Optional().GET("/authorize", authz.AccountSession, m.authorizationHandler.authorize)
root.Optional().POST("/authorize", authz.AccountSession, m.authorizationHandler.authorize)
// Browsers reach the authorize endpoint through a redirect, so authorization errors are shown on the interaction error page instead of as JSON
authorize := root.Use(m.authorizationHandler.redirectAuthorizationErrors).Optional()
authorize.GET("/authorize", authz.AccountAuthorize, m.authorizationHandler.authorize)
authorize.POST("/authorize", authz.AccountAuthorize, m.authorizationHandler.authorize)
api.Public().GET("/oidc/interactions/:id", m.authorizationHandler.getInteractionSession)
api.POST("/oidc/interactions/:id/complete", authz.AccountSession, m.authorizationHandler.completeInteraction)
api.POST("/oidc/interactions/:id/complete", authz.AccountAuthorize, m.authorizationHandler.completeInteraction)
api.Public().POST("/oidc/par", m.parHandler.pushedAuthorizationRequest)
@@ -165,6 +169,6 @@ func (m *Module) RegisterRoutes(root, api *authz.Router) {
api.Optional().POST("/oidc/end-session", authz.AccountSession, m.endSessionHandler.endSession)
api.Public().POST("/oidc/device/authorize", m.deviceHandler.authorizeDevice)
api.POST("/oidc/device/verify", authz.AccountSession, m.deviceHandler.verifyDeviceCode)
api.GET("/oidc/device/info", authz.AccountSession, m.deviceHandler.deviceCodeInfo)
api.POST("/oidc/device/verify", authz.AccountAuthorize, m.deviceHandler.verifyDeviceCode)
api.GET("/oidc/device/info", authz.AccountAuthorize, m.deviceHandler.deviceCodeInfo)
}
+3 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -26,7 +27,7 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
}, nil)
require.NoError(t, err)
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies)
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil, appconfig.StaticResolver{}), provider.tokenStrategies)
const (
userID = "test-user"
@@ -89,7 +90,7 @@ func TestClientPreviewBuilderIgnoresUnknownScopes(t *testing.T) {
// The preview mirrors the authorize endpoint: unknown scopes are dropped
// from the previewed tokens instead of failing the preview.
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies)
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil, appconfig.StaticResolver{}), provider.tokenStrategies)
preview, err := builder.BuildClientPreview(t.Context(), model.OidcClient{
Base: model.Base{ID: "test-client"},
Name: "Test Client",
+2 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -42,7 +43,7 @@ func TestRefreshTokenRotationGrace(t *testing.T) {
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "grace"}).Error)
provider, err := newProvider(store, nil, testTokenSigner{key: key}, Config{BaseURL: issuer, TokenBaseURL: issuer, Secret: []byte(secret)}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, issuer, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, issuer, nil, appconfig.StaticResolver{}), nil)
// Seed an old grant so the grace period must start at rotation rather than token issuance
request := newTestRequester(requestID, clientID, userID, "grace-jti")
+9 -8
View File
@@ -22,6 +22,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -63,7 +64,7 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil)
form := url.Values{"grant_type": {"client_credentials"}}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
@@ -122,7 +123,7 @@ func TestTokenHandlerClientCredentialsDropsIdentityScopes(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil)
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"openid"}}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
@@ -182,7 +183,7 @@ func TestTokenHandlerClientCredentialsUsesClientSubjectGrants(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), apiAccess)
requestToken := func(t *testing.T, scope string) map[string]any {
t.Helper()
@@ -253,7 +254,7 @@ func TestTokenHandlerClientCredentialsDefaultsResourceScopes(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), apiAccess)
requestToken := func(t *testing.T, target string, form url.Values) map[string]any {
t.Helper()
@@ -408,7 +409,7 @@ func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil)
form := url.Values{
"grant_type": {"refresh_token"},
@@ -592,7 +593,7 @@ func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), apiAccess)
form := url.Values{
"grant_type": {"refresh_token"},
@@ -759,7 +760,7 @@ func TestTokenHandlerClientSecretRotation(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil)
requestToken := func(t *testing.T, clientSecret string) map[string]any {
t.Helper()
@@ -827,7 +828,7 @@ func TestTokenHandlerClientWithoutSecrets(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil)
form := url.Values{"grant_type": {"client_credentials"}}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
@@ -14,6 +14,7 @@ import (
"github.com/pocket-id/fosite"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -54,7 +55,7 @@ func TestUserInfoHandler(t *testing.T) {
}, nil)
require.NoError(t, err)
handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil), baseURL)
handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), baseURL)
issueAccessToken := func(t *testing.T, requestID, subject string, scopes ...string) string {
t.Helper()