diff --git a/.github/workflows/e2e-tests-run.yaml b/.github/workflows/e2e-tests-run.yaml index 1824989ae4..fa8ae23197 100644 --- a/.github/workflows/e2e-tests-run.yaml +++ b/.github/workflows/e2e-tests-run.yaml @@ -23,6 +23,8 @@ on: required: false E2E_MSENTRA_CLIENT_ID: required: false + E2E_MSENTRA_CLIENT_SECRET: + required: false E2E_MSENTRA_USERNAME: required: false E2E_MSENTRA_PASSWORD: @@ -225,6 +227,7 @@ jobs: export E2E_USER="${{ secrets.E2E_MSENTRA_USERNAME }}" export E2E_PASSWORD="${{ secrets.E2E_MSENTRA_PASSWORD }}" export TOTP_SECRET="${{ secrets.E2E_MSENTRA_TOTP_SECRET }}" + export AUTHD_MSENTRAID_CLIENT_SECRET="${{ secrets.E2E_MSENTRA_CLIENT_SECRET }}" elif [ "${{ inputs.broker }}" = "authd-google" ]; then export E2E_USER="${{ secrets.E2E_GOOGLE_USERNAME }}" export E2E_PASSWORD="${{ secrets.E2E_GOOGLE_PASSWORD }}" diff --git a/AGENTS.md b/AGENTS.md index 51525cf50c..72ebcf859f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,8 @@ Explain why, not what — the diff shows what changed. - For bug fixes, describe the observable symptom before the root cause - Document non-obvious decisions and rejected alternatives - One-liners are fine for mechanical changes; anything behavioral needs a body +- Try to keep the subject line at 72 characters or less; wrap body lines at 72 characters + (URLs that cannot be split are the only accepted exception) Don't narrate your activity ("Fixed X as requested") or describe the diff ("Add null check before calling Process()"). diff --git a/authd-oidc-brokers/cmd/authd-oidc/daemon/export_test.go b/authd-oidc-brokers/cmd/authd-oidc/daemon/export_test.go index fda6f80346..7759481a4b 100644 --- a/authd-oidc-brokers/cmd/authd-oidc/daemon/export_test.go +++ b/authd-oidc-brokers/cmd/authd-oidc/daemon/export_test.go @@ -70,16 +70,22 @@ func GenerateBrokerConfig(t *testing.T, p, providerURL string) { require.NoError(t, err, "Setup: could not create parent broker configuration directory for tests") brokerCfg := fmt.Sprintf(` - [authd] - name = %[1]s - brand_icon = broker_icon.png - dbus_name = com.ubuntu.authd.%[1]s - dbus_object = /com/ubuntu/authd/%[1]s - - [oidc] - issuer = %[2]s - client_id = client_id - `, strings.ReplaceAll(t.Name(), "/", "_"), providerURL) +[authd] +name = %[1]s +brand_icon = broker_icon.png +dbus_name = com.ubuntu.authd.%[1]s +dbus_object = /com/ubuntu/authd/%[1]s + +[oidc] +issuer = %[2]s +client_id = client_id + +[flows] +# These tests don't exercise the entra_password flow, and the default +# (enabled) would fail startup validation under the withmsentraid tag +# because no client_secret or register_device is configured here. +entra_password = false +`, strings.ReplaceAll(t.Name(), "/", "_"), providerURL) err = os.WriteFile(p, []byte(brokerCfg), 0600) require.NoError(t, err, "Setup: could not create broker configuration for tests") } diff --git a/authd-oidc-brokers/conf/variants/msentraid/broker.conf b/authd-oidc-brokers/conf/variants/msentraid/broker.conf index e39b90448b..333234bee5 100644 --- a/authd-oidc-brokers/conf/variants/msentraid/broker.conf +++ b/authd-oidc-brokers/conf/variants/msentraid/broker.conf @@ -6,6 +6,9 @@ issuer = https://login.microsoftonline.com//v2.0 ## The client ID of the application registered in Entra ID. client_id = +## Optional: Client secret for the OIDC application registered in Entra ID. +#client_secret = + ## Force verification with the identity provider during login. ## ## When enabled, authd always verifies during login that the user still @@ -93,3 +96,18 @@ client_id = ## (see 'owner' option) will be added to these groups. ## Example: owner_extra_groups = sudo,lpadmin #owner_extra_groups = + +[flows] +## Control which authentication flows are offered to users. +## +## device_auth: When true (default), users can authenticate with the +## device code flow (scanning a QR code or visiting a URL and entering +## a code). +#device_auth = true + +## entra_password: When true (default), users can authenticate by entering +## their Microsoft Entra ID password directly, followed by MFA verification. +## +## Note: If both flows are disabled, no authentication will be available +## and users will not be able to log in. +#entra_password = true diff --git a/authd-oidc-brokers/internal/broker/authmodes/consts.go b/authd-oidc-brokers/internal/broker/authmodes/consts.go index 366a9d2af5..5afabb06cf 100644 --- a/authd-oidc-brokers/internal/broker/authmodes/consts.go +++ b/authd-oidc-brokers/internal/broker/authmodes/consts.go @@ -13,14 +13,27 @@ const ( // NewPassword is the ID of the new password configuration method. NewPassword = "newpassword" + + // EntraPassword is the ID of the Entra ID password + MFA authentication method. + EntraPassword = "entra_password" + + // EntraMFAWait is the ID of the poll-based MFA follow-up mode. + EntraMFAWait = "entra_mfa_wait" + + // EntraMFACode is the ID of the code-entry MFA follow-up mode. + EntraMFACode = "entra_mfa_code" ) var ( // Label is a map of auth mode IDs to their display labels. + //nolint:gosec // G101: These are auth mode display labels, not credentials. Label = map[string]string{ - Password: "Local Password Authentication", - Device: "Device Authentication", - DeviceQr: "Device Authentication", - NewPassword: "Define your local password", + Password: "Local Password Authentication", + Device: "Device Authentication", + DeviceQr: "Device Authentication", + NewPassword: "Define your local password", + EntraPassword: "Entra ID password", + EntraMFAWait: "Waiting for MFA approval", + EntraMFACode: "Enter your MFA code", } ) diff --git a/authd-oidc-brokers/internal/broker/authresponses.go b/authd-oidc-brokers/internal/broker/authresponses.go index eae9c4b625..3e8a551948 100644 --- a/authd-oidc-brokers/internal/broker/authresponses.go +++ b/authd-oidc-brokers/internal/broker/authresponses.go @@ -2,6 +2,12 @@ package broker import "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" +// cachedPasswordMessage is the user-facing notice attached to a granted +// response after the user's Entra password is saved as the local password +// (during the Entra password + MFA flow). It is broker-owned so it can be +// localized independently of authd. +const cachedPasswordMessage = "Your local password has been set to your Entra password" + type isAuthenticatedDataResponse interface { isAuthenticatedDataResponse() } @@ -9,6 +15,7 @@ type isAuthenticatedDataResponse interface { // userInfoMessage represents the user information message that is returned to authd. type userInfoMessage struct { UserInfo info.User `json:"userinfo"` + Message string `json:"message,omitempty"` } func (userInfoMessage) isAuthenticatedDataResponse() {} diff --git a/authd-oidc-brokers/internal/broker/broker.go b/authd-oidc-brokers/internal/broker/broker.go index 6c37df0d93..cd9b13240b 100644 --- a/authd-oidc-brokers/internal/broker/broker.go +++ b/authd-oidc-brokers/internal/broker/broker.go @@ -26,6 +26,7 @@ import ( "github.com/canonical/authd/authd-oidc-brokers/internal/providers" providerErrors "github.com/canonical/authd/authd-oidc-brokers/internal/providers/errors" "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/himmelblau" "github.com/canonical/authd/authd-oidc-brokers/internal/token" "github.com/canonical/authd/log" "github.com/coreos/go-oidc/v3/oidc" @@ -41,8 +42,16 @@ const ( maxAuthAttempts = 3 maxRequestDuration = 5 * time.Second + + // maxMFAPollDuration caps the total wall-clock time spent polling for MFA + // approval, to prevent infinite polling. + maxMFAPollDuration = 5 * time.Minute ) +// reauthModes is the set of auth modes offered when the user must re-authenticate +// (e.g. after token revocation, expiry, or password change). +var reauthModes = []string{authmodes.EntraPassword, authmodes.Device, authmodes.DeviceQr} + // Config is the configuration for the broker. type Config struct { ConfigFile string @@ -56,8 +65,9 @@ type Broker struct { cfg Config apiVersion uint - provider providers.Provider - oidcCfg oidc.Config + provider providers.Provider + oidcCfg oidc.Config + oidcClientSecret string currentSessions map[string]session currentSessionsMu sync.RWMutex @@ -87,6 +97,9 @@ type session struct { // Data to pass from one request to another. deviceAuthResponse *oauth2.DeviceAuthResponse authInfo *token.AuthCachedInfo + mfaFlowActive *himmelblau.MFAFlowState + mfaChallengeInfo *himmelblau.MFAChallengeInfo + entraPasswordHash string // pre-computed hash (not plaintext) for offline use isAuthenticating *isAuthenticatedCtx } @@ -96,6 +109,91 @@ type isAuthenticatedCtx struct { cancelFunc context.CancelFunc } +// verifyAndExtractEntraUserInfo verifies the Entra MFA access token's RS256 +// signature against the tenant JWKS and extracts user info from that verified +// access token. It does NOT cross-check the username against the session; first +// login does that via userInfoFromTokenExtras. +func (b *Broker) verifyAndExtractEntraUserInfo(ctx context.Context, token *oauth2.Token) (info.User, error) { + ep, ok := providers.ProviderAs[himmelblau.EntraPasswordProvider](b.provider) + if !ok { + return info.User{}, errors.New("provider does not support Entra password authentication") + } + if err := ep.VerifyAccessToken(ctx, b.cfg.issuerURL, token.AccessToken); err != nil { + return info.User{}, fmt.Errorf("access token verification failed: %w", err) + } + + userInfo, err := ep.UserInfoFromAccessToken(token.AccessToken) + if err != nil { + return info.User{}, fmt.Errorf("could not extract user info from access token: %w", err) + } + + if !filepath.IsAbs(userInfo.Home) { + userInfo.Home = filepath.Join(b.cfg.homeBaseDir, userInfo.Home) + } + + return userInfo, nil +} + +// userInfoFromTokenExtras is verifyAndExtractEntraUserInfo plus a cross-check +// that the returned access-token identity matches the username the user +// authenticated as. Used on first login (finishEntraAuth), where the username +// has not yet been bound to a verified identity. +func (b *Broker) userInfoFromTokenExtras(ctx context.Context, session *session, token *oauth2.Token) (info.User, error) { + userInfo, err := b.verifyAndExtractEntraUserInfo(ctx, token) + if err != nil { + return info.User{}, err + } + + if err := b.provider.VerifyUsername(session.username, userInfo.Name); err != nil { + return info.User{}, fmt.Errorf("username verification failed: %w", err) + } + + return userInfo, nil +} + +// populateAuthInfo creates an AuthCachedInfo and populates it with provider +// metadata and user info. It returns the populated authInfo, or an auth +// response pair if a step fails. +// +// When userInfoOverride is nil, the default verified OIDC ID token path +// (getUserInfo) is used. Callers that already resolved user info through a +// different trust path (e.g. a verified Entra MFA access token) can pass it +// directly. +func (b *Broker) populateAuthInfo(ctx context.Context, session *session, t *oauth2.Token, rawIDToken string, userInfoOverride *info.User) (*token.AuthCachedInfo, string, isAuthenticatedDataResponse) { + mp, mpOK := providers.ProviderAs[providers.MetadataProvider](b.provider) + var extraFields map[string]interface{} + if mpOK { + extraFields = mp.GetExtraFields(t) + } + authInfo := token.NewAuthCachedInfo(t, rawIDToken, extraFields) + + var err error + if mpOK { + authInfo.ProviderMetadata, err = mp.GetMetadata(session.oidcServer) + if err != nil { + log.Errorf(context.Background(), "could not get provider metadata: %s", err) + return nil, AuthDenied, unexpectedErrMsg("could not get provider metadata") + } + } + + if userInfoOverride != nil { + authInfo.UserInfo = *userInfoOverride + } else { + authInfo.UserInfo, err = b.getUserInfo(ctx, session, t, rawIDToken, false) + } + if err != nil { + log.Errorf(context.Background(), "could not get user info: %s", err) + return nil, AuthDenied, errorMessageForDisplay(err, "Could not get user info") + } + + if !b.userNameIsAllowed(authInfo.UserInfo.Name) { + log.Warning(context.Background(), b.userNotAllowedLogMsg(authInfo.UserInfo.Name)) + return nil, AuthDenied, errorMessage{Message: "Authentication failure: user not allowed in broker configuration"} + } + + return authInfo, "", nil +} + type option struct { provider providers.Provider } @@ -130,6 +228,19 @@ func New(cfg Config, apiVersion uint, args ...Option) (b *Broker, err error) { if cfg.clientID == "" { err = errors.Join(err, errors.New("client ID is required and was not provided")) } + // The entra_password flow can only retrieve groups from Microsoft Graph when + // device registration or a client secret is available (see the matching check + // in isAuthModeAvailable). If neither is configured, the flow is unusable, so + // fail here rather than silently falling back at login time: a startup failure + // is far more visible to the administrator than a per-login denial. + if cfg.flows.EntraPassword && !cfg.registerDevice && cfg.clientSecret == "" { + if _, ok := providers.ProviderAs[himmelblau.EntraPasswordProvider](opts.provider); ok { + err = errors.Join(err, fmt.Errorf( + "invalid configuration: the %[1]q flow is enabled in [%[2]s], but it cannot retrieve group memberships from Microsoft Graph without %[3]q enabled or a %[4]q configured; "+ + "fix this by either disabling %[1]q, enabling %[3]q, or granting the app the GroupMember.Read.All application permission and configuring a %[4]q", + flowsEntraPasswordKey, flowsSection, registerDeviceKey, clientSecret)) + } + } if err != nil { return nil, err } @@ -150,16 +261,33 @@ func New(cfg Config, apiVersion uint, args ...Option) (b *Broker, err error) { clientID = consts.MicrosoftBrokerAppID } + // The Microsoft Broker App is a public client and must never send a secret, + // even when client_secret is configured (for Graph API fallback). Resolve + // this once so that NewSession never needs to re-derive it from the client ID. + oidcClientSecret := cfg.clientSecret + if clientID == consts.MicrosoftBrokerAppID { + oidcClientSecret = "" + } + b = &Broker{ - cfg: cfg, - apiVersion: apiVersion, - provider: opts.provider, - oidcCfg: oidc.Config{ClientID: clientID}, - privateKey: privateKey, + cfg: cfg, + apiVersion: apiVersion, + provider: opts.provider, + oidcCfg: oidc.Config{ClientID: clientID}, + oidcClientSecret: oidcClientSecret, + privateKey: privateKey, currentSessions: make(map[string]session), currentSessionsMu: sync.RWMutex{}, } + + // If the provider supports app-only Graph API group lookup and a client secret + // is configured, propagate the secret so it can use client credentials as + // a fallback when the delegated token lacks GroupMember.Read.All. + if setter, ok := providers.ProviderAs[providers.GraphClientSecretSetter](opts.provider); ok && cfg.clientSecret != "" { + setter.SetGraphClientSecret(cfg.clientSecret) + } + return b, nil } @@ -619,11 +747,10 @@ func (b *Broker) NewSession(username, lang, mode, providerID string) (sessionID, } // Append extra scopes from config scopes = append(scopes, b.cfg.extraScopes...) - if s.oidcServer != nil { s.oauth2Config = oauth2.Config{ ClientID: b.oidcCfg.ClientID, - ClientSecret: b.cfg.clientSecret, + ClientSecret: b.oidcClientSecret, Endpoint: s.oidcServer.Endpoint(), Scopes: scopes, } @@ -715,7 +842,7 @@ func (b *Broker) availableAuthModes(session session) (availableModes []string, e // The order of the modes is important, because authd picks the first supported one. // Password authentication should be the first option if available, to avoid performing device authentication // when it's not necessary. - modes := append([]string{authmodes.Password}, b.provider.SupportedOIDCAuthModes()...) + modes := append([]string{authmodes.Password}, b.provider.SupportedOnlineAuthModes()...) for _, mode := range modes { if b.authModeIsAvailable(session, mode) { availableModes = append(availableModes, mode) @@ -759,11 +886,7 @@ func (b *Broker) authModeIsAvailable(session session, authMode string) bool { return true } - isTokenForDeviceRegistration, err := dr.IsTokenForDeviceRegistration(authInfo.Token) - if err != nil { - log.Warningf(context.Background(), "Could not check if token is for device registration, so local password authentication is not available: %v", err) - return false - } + isTokenForDeviceRegistration := dr.IsTokenForDeviceRegistration(authInfo) if b.cfg.registerDevice && !isTokenForDeviceRegistration { // TODO: We might want to display a message to the user in this case @@ -780,6 +903,10 @@ func (b *Broker) authModeIsAvailable(session session, authMode string) bool { case authmodes.NewPassword: return true case authmodes.Device, authmodes.DeviceQr: + if !b.cfg.flows.DeviceAuth { + log.Debugf(context.Background(), "Device authentication is disabled in the [flows] config, so it is not available") + return false + } if session.oidcServer == nil { log.Debugf(context.Background(), "OIDC server is not initialized, so device authentication is not available") return false @@ -793,6 +920,33 @@ func (b *Broker) authModeIsAvailable(session session, authMode string) bool { return false } return true + case authmodes.EntraPassword: + if _, ok := providers.ProviderAs[himmelblau.EntraPasswordProvider](b.provider); !ok { + return false + } + if !b.cfg.flows.EntraPassword { + log.Debugf(context.Background(), "The %q flow is disabled in the [flows] config, so it is not available", authmodes.EntraPassword) + return false + } + if session.isOffline { + log.Debugf(context.Background(), "Session is in offline mode, so Entra password authentication is not available") + return false + } + // The entra_password flow can only retrieve groups from Microsoft Graph + // when device registration (PRT-based token exchange) or a client secret + // (app-only client credentials) is available. Without either, every + // entra_password login would fail at the group-fetch step, so don't offer + // the mode rather than letting users hit an undiagnosable denial. New() + // already rejects that configuration for real broker startup, so this is a + // defensive guard for tests or manually constructed brokers. + if !b.cfg.registerDevice && b.cfg.clientSecret == "" { + log.Debugf(context.Background(), "The %q flow requires %q to be enabled or a client secret to be configured to retrieve groups from Microsoft Graph, so it is not available", flowsEntraPasswordKey, registerDeviceKey) + return false + } + return true + case authmodes.EntraMFAWait, authmodes.EntraMFACode: + // MFA follow-up modes are always available when offered via AuthNext. + return true } return false } @@ -815,37 +969,43 @@ func passwordFileExists(session session) bool { func (b *Broker) authModesSupportedByUI(supportedUILayouts []map[string]string) (supportedModes []string) { for _, layout := range supportedUILayouts { - mode := b.supportedAuthModeFromLayout(layout) - if mode != "" { - supportedModes = append(supportedModes, mode) - } + modes := b.supportedAuthModesFromLayout(layout) + supportedModes = append(supportedModes, modes...) } return supportedModes } -func (b *Broker) supportedAuthModeFromLayout(layout map[string]string) string { +func (b *Broker) supportedAuthModesFromLayout(layout map[string]string) []string { supportedEntries := strings.Split(strings.TrimPrefix(layout["entry"], "optional:"), ",") switch layout["type"] { case "qrcode": if !strings.Contains(layout["wait"], "true") { - return "" + return nil } if layout["renders_qrcode"] == "false" { - return authmodes.Device + return []string{authmodes.Device} } - return authmodes.DeviceQr + return []string{authmodes.DeviceQr} case "form": + var modes []string if slices.Contains(supportedEntries, "chars_password") { - return authmodes.Password + modes = append(modes, authmodes.Password, authmodes.EntraPassword) + } + if strings.Contains(layout["wait"], "true") { + modes = append(modes, authmodes.EntraMFAWait) + } + if slices.Contains(supportedEntries, "chars") { + modes = append(modes, authmodes.EntraMFACode) } + return modes case "newpassword": if slices.Contains(supportedEntries, "chars_password") { - return authmodes.NewPassword + return []string{authmodes.NewPassword} } } - return "" + return nil } // SelectAuthenticationMode selects the authentication mode for the user. @@ -931,6 +1091,31 @@ func (b *Broker) generateUILayout(session *session, authModeID string) (map[stri "entry": "chars_password", } + case authmodes.EntraPassword: + uiLayout = map[string]string{ + "type": "form", + "label": "Enter your Entra ID password", + "entry": "chars_password", + } + + case authmodes.EntraMFAWait: + mfaWaitLabel := "Waiting for MFA approval..." + if session.mfaChallengeInfo != nil && session.mfaChallengeInfo.Message != "" { + mfaWaitLabel = session.mfaChallengeInfo.Message + } + uiLayout = map[string]string{ + "type": "form", + "label": mfaWaitLabel, + "wait": "true", + } + + case authmodes.EntraMFACode: + uiLayout = map[string]string{ + "type": "form", + "entry": "chars", + "label": "Enter your MFA code", + } + case authmodes.NewPassword: label := "Create a local password" if session.mode == sessionmode.ChangePassword || session.mode == sessionmode.ChangePasswordOld { @@ -993,6 +1178,10 @@ func (b *Broker) IsAuthenticated(sessionID, authenticationData string) (string, access = AuthDenied } iadResponse = errorMessage{Message: "Maximum number of authentication attempts reached"} + // Free any in-progress MFA flow immediately rather than waiting for + // EndSession — consistent with all other terminal paths. + session.entraPasswordHash = "" + clearEntraMFAState(&session) } } @@ -1036,6 +1225,12 @@ func (b *Broker) handleIsAuthenticated(ctx context.Context, session *session, au return b.passwordAuth(ctx, session, secret) case authmodes.NewPassword: return b.newPassword(session, secret) + case authmodes.EntraPassword: + return b.entraPasswordAuth(ctx, session, secret) + case authmodes.EntraMFAWait: + return b.entraMFAWaitAuth(ctx, session) + case authmodes.EntraMFACode: + return b.entraMFACodeAuth(ctx, session, secret) default: log.Errorf(context.Background(), "unknown authentication mode %q", session.selectedMode) return AuthDenied, unexpectedErrMsg("unknown authentication mode") @@ -1080,59 +1275,24 @@ func (b *Broker) deviceAuth(ctx context.Context, session *session) (string, isAu return AuthDenied, unexpectedErrMsg("token response does not contain an ID token") } - var extraFields map[string]interface{} - if mp, ok := providers.ProviderAs[providers.MetadataProvider](b.provider); ok { - extraFields = mp.GetExtraFields(t) - } - authInfo := token.NewAuthCachedInfo(t, rawIDToken, extraFields) - - if mp, ok := providers.ProviderAs[providers.MetadataProvider](b.provider); ok { - authInfo.ProviderMetadata, err = mp.GetMetadata(session.oidcServer) - if err != nil { - log.Errorf(context.Background(), "could not get provider metadata: %s", err) - return AuthDenied, unexpectedErrMsg("could not get provider metadata") - } + authInfo, access, data := b.populateAuthInfo(ctx, session, t, rawIDToken, nil) + if authInfo == nil { + return access, data } - authInfo.UserInfo, err = b.getUserInfo(ctx, session, t, rawIDToken, false) - if err != nil { - log.Errorf(context.Background(), "could not get user info: %s", err) - return AuthDenied, errorMessageForDisplay(err, "Could not get user info") - } - - if !b.userNameIsAllowed(authInfo.UserInfo.Name) { - log.Warning(context.Background(), b.userNotAllowedLogMsg(authInfo.UserInfo.Name)) - return AuthDenied, errorMessage{Message: "Authentication failure: user not allowed in broker configuration"} + // Load existing device registration data if there is any, to avoid re-registering the device. + var deviceRegistrationData []byte + if oldAuthInfo, err := token.LoadAuthInfo(session.tokenPath); err == nil { + deviceRegistrationData = oldAuthInfo.DeviceRegistrationData } if authInfo.UserInfo.ProviderID != "" && session.providerID == "" { b.ensureProviderIDCacheDir(session, authInfo.UserInfo.ProviderID) } - if dr, ok := providers.ProviderAs[providers.DeviceRegisterer](b.provider); ok && b.cfg.registerDevice { - // Load existing device registration data if there is any, to avoid re-registering the device. - var deviceRegistrationData []byte - oldAuthInfo, err := token.LoadAuthInfo(session.tokenPath) - if err == nil { - deviceRegistrationData = oldAuthInfo.DeviceRegistrationData - } - - var cleanup func() - authInfo.DeviceRegistrationData, cleanup, err = dr.MaybeRegisterDevice(ctx, t, - session.username, - b.cfg.issuerURL, - deviceRegistrationData, - ) - if err != nil { - log.Errorf(context.Background(), "error registering device: %s", err) - return AuthDenied, errorMessage{Message: "Error registering device"} - } - defer cleanup() - - // Store the auth info, so that the device registration data is not lost if the login fails after this point. - if err := token.CacheAuthInfo(session.tokenPath, authInfo); err != nil { - log.Errorf(context.Background(), "Failed to store token: %s", err) - return AuthDenied, unexpectedErrMsg("failed to store token") - } + cleanup, access, data := b.maybeRegisterDevice(ctx, session, authInfo, t, deviceRegistrationData) + defer cleanup() + if access != "" { + return access, data } // We can only fetch the groups after registering the device, because the token acquired for device registration @@ -1178,28 +1338,42 @@ func (b *Broker) passwordAuth(ctx context.Context, session *session, secret stri return AuthNext, nil } - // Refresh the token if we're online even if the token has not expired + // Refresh the token on every online login (even if it has not expired) to + // re-verify the account with the provider. This refresh is also the live + // disabled/revoked-user check. Entra password + MFA tokens are issued by the + // Microsoft Broker App and are refreshed as a public client (no client_secret) + // via the provider; all other tokens use the OIDC app refresh. Both paths feed + // the same error classification below. if b.cfg.forceAccessCheckWithProvider || !session.isOffline { - // Check if we have a refresh token before attempting to refresh + oldAuthInfo := authInfo + // Both refresh paths use the cached refresh token; without one we can't + // perform the liveness check, so require re-authentication. if authInfo.Token.RefreshToken == "" { log.Warningf(context.Background(), "No refresh token available for user %q", session.username) - session.nextAuthModes = []string{authmodes.Device, authmodes.DeviceQr} + session.nextAuthModes = reauthModes return AuthNext, errorMessage{Message: "Remote authentication failed: No refresh token. Please contact your administrator."} } - - // We have a refresh token, attempt to refresh - oldAuthInfo := authInfo - authInfo, err = b.refreshToken(ctx, session, authInfo) + if authInfo.ObtainedViaEntraPasswordAuth { + authInfo, err = b.refreshEntraPasswordToken(ctx, session, authInfo) + } else { + authInfo, err = b.refreshToken(ctx, session, authInfo) + } var retrieveErr *oauth2.RetrieveError if errors.As(err, &retrieveErr) { + if isAADSTSGrantRevokedError(retrieveErr) { + log.Noticef(context.Background(), "Refresh token revoked for user %q after a remote password change/reset", session.username) + b.invalidateCachedCredentials(session) + session.nextAuthModes = reauthModes + return AuthNext, errorMessage{Message: "Your password was changed remotely. Please re-authenticate."} + } if b.provider.IsTokenExpiredError(retrieveErr) { - log.Noticef(context.Background(), "Refresh token expired for user %q, new device authentication required", session.username) - session.nextAuthModes = []string{authmodes.Device, authmodes.DeviceQr} - return AuthNext, errorMessage{Message: "Refresh token expired, please authenticate again using device authentication."} + log.Noticef(context.Background(), "Refresh token expired for user %q, re-authentication required", session.username) + session.nextAuthModes = reauthModes + return AuthNext, errorMessage{Message: "Refresh token expired, please authenticate again."} } if udc, ok := providers.ProviderAs[providers.UserDisabledChecker](b.provider); ok && udc.IsUserDisabledError(retrieveErr) { log.Error(context.Background(), retrieveErr.Error()) - log.Errorf(context.Background(), "Login denied: User %q is disabled in %s, please contact your administrator.", session.username, b.provider.DisplayName()) + log.Errorf(context.Background(), "Login denied: user %q is disabled in %s", session.username, b.provider.DisplayName()) // Store the information that the user is disabled, so that we can deny login on subsequent offline attempts. oldAuthInfo.UserIsDisabled = true @@ -1241,24 +1415,12 @@ func (b *Broker) passwordAuth(ctx context.Context, session *session, secret stri } // If device registration is enabled, ensure that the device is registered. - if dr, ok := providers.ProviderAs[providers.DeviceRegisterer](b.provider); ok && !session.isOffline && b.cfg.registerDevice { - var cleanup func() - authInfo.DeviceRegistrationData, cleanup, err = dr.MaybeRegisterDevice(ctx, - authInfo.Token, - session.username, - b.cfg.issuerURL, - authInfo.DeviceRegistrationData, - ) - if err != nil { - log.Errorf(context.Background(), "error registering device: %s", err) - return AuthDenied, errorMessage{Message: "Error registering device"} - } + // Skipped when offline: registration requires a live provider connection. + if !session.isOffline { + cleanup, access, data := b.maybeRegisterDevice(ctx, session, authInfo, authInfo.Token, authInfo.DeviceRegistrationData) defer cleanup() - - // Store the auth info, so that the device registration data is not lost if the login fails after this point. - if err := token.CacheAuthInfo(session.tokenPath, authInfo); err != nil { - log.Errorf(context.Background(), "Failed to store token: %s", err) - return AuthDenied, unexpectedErrMsg("failed to store token") + if access != "" { + return access, data } } @@ -1266,7 +1428,7 @@ func (b *Broker) passwordAuth(ctx context.Context, session *session, secret stri groups, err := b.getGroups(ctx, session, authInfo) if errors.Is(err, providerErrors.ErrDeviceDisabled) { // The device is disabled, deny login - log.Errorf(context.Background(), "Login failed: %s", err) + log.Errorf(context.Background(), "Login denied: device is disabled in %s for user %q", b.provider.DisplayName(), session.username) // Store the information that the device is disabled, so that we can deny login on subsequent offline attempts. authInfo.DeviceIsDisabled = true @@ -1279,7 +1441,7 @@ func (b *Broker) passwordAuth(ctx context.Context, session *session, secret stri } if errors.Is(err, providerErrors.ErrInvalidRedirectURI) { // Deny login if the redirect URI is invalid, so that users and administrators are aware of the issue. - log.Errorf(context.Background(), "Login failed: %s", err) + log.Errorf(context.Background(), "Login denied: %s", err) return AuthDenied, errorMessageForDisplay(err, "Invalid redirect URI") } var retryWithDeviceAuthError *providerErrors.RetryWithDeviceAuthError @@ -1296,12 +1458,15 @@ func (b *Broker) passwordAuth(ctx context.Context, session *session, secret stri return AuthDenied, unexpectedErrMsg("failed to store token") } - session.nextAuthModes = []string{authmodes.Device, authmodes.DeviceQr} - msg := "Authentication failed due to a token issue. Please try again using device authentication." + session.nextAuthModes = reauthModes + msg := "Authentication failed due to a token issue. Please try again." return AuthNext, errorMessage{Message: msg} } if err != nil { - // We couldn't fetch the groups, but we have valid cached ones. + // We couldn't fetch the groups, but we have valid cached ones. The live + // provider check (and force_access_check_with_provider enforcement) happens + // at the token refresh above, the same as the device-auth flow, so a + // group-fetch failure here falls back to cached groups for both flows. log.Warningf(context.Background(), "Could not get groups: %v. Using cached groups.", err) } else { authInfo.UserInfo.Groups = groups @@ -1310,6 +1475,515 @@ func (b *Broker) passwordAuth(ctx context.Context, session *session, secret stri return b.finishAuth(session, authInfo) } +func (b *Broker) entraPasswordAuth(ctx context.Context, session *session, userPassword string) (string, isAuthenticatedDataResponse) { + entraProvider, ok := providers.ProviderAs[himmelblau.EntraPasswordProvider](b.provider) + if !ok { + log.Error(context.Background(), "entra_password mode selected but provider does not support it") + return AuthDenied, unexpectedErrMsg("provider does not support Entra password authentication") + } + + // A prior MFA flow may still be active if the password step is restarted + // (e.g. the user navigates back to re-enter the password). Release it before + // starting a new one so the libhimmelblau continuation it owns is not leaked. + clearEntraMFAState(session) + + // Load the cached auth info once at the start of the flow and stash it on the + // session, so the second step (entra_mfa_wait/entra_mfa_code → finishEntraAuth) + // reuses it instead of re-reading the token from disk on every call. + // + // A load error is non-fatal: it is expected on a first login (no cached token + // yet), and for any other reason (e.g. an unreadable token) the flow can still + // proceed by treating it as "no prior device data". A nil session.authInfo is + // the correct state in both cases; log it for visibility. + cachedAuthInfo, err := token.LoadAuthInfo(session.tokenPath) + if err != nil { + log.Debugf(context.Background(), "No cached auth info for user %q (first login or unreadable token): %v", session.username, err) + } + session.authInfo = cachedAuthInfo + + // Existing device registration data for the MFA flow (from the cached info). + deviceRegistrationData := b.cachedDeviceRegistrationData(session) + + // Use device-scoped MFA flow when we expect to register the device or + // already have valid device data for PRT-based token exchange. The || + // short-circuits so we skip parsing the data when registration is enabled. + withDeviceScope := b.cfg.registerDevice || himmelblau.ValidDeviceRegistrationDataJSON(deviceRegistrationData) + + flow, challengeInfo, err := entraProvider.InitiateEntraPasswordAuth(ctx, b.cfg.clientID, b.cfg.issuerURL, session.username, userPassword, deviceRegistrationData, withDeviceScope) + if err != nil { + var mfaErr *himmelblau.MFAError + if errors.As(err, &mfaErr) { + return b.routeMFAInitError(mfaErr, session) + } + // A non-MFAError here is unexpected (the provider should classify expected + // failures as MFAError); surface it as a reportable bug. + log.Errorf(context.Background(), "Entra password authentication failed: %v", err) + return AuthDenied, unexpectedErrMsg("failed to initiate Entra password flow") + } + if flow == nil || challengeInfo == nil { + himmelblau.FreeMFAFlowState(flow) + log.Error(context.Background(), "Entra password authentication did not return a complete MFA challenge") + return AuthDenied, unexpectedErrMsg("provider returned incomplete MFA challenge") + } + + // InitiateEntraPasswordAuth is a non-preemptible cgo call; if the request was + // cancelled while it was in flight, IsAuthenticated already returned via its + // ctx.Done() branch without persisting this session update. Stashing the flow + // on session at that point would make it unreachable, leaking the native + // continuation state it owns, so free it immediately instead (same reasoning + // as the equivalent check in finishEntraAuth). + if ctx.Err() != nil { + himmelblau.FreeMFAFlowState(flow) + log.Noticef(context.Background(), "Entra password authentication succeeded but the request was cancelled; discarding MFA flow for user %q", session.username) + return AuthCancelled, nil + } + + session.mfaFlowActive = flow + session.mfaChallengeInfo = challengeInfo + + // Hash the password immediately to narrow the plaintext memory window. + // The hash is written to disk in finishEntraAuth after MFA succeeds. + passwordHash, hashErr := password.HashPassword(userPassword) + if hashErr != nil { + log.Errorf(context.Background(), "Failed to hash password: %v", hashErr) + clearEntraMFAState(session) + return AuthDenied, unexpectedErrMsg("failed to process password") + } + session.entraPasswordHash = passwordHash + + // Determine MFA challenge type. + mfaMethod := challengeInfo.Method + pollingInterval := challengeInfo.PollingIntervalMs + + // FIDO/security-key MFA is not yet wired up in this terminal-based flow. + // This is an implementation gap, not a fundamental limitation: libhimmelblau + // can do FIDO (see https://github.com/himmelblau-idm/himmelblau/blob/main/src/common/src/auth.rs). + // TODO: support FIDO MFA directly without redirecting to Device Authentication. + if isFIDOMethod(mfaMethod) { + log.Noticef(context.Background(), "FIDO MFA method %q detected for user %q; redirecting to Device Authentication", mfaMethod, session.username) + session.entraPasswordHash = "" + clearEntraMFAState(session) + if b.cfg.flows.DeviceAuth { + session.nextAuthModes = []string{authmodes.Device, authmodes.DeviceQr} + return AuthNext, errorMessage{Message: "This account requires FIDO/security key authentication. Please complete authentication using Device Authentication."} + } + return AuthDenied, errorMessage{Message: "This account requires FIDO/security key authentication, which is not yet supported in this mode. Device Authentication is also unavailable. Please contact your administrator."} + } + + switch { + case isPromptMethod(mfaMethod): + // Code-entry MFA: user must type a code (OTP, SMS, etc.). + session.nextAuthModes = []string{authmodes.EntraMFACode} + case isPollMethod(mfaMethod): + // Poll-based MFA: approval happens out of band (push notification or + // phone call), so wait and poll. The poll loop applies a default + // interval if the challenge does not carry a positive one. + session.nextAuthModes = []string{authmodes.EntraMFAWait} + case pollingInterval > 0: + // Unknown method: a polling interval hints that approval happens out of band. + log.Warningf(context.Background(), "Unknown MFA method %q with polling interval %dms, treating it as a poll-based method", mfaMethod, pollingInterval) + session.nextAuthModes = []string{authmodes.EntraMFAWait} + default: + log.Warningf(context.Background(), "Unknown MFA method %q without a polling interval, treating it as a code-entry method", mfaMethod) + session.nextAuthModes = []string{authmodes.EntraMFACode} + } + + return AuthNext, nil +} + +func clearEntraMFAState(session *session) { + himmelblau.FreeMFAFlowState(session.mfaFlowActive) + session.mfaFlowActive = nil + session.mfaChallengeInfo = nil +} + +// cachedDeviceRegistrationData returns the device registration data from the +// session's cached auth info (loaded once at the start of the flow by +// entraPasswordAuth), or nil if there is no cached token or it carries none. +func (b *Broker) cachedDeviceRegistrationData(session *session) []byte { + if session.authInfo != nil { + return session.authInfo.DeviceRegistrationData + } + return nil +} + +func (b *Broker) entraMFAWaitAuth(ctx context.Context, session *session) (string, isAuthenticatedDataResponse) { + entraProvider, ok := providers.ProviderAs[himmelblau.EntraPasswordProvider](b.provider) + if !ok { + log.Error(context.Background(), "entra_mfa_wait mode selected but provider does not support it") + return AuthDenied, unexpectedErrMsg("provider does not support Entra MFA") + } + + if session.mfaFlowActive == nil { + log.Error(context.Background(), "MFA wait mode selected but no active MFA flow") + return AuthDenied, unexpectedErrMsg("no active MFA flow") + } + if session.mfaChallengeInfo == nil { + log.Error(context.Background(), "MFA wait mode selected but no MFA challenge metadata is available") + return AuthDenied, unexpectedErrMsg("no active MFA challenge") + } + + maxAttempts := session.mfaChallengeInfo.MaxPollAttempts + + deviceRegistrationData := b.cachedDeviceRegistrationData(session) + + pollCtx, pollCancel := context.WithTimeout(ctx, maxMFAPollDuration) + defer pollCancel() + + // The first poll attempt is 1 for Himmelblau's poll-based MFA flow. + // maxAttempts <= 0 means "no usable attempt budget from the challenge": -1 is + // libhimmelblau's "no max defined", and 0 can result from its + // expires_in/polling_interval integer division when expires_in < polling_interval. + // In both cases poll until the wall-clock cap above rather than skipping every + // poll and reporting an immediate (false) timeout. + for attempt := 1; maxAttempts <= 0 || attempt <= maxAttempts; attempt++ { + oauthToken, err := entraProvider.AcquireTokenByMFAFlow( + pollCtx, b.cfg.clientID, b.cfg.issuerURL, session.username, + session.mfaFlowActive, "", attempt, + deviceRegistrationData, + ) + if err != nil { + var mfaErr *himmelblau.MFAError + if errors.As(err, &mfaErr) && mfaErr.IsMFAPollContinue() { + // MFA not yet approved, keep polling. + pollingInterval := session.mfaChallengeInfo.PollingIntervalMs + if pollingInterval <= 0 { + pollingInterval = 1000 + } + select { + case <-pollCtx.Done(): + return b.endExpiredMFAPoll(ctx, session) + case <-time.After(time.Duration(pollingInterval) * time.Millisecond): + continue + } + } + // A user denial is terminal — handle it first, even if our poll + // deadline happened to elapse during this (non-preemptible) call. + if errors.As(err, &mfaErr) && mfaErr.IsMFADenied() { + session.entraPasswordHash = "" + clearEntraMFAState(session) + log.Noticef(context.Background(), "MFA authentication denied for user %q", session.username) + return AuthDenied, errorMessage{Message: "MFA authentication was denied."} + } + // AcquireTokenByMFAFlow is a non-preemptible CGo call: our poll + // deadline (or the caller's cancellation) can elapse while it is in + // flight, after which it returns a generic error rather than a poll + // continuation. Report that as the timeout/cancellation it really is, + // keeping the underlying error in the log for diagnosis. + if pollCtx.Err() != nil { + log.Errorf(context.Background(), "MFA poll error at deadline for user %q: %v", session.username, err) + return b.endExpiredMFAPoll(ctx, session) + } + // Genuine MFA failure. + session.entraPasswordHash = "" + clearEntraMFAState(session) + log.Errorf(context.Background(), "MFA poll failed: %v", err) + // MFA flow state was cleared; direct the client back to entra_password + // so it can restart the flow rather than re-entering a dead MFA mode. + session.nextAuthModes = []string{authmodes.EntraPassword} + return AuthNext, errorMessage{Message: "MFA authentication failed. Please try again."} + } + + // MFA approved — finish auth. + clearEntraMFAState(session) + return b.finishEntraAuth(ctx, session, oauthToken) + } + + // Max poll attempts exceeded. + return b.endExpiredMFAPoll(ctx, session) +} + +// endExpiredMFAPoll handles a poll-loop exit caused by the internal poll +// deadline elapsing, the caller cancelling the request, or the maximum number +// of poll attempts being exhausted. It clears the now-dead MFA state and directs +// the client back to entra_password so it can restart the flow, distinguishing a +// caller cancellation (AuthCancelled) from a wall-clock timeout (AuthNext). +func (b *Broker) endExpiredMFAPoll(ctx context.Context, session *session) (string, isAuthenticatedDataResponse) { + session.entraPasswordHash = "" + clearEntraMFAState(session) + session.nextAuthModes = []string{authmodes.EntraPassword} + if ctx.Err() != nil { + // The whole IsAuthenticated request was cancelled by the caller. + log.Noticef(context.Background(), "MFA poll cancelled for user %q", session.username) + return AuthCancelled, nil + } + log.Noticef(context.Background(), "MFA poll timed out for user %q", session.username) + return AuthNext, errorMessage{Message: "MFA approval timed out. Please try again."} +} + +func (b *Broker) entraMFACodeAuth(ctx context.Context, session *session, code string) (string, isAuthenticatedDataResponse) { + entraProvider, ok := providers.ProviderAs[himmelblau.EntraPasswordProvider](b.provider) + if !ok { + log.Error(context.Background(), "entra_mfa_code mode selected but provider does not support it") + return AuthDenied, unexpectedErrMsg("provider does not support Entra MFA") + } + + if session.mfaFlowActive == nil { + log.Error(context.Background(), "MFA code mode selected but no active MFA flow") + return AuthDenied, unexpectedErrMsg("no active MFA flow") + } + + deviceRegistrationData := b.cachedDeviceRegistrationData(session) + + oauthToken, err := entraProvider.AcquireTokenByMFAFlow( + ctx, b.cfg.clientID, b.cfg.issuerURL, session.username, + session.mfaFlowActive, code, 0, + deviceRegistrationData, + ) + if err != nil { + var mfaErr *himmelblau.MFAError + if errors.As(err, &mfaErr) && mfaErr.IsMFADenied() { + log.Noticef(context.Background(), "MFA code verification denied for user %q", session.username) + session.entraPasswordHash = "" + clearEntraMFAState(session) + return AuthDenied, errorMessage{Message: "MFA authentication was denied."} + } + if errors.As(err, &mfaErr) && mfaErr.IsMFARetryableCode() { + // An incorrect or expired one-time code: re-prompt for the code + // rather than discarding the flow and forcing password re-entry. + // The MFA flow remains valid on this path (libhimmelblau only + // advances flow.ctx/flow_token on success), so the next code + // submission reuses it. AuthRetry stays on the entra_mfa_code mode + // and is capped by maxAuthAttempts, so repeated wrong codes still + // end in denial. + log.Noticef(context.Background(), "Incorrect MFA code for user %q, re-prompting", session.username) + return AuthRetry, errorMessage{Message: "Incorrect or expired code. Please try again."} + } + log.Noticef(context.Background(), "MFA code verification failed for user %q: %v", session.username, err) + session.entraPasswordHash = "" + clearEntraMFAState(session) + // MFA flow state was cleared; direct the client back to entra_password + // so it can restart the flow rather than re-entering the dead code mode. + session.nextAuthModes = []string{authmodes.EntraPassword} + return AuthNext, errorMessage{Message: "MFA authentication failed. Please try again."} + } + + clearEntraMFAState(session) + return b.finishEntraAuth(ctx, session, oauthToken) +} + +func (b *Broker) finishEntraAuth(ctx context.Context, session *session, mfaToken *oauth2.Token) (string, isAuthenticatedDataResponse) { + // Ensure any cached password hash is cleared from memory on all exit paths. + defer func() { session.entraPasswordHash = "" }() + + // AcquireTokenByMFAFlow returns (nil, nil) only on a provider contract + // violation, but this is the trust boundary into the generic broker: guard + // against it so a misbehaving provider denies rather than panicking (and + // taking down the broker process) on the t.Extra dereference below. + if mfaToken == nil { + log.Error(context.Background(), "Entra MFA flow completed without returning a token") + return AuthDenied, unexpectedErrMsg("MFA flow returned no token") + } + + // handleIsAuthenticated runs in a goroutine; on cancellation IsAuthenticated + // returns AuthCancelled without awaiting it. If the (non-preemptible) MFA call + // completed but the request was cancelled in the meantime, stop here rather + // than registering a device and persisting a token + password file for an + // authentication the client already abandoned. + if ctx.Err() != nil { + log.Noticef(context.Background(), "Entra MFA succeeded but the request was cancelled; not persisting credentials for user %q", session.username) + return AuthCancelled, nil + } + + t := mfaToken + // Reuse the auth info loaded once at the start of the flow (entraPasswordAuth) + // rather than re-reading the token from disk. + oldAuthInfo := session.authInfo + + // The Entra MFA path does not produce a verified raw OIDC ID token for authd to + // persist. Carry over a cached RawIDToken from a previous login so we never + // replace it with an empty one. + var rawIDToken string + if oldAuthInfo != nil { + rawIDToken = oldAuthInfo.RawIDToken + } + + // The MFA token is issued for the Entra native API audience, so standard OIDC + // ID token verification (getUserInfo) would fail. Extract user info from the + // access token after verifying it, then cross-check it against the session + // username. + userInfo, err := b.userInfoFromTokenExtras(ctx, session, t) + if err != nil { + log.Errorf(context.Background(), "could not get user info: %s", err) + return AuthDenied, errorMessageForDisplay(err, "Could not get user info") + } + authInfo, access, data := b.populateAuthInfo(ctx, session, t, rawIDToken, &userInfo) + if authInfo == nil { + return access, data + } + + // Mark this token as having been obtained via the entra_password MFA flow so + // that returning logins refresh it through the Microsoft Broker App public + // refresh path (the liveness/revocation check) rather than the OIDC app + // refresh. + authInfo.ObtainedViaEntraPasswordAuth = true + + // Carry over device registration data from a previous login when we are not + // (re-)registering the device in this one. authInfo is built fresh from the + // MFA token, so without this the subsequent finishAuth would persist an empty + // value and silently discard a device that was registered earlier. For a + // first-time login (no cached token) it keeps its zero value, which is correct. + if oldAuthInfo != nil { + authInfo.DeviceRegistrationData = oldAuthInfo.DeviceRegistrationData + } + + var deviceRegistrationData []byte + if oldAuthInfo != nil { + deviceRegistrationData = oldAuthInfo.DeviceRegistrationData + } + cleanup, access, data := b.maybeRegisterDevice(ctx, session, authInfo, t, deviceRegistrationData) + defer cleanup() + if access != "" { + return access, data + } + + // Fetch groups. The MFA flow just performed a live provider verification, so a + // group-fetch failure here is not a liveness signal: fall back to cached groups + // on a returning auth, and only deny first-time logins that have no cached groups. + groups, err := b.getGroups(ctx, session, authInfo) + if err != nil { + if oldAuthInfo != nil { + log.Warningf(context.Background(), "Could not get groups: %v. Using cached groups.", err) + authInfo.UserInfo.Groups = oldAuthInfo.UserInfo.Groups + } else { + log.Errorf(context.Background(), "failed to get groups: %s", err) + return AuthDenied, errorMessageForDisplay(err, "Failed to retrieve groups from Microsoft Graph API") + } + } else { + authInfo.UserInfo.Groups = groups + } + + access, data = b.finishAuth(session, authInfo) + if access != AuthGranted { + return access, data + } + + // Store the pre-computed password hash for offline authentication. This runs + // after finishAuth so that a denial there cannot leave a password file on + // disk without a cached token (token-then-password matches the ordering of + // the device-auth flow). + if session.entraPasswordHash != "" { + if hashErr := password.StoreHashedPassword(session.entraPasswordHash, session.passwordPath); hashErr != nil { + log.Errorf(context.Background(), "Failed to store password hash: %v", hashErr) + return AuthDenied, unexpectedErrMsg("failed to store password") + } + session.entraPasswordHash = "" + + if msg, ok := data.(userInfoMessage); ok { + msg.Message = cachedPasswordMessage + data = msg + } + } + + return access, data +} + +// routeMFAInitError routes the AADSTS errors returned by InitiateEntraPasswordAuth +// (the MFA init step) to appropriate broker responses. +func (b *Broker) routeMFAInitError(mfaErr *himmelblau.MFAError, session *session) (string, isAuthenticatedDataResponse) { + switch mfaErr.AADSTS { + case 50053: + log.Noticef(context.Background(), "Account locked for user %q (AADSTS50053)", session.username) + return AuthDenied, errorMessage{Message: "Your account is locked. Please try again later or contact your administrator."} + case 50055: + log.Noticef(context.Background(), "Entra password expired for user %q", session.username) + return AuthDenied, errorMessage{Message: "Your password has expired. Please change it via the Entra portal."} + case 50057: + log.Noticef(context.Background(), "Login denied: user %q is disabled in %s (AADSTS50057)", session.username, b.provider.DisplayName()) + return AuthDenied, errorMessage{Message: fmt.Sprintf("Your user account is disabled in %s, please contact your administrator.", b.provider.DisplayName())} + case 50072, 50079, 50203: + log.Noticef(context.Background(), "MFA enrollment required for user %q (AADSTS%d)", session.username, mfaErr.AADSTS) + if b.cfg.flows.DeviceAuth { + session.nextAuthModes = []string{authmodes.Device, authmodes.DeviceQr} + return AuthNext, errorMessage{Message: "MFA registration required. Please complete setup using Device Authentication."} + } + return AuthDenied, errorMessage{Message: "MFA registration required, but Device Authentication is disabled. Please contact your administrator."} + case 16000: + log.Noticef(context.Background(), "Interactive authentication required for user %q (AADSTS16000)", session.username) + if b.cfg.flows.DeviceAuth { + session.nextAuthModes = []string{authmodes.Device, authmodes.DeviceQr} + return AuthNext, errorMessage{Message: "MFA registration required. Please complete setup using Device Authentication."} + } + return AuthDenied, errorMessage{Message: "MFA registration required, but Device Authentication is disabled. Please contact your administrator."} + case 50126: + log.Noticef(context.Background(), "Invalid credentials for user %q", session.username) + return AuthRetry, errorMessage{Message: "Incorrect password, please try again."} + case 50173: + log.Noticef(context.Background(), "Password changed remotely for user %q, invalidating cached credentials", session.username) + b.invalidateCachedCredentials(session) + session.nextAuthModes = reauthModes + return AuthNext, errorMessage{Message: "Your password was changed remotely. Please re-authenticate."} + case 53003: + log.Noticef(context.Background(), "Conditional Access blocked sign-in for user %q (AADSTS53003)", session.username) + return AuthDenied, errorMessage{Message: "Access was blocked by your organization's Conditional Access policies. Please contact your administrator."} + default: + if mfaErr.IsMFARequired() { + // The native password MFA flow could not be set up; redirect to Device + // Authentication which handles MFA via a separate flow. + log.Noticef(context.Background(), "MFA required for user %q; redirecting to Device Authentication", session.username) + if b.cfg.flows.DeviceAuth { + session.nextAuthModes = []string{authmodes.Device, authmodes.DeviceQr} + return AuthNext, errorMessage{Message: "MFA is required. Please complete authentication using Device Authentication."} + } + return AuthDenied, errorMessage{Message: "MFA is required but Device Authentication is disabled. Please contact your administrator."} + } + log.Errorf(context.Background(), "Unhandled AADSTS error %d: %s", mfaErr.AADSTS, mfaErr.Message) + return AuthDenied, unexpectedErrMsg(mfaErr.Error()) + } +} + +func (b *Broker) invalidateCachedCredentials(session *session) { + for _, path := range []string{session.passwordPath, session.tokenPath} { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Warningf(context.Background(), "Failed to remove cached credential %q: %v", path, err) + } + } +} + +func isAADSTSGrantRevokedError(err *oauth2.RetrieveError) bool { + if err == nil || err.ErrorCode != "invalid_grant" { + return false + } + return strings.HasPrefix(err.ErrorDescription, "AADSTS50173:") +} + +// isFIDOMethod returns true if the MFA method is a FIDO/security key method. +func isFIDOMethod(method string) bool { + method = strings.ToLower(method) + return strings.Contains(method, "fido") || strings.Contains(method, "webauthn") || strings.Contains(method, "security_key") +} + +// isPromptMethod reports whether the MFA method requires the user to enter a +// code (TOTP, SMS OTP, access-pass, etc.) rather than approve a push +// notification or answer a phone call. +// +// Method identifiers and their UX are derived from libhimmelblau's +// auth.rs MFA branch (third_party/libhimmelblau/src/auth.rs around L3340-L3360): +// - AccessPass, PhoneAppOTP, OneWaySMS, ConsolidatedTelephony → user types a code (prompt) +// - PhoneAppNotification, CompanionAppsNotification → push approval (no prompt) +// - TwoWayVoiceMobile, TwoWayVoiceAlternateMobile, TwoWayVoiceOffice → answer a phone call (no prompt) +// - FidoKey → handled separately via isFIDOMethod +func isPromptMethod(method string) bool { + switch method { + case "AccessPass", "PhoneAppOTP", "OneWaySMS", "ConsolidatedTelephony": + return true + } + return false +} + +// isPollMethod reports whether the MFA method is approved out of band (push +// notification or phone call), in which case the broker polls for completion +// instead of prompting the user for a code. See isPromptMethod for where the +// method identifiers come from. +func isPollMethod(method string) bool { + switch method { + case "PhoneAppNotification", "CompanionAppsNotification", + "TwoWayVoiceMobile", "TwoWayVoiceAlternateMobile", "TwoWayVoiceOffice": + return true + } + return false +} + func (b *Broker) finishAuth(session *session, authInfo *token.AuthCachedInfo) (string, isAuthenticatedDataResponse) { if b.cfg.shouldRegisterOwner() { if err := b.cfg.registerOwner(b.cfg.ConfigFile, authInfo.UserInfo.Name); err != nil { @@ -1442,8 +2116,18 @@ func (b *Broker) EndSession(sessionID string) error { } // Checks if there is a isAuthenticated call running for this session and cancels it before ending the session. + // When a poll is in flight, cancelling lets that goroutine free the MFA flow + // as it unwinds; otherwise we free it here. These two paths can race (the + // finishing goroutine may nil isAuthenticating via CancelIsAuthenticated just + // as we read our own session copy), so both could call FreeMFAFlowState on the + // same pointer. That is safe: FreeMFAFlowState takes MFAFlowState.mu and nils + // its release callback, so the underlying C free runs exactly once and a + // second call is a no-op. Sessions are stored by value, so there is no shared + // write to mfaFlowActive itself (confirmed race-clean under `go test -race`). if session.isAuthenticating != nil { b.CancelIsAuthenticated(sessionID) + } else { + himmelblau.FreeMFAFlowState(session.mfaFlowActive) } b.currentSessionsMu.Lock() @@ -1582,21 +2266,107 @@ func (b *Broker) updateSession(sessionID string, session session) error { return nil } +// refreshEntraPasswordToken refreshes an Entra password + MFA token for the +// liveness/revocation check on a returning login. The provider performs a public +// refresh (no client_secret) as the Microsoft Broker App; on success the rotated +// refresh token replaces the cached one (kept fresh on each login, like the +// device-auth refresh). Errors are returned unwrapped so the caller classifies them +// with the same checks it uses for device-auth (IsUserDisabledError → AADSTS50057, +// IsTokenExpiredError → AADSTS50173, isAADSTSGrantRevokedError, net.Error → offline). +func (b *Broker) refreshEntraPasswordToken(ctx context.Context, session *session, oldToken *token.AuthCachedInfo) (*token.AuthCachedInfo, error) { + ep, ok := providers.ProviderAs[himmelblau.EntraPasswordProvider](b.provider) + if !ok { + // The token was obtained via the entra_password flow, so the provider that + // issued it must implement EntraPasswordProvider. If it no longer does, the + // deployment is misconfigured: fail the login rather than skipping the + // liveness/revocation check, which would let a deleted/disabled user keep + // logging in with the cached token. + return nil, fmt.Errorf("provider does not implement EntraPasswordProvider; cannot refresh entra_password token for user %q", oldToken.UserInfo.Name) + } + refreshCtx, cancel := context.WithTimeout(ctx, maxRequestDuration) + defer cancel() + newTok, err := ep.RefreshEntraPasswordToken(refreshCtx, b.cfg.issuerURL, oldToken.Token.RefreshToken) + if err != nil { + return oldToken, err + } + refreshed := *oldToken + tokenCopy := *oldToken.Token + refreshed.Token = &tokenCopy + refreshed.Token.RefreshToken = newTok.RefreshToken + oldToken = &refreshed + cacheRotatedToken := func(reason string) { + if cacheErr := token.CacheAuthInfo(session.tokenPath, oldToken); cacheErr != nil { + log.Errorf(context.Background(), "Failed to store rotated refresh token after %s: %s", reason, cacheErr) + } + } + + // Refresh the cached user info from the verified refreshed access token's + // claims. Keep the cached gecos if the refreshed token omits one, and keep + // groups (those are refreshed separately by getGroups). Verification can hit + // the network itself (JWKS fetch), so give it its own request timeout rather + // than sharing whatever remains after the token refresh call. + verifyCtx, verifyCancel := context.WithTimeout(ctx, maxRequestDuration) + defer verifyCancel() + if err := ep.VerifyAccessToken(verifyCtx, b.cfg.issuerURL, newTok.AccessToken); err != nil { + // Refresh-token rotation has already succeeded server-side. Preserve the + // rotated token even though this login is denied, otherwise a local issue + // such as clock skew can strand the cache with a dead refresh token. + cacheRotatedToken("verification failure") + return oldToken, fmt.Errorf("access token verification failed: %w", err) + } + userInfo, err := ep.UserInfoFromAccessToken(newTok.AccessToken) + if err != nil { + cacheRotatedToken("user info extraction failure") + return oldToken, fmt.Errorf("could not refresh user info from the refreshed Entra token: %w", err) + } + // getUserInfo (the device-auth refresh path) re-checks this on every refresh, + // not just on first login; do the same here so a refreshed Entra token can't + // silently swap the cached identity. + if err := b.provider.VerifyUsername(session.username, userInfo.Name); err != nil { + cacheRotatedToken("username verification failure") + return oldToken, fmt.Errorf("username verification failed: %w", err) + } + if !filepath.IsAbs(userInfo.Home) { + userInfo.Home = filepath.Join(b.cfg.homeBaseDir, userInfo.Home) + } + if userInfo.Gecos == "" { + userInfo.Gecos = oldToken.UserInfo.Gecos + } + userInfo.Groups = oldToken.UserInfo.Groups + oldToken.UserInfo = userInfo + + return oldToken, nil +} + // refreshToken refreshes the OAuth2 token and returns the updated AuthCachedInfo. func (b *Broker) refreshToken(ctx context.Context, session *session, oldToken *token.AuthCachedInfo) (*token.AuthCachedInfo, error) { timeoutCtx, cancel := context.WithTimeout(ctx, maxRequestDuration) defer cancel() - // set cached token expiry time to one hour in the past - // this makes sure the token is refreshed even if it has not 'actually' expired - oldToken.Token.Expiry = time.Now().Add(-time.Hour) - oauthToken, err := session.oauth2Config.TokenSource(timeoutCtx, oldToken.Token).Token() + // Build a token carrying only the refresh token, like refreshEntraPasswordToken + // does: oauth2.Token.Valid() requires a non-empty AccessToken, so omitting it + // forces TokenSource to hit the token endpoint even if the cached token has not + // actually expired, without mutating the caller's cached oldToken. + oauthToken, err := session.oauth2Config.TokenSource(timeoutCtx, &oauth2.Token{RefreshToken: oldToken.Token.RefreshToken}).Token() if err != nil { return nil, err } + refreshed := *oldToken + tokenCopy := *oldToken.Token + refreshed.Token = &tokenCopy + if oauthToken.RefreshToken != "" { + refreshed.Token.RefreshToken = oauthToken.RefreshToken + } + oldToken = &refreshed + cacheRotatedToken := func(reason string) { + if cacheErr := token.CacheAuthInfo(session.tokenPath, oldToken); cacheErr != nil { + log.Errorf(context.Background(), "Failed to store rotated refresh token after %s: %s", reason, cacheErr) + } + } - // Update the raw ID token - rawIDToken, ok := oauthToken.Extra("id_token").(string) - if !ok { + // Update the raw ID token. Treat an absent, null, or empty id_token the same: + // keep the cached one rather than storing an empty value. + rawIDToken, _ := oauthToken.Extra("id_token").(string) + if rawIDToken == "" { log.Debug(context.Background(), "refreshed token does not contain an ID token, keeping the old one") rawIDToken = oldToken.RawIDToken } @@ -1611,7 +2381,11 @@ func (b *Broker) refreshToken(ctx context.Context, session *session, oldToken *t t.UserInfo, err = b.getUserInfo(ctx, session, oauthToken, rawIDToken, true) if err != nil { - return nil, err + // Token refresh has already succeeded server-side. Preserve a rotated + // refresh token even if a later local validation step fails, otherwise the + // cache can be stranded with a refresh token the provider already invalidated. + cacheRotatedToken("user info refresh failure") + return oldToken, err } if t.UserInfo.Gecos == "" { t.UserInfo.Gecos = oldToken.UserInfo.Gecos @@ -1628,14 +2402,30 @@ func (b *Broker) refreshToken(ctx context.Context, session *session, oldToken *t // Note that verifying the ID token requires a working network connection to the provider's JWKs endpoint, // so make sure to only call this function if the session is online. func (b *Broker) getUserInfo(ctx context.Context, session *session, token *oauth2.Token, rawIDToken string, isRefresh bool) (info.User, error) { - idToken, err := session.oidcServer.Verifier(&b.oidcCfg).Verify(ctx, rawIDToken) - if err != nil { - return info.User{}, fmt.Errorf("could not verify token: %w", err) + var ( + claims info.Claimer + userInfo info.User + idToken *oidc.IDToken + err error + ) + + if rawIDToken == "" { + claims, err = session.oidcServer.UserInfo(ctx, oauth2.StaticTokenSource(token)) + if err != nil { + return info.User{}, fmt.Errorf("could not get user info from UserInfo endpoint: %w", err) + } + } else { + var verifyErr error + idToken, verifyErr = session.oidcServer.Verifier(&b.oidcCfg).Verify(ctx, rawIDToken) + if verifyErr != nil { + return info.User{}, fmt.Errorf("could not verify token: %w", verifyErr) + } + claims = idToken } - userInfo, err := b.provider.GetUserInfo(idToken, isRefresh) + userInfo, err = b.provider.GetUserInfo(claims, isRefresh) var missingClaimErr *providerErrors.MissingClaimError - if errors.As(err, &missingClaimErr) { + if rawIDToken != "" && errors.As(err, &missingClaimErr) { // The ID token is missing a required claim. Try fetching the claims from the UserInfo endpoint. log.Infof(context.Background(), "ID token is missing claim %q. Fetching claims from UserInfo endpoint.", missingClaimErr.Claim) var userInfoClaims info.Claimer @@ -1660,8 +2450,7 @@ func (b *Broker) getUserInfo(ctx context.Context, session *session, token *oauth // Merge ID token claims with UserInfo claims. // UserInfo claims override ID token claims for the same key. - var claims info.Claimer - claims, err = info.NewMergedClaimer(idToken, userInfoClaims) + claims, err = info.NewMergedClaimer(claims, userInfoClaims) if err != nil { return info.User{}, fmt.Errorf("could not merge ID token and UserInfo endpoint claims: %w", err) } @@ -1683,6 +2472,44 @@ func (b *Broker) getUserInfo(ctx context.Context, session *session, token *oauth return userInfo, nil } +// maybeRegisterDevice registers the device when the provider supports it and +// register_device is enabled, updating and persisting authInfo.DeviceRegistrationData. +// regToken is the token used to perform the registration; existingData is any +// previously stored device-registration data, passed to avoid re-registering. +// +// The returned cleanup must be deferred by the caller until AFTER group retrieval, +// because the Graph token exchange depends on the registration state that cleanup +// releases. cleanup is always non-nil (a no-op when nothing was registered), so the +// caller can defer it unconditionally. When access is non-empty the caller must +// return (access, data); an empty access means "proceed". +func (b *Broker) maybeRegisterDevice(ctx context.Context, session *session, authInfo *token.AuthCachedInfo, regToken *oauth2.Token, existingData []byte) (cleanup func(), access string, data isAuthenticatedDataResponse) { + cleanup = func() {} + + dr, ok := providers.ProviderAs[providers.DeviceRegisterer](b.provider) + if !ok || !b.cfg.registerDevice { + return cleanup, "", nil + } + + var err error + authInfo.DeviceRegistrationData, cleanup, err = dr.MaybeRegisterDevice(ctx, regToken, + session.username, + b.cfg.issuerURL, + existingData, + ) + if err != nil { + log.Errorf(context.Background(), "error registering device: %s", err) + return func() {}, AuthDenied, errorMessage{Message: "Error registering device"} + } + + // Store the auth info, so that the device registration data is not lost if the login fails after this point. + if err := token.CacheAuthInfo(session.tokenPath, authInfo); err != nil { + log.Errorf(context.Background(), "Failed to store token: %s", err) + return cleanup, AuthDenied, unexpectedErrMsg("failed to store token") + } + + return cleanup, "", nil +} + func (b *Broker) getGroups(ctx context.Context, session *session, t *token.AuthCachedInfo) ([]info.Group, error) { if session.isOffline { return nil, errors.New("session is in offline mode") @@ -1692,13 +2519,16 @@ func (b *Broker) getGroups(ctx context.Context, session *session, t *token.AuthC if !ok { return nil, nil } - + // A cached token that carries device-registration data has a PRT that must be + // exchanged for a Graph-scoped token (strategy 2). Derive this from the + // presence of that data rather than tracking a separate persisted flag. return gf.GetGroups(ctx, b.cfg.clientID, b.cfg.issuerURL, t.Token, t.ProviderMetadata, t.DeviceRegistrationData, + len(t.DeviceRegistrationData) > 0, ) } diff --git a/authd-oidc-brokers/internal/broker/broker_test.go b/authd-oidc-brokers/internal/broker/broker_test.go index 0d5b15f420..b5a5e136e6 100644 --- a/authd-oidc-brokers/internal/broker/broker_test.go +++ b/authd-oidc-brokers/internal/broker/broker_test.go @@ -1,6 +1,7 @@ package broker_test import ( + "context" "encoding/json" "errors" "fmt" @@ -8,10 +9,12 @@ import ( "net/http" "os" "path/filepath" + "reflect" "slices" "strings" "testing" "time" + "unsafe" "github.com/canonical/authd/authd-oidc-brokers/internal/broker" "github.com/canonical/authd/authd-oidc-brokers/internal/broker/authmodes" @@ -20,15 +23,235 @@ import ( "github.com/canonical/authd/authd-oidc-brokers/internal/password" providerErrors "github.com/canonical/authd/authd-oidc-brokers/internal/providers/errors" "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/himmelblau" "github.com/canonical/authd/authd-oidc-brokers/internal/testutils" + "github.com/canonical/authd/authd-oidc-brokers/internal/token" "github.com/canonical/authd/internal/testutils/golden" "github.com/canonical/authd/log" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "gopkg.in/yaml.v3" ) var defaultIssuerURL string +func newTrackedMFAFlowState(release func()) *himmelblau.MFAFlowState { + flow := &himmelblau.MFAFlowState{} + releaseField := reflect.ValueOf(flow).Elem().FieldByName("release") + //nolint:gosec // G103: unsafe pointer required to set unexported field for testing purposes only. + reflect.NewAt(releaseField.Type(), unsafe.Pointer(releaseField.UnsafeAddr())).Elem().Set(reflect.ValueOf(release)) + return flow +} + +type mockEntraPasswordProvider struct { + *testutils.MockProvider + flowState *himmelblau.MFAFlowState + challengeInfo *himmelblau.MFAChallengeInfo + mfaTokenResult *oauth2.Token + initErr error + recordedPollAttempts []int + recordedChallengeData []string + refreshResult *oauth2.Token // returned by RefreshEntraPasswordToken (defaults to a rotated token) + refreshErr error // when set, RefreshEntraPasswordToken returns it (e.g. AADSTS50057) + refreshDelay time.Duration + refreshCtxDeadline time.Time + verifyCtxDeadline time.Time + userDisabledErrorCode string // when set, IsUserDisabledError matches an *oauth2.RetrieveError with this code + verifyAccessTokenErr error // when set, VerifyAccessToken returns it (signature verification failure) + accessTokenUserInfo *info.User // when set, UserInfoFromAccessToken returns this user info + userInfoFromTokenErr error // when set, UserInfoFromAccessToken returns this error +} + +func (p *mockEntraPasswordProvider) VerifyAccessToken(ctx context.Context, _, _ string) error { + if deadline, ok := ctx.Deadline(); ok { + p.verifyCtxDeadline = deadline + } + return p.verifyAccessTokenErr +} + +func (p *mockEntraPasswordProvider) UserInfoFromAccessToken(_ string) (info.User, error) { + if p.userInfoFromTokenErr != nil { + return info.User{}, p.userInfoFromTokenErr + } + if p.accessTokenUserInfo != nil { + return *p.accessTokenUserInfo, nil + } + return info.NewUser("test-user@email.com", "", "saved-user-id", "", "test-user", nil), nil +} + +type mockProviderWithEntraModes struct { + *testutils.MockProvider +} + +func (p *mockProviderWithEntraModes) SupportedOnlineAuthModes() []string { + return []string{authmodes.Device, authmodes.DeviceQr, authmodes.EntraPassword} +} + +type mockGrantRevokedProvider struct { + *mockProviderWithEntraModes +} + +func (p *mockGrantRevokedProvider) IsTokenExpiredError(err *oauth2.RetrieveError) bool { + return err != nil && err.ErrorCode == "invalid_grant" && strings.HasPrefix(err.ErrorDescription, "AADSTS50173:") +} + +var mockDeviceRegistrationData = []byte(`{"device_id":"test-device-id","cert_key":"Y2VydA==","transport_key":"dHJhbnNwb3J0","auth_value":"test-auth-value","tpm_machine_key":"dHBtLW1hY2hpbmUta2V5"}`) + +func (p *mockEntraPasswordProvider) InitiateEntraPasswordAuth(_ context.Context, _, _ string, _, _ string, _ []byte, _ bool) (*himmelblau.MFAFlowState, *himmelblau.MFAChallengeInfo, error) { + if p.initErr != nil { + return nil, nil, p.initErr + } + return p.flowState, p.challengeInfo, nil +} + +func (p *mockEntraPasswordProvider) AcquireTokenByMFAFlow(_ context.Context, _, _ string, _ string, _ *himmelblau.MFAFlowState, authData string, pollAttempt int, _ []byte) (*oauth2.Token, error) { + p.recordedPollAttempts = append(p.recordedPollAttempts, pollAttempt) + p.recordedChallengeData = append(p.recordedChallengeData, authData) + if p.mfaTokenResult == nil { + return nil, fmt.Errorf("missing MFA token result") + } + return p.mfaTokenResult, nil +} + +func (p *mockEntraPasswordProvider) RefreshEntraPasswordToken(ctx context.Context, _, _ string) (*oauth2.Token, error) { + if deadline, ok := ctx.Deadline(); ok { + p.refreshCtxDeadline = deadline + } + if p.refreshDelay > 0 { + timer := time.NewTimer(p.refreshDelay) + defer timer.Stop() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + } + } + if p.refreshErr != nil { + return nil, p.refreshErr + } + tok := p.refreshResult + if tok == nil { + // Default: an active user — a successful refresh that rotates the refresh token. + tok = &oauth2.Token{AccessToken: "mock-access-token", RefreshToken: "mock-rotated-refresh-token"} + } + return tok, nil +} + +// IsUserDisabledError lets the mock stand in as a providers.UserDisabledChecker so +// broker tests can exercise the refresh-rejection classification. It matches on a +// sentinel error code, mirroring testutils.MockUserDisabledCheckerProvider; the real +// AADSTS50057 detection is covered by the provider-level tests. +func (p *mockEntraPasswordProvider) IsUserDisabledError(err *oauth2.RetrieveError) bool { + return p.userDisabledErrorCode != "" && err != nil && err.ErrorCode == p.userDisabledErrorCode +} + +func (p *mockEntraPasswordProvider) IsTokenForDeviceRegistration(authInfo *token.AuthCachedInfo) bool { + return authInfo != nil && len(authInfo.DeviceRegistrationData) > 0 +} + +func (p *mockEntraPasswordProvider) MaybeRegisterDevice(_ context.Context, _ *oauth2.Token, _ string, _ string, oldData []byte) ([]byte, func(), error) { + if len(oldData) > 0 { + return oldData, func() {}, nil + } + return mockDeviceRegistrationData, func() {}, nil +} + +// mockMFADeniedProvider simulates MFA push notification being denied by the user. +type mockMFADeniedProvider struct { + *mockEntraPasswordProvider +} + +// mockDeviceRegistrationFailProvider simulates a first-time login where device +// registration fails at the network level (e.g. no connectivity to +// enterpriseregistration.windows.net). +type mockDeviceRegistrationFailProvider struct { + *mockEntraPasswordProvider +} + +func (p *mockDeviceRegistrationFailProvider) MaybeRegisterDevice(_ context.Context, _ *oauth2.Token, _ string, _ string, oldData []byte) ([]byte, func(), error) { + if len(oldData) > 0 { + // Re-use existing registration — failure is only on first registration. + return oldData, func() {}, nil + } + return nil, func() {}, fmt.Errorf("failed to enroll device: Request failed: error sending request for url (https://enterpriseregistration.windows.net/EnrollmentServer/device/?api-version=2.0)") +} + +func (p *mockMFADeniedProvider) AcquireTokenByMFAFlow(_ context.Context, _, _ string, _ string, _ *himmelblau.MFAFlowState, _ string, _ int, _ []byte) (*oauth2.Token, error) { + // Simulate the user denying the push notification: ACQUIRE_TOKEN_FAILED without AADSTS. + return nil, &himmelblau.MFAError{Category: himmelblau.MFAErrorDenied, Message: "MFA denied by user"} +} + +// mockMFATimeoutProvider simulates MFA poll continuing until max attempts are exhausted. +type mockMFATimeoutProvider struct { + *mockEntraPasswordProvider +} + +func (p *mockMFATimeoutProvider) AcquireTokenByMFAFlow(_ context.Context, _, _ string, _ string, _ *himmelblau.MFAFlowState, _ string, _ int, _ []byte) (*oauth2.Token, error) { + // Always return poll-continue so the loop exhausts max attempts. + return nil, &himmelblau.MFAError{Category: himmelblau.MFAErrorPollContinue, Message: "MFA poll continue"} +} + +// mockMFAWrongCodeThenSuccessProvider simulates an incorrect or expired +// one-time code on the first code submission followed by a correct code on the +// second. libhimmelblau reports a wrong code as an MFAInvalidCode error (which +// authd maps to MFAErrorRetryableCode via the C enum code), while leaving the +// flow intact. This is what production consumers see. +type mockMFAWrongCodeThenSuccessProvider struct { + *mockEntraPasswordProvider + codeAttempts int +} + +func (p *mockMFAWrongCodeThenSuccessProvider) AcquireTokenByMFAFlow(_ context.Context, _, _ string, _ string, _ *himmelblau.MFAFlowState, authData string, _ int, _ []byte) (*oauth2.Token, error) { + p.recordedChallengeData = append(p.recordedChallengeData, authData) + p.codeAttempts++ + if p.codeAttempts == 1 { + return nil, &himmelblau.MFAError{ + Category: himmelblau.MFAErrorRetryableCode, + Message: "AuthResponse indicates failure: Your sign-in was blocked by a One-Time Passcode mismatch.", + } + } + return p.mfaTokenResult, nil +} + +// mockMFANilTokenProvider violates the provider contract by returning (nil, nil) +// from AcquireTokenByMFAFlow, exercising the broker's defensive nil-token guard +// (a misbehaving provider must deny, not panic the broker). +type mockMFANilTokenProvider struct { + *mockEntraPasswordProvider +} + +func (p *mockMFANilTokenProvider) AcquireTokenByMFAFlow(_ context.Context, _, _ string, _ string, _ *himmelblau.MFAFlowState, _ string, _ int, _ []byte) (*oauth2.Token, error) { + return nil, nil +} + +// mockMFAAlwaysWrongCodeProvider simulates every submitted one-time code being +// incorrect or expired (MFAErrorRetryableCode), while the MFA flow itself stays +// valid. This is used to exercise the maxAuthAttempts lockout path: repeated +// retryable wrong codes must eventually return AuthDeniedMaxTries and release +// the in-progress MFA flow. +type mockMFAAlwaysWrongCodeProvider struct { + *mockEntraPasswordProvider +} + +func (p *mockMFAAlwaysWrongCodeProvider) AcquireTokenByMFAFlow(_ context.Context, _, _ string, _ string, _ *himmelblau.MFAFlowState, authData string, _ int, _ []byte) (*oauth2.Token, error) { + p.recordedChallengeData = append(p.recordedChallengeData, authData) + return nil, &himmelblau.MFAError{ + Category: himmelblau.MFAErrorRetryableCode, + Message: "AuthResponse indicates failure: Your sign-in was blocked by a One-Time Passcode mismatch.", + } +} + +// newMFATokenResult builds an oauth2.Token with access-token-style extras like +// himmelblau.AcquireTokenByMFAFlow returns. Broker identity binding must come +// from UserInfoFromAccessToken after VerifyAccessToken, not from these extras. +func newMFATokenResult(t *oauth2.Token) *oauth2.Token { + return t.WithExtra(map[string]any{ + "preferred_username": "test-user@email.com", + "sub": "saved-user-id", + "name": "test-user", + }) +} + func requireIssuerCacheTree(t *testing.T, issuerDir string, want map[string]string) { t.Helper() @@ -131,6 +354,52 @@ func TestNew(t *testing.T) { } } +// TestNewRejectsUnusableEntraPasswordWithoutGroupSource verifies that New fails +// fast when entra_password is enabled but can't retrieve groups from Microsoft +// Graph (no device registration, no client secret) — rather than starting +// successfully and only failing once a user logs in. +func TestNewRejectsUnusableEntraPasswordWithoutGroupSource(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + deviceAuthEnabled bool + registerDevice bool + clientSecret string + + wantErr bool + }{ + "Error_when_entra_password_is_the_only_flow_and_unusable": {wantErr: true}, + "Error_when_device_auth_is_also_enabled_but_entra_password_is_still_unusable": { + deviceAuthEnabled: true, + wantErr: true, + }, + "No_error_when_device_registration_makes_it_usable": {registerDevice: true}, + "No_error_when_a_client_secret_makes_it_usable": {clientSecret: "test-client-secret"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + bCfg := &broker.Config{DataDir: t.TempDir()} + bCfg.Init() + bCfg.SetIssuerURL(defaultIssuerURL) + bCfg.SetClientID("test-client-id") + bCfg.SetFlows(tc.deviceAuthEnabled, true) + bCfg.SetRegisterDevice(tc.registerDevice) + bCfg.SetClientSecret(tc.clientSecret) + + provider := &mockEntraPasswordProvider{MockProvider: &testutils.MockProvider{}} + b, err := broker.New(*bCfg, broker.LatestAPIVersion, broker.WithCustomProvider(provider)) + if tc.wantErr { + require.Error(t, err, "New should have returned an error") + return + } + require.NoError(t, err, "New should not have returned an error") + require.NotNil(t, b, "New should have returned a non-nil broker") + }) + } +} + func TestNewSession(t *testing.T) { t.Parallel() @@ -501,11 +770,6 @@ func TestGetAuthenticationModes(t *testing.T) { deviceAuthUnsupported: true, wantModes: []string{authmodes.Password}, }, - "Get_only_device_auth_if_token_exists_but_checking_if_it_is_for_device_registration_fails": { - token: &tokenOptions{noIsForDeviceRegistration: true}, - providerSupportsDeviceRegistration: true, - wantModes: []string{authmodes.DeviceQr}, - }, // === Change password session === "Get_only_password_if_token_exists_and_session_is_for_changing_password": { @@ -780,9 +1044,9 @@ func TestIsAuthenticated(t *testing.T) { firstSecret string badFirstKey bool getGroupsFails bool - getGroupsFunc func() ([]info.Group, error) useOldNameForSecretField bool groupsReturnedByProvider []info.Group + getGroupsFunc func() ([]info.Group, error) customHandlers map[string]testutils.EndpointHandler address string @@ -899,28 +1163,28 @@ func TestIsAuthenticated(t *testing.T) { "Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode": { firstMode: authmodes.Password, token: &tokenOptions{refreshTokenExpired: true}, - wantNextAuthModes: []string{authmodes.Device, authmodes.DeviceQr}, + wantNextAuthModes: []string{authmodes.EntraPassword, authmodes.Device, authmodes.DeviceQr}, wantSecondCall: true, secondMode: authmodes.DeviceQr, }, "Authenticating_with_password_when_refresh_token_is_expired_due_to_inactivity_results_in_device_auth_as_next_mode": { firstMode: authmodes.Password, token: &tokenOptions{refreshTokenInactiveExpired: true}, - wantNextAuthModes: []string{authmodes.Device, authmodes.DeviceQr}, + wantNextAuthModes: []string{authmodes.EntraPassword, authmodes.Device, authmodes.DeviceQr}, wantSecondCall: true, secondMode: authmodes.DeviceQr, }, "Authenticating_with_password_when_refresh_token_is_expired_due_to_ca_sign_in_frequency_results_in_device_auth_as_next_mode": { firstMode: authmodes.Password, token: &tokenOptions{refreshTokenStale: true}, - wantNextAuthModes: []string{authmodes.Device, authmodes.DeviceQr}, + wantNextAuthModes: []string{authmodes.EntraPassword, authmodes.Device, authmodes.DeviceQr}, wantSecondCall: true, secondMode: authmodes.DeviceQr, }, "Authenticating_with_password_when_no_refresh_token_results_in_device_auth_as_next_mode": { firstMode: authmodes.Password, token: &tokenOptions{noRefreshToken: true}, - wantNextAuthModes: []string{authmodes.Device, authmodes.DeviceQr}, + wantNextAuthModes: []string{authmodes.EntraPassword, authmodes.Device, authmodes.DeviceQr}, wantSecondCall: true, secondMode: authmodes.DeviceQr, }, @@ -937,6 +1201,12 @@ func TestIsAuthenticated(t *testing.T) { token: &tokenOptions{}, forceAccessCheckWithProvider: true, }, + // Note: the entra_password group-fetch fallback (a returning login whose + // liveness refresh succeeds but whose group fetch fails must use cached + // groups, not deny) is covered by the dedicated + // TestIsAuthenticatedPasswordEntraTokenFallsBackToCachedGroupsOnGroupFetchError, + // which uses a provider that implements EntraPasswordProvider so the refresh + // path is actually exercised rather than the misconfiguration no-op. "Extra_groups_configured": { firstMode: authmodes.Password, token: &tokenOptions{}, @@ -1218,7 +1488,7 @@ func TestIsAuthenticated(t *testing.T) { getGroupsFunc: func() ([]info.Group, error) { return nil, &providerErrors.RetryWithDeviceAuthError{Err: errors.New("token acquisition failed")} }, - wantNextAuthModes: []string{authmodes.Device, authmodes.DeviceQr}, + wantNextAuthModes: []string{authmodes.EntraPassword, authmodes.Device, authmodes.DeviceQr}, }, } for name, tc := range tests { @@ -1850,167 +2120,1880 @@ func TestEndSession(t *testing.T) { require.NoError(t, err, "EndSession should not have returned an error when ending an existent session") } -func TestUserPreCheck(t *testing.T) { +func TestEndSessionReleasesPendingMFAFlow(t *testing.T) { t.Parallel() - tests := map[string]struct { - username string - allowedSuffixes []string - homePrefix string - }{ - "Successfully_allow_username_with_matching_allowed_suffix": { - username: "user@allowed", - allowedSuffixes: []string{"@allowed"}}, - "Successfully_allow_username_that_matches_at_least_one_allowed_suffix": { - username: "user@allowed", - allowedSuffixes: []string{"@other", "@something", "@allowed"}, - }, - "Successfully_allow_username_if_suffix_is_allow_all": { - username: "user@doesnotmatter", - allowedSuffixes: []string{"*"}, - }, - "Successfully_allow_username_if_suffix_has_asterisk": { - username: "user@allowed", - allowedSuffixes: []string{"*@allowed"}, - }, - "Successfully_allow_username_ignoring_empty_string_in_config": { - username: "user@allowed", - allowedSuffixes: []string{"@anothersuffix", "", "@allowed"}, - }, - "Return_userinfo_with_correct_homedir_after_precheck": { - username: "user@allowed", - allowedSuffixes: []string{"@allowed"}, - homePrefix: "/home/allowed/", - }, - - "Empty_userinfo_if_username_does_not_match_allowed_suffix": { - username: "user@notallowed", - allowedSuffixes: []string{"@allowed"}, - }, - "Empty_userinfo_if_username_does_not_match_any_of_the_allowed_suffixes": { - username: "user@notallowed", - allowedSuffixes: []string{"@other", "@something", "@allowed", ""}, - }, - "Empty_userinfo_if_no_allowed_suffixes_are_provided": { - username: "user@allowed", - }, - "Empty_userinfo_if_allowed_suffixes_has_only_empty_string": { - username: "user@allowed", - allowedSuffixes: []string{""}, + released := 0 + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: newTrackedMFAFlowState(func() { released++ }), + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request in Microsoft Authenticator", + Method: "PhoneAppNotification", + PollingIntervalMs: 5000, + MaxPollAttempts: 10, }, } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - b := newBrokerForTests(t, &brokerForTestConfig{ - issuerURL: defaultIssuerURL, - homeBaseDir: tc.homePrefix, - allowedSSHSuffixes: tc.allowedSuffixes, - }) + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) - got, err := b.UserPreCheck(tc.username) - require.NoError(t, err, "UserPreCheck should not have returned an error") + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) - golden.CheckOrUpdate(t, got) - }) - } + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, 0, released, "MFA flow should still be active before ending the session") + + err = b.EndSession(sessionID) + require.NoError(t, err) + require.Equal(t, 1, released, "EndSession should release any pending MFA flow state") } -func TestNormalizedIssuer(t *testing.T) { +func TestIsAuthenticatedEntraMFAWaitStartsPollingAtOne(t *testing.T) { t.Parallel() - tests := map[string]struct { - issuerURL string - want string - }{ - "HTTP_issuerURL": {issuerURL: "http://example.com", want: "example.com"}, - "HTTPS_issuerURL": {issuerURL: "https://example.com", want: "example.com"}, - "IssuerURL_with_path": {issuerURL: "https://example.com/tenant/v2.0", want: "example.com_tenant_v2.0"}, - "IssuerURL_with_port": {issuerURL: "https://example.com:8080", want: "example.com_8080"}, - "IssuerURL_with_port_and_path": {issuerURL: "https://example.com:8080/path", want: "example.com_8080_path"}, - "IssuerURL_with_IP_address": {issuerURL: "https://127.0.0.1", want: "127.0.0.1"}, - "IssuerURL_with_IP_address_and_port": {issuerURL: "https://127.0.0.1:8080", want: "127.0.0.1_8080"}, - "IssuerURL_without_scheme": {issuerURL: "example.com", want: "example.com"}, + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request in Microsoft Authenticator", + PollingIntervalMs: 1, + MaxPollAttempts: 1, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) - b := newBrokerForTests(t, &brokerForTestConfig{ - issuerURL: tc.issuerURL, - }) + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) - got := b.NormalizedIssuer(tc.issuerURL) - require.Equal(t, tc.want, got, "NormalizedIssuer returned unexpected result") - }) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + + access, data, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, "{}", data, "AuthNext after password should carry no message (avoids PAM read-delay)") + require.Equal(t, []string{authmodes.EntraMFAWait}, b.GetNextAuthModes(sessionID)) + + err = b.SetAvailableMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err, "Setup: SetAvailableMode should not have returned an error") + layout, err := b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err, "Setup: SelectAuthenticationMode should not have returned an error") + require.Equal(t, "Approve the sign-in request in Microsoft Authenticator", layout["label"], + "entra_mfa_wait layout label should reflect the MFA challenge message") + + access, data, err = b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access) + require.True(t, json.Valid([]byte(data)), "IsAuthenticated returned data must be valid JSON") + require.Equal(t, []int{1}, provider.recordedPollAttempts) + require.Equal(t, []string{""}, provider.recordedChallengeData) + + var grantPayload struct { + Message string `json:"message"` } + require.NoError(t, json.Unmarshal([]byte(data), &grantPayload)) + require.Equal(t, broker.CachedPasswordMessage, grantPayload.Message, + "Entra MFA completion should attach the offline-password caching notice") + + _, err = os.Stat(b.PasswordFilepathForSession(sessionID)) + require.NoError(t, err, "Entra MFA completion should cache the offline password") + _, err = os.Stat(b.TokenPathForSession(sessionID)) + require.NoError(t, err, "Entra MFA completion should cache the refreshed token") } -func TestUserDataDir(t *testing.T) { - t.Parallel() +// advanceToEntraMFAWait submits the Entra password for the session and selects the +// entra_mfa_wait mode, leaving the session ready for the polling IsAuthenticated("{}"). +func advanceToEntraMFAWait(t *testing.T, b *broker.Broker, sessionID, key string) { + t.Helper() - tests := map[string]struct { - issuerURL string - username string - want string - wantErr bool - }{ - "Successfully_return_user_data_dir_for_simple_username_and_issuer": { - issuerURL: "https://example.com", - username: "user@example.com", - want: "example.com/user@example.com", - }, - "Successfully_return_user_data_dir_for_issuer_url_without_scheme": { - issuerURL: "example.com", - username: "user@example.com", - want: "example.com/user@example.com", - }, - "Error_when_username_is_empty": { - issuerURL: "https://example.com", - username: "", - wantErr: true, - }, - "Error_when_username_contains_path_traversal": { - issuerURL: "https://example.com", - username: "../test", - wantErr: true, - }, - "Error_when_username_contains_path_traversal_but_does_not_leave_the_parent_directory": { - issuerURL: "https://example.com", - username: "test/../other-user", - wantErr: true, - }, - "Error_when_issuer_contains_path_traversal": { - issuerURL: "https://..", - username: "validuser", - wantErr: true, - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) - b := newBrokerForTests(t, &brokerForTestConfig{ - issuerURL: tc.issuerURL, - }) + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, []string{authmodes.EntraMFAWait}, b.GetNextAuthModes(sessionID)) - got, err := b.UserDataDir(tc.username) - if tc.wantErr { - require.Error(t, err, "UserDataDir should return an error, but did not") - return - } - require.NoError(t, err, "UserDataDir should not return an error") - require.Equal(t, filepath.Join(b.DataDir(), tc.want), got, "UserDataDir returned unexpected result") - }) - } + require.NoError(t, b.SetAvailableMode(sessionID, authmodes.EntraMFAWait)) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) } -func TestDeleteUser(t *testing.T) { +// TestIsAuthenticatedEntraMFADeniesOnAccessTokenVerificationFailure verifies that +// when the MFA access token fails signature verification (the TLS-MITM defense), +// the login is denied rather than trusting the token's identity claims. +func TestIsAuthenticatedEntraMFADeniesOnAccessTokenVerificationFailure(t *testing.T) { t.Parallel() - const providerID = "provider-id-123" + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request", + PollingIntervalMs: 1, + MaxPollAttempts: 1, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + verifyAccessTokenErr: errors.New("token signature verification failed"), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + advanceToEntraMFAWait(t, b, sessionID, key) + + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, + "an access token that fails signature verification must be denied") +} + +// TestIsAuthenticatedEntraMFAWaitPollsWhenMaxPollAttemptsZero verifies that a +// MaxPollAttempts value of 0 (which libhimmelblau can produce from +// expires_in/polling_interval flooring to zero) still polls rather than returning +// an immediate, false "MFA timed out". +func TestIsAuthenticatedEntraMFAWaitPollsWhenMaxPollAttemptsZero(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{Message: "Approve the sign-in request", PollingIntervalMs: 1, MaxPollAttempts: 0}, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + advanceToEntraMFAWait(t, b, sessionID, key) + + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access, "MaxPollAttempts==0 must still poll, not instant-timeout") + require.Equal(t, []int{1}, provider.recordedPollAttempts, "the poll loop must run at least once when MaxPollAttempts==0") +} + +// TestIsAuthenticatedEntraMFADeniesOnNilToken verifies the defensive nil-token +// guard: a provider returning (nil, nil) from AcquireTokenByMFAFlow must deny +// rather than panic the broker on the token dereference in finishEntraAuth. +func TestIsAuthenticatedEntraMFADeniesOnNilToken(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + provider := &mockMFANilTokenProvider{ + mockEntraPasswordProvider: &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{Message: "Approve the sign-in request", PollingIntervalMs: 1, MaxPollAttempts: 1}, + }, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + advanceToEntraMFAWait(t, b, sessionID, key) + + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "a (nil, nil) MFA result must deny, not panic") +} + +// TestIsAuthenticatedEntraMFAWaitNumberMatchingLabelShown verifies that when the MFA +// challenge message from libhimmelblau includes a number-matching code (e.g. +// PhoneAppNotification with entropy), that message is used as the entra_mfa_wait +// layout label so the user can see the number to match in the Authenticator app. +func TestIsAuthenticatedEntraMFAWaitNumberMatchingLabelShown(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + // Simulate the message libhimmelblau returns for PhoneAppNotification with number + // matching: "Open your Authenticator app, and enter the number '60' to sign in." + numberMatchingMsg := "Open your Authenticator app, and enter the number '60' to sign in." + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: numberMatchingMsg, + Method: "PhoneAppNotification", + PollingIntervalMs: 5000, + MaxPollAttempts: 10, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + + // Submit password – broker should offer entra_mfa_wait for PhoneAppNotification. + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, []string{authmodes.EntraMFAWait}, b.GetNextAuthModes(sessionID)) + + err = b.SetAvailableMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + layout, err := b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + require.Equal(t, numberMatchingMsg, layout["label"], + "entra_mfa_wait label must show the number-matching message so the user can approve in the Authenticator app") +} + +// TestIsAuthenticatedEntraMFAWaitDeniedWhenDeviceRegistrationFails verifies +// that authentication is denied when device registration fails, even after +// successful MFA. Without device registration the token exchange cannot be +// completed and group membership cannot be resolved, so granting access would +// leave the user in a broken state. +func TestIsAuthenticatedEntraMFAWaitDeniedWhenDeviceRegistrationFails(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockDeviceRegistrationFailProvider{ + mockEntraPasswordProvider: &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request in Microsoft Authenticator", + Method: "PhoneAppNotification", + PollingIntervalMs: 5000, + MaxPollAttempts: 10, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + }, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + registerDevice: true, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + + // Step 1: Submit password — should initiate MFA. + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, []string{authmodes.EntraMFAWait}, b.GetNextAuthModes(sessionID)) + + // Step 2: MFA poll — device registration fails and auth should be denied. + updateAuthModes(t, b, sessionID, authmodes.EntraMFAWait) + access, data, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, + "device registration failure must deny auth: without device registration the token exchange and group resolution cannot succeed") + require.True(t, json.Valid([]byte(data)), "IsAuthenticated returned data must be valid JSON") +} + +func TestIsAuthenticatedEntraMFADeniedWhenInitialGroupFetchFails(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{GetGroupsFails: true}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request in Microsoft Authenticator", + Method: "PhoneAppNotification", + PollingIntervalMs: 1, + MaxPollAttempts: 1, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + + err = b.SetAvailableMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + + access, data, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "initial Entra MFA logins must be denied when groups cannot be resolved") + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Contains(t, payload.Message, "Failed to retrieve groups") +} + +func TestIsAuthenticatedEntraMFAUsesCachedGroupsWhenRefreshFails(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + oldAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + oldAuthInfo.UserInfo.Groups = []info.Group{{Name: "cached-group", UGID: "cached-id"}} + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{GetGroupsFails: true}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request in Microsoft Authenticator", + Method: "PhoneAppNotification", + PollingIntervalMs: 1, + MaxPollAttempts: 1, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + require.NoError(t, token.CacheAuthInfo(b.TokenPathForSession(sessionID), oldAuthInfo)) + + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + + err = b.SetAvailableMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + + access, data, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access, "cached groups should permit re-authentication when Graph refresh fails") + + var payload struct { + UserInfo struct { + Groups []info.Group `json:"groups"` + } `json:"userinfo"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Equal(t, []info.Group{{Name: "cached-group", UGID: "cached-id"}}, payload.UserInfo.Groups) +} + +// TestIsAuthenticatedEntraMFASurfacesForDisplayErrorOnFirstLogin verifies that on a +// first Entra MFA login (no cached groups to fall back to) a group fetch that fails +// with a user-displayable ForDisplayError (e.g. a missing GroupMember.Read.All +// permission — a configuration problem) is surfaced verbatim by finishEntraAuth +// instead of being replaced by a misleading generic network hint. This is +// independent of force_access_check_with_provider (left unset here on purpose): the +// surfacing is driven by there being no cached groups, not by the forced check. +func TestIsAuthenticatedEntraMFASurfacesForDisplayErrorOnFirstLogin(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + const graphPermMsg = "Error: the Microsoft Entra ID app is missing the GroupMember.Read.All permission" + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{ + GetGroupsFunc: func() ([]info.Group, error) { + return nil, &providerErrors.ForDisplayError{Message: graphPermMsg} + }, + }, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request in Microsoft Authenticator", + Method: "PhoneAppNotification", + PollingIntervalMs: 1, + MaxPollAttempts: 1, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + + err = b.SetAvailableMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + + access, data, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access) + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Equal(t, graphPermMsg, payload.Message, + "a ForDisplayError from the group fetch must be surfaced verbatim, not replaced by the generic network message") + require.NotContains(t, payload.Message, "network connection") +} + +func TestGetAuthenticationModesFiltersNextAuthModesByFlows(t *testing.T) { + t.Parallel() + + // Use a provider that implements EntraPasswordProvider so that + // authModeIsAvailable can confirm the capability before offering the mode. + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{}, + } + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + provider: provider, + issuerURL: defaultIssuerURL, + ownerAllowed: true, + firstUserBecomesOwner: true, + deviceAuthFlowDisabled: true, + // Provide a group source (device registration) so the entra_password + // flow passes the group-lookup availability check in authModeIsAvailable. + registerDevice: true, + }) + + sessionID, _ := newSessionForTests(t, b, "", sessionmode.Login) + b.SetNextAuthModes(sessionID, []string{authmodes.EntraPassword, authmodes.DeviceQr}) + + modes, err := b.GetAuthenticationModes(sessionID, []map[string]string{ + supportedUILayouts["form"], + supportedUILayouts["qrcode"], + }) + require.NoError(t, err) + require.Equal(t, []map[string]string{{ + "id": authmodes.EntraPassword, + "label": authmodes.Label[authmodes.EntraPassword], + }}, modes) +} + +// TestGetAuthenticationModesEntraPasswordRequiresGroupSource verifies that once +// the broker has started successfully, the entra_password mode is offered only +// when a Microsoft Graph group source is available, i.e. device registration or +// a client secret. The missing-group-source case is rejected earlier by New(). +func TestGetAuthenticationModesEntraPasswordRequiresGroupSource(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + registerDevice bool + clientSecret string + }{ + "Offered_with_device_registration": {registerDevice: true}, + "Offered_with_client_secret": {clientSecret: "test-client-secret"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{}, + } + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + provider: provider, + issuerURL: defaultIssuerURL, + clientSecret: tc.clientSecret, + ownerAllowed: true, + firstUserBecomesOwner: true, + registerDevice: tc.registerDevice, + }) + + sessionID, _ := newSessionForTests(t, b, "", sessionmode.Login) + b.SetNextAuthModes(sessionID, []string{authmodes.EntraPassword, authmodes.DeviceQr}) + + modes, err := b.GetAuthenticationModes(sessionID, []map[string]string{ + supportedUILayouts["form"], + supportedUILayouts["qrcode"], + }) + require.NoError(t, err) + + var ids []string + for _, m := range modes { + ids = append(ids, m["id"]) + } + require.Contains(t, ids, authmodes.EntraPassword, "entra_password should be offered when a group source is available") + }) + } +} + +func TestIsAuthenticatedPasswordGrantRevokedInvalidatesCachedCredentials(t *testing.T) { + t.Parallel() + + const correctPassword = "password" + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + provider: &mockGrantRevokedProvider{mockProviderWithEntraModes: &mockProviderWithEntraModes{ + MockProvider: &testutils.MockProvider{}, + }}, + ownerAllowed: true, + firstUserBecomesOwner: true, + customHandlers: map[string]testutils.EndpointHandler{ + "/token": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"AADSTS50173: The provided grant has been revoked due to a password reset."}`)) + }, + }, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + generateAndStoreCachedInfo(t, tokenOptions{}, b.TokenPathForSession(sessionID)) + err := password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID)) + require.NoError(t, err) + + updateAuthModes(t, b, sessionID, authmodes.Password) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, correctPassword, key)) + + access, data, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.True(t, json.Valid([]byte(data)), "IsAuthenticated returned data must be valid JSON") + // reauthModes includes EntraPassword, but the provider does not implement + // EntraPasswordProvider, so authModeIsAvailable filters it out — only + // Device/DeviceQr survive into the actual offer. + require.Equal(t, []string{authmodes.EntraPassword, authmodes.Device, authmodes.DeviceQr}, b.GetNextAuthModes(sessionID)) + + _, err = os.Stat(b.PasswordFilepathForSession(sessionID)) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(b.TokenPathForSession(sessionID)) + require.ErrorIs(t, err, os.ErrNotExist) + + nextSessionID, _ := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + modes, err := b.GetAuthenticationModes(nextSessionID, []map[string]string{ + supportedUILayouts["form"], + supportedUILayouts["qrcode"], + }) + require.NoError(t, err) + + var modeIDs []string + for _, mode := range modes { + modeIDs = append(modeIDs, mode["id"]) + } + // entra_password is in reauthModes but filtered by the capability check; only device modes offered. + require.ElementsMatch(t, []string{authmodes.DeviceQr}, modeIDs) +} + +// TestIsAuthenticatedPasswordEntraTokenFallsBackToCachedGroupsOnGroupFetchError +// verifies that on a returning login with a cached Entra password + MFA token, a +// group-fetch failure — even a user-displayable ForDisplayError such as a missing +// GroupMember.Read.All permission — falls back to the cached groups instead of +// denying, exactly like the device-auth flow. The live provider check now happens +// at the token refresh (see refreshEntraPasswordToken), so the group fetch is no +// longer a liveness signal. The ForDisplayError is still surfaced on a *first* +// login that has no cached groups (see +// TestIsAuthenticatedEntraMFASurfacesForDisplayErrorOnFirstLogin). +func TestIsAuthenticatedPasswordEntraTokenFallsBackToCachedGroupsOnGroupFetchError(t *testing.T) { + t.Parallel() + + const correctPassword = "password" + const graphPermMsg = "Error: the Microsoft Entra ID app is missing the GroupMember.Read.All permission" + cachedGroups := []info.Group{{Name: "cached-group", UGID: "cached-id"}} + + // The token was obtained via the entra_password flow, so the provider must + // implement EntraPasswordProvider for the returning-login liveness refresh. + // The refresh succeeds (active user); the subsequent group fetch fails, which + // must fall back to cached groups rather than deny. + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{ + GetGroupsFunc: func() ([]info.Group, error) { + return nil, &providerErrors.ForDisplayError{Message: graphPermMsg} + }, + }, + } + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + provider: provider, + ownerAllowed: true, + firstUserBecomesOwner: true, + issuerURL: defaultIssuerURL, + forceAccessCheckWithProvider: true, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + generateAndStoreCachedInfo(t, tokenOptions{obtainedViaEntraPasswordAuth: true, groups: cachedGroups}, b.TokenPathForSession(sessionID)) + require.NoError(t, password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID))) + + updateAuthModes(t, b, sessionID, authmodes.Password) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, correctPassword, key)) + + access, data, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access, + "a returning Entra login must fall back to cached groups when the group fetch fails, not deny") + + var payload struct { + UserInfo struct { + Groups []info.Group `json:"groups"` + } `json:"userinfo"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Equal(t, cachedGroups, payload.UserInfo.Groups, "cached groups must be used when the group fetch fails") +} + +// TestIsAuthenticatedPasswordEntraTokenRefreshDetectsDisabledUser verifies that on a +// returning login the Entra password token refresh (refreshEntraPasswordToken) is the +// live disabled-user check: an AADSTS50057-class rejection is classified exactly like +// the device-auth flow — login is denied and UserIsDisabled is cached so later offline +// attempts are denied too. +func TestIsAuthenticatedPasswordEntraTokenRefreshDetectsDisabledUser(t *testing.T) { + t.Parallel() + + const correctPassword = "password" + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + userDisabledErrorCode: "user_disabled", + refreshErr: &oauth2.RetrieveError{ + ErrorCode: "user_disabled", + ErrorDescription: "AADSTS50057: The user account is disabled.", + }, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + generateAndStoreCachedInfo(t, tokenOptions{obtainedViaEntraPasswordAuth: true}, b.TokenPathForSession(sessionID)) + require.NoError(t, password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID))) + + updateAuthModes(t, b, sessionID, authmodes.Password) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, correctPassword, key)) + + access, data, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "a disabled user must be denied at the refresh step") + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Contains(t, payload.Message, "disabled") + + // The disabled state must be cached so subsequent offline logins are denied too. + cached, err := token.LoadAuthInfo(b.TokenPathForSession(sessionID)) + require.NoError(t, err) + require.True(t, cached.UserIsDisabled, "UserIsDisabled must be cached after an AADSTS50057 refresh rejection") +} + +// TestIsAuthenticatedPasswordRefreshPreservesRotationOnUserInfoError verifies +// that the generic OIDC refresh path preserves a rotated refresh token even if +// a later local validation step (here: ID-token verification in getUserInfo) +// fails. Otherwise the cache can be stranded with a refresh token the provider +// already invalidated server-side. +func TestIsAuthenticatedPasswordRefreshPreservesRotationOnUserInfoError(t *testing.T) { + t.Parallel() + + const correctPassword = "password" + const listenAddress = "127.0.0.1:31317" + const rotatedRefreshToken = "rotated-refresh-token" + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + listenAddress: listenAddress, + customHandlers: map[string]testutils.EndpointHandler{ + "/token": func(w http.ResponseWriter, _ *http.Request) { + response := fmt.Sprintf(`{ + "access_token": "accesstoken", + "refresh_token": %q, + "token_type": "Bearer", + "scope": %q, + "expires_in": 3600, + "id_token": ".invalid." + }`, rotatedRefreshToken, strings.Join(consts.DefaultScopes, " ")) + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write([]byte(response)) + }, + }, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + seeded := generateCachedInfo(t, tokenOptions{}) + require.NoError(t, token.CacheAuthInfo(b.TokenPathForSession(sessionID), seeded)) + require.NoError(t, password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID))) + + updateAuthModes(t, b, sessionID, authmodes.Password) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, correctPassword, key)) + + access, _, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, + "a refreshed token whose ID token cannot be verified must deny the returning login") + + cached, err := token.LoadAuthInfo(b.TokenPathForSession(sessionID)) + require.NoError(t, err) + require.Equal(t, rotatedRefreshToken, cached.Token.RefreshToken, + "a local user-info failure must not discard an already-rotated refresh token") + require.Equal(t, seeded.RawIDToken, cached.RawIDToken, + "a failed local validation must not replace the cached raw ID token") +} + +// TestIsAuthenticatedPasswordEntraTokenRefreshRotatesRefreshToken verifies that a +// successful Entra password token refresh on a returning login rotates the cached +// refresh token (kept fresh on each login, like the device-auth flow) and that the +// rotated token is persisted for the next login. +func TestIsAuthenticatedPasswordEntraTokenRefreshRotatesRefreshToken(t *testing.T) { + t.Parallel() + + const correctPassword = "password" + const rotatedRefreshToken = "rotated-refresh-token" + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{GetGroupsFunc: func() ([]info.Group, error) { return []info.Group{{Name: "remote-group"}}, nil }}, + refreshResult: &oauth2.Token{AccessToken: "new-access-token", RefreshToken: rotatedRefreshToken}, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + generateAndStoreCachedInfo(t, tokenOptions{obtainedViaEntraPasswordAuth: true, groups: []info.Group{{Name: "remote-group"}}}, b.TokenPathForSession(sessionID)) + require.NoError(t, password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID))) + + updateAuthModes(t, b, sessionID, authmodes.Password) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, correctPassword, key)) + + access, _, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access) + + cached, err := token.LoadAuthInfo(b.TokenPathForSession(sessionID)) + require.NoError(t, err) + require.Equal(t, rotatedRefreshToken, cached.Token.RefreshToken, + "the rotated refresh token from refreshEntraPasswordToken must be persisted") +} + +// TestIsAuthenticatedPasswordEntraTokenRefreshUpdatesUserInfo verifies that a +// successful Entra password token refresh re-derives the cached user info via +// the provider's access-token claim extraction (rather than refreshed-token +// extras), preserves the cached gecos when the refreshed token omits it, and +// keeps the separately-managed groups unchanged. +func TestIsAuthenticatedPasswordEntraTokenRefreshUpdatesUserInfo(t *testing.T) { + t.Parallel() + + const correctPassword = "password" + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{GetGroupsFunc: func() ([]info.Group, error) { + return []info.Group{{Name: "remote-group"}}, nil + }}, + refreshResult: &oauth2.Token{AccessToken: "new-access-token", RefreshToken: "new-refresh-token"}, + accessTokenUserInfo: &info.User{Name: "test-user@email.com", ProviderID: "saved-user-id"}, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + // Seed a stale cached token with a different gecos and the groups that should + // survive the refresh. + generateAndStoreCachedInfo(t, tokenOptions{ + obtainedViaEntraPasswordAuth: true, + gecos: "stale gecos", + groups: []info.Group{{Name: "remote-group"}}, + }, b.TokenPathForSession(sessionID)) + require.NoError(t, password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID))) + + updateAuthModes(t, b, sessionID, authmodes.Password) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, correctPassword, key)) + access, _, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access) + + cached, err := token.LoadAuthInfo(b.TokenPathForSession(sessionID)) + require.NoError(t, err) + require.Equal(t, "stale gecos", cached.UserInfo.Gecos, + "cached gecos must be preserved when the refreshed token omits it") + require.Equal(t, "test-user@email.com", cached.UserInfo.Name, + "user name must be re-derived from the refreshed token's claims") + // Groups are managed separately and must be preserved as-is from the refresh. + require.Equal(t, []info.Group{{Name: "remote-group"}}, cached.UserInfo.Groups, + "groups must be preserved from the cached token, not overwritten by the refresh") +} + +func runReturningEntraPasswordLogin(t *testing.T, provider *mockEntraPasswordProvider) (*broker.Broker, string, string) { + t.Helper() + + const correctPassword = "password" + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + generateAndStoreCachedInfo(t, tokenOptions{obtainedViaEntraPasswordAuth: true}, b.TokenPathForSession(sessionID)) + require.NoError(t, password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID))) + + updateAuthModes(t, b, sessionID, authmodes.Password) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, correctPassword, key)) + access, _, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + + return b, sessionID, access +} + +// TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnVerificationFailure verifies +// that if the refreshed Entra password token fails signature verification the +// returning login is denied — mirroring the first-login deny path in +// TestIsAuthenticatedEntraMFADeniesOnAccessTokenVerificationFailure. +func TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnVerificationFailure(t *testing.T) { + t.Parallel() + + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{GetGroupsFunc: func() ([]info.Group, error) { + return []info.Group{{Name: "remote-group"}}, nil + }}, + refreshResult: &oauth2.Token{AccessToken: "new-access-token", RefreshToken: "new-refresh-token"}, + verifyAccessTokenErr: errors.New("token signature verification failed"), + } + + b, sessionID, access := runReturningEntraPasswordLogin(t, provider) + require.Equal(t, broker.AuthDenied, access, + "a refreshed token that fails signature verification must deny the returning login") + + cached, err := token.LoadAuthInfo(b.TokenPathForSession(sessionID)) + require.NoError(t, err) + require.Equal(t, "new-refresh-token", cached.Token.RefreshToken, + "a local verification failure must not discard an already-rotated refresh token") +} + +// TestIsAuthenticatedPasswordEntraTokenRefreshVerificationHasOwnTimeout verifies +// that access-token verification gets its own request timeout after a successful +// Entra password token refresh. Verification may fetch JWKS on a cold cache or +// key rotation; it must not inherit only the leftover time from the refresh call. +func TestIsAuthenticatedPasswordEntraTokenRefreshVerificationHasOwnTimeout(t *testing.T) { + t.Parallel() + + const correctPassword = "password" + const refreshDelay = 50 * time.Millisecond + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{GetGroupsFunc: func() ([]info.Group, error) { + return []info.Group{{Name: "remote-group"}}, nil + }}, + refreshDelay: refreshDelay, + refreshResult: &oauth2.Token{AccessToken: "new-access-token", RefreshToken: "new-refresh-token"}, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + generateAndStoreCachedInfo(t, tokenOptions{obtainedViaEntraPasswordAuth: true}, b.TokenPathForSession(sessionID)) + require.NoError(t, password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID))) + + updateAuthModes(t, b, sessionID, authmodes.Password) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, correctPassword, key)) + access, _, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access) + + require.False(t, provider.refreshCtxDeadline.IsZero(), "refresh call should receive a deadline") + require.False(t, provider.verifyCtxDeadline.IsZero(), "verification call should receive a deadline") + require.GreaterOrEqual(t, provider.verifyCtxDeadline.Sub(provider.refreshCtxDeadline), refreshDelay/2, + "verification should get a fresh timeout instead of sharing the refresh context") +} + +// TestIsAuthenticatedPasswordEntraTokenRefreshPreservesRotationOnUserInfoError +// verifies that a local failure after a successful Entra refresh still persists +// the rotated refresh token. Otherwise the cache can be stranded with a refresh +// token that Entra already invalidated server-side. +func TestIsAuthenticatedPasswordEntraTokenRefreshPreservesRotationOnUserInfoError(t *testing.T) { + t.Parallel() + + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{GetGroupsFunc: func() ([]info.Group, error) { + return []info.Group{{Name: "remote-group"}}, nil + }}, + refreshResult: &oauth2.Token{AccessToken: "new-access-token", RefreshToken: "new-refresh-token"}, + userInfoFromTokenErr: errors.New("missing preferred_username claim"), + } + + b, sessionID, access := runReturningEntraPasswordLogin(t, provider) + require.Equal(t, broker.AuthDenied, access, + "a refreshed token whose user info cannot be extracted must deny the returning login") + + cached, err := token.LoadAuthInfo(b.TokenPathForSession(sessionID)) + require.NoError(t, err) + require.Equal(t, "new-refresh-token", cached.Token.RefreshToken, + "a local user-info failure must not discard an already-rotated refresh token") +} + +// TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnUsernameMismatch verifies +// that if the refreshed Entra password token's identity no longer matches the +// session's username, the returning login is denied — mirroring the username +// cross-check that the device-auth refresh path (getUserInfo) performs on every +// refresh, and that the Entra password flow itself performs on first login. +func TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnUsernameMismatch(t *testing.T) { + t.Parallel() + + const correctPassword = "password" + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{GetGroupsFunc: func() ([]info.Group, error) { + return []info.Group{{Name: "remote-group"}}, nil + }}, + refreshResult: &oauth2.Token{AccessToken: "new-access-token", RefreshToken: "new-refresh-token"}, + accessTokenUserInfo: &info.User{Name: "someone-else@email.com", ProviderID: "different-user-id"}, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + generateAndStoreCachedInfo(t, tokenOptions{obtainedViaEntraPasswordAuth: true}, b.TokenPathForSession(sessionID)) + require.NoError(t, password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID))) + + updateAuthModes(t, b, sessionID, authmodes.Password) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, correctPassword, key)) + access, _, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, + "a refreshed token whose identity no longer matches the session's username must deny the returning login") + + cached, err := token.LoadAuthInfo(b.TokenPathForSession(sessionID)) + require.NoError(t, err) + require.Equal(t, "new-refresh-token", cached.Token.RefreshToken, + "a local username verification failure must not discard an already-rotated refresh token") +} + +// TestDeviceAuthClearsDeviceRegistrationDataWhenRegistrationDisabled verifies that +// when register_device is changed from true to false and the user re-authenticates +// via device-code (which they are forced into because the stale device-registration +// token cannot be used for local-password auth), the new stored token has +// DeviceRegistrationData=nil. This ensures subsequent getGroups calls don't +// incorrectly attempt the PRT-exchange path. +func TestDeviceAuthClearsDeviceRegistrationDataWhenRegistrationDisabled(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + issuerURL: defaultIssuerURL, + supportsDeviceRegistration: true, + // register_device was previously true (device got registered), now disabled. + registerDevice: false, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + + // Seed a token from when register_device=true: it carries DeviceRegistrationData. + // authModeIsAvailable will block the password mode (register_device=false but + // token isForDeviceRegistration=true), forcing the user to re-authenticate via DAG. + generateAndStoreCachedInfo(t, tokenOptions{isForDeviceRegistration: true}, b.TokenPathForSession(sessionID)) + require.NoError(t, password.HashAndStorePassword("password", b.PasswordFilepathForSession(sessionID))) + + // Step 1: device-code auth. + updateAuthModes(t, b, sessionID, authmodes.DeviceQr) + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, []string{authmodes.NewPassword}, b.GetNextAuthModes(sessionID)) + + // Step 2: set a new local password, which writes the token to disk. + updateAuthModes(t, b, sessionID, authmodes.NewPassword) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "newpassword", key)) + access, _, err = b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access) + + // The newly stored token must carry no DeviceRegistrationData; otherwise the + // next login's getGroups call would incorrectly try the PRT-exchange path. + cached, err := token.LoadAuthInfo(b.TokenPathForSession(sessionID)) + require.NoError(t, err) + require.Empty(t, cached.DeviceRegistrationData, + "re-authenticating via device-code with register_device=false must store a token "+ + "without DeviceRegistrationData so subsequent group lookups don't attempt PRT exchange") +} + +// TestIsAuthenticatedPhoneAppOTPRoutesToMFACode verifies that PhoneAppOTP +// (Authenticator TOTP) is routed to entra_mfa_code even when pollingInterval > 0, +// and that AcquireTokenByMFAFlow is called with poll_attempt=0 and the user's code. +func TestIsAuthenticatedPhoneAppOTPRoutesToMFACode(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Please type in the code displayed on your authenticator app from your device:", + Method: "PhoneAppOTP", + PollingIntervalMs: 5000, // positive — must NOT cause poll routing + MaxPollAttempts: 10, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + + // Step 1: Submit password — broker should recognise PhoneAppOTP and offer entra_mfa_code. + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + + access, data, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, "{}", data, "AuthNext after password should carry no message (avoids PAM read-delay)") + require.Equal(t, []string{authmodes.EntraMFACode}, b.GetNextAuthModes(sessionID), + "PhoneAppOTP should route to entra_mfa_code, not entra_mfa_wait") + + // Step 2: Submit the OTP code — should call AcquireTokenByMFAFlow with poll_attempt=0. + err = b.SetAvailableMode(sessionID, authmodes.EntraMFACode) + require.NoError(t, err, "Setup: SetAvailableMode should not have returned an error") + layout, err := b.SelectAuthenticationMode(sessionID, authmodes.EntraMFACode) + require.NoError(t, err, "Setup: SelectAuthenticationMode should not have returned an error") + require.Equal(t, "Enter your MFA code", layout["label"], + "The input label should remain generic") + + otpCode := "123456" + otpAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, otpCode, key)) + + access, data, err = b.IsAuthenticated(sessionID, otpAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access) + require.True(t, json.Valid([]byte(data)), "IsAuthenticated returned data must be valid JSON") + require.Equal(t, []int{0}, provider.recordedPollAttempts, + "PhoneAppOTP must call AcquireTokenByMFAFlow with poll_attempt=0") + require.Equal(t, []string{otpCode}, provider.recordedChallengeData, + "PhoneAppOTP must pass the user-entered code as auth_data") + + _, err = os.Stat(b.PasswordFilepathForSession(sessionID)) + require.NoError(t, err, "Entra MFA completion should cache the offline password") + _, err = os.Stat(b.TokenPathForSession(sessionID)) + require.NoError(t, err, "Entra MFA completion should cache the refreshed token") +} + +// TestIsAuthenticatedEntraMFACodeWrongCodeRetries verifies that an incorrect or +// expired one-time code keeps the MFA flow alive and re-prompts for the code +// (AuthRetry) rather than discarding the flow and forcing password re-entry. A +// subsequent correct code then completes authentication. +func TestIsAuthenticatedEntraMFACodeWrongCodeRetries(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + released := 0 + provider := &mockMFAWrongCodeThenSuccessProvider{ + mockEntraPasswordProvider: &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: newTrackedMFAFlowState(func() { released++ }), + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Please type in the code displayed on your authenticator app:", + Method: "PhoneAppOTP", + PollingIntervalMs: 5000, + MaxPollAttempts: 10, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + }, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + + // Step 1: Submit password — routed to entra_mfa_code (PhoneAppOTP). + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, []string{authmodes.EntraMFACode}, b.GetNextAuthModes(sessionID)) + + err = b.SetAvailableMode(sessionID, authmodes.EntraMFACode) + require.NoError(t, err, "Setup: SetAvailableMode should not have returned an error") + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFACode) + require.NoError(t, err, "Setup: SelectAuthenticationMode should not have returned an error") + + // Step 2: Submit a wrong code — should retry (stay on the code prompt), keep + // the flow alive, and not yet cache the offline password. + wrongAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "000000", key)) + access, data, err := b.IsAuthenticated(sessionID, wrongAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthRetry, access, "a wrong MFA code must return AuthRetry, not bounce back to the password step") + require.Contains(t, data, "Incorrect or expired code", "the retry message should ask for the code again") + require.Equal(t, 0, released, "the MFA flow must NOT be released on a retryable wrong code") + _, err = os.Stat(b.PasswordFilepathForSession(sessionID)) + require.Error(t, err, "no offline password should be cached after a wrong code") + + // Step 3: Submit the correct code — completes auth using the same flow. + rightAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "123456", key)) + access, _, err = b.IsAuthenticated(sessionID, rightAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access, "a correct code after a wrong one should grant access") + require.Equal(t, []string{"000000", "123456"}, provider.recordedChallengeData, + "both code submissions should reuse the same MFA flow") + _, err = os.Stat(b.PasswordFilepathForSession(sessionID)) + require.NoError(t, err, "successful MFA completion should cache the offline password") +} + +func TestIsAuthenticatedEntraMFAWaitDenialReturnsAuthDenied(t *testing.T) { + t.Parallel() + + provider := &mockMFADeniedProvider{ + mockEntraPasswordProvider: &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request in Microsoft Authenticator", + PollingIntervalMs: 1, + MaxPollAttempts: 5, + }, + }, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access, "password submission should transition to MFA") + + // Select the MFA wait mode. + err = b.SetAvailableMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + + // Poll - the mock will return a denial on first poll. + access, data, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "MFA denial should return AuthDenied, not AuthRetry") + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Contains(t, payload.Message, "denied") +} + +func TestIsAuthenticatedEntraMFAWaitTimeoutReturnsAuthNext(t *testing.T) { + t.Parallel() + + provider := &mockMFATimeoutProvider{ + mockEntraPasswordProvider: &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request in Microsoft Authenticator", + PollingIntervalMs: 1, + MaxPollAttempts: 2, + }, + }, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + + err = b.SetAvailableMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + + // Poll - the mock always returns MFA_POLL_CONTINUE, so max attempts will be exhausted. + // After timeout the broker should redirect back to entra_password rather than + // asking the client to retry a dead MFA wait mode. + access, data, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access, "MFA timeout should return AuthNext to restart from entra_password") + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Contains(t, payload.Message, "timed out") +} + +// TestEntraPasswordRoutesAADSTSErrors verifies that an AADSTS error raised while +// initiating the password+MFA flow is mapped to the right broker outcome. +func TestEntraPasswordRoutesAADSTSErrors(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + aadsts int + category himmelblau.MFAErrorCategory + deviceAuthDisabled bool + + wantAccess string + wantNextModes []string + wantMsg string + }{ + "Account_locked": {aadsts: 50053, wantAccess: broker.AuthDenied, wantMsg: "locked"}, + "Password_expired": {aadsts: 50055, wantAccess: broker.AuthDenied, wantMsg: "expired"}, + "Invalid_credentials_retry": {aadsts: 50126, wantAccess: broker.AuthRetry, wantMsg: "Incorrect password"}, + "Conditional_access_blocked": {aadsts: 53003, wantAccess: broker.AuthDenied, wantMsg: "Conditional Access"}, + "Interactive_auth_to_device": {aadsts: 16000, wantAccess: broker.AuthNext, wantNextModes: []string{authmodes.Device, authmodes.DeviceQr}, wantMsg: "MFA registration required"}, + "Interactive_auth_denied_when_device_disabled": {aadsts: 16000, deviceAuthDisabled: true, wantAccess: broker.AuthDenied, wantMsg: "disabled"}, + "MFA_enrollment_to_device": {aadsts: 50072, wantAccess: broker.AuthNext, wantNextModes: []string{authmodes.Device, authmodes.DeviceQr}, wantMsg: "MFA registration required"}, + "MFA_enrollment_alt_to_device": {aadsts: 50079, wantAccess: broker.AuthNext, wantNextModes: []string{authmodes.Device, authmodes.DeviceQr}, wantMsg: "MFA registration required"}, + "Authenticator_registration_to_device": {aadsts: 50203, wantAccess: broker.AuthNext, wantNextModes: []string{authmodes.Device, authmodes.DeviceQr}, wantMsg: "MFA registration required"}, + "MFA_enrollment_denied_when_device_disabled": {aadsts: 50072, deviceAuthDisabled: true, wantAccess: broker.AuthDenied, wantMsg: "disabled"}, + "MFA_required_to_device": {category: himmelblau.MFAErrorRequired, wantAccess: broker.AuthNext, wantNextModes: []string{authmodes.Device, authmodes.DeviceQr}, wantMsg: "MFA is required"}, + "MFA_required_denied_when_device_disabled": {category: himmelblau.MFAErrorRequired, deviceAuthDisabled: true, wantAccess: broker.AuthDenied, wantMsg: "disabled"}, + "Unhandled_AADSTS_denied": {aadsts: 99999, wantAccess: broker.AuthDenied, wantMsg: "AADSTS99999: simulated error. Please report this error"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + initErr: &himmelblau.MFAError{ + AADSTS: tc.aadsts, + Category: tc.category, + Message: "simulated error", + }, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + deviceAuthFlowDisabled: tc.deviceAuthDisabled, + // Provide a group source (device registration) so a broker with + // device_auth disabled still satisfies the entra_password + // only-enabled-flow startup check in New(). + registerDevice: true, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, data, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, tc.wantAccess, access) + + if tc.wantNextModes != nil { + require.Equal(t, tc.wantNextModes, b.GetNextAuthModes(sessionID)) + } + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Contains(t, payload.Message, tc.wantMsg) + }) + } +} + +func TestIsAuthenticatedPasswordDeviceRegistrationRefreshDoesNotSendClientSecretToMicrosoftBrokerApp(t *testing.T) { + t.Parallel() + + const correctPassword = "password" + const listenAddress = "127.0.0.1:31316" + const serverURL = "http://" + listenAddress + + var sawBrokerAppRefresh bool + var refreshClientSecret string + baseTokenHandler := testutils.TokenHandler(serverURL, &testutils.TokenHandlerOptions{ + IDTokenClaims: []map[string]interface{}{ + {"aud": consts.MicrosoftBrokerAppID}, + }, + }) + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + clientSecret: "test-client-secret", + registerDevice: true, + supportsDeviceRegistration: true, + listenAddress: listenAddress, + customHandlers: map[string]testutils.EndpointHandler{ + "/token": func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + basicUser, basicPassword, hasBasicAuth := r.BasicAuth() + isBrokerAppRefresh := r.FormValue("grant_type") == "refresh_token" && + (r.FormValue("client_id") == consts.MicrosoftBrokerAppID || + (hasBasicAuth && basicUser == consts.MicrosoftBrokerAppID)) + if isBrokerAppRefresh { + sawBrokerAppRefresh = true + refreshClientSecret = r.FormValue("client_secret") + if refreshClientSecret == "" && hasBasicAuth { + refreshClientSecret = basicPassword + } + if refreshClientSecret != "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"AADSTS700025: Client is public so neither 'client_assertion' nor 'client_secret' should be presented."}`)) + return + } + } + baseTokenHandler(w, r) + }, + }, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + generateAndStoreCachedInfo(t, tokenOptions{isForDeviceRegistration: true}, b.TokenPathForSession(sessionID)) + require.NoError(t, password.HashAndStorePassword(correctPassword, b.PasswordFilepathForSession(sessionID))) + + updateAuthModes(t, b, sessionID, authmodes.Password) + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, correctPassword, key)) + + access, _, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access, + "returning device-registration logins must refresh successfully even when a client secret is configured for Graph fallback") + require.True(t, sawBrokerAppRefresh, "the returning login must exercise the Microsoft Broker App refresh path") + require.Empty(t, refreshClientSecret, + "the Microsoft Broker App is a public client, so refresh must not send the configured OIDC client secret") +} + +// TestEntraPasswordInvalidatesCachedCredentialsOnRemotePasswordChange verifies +// that an AADSTS50173 (grant revoked by a remote password change) wipes the +// cached token and password files and offers re-authentication. +func TestEntraPasswordInvalidatesCachedCredentialsOnRemotePasswordChange(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + initErr: &himmelblau.MFAError{AADSTS: 50173, Message: "grant revoked"}, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + + // Seed cached credentials that the revocation must invalidate. + cached := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + require.NoError(t, token.CacheAuthInfo(b.TokenPathForSession(sessionID), cached)) + require.NoError(t, password.HashAndStorePassword("password", b.PasswordFilepathForSession(sessionID))) + + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, data, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Contains(t, payload.Message, "changed remotely") + + require.NoFileExists(t, b.TokenPathForSession(sessionID), "cached token should be removed on remote password change") + require.NoFileExists(t, b.PasswordFilepathForSession(sessionID), "cached password should be removed on remote password change") +} + +// TestIsAuthenticatedFIDOMethodRoutesToDevice verifies that a FIDO/security-key +// MFA method redirects to Device Authentication (or denies when device auth is +// unavailable), and no credentials are cached in either case. +func TestIsAuthenticatedFIDOMethodRoutesToDevice(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + deviceAuthDisabled bool + wantAccess string + wantNextModes []string + wantMsgContains string + }{ + "Redirects_to_device": {wantAccess: broker.AuthNext, wantNextModes: []string{authmodes.Device, authmodes.DeviceQr}, wantMsgContains: "Device Authentication"}, + "Denied_when_device_disabled": {deviceAuthDisabled: true, wantAccess: broker.AuthDenied, wantMsgContains: "FIDO"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Use your security key", + Method: "FidoKey", + }, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + deviceAuthFlowDisabled: tc.deviceAuthDisabled, + // Provide a group source (device registration) so a broker with + // device_auth disabled still satisfies the entra_password + // only-enabled-flow startup check in New(). + registerDevice: true, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, data, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, tc.wantAccess, access) + + if tc.wantNextModes != nil { + require.Equal(t, tc.wantNextModes, b.GetNextAuthModes(sessionID)) + } + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Contains(t, payload.Message, tc.wantMsgContains) + + require.NoFileExists(t, b.PasswordFilepathForSession(sessionID)) + }) + } +} + +// TestIsAuthenticatedEntraMFACodeDenied verifies that a denied code submission +// returns AuthDenied. +func TestIsAuthenticatedEntraMFACodeDenied(t *testing.T) { + t.Parallel() + + provider := &mockMFADeniedProvider{ + mockEntraPasswordProvider: &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Enter the code from your authenticator app", + Method: "PhoneAppOTP", + }, + }, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, []string{authmodes.EntraMFACode}, b.GetNextAuthModes(sessionID)) + + updateAuthModes(t, b, sessionID, authmodes.EntraMFACode) + codeAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "123456", key)) + access, data, err := b.IsAuthenticated(sessionID, codeAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access) + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Contains(t, payload.Message, "denied") +} + +// TestIsAuthenticatedEntraMFACodeFailureRoutesBack verifies that a non-denial +// failure during code verification clears the dead MFA state and routes the +// client back to entra_password rather than the now-dead code mode. +func TestIsAuthenticatedEntraMFACodeFailureRoutesBack(t *testing.T) { + t.Parallel() + + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Enter the code from your authenticator app", + Method: "PhoneAppOTP", + }, + mfaTokenResult: nil, // AcquireTokenByMFAFlow returns a generic (non-denial) error. + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@email.com", sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + + updateAuthModes(t, b, sessionID, authmodes.EntraMFACode) + codeAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "123456", key)) + access, data, err := b.IsAuthenticated(sessionID, codeAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, []string{authmodes.EntraPassword}, b.GetNextAuthModes(sessionID), + "a failed code submission should route back to entra_password") + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal([]byte(data), &payload)) + require.Contains(t, payload.Message, "failed") +} + +// TestIsAuthenticatedEntraMFAFallsBackToEmailClaim verifies that when the MFA +// token carries no preferred_username, the user identity is recovered from the +// email extra instead. +func TestIsAuthenticatedEntraMFAFallsBackToEmailClaim(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Enter the code from your authenticator app", + Method: "PhoneAppOTP", + }, + mfaTokenResult: mfaAuthInfo.Token.WithExtra(map[string]any{ + "email": username, + "sub": "saved-user-id", + "name": "test-user", + }), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + + updateAuthModes(t, b, sessionID, authmodes.EntraMFACode) + codeAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "123456", key)) + access, _, err = b.IsAuthenticated(sessionID, codeAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access, "email claim should satisfy the user identity when preferred_username is absent") +} + +func TestUserPreCheck(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + username string + allowedSuffixes []string + homePrefix string + }{ + "Successfully_allow_username_with_matching_allowed_suffix": { + username: "user@allowed", + allowedSuffixes: []string{"@allowed"}}, + "Successfully_allow_username_that_matches_at_least_one_allowed_suffix": { + username: "user@allowed", + allowedSuffixes: []string{"@other", "@something", "@allowed"}, + }, + "Successfully_allow_username_if_suffix_is_allow_all": { + username: "user@doesnotmatter", + allowedSuffixes: []string{"*"}, + }, + "Successfully_allow_username_if_suffix_has_asterisk": { + username: "user@allowed", + allowedSuffixes: []string{"*@allowed"}, + }, + "Successfully_allow_username_ignoring_empty_string_in_config": { + username: "user@allowed", + allowedSuffixes: []string{"@anothersuffix", "", "@allowed"}, + }, + "Return_userinfo_with_correct_homedir_after_precheck": { + username: "user@allowed", + allowedSuffixes: []string{"@allowed"}, + homePrefix: "/home/allowed/", + }, + + "Empty_userinfo_if_username_does_not_match_allowed_suffix": { + username: "user@notallowed", + allowedSuffixes: []string{"@allowed"}, + }, + "Empty_userinfo_if_username_does_not_match_any_of_the_allowed_suffixes": { + username: "user@notallowed", + allowedSuffixes: []string{"@other", "@something", "@allowed", ""}, + }, + "Empty_userinfo_if_no_allowed_suffixes_are_provided": { + username: "user@allowed", + }, + "Empty_userinfo_if_allowed_suffixes_has_only_empty_string": { + username: "user@allowed", + allowedSuffixes: []string{""}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + issuerURL: defaultIssuerURL, + homeBaseDir: tc.homePrefix, + allowedSSHSuffixes: tc.allowedSuffixes, + }) + + got, err := b.UserPreCheck(tc.username) + require.NoError(t, err, "UserPreCheck should not have returned an error") + + golden.CheckOrUpdate(t, got) + }) + } +} + +func TestNormalizedIssuer(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + issuerURL string + want string + }{ + "HTTP_issuerURL": {issuerURL: "http://example.com", want: "example.com"}, + "HTTPS_issuerURL": {issuerURL: "https://example.com", want: "example.com"}, + "IssuerURL_with_path": {issuerURL: "https://example.com/tenant/v2.0", want: "example.com_tenant_v2.0"}, + "IssuerURL_with_port": {issuerURL: "https://example.com:8080", want: "example.com_8080"}, + "IssuerURL_with_port_and_path": {issuerURL: "https://example.com:8080/path", want: "example.com_8080_path"}, + "IssuerURL_with_IP_address": {issuerURL: "https://127.0.0.1", want: "127.0.0.1"}, + "IssuerURL_with_IP_address_and_port": {issuerURL: "https://127.0.0.1:8080", want: "127.0.0.1_8080"}, + "IssuerURL_without_scheme": {issuerURL: "example.com", want: "example.com"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + issuerURL: tc.issuerURL, + }) + + got := b.NormalizedIssuer(tc.issuerURL) + require.Equal(t, tc.want, got, "NormalizedIssuer returned unexpected result") + }) + } +} + +func TestUserDataDir(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + issuerURL string + username string + want string + wantErr bool + }{ + "Successfully_return_user_data_dir_for_simple_username_and_issuer": { + issuerURL: "https://example.com", + username: "user@example.com", + want: "example.com/user@example.com", + }, + "Successfully_return_user_data_dir_for_issuer_url_without_scheme": { + issuerURL: "example.com", + username: "user@example.com", + want: "example.com/user@example.com", + }, + "Error_when_username_is_empty": { + issuerURL: "https://example.com", + username: "", + wantErr: true, + }, + "Error_when_username_contains_path_traversal": { + issuerURL: "https://example.com", + username: "../test", + wantErr: true, + }, + "Error_when_username_contains_path_traversal_but_does_not_leave_the_parent_directory": { + issuerURL: "https://example.com", + username: "test/../other-user", + wantErr: true, + }, + "Error_when_issuer_contains_path_traversal": { + issuerURL: "https://..", + username: "validuser", + wantErr: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + issuerURL: tc.issuerURL, + }) + + got, err := b.UserDataDir(tc.username) + if tc.wantErr { + require.Error(t, err, "UserDataDir should return an error, but did not") + return + } + require.NoError(t, err, "UserDataDir should not return an error") + require.Equal(t, filepath.Join(b.DataDir(), tc.want), got, "UserDataDir returned unexpected result") + }) + } +} + +func TestDeleteUser(t *testing.T) { + t.Parallel() + + const providerID = "provider-id-123" tests := map[string]struct { username string @@ -2706,6 +4689,497 @@ func TestCompatibilitySymlinkSurvivesIssuerTreeMove(t *testing.T) { "compatibility symlink should point to the provider ID dir in the new location") } +func TestIsFIDOMethod(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + method string + want bool + }{ + "Empty": {method: "", want: false}, + "Fido_lower": {method: "fido", want: true}, + "Fido_upper": {method: "FIDO", want: true}, + "FidoKey": {method: "FidoKey", want: true}, + "Fido2_token": {method: "FIDO2_token", want: true}, + "Webauthn_lower": {method: "webauthn", want: true}, + "WebAuthn_camel": {method: "WebAuthn", want: true}, + "Security_key": {method: "security_key", want: true}, + "PhoneAppOTP": {method: "PhoneAppOTP", want: false}, + "PhoneAppPush": {method: "PhoneAppNotification", want: false}, + "OneWaySMS": {method: "OneWaySMS", want: false}, + "Random": {method: "AnythingElse", want: false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, broker.IsFIDOMethod(tc.method)) + }) + } +} + +func TestIsPromptMethod(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + method string + want bool + }{ + "AccessPass": {method: "AccessPass", want: true}, + "PhoneAppOTP": {method: "PhoneAppOTP", want: true}, + "OneWaySMS": {method: "OneWaySMS", want: true}, + "ConsolidatedTelephony": {method: "ConsolidatedTelephony", want: true}, + "PhoneAppNotification": {method: "PhoneAppNotification", want: false}, + "CompanionApps": {method: "CompanionAppsNotification", want: false}, + "FidoKey": {method: "FidoKey", want: false}, + "Empty": {method: "", want: false}, + "Lowercase_no_match": {method: "phoneappotp", want: false}, + "Unknown": {method: "SomeFutureMethod", want: false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, broker.IsPromptMethod(tc.method)) + }) + } +} + +// TestEntraPasswordAuthProviderNotSupported verifies that entraPasswordAuth +// returns AuthDenied when the broker's provider does not implement +// EntraPasswordProvider (defensive guard against misconfiguration). +func TestEntraPasswordAuthProviderNotSupported(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + ownerAllowed: true, + firstUserBecomesOwner: true, + issuerURL: defaultIssuerURL, + // Default MockProvider — does NOT implement EntraPasswordProvider. + }) + + sessionID, _ := newSessionForTests(t, b, "test-user@example.com", sessionmode.Login) + + // Force the session into entra_password mode without going through the + // normal availability check (which would reject a provider that lacks support). + err := b.SetAvailableMode(sessionID, authmodes.EntraPassword) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraPassword) + require.NoError(t, err) + + // Empty auth data (no secret) is fine: ProviderAs check fires before any + // password is consumed. + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "entra_password with unsupported provider must deny") +} + +// TestEntraPasswordAuthNonMFAError verifies that a non-MFAError from +// InitiateEntraPasswordAuth (e.g. a network failure) returns AuthDenied. +func TestEntraPasswordAuthNonMFAError(t *testing.T) { + t.Parallel() + + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + initErr: errors.New("simulated network failure"), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@example.com", sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, _, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "non-MFAError from InitiateEntraPasswordAuth must deny") +} + +// TestEntraPasswordAuthNilFlowOrChallenge verifies that a nil flow/challenge +// returned by InitiateEntraPasswordAuth (provider contract violation) returns +// AuthDenied. +func TestEntraPasswordAuthNilFlowOrChallenge(t *testing.T) { + t.Parallel() + + // initErr is nil but both flowState and challengeInfo are nil (default zero values). + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + // flowState and challengeInfo left nil. + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, "test-user@example.com", sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + + authData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, _, err := b.IsAuthenticated(sessionID, authData) + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "nil flow/challenge from provider must deny") +} + +// TestEntraMFAWaitAuthProviderNotSupported verifies that entraMFAWaitAuth +// returns AuthDenied when the broker's provider does not implement +// EntraPasswordProvider. +func TestEntraMFAWaitAuthProviderNotSupported(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + ownerAllowed: true, + firstUserBecomesOwner: true, + issuerURL: defaultIssuerURL, + }) + + sessionID, _ := newSessionForTests(t, b, "test-user@example.com", sessionmode.Login) + + err := b.SetAvailableMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "entra_mfa_wait with unsupported provider must deny") +} + +// TestEntraMFAWaitAuthNoActiveMFAFlow verifies that entraMFAWaitAuth returns +// AuthDenied when the session has no active MFA flow (the password step was +// never completed). +func TestEntraMFAWaitAuthNoActiveMFAFlow(t *testing.T) { + t.Parallel() + + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{PollingIntervalMs: 1, MaxPollAttempts: 1}, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, _ := newSessionForTests(t, b, "test-user@example.com", sessionmode.Login) + + // Jump straight to entra_mfa_wait without running entra_password first, + // so session.mfaFlowActive remains nil. + err := b.SetAvailableMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "entra_mfa_wait with no active MFA flow must deny") +} + +// TestEntraMFAWaitAuthNoChallengeMeta verifies that entraMFAWaitAuth returns +// AuthDenied when the session has an active MFA flow but no challenge metadata +// (another provider contract violation guard). +func TestEntraMFAWaitAuthNoChallengeMeta(t *testing.T) { + t.Parallel() + + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{PollingIntervalMs: 1, MaxPollAttempts: 1}, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, _ := newSessionForTests(t, b, "test-user@example.com", sessionmode.Login) + + err := b.SetAvailableMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + + // Set only the flow; leave mfaChallengeInfo nil to exercise the guard. + err = b.SetSessionMFAFlowActive(sessionID, &himmelblau.MFAFlowState{}) + require.NoError(t, err) + + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "entra_mfa_wait with nil challenge metadata must deny") +} + +// TestEntraMFACodeAuthProviderNotSupported verifies that entraMFACodeAuth +// returns AuthDenied when the provider does not implement EntraPasswordProvider. +func TestEntraMFACodeAuthProviderNotSupported(t *testing.T) { + t.Parallel() + + b := newBrokerForTests(t, &brokerForTestConfig{ + ownerAllowed: true, + firstUserBecomesOwner: true, + issuerURL: defaultIssuerURL, + }) + + sessionID, _ := newSessionForTests(t, b, "test-user@example.com", sessionmode.Login) + + err := b.SetAvailableMode(sessionID, authmodes.EntraMFACode) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFACode) + require.NoError(t, err) + + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "entra_mfa_code with unsupported provider must deny") +} + +// TestEntraMFACodeAuthNoActiveMFAFlow verifies that entraMFACodeAuth returns +// AuthDenied when the session has no active MFA flow. +func TestEntraMFACodeAuthNoActiveMFAFlow(t *testing.T) { + t.Parallel() + + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{}, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, _ := newSessionForTests(t, b, "test-user@example.com", sessionmode.Login) + + // Jump straight to entra_mfa_code without running entra_password first. + err := b.SetAvailableMode(sessionID, authmodes.EntraMFACode) + require.NoError(t, err) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFACode) + require.NoError(t, err) + + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "entra_mfa_code with no active MFA flow must deny") +} + +// TestIsAuthenticatedEntraMFAUsesVerifiedAccessTokenIdentity verifies that +// first-login identity comes from UserInfoFromAccessToken after VerifyAccessToken, +// not from OAuth token extras that may have been sourced from an unverified +// id_token by libhimmelblau. +func TestIsAuthenticatedEntraMFAUsesVerifiedAccessTokenIdentity(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request", + PollingIntervalMs: 1, + MaxPollAttempts: 1, + }, + accessTokenUserInfo: &info.User{Name: username, ProviderID: "verified-access-token-user-id"}, + mfaTokenResult: mfaAuthInfo.Token.WithExtra(map[string]any{ + "preferred_username": "someone-else@email.com", + "sub": "unverified-id-token-user-id", + "name": "Someone Else", + }), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + advanceToEntraMFAWait(t, b, sessionID, key) + + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthGranted, access, + "a first-login MFA token must bind identity to the verified access-token claims, not token extras") + + cached, err := token.LoadAuthInfo(b.TokenPathForSession(sessionID)) + require.NoError(t, err) + require.Equal(t, "verified-access-token-user-id", cached.UserInfo.ProviderID) +} + +// TestIsAuthenticatedEntraMFADeniesOnUsernameMismatch verifies the first-login +// identity cross-check: when the verified MFA access token identity does not +// match the username the user authenticated as, VerifyUsername fails and the +// login is denied. This is the first-login counterpart to the refresh-path +// mismatch test (TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnUsernameMismatch). +func TestIsAuthenticatedEntraMFADeniesOnUsernameMismatch(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request", + PollingIntervalMs: 1, + MaxPollAttempts: 1, + }, + accessTokenUserInfo: &info.User{Name: "someone-else@email.com", ProviderID: "different-user-id"}, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + advanceToEntraMFAWait(t, b, sessionID, key) + + access, _, err := b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, + "a first-login MFA access token whose identity does not match the session username must be denied") +} + +// TestIsAuthenticatedEntraMFADenialsDoNotCachePassword verifies that an Entra MFA +// denial does not persist an offline password file. A successful first login +// caches the password for offline use; a denied one must leave no such artifact, +// otherwise a later offline login could grant access to a user who never +// authenticated. This complements the existing denial tests, which assert the +// AuthDenied reply but not the absence of the password file. +func TestIsAuthenticatedEntraMFADenialsDoNotCachePassword(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + provider := &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{GetGroupsFails: true}, + flowState: &himmelblau.MFAFlowState{}, + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Approve the sign-in request in Microsoft Authenticator", + Method: "PhoneAppNotification", + PollingIntervalMs: 1, + MaxPollAttempts: 1, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + + require.NoError(t, b.SetAvailableMode(sessionID, authmodes.EntraMFAWait)) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) + + access, _, err = b.IsAuthenticated(sessionID, "{}") + require.NoError(t, err) + require.Equal(t, broker.AuthDenied, access, "initial Entra MFA logins must be denied when groups cannot be resolved") + + require.NoFileExists(t, b.PasswordFilepathForSession(sessionID), "a denied Entra MFA login must not cache an offline password") +} + +// TestIsAuthenticatedEntraMFACodeMaxAttemptsLockout verifies that repeated +// retryable wrong MFA codes are capped by maxAuthAttempts: after +// MaxAuthAttempts wrong submissions the broker returns AuthDeniedMaxTries +// (not AuthRetry) and releases the in-progress MFA flow immediately rather +// than waiting for EndSession. This is the lockout counterpart to the +// single-retry-then-success test (TestIsAuthenticatedEntraMFACodeWrongCodeRetries). +func TestIsAuthenticatedEntraMFACodeMaxAttemptsLockout(t *testing.T) { + t.Parallel() + + username := "test-user@email.com" + mfaAuthInfo := generateCachedInfo(t, tokenOptions{username: username, issuer: defaultIssuerURL}) + released := 0 + provider := &mockMFAAlwaysWrongCodeProvider{ + mockEntraPasswordProvider: &mockEntraPasswordProvider{ + MockProvider: &testutils.MockProvider{}, + flowState: newTrackedMFAFlowState(func() { released++ }), + challengeInfo: &himmelblau.MFAChallengeInfo{ + Message: "Please type in the code displayed on your authenticator app:", + Method: "PhoneAppOTP", + PollingIntervalMs: 5000, + MaxPollAttempts: 10, + }, + mfaTokenResult: newMFATokenResult(mfaAuthInfo.Token), + }, + } + + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) + + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) + + // Step 1: Submit password — routed to entra_mfa_code (PhoneAppOTP). + updateAuthModes(t, b, sessionID, authmodes.EntraPassword) + passwordAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "password", key)) + access, _, err := b.IsAuthenticated(sessionID, passwordAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthNext, access) + require.Equal(t, []string{authmodes.EntraMFACode}, b.GetNextAuthModes(sessionID)) + + require.NoError(t, b.SetAvailableMode(sessionID, authmodes.EntraMFACode)) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFACode) + require.NoError(t, err) + + // Step 2: Submit wrong codes up to MaxAuthAttempts-1 — each must retry, + // keep the flow alive, and not yet cache the offline password. + wrongAuthData := fmt.Sprintf(`{"%s":"%s"}`, broker.AuthDataSecret, encryptSecret(t, "000000", key)) + for i := 0; i < broker.MaxAuthAttempts-1; i++ { + access, data, err := b.IsAuthenticated(sessionID, wrongAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthRetry, access, "a wrong MFA code must return AuthRetry, not a terminal denial (attempt %d)", i+1) + require.Contains(t, data, "Incorrect or expired code", "the retry message should ask for the code again") + require.Equal(t, 0, released, "the MFA flow must NOT be released on a retryable wrong code") + require.NoFileExists(t, b.PasswordFilepathForSession(sessionID), "no offline password should be cached while retrying wrong codes") + } + + // Step 3: The MaxAuthAttempts-th wrong code triggers the max-tries lockout. + access, _, err = b.IsAuthenticated(sessionID, wrongAuthData) + require.NoError(t, err) + require.Equal(t, broker.AuthDeniedMaxTries, access, + "exhausting MaxAuthAttempts wrong MFA codes must return AuthDeniedMaxTries, not AuthRetry") + require.Equal(t, 1, released, "the MFA flow must be released immediately on max-tries lockout") + require.Len(t, provider.recordedChallengeData, broker.MaxAuthAttempts, + "each wrong code submission must reuse the same MFA flow") + require.NoFileExists(t, b.PasswordFilepathForSession(sessionID), "a max-tries lockout must not cache an offline password") +} + func TestMain(m *testing.M) { log.SetLevel(log.DebugLevel) diff --git a/authd-oidc-brokers/internal/broker/config.go b/authd-oidc-brokers/internal/broker/config.go index 7bbff45706..a93e89b87e 100644 --- a/authd-oidc-brokers/internal/broker/config.go +++ b/authd-oidc-brokers/internal/broker/config.go @@ -61,6 +61,13 @@ const ( // ownerUserKeyword is the keyword for the `allowed_users` key that allows access to the owner. ownerUserKeyword = "OWNER" + // flowsSection is the section name in the config file for the authentication flow control. + flowsSection = "flows" + // flowsDeviceAuthKey controls whether device_auth and device_auth_qr modes are enabled. + flowsDeviceAuthKey = "device_auth" + // flowsEntraPasswordKey controls whether entra_password mode is enabled. + flowsEntraPasswordKey = "entra_password" + // ownerAutoRegistrationConfigPath is the name of the file that will be auto-generated to register the owner. ownerAutoRegistrationConfigPath = "20-owner-autoregistration.conf" ownerAutoRegistrationConfigTemplate = "templates/20-owner-autoregistration.conf.tmpl" @@ -92,6 +99,10 @@ var ( extraGroupsKey: {}, ownerExtraGroupsKey: {}, }, + flowsSection: { + flowsDeviceAuthKey: {}, + flowsEntraPasswordKey: {}, + }, } ) @@ -129,9 +140,25 @@ type userConfig struct { ownerExtraGroups []string extraScopes []string + flows flowsConfig + provider provider } +// flowsConfig holds the parsed [flows] section configuration. +type flowsConfig struct { + DeviceAuth bool + EntraPassword bool +} + +// defaultFlowsConfig returns the default flows configuration (all modes enabled). +func defaultFlowsConfig() flowsConfig { + return flowsConfig{ + DeviceAuth: true, + EntraPassword: true, + } +} + // GetDropInDir takes the broker configuration path and returns the drop in dir path. func GetDropInDir(cfgPath string) string { return cfgPath + ".d" @@ -325,7 +352,7 @@ func parseConfig(cfg configFile, dropInCfgs []configFile, p provider) (userConfi if oidc != nil { uc.issuerURL = oidc.Key(issuerKey).String() uc.clientID = oidc.Key(clientIDKey).String() - uc.clientSecret = oidc.Key(clientSecret).String() + uc.clientSecret = strings.TrimSpace(oidc.Key(clientSecret).String()) uc.extraScopes = oidc.Key(extraScopesKey).Strings(",") forceAccessCheckKey := forceAccessCheckWithProviderKey @@ -345,6 +372,11 @@ func parseConfig(cfg configFile, dropInCfgs []configFile, p provider) (userConfi uc.registerDevice, _ = entraID.Key(registerDeviceKey).Bool() } + uc.flows, err = parseFlowsConfig(iniCfg.Section(flowsSection)) + if err != nil { + return userConfig{}, err + } + uc.populateUsersConfig(iniCfg.Section(usersSection)) return uc, nil @@ -430,3 +462,37 @@ func (uc *userConfig) registerOwner(cfgPath, userName string) error { return nil } + +// parseFlowsConfig parses the [flows] section and returns a flowsConfig with defaults for missing keys. +func parseFlowsConfig(section *ini.Section) (flowsConfig, error) { + fc := defaultFlowsConfig() + + if section == nil { + return fc, nil + } + + if section.HasKey(flowsDeviceAuthKey) { + val, err := section.Key(flowsDeviceAuthKey).Bool() + if err != nil { + log.Warningf(context.Background(), "invalid value for %q in [%s] section, using default (true)", flowsDeviceAuthKey, flowsSection) + } else { + fc.DeviceAuth = val + } + } + + if section.HasKey(flowsEntraPasswordKey) { + val, err := section.Key(flowsEntraPasswordKey).Bool() + if err != nil { + log.Warningf(context.Background(), "invalid value for %q in [%s] section, using default (true)", flowsEntraPasswordKey, flowsSection) + } else { + fc.EntraPassword = val + } + } + + if !fc.DeviceAuth && !fc.EntraPassword { + return flowsConfig{}, fmt.Errorf("invalid [%s] configuration: all authentication flows are disabled; at least one of %q or %q must be enabled", + flowsSection, flowsDeviceAuthKey, flowsEntraPasswordKey) + } + + return fc, nil +} diff --git a/authd-oidc-brokers/internal/broker/config_test.go b/authd-oidc-brokers/internal/broker/config_test.go index cc1f96da8f..7736730794 100644 --- a/authd-oidc-brokers/internal/broker/config_test.go +++ b/authd-oidc-brokers/internal/broker/config_test.go @@ -62,6 +62,44 @@ client_id = client_id [msentraid] register_device = true +`, + + "valid+flows_disabled": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[flows] +device_auth = false +entra_password = false +`, + + "valid+one_flow_disabled": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[flows] +device_auth = false +entra_password = true +`, + + "invalid_device_auth_value": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[flows] +device_auth = not-a-bool +`, + + "invalid_entra_password_value": ` +[oidc] +issuer = https://issuer.url.com +client_id = client_id + +[flows] +entra_password = not-a-bool `, "invalid_register_device_value": ` @@ -84,6 +122,11 @@ client_id = lower_precedence_client_id "overwrite_higher_precedence": ` [oidc] issuer = https://higher-precedence-issuer.url.com +`, + + "overwrite_enable_entra_password": ` +[flows] +entra_password = true `, } @@ -99,13 +142,21 @@ func TestParseConfig(t *testing.T) { wantErr bool wantErrContainsDropInConfigPath bool }{ - "Successfully_parse_config_file": {}, - "Successfully_parse_config_file_with_optional_values": {configType: "valid+optional"}, - "Successfully_parse_config_file_with_register_device": {configType: "valid+register_device"}, - "Successfully_parse_config_with_drop_in_files": {dropInType: "valid"}, + "Successfully_parse_config_file": {}, + "Successfully_parse_config_file_with_optional_values": {configType: "valid+optional"}, + "Successfully_parse_config_file_with_register_device": {configType: "valid+register_device"}, + "Successfully_parse_config_file_with_flow_values": {configType: "valid+one_flow_disabled"}, + "Warns_and_uses_default_for_invalid_device_auth_flow_value": {configType: "invalid_device_auth_value"}, + "Warns_and_uses_default_for_invalid_entra_password_flow_value": {configType: "invalid_entra_password_value"}, + "Successfully_parse_config_with_drop_in_files": {dropInType: "valid"}, + "Successfully_parse_config_with_flow_drop_in_files": { + configType: "valid+flows_disabled", + dropInType: "flows", + }, "Do_not_fail_if_values_contain_a_single_template_delimiter": {configType: "singles"}, + "Error_if_all_flows_are_disabled": {configType: "valid+flows_disabled", wantErr: true}, "Error_if_file_does_not_exist": {configType: "inexistent", wantErr: true}, "Error_if_file_is_unreadable": {configType: "unreadable", wantErr: true}, "Error_if_file_is_not_updated": {configType: "template", wantErr: true}, @@ -155,6 +206,9 @@ func TestParseConfig(t *testing.T) { // are still present. err = os.WriteFile(confPath, []byte(configTypes["valid+optional"]), 0600) require.NoError(t, err, "Setup: Failed to write config file") + case "flows": + err = os.WriteFile(filepath.Join(dropInDir, "00-drop-in.conf"), []byte(configTypes["overwrite_enable_entra_password"]), 0600) + require.NoError(t, err, "Setup: Failed to write drop-in file") case "unreadable-dir": err = os.Chmod(dropInDir, 0000) require.NoError(t, err, "Setup: Failed to make drop-in directory unreadable") diff --git a/authd-oidc-brokers/internal/broker/export_test.go b/authd-oidc-brokers/internal/broker/export_test.go index b1904be283..008407cd8b 100644 --- a/authd-oidc-brokers/internal/broker/export_test.go +++ b/authd-oidc-brokers/internal/broker/export_test.go @@ -2,16 +2,29 @@ package broker import ( "sync" + + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/himmelblau" +) + +// IsFIDOMethod and IsPromptMethod expose the unexported MFA method classifiers for tests. +var ( + IsFIDOMethod = isFIDOMethod + IsPromptMethod = isPromptMethod ) func (cfg *Config) Init() { cfg.ownerMutex = &sync.RWMutex{} + cfg.flows = defaultFlowsConfig() } func (cfg *Config) SetClientID(clientID string) { cfg.clientID = clientID } +func (cfg *Config) SetClientSecret(clientSecret string) { + cfg.clientSecret = clientSecret +} + func (cfg *Config) SetIssuerURL(issuerURL string) { cfg.issuerURL = issuerURL } @@ -69,6 +82,12 @@ func (cfg *Config) SetAllowedSSHSuffixes(allowedSSHSuffixes []string) { cfg.allowedSSHSuffixes = allowedSSHSuffixes } +func (cfg *Config) SetFlows(deviceAuth, entraPassword bool) { + cfg.flows = defaultFlowsConfig() + cfg.flows.DeviceAuth = deviceAuth + cfg.flows.EntraPassword = entraPassword +} + func (cfg *Config) SetProvider(provider provider) { cfg.provider = provider } @@ -222,3 +241,19 @@ const MaxRequestDuration = maxRequestDuration // MaxAuthAttempts exposes the broker's maxAuthAttempts for tests. const MaxAuthAttempts = maxAuthAttempts + +// CachedPasswordMessage exposes the broker's cachedPasswordMessage for tests. +const CachedPasswordMessage = cachedPasswordMessage + +// SetSessionMFAFlowActive lets tests set mfaFlowActive on a session without +// going through entraPasswordAuth. The challenge info is left nil so that +// tests can exercise the "flow active but no challenge metadata" guard in +// entraMFAWaitAuth. +func (b *Broker) SetSessionMFAFlowActive(sessionID string, flow *himmelblau.MFAFlowState) error { + s, err := b.getSession(sessionID) + if err != nil { + return err + } + s.mfaFlowActive = flow + return b.updateSession(sessionID, s) +} diff --git a/authd-oidc-brokers/internal/broker/helper_test.go b/authd-oidc-brokers/internal/broker/helper_test.go index bc059f40e2..83c77af034 100644 --- a/authd-oidc-brokers/internal/broker/helper_test.go +++ b/authd-oidc-brokers/internal/broker/helper_test.go @@ -15,6 +15,7 @@ import ( "github.com/canonical/authd/authd-oidc-brokers/internal/broker/sessionmode" "github.com/canonical/authd/authd-oidc-brokers/internal/providers" "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/himmelblau" "github.com/canonical/authd/authd-oidc-brokers/internal/testutils" "github.com/canonical/authd/authd-oidc-brokers/internal/token" "github.com/golang-jwt/jwt/v5" @@ -25,8 +26,11 @@ import ( type brokerForTestConfig struct { broker.Config issuerURL string + clientSecret string forceAccessCheckWithProvider bool registerDevice bool + deviceAuthFlowDisabled bool + entraPasswordFlowDisabled bool allowedUsers map[string]struct{} allUsersAllowed bool ownerAllowed bool @@ -41,11 +45,11 @@ type brokerForTestConfig struct { getGroupsFails bool supportsDeviceRegistration bool + supportsFetchingGroups bool supportsMetadata bool metadataGetErr error supportsUserDisabledCheck bool userDisabledErrorCode string - supportsFetchingGroups bool requireNameClaimOnInitialAuth bool firstCallDelay int secondCallDelay int @@ -85,12 +89,18 @@ func newBrokerForTests(t *testing.T, cfg *brokerForTestConfig) (b *broker.Broker if cfg.issuerURL != "" { cfg.SetIssuerURL(cfg.issuerURL) } + if cfg.clientSecret != "" { + cfg.SetClientSecret(cfg.clientSecret) + } if cfg.forceAccessCheckWithProvider { cfg.SetforceAccessCheckWithProvider(cfg.forceAccessCheckWithProvider) } if cfg.registerDevice { cfg.SetRegisterDevice(cfg.registerDevice) } + if cfg.deviceAuthFlowDisabled || cfg.entraPasswordFlowDisabled { + cfg.SetFlows(!cfg.deviceAuthFlowDisabled, !cfg.entraPasswordFlowDisabled) + } if cfg.homeBaseDir != "" { cfg.SetHomeBaseDir(cfg.homeBaseDir) } @@ -119,25 +129,32 @@ func newBrokerForTests(t *testing.T, cfg *brokerForTestConfig) (b *broker.Broker cfg.SetOwnerExtraGroups(cfg.ownerExtraGroups) } - provider := &testutils.MockProvider{ - GetGroupsFails: cfg.getGroupsFails, - RequireNameClaimOnInitialAuth: cfg.requireNameClaimOnInitialAuth, - FirstCallDelay: cfg.firstCallDelay, - SecondCallDelay: cfg.secondCallDelay, - GetGroupsFunc: cfg.getGroupsFunc, - } - - brokerProvider := brokerProviderWithOptionalCapabilities(provider, cfg) - - if cfg.provider == nil { - cfg.SetProvider(provider) + provider := cfg.provider + if provider == nil { + mockProvider := &testutils.MockProvider{ + GetGroupsFails: cfg.getGroupsFails, + RequireNameClaimOnInitialAuth: cfg.requireNameClaimOnInitialAuth, + FirstCallDelay: cfg.firstCallDelay, + SecondCallDelay: cfg.secondCallDelay, + GetGroupsFunc: cfg.getGroupsFunc, + } + provider = brokerProviderWithOptionalCapabilities(mockProvider, cfg) } + cfg.SetProvider(provider) if cfg.DataDir == "" { cfg.DataDir = t.TempDir() } if cfg.ClientID() == "" { cfg.SetClientID("test-client-id") } + if !cfg.entraPasswordFlowDisabled && cfg.clientSecret == "" && !cfg.registerDevice { + if _, ok := providers.ProviderAs[himmelblau.EntraPasswordProvider](provider); ok { + // Most Entra password broker tests are not exercising startup validation; + // give them a minimal Graph group source so they keep building a valid + // broker after New() started rejecting unusable entra_password configs. + cfg.SetClientSecret("test-client-secret") + } + } if cfg.IssuerURL() == "" { var serverOpts []testutils.ProviderServerOption @@ -158,7 +175,7 @@ func newBrokerForTests(t *testing.T, cfg *brokerForTestConfig) (b *broker.Broker apiVersion = cfg.apiVersion } - b, err := broker.New(cfg.Config, apiVersion, broker.WithCustomProvider(brokerProvider)) + b, err := broker.New(cfg.Config, apiVersion, broker.WithCustomProvider(provider)) require.NoError(t, err, "Setup: New should not have returned an error") return b } @@ -231,19 +248,19 @@ type tokenOptions struct { gecos string groups []info.Group - expired bool - noRefreshToken bool - refreshTokenExpired bool - refreshTokenInactiveExpired bool - refreshTokenStale bool - noIDToken bool - invalid bool - invalidClaims bool - noUserInfo bool - isForDeviceRegistration bool - noIsForDeviceRegistration bool - deviceIsDisabled bool - userIsDisabled bool + expired bool + noRefreshToken bool + refreshTokenExpired bool + refreshTokenInactiveExpired bool + refreshTokenStale bool + noIDToken bool + invalid bool + invalidClaims bool + noUserInfo bool + isForDeviceRegistration bool + deviceIsDisabled bool + userIsDisabled bool + obtainedViaEntraPasswordAuth bool } func generateCachedInfo(t *testing.T, options tokenOptions) *token.AuthCachedInfo { @@ -279,8 +296,9 @@ func generateCachedInfo(t *testing.T, options tokenOptions) *token.AuthCachedInf RefreshToken: "refreshtoken", Expiry: time.Now().Add(1000 * time.Hour), }, - DeviceIsDisabled: options.deviceIsDisabled, - UserIsDisabled: options.userIsDisabled, + DeviceIsDisabled: options.deviceIsDisabled, + UserIsDisabled: options.userIsDisabled, + ObtainedViaEntraPasswordAuth: options.obtainedViaEntraPasswordAuth, } if options.expired { @@ -298,8 +316,8 @@ func generateCachedInfo(t *testing.T, options tokenOptions) *token.AuthCachedInf if options.refreshTokenStale { tok.Token.RefreshToken = testutils.StaleRefreshToken } - if !options.noIsForDeviceRegistration { - tok.ExtraFields = map[string]any{testutils.IsForDeviceRegistrationClaim: options.isForDeviceRegistration} + if options.isForDeviceRegistration { + tok.DeviceRegistrationData = []byte("device-registration-data") } if !options.noUserInfo { diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_due_to_ca_sign_in_frequency_results_in_device_auth_as_next_mode/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_due_to_ca_sign_in_frequency_results_in_device_auth_as_next_mode/first_call index 7cfe409ecb..151f1b72cb 100644 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_due_to_ca_sign_in_frequency_results_in_device_auth_as_next_mode/first_call +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_due_to_ca_sign_in_frequency_results_in_device_auth_as_next_mode/first_call @@ -1,3 +1,3 @@ access: next -data: '{"message":"Refresh token expired, please authenticate again using device authentication."}' +data: '{"message":"Refresh token expired, please authenticate again."}' err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_due_to_inactivity_results_in_device_auth_as_next_mode/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_due_to_inactivity_results_in_device_auth_as_next_mode/first_call index 7cfe409ecb..151f1b72cb 100644 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_due_to_inactivity_results_in_device_auth_as_next_mode/first_call +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_due_to_inactivity_results_in_device_auth_as_next_mode/first_call @@ -1,3 +1,3 @@ access: next -data: '{"message":"Refresh token expired, please authenticate again using device authentication."}' +data: '{"message":"Refresh token expired, please authenticate again."}' err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/first_call index 7cfe409ecb..151f1b72cb 100644 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/first_call +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Authenticating_with_password_when_refresh_token_is_expired_results_in_device_auth_as_next_mode/first_call @@ -1,3 +1,3 @@ access: next -data: '{"message":"Refresh token expired, please authenticate again using device authentication."}' +data: '{"message":"Refresh token expired, please authenticate again."}' err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_getgroups_returns_retry_with_device_auth_error/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_getgroups_returns_retry_with_device_auth_error/first_call index 83da4e25c4..b5c99ca3f5 100644 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_getgroups_returns_retry_with_device_auth_error/first_call +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Error_when_getgroups_returns_retry_with_device_auth_error/first_call @@ -1,3 +1,3 @@ access: next -data: '{"message":"Authentication failed due to a token issue. Please try again using device authentication."}' +data: '{"message":"Authentication failed due to a token issue. Please try again."}' err: diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Do_not_fail_if_values_contain_a_single_template_delimiter/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Do_not_fail_if_values_contain_a_single_template_delimiter/config.txt index 98d04ea322..bf76d04643 100644 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Do_not_fail_if_values_contain_a_single_template_delimiter/config.txt +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Do_not_fail_if_values_contain_a_single_template_delimiter/config.txt @@ -12,4 +12,5 @@ homeBaseDir= allowedSSHSuffixes=[] extraGroups=[] ownerExtraGroups=[] -extraScopes=[] \ No newline at end of file +extraScopes=[] +flows={true true} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file/config.txt index 59db92e42b..acb444f6a0 100644 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file/config.txt +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file/config.txt @@ -12,4 +12,5 @@ homeBaseDir= allowedSSHSuffixes=[] extraGroups=[] ownerExtraGroups=[] -extraScopes=[] \ No newline at end of file +extraScopes=[] +flows={true true} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_flow_values/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_flow_values/config.txt new file mode 100644 index 0000000000..f464f07626 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_flow_values/config.txt @@ -0,0 +1,16 @@ +clientID=client_id +clientSecret= +issuerURL=https://issuer.url.com +forceAccessCheckWithProvider=false +registerDevice=false +allowedUsers=map[] +allUsersAllowed=false +ownerAllowed=true +firstUserBecomesOwner=true +owner= +homeBaseDir= +allowedSSHSuffixes=[] +extraGroups=[] +ownerExtraGroups=[] +extraScopes=[] +flows={false true} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_optional_values/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_optional_values/config.txt index c7b377b176..5ae17edb61 100644 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_optional_values/config.txt +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_optional_values/config.txt @@ -12,4 +12,5 @@ homeBaseDir=/home allowedSSHSuffixes=[@issuer.url.com] extraGroups=[] ownerExtraGroups=[] -extraScopes=[groups offline_access some_other_scope] \ No newline at end of file +extraScopes=[groups offline_access some_other_scope] +flows={true true} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_register_device/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_register_device/config.txt index 197f41d71e..713af6d37e 100644 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_register_device/config.txt +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_register_device/config.txt @@ -12,4 +12,5 @@ homeBaseDir= allowedSSHSuffixes=[] extraGroups=[] ownerExtraGroups=[] -extraScopes=[] \ No newline at end of file +extraScopes=[] +flows={true true} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_drop_in_files/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_drop_in_files/config.txt index e05ff01623..e77a27be56 100644 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_drop_in_files/config.txt +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_drop_in_files/config.txt @@ -12,4 +12,5 @@ homeBaseDir=/home allowedSSHSuffixes=[@issuer.url.com] extraGroups=[] ownerExtraGroups=[] -extraScopes=[groups offline_access some_other_scope] \ No newline at end of file +extraScopes=[groups offline_access some_other_scope] +flows={true true} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_flow_drop_in_files/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_flow_drop_in_files/config.txt new file mode 100644 index 0000000000..f464f07626 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_flow_drop_in_files/config.txt @@ -0,0 +1,16 @@ +clientID=client_id +clientSecret= +issuerURL=https://issuer.url.com +forceAccessCheckWithProvider=false +registerDevice=false +allowedUsers=map[] +allUsersAllowed=false +ownerAllowed=true +firstUserBecomesOwner=true +owner= +homeBaseDir= +allowedSSHSuffixes=[] +extraGroups=[] +ownerExtraGroups=[] +extraScopes=[] +flows={false true} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Warns_and_uses_default_for_invalid_device_auth_flow_value/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Warns_and_uses_default_for_invalid_device_auth_flow_value/config.txt new file mode 100644 index 0000000000..acb444f6a0 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Warns_and_uses_default_for_invalid_device_auth_flow_value/config.txt @@ -0,0 +1,16 @@ +clientID=client_id +clientSecret= +issuerURL=https://issuer.url.com +forceAccessCheckWithProvider=false +registerDevice=false +allowedUsers=map[] +allUsersAllowed=false +ownerAllowed=true +firstUserBecomesOwner=true +owner= +homeBaseDir= +allowedSSHSuffixes=[] +extraGroups=[] +ownerExtraGroups=[] +extraScopes=[] +flows={true true} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Warns_and_uses_default_for_invalid_entra_password_flow_value/config.txt b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Warns_and_uses_default_for_invalid_entra_password_flow_value/config.txt new file mode 100644 index 0000000000..acb444f6a0 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Warns_and_uses_default_for_invalid_entra_password_flow_value/config.txt @@ -0,0 +1,16 @@ +clientID=client_id +clientSecret= +issuerURL=https://issuer.url.com +forceAccessCheckWithProvider=false +registerDevice=false +allowedUsers=map[] +allUsersAllowed=false +ownerAllowed=true +firstUserBecomesOwner=true +owner= +homeBaseDir= +allowedSSHSuffixes=[] +extraGroups=[] +ownerExtraGroups=[] +extraScopes=[] +flows={true true} \ No newline at end of file diff --git a/authd-oidc-brokers/internal/dbusservice/methods_test.go b/authd-oidc-brokers/internal/dbusservice/methods_test.go index d211fcb50d..cd98f19e54 100644 --- a/authd-oidc-brokers/internal/dbusservice/methods_test.go +++ b/authd-oidc-brokers/internal/dbusservice/methods_test.go @@ -24,6 +24,9 @@ func newInterfaceForTests(t *testing.T) *dbusservice.Interface { require.NoError(t, os.WriteFile(confPath, []byte(`[oidc] issuer = `+defaultIssuerURL+` client_id = test-client-id + +[flows] +entra_password = false `), 0600), "Setup: writing broker config should not fail") cfg := broker.Config{ConfigFile: confPath, DataDir: t.TempDir()} diff --git a/authd-oidc-brokers/internal/password/password.go b/authd-oidc-brokers/internal/password/password.go index 93477d3efa..2edf3840a9 100644 --- a/authd-oidc-brokers/internal/password/password.go +++ b/authd-oidc-brokers/internal/password/password.go @@ -14,22 +14,38 @@ import ( // HashAndStorePassword hashes the password and stores it in the data directory. func HashAndStorePassword(password, path string) error { - // Ensure that the password file's parent directory exists. - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("could not create password parent directory: %w", err) + encoded, err := HashPassword(password) + if err != nil { + return err } + return StoreHashedPassword(encoded, path) +} +// HashPassword hashes a plaintext password and returns the base64-encoded +// salt+hash string. The result can later be persisted with StoreHashedPassword. +// +// Splitting hashing from storage lets callers narrow the plaintext memory +// window: hash early, then drop the plaintext. +func HashPassword(password string) (string, error) { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { - return fmt.Errorf("could not generate salt: %w", err) + return "", fmt.Errorf("could not generate salt: %w", err) } hash := hashPassword(password, salt) - s := base64.StdEncoding.EncodeToString(append(salt, hash...)) - if err := os.WriteFile(path, []byte(s), 0o600); err != nil { + return base64.StdEncoding.EncodeToString(append(salt, hash...)), nil +} + +// StoreHashedPassword writes a pre-computed password hash (from HashPassword) +// to the given path. +func StoreHashedPassword(encoded, path string) error { + // Ensure that the password file's parent directory exists. + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("could not create password parent directory: %w", err) + } + if err := os.WriteFile(path, []byte(encoded), 0o600); err != nil { return fmt.Errorf("could not store password: %w", err) } - return nil } diff --git a/authd-oidc-brokers/internal/password/password_test.go b/authd-oidc-brokers/internal/password/password_test.go index 5da3031314..e2e353ae04 100644 --- a/authd-oidc-brokers/internal/password/password_test.go +++ b/authd-oidc-brokers/internal/password/password_test.go @@ -11,6 +11,65 @@ import ( "github.com/stretchr/testify/require" ) +func TestHashPassword(t *testing.T) { + t.Parallel() + + encoded, err := password.HashPassword("test123") + require.NoError(t, err, "HashPassword() failed") + + decoded, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err, "HashPassword() did not return valid base64") + require.Len(t, decoded, 16+32, "HashPassword() should return salt(16)+hash(32)") + + encoded2, err := password.HashPassword("test123") + require.NoError(t, err, "HashPassword() failed on second call") + require.NotEqual(t, encoded, encoded2, "HashPassword() should use a fresh random salt") +} + +func TestStoreHashedPassword(t *testing.T) { + t.Parallel() + + t.Run("Success_when_parent_directory_does_not_exist", func(t *testing.T) { + t.Parallel() + + parentDir := filepath.Join(t.TempDir(), "nested", "dir") + path := filepath.Join(parentDir, "password") + encoded := "already-hashed-password" + + err := password.StoreHashedPassword(encoded, path) + require.NoError(t, err, "StoreHashedPassword() failed") + + data, err := os.ReadFile(path) + require.NoError(t, err, "Reading stored password failed") + require.Equal(t, encoded, string(data), "Stored password contents should match input") + }) + + t.Run("Error_when_parent_path_is_a_file", func(t *testing.T) { + t.Parallel() + + base := t.TempDir() + fileParent := filepath.Join(base, "not-a-directory") + err := os.WriteFile(fileParent, []byte("file"), 0o600) + require.NoError(t, err, "Creating parent file failed") + + path := filepath.Join(fileParent, "password") + err = password.StoreHashedPassword("already-hashed-password", path) + require.Error(t, err, "StoreHashedPassword() should fail when the parent path is a file") + }) + + t.Run("Error_when_password_file_is_not_writable", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "password") + // Create the file first with no write permission. + err := os.WriteFile(path, []byte("existing"), 0o400) + require.NoError(t, err, "Creating read-only password file failed") + + err = password.StoreHashedPassword("already-hashed-password", path) + require.Error(t, err, "StoreHashedPassword() should fail when the file is not writable") + }) +} + func TestHashAndStorePassword(t *testing.T) { t.Parallel() diff --git a/authd-oidc-brokers/internal/providers/genericprovider/genericprovider.go b/authd-oidc-brokers/internal/providers/genericprovider/genericprovider.go index 5ea017e463..7000588e93 100644 --- a/authd-oidc-brokers/internal/providers/genericprovider/genericprovider.go +++ b/authd-oidc-brokers/internal/providers/genericprovider/genericprovider.go @@ -97,8 +97,9 @@ func (p GenericProvider) VerifyUsername(requestedUsername, username string) erro return nil } -// SupportedOIDCAuthModes returns the OIDC authentication modes supported by the provider. -func (p GenericProvider) SupportedOIDCAuthModes() []string { +// SupportedOnlineAuthModes returns the authentication modes supported by the +// provider that require a connection to the identity provider. +func (p GenericProvider) SupportedOnlineAuthModes() []string { return []string{authmodes.Device, authmodes.DeviceQr} } diff --git a/authd-oidc-brokers/internal/providers/msentraid/export_test.go b/authd-oidc-brokers/internal/providers/msentraid/export_test.go index 1899f9019d..18703799b1 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/export_test.go +++ b/authd-oidc-brokers/internal/providers/msentraid/export_test.go @@ -9,10 +9,6 @@ func AllExpectedScopes() string { return strings.Join(New().expectedScopes, " ") } -func (p *Provider) SetNeedsAccessTokenForGraphAPI(value bool) { - p.needsAccessTokenForGraphAPI = value -} - // SetTokenScopesForGraphAPI can be used in tests to set the scopes for the Microsoft Graph API access token. func (p *Provider) SetTokenScopesForGraphAPI(scopes []string) { p.tokenScopesForGraphAPI = scopes diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/deviceregistration.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/deviceregistration.go new file mode 100644 index 0000000000..ba479a1eed --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/deviceregistration.go @@ -0,0 +1,38 @@ +package himmelblau + +import "encoding/json" + +// DeviceRegistrationData contains the data returned by RegisterDevice which is +// needed to acquire an access token later. The fields are populated by the +// libhimmelblau-backed flow but the struct is defined here (without a build +// tag) so that the broker can validate cached JSON without depending on the +// libhimmelblau build. +type DeviceRegistrationData struct { + DeviceID string `json:"device_id"` + CertKey []byte `json:"cert_key"` + TransportKey []byte `json:"transport_key"` + AuthValue string `json:"auth_value"` + TPMMachineKey []byte `json:"tpm_machine_key"` +} + +// IsValid checks whether all fields of the DeviceRegistrationData are set. +func (d *DeviceRegistrationData) IsValid() bool { + return d.DeviceID != "" && + len(d.CertKey) > 0 && + len(d.TransportKey) > 0 && + d.AuthValue != "" && + len(d.TPMMachineKey) > 0 +} + +// ValidDeviceRegistrationDataJSON reports whether raw is a valid JSON-encoded +// device registration payload with all required fields present and non-empty. +func ValidDeviceRegistrationDataJSON(raw []byte) bool { + if len(raw) == 0 { + return false + } + var d DeviceRegistrationData + if err := json.Unmarshal(raw, &d); err != nil { + return false + } + return d.IsValid() +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/deviceregistration_test.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/deviceregistration_test.go new file mode 100644 index 0000000000..decce252cb --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/deviceregistration_test.go @@ -0,0 +1,44 @@ +package himmelblau + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidDeviceRegistrationDataJSON(t *testing.T) { + t.Parallel() + + const validJSON = `{ + "device_id": "00000000-0000-0000-0000-000000000001", + "cert_key": "AQID", + "transport_key": "BAUG", + "auth_value": "auth", + "tpm_machine_key": "BwgJ" + }` + + tests := map[string]struct { + raw []byte + want bool + }{ + "Valid": {raw: []byte(validJSON), want: true}, + "Nil": {raw: nil, want: false}, + "Empty": {raw: []byte(""), want: false}, + "Not_JSON": {raw: []byte("not-json"), want: false}, + "Empty_object": {raw: []byte("{}"), want: false}, + "Missing_device_id": {raw: []byte(`{"cert_key":"AQID","transport_key":"BAUG","auth_value":"x","tpm_machine_key":"BwgJ"}`), want: false}, + "Empty_device_id": {raw: []byte(`{"device_id":"","cert_key":"AQID","transport_key":"BAUG","auth_value":"x","tpm_machine_key":"BwgJ"}`), want: false}, + "Empty_cert_key": {raw: []byte(`{"device_id":"d","cert_key":"","transport_key":"BAUG","auth_value":"x","tpm_machine_key":"BwgJ"}`), want: false}, + "Empty_transport_key": {raw: []byte(`{"device_id":"d","cert_key":"AQID","transport_key":"","auth_value":"x","tpm_machine_key":"BwgJ"}`), want: false}, + "Empty_auth_value": {raw: []byte(`{"device_id":"d","cert_key":"AQID","transport_key":"BAUG","auth_value":"","tpm_machine_key":"BwgJ"}`), want: false}, + "Empty_tpm_machine_key": {raw: []byte(`{"device_id":"d","cert_key":"AQID","transport_key":"BAUG","auth_value":"x","tpm_machine_key":""}`), want: false}, + "Wrong_type_for_cert_key": {raw: []byte(`{"device_id":"d","cert_key":42,"transport_key":"BAUG","auth_value":"x","tpm_machine_key":"BwgJ"}`), want: false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, ValidDeviceRegistrationDataJSON(tc.raw)) + }) + } +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd.go new file mode 100644 index 0000000000..687eed2b45 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd.go @@ -0,0 +1,184 @@ +package himmelblau + +import ( + "context" + "fmt" + "sync" + + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "golang.org/x/oauth2" +) + +// EntraPasswordProvider is an optional interface that providers can implement +// to support the Entra ID password + MFA authentication flow. +type EntraPasswordProvider interface { + // InitiateEntraPasswordAuth starts the Entra password + MFA flow. + // It submits credentials and returns an MFA challenge state. + // clientID is the OIDC application client ID (on_behalf_of_client_id); + // it is used to build the OIDC app inside the Rust layer so that the + // resulting tokens can include Microsoft Graph API scopes. + // When withDeviceScope is true, the MFA flow adds Intune enrollment + // resources to the token request (needed for PRT-based token exchange). + // When false, it uses only MS Graph scopes. + InitiateEntraPasswordAuth( + ctx context.Context, + clientID string, + issuerURL string, + username, password string, + deviceRegistrationData []byte, + withDeviceScope bool, + ) (*MFAFlowState, *MFAChallengeInfo, error) + + // AcquireTokenByMFAFlow completes the MFA challenge. + // clientID is the OIDC application client ID (on_behalf_of_client_id). + // For poll-based MFA, authData is empty and pollAttempt increments. + // For code-based MFA, authData is the user-entered code. + // Returns an OAuth token built from the MFA result on success. + AcquireTokenByMFAFlow( + ctx context.Context, + clientID string, + issuerURL string, + username string, + flow *MFAFlowState, + authData string, + pollAttempt int, + deviceRegistrationData []byte, + ) (*oauth2.Token, error) + + // RefreshEntraPasswordToken refreshes a cached Entra password + MFA refresh + // token to re-verify the account against Entra ID on a returning login, the + // same way the device-auth flow's token refresh does. It is a plain OAuth2 + // refresh as a public client (no client_secret) for basic scopes only — never + // Microsoft Graph — so it works regardless of register_device and never hits + // the Broker-app↔Graph preauthorization wall. + // + // On success it returns the rotated token (the new refresh token must be + // persisted). On an Entra rejection it returns an *oauth2.RetrieveError so the + // broker can classify it with the same checks it uses for device-auth + // (IsUserDisabledError → AADSTS50057, IsTokenExpiredError → AADSTS50173, etc.). + RefreshEntraPasswordToken( + ctx context.Context, + issuerURL string, + refreshToken string, + ) (*oauth2.Token, error) + + // VerifyAccessToken verifies the RS256 signature of the MFA-flow access token + // against the tenant's published JWKS (handling the header-nonce rewrite that + // Microsoft first-party tokens use) and that its tenant claim matches. It is + // the defense-in-depth check that the token genuinely came from Microsoft, so + // the identity read from its claims is not trusted on TLS alone. Returns nil + // only when the token verifies. + VerifyAccessToken( + ctx context.Context, + issuerURL string, + accessToken string, + ) error + + // UserInfoFromAccessToken extracts user identity from a verified Entra access + // token, mapping provider-specific claims (e.g. oid/upn) to the same info.User + // shape that GetUserInfo returns for OIDC ID tokens / UserInfo responses. + UserInfoFromAccessToken(accessToken string) (info.User, error) +} + +// MFAFlowState is an opaque handle to an in-progress MFA flow. +// The actual continuation state is owned by the libhimmelblau-backed +// implementation, which also supplies the release callback used by +// FreeMFAFlowState. +type MFAFlowState struct { + // mu serializes access to the underlying continuation state so that a + // concurrent FreeMFAFlowState (e.g. from EndSession while a cancelled + // poll goroutine is still running) cannot release it while it is in use + // or release it twice. + mu sync.Mutex + opaque any + release func() +} + +// FreeMFAFlowState releases resources associated with the MFA flow state. +// It is safe to call with a nil state, to call repeatedly, and to call +// concurrently with an in-flight use of the flow (it blocks until the use +// completes). +func FreeMFAFlowState(flow *MFAFlowState) { + if flow == nil { + return + } + flow.mu.Lock() + defer flow.mu.Unlock() + if flow.release != nil { + flow.release() + } + flow.release = nil + flow.opaque = nil +} + +// MFAChallengeInfo describes the MFA challenge that must be presented to the user. +type MFAChallengeInfo struct { + Message string + Method string + PollingIntervalMs int + MaxPollAttempts int +} + +// MFAErrorCategory classifies an MFA error so the broker can route +// it without depending on libhimmelblau-specific numeric codes. +type MFAErrorCategory int + +const ( + // MFAErrorOther is the default category and means the error has no + // specific routing semantics. + MFAErrorOther MFAErrorCategory = iota + // MFAErrorPollContinue means the MFA poll loop should keep polling. + MFAErrorPollContinue + // MFAErrorDenied means the user actively rejected the MFA challenge + // (e.g. tapped "Deny" on a push notification). + MFAErrorDenied + // MFAErrorRequired means MFA is required to complete authentication. + MFAErrorRequired + // MFAErrorRetryableCode means a submitted one-time code was incorrect or + // expired while the MFA flow itself remains valid, so the user can simply + // re-enter the code without restarting the flow. See newMFAError for how + // this is detected. + MFAErrorRetryableCode +) + +// MFAError represents an error from initiating or continuing an MFA flow. +// +// Category is set so that consumers can branch on well-known outcomes without +// referencing libhimmelblau-specific error codes. AADSTS, when non-zero, +// carries the Entra ID AADSTS error code. +type MFAError struct { + Category MFAErrorCategory + AADSTS int + Message string +} + +// Error returns the formatted error message. +func (e *MFAError) Error() string { + if e.AADSTS != 0 { + return fmt.Sprintf("AADSTS%d: %s", e.AADSTS, e.Message) + } + return e.Message +} + +// IsMFAPollContinue returns true if the error indicates the MFA poll should continue. +func (e *MFAError) IsMFAPollContinue() bool { + return e.Category == MFAErrorPollContinue +} + +// IsMFADenied returns true if the error indicates the MFA request was actively +// rejected (e.g., user denied the push notification). +func (e *MFAError) IsMFADenied() bool { + return e.Category == MFAErrorDenied +} + +// IsMFARequired returns true if the error indicates MFA is required. +func (e *MFAError) IsMFARequired() bool { + return e.Category == MFAErrorRequired +} + +// IsMFARetryableCode returns true if the error indicates a submitted one-time +// code was incorrect or expired while the MFA flow remains valid, so the user +// can retry the code without restarting the flow. +func (e *MFAError) IsMFARetryableCode() bool { + return e.Category == MFAErrorRetryableCode +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd_test.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd_test.go new file mode 100644 index 0000000000..0cfbe41fad --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd_test.go @@ -0,0 +1,137 @@ +package himmelblau + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMFAError_Error(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err *MFAError + want string + }{ + "Without_AADSTS": {err: &MFAError{Message: "plain message"}, want: "plain message"}, + "With_AADSTS": {err: &MFAError{AADSTS: 50126, Message: "bad credentials"}, want: "AADSTS50126: bad credentials"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, tc.err.Error()) + }) + } +} + +func TestMFAError_IsMFAPollContinue(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err *MFAError + want bool + }{ + "Poll_continue": {err: &MFAError{Category: MFAErrorPollContinue}, want: true}, + "Denied": {err: &MFAError{Category: MFAErrorDenied}, want: false}, + "Required": {err: &MFAError{Category: MFAErrorRequired}, want: false}, + "Other": {err: &MFAError{Category: MFAErrorOther}, want: false}, + "Poll_continue_with_aadsts": {err: &MFAError{Category: MFAErrorPollContinue, AADSTS: 50126}, want: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, tc.err.IsMFAPollContinue()) + }) + } +} + +func TestMFAError_IsMFADenied(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err *MFAError + want bool + }{ + "Denied_no_aadsts": {err: &MFAError{Category: MFAErrorDenied}, want: true}, + "Denied_with_aadsts": {err: &MFAError{Category: MFAErrorDenied, AADSTS: 50126}, want: true}, + "Poll_continue": {err: &MFAError{Category: MFAErrorPollContinue}, want: false}, + "Required": {err: &MFAError{Category: MFAErrorRequired}, want: false}, + "Other": {err: &MFAError{Category: MFAErrorOther}, want: false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, tc.err.IsMFADenied()) + }) + } +} + +func TestMFAError_IsMFARequired(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err *MFAError + want bool + }{ + "Required": {err: &MFAError{Category: MFAErrorRequired}, want: true}, + "Poll_continue": {err: &MFAError{Category: MFAErrorPollContinue}, want: false}, + "Denied": {err: &MFAError{Category: MFAErrorDenied}, want: false}, + "Other": {err: &MFAError{Category: MFAErrorOther}, want: false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, tc.err.IsMFARequired()) + }) + } +} + +func TestMFAError_IsMFARetryableCode(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err *MFAError + want bool + }{ + "Retryable_code": {err: &MFAError{Category: MFAErrorRetryableCode}, want: true}, + "Retryable_code_with_aadsts": {err: &MFAError{Category: MFAErrorRetryableCode, AADSTS: 50126}, want: true}, + "Poll_continue": {err: &MFAError{Category: MFAErrorPollContinue}, want: false}, + "Denied": {err: &MFAError{Category: MFAErrorDenied}, want: false}, + "Required": {err: &MFAError{Category: MFAErrorRequired}, want: false}, + "Other": {err: &MFAError{Category: MFAErrorOther}, want: false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, tc.err.IsMFARetryableCode()) + }) + } +} + +func TestFreeMFAFlowState_NilSafe(t *testing.T) { + t.Parallel() + + // Must not panic on nil. + FreeMFAFlowState(nil) + + // Must not panic on a state with no release func, and must reset opaque. + flow := &MFAFlowState{opaque: "data"} + FreeMFAFlowState(flow) + require.Nil(t, flow.opaque) + + // Must call release once and clear it. + released := 0 + flow = &MFAFlowState{opaque: "data", release: func() { released++ }} + FreeMFAFlowState(flow) + require.Equal(t, 1, released) + require.Nil(t, flow.opaque) + + // A subsequent call must be a no-op. + FreeMFAFlowState(flow) + require.Equal(t, 1, released) +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go index ca126d78b7..1f946eee30 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go @@ -5,6 +5,8 @@ package himmelblau import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "net/url" "os" @@ -12,6 +14,7 @@ import ( "sync" "github.com/canonical/authd/log" + "github.com/golang-jwt/jwt/v5" "golang.org/x/oauth2" ) @@ -21,10 +24,8 @@ var ( //nolint:errname // This is not a sentinel error. tpmInitErr error - brokerClientApp *brokerClientApplication - brokerClientAppInitOnce sync.Once - //nolint:errname // This is not a sentinel error. - brokerClientAppInitErr error + brokerClientApps = make(map[brokerClientAppCacheKey]*brokerClientAppEntry) + brokerClientAppsMu sync.Mutex authorityBaseURL = "https://login.microsoftonline.com" authorityBaseURLMu sync.RWMutex @@ -32,6 +33,23 @@ var ( deviceRegistrationMu sync.RWMutex ) +type brokerClientAppCacheKey struct { + authority string + clientID string + transportKeyHash string + certKeyHash string +} + +// brokerClientAppEntry is a cache slot for a broker client app. The once gate +// ensures initBroker runs only once per key while letting unrelated keys +// initialize concurrently (the global mutex is held only for map access, not +// across the cgo call, which performs TPM and network work). +type brokerClientAppEntry struct { + once sync.Once + app *brokerClientApplication + err error +} + func ensureTPMInitialized() error { tpmInitOnce.Do(func() { filters := []string{"warn"} @@ -58,52 +76,99 @@ func ensureTPMInitialized() error { return tpmInitErr } -func ensureBrokerClientAppInitialized(tenantID string, data *DeviceRegistrationData) error { +func brokerClientAppFor(clientID, tenantID string, data *DeviceRegistrationData) (*brokerClientApplication, error) { if err := ensureTPMInitialized(); err != nil { - return err + return nil, err } - brokerClientAppInitOnce.Do(func() { - authorityBaseURLMu.RLock() - authority, err := url.JoinPath(authorityBaseURL, tenantID) - authorityBaseURLMu.RUnlock() - if err != nil { - brokerClientAppInitErr = fmt.Errorf("failed to construct authority URL: %v", err) - return - } - var transportKey []byte - var certKey []byte - if data != nil { - transportKey = data.TransportKey - certKey = data.CertKey - } + authorityBaseURLMu.RLock() + authority, err := url.JoinPath(authorityBaseURL, tenantID) + authorityBaseURLMu.RUnlock() + if err != nil { + return nil, fmt.Errorf("failed to construct authority URL: %v", err) + } - brokerClientApp, brokerClientAppInitErr = initBroker(authority, "", transportKey, certKey) - if brokerClientAppInitErr != nil { - return - } + var transportKey []byte + var certKey []byte + if data != nil { + transportKey = data.TransportKey + certKey = data.CertKey + } + + key := brokerClientAppCacheKey{ + authority: authority, + clientID: clientID, + transportKeyHash: hashCacheKeyBytes(transportKey), + certKeyHash: hashCacheKeyBytes(certKey), + } + + brokerClientAppsMu.Lock() + entry := brokerClientApps[key] + if entry == nil { + entry = &brokerClientAppEntry{} + brokerClientApps[key] = entry + } + brokerClientAppsMu.Unlock() + + entry.once.Do(func() { + entry.app, entry.err = initBroker(authority, clientID, transportKey, certKey) }) + if entry.err != nil { + // Do not cache failures: drop the entry so a later call can retry. + brokerClientAppsMu.Lock() + if brokerClientApps[key] == entry { + delete(brokerClientApps, key) + } + brokerClientAppsMu.Unlock() + return nil, entry.err + } - return brokerClientAppInitErr + return entry.app, nil } -// DeviceRegistrationData contains the data returned by RegisterDevice -// which is needed to acquire an access token later. -type DeviceRegistrationData struct { - DeviceID string `json:"device_id"` - CertKey []byte `json:"cert_key"` - TransportKey []byte `json:"transport_key"` - AuthValue string `json:"auth_value"` - TPMMachineKey []byte `json:"tpm_machine_key"` +func hashCacheKeyBytes(value []byte) string { + if len(value) == 0 { + return "" + } + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) } -// IsValid checks whether all fields of the DeviceRegistrationData are set. -func (d *DeviceRegistrationData) IsValid() bool { - return d.DeviceID != "" && - d.CertKey != nil && - d.TransportKey != nil && - d.AuthValue != "" && - d.TPMMachineKey != nil +func tokenExtrasFromAccessToken(ctx context.Context, accessToken string) map[string]any { + parsedToken, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{}) + if err != nil { + log.Debugf(ctx, "Could not parse access token claims: %v", err) + return nil + } + + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok { + log.Debug(ctx, "Could not cast access token claims to jwt.MapClaims") + return nil + } + + extras := map[string]any{} + if preferredUsername, ok := claims["preferred_username"].(string); ok && preferredUsername != "" { + extras["preferred_username"] = preferredUsername + } else if upn, ok := claims["upn"].(string); ok && upn != "" { + extras["preferred_username"] = upn + } + if sub, ok := claims["sub"].(string); ok && sub != "" { + extras["sub"] = sub + } + if name, ok := claims["name"].(string); ok && name != "" { + extras["name"] = name + } + if scp, ok := claims["scp"].(string); ok && scp != "" { + extras["scp"] = scp + extras["scope"] = scp + } + + if len(extras) == 0 { + return nil + } + + return extras } // RegisterDevice registers the device with Microsoft Entra ID and returns the @@ -139,7 +204,8 @@ func RegisterDevice( } }() - if err := ensureBrokerClientAppInitialized(tenantID, nil); err != nil { + brokerClientApp, err := brokerClientAppFor("", tenantID, nil) + if err != nil { return nil, nil, fmt.Errorf("failed to initialize broker client application: %v", err) } @@ -221,7 +287,14 @@ func AcquireAccessTokenForGraphAPI( token *oauth2.Token, data DeviceRegistrationData, ) (string, error) { - if err := ensureBrokerClientAppInitialized(tenantID, &data); err != nil { + // Pass an empty client ID to broker_init: there it only sets the *default* + // on_behalf_of client ID, which we always override per-call below in + // acquireTokenByRefreshToken. Passing the real client ID here would have no + // effect on the resulting token and would only force a redundant broker_init + // for a separate cache key (device registration initializes the broker app + // with an empty client ID). + brokerClientApp, err := brokerClientAppFor("", tenantID, &data) + if err != nil { return "", fmt.Errorf("failed to initialize broker client application: %v", err) } @@ -242,10 +315,11 @@ func AcquireAccessTokenForGraphAPI( token.RefreshToken, []string{"GroupMember.Read.All"}, "", - // We could use `nil` here instead of the client ID if we also use `nil` as the client ID - // in the `broker_init` call, which means that the user doesn't even have to register - // an OIDC app in Entra. However, that has the effect that we can't fetch the groups - // of the user. + // Acquire the token on behalf of the user's OIDC app. This is what makes + // the user's groups resolvable; without a client ID here (and without an + // OIDC app registered in Entra) the group claims are unavailable. It is + // passed per-call rather than via broker_init because the per-call value + // takes precedence over the broker app's default on_behalf_of client ID. clientID, tpm, machineKey, @@ -263,3 +337,96 @@ func AcquireAccessTokenForGraphAPI( return accessToken, nil } + +// InitiateMFAFlowWithPassword starts the password+MFA flow for a user. +// It submits the user's credentials to Entra ID and returns an MFAFlowState +// that can be used to complete the MFA challenge. +// When withDeviceScope is true, the MFA flow requests scopes required for device +// enrollment. When false, it uses standard scopes without enrollment resources. +func InitiateMFAFlowWithPassword(ctx context.Context, clientID, tenantID string, data *DeviceRegistrationData, username, password string, withDeviceScope bool) (*MFAFlowState, *MFAChallengeInfo, error) { + brokerClientApp, err := brokerClientAppFor(clientID, tenantID, data) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize broker client application: %v", err) + } + + log.Debugf(ctx, "Initiating MFA flow for user %q (withDeviceScope=%v)", username, withDeviceScope) + var flow *MFAFlowState + if withDeviceScope { + flow, err = initiateMFAFlowForEnrollment(brokerClientApp, username, password) + } else { + flow, err = initiateMFAFlow(brokerClientApp, username, password) + } + if err != nil { + return nil, nil, err + } + + msg, err := mfaFlowMessage(flow) + if err != nil { + FreeMFAFlowState(flow) + return nil, nil, err + } + + method, err := mfaFlowMethod(flow) + if err != nil { + FreeMFAFlowState(flow) + return nil, nil, err + } + + challengeInfo := &MFAChallengeInfo{ + Message: msg, + Method: method, + PollingIntervalMs: mfaFlowPollingInterval(flow), + MaxPollAttempts: mfaFlowMaxPollAttempts(flow), + } + + return flow, challengeInfo, nil +} + +// AcquireTokenByMFAFlow completes the MFA challenge (poll or code submission). +// For poll-based MFA, pass empty authData and increment pollAttempt. +// For code-based MFA, pass the code as authData with pollAttempt=0. +// Returns an OAuth token containing the access and refresh tokens from the MFA result. +func AcquireTokenByMFAFlow(ctx context.Context, clientID, tenantID string, data *DeviceRegistrationData, username string, flow *MFAFlowState, authData string, pollAttempt int) (*oauth2.Token, error) { + brokerClientApp, err := brokerClientAppFor(clientID, tenantID, data) + if err != nil { + return nil, fmt.Errorf("failed to initialize broker client application: %v", err) + } + + log.Debugf(ctx, "Acquiring token by MFA flow for user %q (poll_attempt=%d)", username, pollAttempt) + userToken, cleanup, err := acquireTokenByMFAFlow(brokerClientApp, username, flow, authData, pollAttempt) + if err != nil { + return nil, err + } + defer cleanup() + + refreshToken, err := refreshTokenFromUserToken(userToken) + if err != nil { + return nil, fmt.Errorf("failed to extract refresh token from MFA result: %v", err) + } + + accessToken, err := accessTokenFromUserToken(userToken) + if err != nil { + return nil, fmt.Errorf("failed to extract access token from MFA result: %v", err) + } + + // The access token from the native MFA flow is issued for the Entra native API + // and cannot be used with the standard OIDC UserInfo endpoint (different + // audience). Recover non-authoritative token extras from the access token only; + // broker identity binding parses the same access token after verifying it. + extras := map[string]interface{}{} + if accessExtras := tokenExtrasFromAccessToken(ctx, accessToken); len(accessExtras) > 0 { + for k, v := range accessExtras { + extras[k] = v + } + } + + t := &oauth2.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + TokenType: "Bearer", + } + if len(extras) > 0 { + return t.WithExtra(extras), nil + } + return t, nil +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go index cb517d9d75..65a2ad8422 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go @@ -5,6 +5,13 @@ package himmelblau //go:generate ./generate.sh /* +// Define the feature macros that generate.sh enables when building the library +// (changepassword, on_behalf_of). cbindgen guards the corresponding enum +// variants and prototypes behind these macros, so cgo must define them to +// compile the header against the same ABI the shared library exposes. Omitting +// them drops the CHANGE_PASSWORD enum variant, which shifts every later +// MSAL_ERROR_CODE value down by one and misclassifies MFA error codes. +#cgo CFLAGS: -DCHANGEPASSWORD -DON_BEHALF_OF #cgo LDFLAGS: -L${SRCDIR} -lhimmelblau // Add the current directory to the library search path if we're building for testing, // because libhimmelblau is not installed in the standard search directories. @@ -32,6 +39,43 @@ import ( "github.com/canonical/authd/log" ) +// MSAL_ERROR_CODE values, derived from the cgo enum constants rather than +// hardcoded, so they always match the header the binding was compiled against. +// +// The enum values are NOT stable integers: several variants (e.g. CHANGE_PASSWORD) +// are gated behind cargo features, so the numeric value of later variants such as +// MFA_REQUIRED shifts depending on the feature set the library was built with +// (MFA_REQUIRED is 25 with the changepassword feature that generate.sh enables, +// not 24 — 24 is AUTH_CODE_RECEIVED). These are package vars (not a cgo import in +// the test) so the mapping can be unit-tested; test files cannot import cgo. +var ( + codeMFAPollContinue = uint32(C.MFA_POLL_CONTINUE) + codeMFARequired = uint32(C.MFA_REQUIRED) + codeAuthCodeReceived = uint32(C.AUTH_CODE_RECEIVED) + codeAuthorizationDenied = uint32(C.AUTHORIZATION_DENIED) + codeMFAInvalidCode = uint32(C.MFA_INVALID_CODE) + codeMFADAGFallbackDisab = uint32(C.MFA_DAG_FALLBACK_DISABLED) +) + +// mfaErrorCategory maps a libhimmelblau MSAL error code into an +// MFAErrorCategory so the broker can branch on outcomes without +// referencing the underlying numeric codes. +func mfaErrorCategory(code uint32) MFAErrorCategory { + switch code { + case codeMFAPollContinue: + return MFAErrorPollContinue + case codeMFARequired: + return MFAErrorRequired + case codeAuthorizationDenied: + return MFAErrorDenied + case codeMFAInvalidCode: + return MFAErrorRetryableCode + case codeMFADAGFallbackDisab: + return MFAErrorRequired + } + return MFAErrorOther +} + // Entra AADSTS error codes as defined in // https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes const ( @@ -50,8 +94,11 @@ type boxedDynTPM C.BoxedDynTpm type brokerClientApplication C.BrokerClientApplication func setTracingFilter(filter string) error { + // Do NOT free this C string: set_module_tracing_filter takes ownership of it + // (the Rust side reclaims it via CString::from_raw and drops it), so freeing + // it here would be a double free. The const char* in the header is misleading. if msalErr := C.set_module_tracing_filter(C.CString(filter)); msalErr != nil { - return fmt.Errorf("failed to set libhimmelblau tracing filter: %v", C.GoString(msalErr.msg)) + return fmt.Errorf("failed to set libhimmelblau tracing filter: %v", msalErrorMsg(msalErr)) } return nil @@ -65,7 +112,7 @@ func initTPM(tctiName string) (tpm *boxedDynTPM, err error) { } if msalErr := C.tpm_init(cTctiName, (**C.BoxedDynTpm)(unsafe.Pointer(&tpm))); msalErr != nil { - return nil, fmt.Errorf("failed to initialize TPM: %v", C.GoString(msalErr.msg)) + return nil, fmt.Errorf("failed to initialize TPM: %v", msalErrorMsg(msalErr)) } return tpm, nil @@ -93,7 +140,7 @@ func initBroker(authority, clientID string, transportKeyBytes, certKeyBytes []by &cTransportKey, ) if msalErr != nil { - return nil, fmt.Errorf("failed to deserialize transport key: %v", C.GoString(msalErr.msg)) + return nil, fmt.Errorf("failed to deserialize transport key: %v", msalErrorMsg(msalErr)) } defer C.loadable_ms_oapxbc_rsa_key_free(cTransportKey) } @@ -106,7 +153,7 @@ func initBroker(authority, clientID string, transportKeyBytes, certKeyBytes []by &cCertKey, ) if msalErr != nil { - return nil, fmt.Errorf("failed to deserialize cert key: %v", C.GoString(msalErr.msg)) + return nil, fmt.Errorf("failed to deserialize cert key: %v", msalErrorMsg(msalErr)) } defer C.loadable_ms_device_enrollment_key_free(cCertKey) } @@ -122,7 +169,7 @@ func initBroker(authority, clientID string, transportKeyBytes, certKeyBytes []by (**C.BrokerClientApplication)(unsafe.Pointer(&broker)), ) if msalErr != nil { - return nil, fmt.Errorf("failed to initialize broker client: %v", C.GoString(msalErr.msg)) + return nil, fmt.Errorf("failed to initialize broker client: %v", msalErrorMsg(msalErr)) } return broker, nil @@ -145,7 +192,7 @@ func initEnrollAttrs(domain, hostname, osVersion string) (attrs *C.EnrollAttrs, &attrs, ) if msalErr != nil { - return nil, fmt.Errorf("failed to initialize enroll attributes: %v", C.GoString(msalErr.msg)) + return nil, fmt.Errorf("failed to initialize enroll attributes: %v", msalErrorMsg(msalErr)) } // TODO: Do we not have to free the attrs? @@ -156,7 +203,7 @@ func initEnrollAttrs(domain, hostname, osVersion string) (attrs *C.EnrollAttrs, func generateAuthValue() (authValue string, err error) { var cAuthValue *C.char if msalErr := C.auth_value_generate(&cAuthValue); msalErr != nil { - return "", fmt.Errorf("failed to generate auth value: %v", C.GoString(msalErr.msg)) + return "", fmt.Errorf("failed to generate auth value: %v", msalErrorMsg(msalErr)) } defer C.free(unsafe.Pointer(cAuthValue)) @@ -170,7 +217,7 @@ func createTPMMachineKey(tpm *boxedDynTPM, authValue string) (key *C.LoadableMac var loadableMachineKey *C.LoadableMachineKey msalErr := C.tpm_machine_key_create((*C.BoxedDynTpm)(unsafe.Pointer(tpm)), cAuthValue, &loadableMachineKey) if msalErr != nil { - return nil, nil, fmt.Errorf("failed to create loadable machine key: %v", C.GoString(msalErr.msg)) + return nil, nil, fmt.Errorf("failed to create loadable machine key: %v", msalErrorMsg(msalErr)) } cleanup = func() { C.loadable_machine_key_free(loadableMachineKey) } @@ -183,7 +230,7 @@ func loadTPMMachineKey(tpm *boxedDynTPM, authValue string, loadableMachineKey *C defer C.free(unsafe.Pointer(cAuthValue)) if msalErr := C.tpm_machine_key_load((*C.BoxedDynTpm)(unsafe.Pointer(tpm)), cAuthValue, loadableMachineKey, &key); msalErr != nil { - return nil, nil, fmt.Errorf("failed to load TPM machine key: %v", C.GoString(msalErr.msg)) + return nil, nil, fmt.Errorf("failed to load TPM machine key: %v", msalErrorMsg(msalErr)) } cleanup = func() { C.machine_key_free(key) } @@ -210,7 +257,7 @@ func enrollDevice(broker *brokerClientApplication, refreshToken string, attrs *C &cDeviceID, ) if msalErr != nil { - return nil, fmt.Errorf("failed to enroll device: %v", C.GoString(msalErr.msg)) + return nil, fmt.Errorf("failed to enroll device: %v", msalErrorMsg(msalErr)) } defer C.loadable_ms_oapxbc_rsa_key_free(cTransportKey) defer C.loadable_ms_device_enrollment_key_free(cCertKey) @@ -224,7 +271,7 @@ func enrollDevice(broker *brokerClientApplication, refreshToken string, attrs *C defer C.free(unsafe.Pointer(cSerializedCertKey)) msalErr = C.serialize_loadable_ms_device_enrolment_key(cCertKey, &cSerializedCertKey, &cSerializedCertKeyLen) if msalErr != nil { - return nil, fmt.Errorf("failed to serialize device enrollment key: %v", C.GoString(msalErr.msg)) + return nil, fmt.Errorf("failed to serialize device enrollment key: %v", msalErrorMsg(msalErr)) } if cSerializedCertKeyLen > 0 { certKey = C.GoBytes(unsafe.Pointer(cSerializedCertKey), C.int(cSerializedCertKeyLen)) @@ -236,7 +283,7 @@ func enrollDevice(broker *brokerClientApplication, refreshToken string, attrs *C defer C.free(unsafe.Pointer(cSerializedTransportKey)) msalErr = C.serialize_loadable_ms_oapxbc_rsa_key(cTransportKey, &cSerializedTransportKey, &cSerializedTransportKeyLen) if msalErr != nil { - return nil, fmt.Errorf("failed to serialize transport key: %v", C.GoString(msalErr.msg)) + return nil, fmt.Errorf("failed to serialize transport key: %v", msalErrorMsg(msalErr)) } if cSerializedTransportKeyLen > 0 { transportKey = C.GoBytes(unsafe.Pointer(cSerializedTransportKey), C.int(cSerializedTransportKeyLen)) @@ -255,7 +302,7 @@ func serializeLoadableMachineKey(loadableMachineKey *C.LoadableMachineKey) (key defer C.free(unsafe.Pointer(cSerializedKey)) msalErr := C.serialize_loadable_machine_key(loadableMachineKey, &cSerializedKey, &cSerializedKeyLen) if msalErr != nil { - return nil, fmt.Errorf("failed to serialize loadable machine key: %v", C.GoString(msalErr.msg)) + return nil, fmt.Errorf("failed to serialize loadable machine key: %v", msalErrorMsg(msalErr)) } if cSerializedKeyLen > 0 { key = C.GoBytes(unsafe.Pointer(cSerializedKey), C.int(cSerializedKeyLen)) @@ -265,13 +312,18 @@ func serializeLoadableMachineKey(loadableMachineKey *C.LoadableMachineKey) (key } func deserializeLoadableMachineKey(key []byte) (loadableMachineKey *C.LoadableMachineKey, cleanup func(), err error) { + // The C call below indexes &key[0], so an empty key would panic. + if len(key) == 0 { + return nil, nil, fmt.Errorf("no machine key provided to deserialize") + } + msalErr := C.deserialize_loadable_machine_key( (*C.uint8_t)(unsafe.Pointer(&key[0])), C.size_t(len(key)), &loadableMachineKey, ) if msalErr != nil { - return nil, nil, fmt.Errorf("failed to deserialize loadable machine key: %v", C.GoString(msalErr.msg)) + return nil, nil, fmt.Errorf("failed to deserialize loadable machine key: %v", msalErrorMsg(msalErr)) } cleanup = func() { C.loadable_machine_key_free(loadableMachineKey) } @@ -280,6 +332,11 @@ func deserializeLoadableMachineKey(key []byte) (loadableMachineKey *C.LoadableMa } func acquireTokenByRefreshToken(broker *brokerClientApplication, refreshToken string, scopes []string, requestResource string, clientID string, tpm *boxedDynTPM, machineKey *C.MachineKey) (token *C.UserToken, cleanup func(), err error) { + // The C call below indexes &cScopes[0], so an empty scope list would panic. + if len(scopes) == 0 { + return nil, nil, fmt.Errorf("no scopes provided for token acquisition") + } + cRefreshToken := C.CString(refreshToken) defer C.free(unsafe.Pointer(cRefreshToken)) @@ -310,16 +367,17 @@ func acquireTokenByRefreshToken(broker *brokerClientApplication, refreshToken st &cScopes[0], C.int(len(scopes)), cRequestResource, - // We could use `nil` here instead of the client ID if we also use `nil` as the client ID - // in the `broker_init` call, which means that the user doesn't even have to register - // an OIDC app in Entra. However, that has the effect that we can't fetch the groups - // of the user. + // on_behalf_of client ID. Passing it per-call (rather than only via + // broker_init) is what lets us resolve the user's groups: it requests the + // token on behalf of the caller's OIDC app. The per-call value takes + // precedence over the broker app's default on_behalf_of client ID. cClientID, (*C.BoxedDynTpm)(unsafe.Pointer(tpm)), machineKey, &userToken, ) if msalErr != nil { + defer C.error_free(msalErr) // Error codes can be returned by libhimmelblau as a single code in the aadsts_code field or // as a list of error codes in the acquire_token_error_codes field. errorCodes := []C.uint32_t{msalErr.aadsts_code} @@ -358,9 +416,218 @@ func accessTokenFromUserToken(userToken *C.UserToken) (accessToken string, err e var cAccessToken *C.char msalErr := C.user_token_access_token(userToken, &cAccessToken) if msalErr != nil { - return "", fmt.Errorf("failed to get access token: %v", C.GoString(msalErr.msg)) + return "", fmt.Errorf("failed to get access token: %v", msalErrorMsg(msalErr)) } defer C.free(unsafe.Pointer(cAccessToken)) return C.GoString(cAccessToken), nil } + +func refreshTokenFromUserToken(userToken *C.UserToken) (refreshToken string, err error) { + var cRefreshToken *C.char + msalErr := C.user_token_refresh_token(userToken, &cRefreshToken) + if msalErr != nil { + return "", fmt.Errorf("failed to get refresh token: %v", msalErrorMsg(msalErr)) + } + defer C.free(unsafe.Pointer(cRefreshToken)) + + return C.GoString(cRefreshToken), nil +} + +func initiateMFAFlow(broker *brokerClientApplication, username, password string) (*MFAFlowState, error) { + cUsername := C.CString(username) + defer C.free(unsafe.Pointer(cUsername)) + + cPassword := C.CString(password) + defer C.free(unsafe.Pointer(cPassword)) + + // cgo maps the C typedef-enum `AuthOption` to Go's plain uint32 (not C.uint or + // C.AuthOption), so the function parameter `const enum AuthOption *` becomes + // *uint32 in the generated binding. uint32(C.NoDAGFallback) converts the cgo + // constant to the matching Go type. + options := [1]uint32{uint32(C.NoDAGFallback)} + var flow *C.MFAAuthContinue + msalErr := C.broker_initiate_acquire_token_by_mfa_flow( + (*C.BrokerClientApplication)(unsafe.Pointer(broker)), + cUsername, + cPassword, + &options[0], + C.uintptr_t(len(options)), + &flow, + ) + if msalErr != nil { + return nil, newMFAError(msalErr) + } + return newMFAFlowState(flow), nil +} + +// newMFAFlowState wraps a C MFAAuthContinue pointer in the shared MFAFlowState type. +func newMFAFlowState(flow *C.MFAAuthContinue) *MFAFlowState { + return &MFAFlowState{ + opaque: flow, + release: func() { C.mfa_auth_continue_free(flow) }, + } +} + +// cFlow extracts the C MFAAuthContinue pointer from an MFAFlowState. +func cFlow(state *MFAFlowState) *C.MFAAuthContinue { + if state == nil { + return nil + } + flow, ok := state.opaque.(*C.MFAAuthContinue) + if !ok { + return nil + } + return flow +} + +// msalErrorMsg extracts the message from a C MSAL_ERROR and frees it. +// Use it on one-shot error-reporting paths to avoid leaking the error struct. +func msalErrorMsg(msalErr *C.MSAL_ERROR) string { + defer C.error_free(msalErr) + return C.GoString(msalErr.msg) +} + +// newMFAError builds an MFAError from an msalErr and frees it. +func newMFAError(msalErr *C.MSAL_ERROR) *MFAError { + defer C.error_free(msalErr) + msg := C.GoString(msalErr.msg) + category := mfaErrorCategory(msalErr.code) + return &MFAError{ + Category: category, + AADSTS: int(msalErr.aadsts_code), + Message: msg, + } +} + +func initiateMFAFlowForEnrollment(broker *brokerClientApplication, username, password string) (*MFAFlowState, error) { + cUsername := C.CString(username) + defer C.free(unsafe.Pointer(cUsername)) + + cPassword := C.CString(password) + defer C.free(unsafe.Pointer(cPassword)) + + options := [1]uint32{uint32(C.NoDAGFallback)} + var flow *C.MFAAuthContinue + msalErr := C.broker_initiate_acquire_token_by_mfa_flow_for_device_enrollment( + (*C.BrokerClientApplication)(unsafe.Pointer(broker)), + cUsername, + cPassword, + &options[0], + C.uintptr_t(len(options)), + &flow, + ) + if msalErr != nil { + return nil, newMFAError(msalErr) + } + + return newMFAFlowState(flow), nil +} + +func acquireTokenByMFAFlow(broker *brokerClientApplication, username string, flow *MFAFlowState, authData string, pollAttempt int) (token *C.UserToken, cleanup func(), err error) { + if flow == nil { + return nil, nil, fmt.Errorf("missing MFA flow state") + } + // Hold the flow lock for the duration of the C call so that a concurrent + // FreeMFAFlowState (e.g. from EndSession after a cancelled poll) cannot + // free the MFAAuthContinue while it is in use. + flow.mu.Lock() + defer flow.mu.Unlock() + cf := cFlow(flow) + if cf == nil { + return nil, nil, fmt.Errorf("MFA flow state has been released") + } + + cUsername := C.CString(username) + defer C.free(unsafe.Pointer(cUsername)) + + var cAuthData *C.char + if authData != "" { + cAuthData = C.CString(authData) + defer C.free(unsafe.Pointer(cAuthData)) + } + + var userToken *C.UserToken + msalErr := C.broker_acquire_token_by_mfa_flow( + (*C.BrokerClientApplication)(unsafe.Pointer(broker)), + cUsername, + cAuthData, + C.int(pollAttempt), + cf, + &userToken, + ) + if msalErr != nil { + return nil, nil, newMFAError(msalErr) + } + + cleanup = func() { C.user_token_free(userToken) } + return userToken, cleanup, nil +} + +// The mfaFlow* accessors read the continuation state, so they take flow.mu to +// honour MFAFlowState's locking contract (a concurrent FreeMFAFlowState must not +// release the state mid-read). They are currently only called at flow creation, +// before the flow is shared, but locking keeps them safe if that ever changes. +func mfaFlowMessage(flow *MFAFlowState) (string, error) { + if flow == nil { + return "", fmt.Errorf("missing MFA flow state") + } + flow.mu.Lock() + defer flow.mu.Unlock() + c := cFlow(flow) + if c == nil { + return "", fmt.Errorf("missing MFA flow state") + } + var cMsg *C.char + msalErr := C.mfa_auth_continue_msg(c, &cMsg) + if msalErr != nil { + return "", fmt.Errorf("failed to get MFA continue message: %v", msalErrorMsg(msalErr)) + } + defer C.free(unsafe.Pointer(cMsg)) + return C.GoString(cMsg), nil +} + +func mfaFlowMethod(flow *MFAFlowState) (string, error) { + if flow == nil { + return "", fmt.Errorf("missing MFA flow state") + } + flow.mu.Lock() + defer flow.mu.Unlock() + c := cFlow(flow) + if c == nil { + return "", fmt.Errorf("missing MFA flow state") + } + var cMethod *C.char + msalErr := C.mfa_auth_continue_mfa_method(c, &cMethod) + if msalErr != nil { + return "", fmt.Errorf("failed to get MFA method: %v", msalErrorMsg(msalErr)) + } + defer C.free(unsafe.Pointer(cMethod)) + return C.GoString(cMethod), nil +} + +func mfaFlowPollingInterval(flow *MFAFlowState) int { + if flow == nil { + return -1 + } + flow.mu.Lock() + defer flow.mu.Unlock() + c := cFlow(flow) + if c == nil { + return -1 + } + return int(C.mfa_auth_continue_polling_interval(c)) +} + +func mfaFlowMaxPollAttempts(flow *MFAFlowState) int { + if flow == nil { + return -1 + } + flow.mu.Lock() + defer flow.mu.Unlock() + c := cFlow(flow) + if c == nil { + return -1 + } + return int(C.mfa_auth_continue_max_poll_attempts(c)) +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c_test.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c_test.go new file mode 100644 index 0000000000..aa19cfc375 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c_test.go @@ -0,0 +1,64 @@ +//go:build withmsentraid + +package himmelblau + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeserializeLoadableMachineKeyRejectsEmptyKey(t *testing.T) { + t.Parallel() + + // An empty key must return an error rather than panicking on &key[0]. + _, cleanup, err := deserializeLoadableMachineKey(nil) + require.Error(t, err, "deserializeLoadableMachineKey should reject an empty key") + require.Nil(t, cleanup, "no cleanup should be returned on error") + + _, cleanup, err = deserializeLoadableMachineKey([]byte{}) + require.Error(t, err, "deserializeLoadableMachineKey should reject a zero-length key") + require.Nil(t, cleanup, "no cleanup should be returned on error") +} + +// TestMFAErrorCategoryMapping guards against the enum-drift bug where the MFA +// error codes were hardcoded as Go integer literals (mfaRequiredCode = 24). The +// MSAL_ERROR_CODE enum gates some variants (e.g. CHANGE_PASSWORD) behind cargo +// features, so the numeric value of later variants such as MFA_REQUIRED depends on +// the build. The codes are now derived from the cgo enum constants, and this test +// pins both the mapping and the documented layout. +func TestMFAErrorCategoryMapping(t *testing.T) { + t.Parallel() + + require.Equal(t, MFAErrorPollContinue, mfaErrorCategory(codeMFAPollContinue), + "MFA_POLL_CONTINUE must map to MFAErrorPollContinue") + require.Equal(t, MFAErrorRequired, mfaErrorCategory(codeMFARequired), + "MFA_REQUIRED must map to MFAErrorRequired") + require.Equal(t, MFAErrorDenied, mfaErrorCategory(codeAuthorizationDenied), + "AUTHORIZATION_DENIED must map to MFAErrorDenied") + require.Equal(t, MFAErrorRetryableCode, mfaErrorCategory(codeMFAInvalidCode), + "MFA_INVALID_CODE must map to MFAErrorRetryableCode") + require.Equal(t, MFAErrorRequired, mfaErrorCategory(codeMFADAGFallbackDisab), + "MFA_DAG_FALLBACK_DISABLED must map to MFAErrorRequired") + + // The original bug hardcoded mfaRequiredCode=24, which is actually + // AUTH_CODE_RECEIVED once the changepassword feature shifts the enum. That + // misclassified AUTH_CODE_RECEIVED as MFAErrorRequired and let the real + // MFA_REQUIRED (25) fall through to MFAErrorOther, breaking the + // "MFA required -> redirect to Device Authentication" fallback. Pin both + // directions so the literal bug cannot return. + require.Equal(t, MFAErrorOther, mfaErrorCategory(codeAuthCodeReceived), + "AUTH_CODE_RECEIVED must NOT be classified as MFAErrorRequired") + require.NotEqual(t, codeAuthCodeReceived, codeMFARequired, + "AUTH_CODE_RECEIVED and MFA_REQUIRED must be distinct codes") + + // Documented enum layout with the changepassword feature enabled (generate.sh). + // A failure here means the compiled C enum shifted, so the code->category + // mapping (and any other code that depends on these values) must be re-verified. + require.Equal(t, uint32(14), codeMFAPollContinue, "MFA_POLL_CONTINUE is expected to be 14") + require.Equal(t, uint32(24), codeAuthCodeReceived, "AUTH_CODE_RECEIVED is expected to be 24") + require.Equal(t, uint32(25), codeMFARequired, "MFA_REQUIRED is expected to be 25 (changepassword enabled)") + require.Equal(t, uint32(26), codeAuthorizationDenied, "AUTHORIZATION_DENIED is expected to be 26") + require.Equal(t, uint32(27), codeMFAInvalidCode, "MFA_INVALID_CODE is expected to be 27") + require.Equal(t, uint32(28), codeMFADAGFallbackDisab, "MFA_DAG_FALLBACK_DISABLED is expected to be 28") +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/msentraid.go b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go index fcd2321c02..24d00164dd 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msentraid.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go @@ -5,12 +5,17 @@ package msentraid import ( "context" + "crypto/rsa" "encoding/json" "errors" "fmt" + "io" + "net/http" + "net/url" "regexp" "slices" "strings" + "sync" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -20,6 +25,8 @@ import ( providerErrors "github.com/canonical/authd/authd-oidc-brokers/internal/providers/errors" "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/himmelblau" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/tokenverify" + "github.com/canonical/authd/authd-oidc-brokers/internal/token" "github.com/canonical/authd/log" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt/v5" @@ -42,11 +49,29 @@ const ( // Provider is the Microsoft Entra ID provider implementation. type Provider struct { - expectedScopes []string - needsAccessTokenForGraphAPI bool + expectedScopes []string + + // graphClientSecret, when non-empty, enables the app-only (client credentials) + // path for group lookups. The secret belongs to the same client_id configured + // in [oidc]; the app must have the GroupMember.Read.All *Application* permission + // admin-consented in Entra. Populated by SetGraphClientSecret after parsing config. + graphClientSecret string // Used as the token scopes of the access token for the Microsoft Graph API in tests. tokenScopesForGraphAPI []string + + // keySetMu guards keySets. + keySetMu sync.Mutex + // keySets caches RemoteKeySet instances by JWKS URI so that the in-memory + // key cache survives across logins for the same tenant. + keySets map[string]*tokenverify.RemoteKeySet +} + +// SetGraphClientSecret stores the OIDC app's client secret so that GetGroups can +// fall back to the app-only (client credentials) Graph API path when the +// user's delegated token lacks the GroupMember.Read.All scope. +func (p *Provider) SetGraphClientSecret(secret string) { + p.graphClientSecret = secret } // New returns a new MSEntraID provider. @@ -83,18 +108,6 @@ func (p *Provider) getTokenScopes(token *jwt.Token) ([]string, error) { return strings.Split(scopesStr, " "), nil } -func (p *Provider) getAppID(token *jwt.Token) (string, error) { - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return "", fmt.Errorf("failed to cast token claims to MapClaims: %v", token.Claims) - } - appID, ok := claims["appid"].(string) - if !ok { - return "", fmt.Errorf("failed to cast appid claim to string: %v", claims["appid"]) - } - return appID, nil -} - // GetExtraFields returns the extra fields of the token which should be stored persistently. func (p *Provider) GetExtraFields(token *oauth2.Token) map[string]interface{} { return map[string]interface{}{ @@ -145,7 +158,70 @@ func (p *Provider) GetUserInfo(claimer info.Claimer, _ bool) (info.User, error) ), nil } +// UserInfoFromAccessToken extracts user info from an Entra access token's +// claims. Access tokens use oid/upn-style claims rather than the OIDC +// preferred_username/sub pair that GetUserInfo expects, so remap them first and +// then reuse the standard GetUserInfo path. +func (p *Provider) UserInfoFromAccessToken(accessToken string) (info.User, error) { + parsed, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{}) + if err != nil { + return info.User{}, fmt.Errorf("failed to parse access token claims: %w", err) + } + + rawClaims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + return info.User{}, errors.New("failed to cast access token claims to MapClaims") + } + if oid, _ := rawClaims["oid"].(string); oid != "" { + rawClaims["sub"] = oid + } + if upn, _ := rawClaims["upn"].(string); upn != "" { + rawClaims["preferred_username"] = upn + } else if email, _ := rawClaims["email"].(string); email != "" { + rawClaims["preferred_username"] = email + } + + return p.GetUserInfo(tokenClaimer(rawClaims), false) +} + // GetGroups retrieves the groups the user is a member of via the Microsoft Graph API. +// +// There are three ways the groups can be resolved, tried in this order: +// +// 1. Client credentials (app-only): used when a [oidc] client_secret is +// configured and the current token does not already carry the +// GroupMember.Read.All scope. This is the path that makes the +// entra_password + MFA flow work *without* device registration: the +// delegated token issued by the Microsoft Broker App during native MFA +// cannot be exchanged for a Graph-scoped delegated token for our OIDC app +// (the FOCI scope wall — see below), so we fall back to an +// application-level token. It requires the app registration to hold the +// GroupMember.Read.All *Application* permission with tenant admin consent. +// Trade-off: an app-only token reflects the directory's group membership, +// not the user's delegated session, so a per-user session revocation is not +// observed at this step (the MFA challenge itself is the live per-user +// check). +// 2. Device-registration token exchange: used when needsAccessTokenForGraphAPI +// is set (the cached token was obtained via device registration). The PRT is +// exchanged for a Graph-scoped access token. +// 3. The current delegated token directly, when it already carries +// GroupMember.Read.All. +// +// FOCI scope wall: FOCI (Family of Client IDs) is the Microsoft mechanism by +// which a set of first-party apps — including the Microsoft Broker App that the +// native MFA flow authenticates as — share a single "family" refresh token. A +// family refresh token can only be redeemed for other apps in that family, not +// for an arbitrary third-party app such as our customer-registered OIDC app. +// Crossing from the broker to a delegated, Graph-scoped token for our OIDC app +// requires a Primary Refresh Token (PRT), which only exists once the device is +// registered. So without device registration there is no PRT, the broker's +// delegated token is walled off from our app's Graph scope, and strategy 1 +// (the app-only token) is the only way to resolve groups. +// +// Strategy 1 is what lets register_device=false deployments resolve groups; if +// the project later decides to require register_device=true for the MFA flow, +// the client_secret path (and SetGraphClientSecret/GraphClientSecretSetter) can +// be dropped in favour of strategy 2 alone. func (p *Provider) GetGroups( ctx context.Context, clientID string, @@ -153,9 +229,49 @@ func (p *Provider) GetGroups( token *oauth2.Token, providerMetadata map[string]interface{}, deviceRegistrationDataJSON []byte, + needsAccessTokenForGraphAPI bool, ) ([]info.Group, error) { accessTokenStr := token.AccessToken - if p.needsAccessTokenForGraphAPI { + accessTokenHasGraphScope := false + // Parse early to check whether the token already carries the required graph scope. + accessToken, _, parseErr := new(jwt.Parser).ParseUnverified(accessTokenStr, jwt.MapClaims{}) + if parseErr == nil { + if scopes, scopeErr := p.getTokenScopes(accessToken); scopeErr == nil { + accessTokenHasGraphScope = slices.Contains(scopes, "GroupMember.Read.All") + } + } + + // If a client secret is configured and the token lacks the Graph scope, use + // the app-only (client credentials) path instead of the delegated-token path. + // This bypasses the FOCI scope wall that prevents third-party apps from + // exchanging MFA tokens for Graph-scoped delegated tokens. + // + // Exclude tokens that need device-registration token exchange + // (needsAccessTokenForGraphAPI): those have a PRT that can be exchanged for a + // Graph-scoped token (strategy 2), which preserves the user's delegated + // session semantics. This keeps register_device=true logins — both + // device-code and entra_password — on the PRT path even when a client_secret + // is configured, so only entra_password-without-device-registration tokens + // take the app-only path. + if p.graphClientSecret != "" && !accessTokenHasGraphScope && !needsAccessTokenForGraphAPI { + if parseErr != nil { + return nil, fmt.Errorf("failed to parse access token for client credentials group lookup: %w", parseErr) + } + oid, oidErr := p.getOIDFromToken(accessToken) + if oidErr != nil { + log.Noticef(ctx, "Could not extract OID from access token for client credentials path, falling back: %v", oidErr) + } else { + host := resolveMSGraphHost(providerMetadata) + // Make the path switch observable: this resolves groups from the + // directory's view of the user via an app-only token, NOT the user's + // delegated session, so per-user session/account-status revocation is + // not reflected here (see the GetGroups doc comment). + log.Infof(ctx, "Resolving groups for OID %s via app-only client credentials (delegated token lacks GroupMember.Read.All)", oid) + return p.fetchUserGroupsByClientCredentials(ctx, clientID, issuerURL, oid, host) + } + } + + if needsAccessTokenForGraphAPI && !accessTokenHasGraphScope { var data himmelblau.DeviceRegistrationData err := json.Unmarshal(deviceRegistrationDataJSON, &data) if err != nil { @@ -179,32 +295,39 @@ func (p *Provider) GetGroups( if err != nil { return nil, fmt.Errorf("failed to acquire access token for Microsoft Graph API: %w", err) } + + // Re-parse the newly acquired token. + accessToken, _, parseErr = new(jwt.Parser).ParseUnverified(accessTokenStr, jwt.MapClaims{}) } // Parse the access token without signature verification, because we're not the audience of the token (that's // the Microsoft Graph API) and we don't use it for authentication, but only to access the Microsoft Graph API. - accessToken, _, err := new(jwt.Parser).ParseUnverified(accessTokenStr, jwt.MapClaims{}) - if err != nil { - return nil, fmt.Errorf("failed to parse access token: %w", err) + if parseErr != nil { + return nil, fmt.Errorf("failed to parse access token: %w", parseErr) } - msgraphHost := fmt.Sprintf("https://%s/%s", defaultMSGraphHost, msgraphAPIVersion) - if providerMetadata["msgraph_host"] != nil { - var ok bool - msgraphHost, ok = providerMetadata["msgraph_host"].(string) - if !ok { - return nil, fmt.Errorf("failed to cast msgraph_host to string: %v", providerMetadata["msgraph_host"]) - } - - // Handle the case that the provider metadata only contains the host without the protocol and API version, - // as was the case before 5fc98520c45294ffb85bb27a81929e2ec1b89fcb. This fixes #858. - if !strings.Contains(msgraphHost, "://") { - msgraphHost = fmt.Sprintf("https://%s/%s", msgraphHost, msgraphAPIVersion) - } - } + msgraphHost := resolveMSGraphHost(providerMetadata) return p.fetchUserGroups(accessToken, msgraphHost) } +// resolveMSGraphHost resolves the Microsoft Graph API host URL from provider metadata, +// falling back to the default public endpoint when metadata is absent or malformed. +func resolveMSGraphHost(providerMetadata map[string]interface{}) string { + if providerMetadata["msgraph_host"] == nil { + return fmt.Sprintf("https://%s/%s", defaultMSGraphHost, msgraphAPIVersion) + } + msgraphHost, ok := providerMetadata["msgraph_host"].(string) + if !ok { + return fmt.Sprintf("https://%s/%s", defaultMSGraphHost, msgraphAPIVersion) + } + // Handle the case that the provider metadata only contains the host without the protocol and API version, + // as was the case before 5fc98520c45294ffb85bb27a81929e2ec1b89fcb. This fixes #858. + if !strings.Contains(msgraphHost, "://") { + msgraphHost = fmt.Sprintf("https://%s/%s", msgraphHost, msgraphAPIVersion) + } + return msgraphHost +} + type claims struct { PreferredUserName string `json:"preferred_username"` // Oid is the Object ID — the stable, cross-application identifier for a user @@ -215,6 +338,18 @@ type claims struct { Gecos string `json:"name"` } +// tokenClaimer implements info.Claimer for a JWT MapClaims map, +// allowing access-token claims to be fed through the standard GetUserInfo path. +type tokenClaimer jwt.MapClaims + +func (tc tokenClaimer) Claims(v any) error { + b, err := json.Marshal(jwt.MapClaims(tc)) + if err != nil { + return err + } + return json.Unmarshal(b, v) +} + // userClaims returns the user claims parsed from the ID token. func (p *Provider) userClaims(idToken info.Claimer) (claims, error) { var userClaims claims @@ -224,6 +359,158 @@ func (p *Provider) userClaims(idToken info.Claimer) (claims, error) { return userClaims, nil } +// newGraphServiceClient builds a Microsoft Graph client that authenticates with +// the given parsed JWT against the resolved Graph host. +func newGraphServiceClient(token *jwt.Token, msgraphHost string) (*msgraphsdk.GraphServiceClient, error) { + cred := azureTokenCredential{token: token} + auth, err := msgraphauth.NewAzureIdentityAuthenticationProvider(cred) + if err != nil { + return nil, fmt.Errorf("failed to create AzureIdentityAuthenticationProvider: %w", err) + } + + adapter, err := msgraphsdk.NewGraphRequestAdapter(auth) + if err != nil { + return nil, fmt.Errorf("failed to create GraphRequestAdapter: %w", err) + } + adapter.SetBaseUrl(msgraphHost) + + return msgraphsdk.NewGraphServiceClient(adapter), nil +} + +// fetchUserGroupsByClientCredentials acquires an application-level Graph API token +// via client credentials and fetches groups for the given user OID. It is used +// when the delegated token lacks GroupMember.Read.All (e.g., native Entra MFA flow). +func (p *Provider) fetchUserGroupsByClientCredentials(ctx context.Context, clientID, issuerURL, userOID, msgraphHost string) ([]info.Group, error) { + log.Debugf(ctx, "Getting user groups via client credentials for OID %s", userOID) + + appTokenStr, err := acquireClientCredentialsToken(ctx, issuerURL, clientID, p.graphClientSecret, msgraphHost) + if err != nil { + return nil, fmt.Errorf("failed to acquire client credentials token for Graph API: %w", err) + } + + appToken, _, err := new(jwt.Parser).ParseUnverified(appTokenStr, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("failed to parse client credentials token: %w", err) + } + + client, err := newGraphServiceClient(appToken, msgraphHost) + if err != nil { + return nil, err + } + + graphGroups, err := getSecurityGroupsByUserID(client, userOID) + if err != nil { + return nil, err + } + + return processSecurityGroups(graphGroups) +} + +// getOIDFromToken extracts the object ID (oid claim) from a parsed JWT token. +func (p *Provider) getOIDFromToken(token *jwt.Token) (string, error) { + if token == nil { + return "", errors.New("access token is nil") + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", errors.New("failed to cast token claims to MapClaims") + } + oid, ok := claims["oid"].(string) + if !ok || oid == "" { + return "", errors.New("oid claim not found or empty in access token") + } + return oid, nil +} + +// acquireClientCredentialsToken obtains an application-level access token for +// the resolved Microsoft Graph host using the OAuth2 client credentials flow. +func acquireClientCredentialsToken(ctx context.Context, issuerURL, clientID, clientSecret, msgraphHost string) (string, error) { + tokenURL, err := clientCredentialsTokenURL(issuerURL) + if err != nil { + return "", err + } + scope, err := graphDefaultScope(msgraphHost) + if err != nil { + return "", err + } + + form := url.Values{} + form.Set("grant_type", "client_credentials") + form.Set("client_id", clientID) + form.Set("client_secret", clientSecret) + form.Set("scope", scope) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("failed to build client credentials request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("client credentials token request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", fmt.Errorf("client credentials token request returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var result struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode token response: %w", err) + } + if result.Error != "" { + return "", fmt.Errorf("client credentials token error %q: %s", result.Error, result.ErrorDescription) + } + if result.AccessToken == "" { + return "", errors.New("empty access_token in client credentials response") + } + return result.AccessToken, nil +} + +func clientCredentialsTokenURL(issuerURL string) (string, error) { + issuer, err := url.Parse(issuerURL) + if err != nil { + return "", fmt.Errorf("failed to parse issuer URL: %w", err) + } + if issuer.Scheme == "" || issuer.Host == "" { + return "", fmt.Errorf("issuer URL %q must include a scheme and host", issuerURL) + } + + tid := tenantID(issuerURL) + if tid == "" { + return "", fmt.Errorf("tenant ID not found in issuer URL %q", issuerURL) + } + + baseURL := (&url.URL{Scheme: issuer.Scheme, Host: issuer.Host}).String() + tokenURL, err := url.JoinPath(baseURL, tid, "oauth2", "v2.0", "token") + if err != nil { + return "", fmt.Errorf("failed to construct client credentials token URL: %w", err) + } + + return tokenURL, nil +} + +func graphDefaultScope(msgraphHost string) (string, error) { + graphURL, err := url.Parse(msgraphHost) + if err != nil { + return "", fmt.Errorf("failed to parse Microsoft Graph host: %w", err) + } + if graphURL.Scheme == "" || graphURL.Host == "" { + return "", fmt.Errorf("the Microsoft Graph host %q must include a scheme and host", msgraphHost) + } + + return (&url.URL{Scheme: graphURL.Scheme, Host: graphURL.Host, Path: ".default"}).String(), nil +} + // fetchUserGroups access the Microsoft Graph API to get the groups the user is a member of. func (p *Provider) fetchUserGroups(token *jwt.Token, msgraphHost string) ([]info.Group, error) { log.Debug(context.Background(), "Getting user groups from Microsoft Graph API") @@ -244,19 +531,10 @@ func (p *Provider) fetchUserGroups(token *jwt.Token, msgraphHost string) ([]info return nil, &providerErrors.ForDisplayError{Message: msg} } - cred := azureTokenCredential{token: token} - auth, err := msgraphauth.NewAzureIdentityAuthenticationProvider(cred) - if err != nil { - return nil, fmt.Errorf("failed to create AzureIdentityAuthenticationProvider: %v", err) - } - - adapter, err := msgraphsdk.NewGraphRequestAdapter(auth) + client, err := newGraphServiceClient(token, msgraphHost) if err != nil { - return nil, fmt.Errorf("failed to create GraphRequestAdapter: %v", err) + return nil, err } - adapter.SetBaseUrl(msgraphHost) - - client := msgraphsdk.NewGraphServiceClient(adapter) // Get the groups (only the groups, not directory roles or administrative units, because that would require // additional permissions) which the user is a member of. @@ -265,6 +543,12 @@ func (p *Provider) fetchUserGroups(token *jwt.Token, msgraphHost string) ([]info return nil, err } + return processSecurityGroups(graphGroups) +} + +// processSecurityGroups converts a slice of Graph API group objects into the +// internal info.Group representation, deduplicating and normalising names. +func processSecurityGroups(graphGroups []msgraphmodels.Groupable) ([]info.Group, error) { var groups []info.Group var msGroupNames []string for _, msGroup := range graphGroups { @@ -359,30 +643,23 @@ func removeNonSecurityGroups(groups []msgraphmodels.Groupable) []msgraphmodels.G return securityGroups } -func getSecurityGroups(client *msgraphsdk.GraphServiceClient) ([]msgraphmodels.Groupable, error) { - // Initial request to get groups - requestBuilder := client.Me().TransitiveMemberOf().GraphGroup() - result, err := requestBuilder.Get(context.Background(), nil) +// collectSecurityGroups walks a paged Microsoft Graph group query to completion +// and returns the security groups. getPage fetches the first page when nextLink +// is empty, and the page at nextLink otherwise; a nil page (no response) is +// treated as "user is not a member of any group". logContext is appended to the +// debug log line (e.g. " for user "). +func collectSecurityGroups(logContext string, getPage func(nextLink string) ([]msgraphmodels.Groupable, *string, error)) ([]msgraphmodels.Groupable, error) { + groups, nextLink, err := getPage("") if err != nil { - return nil, fmt.Errorf("failed to get user groups: %v", err) - } - if result == nil { - log.Debug(context.Background(), "Got nil response from Microsoft Graph API for user's groups, assuming that user is not a member of any group.") - return []msgraphmodels.Groupable{}, nil + return nil, err } - - groups := result.GetValue() - - // Continue fetching groups using paging if a next link is available - for result.GetOdataNextLink() != nil { - nextLink := *result.GetOdataNextLink() - - result, err = requestBuilder.WithUrl(nextLink).Get(context.Background(), nil) + for nextLink != nil { + var page []msgraphmodels.Groupable + page, nextLink, err = getPage(*nextLink) if err != nil { - return nil, fmt.Errorf("failed to get next page of user groups: %v", err) + return nil, err } - - groups = append(groups, result.GetValue()...) + groups = append(groups, page...) } // Remove the groups which are not security groups (but for example Microsoft 365 groups, which can be created @@ -391,16 +668,56 @@ func getSecurityGroups(client *msgraphsdk.GraphServiceClient) ([]msgraphmodels.G var groupNames []string for _, group := range groups { - groupNamePtr := group.GetDisplayName() - if groupNamePtr != nil { + if groupNamePtr := group.GetDisplayName(); groupNamePtr != nil { groupNames = append(groupNames, *groupNamePtr) } } - log.Debugf(context.Background(), "Got groups: %s", strings.Join(groupNames, ", ")) + log.Debugf(context.Background(), "Got groups%s: %s", logContext, strings.Join(groupNames, ", ")) return groups, nil } +func getSecurityGroups(client *msgraphsdk.GraphServiceClient) ([]msgraphmodels.Groupable, error) { + requestBuilder := client.Me().TransitiveMemberOf().GraphGroup() + return collectSecurityGroups("", func(nextLink string) ([]msgraphmodels.Groupable, *string, error) { + rb := requestBuilder + if nextLink != "" { + rb = requestBuilder.WithUrl(nextLink) + } + result, err := rb.Get(context.Background(), nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to get user groups: %v", err) + } + if result == nil { + log.Debug(context.Background(), "Got nil response from Microsoft Graph API for user's groups, assuming that user is not a member of any group.") + return nil, nil, nil + } + return result.GetValue(), result.GetOdataNextLink(), nil + }) +} + +// getSecurityGroupsByUserID fetches security groups for a specific user OID using +// the application-permission endpoint /users/{id}/transitiveMemberOf/microsoft.graph.group. +// This requires GroupMember.Read.All Application permission and an app-only token. +func getSecurityGroupsByUserID(client *msgraphsdk.GraphServiceClient, userID string) ([]msgraphmodels.Groupable, error) { + requestBuilder := client.Users().ByUserId(userID).TransitiveMemberOf().GraphGroup() + return collectSecurityGroups(fmt.Sprintf(" for user %s", userID), func(nextLink string) ([]msgraphmodels.Groupable, *string, error) { + rb := requestBuilder + if nextLink != "" { + rb = requestBuilder.WithUrl(nextLink) + } + result, err := rb.Get(context.Background(), nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to get user groups by user ID: %v", err) + } + if result == nil { + log.Debug(context.Background(), "Got nil response from Microsoft Graph API for user's groups, assuming that user is not a member of any group.") + return nil, nil, nil + } + return result.GetValue(), result.GetOdataNextLink(), nil + }) +} + func isSecurityGroup(group msgraphmodels.Groupable) bool { // A group is a security group if the `securityEnabled` property is true and the `groupTypes` property does not // contain "Unified". @@ -420,9 +737,124 @@ func (p *Provider) NormalizeUsername(username string) string { return strings.ToLower(username) } -// SupportedOIDCAuthModes returns the OIDC authentication modes supported by the provider. -func (p *Provider) SupportedOIDCAuthModes() []string { - return []string{authmodes.Device, authmodes.DeviceQr} +// SupportedOnlineAuthModes returns the authentication modes supported by the +// provider that require a connection to Entra ID. +func (p *Provider) SupportedOnlineAuthModes() []string { + return []string{authmodes.EntraPassword, authmodes.Device, authmodes.DeviceQr} +} + +// unmarshalOptionalDeviceRegistrationData decodes JSON device-registration data +// when present. Returns nil (and no error) when raw is empty. +func unmarshalOptionalDeviceRegistrationData(raw []byte) (*himmelblau.DeviceRegistrationData, error) { + if len(raw) == 0 { + return nil, nil + } + data := &himmelblau.DeviceRegistrationData{} + if err := json.Unmarshal(raw, data); err != nil { + return nil, fmt.Errorf("failed to unmarshal device registration data: %v", err) + } + return data, nil +} + +// InitiateEntraPasswordAuth starts the Entra password + MFA flow. +func (p *Provider) InitiateEntraPasswordAuth( + ctx context.Context, + clientID string, + issuerURL string, + username, password string, + deviceRegistrationData []byte, + withDeviceScope bool, +) (*himmelblau.MFAFlowState, *himmelblau.MFAChallengeInfo, error) { + tid := tenantID(issuerURL) + + data, err := unmarshalOptionalDeviceRegistrationData(deviceRegistrationData) + if err != nil { + return nil, nil, err + } + + return himmelblau.InitiateMFAFlowWithPassword(ctx, clientID, tid, data, username, password, withDeviceScope) +} + +// AcquireTokenByMFAFlow completes the MFA challenge. +func (p *Provider) AcquireTokenByMFAFlow( + ctx context.Context, + clientID string, + issuerURL string, + username string, + flow *himmelblau.MFAFlowState, + authData string, + pollAttempt int, + deviceRegistrationData []byte, +) (*oauth2.Token, error) { + tid := tenantID(issuerURL) + + data, err := unmarshalOptionalDeviceRegistrationData(deviceRegistrationData) + if err != nil { + return nil, err + } + + return himmelblau.AcquireTokenByMFAFlow(ctx, clientID, tid, data, username, flow, authData, pollAttempt) +} + +// RefreshEntraPasswordToken refreshes the cached Entra password + MFA refresh token +// as the Microsoft Broker app (a public client, no client_secret) for basic scopes +// only, to re-verify the account on a returning login. The Broker app is the client +// that issued the family refresh token during the MFA flow; the configured OIDC app +// cannot redeem it. Basic scopes (never Microsoft Graph) avoid the Broker-app↔Graph +// preauthorization wall (AADSTS65002), so this works for any register_device setting. +// A failure is returned as the underlying *oauth2.RetrieveError so the broker can +// classify it exactly like the device-auth refresh. +func (p *Provider) RefreshEntraPasswordToken(ctx context.Context, issuerURL, refreshToken string) (*oauth2.Token, error) { + tokenURL, err := clientCredentialsTokenURL(issuerURL) + if err != nil { + return nil, fmt.Errorf("could not build token URL for Entra password refresh: %w", err) + } + + cfg := oauth2.Config{ + ClientID: consts.MicrosoftBrokerAppID, + Scopes: []string{"openid", "profile", "offline_access"}, + Endpoint: oauth2.Endpoint{TokenURL: tokenURL, AuthStyle: oauth2.AuthStyleInParams}, + } + + tok, err := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}).Token() + if err != nil { + return nil, err + } + + return tok, nil +} + +// VerifyAccessToken verifies the RS256 signature of the MFA-flow access token +// against the tenant's published JWKS and that its tenant claim matches the one +// in issuerURL. Microsoft first-party (e.g. Graph) tokens carry a header nonce +// that is SHA256-rewritten before signing; tokenverify handles that. This is the +// defense-in-depth check that the token genuinely came from Microsoft, so the +// identity extracted from its claims does not rest on TLS alone. +func (p *Provider) VerifyAccessToken(ctx context.Context, issuerURL, accessToken string) error { + u, err := url.Parse(issuerURL) + if err != nil { + return fmt.Errorf("could not parse issuer URL %q: %w", issuerURL, err) + } + segments := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(segments) == 0 || segments[0] == "" { + return fmt.Errorf("could not derive tenant from issuer URL %q", issuerURL) + } + tenantID := segments[0] + jwksURI := fmt.Sprintf("%s://%s/%s/discovery/v2.0/keys", u.Scheme, u.Host, tenantID) + + p.keySetMu.Lock() + if p.keySets == nil { + p.keySets = make(map[string]*tokenverify.RemoteKeySet) + } + if _, ok := p.keySets[jwksURI]; !ok { + p.keySets[jwksURI] = tokenverify.NewRemoteKeySet(jwksURI, nil) + } + keySet := p.keySets[jwksURI] + p.keySetMu.Unlock() + + return tokenverify.Verify(accessToken, tenantID, func(kid string) (*rsa.PublicKey, error) { + return keySet.KeyForKID(ctx, kid) + }) } // VerifyUsername checks if the authenticated username matches the requested username and that both are valid. @@ -449,19 +881,12 @@ func (p *Provider) VerifyUsername(requestedUsername, authenticatedUsername strin return nil } -// IsTokenForDeviceRegistration checks if the token is for device registration. -func (p *Provider) IsTokenForDeviceRegistration(token *oauth2.Token) (bool, error) { - accessToken, _, err := new(jwt.Parser).ParseUnverified(token.AccessToken, jwt.MapClaims{}) - if err != nil { - return false, fmt.Errorf("failed to parse access token: %v", err) - } - - appID, err := p.getAppID(accessToken) - if err != nil { - return false, fmt.Errorf("failed to get app ID from access token: %v", err) - } - - return appID == consts.MicrosoftBrokerAppID, nil +// IsTokenForDeviceRegistration reports whether the cached token carries +// device-registration data. The entra_password MFA flow issues tokens under the +// Microsoft Broker App ID too, so the App ID alone cannot distinguish a +// device-registration token; the presence of device-registration data can. +func (p *Provider) IsTokenForDeviceRegistration(authInfo *token.AuthCachedInfo) bool { + return len(authInfo.DeviceRegistrationData) > 0 } // MaybeRegisterDevice checks if the device is already registered and registers it if not. @@ -472,10 +897,6 @@ func (p *Provider) MaybeRegisterDevice( issuerURL string, jsonData []byte, ) (registrationData []byte, cleanup func(), err error) { - // If this function is called, it means that the token that we have is for device registration, - // so we can't use it to access the Microsoft Graph API. - p.needsAccessTokenForGraphAPI = true - nop := func() {} // Check if the device is already registered @@ -520,6 +941,13 @@ func (p *Provider) MaybeRegisterDevice( // For example, given: https://login.microsoftonline.com/8de88d99-6d0f-44d7-a8a5-925b012e5940/v2.0 // it returns: 8de88d99-6d0f-44d7-a8a5-925b012e5940. func tenantID(issuerURL string) string { + if issuer, err := url.Parse(issuerURL); err == nil { + issuerPath := strings.Trim(issuer.Path, "/") + if issuerPath != "" { + return strings.Split(issuerPath, "/")[0] + } + } + return strings.Split(strings.TrimPrefix(issuerURL, "https://login.microsoftonline.com/"), "/")[0] } diff --git a/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go b/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go index bc309f7734..c7231acc32 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go @@ -7,16 +7,19 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" "os" "strings" "sync" + "sync/atomic" "testing" "time" - "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid" "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/himmelblau" "github.com/canonical/authd/authd-oidc-brokers/internal/testutils" + "github.com/canonical/authd/authd-oidc-brokers/internal/token" "github.com/canonical/authd/internal/testutils/golden" "github.com/canonical/authd/log" "github.com/golang-jwt/jwt/v5" @@ -132,6 +135,63 @@ func TestGetUserInfo(t *testing.T) { } } +func TestUserInfoFromAccessToken(t *testing.T) { + t.Parallel() + + accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "oid": "saved-user-id", + "upn": "test-user@email.com", + "name": "test-user", + }) + accessTokenStr, err := accessToken.SignedString(testutils.MockKey) + require.NoError(t, err, "Failed to sign access token") + + got, err := msentraid.New().UserInfoFromAccessToken(accessTokenStr) + require.NoError(t, err, "UserInfoFromAccessToken should not return an error") + require.Equal(t, info.NewUser("test-user@email.com", "", "saved-user-id", "", "test-user", nil), got) +} + +func TestRefreshEntraPasswordToken(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + refreshHandler http.HandlerFunc + wantErr bool + wantErrSubstr string + }{ + "Active_user_refresh_succeeds": {}, + "Disabled_user_returns_AADSTS50057": { + refreshHandler: disabledRefreshHandler, + wantErr: true, + wantErrSubstr: "AADSTS50057", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockServer, cleanup := startMockMSServer(t, &mockMSServerConfig{RefreshHandler: tc.refreshHandler}) + t.Cleanup(cleanup) + + got, err := msentraid.New().RefreshEntraPasswordToken( + context.Background(), + mockServer.URL+"/tenant-id/v2.0", + "refreshtoken", + ) + if tc.wantErr { + require.Error(t, err, "RefreshEntraPasswordToken should fail") + require.Contains(t, err.Error(), tc.wantErrSubstr, "unexpected error from refresh") + return + } + require.NoError(t, err, "RefreshEntraPasswordToken should succeed for an active user") + require.NotEmpty(t, got.AccessToken, "expected a rotated token on success") + require.Nil(t, got.Extra("preferred_username"), "refresh should not add redundant preferred_username extras") + require.Nil(t, got.Extra("sub"), "refresh should not add redundant sub extras") + require.Nil(t, got.Extra("name"), "refresh should not add redundant name extras") + }) + } +} + func TestGetGroups(t *testing.T) { t.Parallel() @@ -190,7 +250,6 @@ func TestGetGroups(t *testing.T) { } p := msentraid.New() - p.SetNeedsAccessTokenForGraphAPI(tc.acquireAccessToken) p.SetTokenScopesForGraphAPI(tc.tokenScopes) got, err := p.GetGroups( @@ -200,6 +259,7 @@ func TestGetGroups(t *testing.T) { token, tc.providerMetadata, deviceRegistrationData, + tc.acquireAccessToken, ) if tc.wantErr { require.Error(t, err, "GetUserInfo should return an error") @@ -212,51 +272,233 @@ func TestGetGroups(t *testing.T) { } } +func TestGetGroupsUsesCurrentTokenWhenAlreadyGraphCapable(t *testing.T) { + t.Parallel() + + accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "scp": "GroupMember.Read.All User.Read", + }) + accessTokenStr, err := accessToken.SignedString(testutils.MockKey) + require.NoError(t, err, "Failed to sign access token") + + token := &oauth2.Token{ + AccessToken: accessTokenStr, + RefreshToken: "refreshtoken", + Expiry: time.Now().Add(1000 * time.Hour), + } + + mockServer, cleanup := startMockMSServer(t, nil) + t.Cleanup(cleanup) + + p := msentraid.New() + + got, err := p.GetGroups( + context.Background(), + "", + "", + token, + map[string]any{"msgraph_host": mockServer.URL}, + nil, + true, + ) + require.NoError(t, err, "GetGroups should use the current token when it already has Graph scopes") + require.ElementsMatch(t, []info.Group{ + {Name: "group1", UGID: "id1"}, + {Name: "group2", UGID: "id2"}, + }, got) +} + +func TestGetGroupsUsesClientCredentialsFallback(t *testing.T) { + t.Parallel() + + mockServer, cleanup := startMockMSServer(t, nil) + t.Cleanup(cleanup) + + accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "oid": "00000000-0000-0000-0000-000000000000", + }) + accessTokenStr, err := accessToken.SignedString(testutils.MockKey) + require.NoError(t, err, "Failed to sign access token") + + token := &oauth2.Token{ + AccessToken: accessTokenStr, + RefreshToken: "refreshtoken", + Expiry: time.Now().Add(1000 * time.Hour), + } + + p := msentraid.New() + p.SetGraphClientSecret("client-secret") + + got, err := p.GetGroups( + context.Background(), + "client-id", + mockServer.URL+"/tenant-id/v2.0", + token, + map[string]any{"msgraph_host": mockServer.URL}, + nil, + false, + ) + require.NoError(t, err, "GetGroups should fall back to client credentials when the delegated token lacks Graph scope") + require.ElementsMatch(t, []info.Group{ + {Name: "group1", UGID: "id1"}, + {Name: "group2", UGID: "id2"}, + }, got) +} + +func TestGetGroupsDeviceRegistrationTokenDoesNotUseClientCredentials(t *testing.T) { + t.Parallel() + + mockServer, cleanup := startMockMSServer(t, nil) + t.Cleanup(cleanup) + + accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "oid": "00000000-0000-0000-0000-000000000000", + }) + accessTokenStr, err := accessToken.SignedString(testutils.MockKey) + require.NoError(t, err, "Failed to sign access token") + + token := &oauth2.Token{ + AccessToken: accessTokenStr, + RefreshToken: "refreshtoken", + Expiry: time.Now().Add(1000 * time.Hour), + } + + p := msentraid.New() + p.SetGraphClientSecret("client-secret") + + // needsAccessTokenForGraphAPI=true marks a device-registration token, which + // must be exchanged via the PRT path (strategy 2) rather than the app-only + // client-credentials path, even when a client secret is configured. With no + // device registration data the PRT path fails, but it must NOT silently fall + // through to client credentials (which would otherwise succeed here). + _, err = p.GetGroups( + context.Background(), + "client-id", + mockServer.URL+"/tenant-id/v2.0", + token, + map[string]any{"msgraph_host": mockServer.URL}, + nil, + true, + ) + require.Error(t, err, "GetGroups must not use client credentials for a device-registration token") + require.Contains(t, err.Error(), "device registration", + "GetGroups should fail on the device-registration token-exchange path, not client credentials") +} + +func TestGetGroupsInvalidTokenWithClientCredentialsReturnsError(t *testing.T) { + t.Parallel() + + mockServer, cleanup := startMockMSServer(t, nil) + t.Cleanup(cleanup) + + token := &oauth2.Token{AccessToken: "invalid-token"} + + p := msentraid.New() + p.SetGraphClientSecret("client-secret") + + _, err := p.GetGroups( + context.Background(), + "client-id", + mockServer.URL+"/tenant-id/v2.0", + token, + map[string]any{"msgraph_host": mockServer.URL}, + nil, + false, + ) + require.Error(t, err, "GetGroups should return an error instead of panicking on invalid delegated tokens") +} + +func TestGetGroupsClientCredentialsUsesConfiguredIssuerAndGraphHosts(t *testing.T) { + t.Parallel() + + const ( + clientID = "client-id" + clientSecret = "client-secret" + tenantID = "tenant-id" + userOID = "user-oid" + ) + + accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "oid": userOID, + "scp": "User.Read", + }) + accessTokenStr, err := accessToken.SignedString(testutils.MockKey) + require.NoError(t, err, "Failed to sign access token") + + var tokenEndpointCalled atomic.Bool + var graphEndpointCalled atomic.Bool + var mockServer *httptest.Server + mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/"+tenantID+"/oauth2/v2.0/token": + tokenEndpointCalled.Store(true) + require.NoError(t, r.ParseForm(), "failed to parse client credentials form") + require.Equal(t, "client_credentials", r.Form.Get("grant_type")) + require.Equal(t, clientID, r.Form.Get("client_id")) + require.Equal(t, clientSecret, r.Form.Get("client_secret")) + require.Equal(t, mockServer.URL+"/.default", r.Form.Get("scope")) + + appToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{"exp": time.Now().Add(time.Hour).Unix()}) + appTokenStr, err := appToken.SignedString(testutils.MockKey) + require.NoError(t, err, "failed to sign app token") + + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token":%q,"token_type":"Bearer","expires_in":3600}`, appTokenStr) + + case r.Method == http.MethodGet && + strings.Contains(r.URL.Path, "/users/"+userOID+"/") && + strings.Contains(r.URL.Path, "/transitiveMemberOf/") && + strings.HasSuffix(r.URL.Path, "graph.group"): + graphEndpointCalled.Store(true) + simpleGroupHandler(w, r) + + default: + require.Fail(t, "unexpected request", "method=%s path=%s", r.Method, r.URL.Path) + } + })) + t.Cleanup(mockServer.Close) + + p := msentraid.New() + p.SetGraphClientSecret(clientSecret) + + got, err := p.GetGroups( + context.Background(), + clientID, + fmt.Sprintf("%s/%s/v2.0", mockServer.URL, tenantID), + &oauth2.Token{AccessToken: accessTokenStr}, + map[string]any{"msgraph_host": mockServer.URL + "/v1.0"}, + nil, + false, + ) + require.NoError(t, err, "GetGroups should use client credentials against configured hosts") + require.True(t, tokenEndpointCalled.Load(), "client credentials token endpoint should have been called") + require.True(t, graphEndpointCalled.Load(), "Graph users endpoint should have been called") + require.ElementsMatch(t, []info.Group{ + {Name: "group1", UGID: "id1"}, + {Name: "group2", UGID: "id2"}, + }, got) +} + func TestIsTokenForDeviceRegistration(t *testing.T) { t.Parallel() tests := map[string]struct { - appID string - invalidToken bool + deviceRegistrationData []byte - want bool - wantErr bool + want bool }{ - "Success_when_token_has_microsoft_broker_app_ID": {appID: consts.MicrosoftBrokerAppID, want: true}, - "Success_when_token_has_other_app_ID": {appID: "some-other-app-id", want: false}, - "Success_when_token_has_empty_app_ID": {appID: "", want: false}, - - "Error_when_token_has_no_app_ID": {appID: "-", wantErr: true}, - "Error_when_token_is_invalid": {invalidToken: true, wantErr: true}, + "True_when_device_registration_data_is_present": {deviceRegistrationData: []byte("device-registration-data"), want: true}, + "False_when_device_registration_data_is_absent": {deviceRegistrationData: nil, want: false}, + "False_when_device_registration_data_is_empty": {deviceRegistrationData: []byte{}, want: false}, } for name, tc := range tests { t.Run(name, func(t *testing.T) { t.Parallel() - claims := jwt.MapClaims{"appid": tc.appID} - if tc.appID == "-" { - claims = jwt.MapClaims{} - } - - accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - accessTokenString, err := accessToken.SignedString(testutils.MockKey) - require.NoError(t, err, "Failed to sign access token") - - if tc.invalidToken { - accessTokenString = "invalid-token" - } - - token := &oauth2.Token{AccessToken: accessTokenString} - p := msentraid.New() - got, err := p.IsTokenForDeviceRegistration(token) + got := p.IsTokenForDeviceRegistration(&token.AuthCachedInfo{DeviceRegistrationData: tc.deviceRegistrationData}) - if tc.wantErr { - require.Error(t, err, "IsTokenForDeviceRegistration should return an error") - return - } - require.NoError(t, err, "IsTokenForDeviceRegistration should not return an error") require.Equal(t, tc.want, got, "IsTokenForDeviceRegistration should return the expected value") }) } diff --git a/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go b/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go index 0a9757816e..f76e688c4e 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go @@ -48,6 +48,7 @@ func ensureMockMSServerForDeviceRegistration(t *testing.T) { type mockMSServer struct { *httptest.Server + config *mockMSServerConfig rsaPrivateKey *rsa.PrivateKey transportKeyBySPKI map[string]*rsa.PublicKey transportKeyMu sync.RWMutex @@ -58,6 +59,9 @@ type mockMSServerConfig struct { // If empty, requests to the token endpoint will be accepted for any tenant. TenantID string GroupEndpointHandler http.HandlerFunc + // RefreshHandler overrides the refresh_token grant response. Defaults to a + // successful token; set it to simulate a disabled/revoked user (e.g. AADSTS50057). + RefreshHandler http.HandlerFunc } func startMockMSServer(t *testing.T, config *mockMSServerConfig) (mockServer *mockMSServer, cleanup func()) { @@ -73,6 +77,7 @@ func startMockMSServer(t *testing.T, config *mockMSServerConfig) (mockServer *mo require.NoError(t, err, "failed to generate RSA private key") m := &mockMSServer{ + config: config, rsaPrivateKey: rsaPrivateKey, transportKeyBySPKI: make(map[string]*rsa.PublicKey), } @@ -96,7 +101,7 @@ func startMockMSServer(t *testing.T, config *mockMSServerConfig) (mockServer *mo m.handleDeviceEnrollmentRequest(t, w, r) // ===== graph.microsoft.com ===== - case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/me/transitiveMemberOf/graph.group"): + case r.Method == http.MethodGet && (strings.HasSuffix(r.URL.Path, "/me/transitiveMemberOf/graph.group") || strings.Contains(r.URL.Path, "/transitiveMemberOf/graph.group")): config.GroupEndpointHandler(w, r) default: @@ -154,6 +159,9 @@ func (m *mockMSServer) handleTokenRequest(t *testing.T, w http.ResponseWriter, r case "refresh_token": m.handleRefreshTokenRequest(t, w, r) + case "client_credentials": + m.handleClientCredentialsRequest(t, w, r) + case "srv_challenge": m.handleNonceRequest(t, w, r) @@ -168,6 +176,28 @@ func (m *mockMSServer) handleTokenRequest(t *testing.T, w http.ResponseWriter, r } } +func (m *mockMSServer) handleClientCredentialsRequest(t *testing.T, w http.ResponseWriter, r *http.Request) { + require.Equal(t, strings.TrimRight(m.URL, "/")+"/.default", r.Form.Get("scope"), "unexpected client credentials scope") + + accessToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "exp": float64(time.Now().Add(time.Hour).Unix()), + }) + accessTokenStr, err := accessToken.SignedString(m.rsaPrivateKey) + require.NoError(t, err, "failed to sign app access token") + + resp := map[string]interface{}{ + "token_type": "Bearer", + "expires_in": 3600, + "ext_expires_in": 3600, + "access_token": accessTokenStr, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err, "failed to encode response") +} + func (m *mockMSServer) handleAuthorizeRequest(t *testing.T, w http.ResponseWriter, r *http.Request) { // Example path: //oAuth2/v2.0/authorize // Example query: client_id=...&response_type=code&redirect_uri=...&client-request-id=...&scope=... @@ -276,7 +306,11 @@ func (m *mockMSServer) handleNonceRequest(t *testing.T, w http.ResponseWriter, _ require.NoError(t, err, "failed to encode response") } -func (m *mockMSServer) handleRefreshTokenRequest(t *testing.T, w http.ResponseWriter, _ *http.Request) { +func (m *mockMSServer) handleRefreshTokenRequest(t *testing.T, w http.ResponseWriter, r *http.Request) { + if m.config != nil && m.config.RefreshHandler != nil { + m.config.RefreshHandler(w, r) + return + } fmt.Fprint(os.Stderr, "Mock MS server responding with access token from refresh\n") resp := map[string]any{ "token_type": "Bearer", @@ -372,6 +406,18 @@ func simpleGroupHandler(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(response) } +// disabledRefreshHandler simulates Entra rejecting a refresh_token grant for a +// disabled user with AADSTS50057, mirroring the real token-endpoint response. +func disabledRefreshHandler(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "invalid_grant", + "error_description": "AADSTS50057: The user account is disabled.", + "error_codes": []int{50057}, + }) +} + // localGroupHandler simulates a successful response with a list of local groups. func localGroupHandler(w http.ResponseWriter, r *http.Request) { response := map[string]any{ diff --git a/authd-oidc-brokers/internal/providers/msentraid/tokenverify/keyset.go b/authd-oidc-brokers/internal/providers/msentraid/tokenverify/keyset.go new file mode 100644 index 0000000000..499bbff2b9 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/tokenverify/keyset.go @@ -0,0 +1,109 @@ +package tokenverify + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "sync" + "time" +) + +// RemoteKeySet fetches and caches the RSA signing keys published at a JWKS URI +// (e.g. the tenant's `.../discovery/v2.0/keys`). It refetches on a cache miss so +// that key rotation is handled transparently. +type RemoteKeySet struct { + jwksURI string + client *http.Client + + mu sync.Mutex + keys map[string]*rsa.PublicKey +} + +// NewRemoteKeySet returns a RemoteKeySet for the given JWKS URI. If client is +// nil, a client with a 10-second timeout is used so a hung JWKS endpoint +// cannot stall the login path indefinitely. +func NewRemoteKeySet(jwksURI string, client *http.Client) *RemoteKeySet { + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + return &RemoteKeySet{jwksURI: jwksURI, client: client} +} + +// KeyForKID returns the RSA public key for kid, fetching the JWKS if it is not +// already cached. It satisfies the KeyForKID signature used by Verify. +func (r *RemoteKeySet) KeyForKID(ctx context.Context, kid string) (*rsa.PublicKey, error) { + if k := r.cached(kid); k != nil { + return k, nil + } + if err := r.fetch(ctx); err != nil { + return nil, err + } + if k := r.cached(kid); k != nil { + return k, nil + } + return nil, fmt.Errorf("no signing key with kid %q in JWKS", kid) +} + +func (r *RemoteKeySet) cached(kid string) *rsa.PublicKey { + r.mu.Lock() + defer r.mu.Unlock() + return r.keys[kid] +} + +func (r *RemoteKeySet) fetch(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.jwksURI, nil) + if err != nil { + return fmt.Errorf("could not build JWKS request: %w", err) + } + resp, err := r.client.Do(req) + if err != nil { + return fmt.Errorf("could not fetch JWKS: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) + } + + // Limit the response body to 1 MiB to prevent OOM from a malformed or + // hostile JWKS response. + var doc struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil { + return fmt.Errorf("could not parse JWKS: %w", err) + } + + keys := make(map[string]*rsa.PublicKey, len(doc.Keys)) + for _, k := range doc.Keys { + if k.Kty != "RSA" { + continue + } + nBytes, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + continue + } + eBytes, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + continue + } + keys[k.Kid] = &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(new(big.Int).SetBytes(eBytes).Int64()), + } + } + + r.mu.Lock() + r.keys = keys + r.mu.Unlock() + return nil +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/tokenverify/keyset_test.go b/authd-oidc-brokers/internal/providers/msentraid/tokenverify/keyset_test.go new file mode 100644 index 0000000000..5946e9441e --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/tokenverify/keyset_test.go @@ -0,0 +1,218 @@ +package tokenverify_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/tokenverify" + "github.com/stretchr/testify/require" +) + +// jwksServer serves a JWKS containing the given RSA public key under testKID. +func jwksServer(t *testing.T, pub *rsa.PublicKey) *httptest.Server { + t.Helper() + doc := map[string]any{"keys": []map[string]string{{ + "kid": testKID, "kty": "RSA", + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }}} + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(doc)) + })) +} + +func TestRemoteKeySetVerifyEndToEnd(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + srv := jwksServer(t, &key.PublicKey) + defer srv.Close() + + ks := tokenverify.NewRemoteKeySet(srv.URL, srv.Client()) + resolver := func(kid string) (*rsa.PublicKey, error) { + return ks.KeyForKID(context.Background(), kid) + } + + payload, err := json.Marshal(map[string]any{"tid": testTenant, "exp": time.Now().Unix() + 3600}) + require.NoError(t, err) + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, payload) + + require.NoError(t, tokenverify.Verify(tok, testTenant, resolver), + "token must verify against the key served by the JWKS endpoint") +} + +func TestRemoteKeySetUnknownKID(t *testing.T) { + t.Parallel() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + srv := jwksServer(t, &key.PublicKey) + defer srv.Close() + + ks := tokenverify.NewRemoteKeySet(srv.URL, srv.Client()) + _, err = ks.KeyForKID(context.Background(), "not-present") + require.Error(t, err, "an absent kid must error") +} + +func TestNewRemoteKeySetNilClientUsesBoundedClient(t *testing.T) { + t.Parallel() + // Passing nil must not panic — it falls back to a bounded HTTP client. + ks := tokenverify.NewRemoteKeySet("http://localhost:0", nil) + // A fetch to a definitely-closed port will error, but the important thing + // is that the keyset was constructed without panic. + _, err := ks.KeyForKID(context.Background(), "any-kid") + require.Error(t, err, "should error when the JWKS endpoint is unreachable") +} + +func TestRemoteKeySetFetchErrors(t *testing.T) { + t.Parallel() + + t.Run("HTTP_fetch_failure", func(t *testing.T) { + t.Parallel() + // A server that closes immediately triggers a fetch error. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Close the connection without writing anything. + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijack not supported", http.StatusInternalServerError) + return + } + conn, _, _ := hj.Hijack() + conn.Close() + })) + defer srv.Close() + + ks := tokenverify.NewRemoteKeySet(srv.URL, srv.Client()) + _, err := ks.KeyForKID(context.Background(), testKID) + require.Error(t, err) + }) + + t.Run("Non_200_status", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + ks := tokenverify.NewRemoteKeySet(srv.URL, srv.Client()) + _, err := ks.KeyForKID(context.Background(), testKID) + require.Error(t, err) + require.Contains(t, err.Error(), "500") + }) + + t.Run("Invalid_JSON_body", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not-json")) + })) + defer srv.Close() + + ks := tokenverify.NewRemoteKeySet(srv.URL, srv.Client()) + _, err := ks.KeyForKID(context.Background(), testKID) + require.Error(t, err) + }) + + t.Run("Non_RSA_key_is_skipped", func(t *testing.T) { + t.Parallel() + // Serve a JWKS with a non-RSA key type; KeyForKID should skip it and + // return a "no signing key" error. + doc := map[string]any{"keys": []map[string]string{{ + "kid": testKID, "kty": "EC", + }}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(doc)) + })) + defer srv.Close() + + ks := tokenverify.NewRemoteKeySet(srv.URL, srv.Client()) + _, err := ks.KeyForKID(context.Background(), testKID) + require.Error(t, err) + }) + + t.Run("Invalid_base64_N_is_skipped", func(t *testing.T) { + t.Parallel() + doc := map[string]any{"keys": []map[string]string{{ + "kid": testKID, "kty": "RSA", + "n": "!!!invalid-base64!!!", + "e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), + }}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(doc)) + })) + defer srv.Close() + + ks := tokenverify.NewRemoteKeySet(srv.URL, srv.Client()) + _, err := ks.KeyForKID(context.Background(), testKID) + require.Error(t, err) + }) + + t.Run("Invalid_base64_E_is_skipped", func(t *testing.T) { + t.Parallel() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + doc := map[string]any{"keys": []map[string]string{{ + "kid": testKID, "kty": "RSA", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": "!!!invalid-base64!!!", + }}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(doc)) + })) + defer srv.Close() + + ks := tokenverify.NewRemoteKeySet(srv.URL, srv.Client()) + _, err = ks.KeyForKID(context.Background(), testKID) + require.Error(t, err) + }) +} + +func TestNewRemoteKeySetInvalidURLFailsOnFetch(t *testing.T) { + t.Parallel() + // A URL containing a control character causes http.NewRequestWithContext to + // fail when the keyset tries to build its JWKS request. + ks := tokenverify.NewRemoteKeySet("http://\x00invalid", http.DefaultClient) + _, err := ks.KeyForKID(context.Background(), testKID) + require.Error(t, err, "an invalid JWKS URI must error on fetch") +} + +func TestRemoteKeySetCachesKeyAfterFetch(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + fetchCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetchCount++ + doc := map[string]any{"keys": []map[string]string{{ + "kid": testKID, "kty": "RSA", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()), + }}} + require.NoError(t, json.NewEncoder(w).Encode(doc)) + })) + defer srv.Close() + + ks := tokenverify.NewRemoteKeySet(srv.URL, srv.Client()) + + // First call fetches from the server. + k1, err := ks.KeyForKID(context.Background(), testKID) + require.NoError(t, err) + require.Equal(t, 1, fetchCount, "first call should fetch from the server") + + // Second call must return the cached key without a second fetch. + k2, err := ks.KeyForKID(context.Background(), testKID) + require.NoError(t, err) + require.Equal(t, 1, fetchCount, "second call must use the cache, not re-fetch") + require.Equal(t, k1, k2, "cached key must match the fetched key") +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/tokenverify/tokenverify.go b/authd-oidc-brokers/internal/providers/msentraid/tokenverify/tokenverify.go new file mode 100644 index 0000000000..7b6baa38fc --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/tokenverify/tokenverify.go @@ -0,0 +1,225 @@ +// Package tokenverify verifies the RS256 signature of Microsoft Entra access +// tokens. It is deliberately free of any cgo/libhimmelblau dependency so it can +// be unit-tested on its own. +// +// Microsoft first-party access tokens (e.g. Microsoft Graph-scoped) carry a +// "nonce" in the JWT header that Entra replaces with its SHA256 (base64url +// no-padding) value *before* signing, then serves with the plaintext nonce. As a +// result such tokens do not verify with a standard JWT check; the resource +// applies the same rewrite before validating. Verify reproduces that rewrite so +// both nonce-bearing and plain tokens validate against the tenant JWKS. +package tokenverify + +import ( + "bytes" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// KeyForKID returns the RSA public key published for the given JWT "kid", or an +// error if it cannot be resolved. +type KeyForKID func(kid string) (*rsa.PublicKey, error) + +// replaceNonceValue rewrites the value of the "nonce" field in headerJSON with +// replacement, byte-for-byte, leaving every other byte untouched. Unlike a plain +// substring replace, it anchors to the "nonce" key itself, so it cannot match a +// coincidental occurrence of nonce's text inside another header field's value. +// +// replacement is spliced in raw, without JSON encoding — callers must ensure it +// contains no characters that require JSON escaping (no `"` or `\`). The current +// sole call site passes a SHA256 base64url-encoded string, which satisfies this +// invariant structurally. +func replaceNonceValue(headerJSON []byte, nonce, replacement string) ([]byte, error) { + i := skipJSONWhitespace(headerJSON, 0) + if i >= len(headerJSON) || headerJSON[i] != '{' { + return nil, errors.New("token header is not a JSON object") + } + i++ + + for { + i = skipJSONWhitespace(headerJSON, i) + if i >= len(headerJSON) { + return nil, errors.New(`"nonce" key not found`) + } + if headerJSON[i] == '}' { + return nil, errors.New(`"nonce" key not found`) + } + + keyEnd, key, err := scanJSONString(headerJSON, i) + if err != nil { + return nil, fmt.Errorf("malformed token header key: %w", err) + } + i = skipJSONWhitespace(headerJSON, keyEnd) + if i >= len(headerJSON) || headerJSON[i] != ':' { + return nil, errors.New("malformed token header field") + } + i = skipJSONWhitespace(headerJSON, i+1) + + if key == "nonce" { + valueEnd, parsed, err := scanJSONString(headerJSON, i) + if err != nil { + return nil, fmt.Errorf(`malformed "nonce" value: %w`, err) + } + if parsed != nonce { + return nil, errors.New(`"nonce" value does not match the parsed header`) + } + + rewritten := make([]byte, 0, len(headerJSON)) + rewritten = append(rewritten, headerJSON[:i+1]...) + rewritten = append(rewritten, replacement...) + rewritten = append(rewritten, headerJSON[valueEnd-1:]...) + return rewritten, nil + } + + next, err := skipJSONValue(headerJSON, i) + if err != nil { + return nil, fmt.Errorf("malformed token header field %q: %w", key, err) + } + i = next + i = skipJSONWhitespace(headerJSON, i) + if i >= len(headerJSON) { + return nil, errors.New("unterminated token header object") + } + switch headerJSON[i] { + case ',': + i++ + case '}': + return nil, errors.New(`"nonce" key not found`) + default: + return nil, errors.New("malformed token header object") + } + } +} + +func skipJSONWhitespace(data []byte, start int) int { + for start < len(data) { + switch data[start] { + case ' ', '\n', '\r', '\t': + start++ + default: + return start + } + } + return start +} + +func scanJSONString(data []byte, start int) (end int, value string, err error) { + if start >= len(data) || data[start] != '"' { + return 0, "", errors.New("expected JSON string") + } + for end := start + 1; end < len(data); end++ { + if data[end] == '\\' { + end++ + continue + } + if data[end] == '"' { + if err := json.Unmarshal(data[start:end+1], &value); err != nil { + return 0, "", err + } + return end + 1, value, nil + } + } + return 0, "", errors.New("unterminated JSON string") +} + +func skipJSONValue(data []byte, start int) (int, error) { + decoder := json.NewDecoder(bytes.NewReader(data[start:])) + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + return 0, err + } + return start + int(decoder.InputOffset()), nil +} + +// Verify checks that rawToken is an RS256 JWT whose signature validates against +// the key resolved for its "kid", and that its "tid" (tenant) claim equals +// expectedTenantID. It returns nil only on success. +func Verify(rawToken, expectedTenantID string, keyForKID KeyForKID) error { + if expectedTenantID == "" { + return errors.New("expectedTenantID must not be empty") + } + + parts := strings.Split(rawToken, ".") + if len(parts) != 3 { + return errors.New("access token is not a JWT") + } + + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return fmt.Errorf("could not decode token header: %w", err) + } + var header struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + Nonce string `json:"nonce"` + } + if err := json.Unmarshal(headerJSON, &header); err != nil { + return fmt.Errorf("could not parse token header: %w", err) + } + if header.Alg != "RS256" { + return fmt.Errorf("unsupported token signature algorithm %q (want RS256)", header.Alg) + } + + // Reconstruct the bytes Entra actually signed over. For nonce-bearing tokens + // that means the header with the nonce replaced by its SHA256. We rewrite the + // raw header bytes in place (rather than re-marshalling) so the byte order is + // preserved exactly — re-marshalling would reorder keys and break the signature. + signingInput := parts[0] + "." + parts[1] + if header.Nonce != "" { + sum := sha256.Sum256([]byte(header.Nonce)) + hashed := base64.RawURLEncoding.EncodeToString(sum[:]) + rewritten, err := replaceNonceValue(headerJSON, header.Nonce, hashed) + if err != nil { + return fmt.Errorf("could not locate nonce field in token header: %w", err) + } + signingInput = base64.RawURLEncoding.EncodeToString(rewritten) + "." + parts[1] + } + + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return fmt.Errorf("could not decode token signature: %w", err) + } + key, err := keyForKID(header.Kid) + if err != nil { + return fmt.Errorf("could not resolve signing key %q: %w", header.Kid, err) + } + digest := sha256.Sum256([]byte(signingInput)) + if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], sig); err != nil { + return fmt.Errorf("token signature verification failed: %w", err) + } + + // Bind the (now signature-verified) token to the expected tenant. + payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return fmt.Errorf("could not decode token payload: %w", err) + } + var claims struct { + Tid string `json:"tid"` + Exp json.Number `json:"exp"` + } + if err := json.Unmarshal(payloadJSON, &claims); err != nil { + return fmt.Errorf("could not parse token payload: %w", err) + } + // Reject expired tokens. Microsoft Entra access tokens typically have a + // 1-hour lifetime; allow a 60-second clock-skew window. + exp, err := claims.Exp.Int64() + if err != nil || exp == 0 { + return errors.New("token payload missing valid exp claim") + } + now := time.Now().Unix() + if now-60 > exp { + return fmt.Errorf("token expired at %d (now: %d)", exp, now) + } + if claims.Tid != expectedTenantID { + return fmt.Errorf("token tenant %q does not match expected tenant %q", claims.Tid, expectedTenantID) + } + + return nil +} diff --git a/authd-oidc-brokers/internal/providers/msentraid/tokenverify/tokenverify_test.go b/authd-oidc-brokers/internal/providers/msentraid/tokenverify/tokenverify_test.go new file mode 100644 index 0000000000..8404f85760 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/tokenverify/tokenverify_test.go @@ -0,0 +1,248 @@ +package tokenverify_test + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/msentraid/tokenverify" + "github.com/stretchr/testify/require" +) + +const testKID = "test-kid" +const testTenant = "03c73201-ef9e-4182-ae04-0adb51f4a0b6" + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// signToken builds a JWT served as `servedHeaderJSON.payload.sig`, where the +// signature is computed over `signedHeaderJSON.payload`. Passing different served +// and signed headers reproduces Microsoft's nonce behavior (served header carries +// the plaintext nonce; the signature was made over the SHA256-rewritten header). +func signToken(t *testing.T, key *rsa.PrivateKey, servedHeaderJSON, signedHeaderJSON, payloadJSON []byte) string { + t.Helper() + pSeg := b64(payloadJSON) + signingInput := b64(signedHeaderJSON) + "." + pSeg + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + require.NoError(t, err, "signing test token") + return b64(servedHeaderJSON) + "." + pSeg + "." + b64(sig) +} + +func hashedNonce(nonce string) string { + sum := sha256.Sum256([]byte(nonce)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func TestVerify(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + resolver := func(kid string) (*rsa.PublicKey, error) { + if kid != testKID { + return nil, fmt.Errorf("unexpected kid %q", kid) + } + return &key.PublicKey, nil + } + payload, err := json.Marshal(map[string]any{"tid": testTenant, "upn": "user@example.com", "exp": time.Now().Unix() + 3600}) + require.NoError(t, err) + + t.Run("Valid token without a nonce", func(t *testing.T) { + t.Parallel() + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, payload) + require.NoError(t, tokenverify.Verify(tok, testTenant, resolver)) + }) + + t.Run("Valid token with a header nonce (Microsoft rewrite)", func(t *testing.T) { + t.Parallel() + nonce := "L6EHQ7sCDM6k8EzwUmsHIDihoWwKBOaFXu4ShzY33J8" + served, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID, "nonce": nonce}) + require.NoError(t, err) + signed, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID, "nonce": hashedNonce(nonce)}) + require.NoError(t, err) + tok := signToken(t, key, served, signed, payload) + // Plain verification (no rewrite) must fail; our Verify must pass. + require.NoError(t, tokenverify.Verify(tok, testTenant, resolver), + "a nonce-bearing token must verify via the SHA256 header rewrite") + }) + + t.Run("Nonce rewrite preserves header byte order", func(t *testing.T) { + t.Parallel() + // Hand-craft a header with a non-alphabetical key order and the nonce not + // last, to prove Verify rewrites in place rather than re-marshalling (which + // would reorder keys and break the signature). + nonce := "ZZZnonceVALUE-0123456789_abcdefghijklmnopq" + served := []byte(`{"typ":"JWT","nonce":"` + nonce + `","alg":"RS256","kid":"` + testKID + `"}`) + signed := []byte(`{"typ":"JWT","nonce":"` + hashedNonce(nonce) + `","alg":"RS256","kid":"` + testKID + `"}`) + tok := signToken(t, key, served, signed, payload) + require.NoError(t, tokenverify.Verify(tok, testTenant, resolver)) + }) + + t.Run("Nonce rewrite ignores nonce-looking values before nonce key", func(t *testing.T) { + t.Parallel() + nonce := "actual-nonce-value" + served := []byte(`{"typ":"nonce","cty":"JWT","nonce":"` + nonce + `","alg":"RS256","kid":"` + testKID + `"}`) + signed := []byte(`{"typ":"nonce","cty":"JWT","nonce":"` + hashedNonce(nonce) + `","alg":"RS256","kid":"` + testKID + `"}`) + tok := signToken(t, key, served, signed, payload) + require.NoError(t, tokenverify.Verify(tok, testTenant, resolver)) + }) + + t.Run("Tampered payload fails", func(t *testing.T) { + t.Parallel() + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, payload) + // Swap the payload segment for a different (unsigned) one. + evil, err := json.Marshal(map[string]any{"tid": testTenant, "upn": "attacker@example.com"}) + require.NoError(t, err) + tampered := b64(hdr) + "." + b64(evil) + "." + tokenSig(tok) + require.Error(t, tokenverify.Verify(tampered, testTenant, resolver)) + }) + + t.Run("Wrong tenant fails", func(t *testing.T) { + t.Parallel() + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, payload) + require.Error(t, tokenverify.Verify(tok, "some-other-tenant", resolver)) + }) + + t.Run("Empty expected tenant fails before resolving key", func(t *testing.T) { + t.Parallel() + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, payload) + resolverCalled := false + err = tokenverify.Verify(tok, "", func(kid string) (*rsa.PublicKey, error) { + resolverCalled = true + return resolver(kid) + }) + require.Error(t, err) + require.Contains(t, err.Error(), "expectedTenantID") + require.False(t, resolverCalled, "empty tenant configuration should fail before fetching keys") + }) + + t.Run("Expired token fails", func(t *testing.T) { + t.Parallel() + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + expiredPayload, err := json.Marshal(map[string]any{"tid": testTenant, "exp": time.Now().Unix() - 61}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, expiredPayload) + require.Error(t, tokenverify.Verify(tok, testTenant, resolver)) + }) + + t.Run("Missing exp fails", func(t *testing.T) { + t.Parallel() + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + missingExpPayload, err := json.Marshal(map[string]any{"tid": testTenant}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, missingExpPayload) + require.Error(t, tokenverify.Verify(tok, testTenant, resolver)) + }) + + t.Run("Non-RS256 alg is rejected", func(t *testing.T) { + t.Parallel() + hdr, err := json.Marshal(map[string]any{"alg": "none", "kid": testKID}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, payload) + require.Error(t, tokenverify.Verify(tok, testTenant, resolver)) + }) + + t.Run("Not a JWT is rejected", func(t *testing.T) { + t.Parallel() + require.Error(t, tokenverify.Verify("accesstoken", testTenant, resolver)) + }) + + t.Run("Unknown_kid_is_rejected", func(t *testing.T) { + t.Parallel() + // Build a valid RS256 token whose kid the resolver does not know. + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": "unknown-kid"}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, payload) + require.Error(t, tokenverify.Verify(tok, testTenant, resolver), + "a token with an unresolvable kid must error") + }) + + t.Run("Malformed_base64_header_is_rejected", func(t *testing.T) { + t.Parallel() + // Replace the header segment with invalid base64. + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, payload) + parts := strings.SplitN(tok, ".", 3) + tampered := "!!!not-base64!!!" + "." + parts[1] + "." + parts[2] + require.Error(t, tokenverify.Verify(tampered, testTenant, resolver)) + }) + + t.Run("Non_JSON_header_is_rejected", func(t *testing.T) { + t.Parallel() + // Build a token whose header segment is valid base64 but not JSON. + badHdr := []byte("this is not json") + pSeg := b64(payload) + signingInput := b64(badHdr) + "." + pSeg + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + require.NoError(t, err) + tok := b64(badHdr) + "." + pSeg + "." + b64(sig) + require.Error(t, tokenverify.Verify(tok, testTenant, resolver)) + }) + + t.Run("Malformed_base64_signature_is_rejected", func(t *testing.T) { + t.Parallel() + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + tok := signToken(t, key, hdr, hdr, payload) + parts := strings.SplitN(tok, ".", 3) + tampered := parts[0] + "." + parts[1] + "." + "!!!not-base64!!!" + require.Error(t, tokenverify.Verify(tampered, testTenant, resolver)) + }) + + t.Run("Malformed_base64_payload_is_rejected", func(t *testing.T) { + t.Parallel() + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + // Build token with invalid base64 payload but valid signature over the header. + badPayload := "!!!not-base64!!!" + signingInput := b64(hdr) + "." + badPayload + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + require.NoError(t, err) + tok := b64(hdr) + "." + badPayload + "." + b64(sig) + require.Error(t, tokenverify.Verify(tok, testTenant, resolver)) + }) + + t.Run("Non_JSON_payload_is_rejected", func(t *testing.T) { + t.Parallel() + hdr, err := json.Marshal(map[string]any{"alg": "RS256", "kid": testKID}) + require.NoError(t, err) + // Payload is valid base64 but not JSON. + badPayload := b64([]byte("this is not json")) + signingInput := b64(hdr) + "." + badPayload + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + require.NoError(t, err) + tok := b64(hdr) + "." + badPayload + "." + b64(sig) + require.Error(t, tokenverify.Verify(tok, testTenant, resolver)) + }) +} + +func tokenSig(tok string) string { + // last dot-separated segment + for i := len(tok) - 1; i >= 0; i-- { + if tok[i] == '.' { + return tok[i+1:] + } + } + return "" +} diff --git a/authd-oidc-brokers/internal/providers/providers.go b/authd-oidc-brokers/internal/providers/providers.go index 744204f6a0..c4c903870a 100644 --- a/authd-oidc-brokers/internal/providers/providers.go +++ b/authd-oidc-brokers/internal/providers/providers.go @@ -5,6 +5,7 @@ import ( "context" "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/canonical/authd/authd-oidc-brokers/internal/token" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) @@ -18,7 +19,11 @@ type Provider interface { GetUserInfo(claimer info.Claimer, isRefresh bool) (info.User, error) IsTokenExpiredError(err *oauth2.RetrieveError) bool NormalizeUsername(username string) string - SupportedOIDCAuthModes() []string + // SupportedOnlineAuthModes returns the authentication modes that require a + // working connection to the identity provider (in contrast to the local + // password mode, which the broker prepends). These are not necessarily OIDC + // flows: entra_password issues OAuth 2.0 tokens without an OIDC id_token. + SupportedOnlineAuthModes() []string VerifyUsername(requestedUsername, authenticatedUsername string) error } @@ -31,6 +36,7 @@ type GroupFetcher interface { token *oauth2.Token, providerMetadata map[string]interface{}, deviceRegistrationData []byte, + needsAccessTokenForGraphAPI bool, ) ([]info.Group, error) } @@ -42,7 +48,12 @@ type MetadataProvider interface { // DeviceRegisterer is implemented by providers that support device registration. type DeviceRegisterer interface { - IsTokenForDeviceRegistration(token *oauth2.Token) (bool, error) + // IsTokenForDeviceRegistration reports whether the cached token carries + // device-registration data (i.e. the device was registered). This is the + // authoritative signal: tokens issued by the Microsoft Broker App (e.g. the + // entra_password MFA flow) are not device-registration tokens unless a device + // was actually registered. + IsTokenForDeviceRegistration(authInfo *token.AuthCachedInfo) bool MaybeRegisterDevice( ctx context.Context, token *oauth2.Token, @@ -58,6 +69,13 @@ type UserDisabledChecker interface { IsUserDisabledError(err *oauth2.RetrieveError) bool } +// GraphClientSecretSetter is implemented by providers that can use the OIDC +// app's client secret for app-only (client credentials) group lookup as a +// fallback when the delegated token cannot be used against the Graph API. +type GraphClientSecretSetter interface { + SetGraphClientSecret(secret string) +} + // Capability is an optional interface that allows a Provider to expose optional // interfaces dynamically, similar to errors.As. Composed or wrapped providers // should implement this to avoid combinatorial type-switch boilerplate. diff --git a/authd-oidc-brokers/internal/testutils/provider.go b/authd-oidc-brokers/internal/testutils/provider.go index 8e506725c4..6e3cfba700 100644 --- a/authd-oidc-brokers/internal/testutils/provider.go +++ b/authd-oidc-brokers/internal/testutils/provider.go @@ -24,6 +24,7 @@ import ( providerErrors "github.com/canonical/authd/authd-oidc-brokers/internal/providers/errors" "github.com/canonical/authd/authd-oidc-brokers/internal/providers/genericprovider" "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" + "github.com/canonical/authd/authd-oidc-brokers/internal/token" "github.com/canonical/authd/log" "github.com/coreos/go-oidc/v3/oidc" "github.com/go-jose/go-jose/v4" @@ -38,8 +39,6 @@ const ( InactiveExpiredRefreshToken = "inactive-expired-refresh-token" // StaleRefreshToken is used to test the expired refresh token due to a not-before policy (simulates Keycloak "Stale token"). StaleRefreshToken = "stale-refresh-token" - // IsForDeviceRegistrationClaim is the claim used to indicate to the mock provider if the token is for device registration. - IsForDeviceRegistrationClaim = "is_for_device_registration" ) // MockKey is the RSA key used to sign the JWTs for the mock provider. @@ -466,7 +465,7 @@ func (p *MockProvider) GetUserInfo(idToken info.Claimer, isRefresh bool) (info.U } // GetGroups returns the groups the user is a member of. -func (p *MockProvider) GetGroups(ctx context.Context, clientID string, issuerURL string, token *oauth2.Token, providerMetadata map[string]interface{}, deviceRegistrationData []byte) ([]info.Group, error) { +func (p *MockProvider) GetGroups(ctx context.Context, clientID string, issuerURL string, token *oauth2.Token, providerMetadata map[string]interface{}, deviceRegistrationData []byte, needsAccessTokenForGraphAPI bool) ([]info.Group, error) { if p.GetGroupsFails { return nil, errors.New("error requested in the mock") } @@ -487,42 +486,16 @@ func (p *MockProvider) GetGroups(ctx context.Context, clientID string, issuerURL return userGroups, nil } -type claims struct { - Email string `json:"email"` - Sub string `json:"sub"` - Home string `json:"home"` - Shell string `json:"shell"` - Gecos string `json:"name"` - MustHave string `json:"must-have-claim"` -} - -// userClaims returns the user claims parsed from the ID token. -func (p *MockProvider) userClaims(idToken info.Claimer) (claims, error) { - var userClaims claims - if err := idToken.Claims(&userClaims); err != nil { - return claims{}, fmt.Errorf("failed to get ID token claims: %v", err) - } - return userClaims, nil -} - // MockDeviceRegistererProvider wraps MockProvider and adds DeviceRegisterer support. // Use this when tests need the provider to implement the DeviceRegisterer interface. type MockDeviceRegistererProvider struct { *MockProvider } -// IsTokenForDeviceRegistration checks if the token is for device registration. -func (p *MockDeviceRegistererProvider) IsTokenForDeviceRegistration(token *oauth2.Token) (bool, error) { - if token == nil { - return false, errors.New("token is nil") - } - - isForDeviceRegistration, ok := token.Extra(IsForDeviceRegistrationClaim).(bool) - if !ok { - return false, fmt.Errorf("token does not contain %q claim", IsForDeviceRegistrationClaim) - } - - return isForDeviceRegistration, nil +// IsTokenForDeviceRegistration reports whether the cached token carries +// device-registration data. +func (p *MockDeviceRegistererProvider) IsTokenForDeviceRegistration(authInfo *token.AuthCachedInfo) bool { + return authInfo != nil && len(authInfo.DeviceRegistrationData) > 0 } // MaybeRegisterDevice is a no-op for the mock device registrar. @@ -607,6 +580,24 @@ func (c *composedProvider) ProviderAs(target any) bool { return false } +type claims struct { + Email string `json:"email"` + Sub string `json:"sub"` + Home string `json:"home"` + Shell string `json:"shell"` + Gecos string `json:"name"` + MustHave string `json:"must-have-claim"` +} + +// userClaims returns the user claims parsed from the ID token. +func (p *MockProvider) userClaims(idToken info.Claimer) (claims, error) { + var userClaims claims + if err := idToken.Claims(&userClaims); err != nil { + return claims{}, fmt.Errorf("failed to get ID token claims: %v", err) + } + return userClaims, nil +} + // ErrorResponseHandler returns a handler that responds with the given HTTP status code and JSON body. func ErrorResponseHandler(statusCode int, body string) EndpointHandler { return func(w http.ResponseWriter, _ *http.Request) { diff --git a/authd-oidc-brokers/internal/token/token.go b/authd-oidc-brokers/internal/token/token.go index 99ca25f9f2..ea060d3fd5 100644 --- a/authd-oidc-brokers/internal/token/token.go +++ b/authd-oidc-brokers/internal/token/token.go @@ -21,9 +21,15 @@ type AuthCachedInfo struct { DeviceRegistrationData []byte DeviceIsDisabled bool UserIsDisabled bool + // ObtainedViaEntraPasswordAuth is set when the token was obtained through the + // entra_password MFA flow. On a returning login it selects the refresh path: + // these tokens are refreshed as the Microsoft Broker App (public client, no + // client_secret) for the liveness/revocation check, rather than via the OIDC + // app refresh used by device-auth tokens. + ObtainedViaEntraPasswordAuth bool } -// NewAuthCachedInfo creates a new AuthCachedInfo. It sets the provided token, rawIDToken, and +// NewAuthCachedInfo creates a new AuthCachedInfo. It sets the provided token and rawIDToken and the provider-specific // extra fields which should be stored persistently. func NewAuthCachedInfo(token *oauth2.Token, rawIDToken string, extraFields map[string]interface{}) *AuthCachedInfo { return &AuthCachedInfo{ diff --git a/authd-oidc-brokers/third_party/libhimmelblau b/authd-oidc-brokers/third_party/libhimmelblau index cc83f618f1..2c028b657d 160000 --- a/authd-oidc-brokers/third_party/libhimmelblau +++ b/authd-oidc-brokers/third_party/libhimmelblau @@ -1 +1 @@ -Subproject commit cc83f618f1a386f351bef2a560c88fa0cf1af904 +Subproject commit 2c028b657d8eb9ad3b514f2cd6c41cde5a499e35 diff --git a/e2e-tests/resources/TOTP.py b/e2e-tests/resources/TOTP.py new file mode 100644 index 0000000000..0379876fa8 --- /dev/null +++ b/e2e-tests/resources/TOTP.py @@ -0,0 +1,25 @@ +"""Robot Framework library for generating TOTP codes.""" + +import os + +from generate_totp import generate_totp + +from robot.api.deco import keyword, library + + +@library +class TOTP: + """Generates time-based one-time passwords (TOTP) for use in e2e tests.""" + + @keyword + def generate_totp_code(self) -> str: + """Return the current TOTP code derived from the TOTP_SECRET environment variable. + + Waits until there are at least 5 seconds left in the current time window + before generating the code (see generate_totp.py) so the code remains + valid long enough to be typed in. + """ + secret = os.environ.get("TOTP_SECRET", "") + if not secret: + raise ValueError("TOTP_SECRET environment variable is not set") + return generate_totp(secret) diff --git a/e2e-tests/resources/broker.resource b/e2e-tests/resources/broker.resource index 0bcad4445e..30a8eb50b9 100644 --- a/e2e-tests/resources/broker.resource +++ b/e2e-tests/resources/broker.resource @@ -9,6 +9,7 @@ Resource kvm.resource Resource resources/utils.resource Resource resources/authd.resource Library ./Browser.py AS Browser +Library ./TOTP.py AS TOTP Library Hid.py AS Hid Library OperatingSystem Library String @@ -430,3 +431,31 @@ Check That Device Was Registered ... sudo jq -r '.DeviceRegistrationData != null and .DeviceRegistrationData != ""' '${token_path}' Should Be Equal ${registered} true ... msg=token.json has no DeviceRegistrationData; the device was not registered. + + +Log In With Remote User Through CLI: Entra Password + [Arguments] ${username} + Try machinectl login Prompt + Hid.Type String ${username} + Hid.Keys Combo Return + + Match Text Select your provider 15 + Match Text 2. ${PROVIDER_DISPLAY_NAME} + Hid.Type String 2 + + # With device_auth disabled there is only one mode; the PAM module auto-selects + # it and goes directly to the Entra ID password prompt. + Match Text Enter your Entra ID password 30 + Hid.Type String %{E2E_PASSWORD} + Hid.Keys Combo Return + + # Wait for the MFA code prompt and enter a fresh TOTP code. + Match Text Enter your MFA code 120 + ${totp_code} = TOTP.Generate Totp Code + Hid.Type String ${totp_code} + Hid.Keys Combo Return + + # Wait for the authenticated shell prompt. Plain Match Text is fuzzy enough + # to false-positive on the preceding machinectl login line, so keep this in + # regex mode and require the remote-shell prefix after the username. + Match Text regex:${username}@ubuntu: 120 diff --git a/e2e-tests/resources/browser_login/base.py b/e2e-tests/resources/browser_login/base.py index 6c11bc23ab..2c90f4a9d0 100644 --- a/e2e-tests/resources/browser_login/base.py +++ b/e2e-tests/resources/browser_login/base.py @@ -30,6 +30,7 @@ def login(browser, username, password, device_code, totp_secret, screenshot_dir) from gi.repository import Gtk # type: ignore from browser_window import BrowserWindow, ascii_string_to_key_events # noqa: F401 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) from generate_totp import generate_totp # noqa: F401 logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s", datefmt="%H:%M:%S", level=logging.INFO) diff --git a/e2e-tests/resources/browser_login/generate_totp.py b/e2e-tests/resources/generate_totp.py similarity index 100% rename from e2e-tests/resources/browser_login/generate_totp.py rename to e2e-tests/resources/generate_totp.py diff --git a/e2e-tests/tests/login_entra_password.robot b/e2e-tests/tests/login_entra_password.robot new file mode 100644 index 0000000000..e2b2fb2737 --- /dev/null +++ b/e2e-tests/tests/login_entra_password.robot @@ -0,0 +1,58 @@ +*** Settings *** +Resource resources/utils.resource +Resource resources/authd.resource +Resource resources/broker.resource + +# Test Tags robot:exit-on-failure + +Test Setup Test Setup +Test Teardown utils.Test Teardown + + +*** Keywords *** +Test Setup + # entra_password is an Entra ID-specific broker option; other brokers + # (e.g. google) don't offer it, so there is nothing to test there. + Skip If '${BROKER_SNAP_NAME}' != 'authd-msentraid' + ... entra_password is only supported by the msentraid broker + utils.Test Setup snapshot=%{BROKER}-installed + # Enable the Entra ID password flow and disable device auth so only the + # new password+MFA mode is offered, avoiding a provider-selection menu. + # entra_password requires register_device=true (or a client_secret) to fetch + # groups from Microsoft Graph on first login. + Change Broker Configuration register_device true + Change Broker Configuration entra_password true + Change Broker Configuration device_auth false + + +*** Variables *** +${username} %{E2E_USER} +# Check If User Was Added Properly uses this cached local password when it +# verifies that sudo prompts for, and accepts, the post-login local password. +${local_password} %{E2E_PASSWORD} + + +*** Test Cases *** +Test login with CLI using Entra ID password and MFA + [Documentation] Verify that a user can authenticate via the Entra ID direct-password + ... + MFA flow through the CLI (machinectl login). + ... + ... With device_auth disabled the broker auto-selects the single available + ... authentication mode (entra_password), so the user goes straight to the + ... password prompt after choosing the provider. After successful MFA the + ... Entra password is cached locally; the provisioning checks verify that + ... the cached password works for sudo. + + # Log in with local user (brings up the desktop so we can open a terminal). + Log In + + # First login: Entra ID password + TOTP MFA. + Open Terminal + Log In With Remote User Through CLI: Entra Password ${username} + # This shared provisioning check covers NSS, group membership, and the + # cached local-password path via sudo. + Check If User Was Added Properly ${username} + + # Verify the user was provisioned in the system. NSS may be briefly + # unavailable while authd commits the new user record, so retry. + Wait Until Keyword Succeeds 30s 3s Check Home Directory ${username} diff --git a/e2e-tests/tests/login_entra_password_client_secret.robot b/e2e-tests/tests/login_entra_password_client_secret.robot new file mode 100644 index 0000000000..583651ee8c --- /dev/null +++ b/e2e-tests/tests/login_entra_password_client_secret.robot @@ -0,0 +1,59 @@ +*** Settings *** +Resource resources/utils.resource +Resource resources/authd.resource +Resource resources/broker.resource + +# Test Tags robot:exit-on-failure + +Test Setup Test Setup +Test Teardown utils.Test Teardown + + +*** Keywords *** +Test Setup + # entra_password is an Entra ID-specific broker option; other brokers + # (e.g. google) don't offer it, so there is nothing to test there. + Skip If '${BROKER_SNAP_NAME}' != 'authd-msentraid' + ... entra_password is only supported by the msentraid broker + utils.Test Setup snapshot=%{BROKER}-installed + # Inject the OIDC client secret into broker.conf at runtime. The base + # snapshot ships with the secret commented out (so public-client flows are + # not broken by AADSTS700025); this test is the only one that needs it, + # because entra_password must stay available with register_device=false + # by falling back to the app-only Graph token (client credentials). + ${secret}= Get Environment Variable AUTHD_MSENTRAID_CLIENT_SECRET + Should Not Be Empty ${secret} AUTHD_MSENTRAID_CLIENT_SECRET must be set to run this test + Change Broker Configuration client_secret ${secret} + Change Broker Configuration register_device false + Change Broker Configuration entra_password true + Change Broker Configuration device_auth false + + +*** Variables *** +${username} %{E2E_USER} +# Check If User Was Added Properly uses this cached local password when it +# verifies that sudo prompts for, and accepts, the post-login local password. +${local_password} %{E2E_PASSWORD} + + +*** Test Cases *** +Test login with CLI using Entra ID password and MFA with client secret + [Documentation] Verify that the Entra ID direct-password + MFA flow works + ... through the CLI when device registration is disabled and the broker is + ... provisioned with a client secret. + ... + ... The client secret is injected into broker.conf at setup (not baked into + ... the snapshot), so the base snapshot stays secret-free for public-client + ... flows. This covers the alternate configuration where entra_password stays + ... available without register_device=true because Microsoft Graph access + ... comes from the configured application secret instead. + + Log In + + Open Terminal + Log In With Remote User Through CLI: Entra Password ${username} + # This shared provisioning check covers NSS, group membership, and the + # cached local-password path via sudo. + Check If User Was Added Properly ${username} + + Wait Until Keyword Succeeds 30s 3s Check Home Directory ${username} diff --git a/e2e-tests/vm/provision-authd.sh b/e2e-tests/vm/provision-authd.sh index aa9e6393cd..103f0c83b6 100755 --- a/e2e-tests/vm/provision-authd.sh +++ b/e2e-tests/vm/provision-authd.sh @@ -216,6 +216,7 @@ function install_broker() { -e "s||${issuer_id}|g" \ -e "s||${client_id}|g" \ -e "s||${client_secret}|g" \ + -e "s/^#entra_password = .*/entra_password = false/" \ /var/snap/${broker}/current/broker.conf echo 'verbosity: 2' > /var/snap/${broker}/current/${broker}.yaml systemctl restart authd.service diff --git a/internal/brokers/broker.go b/internal/brokers/broker.go index 9b7550d91a..8cfb0dab6c 100644 --- a/internal/brokers/broker.go +++ b/internal/brokers/broker.go @@ -22,6 +22,15 @@ import ( // LocalBrokerName is the name of the local broker. const LocalBrokerName = "local" +// grantedData is the canonical envelope used to carry a granted authentication +// result between the broker layer and the PAM service. The optional message is +// an authd-controlled, user-facing notice (for example, a caching indicator) +// that the broker may attach on a successful login. +type grantedData struct { + UserInfo types.UserInfo `json:"userinfo"` + Message string `json:"message,omitempty"` +} + type brokerer interface { NewSession(ctx context.Context, username, lang, mode, providerID string) (sessionID, encryptionKey string, err error) GetAuthenticationModes(ctx context.Context, sessionID string, supportedUILayouts []map[string]string) (authenticationModes []map[string]string, err error) @@ -210,9 +219,14 @@ func (b Broker) IsAuthenticated(ctx context.Context, sessionID, authenticationDa switch access { case auth.Granted: - rawUserInfo, err := unmarshalAndGetKey(data, "userinfo") - if err != nil { - return "", "", err + var rawData map[string]json.RawMessage + if err := json.Unmarshal([]byte(data), &rawData); err != nil { + return "", "", fmt.Errorf("response returned by the broker is not a valid json: %v\nBroker returned: %v", err, data) + } + + rawUserInfo, ok := rawData["userinfo"] + if !ok { + return "", "", fmt.Errorf("missing key %q in returned message, got: %v", "userinfo", data) } info, err := unmarshalUserInfo(rawUserInfo) @@ -224,14 +238,25 @@ func (b Broker) IsAuthenticated(ctx context.Context, sessionID, authenticationDa return "", "", err } - d, err := json.Marshal(info) + var message string + if rawMessage := rawData["message"]; rawMessage != nil { + if err := json.Unmarshal(rawMessage, &message); err != nil { + // A non-string message must not fail an already-granted login; it's cosmetic. + log.Warningf(ctx, "Ignoring non-string message in broker granted response: %v", err) + } + } + + // Always forward a consistent {"userinfo": ..., "message": ...} envelope + // (message omitted when empty) so the consumer always parses the same + // shape regardless of whether the broker attached a success message. + d, err := json.Marshal(grantedData{UserInfo: info, Message: message}) if err != nil { return "", "", fmt.Errorf("can't marshal UserInfo: %v", err) } data = string(d) case auth.Denied, auth.Retry: - if _, err := unmarshalAndGetKey(data, "message"); err != nil { + if err := requireKey(data, "message"); err != nil { return "", "", err } @@ -239,7 +264,7 @@ func (b Broker) IsAuthenticated(ctx context.Context, sessionID, authenticationDa if data == "{}" { break } - if _, err := unmarshalAndGetKey(data, "message"); err != nil { + if err := requireKey(data, "message"); err != nil { return "", "", err } @@ -416,17 +441,16 @@ func validateUserInfo(uInfo types.UserInfo) (err error) { return nil } -// unmarshalAndGetKey tries to unmarshal the content in data and returns the value of the requested key. -func unmarshalAndGetKey(data, key string) (json.RawMessage, error) { +// requireKey unmarshals data and returns an error if the given key is missing. +func requireKey(data, key string) error { var returnedData map[string]json.RawMessage if err := json.Unmarshal([]byte(data), &returnedData); err != nil { - return nil, fmt.Errorf("response returned by the broker is not a valid json: %v\nBroker returned: %v", err, data) + return fmt.Errorf("response returned by the broker is not a valid json: %v\nBroker returned: %v", err, data) } - rawMsg, ok := returnedData[key] - if !ok { - return nil, fmt.Errorf("missing key %q in returned message, got: %v", key, data) + if _, ok := returnedData[key]; !ok { + return fmt.Errorf("missing key %q in returned message, got: %v", key, data) } - return rawMsg, nil + return nil } diff --git a/internal/brokers/broker_test.go b/internal/brokers/broker_test.go index b1a6e9ef64..d30b45f902 100644 --- a/internal/brokers/broker_test.go +++ b/internal/brokers/broker_test.go @@ -213,6 +213,8 @@ func TestIsAuthenticated(t *testing.T) { cancelFirstCall bool }{ "Successfully_authenticate": {sessionID: "success"}, + "Successfully_authenticate_with_granted_message": {sessionID: "ia_granted_with_data"}, + "Ignores_non_string_message_in_granted_response": {sessionID: "ia_granted_with_non_string_message"}, "Successfully_authenticate_after_cancelling_first_call": {sessionID: "ia_second_call", secondCall: true}, "Denies_authentication_when_broker_times_out": {sessionID: "ia_timeout"}, "Adds_default_groups_even_if_broker_did_not_set_them": {sessionID: "ia_info_empty_groups"}, diff --git a/internal/brokers/testdata/golden/TestIsAuthenticated/Adds_default_groups_even_if_broker_did_not_set_them b/internal/brokers/testdata/golden/TestIsAuthenticated/Adds_default_groups_even_if_broker_did_not_set_them index 46eba4c9db..f2202c3ce1 100644 --- a/internal/brokers/testdata/golden/TestIsAuthenticated/Adds_default_groups_even_if_broker_did_not_set_them +++ b/internal/brokers/testdata/golden/TestIsAuthenticated/Adds_default_groups_even_if_broker_did_not_set_them @@ -1,4 +1,4 @@ FIRST CALL: access: granted - data: {"Name":"ia_info_empty_groups@example.com","UID":0,"Gecos":"gecos for ia_info_empty_groups@example.com","Dir":"/home/ia_info_empty_groups@example.com","Shell":"/bin/sh/ia_info_empty_groups@example.com","provider_id":"providerid-ia_info_empty_groups@example.com","Groups":[]} + data: {"userinfo":{"Name":"ia_info_empty_groups@example.com","UID":0,"Gecos":"gecos for ia_info_empty_groups@example.com","Dir":"/home/ia_info_empty_groups@example.com","Shell":"/bin/sh/ia_info_empty_groups@example.com","provider_id":"providerid-ia_info_empty_groups@example.com","Groups":[]}} err: diff --git a/internal/brokers/testdata/golden/TestIsAuthenticated/Ignores_non_string_message_in_granted_response b/internal/brokers/testdata/golden/TestIsAuthenticated/Ignores_non_string_message_in_granted_response new file mode 100644 index 0000000000..7dc24268ac --- /dev/null +++ b/internal/brokers/testdata/golden/TestIsAuthenticated/Ignores_non_string_message_in_granted_response @@ -0,0 +1,4 @@ +FIRST CALL: + access: granted + data: {"userinfo":{"Name":"ia_granted_with_non_string_message@example.com","UID":0,"Gecos":"gecos for ia_granted_with_non_string_message@example.com","Dir":"/home/ia_granted_with_non_string_message@example.com","Shell":"/bin/sh/ia_granted_with_non_string_message@example.com","provider_id":"providerid-ia_granted_with_non_string_message@example.com","Groups":[{"Name":"group-ia_granted_with_non_string_message@example.com","GID":null,"UGID":"ugid-ia_granted_with_non_string_message@example.com"}]}} + err: diff --git a/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_empty_gecos b/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_empty_gecos index dd36531727..86f7fbcd7b 100644 --- a/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_empty_gecos +++ b/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_empty_gecos @@ -1,4 +1,4 @@ FIRST CALL: access: granted - data: {"Name":"ia_info_empty_gecos@example.com","UID":0,"Gecos":"","Dir":"/home/ia_info_empty_gecos@example.com","Shell":"/bin/sh/ia_info_empty_gecos@example.com","provider_id":"providerid-ia_info_empty_gecos@example.com","Groups":[{"Name":"group-ia_info_empty_gecos@example.com","GID":null,"UGID":"ugid-ia_info_empty_gecos@example.com"}]} + data: {"userinfo":{"Name":"ia_info_empty_gecos@example.com","UID":0,"Gecos":"","Dir":"/home/ia_info_empty_gecos@example.com","Shell":"/bin/sh/ia_info_empty_gecos@example.com","provider_id":"providerid-ia_info_empty_gecos@example.com","Groups":[{"Name":"group-ia_info_empty_gecos@example.com","GID":null,"UGID":"ugid-ia_info_empty_gecos@example.com"}]}} err: diff --git a/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_group_with_empty_UGID b/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_group_with_empty_UGID index 86e8f8d50c..510ddbb734 100644 --- a/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_group_with_empty_UGID +++ b/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_group_with_empty_UGID @@ -1,4 +1,4 @@ FIRST CALL: access: granted - data: {"Name":"ia_info_empty_ugid@example.com","UID":0,"Gecos":"gecos for ia_info_empty_ugid@example.com","Dir":"/home/ia_info_empty_ugid@example.com","Shell":"/bin/sh/ia_info_empty_ugid@example.com","provider_id":"providerid-ia_info_empty_ugid@example.com","Groups":[{"Name":"group-ia_info_empty_ugid@example.com","GID":null,"UGID":""}]} + data: {"userinfo":{"Name":"ia_info_empty_ugid@example.com","UID":0,"Gecos":"gecos for ia_info_empty_ugid@example.com","Dir":"/home/ia_info_empty_ugid@example.com","Shell":"/bin/sh/ia_info_empty_ugid@example.com","provider_id":"providerid-ia_info_empty_ugid@example.com","Groups":[{"Name":"group-ia_info_empty_ugid@example.com","GID":null,"UGID":""}]}} err: diff --git a/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_mismatching_username b/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_mismatching_username index e210e943e8..d706fb382f 100644 --- a/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_mismatching_username +++ b/internal/brokers/testdata/golden/TestIsAuthenticated/No_error_when_broker_returns_userinfo_with_mismatching_username @@ -1,4 +1,4 @@ FIRST CALL: access: granted - data: {"Name":"different_username@example.com","UID":0,"Gecos":"gecos for ia_info_mismatching_user_name@example.com","Dir":"/home/ia_info_mismatching_user_name@example.com","Shell":"/bin/sh/ia_info_mismatching_user_name@example.com","provider_id":"providerid-different_username@example.com","Groups":[{"Name":"group-ia_info_mismatching_user_name@example.com","GID":null,"UGID":"ugid-ia_info_mismatching_user_name@example.com"}]} + data: {"userinfo":{"Name":"different_username@example.com","UID":0,"Gecos":"gecos for ia_info_mismatching_user_name@example.com","Dir":"/home/ia_info_mismatching_user_name@example.com","Shell":"/bin/sh/ia_info_mismatching_user_name@example.com","provider_id":"providerid-different_username@example.com","Groups":[{"Name":"group-ia_info_mismatching_user_name@example.com","GID":null,"UGID":"ugid-ia_info_mismatching_user_name@example.com"}]}} err: diff --git a/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate b/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate index a31116a559..26b061e4a7 100644 --- a/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate +++ b/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate @@ -1,4 +1,4 @@ FIRST CALL: access: granted - data: {"Name":"success@example.com","UID":0,"Gecos":"gecos for success@example.com","Dir":"/home/success@example.com","Shell":"/bin/sh/success@example.com","provider_id":"providerid-success@example.com","Groups":[{"Name":"group-success@example.com","GID":null,"UGID":"ugid-success@example.com"}]} + data: {"userinfo":{"Name":"success@example.com","UID":0,"Gecos":"gecos for success@example.com","Dir":"/home/success@example.com","Shell":"/bin/sh/success@example.com","provider_id":"providerid-success@example.com","Groups":[{"Name":"group-success@example.com","GID":null,"UGID":"ugid-success@example.com"}]}} err: diff --git a/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_after_cancelling_first_call b/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_after_cancelling_first_call index 7d9c2cc7bf..f96f6b051f 100644 --- a/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_after_cancelling_first_call +++ b/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_after_cancelling_first_call @@ -4,5 +4,5 @@ FIRST CALL: err: SECOND CALL: access: granted - data: {"Name":"ia_second_call@example.com","UID":0,"Gecos":"gecos for ia_second_call@example.com","Dir":"/home/ia_second_call@example.com","Shell":"/bin/sh/ia_second_call@example.com","provider_id":"providerid-ia_second_call@example.com","Groups":[{"Name":"group-ia_second_call@example.com","GID":null,"UGID":"ugid-ia_second_call@example.com"}]} + data: {"userinfo":{"Name":"ia_second_call@example.com","UID":0,"Gecos":"gecos for ia_second_call@example.com","Dir":"/home/ia_second_call@example.com","Shell":"/bin/sh/ia_second_call@example.com","provider_id":"providerid-ia_second_call@example.com","Groups":[{"Name":"group-ia_second_call@example.com","GID":null,"UGID":"ugid-ia_second_call@example.com"}]}} err: diff --git a/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_after_second_call_without_cancelling b/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_after_second_call_without_cancelling index 10030273d9..3bb566a18f 100644 --- a/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_after_second_call_without_cancelling +++ b/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_after_second_call_without_cancelling @@ -1,8 +1,8 @@ FIRST CALL: access: granted - data: {"Name":"ia_second_call@example.com","UID":0,"Gecos":"gecos for ia_second_call@example.com","Dir":"/home/ia_second_call@example.com","Shell":"/bin/sh/ia_second_call@example.com","provider_id":"providerid-ia_second_call@example.com","Groups":[{"Name":"group-ia_second_call@example.com","GID":null,"UGID":"ugid-ia_second_call@example.com"}]} + data: {"userinfo":{"Name":"ia_second_call@example.com","UID":0,"Gecos":"gecos for ia_second_call@example.com","Dir":"/home/ia_second_call@example.com","Shell":"/bin/sh/ia_second_call@example.com","provider_id":"providerid-ia_second_call@example.com","Groups":[{"Name":"group-ia_second_call@example.com","GID":null,"UGID":"ugid-ia_second_call@example.com"}]}} err: SECOND CALL: access: granted - data: {"Name":"ia_second_call@example.com","UID":0,"Gecos":"gecos for ia_second_call@example.com","Dir":"/home/ia_second_call@example.com","Shell":"/bin/sh/ia_second_call@example.com","provider_id":"providerid-ia_second_call@example.com","Groups":[{"Name":"group-ia_second_call@example.com","GID":null,"UGID":"ugid-ia_second_call@example.com"}]} + data: {"userinfo":{"Name":"ia_second_call@example.com","UID":0,"Gecos":"gecos for ia_second_call@example.com","Dir":"/home/ia_second_call@example.com","Shell":"/bin/sh/ia_second_call@example.com","provider_id":"providerid-ia_second_call@example.com","Groups":[{"Name":"group-ia_second_call@example.com","GID":null,"UGID":"ugid-ia_second_call@example.com"}]}} err: diff --git a/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message b/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message new file mode 100644 index 0000000000..1c20c3030f --- /dev/null +++ b/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message @@ -0,0 +1,4 @@ +FIRST CALL: + access: granted + data: {"userinfo":{"Name":"ia_granted_with_data@example.com","UID":0,"Gecos":"gecos for ia_granted_with_data@example.com","Dir":"/home/ia_granted_with_data@example.com","Shell":"/bin/sh/ia_granted_with_data@example.com","provider_id":"providerid-ia_granted_with_data@example.com","Groups":[{"Name":"group-ia_granted_with_data@example.com","GID":null,"UGID":"ugid-ia_granted_with_data@example.com"}]},"message":"Offline login is enabled with your Entra password"} + err: diff --git a/internal/services/pam/pam.go b/internal/services/pam/pam.go index 891503677a..b6dfaf38b2 100644 --- a/internal/services/pam/pam.go +++ b/internal/services/pam/pam.go @@ -291,19 +291,21 @@ func (s Service) IsAuthenticated(ctx context.Context, req *authd.IARequest) (res }, nil } - var uInfo types.UserInfo - if err := json.Unmarshal([]byte(data), &uInfo); err != nil { + var grantedData struct { + UserInfo types.UserInfo `json:"userinfo"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(data), &grantedData); err != nil { log.Errorf(ctx, "IsAuthenticated: Could not unmarshal user data for session %q: %v", sessionID, err) return nil, fmt.Errorf("user data from broker invalid: %v", err) } - + uInfo := grantedData.UserInfo // authd uses lowercase user and group names uInfo.Name = strings.ToLower(uInfo.Name) uInfo.BrokerID = broker.ID for i, g := range uInfo.Groups { uInfo.Groups[i].Name = strings.ToLower(g.Name) } - // Check if the user is locked. We can only do this after the broker has granted access, because we want to avoid // leaking whether a user exists or not to unauthenticated users. // TODO: We might want to let the broker know whether the user is locked or not, so that it can avoid storing any @@ -313,7 +315,6 @@ func (s Service) IsAuthenticated(ctx context.Context, req *authd.IARequest) (res log.Errorf(ctx, "IsAuthenticated: Could not check if user %q is locked: %v", uInfo.Name, err) return nil, fmt.Errorf("could not check if user %q is locked: %w", uInfo.Name, err) } - // The username may have changed at the IdP, in which case the locked row is still stored under the // previous name and the name-based lookup above misses it. Resolve the stable identity by the // broker-scoped provider ID and honor its locked state too. @@ -324,22 +325,32 @@ func (s Service) IsAuthenticated(ctx context.Context, req *authd.IARequest) (res return nil, fmt.Errorf("could not check if user %q is locked: %w", uInfo.Name, err) } } - // Throw an error if the user trying to authenticate already exists in the database and is locked. if userIsLocked { log.Noticef(ctx, "Authentication failure: user %q is locked", uInfo.Name) return nil, status.Error(codes.PermissionDenied, fmt.Sprintf("user %s is locked", uInfo.Name)) } - // Update database and local groups on granted auth. if err := s.userManager.UpdateUser(uInfo); err != nil { log.Errorf(ctx, "IsAuthenticated: Could not update user %q in database: %v", uInfo.Name, err) return nil, err } + // IAResponse.Msg carries a JSON {"message": ...} envelope (or an empty + // string when there is no message), matching the format expected by the + // PAM client's dataToMsg parser. + msg := "" + if grantedData.Message != "" { + messageData, err := json.Marshal(map[string]string{"message": grantedData.Message}) + if err != nil { + log.Warningf(ctx, "IsAuthenticated: Could not marshal granted message for session %q, ignoring: %v", sessionID, err) + } else { + msg = string(messageData) + } + } return &authd.IAResponse{ Access: access, - Msg: "", + Msg: msg, }, nil } diff --git a/internal/services/pam/pam_test.go b/internal/services/pam/pam_test.go index 8d722df100..14911a4a3b 100644 --- a/internal/services/pam/pam_test.go +++ b/internal/services/pam/pam_test.go @@ -437,6 +437,8 @@ func TestIsAuthenticated(t *testing.T) { // There is no wantErr as it's stored in the golden file. }{ "Successfully_authenticate": {username: "success@example.com"}, + "Successfully_authenticate_with_granted_message": {username: "ia_granted_with_data@example.com"}, + "Successfully_authenticate_with_non_string_message": {username: "ia_granted_with_non_string_message@example.com"}, "Successfully_authenticate_if_first_call_is_canceled": {username: "ia_second_call@example.com", secondCall: true, cancelFirstCall: true}, "Denies_authentication_when_broker_times_out": {username: "ia_timeout@example.com"}, "Update_existing_DB_on_success": {username: "success@example.com", existingDB: "cache-with-user.db"}, diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message/IsAuthenticated new file mode 100644 index 0000000000..01503cec02 --- /dev/null +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message/IsAuthenticated @@ -0,0 +1,4 @@ +FIRST CALL: + access: granted + msg: {"message":"Offline login is enabled with your Entra password"} + err: diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message/cache.db new file mode 100644 index 0000000000..4ec3bbf919 --- /dev/null +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message/cache.db @@ -0,0 +1,22 @@ +users: + - name: ia_granted_with_data@example.com + uid: 1111 + gid: 1111 + gecos: gecos for ia_granted_with_data@example.com + dir: /home/ia_granted_with_data@example.com + shell: /bin/sh/ia_granted_with_data@example.com + broker_id: "1902181170" + provider_id: providerid-ia_granted_with_data@example.com +groups: + - name: ia_granted_with_data@example.com + gid: 1111 + ugid: ia_granted_with_data@example.com + - name: group-ia_granted_with_data@example.com + gid: 22222 + ugid: ugid-ia_granted_with_data@example.com +users_to_groups: + - uid: 1111 + gid: 1111 + - uid: 1111 + gid: 22222 +schema_version: 3 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_non_string_message/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_non_string_message/IsAuthenticated new file mode 100644 index 0000000000..0db1ac0491 --- /dev/null +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_non_string_message/IsAuthenticated @@ -0,0 +1,4 @@ +FIRST CALL: + access: granted + msg: + err: diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_non_string_message/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_non_string_message/cache.db new file mode 100644 index 0000000000..472af775c2 --- /dev/null +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_non_string_message/cache.db @@ -0,0 +1,22 @@ +users: + - name: ia_granted_with_non_string_message@example.com + uid: 1111 + gid: 1111 + gecos: gecos for ia_granted_with_non_string_message@example.com + dir: /home/ia_granted_with_non_string_message@example.com + shell: /bin/sh/ia_granted_with_non_string_message@example.com + broker_id: "1902181170" + provider_id: providerid-ia_granted_with_non_string_message@example.com +groups: + - name: ia_granted_with_non_string_message@example.com + gid: 1111 + ugid: ia_granted_with_non_string_message@example.com + - name: group-ia_granted_with_non_string_message@example.com + gid: 22222 + ugid: ugid-ia_granted_with_non_string_message@example.com +users_to_groups: + - uid: 1111 + gid: 1111 + - uid: 1111 + gid: 22222 +schema_version: 3 diff --git a/internal/testutils/broker.go b/internal/testutils/broker.go index 2bb0facbe6..f542923df4 100644 --- a/internal/testutils/broker.go +++ b/internal/testutils/broker.go @@ -310,6 +310,14 @@ func (b *BrokerBusMock) IsAuthenticated(sessionID, authenticationData string) (a access = authNext data = `{"message": "It's fine to show a message here"}` + case "ia_granted_with_data": + access = authGranted + data = fmt.Sprintf(`{"userinfo": %s, "message": "Offline login is enabled with your Entra password"}`, userInfoFromName(sessionID, nil)) + + case "ia_granted_with_non_string_message": + access = authGranted + data = fmt.Sprintf(`{"userinfo": %s, "message": 42}`, userInfoFromName(sessionID, nil)) + case "ia_next_with_invalid_data": access = authNext data = `{"msg": "there should not be a message here"}` diff --git a/pam/internal/adapter/authentication.go b/pam/internal/adapter/authentication.go index 0e0846a6d9..f98047b127 100644 --- a/pam/internal/adapter/authentication.go +++ b/pam/internal/adapter/authentication.go @@ -352,11 +352,11 @@ func (m authenticationModel) Update(msg tea.Msg) (authModel authenticationModel, var authMsg string if msg.access != auth.Cancelled { - msg, err := dataToMsg(msg.msg) + var err error + authMsg, err = grantedTolerantMsg(msg.access, msg.msg) if err != nil { return m, sendEvent(pamError{status: pam.ErrSystem, msg: err.Error()}) } - authMsg = msg } switch msg.access { @@ -559,6 +559,19 @@ func dataToMsg(data string) (string, error) { return r, nil } +// grantedTolerantMsg parses data via dataToMsg, treating a malformed or +// unexpected message as non-fatal when access is auth.Granted: the message is +// then a purely cosmetic notice, so an already-granted login must never fail +// because of it. For any other access the error is returned unchanged. +func grantedTolerantMsg(access string, data string) (string, error) { + msg, err := dataToMsg(data) + if err != nil && access == auth.Granted { + log.Warningf(context.TODO(), "Ignoring invalid granted message: %v", err) + return "", nil + } + return msg, err +} + func (authData *isAuthenticatedRequestedSend) encryptSecretIfPresent(publicKey *rsa.PublicKey) (*string, error) { // no password value, pass it as is secret, ok := authData.item.(*authd.IARequest_AuthenticationData_Secret) diff --git a/pam/internal/adapter/gdmmodel.go b/pam/internal/adapter/gdmmodel.go index 3cfd7ce83f..9b8d8e8708 100644 --- a/pam/internal/adapter/gdmmodel.go +++ b/pam/internal/adapter/gdmmodel.go @@ -265,7 +265,7 @@ func (m gdmModel) Update(msg tea.Msg) (gdmModel, tea.Cmd) { case gdmIsAuthenticatedResultReceived: access := msg.access - authMsg, err := dataToMsg(msg.msg) + authMsg, err := grantedTolerantMsg(access, msg.msg) if err != nil { return m, sendEvent(pamError{status: pam.ErrSystem, msg: err.Error()}) } diff --git a/pam/internal/adapter/gdmmodel_test.go b/pam/internal/adapter/gdmmodel_test.go index 06adf5f95b..b91f65ad56 100644 --- a/pam/internal/adapter/gdmmodel_test.go +++ b/pam/internal/adapter/gdmmodel_test.go @@ -409,6 +409,52 @@ func TestGdmModel(t *testing.T) { msg: "Hi GDM, it's a pleasure to get you in!", }, }, + "Authenticated_with_invalid_message_still_succeeds_with_preset_PAM_user_and_server_side_broker_and_authMode_selection": { + clientOptions: append(slices.Clone(multiBrokerClientOptions), + pam_test.WithGetBrokerReturn(firstBrokerInfo.Id, nil), + pam_test.WithIsAuthenticatedReturn(&authd.IAResponse{ + Access: auth.Granted, + // A bare (non-JSON) message must never fail an + // already-granted authentication: it is dropped, not fatal. + Msg: "You're in, but this is not a valid JSON envelope!", + }, nil), + ), + pamUser: "pam-preset-user-and-daemon-selected-broker", + messages: []tea.Msg{ + gdmTestWaitForStage{ + stage: proto.Stage_challenge, + commands: []tea.Cmd{ + sendEvent(gdmTestSendAuthDataWhenReady{&authd.IARequest_AuthenticationData_Secret{ + Secret: "gdm-good-password", + }}), + }, + }, + }, + wantSelectedBroker: firstBrokerInfo.Id, + wantGdmRequests: []gdm.RequestType{ + gdm.RequestType_uiLayoutCapabilities, + gdm.RequestType_changeStage, // -> broker Selection + gdm.RequestType_changeStage, // -> authMode Selection + gdm.RequestType_changeStage, // -> password + }, + wantGdmEvents: []gdm.EventType{ + gdm.EventType_userSelected, + gdm.EventType_brokersReceived, + gdm.EventType_brokerSelected, + gdm.EventType_authModeSelected, + gdm.EventType_uiLayoutReceived, + gdm.EventType_authEvent, + gdm.EventType_startAuthentication, + }, + wantStage: proto.Stage_challenge, + wantGdmAuthRes: []*authd.IAResponse{{ + Access: auth.Granted, + Msg: "", + }}, + wantExitStatus: PamSuccess{ + BrokerID: firstBrokerInfo.Id, + }, + }, "New_password_changed_after_server_side_broker_and_authMode_selection": { clientOptions: append(slices.Clone(singleBrokerNewPasswordClientOptions), pam_test.WithGetBrokerReturn(firstBrokerInfo.Id, nil), diff --git a/pam/internal/adapter/nativemodel.go b/pam/internal/adapter/nativemodel.go index 1fc32787bd..ca14936a90 100644 --- a/pam/internal/adapter/nativemodel.go +++ b/pam/internal/adapter/nativemodel.go @@ -317,7 +317,7 @@ func (m nativeModel) Update(msg tea.Msg) (nativeModel, tea.Cmd) { case isAuthenticatedResultReceived: access := msg.access - authMsg, err := dataToMsg(msg.msg) + authMsg, err := grantedTolerantMsg(access, msg.msg) if cmd := maybeSendPamError(err); cmd != nil { return m, cmd } diff --git a/pam/pam.go b/pam/pam.go index bc46bf80c9..3b2cf0dcc3 100644 --- a/pam/pam.go +++ b/pam/pam.go @@ -119,6 +119,21 @@ func sendReturnMessageToPam(mTx pam.ModuleTransaction, retStatus adapter.PamRetu } } +func shouldSendAuthMessage(clientType adapter.PamClientType, msg string, isSuccess bool) bool { + if msg == "" { + return false + } + + if isSuccess { + // Native clients (SSH, non-TTY) already display the success message + // via the native model's sendInfo path; skip the PAM-conversation echo + // to avoid printing it twice. + return clientType != adapter.Native + } + + return true +} + // initLogging initializes the logging given the passed parameters. // It returns a function that should be called in order to reset the logging to // the default and potentially close the opened resources. @@ -326,19 +341,26 @@ func (h *pamModule) handleAuthRequest(mode authd.SessionMode, mTx pam.ModuleTran return pam.ErrAbort } - sendReturnMessageToPam(mTx, exitStatus) - switch exitStatus := exitStatus.(type) { case adapter.PamSuccess: + if shouldSendAuthMessage(pamClientType, exitStatus.Message(), true) { + sendReturnMessageToPam(mTx, exitStatus) + } if err := mTx.SetData(authenticationBrokerIDKey, exitStatus.BrokerID); err != nil { return err } return nil case adapter.PamReturnError: + if shouldSendAuthMessage(pamClientType, exitStatus.Message(), false) { + sendReturnMessageToPam(mTx, exitStatus) + } return fmt.Errorf("%w: %s", exitStatus.Status(), exitStatus.Message()) default: + // Preserve the previous behavior of showing any message associated with + // unexpected exit statuses before returning the system error. + sendReturnMessageToPam(mTx, exitStatus) return fmt.Errorf("%w: unknown exit code: %#v", pam.ErrSystem, exitStatus) } } diff --git a/pam/pam_session_test.go b/pam/pam_session_test.go new file mode 100644 index 0000000000..898776a05a --- /dev/null +++ b/pam/pam_session_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "testing" + + "github.com/canonical/authd/pam/internal/adapter" + "github.com/stretchr/testify/require" +) + +func TestShouldSendAuthMessage(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + clientType adapter.PamClientType + msg string + isSuccess bool + + want bool + }{ + "Does_not_send_native_success_messages_again": { + clientType: adapter.Native, + msg: "cached", + isSuccess: true, + want: false, + }, + "Sends_gdm_success_messages_via_pam_conversation": { + clientType: adapter.Gdm, + msg: "cached", + isSuccess: true, + want: true, + }, + "Sends_interactive_terminal_success_messages": { + clientType: adapter.InteractiveTerminal, + msg: "cached", + isSuccess: true, + want: true, + }, + "Sends_error_messages": { + clientType: adapter.Native, + msg: "denied", + isSuccess: false, + want: true, + }, + "Ignores_empty_messages": { + clientType: adapter.Native, + msg: "", + isSuccess: true, + want: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.want, shouldSendAuthMessage(tc.clientType, tc.msg, tc.isSuccess)) + }) + } +}