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)