Public beta scope: AD workflows have real-system path validation, but NOBA is still under active verification. Treat compliance evidence and self-healing claims as evaluation surfaces unless the source and configured state are shown. Read validation boundaries.
← All posts

Closing the Entra SSO audit — five P0s, three P1s, three P3s, across four tags

2026-04-16

This one is short on new features and long on closing gaps that should not have been there. A dependency audit kicked off last week became a full review of how NOBA handles Microsoft / Entra SSO, and an independent verification caught that the flow was effectively unauthenticated against a few realistic threat models. Five P0 NIS2 blockers. All closed in beta.29.

What the audit found

The verbatim verdict: "Not acceptable for NIS2-compliant production Entra SSO. The flow currently reduces OIDC to 'trust whatever email the userinfo endpoint returns over TLS,' which collapses under a stolen-access-token replay, a compromised Entra app secret, or a mis-scoped /common/ tenant."

The five specific gaps, each mapping to an ASVS 5.0 V6.1–V6.2 requirement an external NIS2 auditor checks:

Combined, those gaps formed a concrete oracle: any Entra user anywhere in the world could hit the /callback endpoint, send a valid access token for some Entra tenant, and have NOBA create a fresh viewer account at users.add(email, "!oidc:disabled", "viewer") — the account-creation line lived at the bottom of the callback handler with no gate. That's a production-grade privilege-escalation vector. It needed to go.

What beta.29 does

A new module server/auth_oidc_verify.py validates Entra id_tokens against every OIDC Core 1.0 §3.1.3.7 check in the spec: RS256 signature against the tenant's JWKS, expected iss (exact match, no prefix tricks), expected aud (the NOBA app's client_id, constant-time compared), exp / iat / nbf with 300s leeway, required claims present (iss / aud / exp / iat / sub), nonce binding, optional at_hash access-token binding, and an optional tid tenant allowlist. The JWKS are cached 24h with automatic kid-miss refresh — the pattern Microsoft Learn explicitly documents for Entra key rotation.

The callback handler in auth_social.py was rewritten end-to-end. On /login every social provider now emits:

On /callback, for Microsoft specifically, the returned id_token is validated against the tenant's JWKS before anything else happens. If the iss, aud, exp, nbf, nonce, or at_hash check fails, the handler raises HTTPException(401) without ever touching the NOBA user database. Only after every check passes do we read the email from the validated claims and proceed with the regular session-creation flow.

Tenant pinning

Before beta.29, the Microsoft preset's authorize URL was https://login.microsoftonline.com/common/oauth2/v2.0/authorize. The /common/ placeholder accepts any Entra user in any tenant — personal Microsoft accounts, partner organizations, competitors, whoever. Combined with the missing tid check, that made NOBA's Microsoft login effectively open to the global Entra population.

beta.29 requires an explicit tenantId on every Microsoft provider config. common, organizations, and consumers are rejected by _resolve_provider unless an operator explicitly opts into allowMultiTenant. An optional allowedTids list is enforced against the id_token's tid claim — so even if you do opt into multi-tenant, you can still limit which tenants are trusted. The Settings UI now surfaces all four fields (Tenant ID, Allowed tenant IDs, Auto-provision, Allow multi-tenant) directly on the Microsoft provider card.

JIT provisioning gate

The users.add(...) call on first callback is now gated behind socialProviders.<provider>.allowJitProvision — default false. When off, a social login from an email that doesn't already exist in NOBA is rejected with a 403 and audit-logged. Operators who trust their IdP to create accounts on first login flip the toggle; operators who don't get a safer default. On the Microsoft path, this is AND-gated with the tid allowlist — so even with JIT enabled, accounts are only auto-created for users in approved tenants.

PyJWT floor — and a lesson on never trusting a pinned number you didn't verify

The new module needs a JWT library. The audit memo had proposed PyJWT>=2.9.0 — that's the version Microsoft Learn recommends in its baseline OIDC walkthrough. Before pinning, I ran the three-step verification we always run: PyPI latest, OSV advisories at the proposed floor, NVD CPE cross-check for vendor confusion. PyPI said the latest was 2.12.1. OSV had six advisories. Two were in my blast radius: CVE-2026-32597 (CVSS 7.5 HIGH, crit header bypass, fixed in 2.12.0) and CVE-2024-53861 (CVSS 7.5 HIGH, iss partial-match, fixed in 2.10.1). The proposed >=2.9.0 floor would have left both open.

The memo was written from general knowledge. Verification caught the gap. The final floor is PyJWT>=2.12.0, declared across all six pip install surfaces per the usual NOBA hygiene. Three minutes of extra work saved us from shipping a version of the JWT library that was vulnerable to a crit-header bypass specifically in the context of our new id_token validation.

The full audit closure — beta.29 through beta.32

beta.29 closed the five P0 items above. The rest of the audit landed over the next two days:

No MSAL adoption. ADR-008 still stands. Hand-rolled is fine — as long as hand-rolled implements the spec, and the spec is what beta.29–beta.32 bring us up to.

Upgrade path: the Microsoft / Entra provider configuration now requires an explicit tenantId — existing deployments that relied on the implicit /common/ endpoint will need to set one. The Settings UI surfaces the new fields directly on the Microsoft card; the configuration page documents the full schema. As always, pull the latest tag with your normal update path (Docker image, installer, or git pull + restart) and the changes apply on restart.

Comments

No comments yet. Be the first.

Comment posted.