Spaces:
Sleeping
Sleeping
| # OAuth | |
| <!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! --> | |
| OAuth is an open standard for ‘access delegation’, commonly used as a | |
| way for Internet users to grant websites or applications access to their | |
| information on other websites but without giving them the passwords. It | |
| is the mechanism that enables “Log in with Google” on many sites, saving | |
| you from having to remember and manage yet another password. Like many | |
| auth-related topics, there’s a lot of depth and complexity to the OAuth | |
| standard, but once you understand the basic usage it can be a very | |
| convenient alternative to managing your own user accounts. | |
| On this page you’ll see how to use OAuth with FastHTML to implement some | |
| common pieces of functionality. | |
| ## Creating an Client | |
| 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. | |
| ``` python | |
| import os | |
| from fasthtml.oauth import GoogleAppClient | |
| client = GoogleAppClient(os.getenv("AUTH_CLIENT_ID"), | |
| os.getenv("AUTH_CLIENT_SECRET")) | |
| ``` | |
| The client is used to obtain a login link and to manage communications | |
| between your app and the OAuth provider | |
| (`client.login_link(redirect_uri="/redirect")`). | |
| ## Using the OAuth class | |
| Once you’ve set up a client, adding OAuth to a FastHTML app can be as | |
| simple as: | |
| ``` python | |
| from fasthtml.oauth import OAuth | |
| from fasthtml.common import FastHTML, RedirectResponse | |
| class Auth(OAuth): | |
| def get_auth(self, info, ident, session, state): | |
| email = info.email or '' | |
| if info.email_verified and email.split('@')[-1]=='answer.ai': | |
| return RedirectResponse('/', status_code=303) | |
| app = FastHTML() | |
| oauth = Auth(app, client) | |
| @app.get('/') | |
| def home(auth): return P('Logged in!'), A('Log out', href='/logout') | |
| @app.get('/login') | |
| def login(req): return Div(P("Not logged in"), A('Log in', href=oauth.login_link(req))) | |
| ``` | |
| There’s a fair bit going on here, so let’s unpack what’s happening in | |
| that code: | |
| - OAuth (and by extension our custom Auth class) has a number of default | |
| arguments, including some key URLs: | |
| `redir_path='/redirect', error_path='/error', logout_path='/logout', login_path='/login'`. | |
| It will create and handle the redirect and logout paths, and it’s up | |
| to you to handle `/login` (where unsuccessful login attempts will be | |
| redirected) and `/error` (for oauth errors). | |
| - When we run `oauth = Auth(app, client)` it adds the redirect and | |
| logout paths to the app and also adds some beforeware. This beforeware | |
| runs on any requests (apart from any specified with the `skip` | |
| parameter). | |
| The added beforeware specifies some app behaviour: | |
| - If someone who isn’t logged in attempts to visit our homepage (`/`) | |
| here, they will be redirected to `/login`. | |
| - If they are logged in, it calls a `check_invalid` method. This | |
| defaults to False, which let’s the user continue to the page they | |
| requested. The behaviour can be modified by defining your own | |
| `check_invalid` method in the Auth class - for example, you could have | |
| this forcibly log out users who have recently been banned. | |
| So how does someone log in? If they visit (or are redirected to) the | |
| login page at `/login`, we show them a login link. This sends them to | |
| the OAuth provider, where they’ll go through the steps of selecting | |
| their account, giving permissions etc. Once done they will be redirected | |
| back to `/redirect`. Behind the scenes a code that comes as part of | |
| their request gets turned into user info, which is then passed to the | |
| key function `get_auth(self, info, ident, session, state)`. Here is | |
| where you’d handle looking up or adding a user in a database, checking | |
| for some condition (for example, this code checks if the email is an | |
| answer.ai email address) or choosing the destination based on state. The | |
| arguments are: | |
| - `self`: the Auth object, which you can use to access the client | |
| (`self.cli`) | |
| - `info`: the information provided by the OAuth provider, typically | |
| including a unique user id, email address, username and other | |
| metadata. | |
| - `ident`: a unique identifier for this user. What this looks like | |
| varies between providers. This is useful for managing a database of | |
| users, for example. | |
| - `session`: the current session, that you can store information in | |
| securely | |
| - `state`: you can optionally pass in some state when creating the login | |
| link. This persists and is returned after the user goes through the | |
| Oath steps, which is useful for returning them to the same page they | |
| left. It can also be used as added security against CSRF attacks. | |
| 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. | |
| ## Explaining OAuth with a from-scratch implementation | |
| Hopefully the example above is enough to get you started. You can also | |
| check out the (fairly minimal) [source | |
| code](https://github.com/AnswerDotAI/fasthtml/blob/main/nbs/api/08_oauth.ipynb) | |
| where this is implemented, and the [examples | |
| here](https://github.com/AnswerDotAI/fasthtml-example/blob/main/oauth_example). | |
| If you’re wanting to learn more about how this works, and to see where | |
| you might add additional functionality, the rest of this page will walk | |
| through some examples **without** the OAuth convenience class, to | |
| illustrate the concepts. This was written before said OAuth class was | |
| available, and is kept here for educational purposes - we recommend you | |
| stick with the new approach shown above in most cases. | |
| ## A Minimal Login Flow (GitHub) | |
| Let’s begin by building a minimal ‘Sign in with GitHub’ flow. This will | |
| demonstrate the basic steps of OAuth. | |
| OAuth requires a “provider” (in this case, GitHub) to authenticate the | |
| user. So the first step when setting up our app is to register with | |
| GitHub to set things up. | |
| Go to https://github.com/settings/developers and click “New OAuth App”. | |
| Fill in the form with the following values, then click ‘Register | |
| application’. | |
| - Application name: Your app name | |
| - Homepage URL: http://localhost:8000 (or whatever URL you’re using - | |
| you can change this later) | |
| - Authorization callback URL: http://localhost:8000/auth_redirect (you | |
| can modify this later too) | |
| <div style="text-align:center;margin:50px 0 45px;"> | |
| <img src="imgs/gh-oauth.png" alt="Setting up an OAuth app in GitHub" width="500" /> | |
| </div> | |
| After you register, you’ll see a screen where you can view the client ID | |
| and generate a client secret. Store these values in a safe place. You’ll | |
| use them to create a | |
| [`GitHubAppClient`](https://www.fastht.ml/docs/api/oauth.html#githubappclient) | |
| object in FastHTML. | |
| This `client` object is responsible for handling the parts of the OAuth | |
| flow which depend on direct communication between your app and GitHub, | |
| as opposed to interactions which go through the user’s browser via | |
| redirects. | |
| Here is how to setup the client object: | |
| ``` python | |
| client = GitHubAppClient( | |
| client_id="your_client_id", | |
| client_secret="your_client_secret" | |
| ) | |
| ``` | |
| You should also save the path component of the authorization callback | |
| URL which you provided on registration. | |
| This route is where GitHub will redirect the user’s browser in order to | |
| send an authorization code to your app. You should save only the URL’s | |
| path component rather than the entire URL because you want your code to | |
| work automatically in deployment, when the host and port part of the URL | |
| change from `localhost:8000` to your real DNS name. | |
| Save the special authorization callback path under an obvious name: | |
| ``` python | |
| auth_callback_path = "/auth_redirect" | |
| ``` | |
| <div> | |
| > **Note** | |
| > | |
| > It’s recommended to store the client ID, and secret, in environment | |
| > variables, rather than hardcoding them in your code. | |
| </div> | |
| When the user visit a normal page of your app, if they are not already | |
| logged in, then you’ll want to redirect them to your app’s login page, | |
| which will live at the `/login` path. We accomplish that by using this | |
| piece of “beforeware”, which defines logic which runs before other work | |
| for all routes except ones we specify to be skipped: | |
| ``` python | |
| def before(req, session): | |
| auth = req.scope['auth'] = session.get('user_id', None) | |
| if not auth: return RedirectResponse('/login', status_code=303) | |
| counts.xtra(name=auth) | |
| bware = Beforeware(before, skip=['/login', auth_callback_path]) | |
| ``` | |
| We configure the beforeware to skip `/login` because that’s where the | |
| user goes to login, and we also skip the special authorization callback | |
| path because that is used by OAuth itself to receive information from | |
| GitHub. | |
| It’s only at your login page that we start the OAuth flow. To start the | |
| OAuth flow, you need to give the user a link to GitHub’s login for your | |
| app. You’ll need the `client` object to generate that link, and the | |
| client object will in turn need the full authorization callback URL, | |
| which we need to build from the authorization callback path, so it is a | |
| multi-step process to produce this GitHub login link. | |
| Here is an implementation of your own `/login` route handler. It | |
| generates the GitHub login link and presents it to the user: | |
| ``` python | |
| @app.get('/login') | |
| def login(request) | |
| redir = redir_url(request,auth_callback_path) | |
| login_link = client.login_link(redir) | |
| return P(A('Login with GitHub', href=login_link)) | |
| ``` | |
| Once the user follows that link, GitHub will ask them to grant | |
| permission to your app to access their GitHub account. If they agree, | |
| GitHub will redirect them back to your app’s authorization callback URL, | |
| carrying an authorization code which your app can use to generate an | |
| access token. To receive this code, you need to set up a route in | |
| FastHTML that listens for requests at the authorization callback path. | |
| For example: | |
| ``` python | |
| @app.get(auth_callback_path) | |
| def auth_redirect(code:str): | |
| return P(f"code: {code}") | |
| ``` | |
| This authorization code is temporary, and is used by your app to | |
| directly ask the provider for user information like an access token. | |
| To recap, you can think of the exchange so far as: | |
| - User to us: “I want to log in with you, app.” | |
| - Us to User: “Okay but first, here’s a special link to log in with | |
| GitHub” | |
| - User to GitHub: “I want to log in with you, GitHub, to use this app.” | |
| - GitHub to User: “OK, redirecting you back to the app’s URL (with an | |
| auth code)” | |
| - User to Us: “Hi again, app. Here’s the GitHub auth code you need to | |
| ask GitHub for info about me” (delivered via | |
| `/auth_redirect?code=...`) | |
| The final steps we need to implement are as follows: | |
| - Us to GitHUb: “A user just gave me this auth code. May I have the user | |
| info (e.g., an access token)?” | |
| - GitHub to us: “Since you have an auth code, here’s the user info” | |
| It’s critical for us to derive the user info from the auth code | |
| immediately in the authorization callback, because the auth code may be | |
| used only once. So we use it that once in order to get information like | |
| an access token, which will remain valid for longer. | |
| To go from the auth code to user info, you use | |
| `info = client.retr_info(code,redirect_uri)`. From the user info, you | |
| can extract the `user_id`, which is a unique identifier for the user: | |
| ``` python | |
| @app.get(auth_callback_path) | |
| def auth_redirect(code:str, request): | |
| redir = redir_url(request, auth_callback_path) | |
| user_info = client.retr_info(code, redir) | |
| user_id = info[client.id_key] | |
| return P(f"User id: {user_id}") | |
| ``` | |
| But we want the user ID not to print it but to remember the user. | |
| So let us store it in the `session` object, to remember who is logged | |
| in: | |
| ``` python | |
| @app.get(auth_callback_path) | |
| def auth_redirect(code:str, request, session): | |
| redir = redir_url(request, auth_callback_path) | |
| user_info = client.retr_info(code, redir) | |
| user_id = user_info[client.id_key] # get their ID | |
| session['user_id'] = user_id # save ID in the session | |
| return RedirectResponse('/', status_code=303) | |
| ``` | |
| The session object is derived from values visible to the user’s browser, | |
| but it is cryptographically signed so the user can’t read it themselves. | |
| This makes it safe to store even information we don’t want to expose to | |
| the user. | |
| For larger quantities of data, we’d want to save that information in a | |
| database and use the session to hold keys to lookup information from | |
| that database. | |
| Here’s a minimal app that puts all these pieces together. It uses the | |
| user info to get the user_id. It stores that in the session object. It | |
| then uses the user_id as a key into a database, which tracks how | |
| frequently every user has hit an increment button. | |
| ``` python | |
| import os | |
| from fasthtml.common import * | |
| from fasthtml.oauth import GitHubAppClient, redir_url | |
| db = database('data/counts.db') | |
| counts = db.t.counts | |
| if counts not in db.t: counts.create(dict(name=str, count=int), pk='name') | |
| Count = counts.dataclass() | |
| # Auth client setup for GitHub | |
| client = GitHubAppClient(os.getenv("AUTH_CLIENT_ID"), | |
| os.getenv("AUTH_CLIENT_SECRET")) | |
| auth_callback_path = "/auth_redirect" | |
| def before(req, session): | |
| # if not logged in, we send them to our login page | |
| # logged in means: | |
| # - 'user_id' in the session object, | |
| # - 'auth' in the request object | |
| auth = req.scope['auth'] = session.get('user_id', None) | |
| if not auth: return RedirectResponse('/login', status_code=303) | |
| counts.xtra(name=auth) | |
| bware = Beforeware(before, skip=['/login', auth_callback_path]) | |
| app = FastHTML(before=bware) | |
| # User asks us to Login | |
| @app.get('/login') | |
| def login(request): | |
| redir = redir_url(request,auth_callback_path) | |
| login_link = client.login_link(redir) | |
| # we tell user to login at github | |
| return P(A('Login with GitHub', href=login_link)) | |
| # User comes back to us with an auth code from Github | |
| @app.get(auth_callback_path) | |
| def auth_redirect(code:str, request, session): | |
| redir = redir_url(request, auth_callback_path) | |
| user_info = client.retr_info(code, redir) | |
| user_id = user_info[client.id_key] # get their ID | |
| session['user_id'] = user_id # save ID in the session | |
| # create a db entry for the user | |
| if user_id not in counts: counts.insert(name=user_id, count=0) | |
| return RedirectResponse('/', status_code=303) | |
| @app.get('/') | |
| def home(auth): | |
| return Div( | |
| P("Count demo"), | |
| P(f"Count: ", Span(counts[auth].count, id='count')), | |
| Button('Increment', hx_get='/increment', hx_target='#count'), | |
| P(A('Logout', href='/logout')) | |
| ) | |
| @app.get('/increment') | |
| def increment(auth): | |
| c = counts[auth] | |
| c.count += 1 | |
| return counts.upsert(c).count | |
| @app.get('/logout') | |
| def logout(session): | |
| session.pop('user_id', None) | |
| return RedirectResponse('/login', status_code=303) | |
| serve() | |
| ``` | |
| Some things to note: | |
| - The [`before`](https://www.fastht.ml/docs/explains/stripe.html#before) | |
| function is used to check if the user is authenticated. If not, they | |
| are redirected to the login page. | |
| - To log the user out, we remove the user ID from the session. | |
| - Calling `counts.xtra(name=auth)` ensures that only the row | |
| corresponding to the current user is accessible when responding to a | |
| request. This is often nicer than trying to remember to filter the | |
| data in every route, and lowers the risk of accidentally leaking data. | |
| - In the `auth_redirect` route, we store the user ID in the session and | |
| create a new row in the `user_counts` table if it doesn’t already | |
| exist. | |
| You can find more heavily-commented version of this code in the [oauth | |
| directory in | |
| fasthtml-example](https://github.com/AnswerDotAI/fasthtml-example/tree/main/oauth_example), | |
| along with an even more minimal example. More examples may be added in | |
| the future. | |
| ### Revoking Tokens (Google) | |
| When the user in the example above logs out, we remove their user ID | |
| from the session. However, the user is still logged in to GitHub. If | |
| they click ‘Login with GitHub’ again, they’ll be redirected back to our | |
| site without having to log in again. This is because GitHub remembers | |
| that they’ve already granted our app permission to access their account. | |
| Most of the time this is convenient, but for testing or security | |
| purposes you may want a way to revoke this permission. | |
| As a user, you can usually revoke access to an app from the provider’s | |
| website (for example, <https://github.com/settings/applications>). But | |
| as a developer, you can also revoke access programmatically - at least | |
| with some providers. This requires keeping track of the access token | |
| (stored in `client.token["access_token"]` after you call `retr_info`), | |
| and sending a request to the provider’s revoke URL: | |
| ``` python | |
| auth_revoke_url = "https://accounts.google.com/o/oauth2/revoke" | |
| def revoke_token(token): | |
| response = requests.post(auth_revoke_url, params={"token": token}) | |
| return response.status_code == 200 # True if successful | |
| ``` | |
| Not all providers support token revocation, and it is not built into | |
| FastHTML clients at the moment. | |
| ### Using State (Hugging Face) | |
| Imagine a user (not logged in) comes to your AI image editing site, | |
| starts testing things out, and then realizes they need to sign in before | |
| they can click “Run (Pro)” on the edit they’re working on. They click | |
| “Sign in with Hugging Face”, log in, and are redirected back to your | |
| site. But now they’ve lost their in-progress edit and are left just | |
| looking at the homepage! This is an example of a case where you might | |
| want to keep track of some additional state. Another strong use case for | |
| being able to pass some uniqie state through the OAuth flow is to | |
| prevent something called a [CSRF | |
| attack](https://en.wikipedia.org/wiki/Cross-site_request_forgery). To | |
| add a state string to the OAuth flow, include a `state` argument when | |
| creating the login link: | |
| ``` python | |
| # in login page: | |
| link = A('Login with GitHub', href=client.login_link(state='current_prompt: add a unicorn')) | |
| # in auth_redirect: | |
| @app.get('/auth_redirect') | |
| def auth_redirect(code:str, session, state:str=None): | |
| print(f"state: {state}") # Use as needed | |
| ... | |
| ``` | |
| The state string is passed through the OAuth flow and back to your site. | |
| ### A Work in Progress | |
| This page (and OAuth support in FastHTML) is a work in progress. | |
| Questions, PRs, and feedback are welcome! | |