From 342fd50d9515158ac5a8e0ba0d6f075a43e63e74 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Wed, 15 Apr 2026 00:08:24 +0200 Subject: [PATCH] pam/adapter: rewrite nativeClient as a sequential function The previous nativeModel used bubbletea's reactive Update/Cmd pattern to drive a fundamentally sequential flow: prompt the user, call an RPC, inspect the result, repeat. This mismatch caused significant accidental complexity: - A 'busy' boolean flag to reject duplicate events that bubbletea could deliver while a blocking operation was in progress. - A stage-mismatch retry loop (tea.Sequence(ChangeStage, repeatEvent)) because stage changes are asynchronous. - startAsyncOp() wrapping every blocking call in a goroutine just to work around the single-threaded Update model. - Internal event types (nativeStageChangeRequest, nativeUserSelection, nativeBrokerSelection, nativeChallengeRequested, nativeGoBack, ...) that existed only to shuttle control between Update calls. Replace nativeModel with nativeClient, a plain Go struct whose run() method executes the entire flow synchronously in one goroutine. It integrates with bubbletea at exactly two points: - Init() returns a tea.Cmd that starts run() in a goroutine. - run() returns a PamReturnStatus, which the main Update loop sees as the final event and uses to quit. All RPCs are now plain blocking calls instead of tea.Cmds wrapped in goroutines. All the internal event types, the busy flag, and the startAsyncOp() helper are gone. Error handling is straightforward Go: return an error, check at the call site. Net change: -716 / +599 lines for equivalent functionality. --- pam/internal/adapter/model.go | 4 +- pam/internal/adapter/nativemodel.go | 1235 +++++++++++++-------------- pam/internal/adapter/utils.go | 2 - 3 files changed, 577 insertions(+), 664 deletions(-) diff --git a/pam/internal/adapter/model.go b/pam/internal/adapter/model.go index 5c4770aa11..e2d0371409 100644 --- a/pam/internal/adapter/model.go +++ b/pam/internal/adapter/model.go @@ -76,7 +76,7 @@ type uiModel struct { authModeSelectionModel authModeSelectionModel authenticationModel authenticationModel gdmModel gdmModel - nativeModel nativeModel + nativeModel nativeClient // exitStatus is a pointer to the [PamReturnStatus] value where the // exit status will be written to. @@ -157,7 +157,7 @@ func newUIModelForClients(mTx pam.ModuleTransaction, clientType PamClientType, m case Gdm: m.gdmModel = gdmModel{pamMTx: m.pamMTx} case Native: - m.nativeModel = newNativeModel(m.pamMTx, userServiceClient) + m.nativeModel = newNativeModel(m.pamMTx, m.client, mode, userServiceClient) } m.userSelectionModel = newUserSelectionModel(m.pamMTx, m.clientType) diff --git a/pam/internal/adapter/nativemodel.go b/pam/internal/adapter/nativemodel.go index 2821b01c61..5591dcfd89 100644 --- a/pam/internal/adapter/nativemodel.go +++ b/pam/internal/adapter/nativemodel.go @@ -2,6 +2,11 @@ package adapter import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha512" + "crypto/x509" + "encoding/base64" "errors" "fmt" "os" @@ -15,29 +20,12 @@ import ( "github.com/canonical/authd/internal/brokers/layouts/entries" "github.com/canonical/authd/internal/proto/authd" "github.com/canonical/authd/log" - "github.com/canonical/authd/pam/internal/proto" tea "github.com/charmbracelet/bubbletea" "github.com/msteinert/pam/v2" "github.com/muesli/termenv" "github.com/skip2/go-qrcode" ) -type nativeModel struct { - pamMTx pam.ModuleTransaction - userServiceClient authd.UserServiceClient - - availableBrokers []*authd.ABResponse_BrokerInfo - authModes []*authd.GAMResponse_AuthenticationMode - selectedAuthMode string - uiLayout *authd.UILayout - - serviceName string - interactive bool - currentStage proto.Stage - busy bool - userSelectionAllowed bool -} - const ( nativeCancelKey = "r" @@ -51,33 +39,26 @@ const ( inputPromptStyleMultiLine ) -// nativeStageChangeRequest is the internal event to request that a stage change. -type nativeStageChangeRequest ChangeStage - -// nativeUserSelection is the internal event that a user needs to be (re)set. -type nativeUserSelection struct{} - -// nativeBrokerSelection is the internal event that a broker needs to be (re)selected. -type nativeBrokerSelection struct{} - -// nativeAuthSelection is used to require the user input for auth selection. -type nativeAuthSelection struct{} - -// nativeChallengeRequested is used to require the user input for password. -type nativeChallengeRequested struct{} - -// nativeAsyncOperationCompleted is a message to tell we're done with an async operation. -type nativeAsyncOperationCompleted struct{} - -// nativeGoBack is a message to require to go back to previous stage. -type nativeGoBack struct{} - var errGoBack = errors.New("request to go back") var errEmptyResponse = errors.New("empty response received") var errNotAnInteger = errors.New("parsed value is not an integer") -func newNativeModel(mTx pam.ModuleTransaction, userServiceClient authd.UserServiceClient) nativeModel { - m := nativeModel{pamMTx: mTx, userServiceClient: userServiceClient} +// nativeClient is the Native PAM client. It runs the entire authentication +// flow as a single sequential goroutine: prompt -> RPC -> check result -> repeat. +// It integrates with bubbletea only at the boundary: Init() returns a tea.Cmd +// that runs the goroutine and returns the final PamReturnStatus when done. +type nativeClient struct { + pamMTx pam.ModuleTransaction + client authd.PAMClient + userServiceClient authd.UserServiceClient + mode authd.SessionMode + + serviceName string + interactive bool +} + +func newNativeModel(mTx pam.ModuleTransaction, client authd.PAMClient, mode authd.SessionMode, userServiceClient authd.UserServiceClient) nativeClient { + m := nativeClient{pamMTx: mTx, client: client, mode: mode, userServiceClient: userServiceClient} var err error m.serviceName, err = m.pamMTx.GetItem(pam.Service) @@ -90,826 +71,760 @@ func newNativeModel(mTx pam.ModuleTransaction, userServiceClient authd.UserServi return m } -// Init initializes the native model orchestrator. -func (m nativeModel) Init() tea.Cmd { - rendersQrCode := m.isQrcodeRenderingSupported() - supportsQrCode := m.serviceName != polkitServiceName - +// Init returns a tea.Cmd that runs the full sequential authentication flow. +// When done, it emits a PamReturnStatus to end the bubbletea program. +func (m nativeClient) Init() tea.Cmd { return func() tea.Msg { - required, optional := layouts.Required, layouts.Optional - supportedEntries := layouts.OptionalItems( - entries.Chars, - entries.CharsPassword, - entries.Digits, - entries.DigitsPassword, - ) - - supportedLayouts := supportedUILayoutsReceived{ - layouts: []*authd.UILayout{ - { - Type: layouts.Form, - Label: &required, - Entry: &supportedEntries, - Wait: &layouts.OptionalWithBooleans, - Button: &optional, - }, - { - Type: layouts.NewPassword, - Label: &required, - Entry: &supportedEntries, - Button: &optional, - }, - }, - } - - if supportsQrCode { - supportedLayouts.layouts = append(supportedLayouts.layouts, &authd.UILayout{ - Type: layouts.QrCode, - Content: &required, - Code: &optional, - Wait: &layouts.RequiredWithBooleans, - Label: &optional, - Button: &optional, - RendersQrcode: &rendersQrCode, - }) - } - - return supportedLayouts - } -} - -func (m nativeModel) checkStage(expected proto.Stage) bool { - if m.currentStage != expected { - log.Debugf(context.Background(), - "Current stage %q is not matching expected %q", m.currentStage, expected) - return false + return m.run() } - return true } -func (m nativeModel) requestStageChange(stage proto.Stage) tea.Cmd { - return sendEvent(nativeStageChangeRequest{stage}) +// Update is a no-op: the nativeClient handles everything in its goroutine. +func (m nativeClient) Update(_ tea.Msg) (nativeClient, tea.Cmd) { + return m, nil } -func (m nativeModel) Update(msg tea.Msg) (nativeModel, tea.Cmd) { - safeMessageDebugWithPrefix("Native model update", msg) - - switch msg := msg.(type) { - case StageChanged: - m.currentStage = msg.Stage - - case nativeStageChangeRequest: - if m.currentStage != msg.Stage { - // Stage is not matching yet, ask for stage change first and repeat. - return m, tea.Sequence(sendEvent(ChangeStage(msg)), sendEvent(msg)) - } - - switch m.currentStage { - case proto.Stage_userSelection: - return m, sendEvent(nativeUserSelection{}) - case proto.Stage_brokerSelection: - return m, sendEvent(nativeBrokerSelection{}) - case proto.Stage_authModeSelection: - return m, sendEvent(nativeAuthSelection{}) - case proto.Stage_challenge: - return m, sendEvent(nativeChallengeRequested{}) - } - - case nativeAsyncOperationCompleted: - m.busy = false - - case nativeGoBack: - return m.goBackCommand() - - case userRequired: - m.userSelectionAllowed = true - return m, m.requestStageChange(proto.Stage_userSelection) - - case nativeUserSelection: - if !m.checkStage(proto.Stage_userSelection) { - return m, nil - } - if m.busy { - // We may receive multiple concurrent requests, but due to the sync nature - // of this model, we can't just accept them once we've one in progress already - log.Debug(context.TODO(), "User selection already in progress") - return m, nil - } - - if cmd := maybeSendPamError(m.pamMTx.SetItem(pam.User, "")); cmd != nil { - return m, cmd - } - - return m.startAsyncOp(m.userSelection) - - case brokersListReceived: - m.availableBrokers = msg.brokers - - // We should only handle this special case if there's more than one broker available. - // Otherwise, we will break polkit for local users. - if m.serviceName == polkitServiceName && len(msg.brokers) > 1 { - // Do not support using local broker in the polkit case. - // FIXME: This should be up to authd to keep a list of brokers based on service. - m.availableBrokers = slices.DeleteFunc(slices.Clone(m.availableBrokers), func(b *authd.ABResponse_BrokerInfo) bool { - return b.Id == brokers.LocalBrokerName - }) - } - - case authModesReceived: - m.authModes = msg.authModes +// run executes the full authentication flow sequentially and returns the result. +func (m nativeClient) run() PamReturnStatus { + // User selection. + username, err := m.selectUser() + if err != nil { + return pamReturnErrorFrom(err) + } - case brokerSelectionRequired: - if m.busy { - // We may receive multiple concurrent requests, but due to the sync nature - // of this model, we can't just accept them once we've one in progress already - log.Debug(context.TODO(), "Broker selection already in progress") - return m, nil - } + // Broker + session selection. + brokerID, sessionID, encryptionKey, err := m.selectBrokerAndStartSession(username) + if err != nil { + return pamReturnErrorFrom(err) + } - user, err := m.pamMTx.GetItem(pam.User) + // Authentication loop: repeats on auth.Next (multi-step auth). + for { + done, result, err := m.authLoop(sessionID, brokerID, encryptionKey) if err != nil { - return m, maybeSendPamError(err) - } - return m.startAsyncOp(func() tea.Cmd { - return m.maybePreCheckUser(user, - m.requestStageChange(proto.Stage_brokerSelection)) - }) - - case nativeBrokerSelection: - if !m.checkStage(proto.Stage_brokerSelection) { - return m, nil - } - if m.busy { - // We may receive multiple concurrent requests, but due to the sync nature - // of this model, we can't just accept them once we've one in progress already - log.Debug(context.TODO(), "Broker selection already in progress") - return m, nil - } - - if len(m.availableBrokers) < 1 { - return m, sendEvent(pamError{ - status: pam.ErrSystem, - msg: "No brokers available to select", - }) - } - - if len(m.availableBrokers) == 1 { - return m, sendEvent(brokerSelected{brokerID: m.availableBrokers[0].Id}) - } - - return m.startAsyncOp(m.brokerSelection) - - case nativeAuthSelection: - if !m.checkStage(proto.Stage_authModeSelection) { - return m, nil - } - if m.busy { - // We may receive multiple concurrent requests, but due to the sync nature - // of this model, we can't just accept them once we've one in progress already - log.Debug(context.TODO(), "Authentication selection already in progress") - return m, nil - } - if m.selectedAuthMode != "" { - return m, nil + return pamReturnErrorFrom(err) } - if len(m.authModes) < 1 { - return m, sendEvent(pamError{ - status: pam.ErrSystem, - msg: "Can't authenticate without authentication modes", - }) + if done { + return result } - - if len(m.authModes) == 1 { - return m, selectAuthMode(m.authModes[0].Id) + // auth.Next: get a new session and continue. + sessionID, encryptionKey, err = m.startSession(brokerID, username) + if err != nil { + return pamReturnErrorFrom(err) } + } +} - return m.startAsyncOp(m.authModeSelection) - - case authModeSelected: - m.selectedAuthMode = msg.id - - case UILayoutReceived: - m.uiLayout = msg.layout - - case startAuthentication: - return m, m.requestStageChange(proto.Stage_challenge) +// selectUser prompts for a username (if not already set by PAM) and returns it. +func (m nativeClient) selectUser() (string, error) { + user, err := m.pamMTx.GetItem(pam.User) + if err != nil { + return "", fmt.Errorf("getting PAM user: %w", err) + } + if user != "" { + // Username was already set by the PAM stack (e.g. SSH, su). + return user, nil + } - case nativeChallengeRequested: - if !m.checkStage(proto.Stage_challenge) { - return m, nil - } - if m.busy { - // We may receive multiple concurrent requests, but due to the sync nature - // of this model, we can't just accept them once we've one in progress already - log.Debug(context.TODO(), "Challenge already in progress") - return m, nil + // Interactive prompt loop. + for { + if err := m.pamMTx.SetItem(pam.User, ""); err != nil { + return "", err } - return m.startAsyncOp(m.startChallenge) - - case newPasswordCheckResult: - if msg.msg != "" { - if cmd := maybeSendPamError(m.sendError(msg.msg)); cmd != nil { - return m, cmd - } - return m, m.newPasswordChallenge(nil) + user, err = m.promptForInput(pam.PromptEchoOn, inputPromptStyleInline, "Username") + if errors.Is(err, errEmptyResponse) { + continue } - return m, m.newPasswordChallenge(&msg.password) - - case isAuthenticatedResultReceived: - access := msg.access - authMsg, err := dataToMsg(msg.msg) - if cmd := maybeSendPamError(err); cmd != nil { - return m, cmd + if err != nil { + return "", err } + break + } - switch access { - case auth.Granted: - return m, maybeSendPamError(m.sendInfo(authMsg)) - case auth.Next: - m.uiLayout = nil - return m, maybeSendPamError(m.sendInfo(authMsg)) - case auth.Retry: - return m, maybeSendPamError(m.sendError(authMsg)) - case auth.Denied: - // This is handled by the main authentication model - return m, nil - case auth.Cancelled: - return m, nil - default: - return m, maybeSendPamError(m.sendError("Access %q is not valid", access)) + // Under SSH, pre-check the user to avoid leaking whether an account exists. + if m.userServiceClient != nil { + _, err := m.userServiceClient.GetUserByName(context.TODO(), &authd.GetUserByNameRequest{ + Name: user, + ShouldPreCheck: true, + }) + if err != nil { + log.Infof(context.TODO(), "can't get user info for %q: %v", user, err) + // Fall through to the local broker so the caller gets ErrIgnore. + return brokers.LocalBrokerName, nil } } - return m, nil -} - -func (m nativeModel) checkForPromptReplyValidity(reply string) error { - switch reply { - case nativeCancelKey: - if m.canGoBack() { - return errGoBack - } - case "", "\n": - return errEmptyResponse + if err := m.pamMTx.SetItem(pam.User, user); err != nil { + return "", err } - return nil + return user, nil } -func (m nativeModel) promptForInput(style pam.Style, inputStyle inputPromptStyle, prompt string) (string, error) { - format := "%s" - if m.interactive { - switch inputStyle { - case inputPromptStyleInline: - format = "%s: " - case inputPromptStyleMultiLine: - format = "%s:\n> " - } +// selectBrokerAndStartSession picks a broker (automatically or by prompting), +// starts a session, and returns the session details. +func (m nativeClient) selectBrokerAndStartSession(username string) (brokerID, sessionID string, encryptionKey *rsa.PublicKey, err error) { + if username == brokers.LocalBrokerName { + return "", "", nil, nativeErrorf(pam.ErrIgnore, "") } - resp, err := m.pamMTx.StartStringConvf(style, format, prompt) + availableBrokers, err := m.getAvailableBrokers() if err != nil { - return "", err + return "", "", nil, err } - return resp.Response(), m.checkForPromptReplyValidity(resp.Response()) -} -func (m nativeModel) promptForNumericInput(style pam.Style, prompt string) (int, error) { - out, err := m.promptForInput(style, inputPromptStyleMultiLine, prompt) - if err != nil { - return -1, err + // Filter out local broker for polkit when other brokers are available. + if m.serviceName == polkitServiceName && len(availableBrokers) > 1 { + availableBrokers = slices.DeleteFunc(slices.Clone(availableBrokers), func(b *authd.ABResponse_BrokerInfo) bool { + return b.Id == brokers.LocalBrokerName + }) } - intOut, err := strconv.Atoi(out) + brokerID, err = m.chooseBroker(username, availableBrokers) if err != nil { - return intOut, fmt.Errorf("%w: %w", errNotAnInteger, err) + return "", "", nil, err + } + if brokerID == brokers.LocalBrokerName { + return "", "", nil, nativeErrorf(pam.ErrIgnore, "") } - return intOut, err + sessionID, encryptionKey, err = m.startSession(brokerID, username) + return brokerID, sessionID, encryptionKey, err } -func (m nativeModel) promptForNumericInputUntilValid(style pam.Style, prompt string) (int, error) { - value, err := m.promptForNumericInput(style, prompt) - if !errors.Is(err, errNotAnInteger) { - return value, err +// chooseBroker returns the broker ID to use, via automatic or manual selection. +func (m nativeClient) chooseBroker(username string, availableBrokers []*authd.ABResponse_BrokerInfo) (string, error) { + // Try automatic selection (previously used broker). + r, err := m.client.GetPreviousBroker(context.TODO(), &authd.GPBRequest{Username: username}) + if err == nil && r.GetPreviousBroker() != "" { + return r.GetPreviousBroker(), nil } - err = m.sendError("Unsupported input") - if err != nil { - return -1, err + if len(availableBrokers) == 0 { + return "", nativeErrorf(pam.ErrSystem, "%s", "no brokers available") + } + if len(availableBrokers) == 1 { + return availableBrokers[0].Id, nil } - return m.promptForNumericInputUntilValid(style, prompt) + var choices []choicePair + for _, b := range availableBrokers { + choices = append(choices, choicePair{id: b.Id, label: b.Name}) + } + for { + id, err := m.promptForChoice("Provider selection", choices, "Choose your provider") + if errors.Is(err, errGoBack) { + // Can't go back past broker selection; loop. + continue + } + if err != nil { + return "", nativeErrorf(pam.ErrSystem, "provider selection: %v", err) + } + return id, nil + } } -func (m nativeModel) promptForNumericInputAsString(style pam.Style, prompt string) (string, error) { - input, err := m.promptForNumericInputUntilValid(style, prompt) - return fmt.Sprint(input), err +// getAvailableBrokers fetches the broker list from authd. +func (m nativeClient) getAvailableBrokers() ([]*authd.ABResponse_BrokerInfo, error) { + resp, err := m.client.AvailableBrokers(context.TODO(), &authd.Empty{}) + if err != nil { + return nil, nativeErrorf(pam.ErrSystem, "could not get available brokers: %v", err) + } + return resp.GetBrokersInfos(), nil } -func (m nativeModel) sendError(errorMsg string, args ...any) error { - if errorMsg == "" { - return nil +// startSession starts a broker session for the given user. +func (m nativeClient) startSession(brokerID, username string) (sessionID string, encryptionKey *rsa.PublicKey, err error) { + lang := "C" + for _, e := range []string{"LANG", "LC_MESSAGES", "LC_ALL"} { + if l := os.Getenv(e); l != "" { + lang = l + } } - _, err := m.pamMTx.StartStringConvf(pam.ErrorMsg, errorMsg, args...) - return err -} + lang = strings.TrimSuffix(lang, ".UTF-8") -func (m nativeModel) sendInfo(infoMsg string, args ...any) error { - if infoMsg == "" { - return nil + resp, err := m.client.SelectBroker(context.TODO(), &authd.SBRequest{ + BrokerId: brokerID, + Username: username, + Lang: lang, + Mode: m.mode, + }) + if err != nil { + return "", nil, nativeErrorf(pam.ErrSystem, "%s", err.Error()) + } + sessionID = resp.GetSessionId() + if sessionID == "" { + return "", nil, nativeErrorf(pam.ErrSystem, "%s", "no session ID returned by broker") + } + encryptionKeyStr := resp.GetEncryptionKey() + if encryptionKeyStr == "" { + return "", nil, nativeErrorf(pam.ErrSystem, "%s", "no encryption key returned by broker") } - _, err := m.pamMTx.StartStringConvf(pam.TextInfo, infoMsg, args...) - return err -} -type choicePair struct { - id string - label string + pubASN1, err := base64.StdEncoding.DecodeString(encryptionKeyStr) + if err != nil { + return "", nil, nativeErrorf(pam.ErrSystem, "encryption key sent by broker is not a valid base64 encoded string: %v", err) + } + pubKey, err := x509.ParsePKIXPublicKey(pubASN1) + if err != nil { + return "", nil, nativeErrorf(pam.ErrSystem, "encryption key sent by broker is not valid: %v", err) + } + rsaKey, ok := pubKey.(*rsa.PublicKey) + if !ok { + return "", nil, nativeErrorf(pam.ErrSystem, "expected RSA public key from broker, got %T", pubKey) + } + return sessionID, rsaKey, nil } -func (m nativeModel) promptForChoiceWithMessage(title string, message string, choices []choicePair, prompt string) (string, error) { - msg := fmt.Sprintf("== %s ==\n", title) - if message != "" { - msg += message + "\n" +// authLoop runs one complete authentication round (select mode -> challenge -> result). +// Returns (done=true, result, nil) when finished, (done=false, _, nil) on auth.Next, +// or (_, _, err) on hard errors. +func (m nativeClient) authLoop(sessionID, brokerID string, encryptionKey *rsa.PublicKey) (done bool, result PamReturnStatus, err error) { + authModes, err := m.getAuthModes(sessionID) + if err != nil { + return true, nil, err } - for i, choice := range choices { - msg += fmt.Sprintf(" %d. %s", i+1, choice.label) - if i < len(choices)-1 { - msg += "\n" - } + selectedModeID, err := m.chooseAuthMode(authModes) + if err != nil { + return true, nil, err } - if goBackLabel := m.goBackActionLabel(); goBackLabel != "" { - msg += fmt.Sprintf("\nOr enter '%s' to %s", nativeCancelKey, goBackLabel) + uiLayout, err := m.getLayout(sessionID, selectedModeID) + if err != nil { + return true, nil, err } + // Challenge loop: retry on auth.Retry, reselectAuthMode on nil item. for { - if err := m.sendInfo(msg); err != nil { - return "", err - } - idx, err := m.promptForNumericInputUntilValid(pam.PromptEchoOn, prompt) + item, err := m.collectChallengeInput(selectedModeID, authModes, uiLayout) if err != nil { - return "", err + return true, nil, err } - // TODO: Maybe add support for default selection... - - if idx < 1 || idx > len(choices) { - if err := m.sendError("Invalid selection"); err != nil { - return "", err + if item == nil { + // Reselect auth mode: re-fetch layout and retry from top. + selectedModeID, err = m.chooseAuthMode(authModes) + if err != nil { + return true, nil, err + } + uiLayout, err = m.getLayout(sessionID, selectedModeID) + if err != nil { + return true, nil, err } continue } - return choices[idx-1].id, nil - } -} + // Encrypt the secret if present. + if secret, ok := item.(*authd.IARequest_AuthenticationData_Secret); ok { + ciphertext, err := rsa.EncryptOAEP(sha512.New(), rand.Reader, encryptionKey, []byte(secret.Secret), nil) + if err != nil { + return true, nil, nativeErrorf(pam.ErrSystem, "failed to encrypt secret: %v", err) + } + item = &authd.IARequest_AuthenticationData_Secret{ + Secret: base64.StdEncoding.EncodeToString(ciphertext), + } + } -func (m nativeModel) promptForChoice(title string, choices []choicePair, prompt string) (string, error) { - return m.promptForChoiceWithMessage(title, "", choices, prompt) -} + resp, err := m.client.IsAuthenticated(context.TODO(), &authd.IARequest{ + SessionId: sessionID, + AuthenticationData: &authd.IARequest_AuthenticationData{Item: item}, + }) + if err != nil { + return true, nil, nativeErrorf(pam.ErrSystem, "%s", err.Error()) + } -func (m nativeModel) startAsyncOp(cmd func() tea.Cmd) (nativeModel, tea.Cmd) { - m.busy = true - return m, func() tea.Msg { - ret := cmd() - return tea.Sequence( - sendEvent(nativeAsyncOperationCompleted{}), - ret, - )() - } -} + msg, err := dataToMsg(resp.GetMsg()) + if err != nil { + return true, nil, nativeErrorf(pam.ErrSystem, "%s", err.Error()) + } -func (m nativeModel) userSelection() tea.Cmd { - user, err := m.promptForInput(pam.PromptEchoOn, inputPromptStyleInline, "Username") - if errors.Is(err, errEmptyResponse) { - return sendEvent(nativeUserSelection{}) - } - if err != nil { - return maybeSendPamError(err) - } + switch resp.GetAccess() { + case auth.Granted: + if err := m.sendInfo(msg); err != nil { + return true, nil, err + } + return true, PamSuccess{BrokerID: brokerID, msg: msg}, nil - return m.maybePreCheckUser(user, sendEvent(userSelected{user})) -} + case auth.Denied: + if msg == "" { + msg = "Access denied" + } + return true, pamError{status: pam.ErrAuth, msg: msg}, nil -func (m nativeModel) maybePreCheckUser(user string, nextCmd tea.Cmd) tea.Cmd { - if m.userServiceClient == nil { - return nextCmd - } + case auth.Next: + if err := m.sendInfo(msg); err != nil { + return true, nil, err + } + return false, nil, nil // caller will start a new session - // When the user service client is defined (i.e. under SSH for now) we want also - // repeat the user pre-check, to ensure that the user is handled by at least - // one broker, or we may end up leaking such infos. - // We don't care about the content, we only care if the user is known by some broker. - _, err := m.userServiceClient.GetUserByName(context.TODO(), &authd.GetUserByNameRequest{ - Name: user, - ShouldPreCheck: true, - }) - if err != nil { - log.Infof(context.TODO(), "can't get user info for %q: %v", user, err) - return sendEvent(brokerSelected{brokerID: brokers.LocalBrokerName}) - } - return nextCmd -} + case auth.Retry: + if err := m.sendError(msg); err != nil { + return true, nil, err + } + continue // retry with same layout -func (m nativeModel) brokerSelection() tea.Cmd { - var choices []choicePair - for _, b := range m.availableBrokers { - choices = append(choices, choicePair{id: b.Id, label: b.Name}) - } + case auth.Cancelled: + return true, pamError{status: pam.ErrAuth, msg: "Authentication cancelled"}, nil - id, err := m.promptForChoice("Provider selection", choices, "Choose your provider") - if errors.Is(err, errGoBack) { - return sendEvent(nativeGoBack{}) + default: + return true, nil, nativeErrorf(pam.ErrSystem, "unknown access type: %q", resp.GetAccess()) + } } +} + +// getAuthModes fetches the authentication modes available for this session. +func (m nativeClient) getAuthModes(sessionID string) ([]*authd.GAMResponse_AuthenticationMode, error) { + resp, err := m.client.GetAuthenticationModes(context.Background(), &authd.GAMRequest{ + SessionId: sessionID, + SupportedUiLayouts: m.supportedUILayouts(), + }) if err != nil { - return sendEvent(pamError{ - status: pam.ErrSystem, - msg: fmt.Sprintf("Provider selection error: %v", err), - }) + return nil, nativeErrorf(pam.ErrSystem, "%s", err.Error()) } - return sendEvent(brokerSelected{brokerID: id}) + authModes := resp.GetAuthenticationModes() + if len(authModes) == 0 { + return nil, nativeErrorf(pam.ErrCredUnavail, "%s", "no supported authentication mode available for this provider") + } + return authModes, nil } -func (m nativeModel) authModeSelection() tea.Cmd { +// chooseAuthMode returns the auth mode ID to use, auto-selecting if only one. +func (m nativeClient) chooseAuthMode(authModes []*authd.GAMResponse_AuthenticationMode) (string, error) { + if len(authModes) == 1 { + return authModes[0].Id, nil + } var choices []choicePair - for _, am := range m.authModes { + for _, am := range authModes { choices = append(choices, choicePair{id: am.Id, label: am.Label}) } - - id, err := m.promptForChoice("Authentication method selection", choices, - "Choose your authentication method") - if errors.Is(err, errGoBack) { - return sendEvent(nativeGoBack{}) - } - if errors.Is(err, errEmptyResponse) { - return m.requestStageChange(proto.Stage_challenge) - } - if err != nil { - return sendEvent(pamError{ - status: pam.ErrSystem, - msg: fmt.Sprintf("Authentication method selection error: %v", err), - }) + for { + id, err := m.promptForChoice("Authentication method selection", choices, "Choose your authentication method") + if errors.Is(err, errGoBack) { + continue + } + if err != nil { + return "", nativeErrorf(pam.ErrSystem, "authentication method selection: %v", err) + } + return id, nil } - - return selectAuthMode(id) } -func (m nativeModel) startChallenge() tea.Cmd { - if m.uiLayout == nil { - return sendEvent(pamError{ - status: pam.ErrSystem, - msg: "Can't authenticate without ui layout selected", +// getLayout fetches the UI layout for the given auth mode. +func (m nativeClient) getLayout(sessionID, authModeID string) (*authd.UILayout, error) { + resp, err := m.client.SelectAuthenticationMode(context.TODO(), &authd.SAMRequest{ + SessionId: sessionID, + AuthenticationModeId: authModeID, + }) + if err != nil { + return nil, nativeErrorf(pam.ErrSystem, "can't select authentication mode: %v", err) + } + if resp.UiLayoutInfo == nil { + return nil, nativeErrorf(pam.ErrSystem, "%s", "invalid empty UI Layout information from broker") + } + return resp.GetUiLayoutInfo(), nil +} + +// supportedUILayouts returns the list of UI layouts this native client supports. +func (m nativeClient) supportedUILayouts() []*authd.UILayout { + required, optional := layouts.Required, layouts.Optional + supportedEntries := layouts.OptionalItems( + entries.Chars, + entries.CharsPassword, + entries.Digits, + entries.DigitsPassword, + ) + + ls := []*authd.UILayout{ + { + Type: layouts.Form, + Label: &required, + Entry: &supportedEntries, + Wait: &layouts.OptionalWithBooleans, + Button: &optional, + }, + { + Type: layouts.NewPassword, + Label: &required, + Entry: &supportedEntries, + Button: &optional, + }, + } + + if m.serviceName != polkitServiceName { + rendersQrCode := m.isQrcodeRenderingSupported() + ls = append(ls, &authd.UILayout{ + Type: layouts.QrCode, + Content: &required, + Code: &optional, + Wait: &layouts.RequiredWithBooleans, + Label: &optional, + Button: &optional, + RendersQrcode: &rendersQrCode, }) } + return ls +} - hasWait := m.uiLayout.GetWait() == layouts.True - - switch m.uiLayout.Type { +// collectChallengeInput collects user input for the given UI layout. +// Returns nil item to signal that the auth mode should be reselected. +func (m nativeClient) collectChallengeInput(modeID string, authModes []*authd.GAMResponse_AuthenticationMode, layout *authd.UILayout) (authd.IARequestAuthenticationDataItem, error) { + hasWait := layout.GetWait() == layouts.True + switch layout.Type { case layouts.Form: - return m.handleFormChallenge(hasWait) - + return m.collectFormInput(modeID, authModes, layout, hasWait) case layouts.QrCode: if !hasWait { - return sendEvent(pamError{ - status: pam.ErrSystem, - msg: "Can't handle qrcode without waiting", - }) + return nil, nativeErrorf(pam.ErrSystem, "%s", "can't handle qrcode without waiting") } - return m.handleQrCode() - + return m.collectQrCodeInput(modeID, authModes, layout) case layouts.NewPassword: - return m.handleNewPassword() - + return m.collectNewPasswordInput(modeID, authModes, layout) default: - return sendEvent(pamError{ - status: pam.ErrSystem, - msg: fmt.Sprintf("Unknown layout type: %q", m.uiLayout.Type), - }) + return nil, nativeErrorf(pam.ErrSystem, "unknown layout type: %q", layout.Type) } } -func (m nativeModel) selectedAuthModeLabel(fallback string) string { - authModeIdx := slices.IndexFunc(m.authModes, func(mode *authd.GAMResponse_AuthenticationMode) bool { - return mode.Id == m.selectedAuthMode +func (m nativeClient) modeLabel(modeID string, authModes []*authd.GAMResponse_AuthenticationMode, fallback string) string { + idx := slices.IndexFunc(authModes, func(am *authd.GAMResponse_AuthenticationMode) bool { + return am.Id == modeID }) - if authModeIdx < 0 { + if idx < 0 { return fallback } - return m.authModes[authModeIdx].Label + return authModes[idx].Label } -func (m nativeModel) handleFormChallenge(hasWait bool) tea.Cmd { - authMode := m.selectedAuthModeLabel("Authentication") +func (m nativeClient) collectFormInput(modeID string, authModes []*authd.GAMResponse_AuthenticationMode, layout *authd.UILayout, hasWait bool) (authd.IARequestAuthenticationDataItem, error) { + authMode := m.modeLabel(modeID, authModes, "Authentication") - if buttonLabel := m.uiLayout.GetButton(); buttonLabel != "" { + if buttonLabel := layout.GetButton(); buttonLabel != "" { choices := []choicePair{ {id: "continue", label: fmt.Sprintf("Proceed with %s", authMode)}, + {id: layouts.Button, label: buttonLabel}, } - if buttonLabel := m.uiLayout.GetButton(); buttonLabel != "" { - choices = append(choices, choicePair{id: layouts.Button, label: buttonLabel}) - } - id, err := m.promptForChoice(authMode, choices, "Choose action") - if errors.Is(err, errGoBack) { - return sendEvent(nativeGoBack{}) - } - if errors.Is(err, errEmptyResponse) { - return sendEvent(nativeChallengeRequested{}) + if errors.Is(err, errGoBack) || errors.Is(err, errEmptyResponse) { + return m.collectFormInput(modeID, authModes, layout, hasWait) } if err != nil { - return maybeSendPamError(err) + return nil, err } if id == layouts.Button { - return sendEvent(reselectAuthMode{}) + return nil, nil // reselect auth mode } } - var prompt string - if m.uiLayout.Label != nil { - prompt = strings.TrimSuffix(*m.uiLayout.Label, ":") - } + prompt := strings.TrimSuffix(layout.GetLabel(), ":") if prompt == "" { - return sendEvent(pamError{ - status: pam.ErrSystem, - msg: fmt.Sprintf("No label provided for entry %q", m.uiLayout.GetEntry()), - }) - } - - var instructions string - if m.canGoBack() { - instructions = "Enter '%[1]s' to cancel the request and %[2]s" + return nil, nativeErrorf(pam.ErrSystem, "no label provided for entry %q", layout.GetEntry()) } if hasWait { - // Duplicating some contents here, as it will be better for translators once we've them - instructions = "Leave the input field empty to wait for the alternative authentication method" - if m.uiLayout.GetEntry() == "" { + instructions := "Leave the input field empty to wait for the alternative authentication method" + if layout.GetEntry() == "" { instructions = "Press Enter to wait for authentication" } - - if m.canGoBack() { - instructions += " or enter '%[1]s' to %[2]s" + if err := m.sendInfo("== %s ==\n%s", authMode, instructions); err != nil { + return nil, err + } + } else { + if err := m.sendInfo("== %s ==", authMode); err != nil { + return nil, err } } - if goBackLabel := m.goBackActionLabel(); goBackLabel != "" { - instructions = "\n" + fmt.Sprintf(instructions, nativeCancelKey, m.goBackActionLabel()) - } - if cmd := maybeSendPamError(m.sendInfo("== %s ==%s", authMode, instructions)); cmd != nil { - return cmd - } - - secret, err := m.promptForSecret(prompt) + secret, err := m.promptForSecret(layout, prompt) if errors.Is(err, errGoBack) { - return sendEvent(nativeGoBack{}) + return m.collectFormInput(modeID, authModes, layout, hasWait) } - if errors.Is(err, errEmptyResponse) { - if hasWait { - return sendAuthWaitCommand() - } - err = nil + if errors.Is(err, errEmptyResponse) && hasWait { + return &authd.IARequest_AuthenticationData_Wait{Wait: layouts.True}, nil } if err != nil { - return maybeSendPamError(err) - } - - return sendEvent(isAuthenticatedRequested{ - item: &authd.IARequest_AuthenticationData_Secret{Secret: secret}, - }) -} - -func (m nativeModel) promptForSecret(prompt string) (string, error) { - switch m.uiLayout.GetEntry() { - case entries.Chars, "": - return m.promptForInput(pam.PromptEchoOn, inputPromptStyleMultiLine, prompt) - case entries.CharsPassword: - return m.promptForInput(pam.PromptEchoOff, inputPromptStyleMultiLine, prompt) - case entries.Digits: - return m.promptForNumericInputAsString(pam.PromptEchoOn, prompt) - case entries.DigitsPassword: - return m.promptForNumericInputAsString(pam.PromptEchoOff, prompt) - default: - return "", fmt.Errorf("unhandled entry %q", m.uiLayout.GetEntry()) - } -} - -func (m nativeModel) renderQrCode(qrCode *qrcode.QRCode) (qr string) { - defer func() { qr = strings.TrimRight(qr, "\n") }() - - if os.Getenv("XDG_SESSION_TYPE") == "tty" { - return qrCode.ToString(false) - } - - switch termenv.DefaultOutput().Profile { - case termenv.ANSI, termenv.Ascii: - return qrCode.ToString(false) - default: - return qrCode.ToSmallString(false) + return nil, err } + return &authd.IARequest_AuthenticationData_Secret{Secret: secret}, nil } -func (m nativeModel) handleQrCode() tea.Cmd { - qrCode, err := qrcode.New(m.uiLayout.GetContent(), qrcode.Medium) +func (m nativeClient) collectQrCodeInput(modeID string, authModes []*authd.GAMResponse_AuthenticationMode, layout *authd.UILayout) (authd.IARequestAuthenticationDataItem, error) { + qrCode, err := qrcode.New(layout.GetContent(), qrcode.Medium) if err != nil { - return sendEvent(pamError{ - status: pam.ErrSystem, - msg: fmt.Sprintf("Can't generate qrcode: %v", err), - }) + return nil, nativeErrorf(pam.ErrSystem, "can't generate qrcode: %v", err) } var qrcodeView []string - qrcodeView = append(qrcodeView, m.uiLayout.GetLabel()) + qrcodeView = append(qrcodeView, layout.GetLabel()) var firstQrCodeLine string if m.isQrcodeRenderingSupported() { - qrcode := m.renderQrCode(qrCode) - qrcodeView = append(qrcodeView, qrcode) - firstQrCodeLine = strings.SplitN(qrcode, "\n", 2)[0] + rendered := m.renderQrCode(qrCode) + qrcodeView = append(qrcodeView, rendered) + firstQrCodeLine = strings.SplitN(rendered, "\n", 2)[0] } if firstQrCodeLine == "" { - firstQrCodeLine = m.uiLayout.GetContent() + firstQrCodeLine = layout.GetContent() } - centeredContent := centerString(m.uiLayout.GetContent(), firstQrCodeLine) - qrcodeView = append(qrcodeView, centeredContent) - - if code := m.uiLayout.GetCode(); code != "" { + qrcodeView = append(qrcodeView, centerString(layout.GetContent(), firstQrCodeLine)) + if code := layout.GetCode(); code != "" { qrcodeView = append(qrcodeView, centerString(code, firstQrCodeLine)) } - - // Add some extra vertical space to improve readability qrcodeView = append(qrcodeView, " ") - choices := []choicePair{ - {id: layouts.Wait, label: "Wait for authentication result"}, - } - if buttonLabel := m.uiLayout.GetButton(); buttonLabel != "" { + choices := []choicePair{{id: layouts.Wait, label: "Wait for authentication result"}} + if buttonLabel := layout.GetButton(); buttonLabel != "" { choices = append(choices, choicePair{id: layouts.Button, label: buttonLabel}) } - id, err := m.promptForChoiceWithMessage(m.selectedAuthModeLabel("QR code"), + id, err := m.promptForChoiceWithMessage(m.modeLabel(modeID, authModes, "QR code"), strings.Join(qrcodeView, "\n"), choices, "Choose action") - if errors.Is(err, errGoBack) { - return sendEvent(nativeGoBack{}) - } - if errors.Is(err, errEmptyResponse) { - return sendAuthWaitCommand() + if errors.Is(err, errGoBack) || errors.Is(err, errEmptyResponse) { + return &authd.IARequest_AuthenticationData_Wait{Wait: layouts.True}, nil } if err != nil { - return maybeSendPamError(err) + return nil, err } - - switch id { - case layouts.Button: - return sendEvent(reselectAuthMode{}) - case layouts.Wait: - return sendAuthWaitCommand() - default: - return nil + if id == layouts.Button { + return nil, nil // reselect auth mode } + return &authd.IARequest_AuthenticationData_Wait{Wait: layouts.True}, nil } -func (m nativeModel) isQrcodeRenderingSupported() bool { - switch m.serviceName { - case polkitServiceName: - return false - default: - if isSSHSession(m.pamMTx) { - return false - } - return IsTerminalTTY(m.pamMTx) - } -} - -func centerString(s string, reference string) string { - sizeDiff := len([]rune(reference)) - len(s) - if sizeDiff <= 0 { - return s - } - - // We put padding in both sides, so that it's respected also by non-terminal UIs - padding := strings.Repeat(" ", sizeDiff/2) - return padding + s + padding -} - -func (m nativeModel) handleNewPassword() tea.Cmd { - if buttonLabel := m.uiLayout.GetButton(); buttonLabel != "" { +func (m nativeClient) collectNewPasswordInput(modeID string, authModes []*authd.GAMResponse_AuthenticationMode, layout *authd.UILayout) (authd.IARequestAuthenticationDataItem, error) { + if buttonLabel := layout.GetButton(); buttonLabel != "" { + label := m.modeLabel(modeID, authModes, "Password Update") choices := []choicePair{ {id: "continue", label: "Proceed with password update"}, + {id: layouts.Button, label: buttonLabel}, } - if buttonLabel := m.uiLayout.GetButton(); buttonLabel != "" { - choices = append(choices, choicePair{id: layouts.Button, label: buttonLabel}) - } - - label := m.selectedAuthModeLabel("Password Update") id, err := m.promptForChoice(label, choices, "Choose action") - if errors.Is(err, errGoBack) { - return sendEvent(nativeGoBack{}) - } - if errors.Is(err, errEmptyResponse) { - return sendEvent(nativeChallengeRequested{}) + if errors.Is(err, errGoBack) || errors.Is(err, errEmptyResponse) { + return m.collectNewPasswordInput(modeID, authModes, layout) } if err != nil { - return maybeSendPamError(err) + return nil, err } if id == layouts.Button { - return sendEvent(isAuthenticatedRequested{ - item: &authd.IARequest_AuthenticationData_Skip{Skip: layouts.True}, - }) + return &authd.IARequest_AuthenticationData_Skip{Skip: layouts.True}, nil } } - - return m.newPasswordChallenge(nil) + return m.newPasswordChallenge(layout, nil) } -func (m nativeModel) newPasswordChallenge(previousPassword *string) tea.Cmd { +func (m nativeClient) newPasswordChallenge(layout *authd.UILayout, previousPassword *string) (authd.IARequestAuthenticationDataItem, error) { if previousPassword == nil { - var instructions string - if goBackLabel := m.goBackActionLabel(); goBackLabel != "" { - instructions = fmt.Sprintf("\nEnter '%[1]s' to cancel the request and %[2]s", - nativeCancelKey, goBackLabel) - } - title := m.selectedAuthModeLabel("Password Update") - if cmd := maybeSendPamError(m.sendInfo("== %s ==%s", title, instructions)); cmd != nil { - return cmd + if err := m.sendInfo("== Password Update =="); err != nil { + return nil, err } } - prompt := m.uiLayout.GetLabel() + prompt := layout.GetLabel() if previousPassword != nil { prompt = "Confirm Password" } - password, err := m.promptForSecret(prompt) + password, err := m.promptForSecret(layout, prompt) if errors.Is(err, errGoBack) { - return sendEvent(nativeGoBack{}) + return m.newPasswordChallenge(layout, nil) } if err != nil && !errors.Is(err, errEmptyResponse) { - return maybeSendPamError(err) + return nil, err } if previousPassword == nil { - return sendEvent(newPasswordCheck{password: password}) + if err := checkPasswordQuality("", password); err != nil { + if sendErr := m.sendError(err.Error()); sendErr != nil { + return nil, sendErr + } + return m.newPasswordChallenge(layout, nil) + } + return m.newPasswordChallenge(layout, &password) } + if password != *previousPassword { - err := m.sendError("Password entries don't match") - if err != nil { - return maybeSendPamError(err) + if err := m.sendError("Password entries don't match"); err != nil { + return nil, err } - return m.newPasswordChallenge(nil) + return m.newPasswordChallenge(layout, nil) } - return sendEvent(isAuthenticatedRequested{ - item: &authd.IARequest_AuthenticationData_Secret{Secret: password}, - }) + return &authd.IARequest_AuthenticationData_Secret{Secret: password}, nil +} + +// ---------- prompting helpers ---------- + +func (m nativeClient) checkForPromptReplyValidity(reply string) error { + switch reply { + case nativeCancelKey: + return errGoBack + case "", "\n": + return errEmptyResponse + } + return nil +} + +func (m nativeClient) promptForInput(style pam.Style, inputStyle inputPromptStyle, prompt string) (string, error) { + format := "%s" + if m.interactive { + switch inputStyle { + case inputPromptStyleInline: + format = "%s: " + case inputPromptStyleMultiLine: + format = "%s:\n> " + } + } + resp, err := m.pamMTx.StartStringConvf(style, format, prompt) + if err != nil { + return "", err + } + return resp.Response(), m.checkForPromptReplyValidity(resp.Response()) } -func (m nativeModel) goBackCommand() (nativeModel, tea.Cmd) { - if m.currentStage >= proto.Stage_challenge && m.uiLayout != nil { - m.uiLayout = nil +func (m nativeClient) promptForNumericInput(style pam.Style, prompt string) (int, error) { + out, err := m.promptForInput(style, inputPromptStyleMultiLine, prompt) + if err != nil { + return -1, err } - if m.currentStage >= proto.Stage_authModeSelection { - m.selectedAuthMode = "" + intOut, err := strconv.Atoi(out) + if err != nil { + return intOut, fmt.Errorf("%w: %w", errNotAnInteger, err) + } + return intOut, err +} + +func (m nativeClient) promptForNumericInputUntilValid(style pam.Style, prompt string) (int, error) { + value, err := m.promptForNumericInput(style, prompt) + if !errors.Is(err, errNotAnInteger) { + return value, err } - if m.currentStage == proto.Stage_authModeSelection { - m.authModes = nil + if err := m.sendError("Unsupported input"); err != nil { + return -1, err } + return m.promptForNumericInputUntilValid(style, prompt) +} - return m, func() tea.Cmd { - if !m.canGoBack() { - return nil +func (m nativeClient) promptForNumericInputAsString(style pam.Style, prompt string) (string, error) { + input, err := m.promptForNumericInputUntilValid(style, prompt) + return fmt.Sprint(input), err +} + +func (m nativeClient) sendError(errorMsg string) error { + if errorMsg == "" { + return nil + } + _, err := m.pamMTx.StartStringConvf(pam.ErrorMsg, errorMsg) + return err +} + +func (m nativeClient) sendInfo(infoMsg string, args ...any) error { + if infoMsg == "" { + return nil + } + _, err := m.pamMTx.StartStringConvf(pam.TextInfo, infoMsg, args...) + return err +} + +type choicePair struct { + id string + label string +} + +func (m nativeClient) promptForChoiceWithMessage(title string, message string, choices []choicePair, prompt string) (string, error) { + msg := fmt.Sprintf("== %s ==\n", title) + if message != "" { + msg += message + "\n" + } + for i, choice := range choices { + msg += fmt.Sprintf(" %d. %s", i+1, choice.label) + if i < len(choices)-1 { + msg += "\n" } - return m.requestStageChange(m.previousStage()) - }() + } + for { + if err := m.sendInfo(msg); err != nil { + return "", err + } + idx, err := m.promptForNumericInputUntilValid(pam.PromptEchoOn, prompt) + if err != nil { + return "", err + } + if idx < 1 || idx > len(choices) { + if err := m.sendError("Invalid selection"); err != nil { + return "", err + } + continue + } + return choices[idx-1].id, nil + } } -func (m nativeModel) canGoBack() bool { - if m.userSelectionAllowed { - return m.currentStage > proto.Stage_userSelection +func (m nativeClient) promptForChoice(title string, choices []choicePair, prompt string) (string, error) { + return m.promptForChoiceWithMessage(title, "", choices, prompt) +} + +func (m nativeClient) promptForSecret(layout *authd.UILayout, prompt string) (string, error) { + switch layout.GetEntry() { + case entries.Chars, "": + return m.promptForInput(pam.PromptEchoOn, inputPromptStyleMultiLine, prompt) + case entries.CharsPassword: + return m.promptForInput(pam.PromptEchoOff, inputPromptStyleMultiLine, prompt) + case entries.Digits: + return m.promptForNumericInputAsString(pam.PromptEchoOn, prompt) + case entries.DigitsPassword: + return m.promptForNumericInputAsString(pam.PromptEchoOff, prompt) + default: + return "", fmt.Errorf("unhandled entry %q", layout.GetEntry()) } - return m.previousStage() > proto.Stage_userSelection } -func (m nativeModel) previousStage() proto.Stage { - if m.currentStage > proto.Stage_authModeSelection && len(m.authModes) > 1 { - return proto.Stage_authModeSelection +// ---------- QR code helpers ---------- + +func (m nativeClient) renderQrCode(qrCode *qrcode.QRCode) (qr string) { + defer func() { qr = strings.TrimRight(qr, "\n") }() + if os.Getenv("XDG_SESSION_TYPE") == "tty" { + return qrCode.ToString(false) + } + switch termenv.DefaultOutput().Profile { + case termenv.ANSI, termenv.Ascii: + return qrCode.ToString(false) + default: + return qrCode.ToSmallString(false) } - if m.currentStage > proto.Stage_brokerSelection && len(m.availableBrokers) > 1 { - return proto.Stage_brokerSelection +} + +func (m nativeClient) isQrcodeRenderingSupported() bool { + switch m.serviceName { + case polkitServiceName: + return false + default: + return !isSSHSession(m.pamMTx) && IsTerminalTTY(m.pamMTx) } - return proto.Stage_userSelection } -func (m nativeModel) goBackActionLabel() string { - if !m.canGoBack() { - return "" +func centerString(s string, reference string) string { + sizeDiff := len([]rune(reference)) - len(s) + if sizeDiff <= 0 { + return s } + padding := strings.Repeat(" ", sizeDiff/2) + return padding + s + padding +} + +// ---------- error helpers ---------- + +// nativeError wraps a pamError so it satisfies the error interface and can be +// returned from internal helper functions. At the boundary, pamReturnErrorFrom +// unwraps it back into a PamReturnStatus. +type nativeError struct{ pamError } - return goBackLabel(m.previousStage()) +func (e nativeError) Error() string { return e.Message() } + +func nativeErrorf(status pam.Error, format string, args ...any) error { + return nativeError{pamError{status: status, msg: fmt.Sprintf(format, args...)}} } -func sendAuthWaitCommand() tea.Cmd { - return sendEvent(isAuthenticatedRequested{ - item: &authd.IARequest_AuthenticationData_Wait{Wait: layouts.True}, - }) +func pamReturnErrorFrom(err error) PamReturnStatus { + var ne nativeError + if errors.As(err, &ne) { + return ne.pamError + } + return pamError{status: pam.ErrSystem, msg: err.Error()} } diff --git a/pam/internal/adapter/utils.go b/pam/internal/adapter/utils.go index 05ca8e0eee..387604b082 100644 --- a/pam/internal/adapter/utils.go +++ b/pam/internal/adapter/utils.go @@ -168,8 +168,6 @@ func defaultSafeMessageFormatter(msg tea.Msg) string { return fmt.Sprintf("%T{Stage:%q}", msg, msg.Stage) case StageChanged: return fmt.Sprintf("%T{Stage:%q}", msg, msg.Stage) - case nativeStageChangeRequest: - return fmt.Sprintf("%T{Stage:%q}", msg, msg.Stage) case tea.KeyMsg: if msg.Type != tea.KeyRunes { return fmt.Sprintf("%T{%s}", msg, msg)