[GH-ISSUE #1088] [Feature]: Provide support for Azure Entra ID #773

Closed
opened 2026-05-06 12:32:49 +02:00 by BreizhHardware · 9 comments

Originally created by @cadtoolbox on GitHub (Apr 22, 2026).
Original GitHub issue: https://github.com/maziggy/bambuddy/issues/1088

Originally assigned to: @netscout2001 on GitHub.

Problem or Use Case

The OIDC callback only trusts the email claim when email_verified is exactly true in the ID token (mfa.py:1376). This is a Google-style OIDC assumption that doesn't hold for Azure Entra ID (Microsoft Entra ID), which does not include email_verified in ID tokens by default — even for directory-verified addresses.

As a result, email-based user matching (auto-linking and auto-creation) silently fails for Azure Entra ID providers. The email is discarded and the log shows:

OIDC provider X: ignoring email for sub='...' because email_verified=None
Users are redirected to no_linked_account even though their email exists in Bambuddy.

OIDC login with Azure Entra ID should resolve users by email when the identity is verified by the tenant.

Proposed Solution

When provider_email is None after the email_verified check, fall back to the preferred_username or upn claims from the ID token. These are directory-controlled claims in Azure Entra ID — the tenant (not the end user) controls their values, making them safe to trust once the issuer has been validated.

if provider_email is None:
for fallback_claim in ("preferred_username", "upn"):
candidate = claims.get(fallback_claim)
if candidate and "@" in candidate:
provider_email = candidate
break

This is safe because:

The issuer is already validated against the OIDC discovery document before this point
preferred_username and upn are tenant-managed claims, not self-asserted
The "@" in candidate check ensures only email-shaped values are used
Google and other providers that emit email_verified: true continue to use the existing primary path unchanged

Affected providers
Any OIDC provider that does not emit the email_verified claim, including:

Microsoft Entra ID (Azure AD)
Some self-hosted providers (e.g., Authentik, depending on configuration)
Environment
Bambuddy version: current
OIDC provider: Azure Entra ID (tenant endpoint https://login.microsoftonline.com/{tenant-id}/v2.0)
Scopes: openid email profile

Alternatives Considered

No response

Feature Category

Other

Priority

Critical for my use case

Mockups or Examples

No response

Contribution

  • I would be willing to help implement this feature

Checklist

  • I have searched existing issues to ensure this feature hasn't already been requested
Originally created by @cadtoolbox on GitHub (Apr 22, 2026). Original GitHub issue: https://github.com/maziggy/bambuddy/issues/1088 Originally assigned to: @netscout2001 on GitHub. ### Problem or Use Case The OIDC callback only trusts the email claim when email_verified is exactly true in the ID token ([mfa.py:1376](vscode-webview://1pra1aoh8ladfjss9evd5gmacp4j65u5jmvu11385i08a7fphmtn/backend/app/api/routes/mfa.py#L1376)). This is a Google-style OIDC assumption that doesn't hold for Azure Entra ID (Microsoft Entra ID), which does not include email_verified in ID tokens by default — even for directory-verified addresses. As a result, email-based user matching (auto-linking and auto-creation) silently fails for Azure Entra ID providers. The email is discarded and the log shows: OIDC provider X: ignoring email for sub='...' because email_verified=None Users are redirected to no_linked_account even though their email exists in Bambuddy. OIDC login with Azure Entra ID should resolve users by email when the identity is verified by the tenant. ### Proposed Solution When provider_email is None after the email_verified check, fall back to the preferred_username or upn claims from the ID token. These are directory-controlled claims in Azure Entra ID — the tenant (not the end user) controls their values, making them safe to trust once the issuer has been validated. if provider_email is None: for fallback_claim in ("preferred_username", "upn"): candidate = claims.get(fallback_claim) if candidate and "@" in candidate: provider_email = candidate break This is safe because: The issuer is already validated against the OIDC discovery document before this point preferred_username and upn are tenant-managed claims, not self-asserted The "@" in candidate check ensures only email-shaped values are used Google and other providers that emit email_verified: true continue to use the existing primary path unchanged Affected providers Any OIDC provider that does not emit the email_verified claim, including: Microsoft Entra ID (Azure AD) Some self-hosted providers (e.g., Authentik, depending on configuration) Environment Bambuddy version: current OIDC provider: Azure Entra ID (tenant endpoint https://login.microsoftonline.com/{tenant-id}/v2.0) Scopes: openid email profile ### Alternatives Considered _No response_ ### Feature Category Other ### Priority Critical for my use case ### Mockups or Examples _No response_ ### Contribution - [ ] I would be willing to help implement this feature ### Checklist - [x] I have searched existing issues to ensure this feature hasn't already been requested
BreizhHardware 2026-05-06 12:32:49 +02:00
Author
Owner

@netscout2001 commented on GitHub (Apr 23, 2026):

Thank you for the detailed writeup

Rather than a hardcoded fallback to preferred_username/upn, we are evaluating an approach that adds two configurable fields per OIDC provider:

Field Default Purpose
email_claim "email" Which JWT claim to read the email from
require_email_verified true Whether to enforce email_verified == true

This would produce three runtime paths:

  • Case A (default, unchanged): email_claim="email" + require_email_verified=true — identical to the current C1-guard; no behaviour change for Google or any other provider that emits email_verified: true.
  • Case B: email_claim="email" + require_email_verified=false — for Entra ID setups where the email claim is present but email_verified is never sent. email_verified=false would still discard the email; only absent/null passes through.
  • Case C: email_claim="preferred_username" (or "upn", or any tenant-managed claim) — for setups where the email identity lives in a non-standard claim. The value would be validated as email-shaped (non-empty local part, exactly one @, non-empty domain, ≤ 255 chars) rather than a bare "@" in value check, which would accept values like "@" or "x@".

Why not the fallback approach

The fallback design in your proposal is clean and would work, but it has two properties that concern us:

  1. Security surface: silently trusting preferred_username when the standard email path failed creates an implicit second trust anchor that is invisible in the provider settings UI. An explicit email_claim field would make the operator's intent auditable.
  2. auto_link_existing_accounts interaction: Case C skips the email_verified check entirely (there is nothing to check), which creates the same account-takeover risk as require_email_verified=false when combined with auto-link. This would require an additional guard: auto_link_existing_accounts should only be permitted with email_claim="email" and require_email_verified=true. The fallback approach would need this guard too, but it would be harder to express cleanly.

One question for you:
When you decode your Entra ID ID token, does the email claim appear (with a value but without email_verified), or is the email identity only available in preferred_username/upn? Knowing which path you hit would help us better understand the scope and write more targeted documentation once a solution is ready.

<!-- gh-comment-id:4302409951 --> @netscout2001 commented on GitHub (Apr 23, 2026): Thank you for the detailed writeup Rather than a hardcoded fallback to `preferred_username`/`upn`, we are evaluating an approach that adds two configurable fields per OIDC provider: | Field | Default | Purpose | |---|---|---| | `email_claim` | `"email"` | Which JWT claim to read the email from | | `require_email_verified` | `true` | Whether to enforce `email_verified == true` | This would produce three runtime paths: - **Case A** (default, unchanged): `email_claim="email"` + `require_email_verified=true` — identical to the current C1-guard; no behaviour change for Google or any other provider that emits `email_verified: true`. - **Case B**: `email_claim="email"` + `require_email_verified=false` — for Entra ID setups where the `email` claim is present but `email_verified` is never sent. `email_verified=false` would still discard the email; only absent/`null` passes through. - **Case C**: `email_claim="preferred_username"` (or `"upn"`, or any tenant-managed claim) — for setups where the email identity lives in a non-standard claim. The value would be validated as email-shaped (non-empty local part, exactly one `@`, non-empty domain, ≤ 255 chars) rather than a bare `"@" in value` check, which would accept values like `"@"` or `"x@"`. ### Why not the fallback approach The fallback design in your proposal is clean and would work, but it has two properties that concern us: 1. **Security surface**: silently trusting `preferred_username` when the standard email path failed creates an implicit second trust anchor that is invisible in the provider settings UI. An explicit `email_claim` field would make the operator's intent auditable. 2. **`auto_link_existing_accounts` interaction**: Case C skips the `email_verified` check entirely (there is nothing to check), which creates the same account-takeover risk as `require_email_verified=false` when combined with auto-link. This would require an additional guard: `auto_link_existing_accounts` should only be permitted with `email_claim="email"` **and** `require_email_verified=true`. The fallback approach would need this guard too, but it would be harder to express cleanly. One question for you: When you decode your Entra ID ID token, does the `email` claim appear (with a value but without `email_verified`), or is the email identity only available in `preferred_username`/`upn`? Knowing which path you hit would help us better understand the scope and write more targeted documentation once a solution is ready.
Author
Owner

@cadtoolbox commented on GitHub (Apr 23, 2026):

@netscout2001 The email claim is present in the token with the correct value. email_verified is missing (None). Azure Entra ID simply doesn't include that claim.

<!-- gh-comment-id:4304585230 --> @cadtoolbox commented on GitHub (Apr 23, 2026): @netscout2001 The email claim is present in the token with the correct value. email_verified is missing (None). Azure Entra ID simply doesn't include that claim.
Author
Owner

@netscout2001 commented on GitHub (Apr 23, 2026):

Thanks for the additional token data — very helpful!

One more question before i can start: could you share which of these claims are present in your Azure token, and whether they contain a valid email-format value (e.g. user@company.com)?

preferred_username
upn

This only affects which claim name in the tooltip/description — not the underlying logic.

<!-- gh-comment-id:4304634274 --> @netscout2001 commented on GitHub (Apr 23, 2026): Thanks for the additional token data — very helpful! One more question before i can start: could you share which of these claims are present in your Azure token, and whether they contain a valid email-format value (e.g. user@company.com)? preferred_username upn This only affects which claim name in the tooltip/description — not the underlying logic.
Author
Owner

@cadtoolbox commented on GitHub (Apr 23, 2026):

@netscout2001 The actual problem is still the email_verified issue. The OIDC callback matches against the email field on the User record in Bambuddy's database (auth.py:306), doing a case-insensitive lookup. But it never gets that far because the email is discarded at mfa.py:1376 before matching is attempted.

So the chain is:

Azure sends email: "user@company.com" but no email_verified
Bambuddy sees email_verified is None, discards the email
provider_email is None, so email matching is skipped entirely
No existing OIDC link exists → no_linked_account error
The fix is the code change to fall back to preferred_username — or to trust email when email_verified is absent (not false).

<!-- gh-comment-id:4304772272 --> @cadtoolbox commented on GitHub (Apr 23, 2026): @netscout2001 The actual problem is still the email_verified issue. The OIDC callback matches against the email field on the User record in Bambuddy's database ([auth.py:306](vscode-webview://1pra1aoh8ladfjss9evd5gmacp4j65u5jmvu11385i08a7fphmtn/backend/app/core/auth.py#L306)), doing a case-insensitive lookup. But it never gets that far because the email is discarded at [mfa.py:1376](vscode-webview://1pra1aoh8ladfjss9evd5gmacp4j65u5jmvu11385i08a7fphmtn/backend/app/api/routes/mfa.py#L1376) before matching is attempted. So the chain is: Azure sends email: "user@company.com" but no email_verified Bambuddy sees email_verified is None, discards the email provider_email is None, so email matching is skipped entirely No existing OIDC link exists → no_linked_account error The fix is the code change to fall back to preferred_username — or to trust email when email_verified is absent (not false).
Author
Owner

@netscout2001 commented on GitHub (Apr 25, 2026):

Please test it using today's daily build.
It includes Azure and RememberMe support for testing.

<!-- gh-comment-id:4319699020 --> @netscout2001 commented on GitHub (Apr 25, 2026): Please test it using today's daily build. It includes Azure and RememberMe support for testing.
Author
Owner

@netscout2001 commented on GitHub (Apr 26, 2026):

should be fixed in /dev branch by https://github.com/maziggy/bambuddy/pull/1126
please check

<!-- gh-comment-id:4321961744 --> @netscout2001 commented on GitHub (Apr 26, 2026): should be fixed in /dev branch by https://github.com/maziggy/bambuddy/pull/1126 please check
Author
Owner

@cadtoolbox commented on GitHub (Apr 27, 2026):

@netscout2001 Good news, it's working now with Azure when the email claim is set to 'preferred_username'. But...

  • There's no way to link an existing account to the Azure SSO:
  • Using 'Auto-create users' works but sets them as Viewer and then there's no way to limit who has access.
  • 'Auto-link existing accounts' does not work with Azure at all. Bambuddy shows an error on the screen as you save with this setting enabled:
    [{"type":"value_error","loc":["body"],"msg":"Value error, auto_link_existing_accounts requires require_email_verified=True and email_claim='email'","input":{"name":"XYZ SSO","issuer_url":"https://login.microsoftonline.com/XXXXXXXXXXXX/v2.0","client_id":"XXXXXXXXXXX,"scopes":"openid email profile","is_enabled":true,"auto_create_users":true,"auto_link_existing_accounts":true,"email_claim":"preferred_username","require_email_verified":false},"ctx":{"error":{}}}]

I need to be able to create an account in Bambuddy and then the user logs in via SSO, it grants them access as needed. Otherwise, anyone and everyone can use it without any control.

<!-- gh-comment-id:4323680549 --> @cadtoolbox commented on GitHub (Apr 27, 2026): @netscout2001 Good news, it's working now with Azure when the email claim is set to 'preferred_username'. But... - There's no way to link an existing account to the Azure SSO: - Using 'Auto-create users' works but sets them as Viewer and then there's no way to limit who has access. - 'Auto-link existing accounts' does not work with Azure at all. Bambuddy shows an error on the screen as you save with this setting enabled: `[{"type":"value_error","loc":["body"],"msg":"Value error, auto_link_existing_accounts requires require_email_verified=True and email_claim='email'","input":{"name":"XYZ SSO","issuer_url":"https://login.microsoftonline.com/XXXXXXXXXXXX/v2.0","client_id":"XXXXXXXXXXX,"scopes":"openid email profile","is_enabled":true,"auto_create_users":true,"auto_link_existing_accounts":true,"email_claim":"preferred_username","require_email_verified":false},"ctx":{"error":{}}}]` I need to be able to create an account in Bambuddy and then the user logs in via SSO, it grants them access as needed. Otherwise, anyone and everyone can use it without any control.
Author
Owner

@netscout2001 commented on GitHub (Apr 28, 2026):

should be fixed in /dev branch by https://github.com/maziggy/bambuddy/pull/1142
please check

<!-- gh-comment-id:4337017645 --> @netscout2001 commented on GitHub (Apr 28, 2026): should be fixed in /dev branch by https://github.com/maziggy/bambuddy/pull/1142 please check
Author
Owner

@cadtoolbox commented on GitHub (Apr 28, 2026):

@netscout2001 Superb work! It works perfectly now.

<!-- gh-comment-id:4337683337 --> @cadtoolbox commented on GitHub (Apr 28, 2026): @netscout2001 Superb work! It works perfectly now.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/bambuddy#773
No description provided.