Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 29 additions & 2 deletions pam/internal/adapter/gdmmodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ type gdmModel struct {
// further conversation with GDM should happen.
conversationsStopped bool
stoppingConversations bool

// pendingEchoAuthModeID is the auth mode we last told GDM to select and
// whose echo we still expect back. GDM echoes our selection in its next
// poll; acting on that echo would issue a second SelectAuthenticationMode
// RPC (and, for device auth, mint a second device code that orphans the
// in-flight poll). It is consumed (cleared) by the first matching echo, so
// a later genuine re-selection of the same mode is still honored.
pendingEchoAuthModeID string
}

type gdmPollResponse struct {
Expand Down Expand Up @@ -111,7 +119,7 @@ func (m gdmModel) pollGdm() tea.Cmd {
}
}

func (m gdmModel) handlePollResponse(gdmPollResults []*gdm.EventData) tea.Cmd {
func (m *gdmModel) handlePollResponse(gdmPollResults []*gdm.EventData) tea.Cmd {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change here goes against the point of using returned copy of the model itself to do changes, in fact ideally no method should use a pointer receiver.

if log.IsLevelEnabled(log.DebugLevel) {
for _, result := range gdmPollResults {
log.Debugf(context.TODO(), "GDM poll response: %v", result.SafeString())
Expand Down Expand Up @@ -141,6 +149,19 @@ func (m gdmModel) handlePollResponse(gdmPollResults []*gdm.EventData) tea.Cmd {
status: pam.ErrSystem, msg: "missing auth mode id",
})
}
// GDM echoes back the auth mode we just told it to select. Ignore
// that one echo to avoid issuing a duplicate SelectAuthenticationMode
// RPC (which, for device auth, mints a second device code and
// orphans the in-flight poll). This is a one-shot per selection:
// a later genuine re-selection of the same mode (the user picking
// it again) is honored because the pending echo has been consumed.
if res.AuthModeSelected.AuthModeId == m.pendingEchoAuthModeID {
log.Debugf(context.TODO(),
"Ignoring GDM auth mode selection echo for %q",
res.AuthModeSelected.AuthModeId)
m.pendingEchoAuthModeID = ""
break
}
commands = append(commands, selectAuthMode(res.AuthModeSelected.AuthModeId))

case *gdm.EventData_IsAuthenticatedRequested:
Expand Down Expand Up @@ -211,14 +232,19 @@ func (m gdmModel) Update(msg tea.Msg) (gdmModel, tea.Cmd) {

switch msg := msg.(type) {
case gdmPollResponse:
return m, m.handlePollResponse(msg.pollResponse)
cmd := m.handlePollResponse(msg.pollResponse)
return m, cmd

case gdmPollDone:
return m, tea.Sequence(
tea.Tick(gdmPollFrequency, func(time.Time) tea.Msg { return nil }),
m.pollGdm())

case StageChanged:
// A genuine (re-)selection always follows a stage change into the
// authModeSelection stage, so any echo we were still expecting from a
// previous selection is no longer relevant once the stage changes.
m.pendingEchoAuthModeID = ""
return m, m.changeStage(msg.Stage)

case userSelected:
Expand All @@ -242,6 +268,7 @@ func (m gdmModel) Update(msg tea.Msg) (gdmModel, tea.Cmd) {
})

case AuthModeSelected:
m.pendingEchoAuthModeID = msg.ID
return m, m.emitEvent(&gdm.EventData_AuthModeSelected{
AuthModeSelected: &gdm.Events_AuthModeSelected{AuthModeId: msg.ID},
})
Expand Down
135 changes: 135 additions & 0 deletions pam/internal/adapter/gdmmodel_authmode_echo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package adapter

import (
"reflect"
"testing"

"github.com/canonical/authd/pam/internal/gdm"
"github.com/canonical/authd/pam/internal/gdm_test"
"github.com/canonical/authd/pam/internal/proto"
tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/require"
)

// collectMessages runs a command and recursively flattens the batch/sequence
// messages it produces into the concrete messages they ultimately deliver.
// tea.Batch and tea.Sequence return []tea.Cmd-shaped messages whose concrete
// types are unexported, so they are detected structurally via reflection.
func collectMessages(cmd tea.Cmd) []tea.Msg {
if cmd == nil {
return nil
}
msg := cmd()
if cmds, ok := asCmdSlice(msg); ok {
var msgs []tea.Msg
for _, c := range cmds {
msgs = append(msgs, collectMessages(c)...)
}
return msgs
}
return []tea.Msg{msg}
}

// asCmdSlice reports whether msg is a []tea.Cmd-shaped batch/sequence message
// and, if so, returns its commands.
func asCmdSlice(msg tea.Msg) ([]tea.Cmd, bool) {
v := reflect.ValueOf(msg)
if v.Kind() != reflect.Slice || v.Type().Elem() != reflect.TypeOf(tea.Cmd(nil)) {
return nil, false
}
cmdType := reflect.TypeOf(tea.Cmd(nil))
cmds := make([]tea.Cmd, v.Len())
for i := range cmds {
cmd, ok := v.Index(i).Convert(cmdType).Interface().(tea.Cmd)
if !ok {
return nil, false
}
cmds[i] = cmd
}
return cmds, true
}

func containsAuthModeSelected(msgs []tea.Msg, id string) bool {
for _, msg := range msgs {
if m, ok := msg.(authModeSelected); ok && m.id == id {
return true
}
}
return false
}

func TestGdmModelIgnoresAuthModeSelectedEcho(t *testing.T) {
t.Parallel()

// After we select an auth mode, GDM echoes the selection back as a poll
// event. Acting on that echo would re-run SelectAuthenticationMode and, for
// device auth, mint a second device code while the poll is still on the
// first one (UDENG-8799).
m := gdmModel{}
m, _ = m.Update(AuthModeSelected{ID: "device_auth_qr"})
require.Equal(t, "device_auth_qr", m.pendingEchoAuthModeID,
"selecting an auth mode should record the expected echo")

echo := []*gdm.EventData{gdm_test.AuthModeSelectedEvent("device_auth_qr")}
msgs := collectMessages(m.handlePollResponse(echo))
require.False(t, containsAuthModeSelected(msgs, "device_auth_qr"),
"echo of the just-selected auth mode must not trigger a re-selection")
require.Empty(t, m.pendingEchoAuthModeID,
"consuming the echo should clear the expected echo")
}

func TestGdmModelActsOnAuthModeChange(t *testing.T) {
t.Parallel()

// A genuine change to a different auth mode must still be acted on.
m := gdmModel{}
m, _ = m.Update(AuthModeSelected{ID: "device_auth_qr"})

change := []*gdm.EventData{gdm_test.AuthModeSelectedEvent("password")}
msgs := collectMessages(m.handlePollResponse(change))
require.True(t, containsAuthModeSelected(msgs, "password"),
"selecting a different auth mode must trigger a re-selection")
}

func TestGdmModelActsOnSameAuthModeReselection(t *testing.T) {
t.Parallel()

// Suppression is a one-shot: only the immediate echo of our own selection
// is dropped. A later genuine re-selection of the same auth mode (the user
// picking it again) must be honored, because the pending echo has already
// been consumed.
m := gdmModel{}
m, _ = m.Update(AuthModeSelected{ID: "device_auth_qr"})

echo := []*gdm.EventData{gdm_test.AuthModeSelectedEvent("device_auth_qr")}
_ = collectMessages(m.handlePollResponse(echo))
require.Empty(t, m.pendingEchoAuthModeID,
"the echo should have been consumed")

reselect := []*gdm.EventData{gdm_test.AuthModeSelectedEvent("device_auth_qr")}
msgs := collectMessages(m.handlePollResponse(reselect))
require.True(t, containsAuthModeSelected(msgs, "device_auth_qr"),
"a genuine re-selection of the same auth mode must be honored")
}

func TestGdmModelStageChangeClearsPendingEcho(t *testing.T) {
t.Parallel()

// A genuine re-selection always follows a stage change back into
// authModeSelection. The stage change must drop any echo we were still
// expecting, so that the re-selection is acted on instead of being
// mistaken for the (never-delivered) echo of the previous selection.
m := gdmModel{}
m, _ = m.Update(AuthModeSelected{ID: "device_auth_qr"})
require.Equal(t, "device_auth_qr", m.pendingEchoAuthModeID)

m, _ = m.Update(StageChanged{Stage: proto.Stage_challenge})
m, _ = m.Update(StageChanged{Stage: proto.Stage_authModeSelection})
require.Empty(t, m.pendingEchoAuthModeID,
"a stage change must drop a still-pending echo")

reselect := []*gdm.EventData{gdm_test.AuthModeSelectedEvent("device_auth_qr")}
msgs := collectMessages(m.handlePollResponse(reselect))
require.True(t, containsAuthModeSelected(msgs, "device_auth_qr"),
"re-selecting the same auth mode after a stage change must be honored")
}
30 changes: 28 additions & 2 deletions pam/internal/adapter/gdmmodel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func TestGdmModel(t *testing.T) {
wantExitStatus PamReturnStatus
wantGdmRequests []gdm.RequestType
wantGdmEvents []gdm.EventType
wantGdmEventsCount map[gdm.EventType]int
wantGdmAuthRes []*authd.IAResponse
wantNoGdmRequests []gdm.RequestType
wantNoGdmEvents []gdm.EventType
Expand Down Expand Up @@ -654,11 +655,16 @@ func TestGdmModel(t *testing.T) {
gdm.EventType_authModesReceived,
gdm.EventType_authModeSelected,
gdm.EventType_uiLayoutReceived,
gdm.EventType_authModeSelected,
gdm.EventType_uiLayoutReceived,
gdm.EventType_authEvent, // retry
gdm.EventType_startAuthentication,
},
// One authModeSelected/uiLayoutReceived per genuine selection (the
// three password-stage cycles in wantGdmRequests). The GDM echo of
// each selection must not add extra cycles.
wantGdmEventsCount: map[gdm.EventType]int{
gdm.EventType_authModeSelected: 3,
gdm.EventType_uiLayoutReceived: 3,
},
wantStage: proto.Stage_challenge,
wantGdmAuthRes: []*authd.IAResponse{
{
Expand Down Expand Up @@ -1238,6 +1244,15 @@ func TestGdmModel(t *testing.T) {
gdm.EventType_startAuthentication,
gdm.EventType_authEvent,
},
// Each genuine selection of the auth mode (the initial one and the
// re-selection after navigating back to authModeSelection) must
// produce exactly one selection cycle: the GDM echo of the
// selection must not add a third one.
wantGdmEventsCount: map[gdm.EventType]int{
gdm.EventType_authModeSelected: 2,
gdm.EventType_uiLayoutReceived: 2,
gdm.EventType_startAuthentication: 2,
},
wantStage: proto.Stage_challenge,
wantGdmAuthRes: []*authd.IAResponse{
{Access: auth.Granted},
Expand Down Expand Up @@ -2672,6 +2687,17 @@ func TestGdmModel(t *testing.T) {
"Required events have not been received: %v vs %v",
stringifySlice(tc.wantGdmEvents), stringifySlice(receivedEventTypes))

for evType, wantN := range tc.wantGdmEventsCount {
gotN := 0
for _, e := range receivedEventTypes {
if e == evType {
gotN++
}
}
require.Equal(t, wantN, gotN,
"GDM event %q received %d times, want %d", evType, gotN, wantN)
}

require.Empty(t, appState.wantMessages, "Wanted messages have not all been processed")

username, err := appState.pamMTx.GetItem(pam.User)
Expand Down
Loading