mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-23 11:29:05 +02:00
1002 lines
38 KiB
Go
1002 lines
38 KiB
Go
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ory/fosite"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
"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"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type fakeAuditLogger struct {
|
|
events []model.AuditLogEvent
|
|
data []model.AuditLogData
|
|
}
|
|
|
|
func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, data model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
|
|
f.events = append(f.events, event)
|
|
f.data = append(f.data, data)
|
|
return model.AuditLog{}, true
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
auditLogger := &fakeAuditLogger{}
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
|
UserID: userID,
|
|
ClientID: clientID,
|
|
Scope: datatype.StringList{"openid"},
|
|
}).Error)
|
|
|
|
authorization, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: newTestAuthorizeRequester("audit-request", clientID, ""),
|
|
meta: requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, authorization.RequiresInteraction)
|
|
|
|
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventClientAuthorization}, auditLogger.events)
|
|
require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
|
|
}
|
|
|
|
func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
auditLogger := &fakeAuditLogger{}
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
ConsentRequired: true,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{},
|
|
}).Error)
|
|
|
|
response, err := service.completeInteractionStep(t.Context(), interactionID, userID, interactionStepConsent, "", time.Now().UTC(), requestMeta{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, response.RedirectURL)
|
|
|
|
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events)
|
|
require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
|
UserID: userID,
|
|
ClientID: clientID,
|
|
Scope: datatype.StringList{"openid"},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{},
|
|
}).Error)
|
|
|
|
authorize := func() (authorizationResult, error) {
|
|
return service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: newTestAuthorizeRequester("consume-request", clientID, ""),
|
|
interactionID: interactionID,
|
|
})
|
|
}
|
|
|
|
authorization, err := authorize()
|
|
require.NoError(t, err)
|
|
require.False(t, authorization.RequiresInteraction)
|
|
|
|
// The interaction session is consumed together with the grant and must be single-use
|
|
var count int64
|
|
require.NoError(t, db.Model(&InteractionSession{}).Where("id = ?", interactionID).Count(&count).Error)
|
|
require.Zero(t, count)
|
|
|
|
_, err = authorize()
|
|
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
|
}
|
|
|
|
func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newInteractionSessionService(db)
|
|
|
|
const (
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{},
|
|
}).Error)
|
|
|
|
_, err := service.get(t.Context(), interactionID)
|
|
require.NoError(t, err)
|
|
|
|
// Backdate the session past its lifetime; BeforeCreate stamps CreatedAt, so update directly
|
|
expiredCreatedAt := datatype.DateTime(time.Now().Add(-interactionSessionLifetime - time.Minute))
|
|
require.NoError(t, db.Model(&InteractionSession{}).Where("id = ?", interactionID).Update("created_at", expiredCreatedAt).Error)
|
|
|
|
_, err = service.get(t.Context(), interactionID)
|
|
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
|
|
newInteractionSession := func(id string) {
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: id},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{},
|
|
}).Error)
|
|
}
|
|
|
|
authorize := func(interactionID string, scopes ...string) (authorizationResult, error) {
|
|
requester := newTestAuthorizeRequester("scope-binding-request", clientID, "")
|
|
requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments(scopes)
|
|
return service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: requester,
|
|
interactionID: interactionID,
|
|
})
|
|
}
|
|
|
|
// Scopes matching the consented interaction session are granted
|
|
newInteractionSession("matching-interaction")
|
|
authorization, err := authorize("matching-interaction", "openid")
|
|
require.NoError(t, err)
|
|
require.False(t, authorization.RequiresInteraction)
|
|
|
|
// Scopes added to the URL after consent must not be granted silently
|
|
newInteractionSession("escalated-interaction")
|
|
_, err = authorize("escalated-interaction", "openid", "profile", "email", "groups")
|
|
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
|
|
|
// The user must not have been recorded as having authorized the escalated scopes
|
|
var authorizedClient model.UserAuthorizedOidcClient
|
|
require.NoError(t, db.First(&authorizedClient, "user_id = ? AND client_id = ?", userID, clientID).Error)
|
|
require.Equal(t, datatype.StringList{"openid"}, authorizedClient.Scope)
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
otherClientID = "other-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: otherClientID},
|
|
Name: "Other Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{},
|
|
}).Error)
|
|
|
|
_, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: newTestAuthorizeRequester("other-client-request", otherClientID, ""),
|
|
interactionID: interactionID,
|
|
})
|
|
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
otherUserID = "other-user"
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
Username: "test-user",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: otherUserID},
|
|
Username: "other-user",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
|
UserID: userID,
|
|
ClientID: clientID,
|
|
Scope: datatype.StringList{"openid"},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
UserID: stringPointer(userID),
|
|
ReauthenticatedAt: new(datatype.DateTime(time.Now().Add(-time.Minute).UTC())),
|
|
ReauthenticationRequired: false,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{
|
|
"prompt": "login",
|
|
},
|
|
}).Error)
|
|
|
|
authorization, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: otherUserID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: newTestAuthorizeRequester("other-user-request", clientID, ""),
|
|
interactionID: interactionID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, authorization.RequiresInteraction)
|
|
require.Equal(t, interactionID, authorization.InteractionID)
|
|
|
|
var interactionSession InteractionSession
|
|
require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
|
|
require.NotNil(t, interactionSession.UserID)
|
|
require.Equal(t, otherUserID, *interactionSession.UserID)
|
|
require.True(t, interactionSession.ConsentRequired)
|
|
require.True(t, interactionSession.ReauthenticationRequired)
|
|
require.Nil(t, interactionSession.ReauthenticatedAt)
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
UserID: stringPointer(userID),
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{},
|
|
}).Error)
|
|
|
|
_, err := service.authorize(t.Context(), authorizeInput{
|
|
requester: newTestAuthorizeRequester("unauthenticated-bound-request", clientID, ""),
|
|
interactionID: interactionID,
|
|
})
|
|
require.ErrorIs(t, err, fosite.ErrLoginRequired)
|
|
}
|
|
|
|
func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
ConsentRequired: true,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{},
|
|
}).Error)
|
|
|
|
response, err := service.completeInteractionStep(t.Context(), interactionID, userID, interactionStepConsent, "", time.Now().UTC(), requestMeta{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, response.RedirectURL)
|
|
|
|
var interactionSession InteractionSession
|
|
require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
|
|
require.NotNil(t, interactionSession.UserID)
|
|
require.Equal(t, userID, *interactionSession.UserID)
|
|
}
|
|
|
|
func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirements(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
otherUserID = "other-user"
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
Username: "test-user",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: otherUserID},
|
|
Username: "other-user",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
UserID: stringPointer(userID),
|
|
AccountSelectionRequired: true,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{
|
|
"prompt": "select_account",
|
|
},
|
|
}).Error)
|
|
|
|
response, err := service.completeInteractionStep(t.Context(), interactionID, otherUserID, interactionStepSelectAccount, "", time.Now().UTC(), requestMeta{})
|
|
require.NoError(t, err)
|
|
require.Empty(t, response.RedirectURL)
|
|
require.NotNil(t, response.Interaction)
|
|
require.Equal(t, interactionStepConsent, response.Interaction.CurrentStep)
|
|
|
|
var interactionSession InteractionSession
|
|
require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
|
|
require.NotNil(t, interactionSession.UserID)
|
|
require.Equal(t, otherUserID, *interactionSession.UserID)
|
|
require.False(t, interactionSession.AccountSelectionRequired)
|
|
require.True(t, interactionSession.ConsentRequired)
|
|
}
|
|
|
|
// TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser ensures consent is
|
|
// evaluated for the FINAL selected user. When an already-consented user starts
|
|
// prompt=select_account and a different, not-yet-consented user is selected, consent must
|
|
// 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)
|
|
|
|
const (
|
|
initiatorID = "initiator-user"
|
|
switchedID = "switched-user"
|
|
clientID = "test-client"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: initiatorID}, Username: "initiator"}).Error)
|
|
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: switchedID}, Username: "switched"}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
|
|
|
|
// The initiator has previously consented to the client; the switched-to user has not.
|
|
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
|
UserID: initiatorID,
|
|
ClientID: clientID,
|
|
Scope: datatype.StringList{"openid"},
|
|
}).Error)
|
|
|
|
authorization, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: initiatorID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: newTestAuthorizeRequester("select-account-request", clientID, "select_account"),
|
|
requestParams: map[string]string{"prompt": "select_account"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, authorization.RequiresInteraction)
|
|
|
|
response, err := service.completeInteractionStep(t.Context(), authorization.InteractionID, switchedID, interactionStepSelectAccount, "", time.Now().UTC(), requestMeta{})
|
|
require.NoError(t, err)
|
|
|
|
// The flow must not be granted yet: consent is still pending for the switched-to user.
|
|
require.Empty(t, response.RedirectURL)
|
|
require.NotNil(t, response.Interaction)
|
|
require.Equal(t, interactionStepConsent, response.Interaction.CurrentStep)
|
|
|
|
// No authorization record may have been silently created for the switched-to user.
|
|
var count int64
|
|
require.NoError(t, db.Model(&model.UserAuthorizedOidcClient{}).
|
|
Where("user_id = ? AND client_id = ?", switchedID, clientID).
|
|
Count(&count).Error)
|
|
require.Zero(t, count)
|
|
}
|
|
|
|
func TestValidateClientPKCERequirement(t *testing.T) {
|
|
pkceClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "c"}, PkceEnabled: true}}
|
|
plainClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "c"}}}
|
|
|
|
withChallenge := newTestAuthorizeRequesterWithForm("with-challenge", "c", url.Values{"code_challenge": {"abc123"}})
|
|
withoutChallenge := newTestAuthorizeRequesterWithForm("without-challenge", "c", url.Values{})
|
|
|
|
require.NoError(t, validateClientPKCERequirement(plainClient, withoutChallenge))
|
|
require.NoError(t, validateClientPKCERequirement(pkceClient, withChallenge))
|
|
|
|
err := validateClientPKCERequirement(pkceClient, withoutChallenge)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
|
}
|
|
|
|
// TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE proves the per-client PkceEnabled
|
|
// 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)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "pkce-client"
|
|
)
|
|
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "test-user"}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "PKCE Client", PkceEnabled: true}).Error)
|
|
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID, Scope: datatype.StringList{"openid"}}).Error)
|
|
|
|
pkceClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "PKCE Client", PkceEnabled: true}}
|
|
|
|
// Without a code_challenge the request is rejected before any interaction.
|
|
missing := newTestAuthorizeRequesterWithForm("authz-no-pkce", clientID, url.Values{})
|
|
missing.(*fosite.AuthorizeRequest).Client = pkceClient
|
|
_, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: missing,
|
|
})
|
|
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
|
|
|
// With a code_challenge the PKCE gate passes and the (already-consented) request is granted.
|
|
withChallenge := newTestAuthorizeRequesterWithForm("authz-pkce", clientID, url.Values{"code_challenge": {"abc123"}})
|
|
withChallenge.(*fosite.AuthorizeRequest).Client = pkceClient
|
|
result, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: withChallenge,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, result.RequiresInteraction)
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
RequiresPushedAuthorizationRequests: true,
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
|
UserID: userID,
|
|
ClientID: clientID,
|
|
Scope: datatype.StringList{"openid"},
|
|
}).Error)
|
|
|
|
authorize := func(interactionID string, hasPushedAuthorizationRequest bool) (authorizationResult, error) {
|
|
requester := newTestAuthorizeRequester("par-request", clientID, "")
|
|
requester.(*fosite.AuthorizeRequest).Client = Client{
|
|
OidcClient: model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
RequiresPushedAuthorizationRequests: true,
|
|
},
|
|
}
|
|
return service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: requester,
|
|
hasPushedAuthorizationRequest: hasPushedAuthorizationRequest,
|
|
interactionID: interactionID,
|
|
})
|
|
}
|
|
|
|
// Without a pushed authorization request the client must be rejected
|
|
_, err := authorize("", false)
|
|
var parRequiredError *common.OidcPARRequiredError
|
|
require.ErrorAs(t, err, &parRequiredError)
|
|
|
|
// Re-entry after a completed interaction carries no request_uri, but the bound
|
|
// interaction session proves the original request was PAR-validated
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{},
|
|
}).Error)
|
|
|
|
authorization, err := authorize(interactionID, false)
|
|
require.NoError(t, err)
|
|
require.False(t, authorization.RequiresInteraction)
|
|
|
|
// An unknown interaction ID must not satisfy the PAR requirement
|
|
_, err = authorize("nonexistent", false)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
|
}
|
|
|
|
func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{
|
|
"client_id": clientID,
|
|
"response_type": "code",
|
|
"scope": "openid",
|
|
"redirect_uri": "https://client.example/callback",
|
|
"state": "test-state",
|
|
},
|
|
}).Error)
|
|
|
|
query, err := service.interactionRequestQuery(t.Context(), interactionID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, clientID, query.Get("client_id"))
|
|
require.Equal(t, "code", query.Get("response_type"))
|
|
require.Equal(t, "openid", query.Get("scope"))
|
|
require.Equal(t, "https://client.example/callback", query.Get("redirect_uri"))
|
|
require.Equal(t, "test-state", query.Get("state"))
|
|
require.Equal(t, interactionID, query.Get("interaction"))
|
|
|
|
_, err = service.interactionRequestQuery(t.Context(), "nonexistent")
|
|
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
)
|
|
loginTime := time.Now().Add(-10 * time.Minute).UTC().Truncate(time.Second)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
|
UserID: userID,
|
|
ClientID: clientID,
|
|
Scope: datatype.StringList{"openid"},
|
|
}).Error)
|
|
|
|
firstAuthorization, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: loginTime,
|
|
requester: newTestAuthorizeRequester("first-request", clientID, ""),
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, firstAuthorization.RequiresInteraction)
|
|
require.Equal(t, loginTime, firstAuthorization.Session.IDTokenClaims().AuthTime)
|
|
|
|
secondAuthorization, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: loginTime,
|
|
requester: newTestAuthorizeRequester("second-request", clientID, "none"),
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, secondAuthorization.RequiresInteraction)
|
|
require.Equal(t, loginTime, secondAuthorization.Session.IDTokenClaims().AuthTime)
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
)
|
|
loginTime := time.Now().Add(-2 * time.Minute).UTC()
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
|
UserID: userID,
|
|
ClientID: clientID,
|
|
Scope: datatype.StringList{"openid"},
|
|
}).Error)
|
|
|
|
authorization, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: loginTime,
|
|
requester: newTestAuthorizeRequesterWithForm("max-age-request", clientID, url.Values{"max_age": {"1"}}),
|
|
requestParams: map[string]string{"max_age": "1"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, authorization.RequiresInteraction)
|
|
|
|
interaction, err := service.getInteractionSession(t.Context(), authorization.InteractionID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, interactionStepReauthenticate, interaction.CurrentStep)
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
loginTime := time.Now().Add(-2 * time.Minute).UTC()
|
|
reauthenticatedAt := time.Now().Add(-1 * time.Second).UTC().Truncate(time.Second)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
|
UserID: userID,
|
|
ClientID: clientID,
|
|
Scope: datatype.StringList{"openid"},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
ReauthenticationRequired: false,
|
|
ReauthenticatedAt: new(datatype.DateTime(reauthenticatedAt)),
|
|
Parameters: map[string]string{
|
|
"max_age": "1",
|
|
},
|
|
}).Error)
|
|
|
|
authorization, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: loginTime,
|
|
requester: newTestAuthorizeRequesterWithForm("final-request", clientID, url.Values{"max_age": {"1"}}),
|
|
interactionID: interactionID,
|
|
requestParams: map[string]string{"max_age": "1"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, authorization.RequiresInteraction)
|
|
require.Equal(t, reauthenticatedAt, authorization.Session.IDTokenClaims().AuthTime)
|
|
}
|
|
|
|
func TestAuthorizationServiceAuthorizeUsesOriginalInteractionRequestTime(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
originalRequestedAt := time.Now().Add(-10 * time.Second).UTC().Truncate(time.Second)
|
|
reauthenticatedAt := originalRequestedAt.Add(5 * time.Second)
|
|
continuationRequestedAt := reauthenticatedAt.Add(5 * time.Second)
|
|
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
|
UserID: userID,
|
|
ClientID: clientID,
|
|
Scope: datatype.StringList{"openid"},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
ReauthenticationRequired: false,
|
|
RequestedAt: datatype.DateTime(originalRequestedAt),
|
|
ReauthenticatedAt: new(datatype.DateTime(reauthenticatedAt)),
|
|
Parameters: map[string]string{
|
|
"prompt": "login",
|
|
},
|
|
}).Error)
|
|
|
|
requester := newTestAuthorizeRequesterWithForm(
|
|
"final-request",
|
|
clientID,
|
|
url.Values{"prompt": {"login"}},
|
|
)
|
|
requester.(*fosite.AuthorizeRequest).RequestedAt = continuationRequestedAt
|
|
|
|
authorization, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: originalRequestedAt.Add(-time.Minute),
|
|
requester: requester,
|
|
interactionID: interactionID,
|
|
requestParams: map[string]string{"prompt": "login"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, authorization.RequiresInteraction)
|
|
require.Equal(t, originalRequestedAt, authorization.Session.IDTokenClaims().RequestedAt)
|
|
require.Equal(t, reauthenticatedAt, authorization.Session.IDTokenClaims().AuthTime)
|
|
require.False(t, authorization.Session.IDTokenClaims().AuthTime.Before(authorization.Session.IDTokenClaims().RequestedAt))
|
|
}
|
|
|
|
func TestInteractionSessionServiceSavePersistsParameters(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := newInteractionSessionService(db)
|
|
|
|
const (
|
|
clientID = "test-client"
|
|
interactionID = "test-interaction"
|
|
)
|
|
|
|
require.NoError(t, db.Create(&model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
}).Error)
|
|
|
|
interactionSession, err := service.create(t.Context(), InteractionSession{
|
|
Base: model.Base{ID: interactionID},
|
|
Scopes: datatype.StringList{"openid"},
|
|
ClientID: clientID,
|
|
ReauthenticationRequired: true,
|
|
RequestedAt: datatype.DateTime(time.Now().UTC()),
|
|
Parameters: map[string]string{
|
|
"max_age": "1",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
reauthenticatedAt := datatype.DateTime(time.Now().UTC().Truncate(time.Second))
|
|
interactionSession.ReauthenticationRequired = false
|
|
interactionSession.ReauthenticatedAt = &reauthenticatedAt
|
|
require.NoError(t, service.update(t.Context(), interactionSession))
|
|
|
|
storedInteractionSession, err := service.get(t.Context(), interactionID)
|
|
require.NoError(t, err)
|
|
require.False(t, storedInteractionSession.ReauthenticationRequired)
|
|
require.Equal(t, "1", storedInteractionSession.Parameters["max_age"])
|
|
require.NotNil(t, storedInteractionSession.ReauthenticatedAt)
|
|
require.Equal(t, reauthenticatedAt.UTC(), storedInteractionSession.ReauthenticatedAt.UTC())
|
|
}
|
|
|
|
func newTestAuthorizeRequester(requestID, clientID, prompt string) fosite.AuthorizeRequester {
|
|
form := url.Values{}
|
|
if prompt != "" {
|
|
form.Set("prompt", prompt)
|
|
}
|
|
return newTestAuthorizeRequesterWithForm(requestID, clientID, form)
|
|
}
|
|
|
|
func newTestAuthorizeRequesterWithForm(requestID, clientID string, form url.Values) fosite.AuthorizeRequester {
|
|
requester := fosite.NewAuthorizeRequest()
|
|
requester.ID = requestID
|
|
requester.RequestedAt = time.Now().UTC()
|
|
requester.Client = Client{
|
|
OidcClient: model.OidcClient{
|
|
Base: model.Base{ID: clientID},
|
|
Name: "Test Client",
|
|
},
|
|
}
|
|
requester.RequestedScope = fosite.Arguments{"openid"}
|
|
requester.ResponseTypes = fosite.Arguments{"code"}
|
|
requester.RedirectURI = &url.URL{Scheme: "https", Host: "client.example", Path: "/callback"}
|
|
requester.Form = form
|
|
return requester
|
|
}
|
|
|
|
func stringPointer(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
func TestConsentRequired(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hasAlreadyAuthorized bool
|
|
skipConsent bool
|
|
prompt string
|
|
want bool
|
|
}{
|
|
{"new client without skip requires consent", false, false, "", true},
|
|
{"returning client without skip skips consent", true, false, "", false},
|
|
{"new client with skip skips consent", false, true, "", false},
|
|
{"returning client with skip skips consent", true, true, "", false},
|
|
{"prompt=consent forces consent despite skip", false, true, "consent", true},
|
|
{"prompt=consent forces consent for returning client", true, false, "consent", true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := consentRequired(tt.hasAlreadyAuthorized, tt.skipConsent, newPromptValues(tt.prompt))
|
|
require.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A client with SkipConsent must be granted without a consent interaction even on the first authorization, while consent is still recorded so the user can later see and revoke the client
|
|
func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
auditLogger := &fakeAuditLogger{}
|
|
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
)
|
|
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}).Error)
|
|
|
|
// No prior UserAuthorizedOidcClient record exists, so a client without skip-consent would require the consent screen here
|
|
requester := newTestAuthorizeRequester("skip-consent-request", clientID, "")
|
|
requester.(*fosite.AuthorizeRequest).Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}}
|
|
|
|
authorization, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: requester,
|
|
meta: requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, authorization.RequiresInteraction)
|
|
require.NotNil(t, authorization.Session)
|
|
|
|
var count int64
|
|
require.NoError(t, db.Model(&model.UserAuthorizedOidcClient{}).Where("user_id = ? AND client_id = ?", userID, clientID).Count(&count).Error)
|
|
require.Equal(t, int64(1), count)
|
|
|
|
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events)
|
|
}
|
|
|
|
// 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)
|
|
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
)
|
|
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
|
|
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}).Error)
|
|
|
|
requester := newTestAuthorizeRequester("skip-consent-prompt-request", clientID, "consent")
|
|
requester.(*fosite.AuthorizeRequest).Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}}
|
|
|
|
authorization, err := service.authorize(t.Context(), authorizeInput{
|
|
userID: userID,
|
|
authenticationTime: time.Now().UTC(),
|
|
requester: requester,
|
|
meta: requestMeta{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, authorization.RequiresInteraction)
|
|
|
|
interactionSession, err := newInteractionSessionService(db).get(t.Context(), authorization.InteractionID)
|
|
require.NoError(t, err)
|
|
require.True(t, interactionSession.ConsentRequired)
|
|
}
|