diff --git a/fasthtml/_modidx.py b/fasthtml/_modidx.py index 5b69b1de..82b0d44f 100644 --- a/fasthtml/_modidx.py +++ b/fasthtml/_modidx.py @@ -216,6 +216,16 @@ 'fasthtml/oauth.py'), 'fasthtml.oauth.DiscordAppClient.parse_response': ( 'api/oauth.html#discordappclient.parse_response', 'fasthtml/oauth.py'), + 'fasthtml.oauth.EntraAppClient': ('api/oauth.html#entraappclient', 'fasthtml/oauth.py'), + 'fasthtml.oauth.EntraAppClient.__init__': ('api/oauth.html#entraappclient.__init__', 'fasthtml/oauth.py'), + 'fasthtml.oauth.EntraAppClient.get_info': ('api/oauth.html#entraappclient.get_info', 'fasthtml/oauth.py'), + 'fasthtml.oauth.EntraAppClient.get_info_async': ( 'api/oauth.html#entraappclient.get_info_async', + 'fasthtml/oauth.py'), + 'fasthtml.oauth.EntraAppClient.id_claims': ('api/oauth.html#entraappclient.id_claims', 'fasthtml/oauth.py'), + 'fasthtml.oauth.EntraAppClient.logout_link': ( 'api/oauth.html#entraappclient.logout_link', + 'fasthtml/oauth.py'), + 'fasthtml.oauth.EntraAppClient.parse_request_body_response': ( 'api/oauth.html#entraappclient.parse_request_body_response', + 'fasthtml/oauth.py'), 'fasthtml.oauth.GitHubAppClient': ('api/oauth.html#githubappclient', 'fasthtml/oauth.py'), 'fasthtml.oauth.GitHubAppClient.__init__': ('api/oauth.html#githubappclient.__init__', 'fasthtml/oauth.py'), 'fasthtml.oauth.GoogleAppClient': ('api/oauth.html#googleappclient', 'fasthtml/oauth.py'), @@ -252,6 +262,7 @@ 'fasthtml.oauth._AppClient.retr_info_async': ( 'api/oauth.html#_appclient.retr_info_async', 'fasthtml/oauth.py'), 'fasthtml.oauth._arun': ('api/oauth.html#_arun', 'fasthtml/oauth.py'), + 'fasthtml.oauth._jwt_claims': ('api/oauth.html#_jwt_claims', 'fasthtml/oauth.py'), 'fasthtml.oauth.get_host': ('api/oauth.html#get_host', 'fasthtml/oauth.py'), 'fasthtml.oauth.load_creds': ('api/oauth.html#load_creds', 'fasthtml/oauth.py'), 'fasthtml.oauth.redir_url': ('api/oauth.html#redir_url', 'fasthtml/oauth.py'), diff --git a/fasthtml/oauth.py b/fasthtml/oauth.py index e27cfab8..e539705a 100644 --- a/fasthtml/oauth.py +++ b/fasthtml/oauth.py @@ -5,13 +5,15 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/08_oauth.ipynb. # %% auto #0 -__all__ = ['log', 'http_patterns', 'GoogleAppClient', 'GitHubAppClient', 'HuggingFaceClient', 'DiscordAppClient', - 'Auth0AppClient', 'AppleAppClient', 'get_host', 'redir_url', 'url_match', 'OAuth', 'load_creds'] +__all__ = ['log', 'AzureAppClient', 'http_patterns', 'GoogleAppClient', 'GitHubAppClient', 'HuggingFaceClient', + 'DiscordAppClient', 'Auth0AppClient', 'EntraAppClient', 'AppleAppClient', 'get_host', 'redir_url', + 'url_match', 'OAuth', 'load_creds'] # %% ../nbs/api/08_oauth.ipynb #793722f2 from .common import * from oauthlib.oauth2 import WebApplicationClient from urllib.parse import urlparse, urlencode, parse_qs, quote, unquote +from base64 import urlsafe_b64decode import secrets, httpx2, time, asyncio, logging # %% ../nbs/api/08_oauth.ipynb #44aa4a88 @@ -115,6 +117,49 @@ def login_link(self, req): d = dict(response_type="code", client_id=self.client_id, scope=self.scope, redirect_uri=redir_url(req, self.redirect_uri)) return f"{self.base_url}?{urlencode(d)}" +# %% ../nbs/api/08_oauth.ipynb #c1b4f2a8 +def _jwt_claims(tok): + "Decode the payload of JWT `tok` without verifying its signature" + payload = tok.split('.')[1] + return loads(urlsafe_b64decode(payload + '='*(-len(payload)%4))) + +class EntraAppClient(_AppClient): + "A `WebApplicationClient` for Microsoft Entra ID (formerly Azure AD) oauth2" + info_url = "https://graph.microsoft.com/oidc/userinfo" + + def __init__(self, client_id, client_secret, tenant_id, code=None, scope=None, **kwargs): + if not scope: scope = ["openid", "profile", "email", "User.Read"] + self.tenant_id = tenant_id + pre = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0" + self.base_url,self.token_url,self.logout_url = f"{pre}/authorize",f"{pre}/token",f"{pre}/logout" + super().__init__(client_id, client_secret, code=code, scope=scope, **kwargs) + + def parse_request_body_response(self, body, scope=None, **kwargs): + "Entra reports granted scopes differently to those requested, so don't check for scope changes" + req_scope,self.scope = self.scope,None + try: return super().parse_request_body_response(body, **kwargs) + finally: self.scope = req_scope + + def id_claims(self): + "Claims from the `id_token`, inc `oid` (user id), `tid` (tenant id) and `preferred_username`" + tok = self.token.get('id_token') if self.token else None + return _jwt_claims(tok) if tok else {} + + def get_info(self, token=None): + "Graph user info, merged with the `id_token` claims" + return {**self.id_claims(), **super().get_info(token)} + + async def get_info_async(self, token=None): + "Graph user info, merged with the `id_token` claims" + return {**self.id_claims(), **(await super().get_info_async(token))} + + def logout_link(self, post_logout_redirect_uri=None): + "Link to log out of Entra itself, not just this app" + if not post_logout_redirect_uri: return self.logout_url + return f"{self.logout_url}?{urlencode(dict(post_logout_redirect_uri=post_logout_redirect_uri))}" + +AzureAppClient = EntraAppClient + # %% ../nbs/api/08_oauth.ipynb #39425d0b class AppleAppClient(_AppClient): "A `WebApplicationClient` for Apple Sign In" diff --git a/nbs/api/08_oauth.ipynb b/nbs/api/08_oauth.ipynb index 8672527f..dcd10e5b 100644 --- a/nbs/api/08_oauth.ipynb +++ b/nbs/api/08_oauth.ipynb @@ -41,6 +41,7 @@ "from fasthtml.common import *\n", "from oauthlib.oauth2 import WebApplicationClient\n", "from urllib.parse import urlparse, urlencode, parse_qs, quote, unquote\n", + "from base64 import urlsafe_b64decode\n", "import secrets, httpx2, time, asyncio, logging" ] }, @@ -222,6 +223,75 @@ " return f\"{self.base_url}?{urlencode(d)}\"" ] }, + { + "cell_type": "markdown", + "id": "e37bd91c", + "metadata": {}, + "source": [ + "### Microsoft Entra ID\n", + "\n", + "Entra ID (formerly Azure AD) needs three things from the [Azure portal](https://portal.azure.com), under App registrations → your app:\n", + "\n", + "1. **Application (client) ID** — this is your `client_id`\n", + "2. **Directory (tenant) ID** — this is your `tenant_id`. Pass `'organizations'` to allow any work or school account, or `'common'` to also allow personal Microsoft accounts. Using your own tenant ID means Entra itself blocks sign-ins from other directories.\n", + "3. **Client secret** — created under Certificates & secrets → New client secret\n", + "\n", + "Your redirect path (e.g. `http://localhost:8000/redirect`) goes under Authentication → Web → Redirect URIs.\n", + "\n", + "Two Entra-specific details are handled for you. Entra normalises the scopes it grants, so they don't always match the ones you asked for — request `https://graph.microsoft.com/User.Read`, as Microsoft's own samples write it, and the token response says `User.Read` — which oauthlib otherwise treats as an error; and `get_info` merges the `id_token` claims into the Graph user info, so you also get `oid` (the user's immutable ID in the directory) and `tid` (the tenant they signed in from). If you allow more than one tenant, check `tid` against your own allowlist in `get_auth` — `sub` and `oid` don't tell you which directory a user came from." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1b4f2a8", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _jwt_claims(tok):\n", + " \"Decode the payload of JWT `tok` without verifying its signature\"\n", + " payload = tok.split('.')[1]\n", + " return loads(urlsafe_b64decode(payload + '='*(-len(payload)%4)))\n", + "\n", + "class EntraAppClient(_AppClient):\n", + " \"A `WebApplicationClient` for Microsoft Entra ID (formerly Azure AD) oauth2\"\n", + " info_url = \"https://graph.microsoft.com/oidc/userinfo\"\n", + "\n", + " def __init__(self, client_id, client_secret, tenant_id, code=None, scope=None, **kwargs):\n", + " if not scope: scope = [\"openid\", \"profile\", \"email\", \"User.Read\"]\n", + " self.tenant_id = tenant_id\n", + " pre = f\"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0\"\n", + " self.base_url,self.token_url,self.logout_url = f\"{pre}/authorize\",f\"{pre}/token\",f\"{pre}/logout\"\n", + " super().__init__(client_id, client_secret, code=code, scope=scope, **kwargs)\n", + "\n", + " def parse_request_body_response(self, body, scope=None, **kwargs):\n", + " \"Entra reports granted scopes differently to those requested, so don't check for scope changes\"\n", + " req_scope,self.scope = self.scope,None\n", + " try: return super().parse_request_body_response(body, **kwargs)\n", + " finally: self.scope = req_scope\n", + "\n", + " def id_claims(self):\n", + " \"Claims from the `id_token`, inc `oid` (user id), `tid` (tenant id) and `preferred_username`\"\n", + " tok = self.token.get('id_token') if self.token else None\n", + " return _jwt_claims(tok) if tok else {}\n", + "\n", + " def get_info(self, token=None):\n", + " \"Graph user info, merged with the `id_token` claims\"\n", + " return {**self.id_claims(), **super().get_info(token)}\n", + "\n", + " async def get_info_async(self, token=None):\n", + " \"Graph user info, merged with the `id_token` claims\"\n", + " return {**self.id_claims(), **(await super().get_info_async(token))}\n", + "\n", + " def logout_link(self, post_logout_redirect_uri=None):\n", + " \"Link to log out of Entra itself, not just this app\"\n", + " if not post_logout_redirect_uri: return self.logout_url\n", + " return f\"{self.logout_url}?{urlencode(dict(post_logout_redirect_uri=post_logout_redirect_uri))}\"\n", + "\n", + "AzureAppClient = EntraAppClient" + ] + }, { "cell_type": "markdown", "id": "b2643c29", diff --git a/nbs/apilist.txt b/nbs/apilist.txt index 91de862c..e629ffda 100644 --- a/nbs/apilist.txt +++ b/nbs/apilist.txt @@ -366,6 +366,26 @@ - `def __init__(self, domain, client_id, client_secret, code, scope, redirect_uri, **kwargs)` - `def login_link(self, req)` +- `class EntraAppClient` + A `WebApplicationClient` for Microsoft Entra ID (formerly Azure AD) oauth2 + + - `def __init__(self, client_id, client_secret, tenant_id, code, scope, **kwargs)` + - `def parse_request_body_response(self, body, scope, **kwargs)` + Entra reports granted scopes differently to those requested, so don't check for scope changes + + - `def id_claims(self)` + Claims from the `id_token`, inc `oid` (user id), `tid` (tenant id) and `preferred_username` + + - `def get_info(self, token)` + Graph user info, merged with the `id_token` claims + + - `def get_info_async(self, token)` + Graph user info, merged with the `id_token` claims + + - `def logout_link(self, post_logout_redirect_uri)` + Link to log out of Entra itself, not just this app + + - `class AppleAppClient` A `WebApplicationClient` for Apple Sign In diff --git a/nbs/explains/oauth.ipynb b/nbs/explains/oauth.ipynb index 5baa888d..aab4ddcc 100644 --- a/nbs/explains/oauth.ipynb +++ b/nbs/explains/oauth.ipynb @@ -31,7 +31,7 @@ "id": "ce2e702c", "metadata": {}, "source": [ - "FastHTML has Client classes for managing settings and state for different OAuth providers. Currently implemented are: GoogleAppClient, GitHubAppClient, HuggingFaceClient and DiscordAppClient - see the [source](https://github.com/AnswerDotAI/fasthtml/blob/main/nbs/api/08_oauth.ipynb) if you need to add other providers. You'll need a `client_id` and `client_secret` from the provider (see the from-scratch example later in this page for an example of registering with GitHub) to create the client. We recommend storing these in environment variables, rather than hardcoding them in your code." + "FastHTML has Client classes for managing settings and state for different OAuth providers. Currently implemented are: GoogleAppClient, GitHubAppClient, HuggingFaceClient, DiscordAppClient, Auth0AppClient, EntraAppClient (for Microsoft Entra ID, also exported as AzureAppClient) and AppleAppClient - see the [source](https://github.com/AnswerDotAI/fasthtml/blob/main/nbs/api/08_oauth.ipynb) if you need to add other providers. You'll need a `client_id` and `client_secret` from the provider (see the from-scratch example later in this page for an example of registering with GitHub) to create the client. We recommend storing these in environment variables, rather than hardcoding them in your code." ] }, { @@ -123,6 +123,73 @@ "In our example, we check the email in `info` (we use a GoogleAppClient, not all providers will include an email). If we aren't happy, and get_auth returns False or nothing (as in the case here for non-answerai people) then the user is redirected back to the login page. But if everything looks good we return a redirect to the homepage, and an `auth` key is added to the session and the scope containing the users identity `ident`. So, for example, in the homepage route we could use `auth` to look up this particular user's profile info and customize the page accordingly. This auth will persist in their session until they clear the browser cache, so by default they'll stay logged in. To log them out, remove it ( `session.pop('auth', None)`) or send them to `/logout` which will do that for you. " ] }, + { + "cell_type": "markdown", + "id": "a3f1d7e0", + "metadata": {}, + "source": [ + "## Microsoft Entra ID (Azure AD)\n", + "\n", + "Entra ID takes one argument more than most providers: the tenant. Register your app in the [Azure portal](https://portal.azure.com) under **App registrations → New registration**, then collect:\n", + "\n", + "- **Application (client) ID** → `client_id`\n", + "- **Directory (tenant) ID** → `tenant_id`\n", + "- A **client secret**, created under **Certificates & secrets** → `client_secret`\n", + "\n", + "Your redirect URL (`http://localhost:8000/redirect` for the example above) goes under **Authentication → Web → Redirect URIs**, and has to match the `redir_path` you give `OAuth`.\n", + "\n", + "```python\n", + "import os\n", + "from fasthtml.oauth import EntraAppClient\n", + "\n", + "client = EntraAppClient(os.getenv(\"AUTH_CLIENT_ID\"),\n", + " os.getenv(\"AUTH_CLIENT_SECRET\"),\n", + " os.getenv(\"AUTH_TENANT_ID\"))\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "b8c25e94", + "metadata": {}, + "source": [ + "`tenant_id` decides who is allowed to sign in at all. Your own directory ID means Entra turns away anyone from another directory before they ever reach your app. You can instead pass `'organizations'` to accept any work or school account, or `'common'` to accept personal Microsoft accounts as well — but then it's your app's job to decide which directories to trust.\n", + "\n", + "For Entra, `info` is Microsoft Graph's user info merged with the claims in the `id_token`, so alongside the usual `sub`, `name` and `email` you also get:\n", + "\n", + "- `oid`: the user's immutable ID within the directory. It's a better database key than `email` or `preferred_username`, both of which can change.\n", + "- `tid`: the directory the user signed in from.\n", + "- `preferred_username`: usually the UPN, e.g. `you@yourcompany.com`.\n", + "\n", + "That makes an allowlist check straightforward:\n", + "\n", + "```python\n", + "ALLOWED_TENANTS = {\"11111111-2222-3333-4444-555555555555\"}\n", + "\n", + "class Auth(OAuth):\n", + " def get_auth(self, info, ident, session, state):\n", + " if info.tid in ALLOWED_TENANTS: return RedirectResponse('/', status_code=303)\n", + "```\n", + "\n", + "`ident` is the `sub` claim, which identifies a user uniquely *per app*. If you'd rather key your database on the directory-wide object ID, set `client.id_key = 'oid'`." + ] + }, + { + "cell_type": "markdown", + "id": "d6e04b17", + "metadata": {}, + "source": [ + "One Entra-specific gotcha: `/logout` clears the session in your app, but the user is still signed in to Microsoft, so clicking 'log in' again signs them straight back in without a prompt. To sign them out of Entra as well, send them to `client.logout_link()`:\n", + "\n", + "```python\n", + "class Auth(OAuth):\n", + " def logout(self, session):\n", + " return RedirectResponse(self.cli.logout_link(\"https://myapp.com/login\"), status_code=303)\n", + "```\n", + "\n", + "Any URL you pass there has to be registered as a front-channel logout / post-logout redirect URI in the portal. To send the user to a specific account instead of the last one they used, pass Entra's extra parameters through `login_link`, e.g. `oauth.login_link(req, prompt='select_account')`." + ] + }, { "cell_type": "markdown", "id": "99f30717",