From 322a805e59c97c83a10a78f8597a4b91cc936498 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 3 Jun 2026 16:46:46 +0300 Subject: [PATCH 01/25] broker/himmelblau: move device registration helpers out of withmsentraid Move `DeviceRegistrationData` and its validation helper into an untagged file so cached device-registration JSON can be validated without the `libhimmelblau` cgo build. --- .../himmelblau/deviceregistration.go | 38 ++++++++++++++++ .../himmelblau/deviceregistration_test.go | 44 +++++++++++++++++++ .../msentraid/himmelblau/himmelblau.go | 19 -------- 3 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 authd-oidc-brokers/internal/providers/msentraid/himmelblau/deviceregistration.go create mode 100644 authd-oidc-brokers/internal/providers/msentraid/himmelblau/deviceregistration_test.go 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/himmelblau.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go index ca126d78b7..136498ea9c 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go @@ -87,25 +87,6 @@ func ensureBrokerClientAppInitialized(tenantID string, data *DeviceRegistrationD return brokerClientAppInitErr } -// 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"` -} - -// 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 -} - // RegisterDevice registers the device with Microsoft Entra ID and returns the // device registration data required for subsequent access token acquisition via // AcquireAccessTokenForGraphAPI. From 1ff45b16a8eef48c893fa4cfc0a116244f5e2e2a Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 3 Jun 2026 17:06:02 +0300 Subject: [PATCH 02/25] broker/himmelblau: add bindings for Entra MFA login Add the Go-side types and Cgo bindings needed to start and continue the `libhimmelblau` MFA flow from the broker. Also add helpers to extract user identity fields from access token claims, since the Entra password flow returns an access token rather than a standard OIDC ID token. Bump `libhimmelblau` to the revision that exposes the MFA C API. --- .../msentraid/himmelblau/entrapwd.go | 166 +++++++++ .../msentraid/himmelblau/entrapwd_test.go | 137 ++++++++ .../msentraid/himmelblau/himmelblau.go | 267 ++++++++++++-- .../msentraid/himmelblau/himmelblau_c.go | 325 +++++++++++++++++- .../msentraid/himmelblau/himmelblau_c_test.go | 55 +++ authd-oidc-brokers/third_party/libhimmelblau | 2 +- 6 files changed, 905 insertions(+), 47 deletions(-) create mode 100644 authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd.go create mode 100644 authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd_test.go create mode 100644 authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c_test.go 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..07bb7d94a6 --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd.go @@ -0,0 +1,166 @@ +package himmelblau + +import ( + "context" + "fmt" + "sync" + + "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) +} + +// 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 + PollingInterval 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 newMFAInitError for how + // this is detected. + MFAErrorRetryableCode +) + +// MFAInitError 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 MFAInitError struct { + Category MFAErrorCategory + AADSTS int + Message string +} + +// Error returns the formatted error message. +func (e *MFAInitError) 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 *MFAInitError) 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 *MFAInitError) IsMFADenied() bool { + return e.Category == MFAErrorDenied +} + +// IsMFARequired returns true if the error indicates MFA is required. +func (e *MFAInitError) 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 *MFAInitError) 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..1e1b4f92ab --- /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 TestMFAInitError_Error(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err *MFAInitError + want string + }{ + "Without_AADSTS": {err: &MFAInitError{Message: "plain message"}, want: "plain message"}, + "With_AADSTS": {err: &MFAInitError{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 TestMFAInitError_IsMFAPollContinue(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err *MFAInitError + want bool + }{ + "Poll_continue": {err: &MFAInitError{Category: MFAErrorPollContinue}, want: true}, + "Denied": {err: &MFAInitError{Category: MFAErrorDenied}, want: false}, + "Required": {err: &MFAInitError{Category: MFAErrorRequired}, want: false}, + "Other": {err: &MFAInitError{Category: MFAErrorOther}, want: false}, + "Poll_continue_with_aadsts": {err: &MFAInitError{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 TestMFAInitError_IsMFADenied(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err *MFAInitError + want bool + }{ + "Denied_no_aadsts": {err: &MFAInitError{Category: MFAErrorDenied}, want: true}, + "Denied_with_aadsts": {err: &MFAInitError{Category: MFAErrorDenied, AADSTS: 50126}, want: true}, + "Poll_continue": {err: &MFAInitError{Category: MFAErrorPollContinue}, want: false}, + "Required": {err: &MFAInitError{Category: MFAErrorRequired}, want: false}, + "Other": {err: &MFAInitError{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 TestMFAInitError_IsMFARequired(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err *MFAInitError + want bool + }{ + "Required": {err: &MFAInitError{Category: MFAErrorRequired}, want: true}, + "Poll_continue": {err: &MFAInitError{Category: MFAErrorPollContinue}, want: false}, + "Denied": {err: &MFAInitError{Category: MFAErrorDenied}, want: false}, + "Other": {err: &MFAInitError{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 TestMFAInitError_IsMFARetryableCode(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err *MFAInitError + want bool + }{ + "Retryable_code": {err: &MFAInitError{Category: MFAErrorRetryableCode}, want: true}, + "Retryable_code_with_aadsts": {err: &MFAInitError{Category: MFAErrorRetryableCode, AADSTS: 50126}, want: true}, + "Poll_continue": {err: &MFAInitError{Category: MFAErrorPollContinue}, want: false}, + "Denied": {err: &MFAInitError{Category: MFAErrorDenied}, want: false}, + "Required": {err: &MFAInitError{Category: MFAErrorRequired}, want: false}, + "Other": {err: &MFAInitError{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 136498ea9c..e1f73a89d8 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,33 +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 +} + +func hashCacheKeyBytes(value []byte) string { + if len(value) == 0 { + return "" + } + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} + +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 @@ -120,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) } @@ -202,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) } @@ -223,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, @@ -244,3 +337,115 @@ 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). + // Include the user's SPN (preferred_username) and UUID (sub) as token extras so that + // finishEntraAuth can recover user info without calling the UserInfo endpoint. + extras := map[string]interface{}{} + if spn, spnErr := spnFromUserToken(userToken); spnErr == nil && spn != "" { + extras["preferred_username"] = spn + log.Debugf(ctx, "MFA token SPN: %q", spn) + } else if spnErr != nil { + log.Debugf(ctx, "Could not get SPN from MFA token: %v", spnErr) + } + if sub, subErr := uuidFromUserToken(userToken); subErr == nil && sub != "" { + extras["sub"] = sub + } else if subErr != nil { + log.Debugf(ctx, "Could not get UUID from MFA token: %v", subErr) + } + + // The Entra password flow returns an access token rather than an OIDC + // id_token, so recover the display name and any missing identity claims + // (name, scp, plus a preferred_username/sub fallback) from the access + // token JWT, which carries the "name" claim in every flow we use. The + // SPN/UUID extras set above take priority over duplicates. + if accessExtras := tokenExtrasFromAccessToken(ctx, accessToken); len(accessExtras) > 0 { + for k, v := range accessExtras { + if _, alreadySet := extras[k]; !alreadySet { + 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..36f3f677fc 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,34 @@ 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) +) + +// 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 + } + return MFAErrorOther +} + // Entra AADSTS error codes as defined in // https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes const ( @@ -50,8 +85,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 +103,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 +131,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 +144,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 +160,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 +183,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 +194,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 +208,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 +221,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 +248,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 +262,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 +274,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 +293,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 +303,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 +323,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)) @@ -320,6 +368,7 @@ func acquireTokenByRefreshToken(broker *brokerClientApplication, refreshToken st &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 +407,255 @@ 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 spnFromUserToken(userToken *C.UserToken) (string, error) { + var cSPN *C.char + msalErr := C.user_token_spn(userToken, &cSPN) + if msalErr != nil { + return "", fmt.Errorf("failed to get SPN from user token: %v", msalErrorMsg(msalErr)) + } + defer C.free(unsafe.Pointer(cSPN)) + return C.GoString(cSPN), nil +} + +func uuidFromUserToken(userToken *C.UserToken) (string, error) { + var cUUID *C.char + msalErr := C.user_token_uuid(userToken, &cUUID) + if msalErr != nil { + return "", fmt.Errorf("failed to get UUID from user token: %v", msalErrorMsg(msalErr)) + } + defer C.free(unsafe.Pointer(cUUID)) + return C.GoString(cUUID), 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)) + + var flow *C.MFAAuthContinue + msalErr := C.broker_initiate_acquire_token_by_mfa_flow( + (*C.BrokerClientApplication)(unsafe.Pointer(broker)), + cUsername, + cPassword, + &flow, + ) + if msalErr != nil { + return nil, newMFAInitError(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) +} + +// newMFAInitError builds an MFAInitError from an msalErr and frees it. +func newMFAInitError(msalErr *C.MSAL_ERROR) *MFAInitError { + defer C.error_free(msalErr) + msg := C.GoString(msalErr.msg) + category := mfaErrorCategory(msalErr.code) + // libhimmelblau surfaces user-denied MFA (authorization_state==1) as a + // GENERAL_FAILURE with the message "Authorization denied" rather than a + // dedicated C error code. Promote that to MFAErrorDenied so the broker's + // denial-specific branch is reachable. + if category == MFAErrorOther && strings.EqualFold(msg, "authorization denied") { + category = MFAErrorDenied + } + // libhimmelblau's code-submission branch of acquire_token_by_mfa_flow + // discards the server's "retry" flag and AADSTS error code for an incorrect + // or expired one-time code, returning a generic GeneralFailure with the + // message "AuthResponse indicates failure: ...". Its polling branch, by + // contrast, surfaces a structured MFA_POLL_CONTINUE. Promote the code-path + // failure to MFAErrorRetryableCode so consumers can re-prompt for the code + // without depending on libhimmelblau's error text themselves. + // + // The robust fix lives upstream: make the EndAuth code-submission branch + // honor auth_response.retry and return MFA_POLL_CONTINUE (mirroring the + // polling branch), after which this promotion would become unnecessary. That + // change was deliberately NOT made because acquire_token_by_mfa_flow is a + // PUBLIC API shared with other consumers (e.g. himmelblau-idm) that do not + // expect MFA_POLL_CONTINUE on the code path. The matched text is unique to + // that branch (the poll branch uses "did not indicate success") and the + // libhimmelblau submodule is pinned, so this match is safe — keep it in sync + // if the submodule is bumped. See third_party/libhimmelblau/src/auth.rs. + if category == MFAErrorOther && strings.Contains(msg, "AuthResponse indicates failure") { + category = MFAErrorRetryableCode + } + return &MFAInitError{ + 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)) + + var flow *C.MFAAuthContinue + msalErr := C.broker_initiate_acquire_token_by_mfa_flow_for_device_enrollment( + (*C.BrokerClientApplication)(unsafe.Pointer(broker)), + cUsername, + cPassword, + &flow, + ) + if msalErr != nil { + return nil, newMFAInitError(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, newMFAInitError(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..7b601c02fb --- /dev/null +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c_test.go @@ -0,0 +1,55 @@ +//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") + + // 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)") +} diff --git a/authd-oidc-brokers/third_party/libhimmelblau b/authd-oidc-brokers/third_party/libhimmelblau index cc83f618f1..6b581a2bae 160000 --- a/authd-oidc-brokers/third_party/libhimmelblau +++ b/authd-oidc-brokers/third_party/libhimmelblau @@ -1 +1 @@ -Subproject commit cc83f618f1a386f351bef2a560c88fa0cf1af904 +Subproject commit 6b581a2bae06a9fdf366e0eac854407647f38a7c From aeb8f7aa5c47cbcd80a1a3f11e6905e087afe928 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 3 Jun 2026 17:06:40 +0300 Subject: [PATCH 03/25] broker/himmelblau: cache broker apps by client configuration The previous `sync.Once` singleton permanently reused the first client configuration that initialized the broker app. Replace it with a keyed cache so device registration and Entra password login can reuse separate broker apps without interfering with each other. --- .../providers/msentraid/himmelblau/himmelblau_c.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 36f3f677fc..58d3af53ec 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go @@ -358,10 +358,10 @@ 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, From 534462a7fcbc64f69373ad539cec32ff909a8645 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Tue, 2 Jun 2026 21:30:40 +0300 Subject: [PATCH 04/25] broker/providers/msentraid: add Graph fallback for group lookup When the delegated token cannot call Microsoft Graph directly, let `GetGroups` fall back to an app-only client-credentials token derived from the configured OIDC client secret. Also keep the Graph-token requirement in cached auth state instead of provider-global mutable state. --- authd-oidc-brokers/internal/broker/broker.go | 4 + .../providers/msentraid/export_test.go | 4 - .../internal/providers/msentraid/msentraid.go | 418 +++++++++++++++--- .../providers/msentraid/msentraid_test.go | 212 ++++++++- .../providers/msentraid/msmock_test.go | 27 +- .../internal/providers/providers.go | 8 + .../internal/testutils/provider.go | 2 +- authd-oidc-brokers/internal/token/token.go | 8 +- 8 files changed, 605 insertions(+), 78 deletions(-) diff --git a/authd-oidc-brokers/internal/broker/broker.go b/authd-oidc-brokers/internal/broker/broker.go index 6c37df0d93..5e16ff7908 100644 --- a/authd-oidc-brokers/internal/broker/broker.go +++ b/authd-oidc-brokers/internal/broker/broker.go @@ -1126,6 +1126,7 @@ func (b *Broker) deviceAuth(ctx context.Context, session *session) (string, isAu log.Errorf(context.Background(), "error registering device: %s", err) return AuthDenied, errorMessage{Message: "Error registering device"} } + authInfo.NeedsAccessTokenForGraphAPI = true defer cleanup() // Store the auth info, so that the device registration data is not lost if the login fails after this point. @@ -1253,6 +1254,7 @@ func (b *Broker) passwordAuth(ctx context.Context, session *session, secret stri log.Errorf(context.Background(), "error registering device: %s", err) return AuthDenied, errorMessage{Message: "Error registering device"} } + authInfo.NeedsAccessTokenForGraphAPI = true defer cleanup() // Store the auth info, so that the device registration data is not lost if the login fails after this point. @@ -1608,6 +1610,7 @@ func (b *Broker) refreshToken(ctx context.Context, session *session, oldToken *t t := token.NewAuthCachedInfo(oauthToken, rawIDToken, extraFields) t.ProviderMetadata = oldToken.ProviderMetadata t.DeviceRegistrationData = oldToken.DeviceRegistrationData + t.NeedsAccessTokenForGraphAPI = oldToken.NeedsAccessTokenForGraphAPI t.UserInfo, err = b.getUserInfo(ctx, session, oauthToken, rawIDToken, true) if err != nil { @@ -1699,6 +1702,7 @@ func (b *Broker) getGroups(ctx context.Context, session *session, t *token.AuthC t.Token, t.ProviderMetadata, t.DeviceRegistrationData, + t.NeedsAccessTokenForGraphAPI, ) } 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/msentraid.go b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go index fcd2321c02..5cc67f4831 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msentraid.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go @@ -8,6 +8,9 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net/http" + "net/url" "regexp" "slices" "strings" @@ -42,13 +45,25 @@ 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 } +// 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. func New() *Provider { return &Provider{ @@ -146,6 +161,43 @@ func (p *Provider) GetUserInfo(claimer info.Claimer, _ bool) (info.User, error) } // 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 +205,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 { @@ -166,45 +258,48 @@ func (p *Provider) GetGroups( tenantID := tenantID(issuerURL) accessTokenStr, err = himmelblau.AcquireAccessTokenForGraphAPI(ctx, clientID, tenantID, token, data) if errors.Is(err, himmelblau.ErrDeviceDisabled) { - return nil, fmt.Errorf("%w: %w", providerErrors.ErrDeviceDisabled, err) + return nil, err } if errors.Is(err, himmelblau.ErrInvalidRedirectURI) { msg := "Token acquisition failed: The app is misconfigured in Microsoft Entra (the redirect URI is missing or invalid). Please contact your administrator." - return nil, &providerErrors.ForDisplayError{Message: msg, Err: fmt.Errorf("%w: %w", providerErrors.ErrInvalidRedirectURI, err)} - } - var tokenAcquisitionError himmelblau.TokenAcquisitionError - if errors.As(err, &tokenAcquisitionError) { - return nil, &providerErrors.RetryWithDeviceAuthError{Err: fmt.Errorf("failed to acquire access token for Microsoft Graph API: %w", err)} + return nil, &providerErrors.ForDisplayError{Message: msg, Err: err} } 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 @@ -224,6 +319,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 +491,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 +503,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 +603,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 +628,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,11 +697,6 @@ 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} -} - // VerifyUsername checks if the authenticated username matches the requested username and that both are valid. func (p *Provider) VerifyUsername(requestedUsername, authenticatedUsername string) error { if p.NormalizeUsername(requestedUsername) != p.NormalizeUsername(authenticatedUsername) { @@ -449,6 +721,11 @@ func (p *Provider) VerifyUsername(requestedUsername, authenticatedUsername strin return nil } +// SupportedOIDCAuthModes returns the OIDC authentication modes supported by the provider. +func (p *Provider) SupportedOIDCAuthModes() []string { + return []string{authmodes.Device, authmodes.DeviceQr} +} + // 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{}) @@ -472,10 +749,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 +793,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..5606f5c917 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go @@ -7,13 +7,16 @@ 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" @@ -190,7 +193,6 @@ func TestGetGroups(t *testing.T) { } p := msentraid.New() - p.SetNeedsAccessTokenForGraphAPI(tc.acquireAccessToken) p.SetTokenScopesForGraphAPI(tc.tokenScopes) got, err := p.GetGroups( @@ -200,6 +202,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,6 +215,213 @@ 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() diff --git a/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go b/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go index 0a9757816e..e445afbae5 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go @@ -96,7 +96,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 +154,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 +171,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=... diff --git a/authd-oidc-brokers/internal/providers/providers.go b/authd-oidc-brokers/internal/providers/providers.go index 744204f6a0..f9cc13b834 100644 --- a/authd-oidc-brokers/internal/providers/providers.go +++ b/authd-oidc-brokers/internal/providers/providers.go @@ -31,6 +31,7 @@ type GroupFetcher interface { token *oauth2.Token, providerMetadata map[string]interface{}, deviceRegistrationData []byte, + needsAccessTokenForGraphAPI bool, ) ([]info.Group, error) } @@ -58,6 +59,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..9a13a77112 100644 --- a/authd-oidc-brokers/internal/testutils/provider.go +++ b/authd-oidc-brokers/internal/testutils/provider.go @@ -466,7 +466,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") } diff --git a/authd-oidc-brokers/internal/token/token.go b/authd-oidc-brokers/internal/token/token.go index 99ca25f9f2..28830765ba 100644 --- a/authd-oidc-brokers/internal/token/token.go +++ b/authd-oidc-brokers/internal/token/token.go @@ -19,8 +19,12 @@ type AuthCachedInfo struct { ProviderMetadata map[string]interface{} UserInfo info.User DeviceRegistrationData []byte - DeviceIsDisabled bool - UserIsDisabled bool + // NeedsAccessTokenForGraphAPI records that group lookup must first exchange + // the cached token for a Graph-scoped access token using device registration + // data. This is explicit auth state rather than provider-global mutable state. + NeedsAccessTokenForGraphAPI bool + DeviceIsDisabled bool + UserIsDisabled bool } // NewAuthCachedInfo creates a new AuthCachedInfo. It sets the provided token, rawIDToken, and From 3e76454436edfd5ee63a4bfc51f0356adce11737 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Tue, 2 Jun 2026 21:34:31 +0300 Subject: [PATCH 05/25] broker/providers/msentraid: implement Entra password auth hooks Implement the provider methods that start the Entra password flow and complete its MFA challenge. Also advertise `entra_password` in the supported auth modes. --- authd-oidc-brokers/internal/broker/broker.go | 2 +- .../internal/broker/broker_test.go | 36 ++++---- .../genericprovider/genericprovider.go | 5 +- .../internal/providers/msentraid/msentraid.go | 87 +++++++++++++++++-- .../providers/msentraid/msentraid_test.go | 38 ++++++++ .../providers/msentraid/msmock_test.go | 23 ++++- .../internal/providers/providers.go | 6 +- 7 files changed, 168 insertions(+), 29 deletions(-) diff --git a/authd-oidc-brokers/internal/broker/broker.go b/authd-oidc-brokers/internal/broker/broker.go index 5e16ff7908..76704ce529 100644 --- a/authd-oidc-brokers/internal/broker/broker.go +++ b/authd-oidc-brokers/internal/broker/broker.go @@ -715,7 +715,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) diff --git a/authd-oidc-brokers/internal/broker/broker_test.go b/authd-oidc-brokers/internal/broker/broker_test.go index 0d5b15f420..11a1294dd4 100644 --- a/authd-oidc-brokers/internal/broker/broker_test.go +++ b/authd-oidc-brokers/internal/broker/broker_test.go @@ -9,25 +9,23 @@ import ( "os" "path/filepath" "slices" - "strings" - "testing" - "time" - - "github.com/canonical/authd/authd-oidc-brokers/internal/broker" - "github.com/canonical/authd/authd-oidc-brokers/internal/broker/authmodes" - "github.com/canonical/authd/authd-oidc-brokers/internal/broker/sessionmode" - "github.com/canonical/authd/authd-oidc-brokers/internal/consts" - "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/testutils" - "github.com/canonical/authd/internal/testutils/golden" - "github.com/canonical/authd/log" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" -) - -var defaultIssuerURL string +func (p *mockMFANilTokenProvider) AcquireTokenByMFAFlow(_ context.Context, _, _ string, _ string, _ *himmelblau.MFAFlowState, _ string, _ int, _ []byte) (*oauth2.Token, error) { + return nil, nil +} + +// newMFATokenResult builds an oauth2.Token mirroring what +// himmelblau.AcquireTokenByMFAFlow returns in production: the user's +// preferred_username/sub/name carried as top-level token extras, recovered +// from the native MFA UserToken. finishEntraAuth relies on these extras since +// the MFA access token cannot be used against the OIDC UserInfo endpoint. The +// sub/name values match the claims set by generateCachedInfo. +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() 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/msentraid.go b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go index 5cc67f4831..06c59ceb68 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msentraid.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go @@ -697,6 +697,88 @@ func (p *Provider) NormalizeUsername(username string) string { return strings.ToLower(username) } +// 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}, + } + + return cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}).Token() +} + // VerifyUsername checks if the authenticated username matches the requested username and that both are valid. func (p *Provider) VerifyUsername(requestedUsername, authenticatedUsername string) error { if p.NormalizeUsername(requestedUsername) != p.NormalizeUsername(authenticatedUsername) { @@ -721,11 +803,6 @@ func (p *Provider) VerifyUsername(requestedUsername, authenticatedUsername strin return nil } -// SupportedOIDCAuthModes returns the OIDC authentication modes supported by the provider. -func (p *Provider) SupportedOIDCAuthModes() []string { - return []string{authmodes.Device, authmodes.DeviceQr} -} - // 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{}) diff --git a/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go b/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go index 5606f5c917..40dc9c4d10 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go @@ -135,6 +135,44 @@ func TestGetUserInfo(t *testing.T) { } } +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") + }) + } +} + func TestGetGroups(t *testing.T) { t.Parallel() diff --git a/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go b/authd-oidc-brokers/internal/providers/msentraid/msmock_test.go index e445afbae5..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), } @@ -301,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", @@ -397,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/providers.go b/authd-oidc-brokers/internal/providers/providers.go index f9cc13b834..2d5f2e1b74 100644 --- a/authd-oidc-brokers/internal/providers/providers.go +++ b/authd-oidc-brokers/internal/providers/providers.go @@ -18,7 +18,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 } From d7fe329b6516a3fd03ceee802185e00c5efb4d20 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Tue, 2 Jun 2026 15:23:04 +0300 Subject: [PATCH 06/25] broker/password: split hashing from password storage Split `HashAndStorePassword` into separate hashing and persistence steps so callers can hash the plaintext immediately and write the result to disk only after MFA succeeds. --- .../internal/password/password.go | 30 +++++++--- .../internal/password/password_test.go | 59 +++++++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) 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() From bca675f3150560cf07a14422b2eccb08b1b2f824 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Tue, 2 Jun 2026 17:15:51 +0300 Subject: [PATCH 07/25] broker: add Entra password login with MFA follow-ups Add the broker-side login flow for direct Entra password authentication and its MFA follow-up modes, including challenge routing, `AADSTS` error handling, and finalization of cached tokens plus the local password hash. Also add `[flows]` configuration for bootstrap auth mode selection and validate the Entra password prerequisites, disabling the flow when neither device registration nor a configured client secret can support group lookup after login. --- .../conf/variants/msentraid/broker.conf | 18 + .../internal/broker/authmodes/consts.go | 21 +- authd-oidc-brokers/internal/broker/broker.go | 1005 +++++++- .../internal/broker/broker_test.go | 2252 +++++++++++++++-- authd-oidc-brokers/internal/broker/config.go | 68 +- .../internal/broker/config_test.go | 62 +- .../internal/broker/export_test.go | 35 + .../internal/broker/helper_test.go | 71 +- .../first_call | 2 +- .../first_call | 2 +- .../first_call | 2 +- .../first_call | 2 +- .../provider_url/test-user@email.com/password | 1 + .../test-user@email.com/token.json | 1 + .../first_call | 3 + .../config.txt | 3 +- .../Successfully_parse_config_file/config.txt | 3 +- .../config.txt | 16 + .../config.txt | 3 +- .../config.txt | 3 +- .../config.txt | 3 +- .../config.txt | 16 + .../config.txt | 16 + .../config.txt | 16 + .../msentraid/himmelblau/entrapwd.go | 42 +- .../msentraid/himmelblau/entrapwd_test.go | 64 +- .../msentraid/himmelblau/himmelblau_c.go | 12 +- .../internal/providers/msentraid/msentraid.go | 85 +- .../providers/msentraid/msentraid_test.go | 58 +- .../internal/providers/providers.go | 8 +- .../internal/testutils/provider.go | 55 +- authd-oidc-brokers/internal/token/token.go | 16 +- 32 files changed, 3501 insertions(+), 463 deletions(-) create mode 100644 authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/password create mode 100644 authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/token.json create mode 100644 authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/first_call create mode 100644 authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_file_with_flow_values/config.txt create mode 100644 authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Successfully_parse_config_with_flow_drop_in_files/config.txt create mode 100644 authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Warns_and_uses_default_for_invalid_device_auth_flow_value/config.txt create mode 100644 authd-oidc-brokers/internal/broker/testdata/golden/TestParseConfig/Warns_and_uses_default_for_invalid_entra_password_flow_value/config.txt 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/broker.go b/authd-oidc-brokers/internal/broker/broker.go index 76704ce529..6487dc1810 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 @@ -87,6 +96,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 +108,113 @@ type isAuthenticatedCtx struct { cancelFunc context.CancelFunc } +// userInfoFromTokenExtras extracts user identity from OAuth token extras +// (preferred_username, sub, name) rather than from a verified OIDC ID token. +// Used exclusively by the Entra password + MFA flow. +// +// Trust model (weaker than the OIDC ID-token path, by necessity): +// - libhimmelblau obtains the token over its own TLS-authenticated session with +// Entra and exposes the claims by base64-decoding the JWT payload — it does NOT +// verify the token signature. We do not verify it here either. +// - The token is an Entra access token whose audience is a Microsoft first-party +// resource (e.g. the Device Registration Service or Graph), not our OIDC app, so +// the standard ID-token check (aud == our client ID) does not apply. +// - The signature itself is verifiable for at least the device-scoped MFA token: +// it is signed by a key published in the tenant JWKS and carries no header +// nonce, so signature + iss + tid verification is feasible and would harden this +// path against a TLS MITM (a forged certificate cannot forge Microsoft's signing +// key). It is not yet wired up: some tokens on this path (e.g. the Graph-scoped +// token on the client_secret path) carry a header nonce and verify differently, +// so it needs per-token handling — tracked as a follow-up. +// - Trust boundary today: TLS to Entra plus the VerifyUsername cross-check below, +// which ties the returned identity to the username the user actually +// authenticated as. +// +// verifyAndExtractEntraUserInfo verifies the Entra MFA access token's RS256 +// signature against the tenant JWKS — defense-in-depth against a TLS MITM, since +// the claims on this path come from libhimmelblau decoding the token rather than a +// verified OIDC ID token — and extracts the user info from its claims. 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) { + preferredUsername, _ := token.Extra("preferred_username").(string) + if preferredUsername == "" { + preferredUsername, _ = token.Extra("email").(string) + } + if preferredUsername == "" { + return info.User{}, errors.New("token extras do not contain preferred_username") + } + + sub, _ := token.Extra("sub").(string) + gecos, _ := token.Extra("name").(string) + userInfo := info.NewUser(preferredUsername, "", sub, "", gecos, nil) + + 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 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. Entra MFA token extras) 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 } @@ -160,6 +279,14 @@ func New(cfg Config, apiVersion uint, args ...Option) (b *Broker, err error) { 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 } @@ -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,40 @@ 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. + // + // This availability is decided here (per login) rather than at config-parse + // time on purpose: an earlier version disabled the flow while parsing the + // config (mutating the user's [flows] setting, and erroring out when + // entra_password was the only enabled flow). That coupled config parsing to + // provider capabilities and rejected otherwise-valid configs at startup. + // The trade-off of deciding it here: if entra_password is the only enabled + // flow and no group source is configured, the user is no longer rejected at + // startup but instead sees "no authentication modes available" at login. + 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 +976,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 +1098,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 +1185,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 +1232,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,60 +1282,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.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") + authInfo, access, data := b.populateAuthInfo(ctx, session, t, rawIDToken, nil) + if authInfo == nil { + return access, data } - 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"} - } - authInfo.NeedsAccessTokenForGraphAPI = true - 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 @@ -1179,28 +1345,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 @@ -1242,25 +1422,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"} - } - authInfo.NeedsAccessTokenForGraphAPI = true + // 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 } } @@ -1268,7 +1435,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 @@ -1281,7 +1448,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 @@ -1298,12 +1465,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 @@ -1312,6 +1482,503 @@ 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 MFA flow never returns an id_token: the libhimmelblau binding only + // surfaces preferred_username/sub/name (from the access token) as token + // extras. Carry over a cached RawIDToken from a previous login so we never + // persist 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 + // token extras instead — see userInfoFromTokenExtras for the trust model. + 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 = "" + } + + 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: + 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 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 { @@ -1444,8 +2111,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() @@ -1584,6 +2261,59 @@ 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) + } + newTok, err := ep.RefreshEntraPasswordToken(ctx, b.cfg.issuerURL, oldToken.Token.RefreshToken) + if err != nil { + return oldToken, err + } + // Rotate the refresh token. + oldToken.Token.RefreshToken = newTok.RefreshToken + + // Refresh the cached user info from the verified refreshed access token's + // claims, mirroring how refreshToken re-derives it from the ID token on the + // device-auth path. Keep the cached gecos if the refreshed token omits one, + // and keep groups (those are refreshed separately by getGroups). + if err := ep.VerifyAccessToken(ctx, b.cfg.issuerURL, newTok.AccessToken); err != nil { + return oldToken, fmt.Errorf("access token verification failed: %w", err) + } + userInfo, err := ep.UserInfoFromAccessToken(newTok.AccessToken) + if err != nil { + 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 { + 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) @@ -1596,9 +2326,10 @@ func (b *Broker) refreshToken(ctx context.Context, session *session, oldToken *t return nil, err } - // 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 } @@ -1610,7 +2341,6 @@ func (b *Broker) refreshToken(ctx context.Context, session *session, oldToken *t t := token.NewAuthCachedInfo(oauthToken, rawIDToken, extraFields) t.ProviderMetadata = oldToken.ProviderMetadata t.DeviceRegistrationData = oldToken.DeviceRegistrationData - t.NeedsAccessTokenForGraphAPI = oldToken.NeedsAccessTokenForGraphAPI t.UserInfo, err = b.getUserInfo(ctx, session, oauthToken, rawIDToken, true) if err != nil { @@ -1631,14 +2361,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 @@ -1663,8 +2409,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) } @@ -1686,6 +2431,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") @@ -1695,14 +2478,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, - t.NeedsAccessTokenForGraphAPI, + 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 11a1294dd4..4c491196ed 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,7 +9,200 @@ 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" + "github.com/canonical/authd/authd-oidc-brokers/internal/broker/sessionmode" + "github.com/canonical/authd/authd-oidc-brokers/internal/consts" + "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) + userDisabledErrorCode string // when set, IsUserDisabledError matches an *oauth2.RetrieveError with this code + verifyAccessTokenErr error // when set, VerifyAccessToken returns it (signature verification failure) + refreshedUserInfo *info.User // when set, UserInfoFromAccessToken returns this user info + userInfoFromTokenErr error // when set, UserInfoFromAccessToken returns this error +} + +func (p *mockEntraPasswordProvider) VerifyAccessToken(_ context.Context, _, _ string) error { + return p.verifyAccessTokenErr +} + +func (p *mockEntraPasswordProvider) UserInfoFromAccessToken(_ string) (info.User, error) { + if p.userInfoFromTokenErr != nil { + return info.User{}, p.userInfoFromTokenErr + } + if p.refreshedUserInfo != nil { + return *p.refreshedUserInfo, 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(_ context.Context, _, _ string) (*oauth2.Token, error) { + 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 a generic GeneralFailure with an +// "AuthResponse indicates failure: ..." message (the code-submission path drops +// the server's retry flag), while leaving the flow intact. newMFAError +// promotes that to MFAErrorRetryableCode, which 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 } @@ -499,11 +693,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": { @@ -778,9 +967,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 @@ -897,28 +1086,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, }, @@ -935,6 +1124,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{}, @@ -1216,7 +1411,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 { @@ -1848,180 +2043,1644 @@ 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) + + _, 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") +} + +// 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() + + 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)) + + require.NoError(t, b.SetAvailableMode(sessionID, authmodes.EntraMFAWait)) + _, err = b.SelectAuthenticationMode(sessionID, authmodes.EntraMFAWait) + require.NoError(t, err) +} + +// 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") } -func TestUserDataDir(t *testing.T) { +// 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() - 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, + 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}, }, } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - b := newBrokerForTests(t, &brokerForTestConfig{ - issuerURL: tc.issuerURL, - }) + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) - 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") - }) - } + 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") } -func TestDeleteUser(t *testing.T) { +// 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() - const providerID = "provider-id-123" + 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), + } - tests := map[string]struct { - username string - providerID string + b := newBrokerForTests(t, &brokerForTestConfig{ + Config: broker.Config{DataDir: t.TempDir()}, + ownerAllowed: true, + firstUserBecomesOwner: true, + provider: provider, + issuerURL: defaultIssuerURL, + }) - createUserDir bool - createProviderIDDir bool - // usernameIsSymlink makes the username path a compatibility symlink pointing - // to the provider ID-keyed directory, as created by the cache migration. - usernameIsSymlink bool - readOnlyDataDir bool + sessionID, key := newSessionForTests(t, b, username, sessionmode.Login) - wantErr bool + // 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 the +// availability gate (in authModeIsAvailable) that only offers the +// entra_password flow when a Microsoft Graph group source is available, i.e. +// device registration or a client secret. Without one, every entra_password +// login would fail at the group-fetch step, so the mode must not be offered. +func TestGetAuthenticationModesEntraPasswordRequiresGroupSource(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + registerDevice bool + clientSecret string + wantEntraPwd bool + }{ + "Offered_with_device_registration": {registerDevice: true, wantEntraPwd: true}, + "Offered_with_client_secret": {clientSecret: "test-client-secret", wantEntraPwd: true}, + "Filtered_without_group_source": {registerDevice: false, wantEntraPwd: false}, + } + + 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"]) + } + if tc.wantEntraPwd { + require.Contains(t, ids, authmodes.EntraPassword, "entra_password should be offered when a group source is available") + } else { + require.NotContains(t, ids, authmodes.EntraPassword, "entra_password should be filtered out without a group source") + } + }) + } +} + +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") +} + +// 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"}, + refreshedUserInfo: &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") +} + +// 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() + + 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"}, + 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, "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 that fails signature verification must deny the returning login") +} + +// 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 +// (userInfoFromTokenExtras). +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"}, + refreshedUserInfo: &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") +} + +// 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"}, + "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"}, + "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, + }) + + 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) + }) + } +} + +// 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, + }) + + 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 + providerID string + + createUserDir bool + createProviderIDDir bool + // usernameIsSymlink makes the username path a compatibility symlink pointing + // to the provider ID-keyed directory, as created by the cache migration. + usernameIsSymlink bool + readOnlyDataDir bool + + wantErr bool }{ "Successfully_delete_existing_user": {username: "user@example.com", createUserDir: true}, "Successfully_delete_unknown_user_is_noop": {username: "unknown@example.com"}, @@ -2704,6 +4363,291 @@ 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") +} + 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..65620a8735 100644 --- a/authd-oidc-brokers/internal/broker/helper_test.go +++ b/authd-oidc-brokers/internal/broker/helper_test.go @@ -25,8 +25,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 +44,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 +88,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,19 +128,18 @@ 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() } @@ -158,7 +166,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 +239,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 +287,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 +307,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/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/password new file mode 100644 index 0000000000..119947240f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/password @@ -0,0 +1 @@ +Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/token.json new file mode 100644 index 0000000000..ecaed7cd75 --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/token.json @@ -0,0 +1 @@ +Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/first_call new file mode 100644 index 0000000000..aca00f387f --- /dev/null +++ b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/first_call @@ -0,0 +1,3 @@ +access: granted +data: '{"userinfo":{"name":"test-user@email.com","uuid":"saved-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"old-group","ugid":""}]}}' +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/providers/msentraid/himmelblau/entrapwd.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd.go index 07bb7d94a6..687eed2b45 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd.go @@ -5,6 +5,7 @@ import ( "fmt" "sync" + "github.com/canonical/authd/authd-oidc-brokers/internal/providers/info" "golang.org/x/oauth2" ) @@ -60,6 +61,23 @@ type EntraPasswordProvider interface { 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. @@ -95,10 +113,10 @@ func FreeMFAFlowState(flow *MFAFlowState) { // MFAChallengeInfo describes the MFA challenge that must be presented to the user. type MFAChallengeInfo struct { - Message string - Method string - PollingInterval int - MaxPollAttempts int + Message string + Method string + PollingIntervalMs int + MaxPollAttempts int } // MFAErrorCategory classifies an MFA error so the broker can route @@ -118,24 +136,24 @@ const ( 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 newMFAInitError for how + // re-enter the code without restarting the flow. See newMFAError for how // this is detected. MFAErrorRetryableCode ) -// MFAInitError represents an error from initiating or continuing an MFA flow. +// 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 MFAInitError struct { +type MFAError struct { Category MFAErrorCategory AADSTS int Message string } // Error returns the formatted error message. -func (e *MFAInitError) Error() string { +func (e *MFAError) Error() string { if e.AADSTS != 0 { return fmt.Sprintf("AADSTS%d: %s", e.AADSTS, e.Message) } @@ -143,24 +161,24 @@ func (e *MFAInitError) Error() string { } // IsMFAPollContinue returns true if the error indicates the MFA poll should continue. -func (e *MFAInitError) IsMFAPollContinue() bool { +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 *MFAInitError) IsMFADenied() bool { +func (e *MFAError) IsMFADenied() bool { return e.Category == MFAErrorDenied } // IsMFARequired returns true if the error indicates MFA is required. -func (e *MFAInitError) IsMFARequired() bool { +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 *MFAInitError) IsMFARetryableCode() bool { +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 index 1e1b4f92ab..0cfbe41fad 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd_test.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/entrapwd_test.go @@ -6,15 +6,15 @@ import ( "github.com/stretchr/testify/require" ) -func TestMFAInitError_Error(t *testing.T) { +func TestMFAError_Error(t *testing.T) { t.Parallel() tests := map[string]struct { - err *MFAInitError + err *MFAError want string }{ - "Without_AADSTS": {err: &MFAInitError{Message: "plain message"}, want: "plain message"}, - "With_AADSTS": {err: &MFAInitError{AADSTS: 50126, Message: "bad credentials"}, want: "AADSTS50126: bad credentials"}, + "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 { @@ -25,18 +25,18 @@ func TestMFAInitError_Error(t *testing.T) { } } -func TestMFAInitError_IsMFAPollContinue(t *testing.T) { +func TestMFAError_IsMFAPollContinue(t *testing.T) { t.Parallel() tests := map[string]struct { - err *MFAInitError + err *MFAError want bool }{ - "Poll_continue": {err: &MFAInitError{Category: MFAErrorPollContinue}, want: true}, - "Denied": {err: &MFAInitError{Category: MFAErrorDenied}, want: false}, - "Required": {err: &MFAInitError{Category: MFAErrorRequired}, want: false}, - "Other": {err: &MFAInitError{Category: MFAErrorOther}, want: false}, - "Poll_continue_with_aadsts": {err: &MFAInitError{Category: MFAErrorPollContinue, AADSTS: 50126}, want: true}, + "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 { @@ -47,18 +47,18 @@ func TestMFAInitError_IsMFAPollContinue(t *testing.T) { } } -func TestMFAInitError_IsMFADenied(t *testing.T) { +func TestMFAError_IsMFADenied(t *testing.T) { t.Parallel() tests := map[string]struct { - err *MFAInitError + err *MFAError want bool }{ - "Denied_no_aadsts": {err: &MFAInitError{Category: MFAErrorDenied}, want: true}, - "Denied_with_aadsts": {err: &MFAInitError{Category: MFAErrorDenied, AADSTS: 50126}, want: true}, - "Poll_continue": {err: &MFAInitError{Category: MFAErrorPollContinue}, want: false}, - "Required": {err: &MFAInitError{Category: MFAErrorRequired}, want: false}, - "Other": {err: &MFAInitError{Category: MFAErrorOther}, want: false}, + "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 { @@ -69,17 +69,17 @@ func TestMFAInitError_IsMFADenied(t *testing.T) { } } -func TestMFAInitError_IsMFARequired(t *testing.T) { +func TestMFAError_IsMFARequired(t *testing.T) { t.Parallel() tests := map[string]struct { - err *MFAInitError + err *MFAError want bool }{ - "Required": {err: &MFAInitError{Category: MFAErrorRequired}, want: true}, - "Poll_continue": {err: &MFAInitError{Category: MFAErrorPollContinue}, want: false}, - "Denied": {err: &MFAInitError{Category: MFAErrorDenied}, want: false}, - "Other": {err: &MFAInitError{Category: MFAErrorOther}, want: false}, + "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 { @@ -90,19 +90,19 @@ func TestMFAInitError_IsMFARequired(t *testing.T) { } } -func TestMFAInitError_IsMFARetryableCode(t *testing.T) { +func TestMFAError_IsMFARetryableCode(t *testing.T) { t.Parallel() tests := map[string]struct { - err *MFAInitError + err *MFAError want bool }{ - "Retryable_code": {err: &MFAInitError{Category: MFAErrorRetryableCode}, want: true}, - "Retryable_code_with_aadsts": {err: &MFAInitError{Category: MFAErrorRetryableCode, AADSTS: 50126}, want: true}, - "Poll_continue": {err: &MFAInitError{Category: MFAErrorPollContinue}, want: false}, - "Denied": {err: &MFAInitError{Category: MFAErrorDenied}, want: false}, - "Required": {err: &MFAInitError{Category: MFAErrorRequired}, want: false}, - "Other": {err: &MFAInitError{Category: MFAErrorOther}, want: false}, + "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 { 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 58d3af53ec..79c3caf4b0 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go @@ -460,7 +460,7 @@ func initiateMFAFlow(broker *brokerClientApplication, username, password string) &flow, ) if msalErr != nil { - return nil, newMFAInitError(msalErr) + return nil, newMFAError(msalErr) } return newMFAFlowState(flow), nil } @@ -492,8 +492,8 @@ func msalErrorMsg(msalErr *C.MSAL_ERROR) string { return C.GoString(msalErr.msg) } -// newMFAInitError builds an MFAInitError from an msalErr and frees it. -func newMFAInitError(msalErr *C.MSAL_ERROR) *MFAInitError { +// 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) @@ -524,7 +524,7 @@ func newMFAInitError(msalErr *C.MSAL_ERROR) *MFAInitError { if category == MFAErrorOther && strings.Contains(msg, "AuthResponse indicates failure") { category = MFAErrorRetryableCode } - return &MFAInitError{ + return &MFAError{ Category: category, AADSTS: int(msalErr.aadsts_code), Message: msg, @@ -546,7 +546,7 @@ func initiateMFAFlowForEnrollment(broker *brokerClientApplication, username, pas &flow, ) if msalErr != nil { - return nil, newMFAInitError(msalErr) + return nil, newMFAError(msalErr) } return newMFAFlowState(flow), nil @@ -585,7 +585,7 @@ func acquireTokenByMFAFlow(broker *brokerClientApplication, username string, flo &userToken, ) if msalErr != nil { - return nil, nil, newMFAInitError(msalErr) + return nil, nil, newMFAError(msalErr) } cleanup = func() { C.user_token_free(userToken) } diff --git a/authd-oidc-brokers/internal/providers/msentraid/msentraid.go b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go index 06c59ceb68..26d21cee25 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msentraid.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go @@ -23,6 +23,7 @@ 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/token" "github.com/canonical/authd/log" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt/v5" @@ -98,18 +99,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{}{ @@ -160,6 +149,32 @@ 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: @@ -258,11 +273,15 @@ func (p *Provider) GetGroups( tenantID := tenantID(issuerURL) accessTokenStr, err = himmelblau.AcquireAccessTokenForGraphAPI(ctx, clientID, tenantID, token, data) if errors.Is(err, himmelblau.ErrDeviceDisabled) { - return nil, err + return nil, fmt.Errorf("%w: %w", providerErrors.ErrDeviceDisabled, err) } if errors.Is(err, himmelblau.ErrInvalidRedirectURI) { msg := "Token acquisition failed: The app is misconfigured in Microsoft Entra (the redirect URI is missing or invalid). Please contact your administrator." - return nil, &providerErrors.ForDisplayError{Message: msg, Err: err} + return nil, &providerErrors.ForDisplayError{Message: msg, Err: fmt.Errorf("%w: %w", providerErrors.ErrInvalidRedirectURI, err)} + } + var tokenAcquisitionError himmelblau.TokenAcquisitionError + if errors.As(err, &tokenAcquisitionError) { + return nil, &providerErrors.RetryWithDeviceAuthError{Err: fmt.Errorf("failed to acquire access token for Microsoft Graph API: %w", err)} } if err != nil { return nil, fmt.Errorf("failed to acquire access token for Microsoft Graph API: %w", err) @@ -310,6 +329,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 @@ -776,7 +807,12 @@ func (p *Provider) RefreshEntraPasswordToken(ctx context.Context, issuerURL, ref Endpoint: oauth2.Endpoint{TokenURL: tokenURL, AuthStyle: oauth2.AuthStyleInParams}, } - return cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}).Token() + tok, err := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}).Token() + if err != nil { + return nil, err + } + + return tok, nil } // VerifyUsername checks if the authenticated username matches the requested username and that both are valid. @@ -803,19 +839,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. diff --git a/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go b/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go index 40dc9c4d10..c7231acc32 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid_test.go @@ -15,11 +15,11 @@ import ( "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" @@ -135,6 +135,22 @@ 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() @@ -169,6 +185,9 @@ func TestRefreshEntraPasswordToken(t *testing.T) { } 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") }) } } @@ -464,47 +483,22 @@ 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/providers.go b/authd-oidc-brokers/internal/providers/providers.go index 2d5f2e1b74..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" ) @@ -47,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, diff --git a/authd-oidc-brokers/internal/testutils/provider.go b/authd-oidc-brokers/internal/testutils/provider.go index 9a13a77112..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. @@ -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 28830765ba..ea060d3fd5 100644 --- a/authd-oidc-brokers/internal/token/token.go +++ b/authd-oidc-brokers/internal/token/token.go @@ -19,15 +19,17 @@ type AuthCachedInfo struct { ProviderMetadata map[string]interface{} UserInfo info.User DeviceRegistrationData []byte - // NeedsAccessTokenForGraphAPI records that group lookup must first exchange - // the cached token for a Graph-scoped access token using device registration - // data. This is explicit auth state rather than provider-global mutable state. - NeedsAccessTokenForGraphAPI bool - DeviceIsDisabled bool - UserIsDisabled bool + 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{ From d9c85e5ef6ed11d6c95f114a6dfae5dfa9ff30ee Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 3 Jun 2026 10:20:04 +0300 Subject: [PATCH 08/25] e2e-tests: disable entra_password in provisioning The current e2e environment relies on device auth flow, so keep the new flow disabled there for now to avoid breaking the current expected flow, until e2e tests for `entra_password` are added --- e2e-tests/vm/provision-authd.sh | 1 + 1 file changed, 1 insertion(+) 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 From db055e798d9ab7f99f603e0b542007294d59fe8c Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Tue, 23 Jun 2026 00:23:05 +0300 Subject: [PATCH 09/25] broker: verify the Entra MFA access-token signature The Entra password + MFA path cannot use the standard OIDC ID token trust path: the MFA result is for Microsoft first-party resources, and libhimmelblau also exposes id_token-derived helpers without signature verification. Verifying an access token while binding identity from a different, unverified source would leave first-login identity outside the control. Verify the MFA access token's RS256 signature against the tenant JWKS, including Microsoft's nonce rewrite, and reject expired or wrong-tenant tokens. Derive first-login and refresh identity from the verified access-token claims via UserInfoFromAccessToken, keep JWKS-backed calls under request timeouts, and drop the now-dead id_token-derived helpers. When a refresh succeeds but later local validation fails, persist the rotated refresh token before denying the login. Otherwise a local clock skew or claim-mapping problem could strand the cache with a refresh token Entra already invalidated server-side. The cgo-free tokenverify package covers signatures, nonce rewriting, expiry, tenant binding, and malformed JWKS cases directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- authd-oidc-brokers/internal/broker/broker.go | 101 +++-- .../internal/broker/broker_test.go | 402 +++++++++++++++++- .../provider_url/test-user@email.com/password | 1 - .../test-user@email.com/token.json | 1 - .../first_call | 3 - .../msentraid/himmelblau/himmelblau.go | 27 +- .../msentraid/himmelblau/himmelblau_c.go | 20 - .../internal/providers/msentraid/msentraid.go | 42 ++ .../providers/msentraid/tokenverify/keyset.go | 109 +++++ .../msentraid/tokenverify/keyset_test.go | 218 ++++++++++ .../msentraid/tokenverify/tokenverify.go | 225 ++++++++++ .../msentraid/tokenverify/tokenverify_test.go | 248 +++++++++++ 12 files changed, 1275 insertions(+), 122 deletions(-) delete mode 100644 authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/password delete mode 100644 authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/token.json delete mode 100644 authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/first_call create mode 100644 authd-oidc-brokers/internal/providers/msentraid/tokenverify/keyset.go create mode 100644 authd-oidc-brokers/internal/providers/msentraid/tokenverify/keyset_test.go create mode 100644 authd-oidc-brokers/internal/providers/msentraid/tokenverify/tokenverify.go create mode 100644 authd-oidc-brokers/internal/providers/msentraid/tokenverify/tokenverify_test.go diff --git a/authd-oidc-brokers/internal/broker/broker.go b/authd-oidc-brokers/internal/broker/broker.go index 6487dc1810..b69c27fb2e 100644 --- a/authd-oidc-brokers/internal/broker/broker.go +++ b/authd-oidc-brokers/internal/broker/broker.go @@ -108,46 +108,23 @@ type isAuthenticatedCtx struct { cancelFunc context.CancelFunc } -// userInfoFromTokenExtras extracts user identity from OAuth token extras -// (preferred_username, sub, name) rather than from a verified OIDC ID token. -// Used exclusively by the Entra password + MFA flow. -// -// Trust model (weaker than the OIDC ID-token path, by necessity): -// - libhimmelblau obtains the token over its own TLS-authenticated session with -// Entra and exposes the claims by base64-decoding the JWT payload — it does NOT -// verify the token signature. We do not verify it here either. -// - The token is an Entra access token whose audience is a Microsoft first-party -// resource (e.g. the Device Registration Service or Graph), not our OIDC app, so -// the standard ID-token check (aud == our client ID) does not apply. -// - The signature itself is verifiable for at least the device-scoped MFA token: -// it is signed by a key published in the tenant JWKS and carries no header -// nonce, so signature + iss + tid verification is feasible and would harden this -// path against a TLS MITM (a forged certificate cannot forge Microsoft's signing -// key). It is not yet wired up: some tokens on this path (e.g. the Graph-scoped -// token on the client_secret path) carry a header nonce and verify differently, -// so it needs per-token handling — tracked as a follow-up. -// - Trust boundary today: TLS to Entra plus the VerifyUsername cross-check below, -// which ties the returned identity to the username the user actually -// authenticated as. -// // verifyAndExtractEntraUserInfo verifies the Entra MFA access token's RS256 -// signature against the tenant JWKS — defense-in-depth against a TLS MITM, since -// the claims on this path come from libhimmelblau decoding the token rather than a -// verified OIDC ID token — and extracts the user info from its claims. It does NOT -// cross-check the username against the session; first login does that via -// userInfoFromTokenExtras. +// 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) { - preferredUsername, _ := token.Extra("preferred_username").(string) - if preferredUsername == "" { - preferredUsername, _ = token.Extra("email").(string) + ep, ok := providers.ProviderAs[himmelblau.EntraPasswordProvider](b.provider) + if !ok { + return info.User{}, errors.New("provider does not support Entra password authentication") } - if preferredUsername == "" { - return info.User{}, errors.New("token extras do not contain preferred_username") + if err := ep.VerifyAccessToken(ctx, b.cfg.issuerURL, token.AccessToken); err != nil { + return info.User{}, fmt.Errorf("access token verification failed: %w", err) } - sub, _ := token.Extra("sub").(string) - gecos, _ := token.Extra("name").(string) - userInfo := info.NewUser(preferredUsername, "", sub, "", gecos, nil) + 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) @@ -156,10 +133,10 @@ func (b *Broker) verifyAndExtractEntraUserInfo(ctx context.Context, token *oauth return userInfo, nil } -// userInfoFromTokenExtras is verifyAndExtractEntraUserInfo plus a cross-check that -// the returned 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. +// 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 { @@ -179,7 +156,8 @@ func (b *Broker) userInfoFromTokenExtras(ctx context.Context, session *session, // // 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. Entra MFA token extras) can pass it directly. +// 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{} @@ -1797,10 +1775,9 @@ func (b *Broker) finishEntraAuth(ctx context.Context, session *session, mfaToken // rather than re-reading the token from disk. oldAuthInfo := session.authInfo - // The MFA flow never returns an id_token: the libhimmelblau binding only - // surfaces preferred_username/sub/name (from the access token) as token - // extras. Carry over a cached RawIDToken from a previous login so we never - // persist an empty one. + // 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 @@ -1808,7 +1785,8 @@ func (b *Broker) finishEntraAuth(ctx context.Context, session *session, mfaToken // 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 - // token extras instead — see userInfoFromTokenExtras for the trust model. + // 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) @@ -2278,28 +2256,47 @@ func (b *Broker) refreshEntraPasswordToken(ctx context.Context, session *session // 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) } - newTok, err := ep.RefreshEntraPasswordToken(ctx, b.cfg.issuerURL, oldToken.Token.RefreshToken) + 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 } - // Rotate the refresh token. - oldToken.Token.RefreshToken = newTok.RefreshToken + 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, mirroring how refreshToken re-derives it from the ID token on the - // device-auth path. Keep the cached gecos if the refreshed token omits one, - // and keep groups (those are refreshed separately by getGroups). - if err := ep.VerifyAccessToken(ctx, b.cfg.issuerURL, newTok.AccessToken); err != nil { + // 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) { diff --git a/authd-oidc-brokers/internal/broker/broker_test.go b/authd-oidc-brokers/internal/broker/broker_test.go index 4c491196ed..819b650528 100644 --- a/authd-oidc-brokers/internal/broker/broker_test.go +++ b/authd-oidc-brokers/internal/broker/broker_test.go @@ -53,13 +53,19 @@ type mockEntraPasswordProvider struct { recordedChallengeData []string refreshResult *oauth2.Token // returned by RefreshEntraPasswordToken (defaults to a rotated token) refreshErr error // when set, RefreshEntraPasswordToken returns it (e.g. AADSTS50057) - userDisabledErrorCode string // when set, IsUserDisabledError matches an *oauth2.RetrieveError with this code - verifyAccessTokenErr error // when set, VerifyAccessToken returns it (signature verification failure) - refreshedUserInfo *info.User // when set, UserInfoFromAccessToken returns this user info - userInfoFromTokenErr error // when set, UserInfoFromAccessToken returns this error + 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(_ context.Context, _, _ string) error { +func (p *mockEntraPasswordProvider) VerifyAccessToken(ctx context.Context, _, _ string) error { + if deadline, ok := ctx.Deadline(); ok { + p.verifyCtxDeadline = deadline + } return p.verifyAccessTokenErr } @@ -67,8 +73,8 @@ func (p *mockEntraPasswordProvider) UserInfoFromAccessToken(_ string) (info.User if p.userInfoFromTokenErr != nil { return info.User{}, p.userInfoFromTokenErr } - if p.refreshedUserInfo != nil { - return *p.refreshedUserInfo, nil + if p.accessTokenUserInfo != nil { + return *p.accessTokenUserInfo, nil } return info.NewUser("test-user@email.com", "", "saved-user-id", "", "test-user", nil), nil } @@ -107,7 +113,19 @@ func (p *mockEntraPasswordProvider) AcquireTokenByMFAFlow(_ context.Context, _, return p.mfaTokenResult, nil } -func (p *mockEntraPasswordProvider) RefreshEntraPasswordToken(_ context.Context, _, _ string) (*oauth2.Token, error) { +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 } @@ -207,12 +225,26 @@ func (p *mockMFANilTokenProvider) AcquireTokenByMFAFlow(_ context.Context, _, _ return nil, nil } -// newMFATokenResult builds an oauth2.Token mirroring what -// himmelblau.AcquireTokenByMFAFlow returns in production: the user's -// preferred_username/sub/name carried as top-level token extras, recovered -// from the native MFA UserToken. finishEntraAuth relies on these extras since -// the MFA access token cannot be used against the OIDC UserInfo endpoint. The -// sub/name values match the claims set by generateCachedInfo. +// 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", @@ -2153,6 +2185,43 @@ func advanceToEntraMFAWait(t *testing.T, b *broker.Broker, sessionID, key string require.NoError(t, err) } +// 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() + + 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 @@ -2799,8 +2868,8 @@ func TestIsAuthenticatedPasswordEntraTokenRefreshUpdatesUserInfo(t *testing.T) { 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"}, - refreshedUserInfo: &info.User{Name: "test-user@email.com", ProviderID: "saved-user-id"}, + 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{ @@ -2838,6 +2907,30 @@ func TestIsAuthenticatedPasswordEntraTokenRefreshUpdatesUserInfo(t *testing.T) { "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 @@ -2845,7 +2938,6 @@ func TestIsAuthenticatedPasswordEntraTokenRefreshUpdatesUserInfo(t *testing.T) { func TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnVerificationFailure(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 @@ -2854,6 +2946,33 @@ func TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnVerificationFailure(t * 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, @@ -2870,16 +2989,44 @@ func TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnVerificationFailure(t * 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 that fails signature verification must deny the returning login") + "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 -// (userInfoFromTokenExtras). +// refresh, and that the Entra password flow itself performs on first login. func TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnUsernameMismatch(t *testing.T) { t.Parallel() @@ -2888,8 +3035,8 @@ func TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnUsernameMismatch(t *tes 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"}, - refreshedUserInfo: &info.User{Name: "someone-else@email.com", ProviderID: "different-user-id"}, + 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{ @@ -2910,6 +3057,11 @@ func TestIsAuthenticatedPasswordEntraTokenRefreshDeniesOnUsernameMismatch(t *tes 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 @@ -4648,6 +4800,212 @@ func TestEntraMFACodeAuthNoActiveMFAFlow(t *testing.T) { 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/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/password b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/password deleted file mode 100644 index 119947240f..0000000000 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/password +++ /dev/null @@ -1 +0,0 @@ -Definitely a hashed password \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/token.json b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/token.json deleted file mode 100644 index ecaed7cd75..0000000000 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/data/provider_url/test-user@email.com/token.json +++ /dev/null @@ -1 +0,0 @@ -Definitely a token \ No newline at end of file diff --git a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/first_call b/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/first_call deleted file mode 100644 index aca00f387f..0000000000 --- a/authd-oidc-brokers/internal/broker/testdata/golden/TestIsAuthenticated/Forced_check_with_entra_password_token_uses_cached_groups_when_group_fetch_fails/first_call +++ /dev/null @@ -1,3 +0,0 @@ -access: granted -data: '{"userinfo":{"name":"test-user@email.com","uuid":"saved-user-id","dir":"/home/test-user@email.com","shell":"/usr/bin/bash","gecos":"test-user@email.com","groups":[{"name":"old-group","ugid":""}]}}' -err: diff --git a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go index e1f73a89d8..1f946eee30 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau.go @@ -410,32 +410,13 @@ func AcquireTokenByMFAFlow(ctx context.Context, clientID, tenantID string, data } // 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). - // Include the user's SPN (preferred_username) and UUID (sub) as token extras so that - // finishEntraAuth can recover user info without calling the UserInfo endpoint. + // 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 spn, spnErr := spnFromUserToken(userToken); spnErr == nil && spn != "" { - extras["preferred_username"] = spn - log.Debugf(ctx, "MFA token SPN: %q", spn) - } else if spnErr != nil { - log.Debugf(ctx, "Could not get SPN from MFA token: %v", spnErr) - } - if sub, subErr := uuidFromUserToken(userToken); subErr == nil && sub != "" { - extras["sub"] = sub - } else if subErr != nil { - log.Debugf(ctx, "Could not get UUID from MFA token: %v", subErr) - } - - // The Entra password flow returns an access token rather than an OIDC - // id_token, so recover the display name and any missing identity claims - // (name, scp, plus a preferred_username/sub fallback) from the access - // token JWT, which carries the "name" claim in every flow we use. The - // SPN/UUID extras set above take priority over duplicates. if accessExtras := tokenExtrasFromAccessToken(ctx, accessToken); len(accessExtras) > 0 { for k, v := range accessExtras { - if _, alreadySet := extras[k]; !alreadySet { - extras[k] = v - } + extras[k] = v } } 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 79c3caf4b0..381fc46e15 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go @@ -425,26 +425,6 @@ func refreshTokenFromUserToken(userToken *C.UserToken) (refreshToken string, err return C.GoString(cRefreshToken), nil } -func spnFromUserToken(userToken *C.UserToken) (string, error) { - var cSPN *C.char - msalErr := C.user_token_spn(userToken, &cSPN) - if msalErr != nil { - return "", fmt.Errorf("failed to get SPN from user token: %v", msalErrorMsg(msalErr)) - } - defer C.free(unsafe.Pointer(cSPN)) - return C.GoString(cSPN), nil -} - -func uuidFromUserToken(userToken *C.UserToken) (string, error) { - var cUUID *C.char - msalErr := C.user_token_uuid(userToken, &cUUID) - if msalErr != nil { - return "", fmt.Errorf("failed to get UUID from user token: %v", msalErrorMsg(msalErr)) - } - defer C.free(unsafe.Pointer(cUUID)) - return C.GoString(cUUID), nil -} - func initiateMFAFlow(broker *brokerClientApplication, username, password string) (*MFAFlowState, error) { cUsername := C.CString(username) defer C.free(unsafe.Pointer(cUsername)) diff --git a/authd-oidc-brokers/internal/providers/msentraid/msentraid.go b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go index 26d21cee25..24d00164dd 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/msentraid.go +++ b/authd-oidc-brokers/internal/providers/msentraid/msentraid.go @@ -5,6 +5,7 @@ package msentraid import ( "context" + "crypto/rsa" "encoding/json" "errors" "fmt" @@ -14,6 +15,7 @@ import ( "regexp" "slices" "strings" + "sync" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -23,6 +25,7 @@ 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" @@ -56,6 +59,12 @@ type Provider struct { // 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 @@ -815,6 +824,39 @@ func (p *Provider) RefreshEntraPasswordToken(ctx context.Context, issuerURL, ref 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. func (p *Provider) VerifyUsername(requestedUsername, authenticatedUsername string) error { if p.NormalizeUsername(requestedUsername) != p.NormalizeUsername(authenticatedUsername) { 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 "" +} From 0adbec141c53526a952aa591a0ee72cafe29b857 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 24 Jun 2026 12:12:41 +0300 Subject: [PATCH 10/25] himmelblau: disable DAG fallback in password+MFA init When MFA setup fails (authenticator not registered, interactive auth required), libhimmelblau silently fell back to its own Device Authorization Grant flow, showing a plain browser-URL prompt that conflicts with authd's QR-code Device Authentication screen and bypasses the auth state machine entirely. AADSTS 50203 and 16000 surface the same MFA-not-configured condition from a different server path and are routed to Device Authentication for the same reason. Based on https://gitlab.com/nooreldeensalah/libhimmelblau/-/tree/capi-mfa-auth-options --- authd-oidc-brokers/internal/broker/broker.go | 9 +++++++- .../internal/broker/broker_test.go | 23 +++++++++++-------- .../msentraid/himmelblau/himmelblau_c.go | 18 +++++++++++++++ authd-oidc-brokers/third_party/libhimmelblau | 2 +- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/authd-oidc-brokers/internal/broker/broker.go b/authd-oidc-brokers/internal/broker/broker.go index b69c27fb2e..92942b7d52 100644 --- a/authd-oidc-brokers/internal/broker/broker.go +++ b/authd-oidc-brokers/internal/broker/broker.go @@ -1871,13 +1871,20 @@ func (b *Broker) routeMFAInitError(mfaErr *himmelblau.MFAError, session *session 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: + 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."} diff --git a/authd-oidc-brokers/internal/broker/broker_test.go b/authd-oidc-brokers/internal/broker/broker_test.go index 819b650528..32d11f6e30 100644 --- a/authd-oidc-brokers/internal/broker/broker_test.go +++ b/authd-oidc-brokers/internal/broker/broker_test.go @@ -3363,16 +3363,19 @@ func TestEntraPasswordRoutesAADSTSErrors(t *testing.T) { 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"}, - "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"}, - "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"}, + "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) { 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 381fc46e15..e2a6d106aa 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go @@ -432,11 +432,18 @@ func initiateMFAFlow(broker *brokerClientApplication, username, password string) 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 { @@ -504,6 +511,14 @@ func newMFAError(msalErr *C.MSAL_ERROR) *MFAError { if category == MFAErrorOther && strings.Contains(msg, "AuthResponse indicates failure") { category = MFAErrorRetryableCode } + // When NoDAGFallback is active, libhimmelblau returns this sentinel instead + // of silently converting an MFA init failure into a DAG continuation. + // Treat it as MFAErrorRequired so the broker redirects to Device Authentication. + // The message is generated in third_party/libhimmelblau/src/auth.rs (dag_fallback! + // macro, NoDAGFallback branch). Keep this string in sync if the submodule is bumped. + if category == MFAErrorOther && msg == "MFA failed and DAG fallback is disabled" { + category = MFAErrorRequired + } return &MFAError{ Category: category, AADSTS: int(msalErr.aadsts_code), @@ -518,11 +533,14 @@ func initiateMFAFlowForEnrollment(broker *brokerClientApplication, username, pas 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 { diff --git a/authd-oidc-brokers/third_party/libhimmelblau b/authd-oidc-brokers/third_party/libhimmelblau index 6b581a2bae..b9962ec3ce 160000 --- a/authd-oidc-brokers/third_party/libhimmelblau +++ b/authd-oidc-brokers/third_party/libhimmelblau @@ -1 +1 @@ -Subproject commit 6b581a2bae06a9fdf366e0eac854407647f38a7c +Subproject commit b9962ec3ce7ec38e2ce7880b6788c2474b215b83 From c634b4916548fbb20ed0b91aa1efc770980768d4 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Thu, 25 Jun 2026 09:25:13 +0300 Subject: [PATCH 11/25] himmelblau: replace MFA string matching with structured error codes libhimmelblau now returns dedicated error variants for the three MFA outcomes that authd previously classified by matching on error message text. Remove the string-matching blocks from newMFAError and map the new C enum codes directly in mfaErrorCategory. --- .../internal/broker/broker_test.go | 7 ++- .../msentraid/himmelblau/himmelblau_c.go | 50 +++++-------------- .../msentraid/himmelblau/himmelblau_c_test.go | 9 ++++ authd-oidc-brokers/third_party/libhimmelblau | 2 +- 4 files changed, 25 insertions(+), 43 deletions(-) diff --git a/authd-oidc-brokers/internal/broker/broker_test.go b/authd-oidc-brokers/internal/broker/broker_test.go index 32d11f6e30..5bba389593 100644 --- a/authd-oidc-brokers/internal/broker/broker_test.go +++ b/authd-oidc-brokers/internal/broker/broker_test.go @@ -193,10 +193,9 @@ func (p *mockMFATimeoutProvider) AcquireTokenByMFAFlow(_ context.Context, _, _ s // 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 a generic GeneralFailure with an -// "AuthResponse indicates failure: ..." message (the code-submission path drops -// the server's retry flag), while leaving the flow intact. newMFAError -// promotes that to MFAErrorRetryableCode, which is what production consumers see. +// 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 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 e2a6d106aa..65a2ad8422 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c.go @@ -49,9 +49,12 @@ import ( // 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) + 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 @@ -63,6 +66,12 @@ func mfaErrorCategory(code uint32) MFAErrorCategory { return MFAErrorPollContinue case codeMFARequired: return MFAErrorRequired + case codeAuthorizationDenied: + return MFAErrorDenied + case codeMFAInvalidCode: + return MFAErrorRetryableCode + case codeMFADAGFallbackDisab: + return MFAErrorRequired } return MFAErrorOther } @@ -484,41 +493,6 @@ func newMFAError(msalErr *C.MSAL_ERROR) *MFAError { defer C.error_free(msalErr) msg := C.GoString(msalErr.msg) category := mfaErrorCategory(msalErr.code) - // libhimmelblau surfaces user-denied MFA (authorization_state==1) as a - // GENERAL_FAILURE with the message "Authorization denied" rather than a - // dedicated C error code. Promote that to MFAErrorDenied so the broker's - // denial-specific branch is reachable. - if category == MFAErrorOther && strings.EqualFold(msg, "authorization denied") { - category = MFAErrorDenied - } - // libhimmelblau's code-submission branch of acquire_token_by_mfa_flow - // discards the server's "retry" flag and AADSTS error code for an incorrect - // or expired one-time code, returning a generic GeneralFailure with the - // message "AuthResponse indicates failure: ...". Its polling branch, by - // contrast, surfaces a structured MFA_POLL_CONTINUE. Promote the code-path - // failure to MFAErrorRetryableCode so consumers can re-prompt for the code - // without depending on libhimmelblau's error text themselves. - // - // The robust fix lives upstream: make the EndAuth code-submission branch - // honor auth_response.retry and return MFA_POLL_CONTINUE (mirroring the - // polling branch), after which this promotion would become unnecessary. That - // change was deliberately NOT made because acquire_token_by_mfa_flow is a - // PUBLIC API shared with other consumers (e.g. himmelblau-idm) that do not - // expect MFA_POLL_CONTINUE on the code path. The matched text is unique to - // that branch (the poll branch uses "did not indicate success") and the - // libhimmelblau submodule is pinned, so this match is safe — keep it in sync - // if the submodule is bumped. See third_party/libhimmelblau/src/auth.rs. - if category == MFAErrorOther && strings.Contains(msg, "AuthResponse indicates failure") { - category = MFAErrorRetryableCode - } - // When NoDAGFallback is active, libhimmelblau returns this sentinel instead - // of silently converting an MFA init failure into a DAG continuation. - // Treat it as MFAErrorRequired so the broker redirects to Device Authentication. - // The message is generated in third_party/libhimmelblau/src/auth.rs (dag_fallback! - // macro, NoDAGFallback branch). Keep this string in sync if the submodule is bumped. - if category == MFAErrorOther && msg == "MFA failed and DAG fallback is disabled" { - category = MFAErrorRequired - } return &MFAError{ Category: category, AADSTS: int(msalErr.aadsts_code), 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 index 7b601c02fb..aa19cfc375 100644 --- a/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c_test.go +++ b/authd-oidc-brokers/internal/providers/msentraid/himmelblau/himmelblau_c_test.go @@ -34,6 +34,12 @@ func TestMFAErrorCategoryMapping(t *testing.T) { "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 @@ -52,4 +58,7 @@ func TestMFAErrorCategoryMapping(t *testing.T) { 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/third_party/libhimmelblau b/authd-oidc-brokers/third_party/libhimmelblau index b9962ec3ce..58a9487fda 160000 --- a/authd-oidc-brokers/third_party/libhimmelblau +++ b/authd-oidc-brokers/third_party/libhimmelblau @@ -1 +1 @@ -Subproject commit b9962ec3ce7ec38e2ce7880b6788c2474b215b83 +Subproject commit 58a9487fdafdf8f7152286f20e0dfffe495ea52f From 6032db62c947746094e66aa764c5be5a4fbcac75 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 24 Jun 2026 09:37:35 +0300 Subject: [PATCH 12/25] broker/pam: wrap granted response in {userinfo,message} envelope The Entra password+MFA login failed with "invalid character 'Y' looking for beginning of value" after a successful grant. The broker emitted the success message as a bare string while the consumer (dataToMsg) expects a {"message": ...} envelope, so the PAM client's parse aborted an already-granted login. Forward a consistent {userinfo, message} envelope from IsAuthenticated so consumers always parse the same shape regardless of whether the broker attached a notice. Encode IAResponse.Msg as {"message": ...} matching the format used for non-granted replies. Non-string values in the broker's message field are treated as absent rather than rejected, so a malformed cosmetic field never blocks an already-granted login at the broker boundary. Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- internal/brokers/broker.go | 50 ++++++++++++++----- internal/brokers/broker_test.go | 2 + ...ult_groups_even_if_broker_did_not_set_them | 2 +- ...res_non_string_message_in_granted_response | 4 ++ ...n_broker_returns_userinfo_with_empty_gecos | 2 +- ...eturns_userinfo_with_group_with_empty_UGID | 2 +- ...returns_userinfo_with_mismatching_username | 2 +- .../Successfully_authenticate | 2 +- ...y_authenticate_after_cancelling_first_call | 2 +- ...icate_after_second_call_without_cancelling | 4 +- ...essfully_authenticate_with_granted_message | 4 ++ internal/services/pam/pam.go | 27 +++++++--- internal/services/pam/pam_test.go | 2 + .../IsAuthenticated | 4 ++ .../cache.db | 22 ++++++++ .../IsAuthenticated | 4 ++ .../cache.db | 22 ++++++++ internal/testutils/broker.go | 8 +++ 18 files changed, 136 insertions(+), 29 deletions(-) create mode 100644 internal/brokers/testdata/golden/TestIsAuthenticated/Ignores_non_string_message_in_granted_response create mode 100644 internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message create mode 100644 internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message/IsAuthenticated create mode 100644 internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message/cache.db create mode 100644 internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_non_string_message/IsAuthenticated create mode 100644 internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_non_string_message/cache.db 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..0569dd5696 --- /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","Groups":[{"Name":"group-ia_granted_with_data@example.com","GID":null,"UGID":"ugid-ia_granted_with_data@example.com"}]},"message":"Your password was cached for offline login."} + 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..a124a85872 --- /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":"Your password was cached for offline login."} + 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..b8f6964b86 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": "Your password was cached for offline login."}`, 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"}` From 7b4c50bacb31dac995811cbe66c38b6bfbb581ad Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 24 Jun 2026 09:37:40 +0300 Subject: [PATCH 13/25] pam: demote malformed granted message to a warning A cosmetic notice attached to a granted IAResponse must never abort an already-granted login. Treat a parse error on the Msg field as a warning and fall back to showing no notice. Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- pam/internal/adapter/authentication.go | 17 ++++++++-- pam/internal/adapter/gdmmodel.go | 2 +- pam/internal/adapter/gdmmodel_test.go | 46 ++++++++++++++++++++++++++ pam/internal/adapter/nativemodel.go | 2 +- 4 files changed, 63 insertions(+), 4 deletions(-) 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 } From cdb06690eac2b9c676efca10cd5893b1a0650461 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Tue, 23 Jun 2026 20:46:07 +0300 Subject: [PATCH 14/25] broker: attach caching notice after Entra password login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the Entra password+MFA flow caches the user's password for offline login, the user had no indication that their local password was set to their Entra password Attach a broker-owned notice to the granted response so it surfaces through the PAM conversation. The notice lives in the broker — the component that knows when caching occurred — rather than being hardcoded in authd. Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- authd-oidc-brokers/internal/broker/authresponses.go | 7 +++++++ authd-oidc-brokers/internal/broker/broker.go | 5 +++++ authd-oidc-brokers/internal/broker/broker_test.go | 7 +++++++ .../Successfully_authenticate_with_granted_message | 2 +- .../IsAuthenticated | 2 +- internal/testutils/broker.go | 2 +- 6 files changed, 22 insertions(+), 3 deletions(-) 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 92942b7d52..531f979515 100644 --- a/authd-oidc-brokers/internal/broker/broker.go +++ b/authd-oidc-brokers/internal/broker/broker.go @@ -1853,6 +1853,11 @@ func (b *Broker) finishEntraAuth(ctx context.Context, session *session, mfaToken return AuthDenied, unexpectedErrMsg("failed to store password") } session.entraPasswordHash = "" + + if msg, ok := data.(userInfoMessage); ok { + msg.Message = cachedPasswordMessage + data = msg + } } return access, data diff --git a/authd-oidc-brokers/internal/broker/broker_test.go b/authd-oidc-brokers/internal/broker/broker_test.go index 5bba389593..9c34eaf276 100644 --- a/authd-oidc-brokers/internal/broker/broker_test.go +++ b/authd-oidc-brokers/internal/broker/broker_test.go @@ -2160,6 +2160,13 @@ func TestIsAuthenticatedEntraMFAWaitStartsPollingAtOne(t *testing.T) { 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)) diff --git a/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message b/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message index 0569dd5696..1c20c3030f 100644 --- a/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message +++ b/internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message @@ -1,4 +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","Groups":[{"Name":"group-ia_granted_with_data@example.com","GID":null,"UGID":"ugid-ia_granted_with_data@example.com"}]},"message":"Your password was cached for offline login."} + 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/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_with_granted_message/IsAuthenticated index a124a85872..01503cec02 100644 --- 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 @@ -1,4 +1,4 @@ FIRST CALL: access: granted - msg: {"message":"Your password was cached for offline login."} + msg: {"message":"Offline login is enabled with your Entra password"} err: diff --git a/internal/testutils/broker.go b/internal/testutils/broker.go index b8f6964b86..f542923df4 100644 --- a/internal/testutils/broker.go +++ b/internal/testutils/broker.go @@ -312,7 +312,7 @@ func (b *BrokerBusMock) IsAuthenticated(sessionID, authenticationData string) (a case "ia_granted_with_data": access = authGranted - data = fmt.Sprintf(`{"userinfo": %s, "message": "Your password was cached for offline login."}`, userInfoFromName(sessionID, nil)) + 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 From 22e5256d67d7857adb4e13664b68b8bba1558317 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Tue, 23 Jun 2026 20:46:14 +0300 Subject: [PATCH 15/25] pam: avoid duplicate success notice on native clients Native (SSH, non-TTY) clients receive the granted message twice: once through nativeModel.sendInfo and again through the PAM TextInfo conversation echo in sendReturnMessageToPam. GDM and interactive- terminal clients do not go through the native sendInfo path, so they must keep receiving the echo. Suppress the redundant PAM-conversation echo only for Native clients by returning false from shouldSendAuthMessage when clientType == Native and the response is a success. Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- pam/pam.go | 26 ++++++++++++++++-- pam/pam_session_test.go | 59 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 pam/pam_session_test.go 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)) + }) + } +} From 353a0994970b4611d442aacc986f01adb45ba6fa Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Fri, 26 Jun 2026 22:56:42 +0300 Subject: [PATCH 16/25] deps(submodule): bump authd-oidc-brokers/third_party/libhimmelblau Bump the libhimmelblau submodule to the latest version `v0.8.24` which contains the necessary patches for this PR The upstream GitLab MR is https://gitlab.com/samba-team/libhimmelblau/-/merge_requests/163 --- authd-oidc-brokers/third_party/libhimmelblau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authd-oidc-brokers/third_party/libhimmelblau b/authd-oidc-brokers/third_party/libhimmelblau index 58a9487fda..2c028b657d 160000 --- a/authd-oidc-brokers/third_party/libhimmelblau +++ b/authd-oidc-brokers/third_party/libhimmelblau @@ -1 +1 @@ -Subproject commit 58a9487fdafdf8f7152286f20e0dfffe495ea52f +Subproject commit 2c028b657d8eb9ad3b514f2cd6c41cde5a499e35 From b162af9707cac4a810741cd77cd37f3b5436b99d Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Mon, 29 Jun 2026 10:23:44 +0300 Subject: [PATCH 17/25] e2e-tests: add TOTP helper and broker configuration resource --- e2e-tests/resources/TOTP.py | 25 ++++++++++++++++ e2e-tests/resources/broker.resource | 29 +++++++++++++++++++ e2e-tests/resources/browser_login/base.py | 1 + .../{browser_login => }/generate_totp.py | 0 4 files changed, 55 insertions(+) create mode 100644 e2e-tests/resources/TOTP.py rename e2e-tests/resources/{browser_login => }/generate_totp.py (100%) 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 From 420d6759e22ee46b7c4ff2115fefda30e5ea6365 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Mon, 29 Jun 2026 10:23:51 +0300 Subject: [PATCH 18/25] e2e-tests: add entra_password login test for the register_device path The new entra_password flow has no automated coverage. Add an end-to-end test that exercises the register_device=true configuration: the broker authenticates via the Microsoft Broker App, registers the device on first login, and caches the password locally for subsequent offline use. Disabling device_auth ensures the broker auto-selects the single available mode, keeping the test focused on the password+MFA flow without an interactive provider-selection step. --- e2e-tests/tests/login_entra_password.robot | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 e2e-tests/tests/login_entra_password.robot diff --git a/e2e-tests/tests/login_entra_password.robot b/e2e-tests/tests/login_entra_password.robot new file mode 100644 index 0000000000..34b66aeb2e --- /dev/null +++ b/e2e-tests/tests/login_entra_password.robot @@ -0,0 +1,54 @@ +*** 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 + 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} From d5496c88754a2f59acd182377984bbbd7f550f2c Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Mon, 29 Jun 2026 10:23:59 +0300 Subject: [PATCH 19/25] e2e-tests: add entra_password login test for the client-secret path Cover the register_device=false configuration, where the broker uses a configured client_secret to obtain an app-only Graph token for group lookup instead of registering a device. The secret is injected into broker.conf at test setup time rather than baked into the provisioning snapshot, keeping the base image clean for public-client flows. Without the secret the test fails immediately at setup, surfacing a misconfigured CI run as a clear error instead of a silent missing-auth-mode failure. --- .github/workflows/e2e-tests-run.yaml | 3 + .../login_entra_password_client_secret.robot | 55 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 e2e-tests/tests/login_entra_password_client_secret.robot 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/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..d5ecb2f5fb --- /dev/null +++ b/e2e-tests/tests/login_entra_password_client_secret.robot @@ -0,0 +1,55 @@ +*** 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 + 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} From 891c9307d74b2f618042de436faab5ffe9ccae02 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Mon, 29 Jun 2026 10:24:07 +0300 Subject: [PATCH 20/25] broker: never send client_secret for public-client OIDC flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When client_secret is configured alongside register_device, refresh token requests against the Microsoft Broker App fail with AADSTS700025 — Entra ID rejects secrets on public clients unconditionally. The Graph API credential (cfg.clientSecret) goes to the client-credentials endpoint, not the OIDC token endpoint, so it is separate and unaffected. --- authd-oidc-brokers/internal/broker/broker.go | 27 +++++--- .../internal/broker/broker_test.go | 64 +++++++++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/authd-oidc-brokers/internal/broker/broker.go b/authd-oidc-brokers/internal/broker/broker.go index 531f979515..87e8907c43 100644 --- a/authd-oidc-brokers/internal/broker/broker.go +++ b/authd-oidc-brokers/internal/broker/broker.go @@ -65,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 @@ -247,12 +248,21 @@ 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{}, @@ -724,11 +734,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, } diff --git a/authd-oidc-brokers/internal/broker/broker_test.go b/authd-oidc-brokers/internal/broker/broker_test.go index 9c34eaf276..b02ebcd2c1 100644 --- a/authd-oidc-brokers/internal/broker/broker_test.go +++ b/authd-oidc-brokers/internal/broker/broker_test.go @@ -3426,6 +3426,70 @@ func TestEntraPasswordRoutesAADSTSErrors(t *testing.T) { } } +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. From 4ef7ef84dc43e04c52fb39652a6f16d465d23a3e Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 1 Jul 2026 01:44:03 +0300 Subject: [PATCH 21/25] e2e-tests: skip entra_password tests when not using the msentraid broker `entra_password` is an Entra ID-specific broker option, but these tests ran against every broker, including google, where the config keys don't apply and the password+MFA UI never appears. CI runs against the google broker were failing instead of skipping. --- e2e-tests/tests/login_entra_password.robot | 4 ++++ e2e-tests/tests/login_entra_password_client_secret.robot | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/e2e-tests/tests/login_entra_password.robot b/e2e-tests/tests/login_entra_password.robot index 34b66aeb2e..e2b2fb2737 100644 --- a/e2e-tests/tests/login_entra_password.robot +++ b/e2e-tests/tests/login_entra_password.robot @@ -11,6 +11,10 @@ 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. diff --git a/e2e-tests/tests/login_entra_password_client_secret.robot b/e2e-tests/tests/login_entra_password_client_secret.robot index d5ecb2f5fb..583651ee8c 100644 --- a/e2e-tests/tests/login_entra_password_client_secret.robot +++ b/e2e-tests/tests/login_entra_password_client_secret.robot @@ -11,6 +11,10 @@ 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 From 792cabba7923c6a9fd59f8c61d84ec39a6ffecd7 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 1 Jul 2026 18:16:43 +0300 Subject: [PATCH 22/25] broker: fail startup for unusable entra_password configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Entra password flow is enabled without any way to read group memberships, users cannot actually use that flow. The previous startup check only rejected the configuration when it was the sole enabled auth flow; if device_auth was also enabled, the broker silently hid the bad configuration behind a fallback to device auth. Reject that configuration whenever the Entra password flow is enabled but has no group source available. Without a group source, any login through it would fail at the group-fetch step — an undiagnosable error at login time; failing startup surfaces it immediately. --- authd-oidc-brokers/internal/broker/broker.go | 26 ++++--- .../internal/broker/broker_test.go | 75 +++++++++++++++---- .../internal/broker/helper_test.go | 9 +++ 3 files changed, 86 insertions(+), 24 deletions(-) diff --git a/authd-oidc-brokers/internal/broker/broker.go b/authd-oidc-brokers/internal/broker/broker.go index 87e8907c43..b34b2f5b8a 100644 --- a/authd-oidc-brokers/internal/broker/broker.go +++ b/authd-oidc-brokers/internal/broker/broker.go @@ -228,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 } @@ -923,16 +936,9 @@ func (b *Broker) authModeIsAvailable(session session, authMode string) bool { // 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. - // - // This availability is decided here (per login) rather than at config-parse - // time on purpose: an earlier version disabled the flow while parsing the - // config (mutating the user's [flows] setting, and erroring out when - // entra_password was the only enabled flow). That coupled config parsing to - // provider capabilities and rejected otherwise-valid configs at startup. - // The trade-off of deciding it here: if entra_password is the only enabled - // flow and no group source is configured, the user is no longer rejected at - // startup but instead sees "no authentication modes available" at login. + // 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 diff --git a/authd-oidc-brokers/internal/broker/broker_test.go b/authd-oidc-brokers/internal/broker/broker_test.go index b02ebcd2c1..8a24199d98 100644 --- a/authd-oidc-brokers/internal/broker/broker_test.go +++ b/authd-oidc-brokers/internal/broker/broker_test.go @@ -354,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() @@ -2599,22 +2645,19 @@ func TestGetAuthenticationModesFiltersNextAuthModesByFlows(t *testing.T) { }}, modes) } -// TestGetAuthenticationModesEntraPasswordRequiresGroupSource verifies the -// availability gate (in authModeIsAvailable) that only offers the -// entra_password flow when a Microsoft Graph group source is available, i.e. -// device registration or a client secret. Without one, every entra_password -// login would fail at the group-fetch step, so the mode must not be offered. +// 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 - wantEntraPwd bool }{ - "Offered_with_device_registration": {registerDevice: true, wantEntraPwd: true}, - "Offered_with_client_secret": {clientSecret: "test-client-secret", wantEntraPwd: true}, - "Filtered_without_group_source": {registerDevice: false, wantEntraPwd: false}, + "Offered_with_device_registration": {registerDevice: true}, + "Offered_with_client_secret": {clientSecret: "test-client-secret"}, } for name, tc := range tests { @@ -2649,11 +2692,7 @@ func TestGetAuthenticationModesEntraPasswordRequiresGroupSource(t *testing.T) { for _, m := range modes { ids = append(ids, m["id"]) } - if tc.wantEntraPwd { - require.Contains(t, ids, authmodes.EntraPassword, "entra_password should be offered when a group source is available") - } else { - require.NotContains(t, ids, authmodes.EntraPassword, "entra_password should be filtered out without a group source") - } + require.Contains(t, ids, authmodes.EntraPassword, "entra_password should be offered when a group source is available") }) } } @@ -3403,6 +3442,10 @@ func TestEntraPasswordRoutesAADSTSErrors(t *testing.T) { 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) @@ -3569,6 +3612,10 @@ func TestIsAuthenticatedFIDOMethodRoutesToDevice(t *testing.T) { 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) diff --git a/authd-oidc-brokers/internal/broker/helper_test.go b/authd-oidc-brokers/internal/broker/helper_test.go index 65620a8735..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" @@ -146,6 +147,14 @@ func newBrokerForTests(t *testing.T, cfg *brokerForTestConfig) (b *broker.Broker 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 From bba53483691b798aa60d9e84e224109cdf1307f2 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 1 Jul 2026 13:21:35 +0300 Subject: [PATCH 23/25] broker: build refresh tokens without mutating the cached expiry refreshToken and refreshEntraPasswordToken both need to force a refresh even when the cached token has not actually expired (it's the liveness/revocation check, not an expiry check), but did it two different ways: refreshToken backdated the cached token's Expiry field in place, while RefreshEntraPasswordToken (which only ever received the refresh token string) built a bare token with just the refresh token, relying on oauth2.Token.Valid requiring a non-empty AccessToken. Standardize on the latter in refreshToken too. It has the same effect without mutating the caller's cached oldToken, which the backdating approach did as a side effect (harmless today since nothing reads Expiry afterwards, but not guaranteed to stay that way). --- authd-oidc-brokers/internal/broker/broker.go | 27 ++++++++-- .../internal/broker/broker_test.go | 54 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/authd-oidc-brokers/internal/broker/broker.go b/authd-oidc-brokers/internal/broker/broker.go index b34b2f5b8a..cd9b13240b 100644 --- a/authd-oidc-brokers/internal/broker/broker.go +++ b/authd-oidc-brokers/internal/broker/broker.go @@ -2342,13 +2342,26 @@ func (b *Broker) refreshEntraPasswordToken(ctx context.Context, session *session 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. Treat an absent, null, or empty id_token the same: // keep the cached one rather than storing an empty value. @@ -2368,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 diff --git a/authd-oidc-brokers/internal/broker/broker_test.go b/authd-oidc-brokers/internal/broker/broker_test.go index 8a24199d98..b5a5e136e6 100644 --- a/authd-oidc-brokers/internal/broker/broker_test.go +++ b/authd-oidc-brokers/internal/broker/broker_test.go @@ -2861,6 +2861,60 @@ func TestIsAuthenticatedPasswordEntraTokenRefreshDetectsDisabledUser(t *testing. 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 From 4d9385bef166a5dbf1479d54c918aa61864e9314 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 1 Jul 2026 19:33:37 +0300 Subject: [PATCH 24/25] AGENTS: document 72-character commit message line-length limit --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) 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()"). From e0985dcc04fddbbeff5c113110220e4297e07d62 Mon Sep 17 00:00:00 2001 From: Noor Eldeen Mansour Date: Wed, 1 Jul 2026 22:16:20 +0300 Subject: [PATCH 25/25] test: disable entra_password in daemon and dbusservice test configs 16 pre-existing tests fail under -tags withmsentraid with "invalid configuration: the entra_password flow is enabled" because the minimal broker configs generated for tests omit [flows], leaving entra_password at its default (enabled). Under the withmsentraid tag the provider implements EntraPasswordProvider, so the startup validation added in 452cd0023 rejects the config before any test logic runs. Disable the flow explicitly in both test config generators, matching the pattern already used by provision-authd.sh for E2E tests. --- .../cmd/authd-oidc/daemon/export_test.go | 26 ++++++++++++------- .../internal/dbusservice/methods_test.go | 3 +++ 2 files changed, 19 insertions(+), 10 deletions(-) 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/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()}