Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions core/authenticate/mocks/consent_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 33 additions & 2 deletions core/authenticate/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ type UserService interface {
}

// ConsentService checks what a caller accepted against what the deployment
// configures, and writes the record for it. With app.consent disabled it
// resolves nothing, so an empty document set is the signal to write no record.
// configures, and writes the record for it. With app.consent disabled both
// Resolve and ResolveAll resolve nothing, so an empty document set is the signal
// to write no record, and the ids are ignored rather than rejected.
type ConsentService interface {
Resolve(ids []string) ([]consent.Document, error)
ResolveAll(ids []string) ([]consent.Document, error)
Grant(ctx context.Context, tx *sqlx.Tx, req consent.GrantRequest) (consent.Consent, error)
RecordGranted(ctx context.Context, granted consent.Consent)
Expand Down Expand Up @@ -231,6 +233,11 @@ func (s Service) StartFlow(ctx context.Context, request RegistrationStartRequest
if !utils.Contains(s.SupportedStrategies(), request.Method) {
return nil, ErrUnsupportedMethod
}
// the consent gate runs first: a check on the request alone, costing no
// lookup, and it has to fail before anything is sent or redirected
if err := s.gateFlowConsent(request.Intent, request.AcceptedDocumentIDs); err != nil {
return nil, err
}
// both mail strategies know the address before anything is sent, and share
// applyMailOTP at the other end, so they share the gate here. Passkey gates
// in its own branch, where it already looks the user up to pick a ceremony.
Expand Down Expand Up @@ -459,6 +466,30 @@ func (s Service) gateFlowStart(ctx context.Context, intent FlowIntent, email str
return checkIntent(intent, err == nil)
}

// gateFlowConsent is the consent half of the flow start check, and the intent
// decides which rule applies, because without one a signup and a login look
// identical. A signup runs the completeness rule here, before anything is sent
// or redirected — true for OIDC too, where the email is unknown but the intent
// is not. Without an intent only the unknown-id rule runs, and completeness
// waits for user creation. A login checks nothing, because it writes no record.
func (s Service) gateFlowConsent(intent FlowIntent, ids []string) error {
if s.consentService == nil || intent == FlowIntentLogin {
return nil
}

var err error
if intent == FlowIntentSignup {
_, err = s.consentService.ResolveAll(ids)
} else {
_, err = s.consentService.Resolve(ids)
}
if err != nil {
// the wrapped error names what is missing, for the log not the response
return fmt.Errorf("%w: %w", ErrConsentRequired, err)
}
return nil
}

// applyMailOTP actions when user submitted otp from the email
// user can be considered as verified if code is valid
// create a new user if required
Expand Down
159 changes: 159 additions & 0 deletions core/authenticate/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,162 @@ func TestService_StartFlow_WritesIntentAndConsent(t *testing.T) {
})
}

