Files
pocket-id/backend/internal/oidc/authorization_service_test.go
2026-07-07 11:03:06 +02:00

1137 lines
44 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, 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)
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 TestAuthorizationServiceRejectsCustomScopeWithoutResource(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", 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)
// A custom permission requested with no resource must be rejected at the
// authorize endpoint, not displayed on the consent screen.
requester := newTestAuthorizeRequesterWithForm("bad-scope-request", clientID, url.Values{})
requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments{"openid", "users:read"}
_, err := service.authorize(t.Context(), authorizeInput{
userID: userID,
authenticationTime: time.Now().UTC(),
requester: requester,
meta: requestMeta{},
})
require.Error(t, err)
var rfcErr *fosite.RFC6749Error
require.ErrorAs(t, err, &rfcErr)
require.Equal(t, "invalid_scope", rfcErr.ErrorField)
}
// TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication guards the pre-authentication resource validation against API enumeration.
// An unknown API and an API the client is simply not granted are different backend states, but the authorize endpoint runs before the user logs in, so both must surface the exact same generic error; otherwise anyone holding a public client_id could diff the responses to map which API audiences exist and which ones the client may request.
func TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication(t *testing.T) {
const (
clientID = "test-client"
knownAPI = "https://api.orders.example.com"
unknownAPI = "https://api.unknown.example.com"
)
authorizeWithResource := func(t *testing.T, apiAccess APIAccessProvider, resource string) error {
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)
requester := newTestAuthorizeRequesterWithForm("resource-probe", clientID, url.Values{"resource": {resource}})
_, err := service.authorize(t.Context(), authorizeInput{
userID: "test-user",
authenticationTime: time.Now().UTC(),
requester: requester,
})
return err
}
// The targeted API does not exist at all
unknownErr := authorizeWithResource(t, userAccess(map[string][]string{}), unknownAPI)
// The targeted API exists but this client has been granted none of its permissions
ungrantedErr := authorizeWithResource(t, userAccess(map[string][]string{knownAPI: {}}), knownAPI)
require.Error(t, unknownErr)
require.Error(t, ungrantedErr)
var unknownRFC, ungrantedRFC *fosite.RFC6749Error
require.ErrorAs(t, unknownErr, &unknownRFC)
require.ErrorAs(t, ungrantedErr, &ungrantedRFC)
// Both states collapse to the identical generic error, so an anonymous caller cannot tell an unknown API from an ungranted one
require.Equal(t, "invalid_request", unknownRFC.ErrorField)
require.Equal(t, unknownRFC.ErrorField, ungrantedRFC.ErrorField)
require.Equal(t, unknownRFC.GetDescription(), ungrantedRFC.GetDescription())
// The ungranted case must not leak fosite's access_denied, which would reveal that the API exists
require.NotEqual(t, "access_denied", ungrantedRFC.ErrorField)
}
func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
auditLogger := &fakeAuditLogger{}
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, 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)
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events)
require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
}
func TestAuthorizationServiceConsentMergesAudienceQualifiedScopeKeys(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
auditLogger := &fakeAuditLogger{}
const (
userID = "test-user"
clientID = "test-client"
apiA = "https://api-a.example.com"
apiB = "https://api-b.example.com"
)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, userAccess(map[string][]string{
apiA: {"read"},
apiB: {"read"},
}))
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)
apiAConsent := consentScopeKeys(apiA, []string{"openid", "read"})
apiBConsent := consentScopeKeys(apiB, []string{"openid", "read"})
hasAlreadyAuthorized, err := service.consent(t.Context(), userID, clientID, apiAConsent)
require.NoError(t, err)
require.False(t, hasAlreadyAuthorized)
hasAlreadyAuthorized, err = service.consent(t.Context(), userID, clientID, apiBConsent)
require.NoError(t, err)
require.False(t, hasAlreadyAuthorized)
var authorizedClient model.UserAuthorizedOidcClient
require.NoError(t, db.First(&authorizedClient, "user_id = ? AND client_id = ?", userID, clientID).Error)
require.ElementsMatch(t, []string{"openid", consentScopeKey(apiA, "read"), consentScopeKey(apiB, "read")}, authorizedClient.Scope)
hasAuthorizedAPIA, err := service.hasAuthorizedClient(t.Context(), clientID, userID, apiAConsent)
require.NoError(t, err)
require.True(t, hasAuthorizedAPIA)
hasAuthorizedAPIB, err := service.hasAuthorizedClient(t.Context(), clientID, userID, apiBConsent)
require.NoError(t, err)
require.True(t, hasAuthorizedAPIB)
form := url.Values{
"prompt": {"none"},
"resource": {apiA},
}
requester := newTestAuthorizeRequesterWithForm("silent-api-a-request", clientID, form)
requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments{"openid", "read"}
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.Equal(t, []model.AuditLogEvent{model.AuditLogEventClientAuthorization}, auditLogger.events)
}
func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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)
// 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, 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)
}