Skip to content

Direct Password and MFA Authentication with MS Entra ID - #1520

Merged
nooreldeenmansour merged 25 commits into
mainfrom
002-entra-password-mfa-auth
Jul 2, 2026
Merged

Direct Password and MFA Authentication with MS Entra ID#1520
nooreldeenmansour merged 25 commits into
mainfrom
002-entra-password-mfa-auth

Conversation

@nooreldeenmansour

@nooreldeenmansour nooreldeenmansour commented May 18, 2026

Copy link
Copy Markdown
Member

Adds a direct password + MFA authentication flow to the MS Entra ID broker as an additional bootstrap authentication method alongside device authentication. Users can authenticate with entra_password, complete MFA via push notification (entra_mfa_wait) or OTP/SMS code (entra_mfa_code), then authd caches the OAuth2 token and stores an Argon2id password hash for offline re-authentication.

Changes Summary

  • Add broker support for direct Entra password authentication, MFA follow-up steps, and user-facing handling for common AADSTS failures such as expired passwords, account lockouts, and required MFA enrollment.
  • Split password hashing from persistence so the plaintext is hashed immediately and written to disk only after MFA succeeds.
  • Extend the MS Entra ID provider and Himmelblau bindings with the APIs needed to start and complete the Entra password + MFA flow.
  • Add a Graph fallback for group lookup: fall back to an app-only client-credentials token when the delegated token lacks GroupMember.Read.All and a client_secret is configured.
  • Replace the single cached Himmelblau broker app with a cache keyed by client configuration so device registration and Entra password login can reuse separate apps safely.
  • Add [flows] toggles for entra_password and device_auth, and validate the prerequisites for entra_password at startup. When neither register_device = true nor a configured client_secret can support group lookup, the flow is disabled or rejected if it would leave no bootstrap login method.
  • Document the new flow, its prerequisites, and the cases that fall back to device authentication.

Related Issues and Discussions (WIP)

UDENG-6756

Closes #857
Closes #886
Closes #561

@nooreldeenmansour
nooreldeenmansour force-pushed the 002-entra-password-mfa-auth branch 4 times, most recently from 71977ed to d44496f Compare May 18, 2026 13:22
@codecov

codecov Bot commented May 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.14590% with 78 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.33%. Comparing base (9ee6ead) to head (e0985dc).

Files with missing lines Patch % Lines
authd-oidc-brokers/internal/broker/broker.go 87.55% 53 Missing ⚠️
...nal/providers/msentraid/tokenverify/tokenverify.go 80.95% 20 Missing ⚠️
authd-oidc-brokers/internal/password/password.go 77.77% 2 Missing ⚠️
authd-oidc-brokers/internal/broker/config.go 95.23% 1 Missing ⚠️
internal/brokers/broker.go 94.11% 1 Missing ⚠️
internal/services/pam/pam.go 88.88% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1520      +/-   ##
==========================================
+ Coverage   83.47%   87.33%   +3.85%     
==========================================
  Files          21      124     +103     
  Lines        1398     8907    +7509     
  Branches        0      111     +111     
==========================================
+ Hits         1167     7779    +6612     
- Misses        231     1072     +841     
- Partials        0       56      +56     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nooreldeenmansour
nooreldeenmansour force-pushed the 002-entra-password-mfa-auth branch 8 times, most recently from 95281f3 to c593968 Compare May 20, 2026 12:18
@nooreldeenmansour
nooreldeenmansour force-pushed the 002-entra-password-mfa-auth branch 12 times, most recently from 552221e to 4b2a458 Compare June 5, 2026 12:18
@nooreldeenmansour
nooreldeenmansour marked this pull request as ready for review June 5, 2026 12:19
@nooreldeenmansour
nooreldeenmansour force-pushed the 002-entra-password-mfa-auth branch 2 times, most recently from 7f4a985 to e3a0ec1 Compare June 8, 2026 17:51

@adombeck adombeck left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Great work! :)

Comment thread AGENTS.md Outdated
Comment thread authd-oidc-brokers/internal/broker/broker.go
Comment thread authd-oidc-brokers/internal/broker/broker.go
nooreldeenmansour and others added 25 commits July 2, 2026 14:20
Move `DeviceRegistrationData` and its validation helper into an
untagged file so cached device-registration JSON can be validated
without the `libhimmelblau` cgo build.
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.
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.
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.
Implement the provider methods that start the Entra password flow and
complete its MFA challenge. Also advertise `entra_password` in the
supported auth modes.
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.
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.
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
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) <noreply@anthropic.com>
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
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.
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>
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>
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>
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>
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
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.
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.
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.
`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.
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.
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).
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
452cd00 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

e2e-tests This issue is related to end-to-end tests / Run end-to-end tests on this pull request

Projects

None yet

6 participants