Closing the Entra SSO audit — five P0s, three P1s, three P3s, across four tags
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:
- No PKCE on the auth-code exchange. A captured authorization code could be redeemed without the client-side secret (ASVS V6.1.3).
- No nonce issued or validated. CSRF state alone doesn't bind the session to a specific id_token (ASVS V6.2.5).
- id_token never parsed. No JWKS fetch, no signature verification, no
iat/exp/nbfwith clock skew, no required-claim check. The code just read the access token out of the response and trusted it (ASVS V6.2.1 / V6.2.6). - No
issvalidation — the Microsoft preset used/common/, which means any Entra user in any tenant could authenticate (ASVS V6.2.4 + NIS2 Art. 21(2)(d)). - No
audcheck — no enforcement that the id_token was minted for our client_id (ASVS V6.2.3).
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:
- PKCE — a 64-byte
code_verifieris generated, hashed tocode_challenge = b64url(sha256(verifier)), sent to the authorize endpoint withcode_challenge_method=S256. The verifier is bound to the session via the existing OAuth state map and sent back on the token-exchange POST. - Nonce — a 32-byte
secrets.token_urlsafestring is issued with every authorize-request and compared against the returned id_token'snonceclaim withsecrets.compare_digest.
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:
- beta.30 closed the P1 tier — strict
at_hashenforcement when an access_token is issued (OIDC Core §3.1.3.6 makes the claim REQUIRED in that case; beta.29 had only enforced it when the claim was present), the Conditional Accessinsufficient_claimsclaims-challenge surface on the login path (_extract_cae_claims→_cae_reauth_redirect, mirroring the Graph connector's workload-identity handling from beta.28-era), and generic-OIDC discovery hardening per RFC 8414 §3.3 (refuseoidcVerifySsl=falsefor non-localhost URLs unless the operator explicitly opts intooidcAllowInsecureDev; enforce issuer-match post-discovery). - beta.31 closed the P3 tier — OpenID Connect RP-Initiated Logout 1.0 (new
GET /api/auth/social/microsoft/logout), Back-Channel Logout 1.0 (newPOST /api/auth/social/microsoft/backchannel-logout+ a publicverify_logout_tokenhelper inauth_oidc_verify), id_tokenjtireplay cache (shared between id_tokens and logout tokens), and converting the token-exchange POST and/meGET inauth_social.pytohttpx.AsyncClientso they stop blocking the event loop during Entra round-trips. - beta.32 automated two of ADR-008's written review triggers — the CVE watcher (
adr008_cve_watch.py) polls OSV every 24h for advisories on eight packages whose CVE feeds would signal our hand-rolled pattern has the same bug shape, and the Graph changelog watcher (adr008_graph_watch.py) polls the Microsoft Graph RSS feed for deprecation / v2.0 / breaking-change keywords. Either fires a WARNING log + audit-trail event, so the "when do we revisit ADR-008?" question has a machine answer, not a calendar reminder.
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.