mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-14 21:59:05 +02:00
feat: add ability to force email verification (#1837)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user