// TestService_StartFlow_Consent covers the first of the two consent gates. It
// is the one that exists for the error: it runs before an OTP is sent and
// before the browser leaves for an identity provider, so a rejection costs the
// user a retry and nothing else.
//
// The intent decides which rule applies, because without one a signup and a
// login look identical.
func TestService_StartFlow_Consent(t *testing.T) {
defaultHashCost := authenticate.OTPHashCost
authenticate.OTPHashCost = bcrypt.MinCost
t.Cleanup(func() { authenticate.OTPHashCost = defaultHashCost })

const email = "test@example.com"
documents := []consent.Document{
{ID: "privacy_policy", Title: "Privacy Policy", Version: "2026-04-01", URL: "https://example.org/p"},
{ID: "terms_of_service", Title: "Terms & Conditions", Version: "2026-04-01", URL: "https://example.org/t"},
}
acceptedIDs := []string{"privacy_policy", "terms_of_service"}

// startFlow runs a mail otp flow start against whatever consent service it
// is given. Mail otp is the strategy that shows the point of this gate,
// because a rejection here is a code that never gets sent. The dialer is a
// bare mock with no expectations, so a flow that reaches SendMail fails
// rather than passing quietly.
startFlow := func(t *testing.T, consentService authenticate.ConsentService,
request authenticate.RegistrationStartRequest, wantFlow bool) (*authenticate.RegistrationStartResponse, error) {
t.Helper()

ctx := context.Background()
mockFlowRepo, mockUserService, _, _, _ := createMocks(t)
if request.Intent != authenticate.FlowIntentUnspecified {
mockUserService.EXPECT().GetByID(ctx, email).
Return(user.User{}, errors.New("user not found")).Maybe()
}

var dialer mailer.Dialer = &mailerMock.Dialer{}
if wantFlow {
mockFlowRepo.EXPECT().Set(ctx, mock.Anything).Return(nil)
dialer = mailer.NewMockDialer()
}

srv := authenticate.NewService(nil, authenticate.Config{
MailOTP: authenticate.MailOTPConfig{Validity: 10 * time.Minute},
TestUsers: testusers.Config{Enabled: true, OTP: "111111", Domain: "example.com"},
}, mockFlowRepo, dialer, nil, nil, mockUserService, nil, nil, nil, consentService, nil)

request.Method = authenticate.MailOTPAuthMethod.String()
request.Email = email
return srv.StartFlow(ctx, request)
}

t.Run("a signup carrying every document starts the flow", func(t *testing.T) {
mockConsent := mocks.NewConsentService(t)
mockConsent.EXPECT().ResolveAll(acceptedIDs).Return(documents, nil)

got, err := startFlow(t, mockConsent, authenticate.RegistrationStartRequest{
Intent: authenticate.FlowIntentSignup,
AcceptedDocumentIDs: acceptedIDs,
}, true)

require.NoError(t, err)
require.NotNil(t, got)
})

t.Run("a signup missing a document is rejected before the code is sent", func(t *testing.T) {
mockConsent := mocks.NewConsentService(t)
mockConsent.EXPECT().ResolveAll([]string{"privacy_policy"}).
Return(nil, consent.ErrMissingDocuments)

got, err := startFlow(t, mockConsent, authenticate.RegistrationStartRequest{
Intent: authenticate.FlowIntentSignup,
AcceptedDocumentIDs: []string{"privacy_policy"},
}, false)

assert.ErrorIs(t, err, authenticate.ErrConsentRequired)
// the wrapped error still names what was missing, for the log
assert.ErrorIs(t, err, consent.ErrMissingDocuments)
assert.Nil(t, got)
})

t.Run("a signup carrying no document at all is rejected too", func(t *testing.T) {
mockConsent := mocks.NewConsentService(t)
mockConsent.EXPECT().ResolveAll([]string(nil)).Return(nil, consent.ErrMissingDocuments)

_, err := startFlow(t, mockConsent, authenticate.RegistrationStartRequest{
Intent: authenticate.FlowIntentSignup,
}, false)

assert.ErrorIs(t, err, authenticate.ErrConsentRequired)
})

t.Run("an unspecified intent checks only that the ids are known", func(t *testing.T) {
// frontier cannot yet know this request will create a user, so
// completeness waits for user creation, but a typo can still be caught
// before the redirect
mockConsent := mocks.NewConsentService(t)
mockConsent.EXPECT().Resolve([]string{"privacy_policy"}).Return(documents[:1], nil)

_, err := startFlow(t, mockConsent, authenticate.RegistrationStartRequest{
AcceptedDocumentIDs: []string{"privacy_policy"},
}, true)

require.NoError(t, err)
})

t.Run("an unspecified intent is rejected for an id config does not know", func(t *testing.T) {
mockConsent := mocks.NewConsentService(t)
mockConsent.EXPECT().Resolve([]string{"not_a_document"}).
Return(nil, consent.ErrUnknownDocuments)

_, err := startFlow(t, mockConsent, authenticate.RegistrationStartRequest{
AcceptedDocumentIDs: []string{"not_a_document"},
}, false)

assert.ErrorIs(t, err, authenticate.ErrConsentRequired)
assert.ErrorIs(t, err, consent.ErrUnknownDocuments)
})

t.Run("a login checks nothing, because it writes no record", func(t *testing.T) {
// an unexpected call fails the test rather than passing silently
mockConsent := mocks.NewConsentService(t)

mockFlowRepo, mockUserService, _, _, _ := createMocks(t)
ctx := context.Background()
mockUserService.EXPECT().GetByID(ctx, email).Return(user.User{ID: "user-id", Email: email}, nil)
mockFlowRepo.EXPECT().Set(ctx, mock.Anything).Return(nil)

srv := authenticate.NewService(nil, authenticate.Config{
MailOTP: authenticate.MailOTPConfig{Validity: 10 * time.Minute},
TestUsers: testusers.Config{Enabled: true, OTP: "111111", Domain: "example.com"},
}, mockFlowRepo, mailer.NewMockDialer(), nil, nil, mockUserService, nil, nil, nil, mockConsent, nil)

_, err := srv.StartFlow(ctx, authenticate.RegistrationStartRequest{
Method: authenticate.MailOTPAuthMethod.String(),
Email: email,
Intent: authenticate.FlowIntentLogin,
})
require.NoError(t, err)
})

t.Run("a deployment with consent disabled ignores the ids rather than rejecting them", func(t *testing.T) {
// one client build works against both kinds of deployment, so the same
// signup that a configured deployment gates goes straight through here
disabled := consent.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)),
consent.Config{Enabled: false}, nil, nil)

got, err := startFlow(t, disabled, authenticate.RegistrationStartRequest{
Intent: authenticate.FlowIntentSignup,
AcceptedDocumentIDs: acceptedIDs,
}, true)

require.NoError(t, err)
require.NotNil(t, got)
})
}

// TestFlow_IntentAndConsent covers the accessors directly: the JSON round trip
// the database puts metadata through, and the nil receiver callers rely on.
func TestFlow_IntentAndConsent(t *testing.T) {
Expand Down Expand Up @@ -1499,6 +1655,8 @@ func TestService_FinishFlow_Intent(t *testing.T) {

if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
// a rejection is the error and nothing else: the handler maps it
// to a connect code and the caller decides what to do with it
assert.Nil(t, got)
return
}
Expand Down Expand Up @@ -1623,6 +1781,7 @@ func TestService_FinishFlow_Consent(t *testing.T) {
assert.ErrorIs(t, err, authenticate.ErrConsentRequired)
// the wrapped error still names what was missing
assert.ErrorIs(t, err, consent.ErrMissingDocuments)
// the rejection is the error and nothing else
assert.Nil(t, got)
mockUserService.AssertNotCalled(t, "Create", mock.Anything, mock.Anything)
mockUserService.AssertNotCalled(t, "CreateWithTx", mock.Anything, mock.Anything, mock.Anything)
Expand Down
Loading
Loading