Skip to content

Add EntraAppClient for Microsoft Entra ID (Azure AD) oauth2 - #920

Open
urskog84 wants to merge 1 commit into
AnswerDotAI:mainfrom
urskog84:entra-oauth-client
Open

Add EntraAppClient for Microsoft Entra ID (Azure AD) oauth2#920
urskog84 wants to merge 1 commit into
AnswerDotAI:mainfrom
urskog84:entra-oauth-client

Conversation

@urskog84

@urskog84 urskog84 commented Aug 16, 2026

Copy link
Copy Markdown

Closes #718.

Adds EntraAppClient (exported as AzureAppClient too) for Microsoft Entra ID, formerly Azure AD.

from fasthtml.oauth import EntraAppClient, OAuth

client = EntraAppClient(os.getenv("AUTH_CLIENT_ID"),
                        os.getenv("AUTH_CLIENT_SECRET"),
                        os.getenv("AUTH_TENANT_ID"))

class Auth(OAuth):
    def get_auth(self, info, ident, session, state):
        if info.tid == MY_TENANT: return RedirectResponse('/', status_code=303)

Why it isn't just another endpoint triple

Two things make Entra different from the existing clients:

  1. Entra normalises the scopes it grants, so they don't always match what was requested, and oauthlib's validate_token_parameters raises Warning: Scope has changed when the sets differ. Requesting https://graph.microsoft.com/User.Read — the form Microsoft's own samples and the original issue use — comes back as plain User.Read, which is enough to break the token exchange. parse_request_body_response therefore skips the scope-change check, and restores the requested scope afterwards so later login links are unaffected.

  2. Directory identity isn't in Graph's userinfo. /oidc/userinfo gives sub, name, email; the claims apps actually need to authorise against a directory — oid (the user's immutable ID) and tid (the tenant they signed in from) — are only in the id_token. get_info/get_info_async merge the two, so get_auth receives both. Decoding is a stdlib base64 unpack of the JWT payload rather than a PyJWT dependency, since the token arrives straight from the token endpoint over TLS and no signature check is performed either way.

API choices worth a second opinion

  • tenant_id is required, not defaulted to 'common'. A concrete directory ID means Entra rejects other directories before the request ever reaches the app, so the safe case is the default one; 'organizations' and 'common' are still there for anyone who wants them, and the docs spell out that they then owe their app a tid check.
  • ident stays sub (unique per user per app, consistent with the other clients). Apps that want the directory-wide ID can set client.id_key = 'oid'.
  • logout_link() is included because /logout alone leaves the user signed in to Microsoft, so the next login round-trips silently — surprising in an enterprise SSO setting.
  • Default scope is openid profile email User.Read, in short form. The OIDC scopes alone are enough for /oidc/userinfo; User.Read is user-consentable and makes the Graph audience unambiguous.

Testing

Run end to end against a real single-tenant Entra app registration (login, token exchange, Graph userinfo, logout), plus offline checks against mocked responses.

Worth recording what the live runs showed about point 1, since it decides whether that code earns its place:

Requested scope Granted scope in token response Unpatched client
openid profile email User.Read email openid profile User.Read fine — same set, just reordered
openid profile email https://graph.microsoft.com/User.Read email openid profile User.Read Warning: Scope has changed

So the client's own default never trips the check, but anyone following Microsoft's docs and passing Graph scopes as URIs does, and gets a login that fails at the token exchange. Same applies when a tenant grants scopes beyond those requested.

The merged info carried sub, oid, tid, preferred_username, email, name and picture, with tid matching the tenant. Note it also carries the id_token's own metadata claims (aud, iss, exp, nbf, iat, sid, uti, ver) — happy to filter those out if you'd rather info stayed lean.

Offline checks (authorize URL, pass-through params like prompt, token exchange payload, scope restoration, sync and async retr_info, id_key override, both logout_link forms) live outside this PR — say the word and I'll add them as tests/test_oauth.py.

nbs/api/08_oauth.ipynb is the edited source; fasthtml/oauth.py and _modidx.py came from nbdev_export. nbs/apilist.txt has only the new section added by hand — regenerating the whole file pulled in unrelated drift (htmx4, vurl, …) that seemed to belong in its own commit.

🤖 Generated with Claude Code

Closes AnswerDotAI#718.

Entra needs two things beyond the usual client boilerplate. It normalises
the scopes it grants, so they don't always match what was requested:
asking for `https://graph.microsoft.com/User.Read`, the form Microsoft's
samples use, comes back as plain `User.Read`, and oauthlib raises
`Warning: Scope has changed` on the difference. `parse_request_body_response`
skips that check, then restores the requested scope. And directory-level
identity lives in the `id_token` rather than in Graph's userinfo, so
`get_info` merges the two, making `oid` (immutable user id) and `tid`
(tenant) available to `get_auth`.

`tenant_id` is required rather than defaulting to 'common', so that the
single-tenant case - where Entra itself turns away other directories -
is what you get unless you ask for more.

Verified against a live single-tenant app registration: login, token
exchange, Graph userinfo and logout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@urskog84
urskog84 force-pushed the entra-oauth-client branch from 8bea150 to f1cf9c8 Compare August 16, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] OAuth Azure AD/Entra

1 participant