plain.oauth
Let users log in with OAuth providers.
This library is intentionally minimal. It has no dependencies and a single database model. If you simply want users to log in with GitHub, Google, Twitter, etc. (and maybe use that access token for API calls), then this is the library for you.
There are three OAuth flows that it makes possible:
- Signup via OAuth (new user, new OAuth connection)
- Login via OAuth (existing user, existing OAuth connection)
- Connect/disconnect OAuth accounts to a user (existing user, new OAuth connection)
Usage
Install the package from PyPi:
pip install plain-oauth
Add plain.oauth
to your INSTALLED_PACKAGES
in settings.py
:
INSTALLED_PACKAGES = [
...
"plain.oauth",
]
In your urls.py
, include plain.oauth.urls
:
urlpatterns = [
path("oauth/", include("plain.oauth.urls")),
...
]
Then run migrations:
python manage.py migrate plain.oauth
Create a new OAuth provider (or copy one from our examples):
# yourapp/oauth.py
import requests
from plain.oauth.providers import OAuthProvider, OAuthToken, OAuthUser
class ExampleOAuthProvider(OAuthProvider):
authorization_url = "https://example.com/login/oauth/authorize"
def get_oauth_token(self, *, code, request):
response = requests.post(
"https://example.com/login/oauth/token",
headers={
"Accept": "application/json",
},
data={
"client_id": self.get_client_id(),
"client_secret": self.get_client_secret(),
"code": code,
},
)
response.raise_for_status()
data = response.json()
return OAuthToken(
access_token=data["access_token"],
)
def get_oauth_user(self, *, oauth_token):
response = requests.get(
"https://example.com/api/user",
headers={
"Accept": "application/json",
"Authorization": f"token {oauth_token.access_token}",
},
)
response.raise_for_status()
data = response.json()
return OAuthUser(
# The provider ID is required
id=data["id"],
# And you can populate any of your User model fields with additional kwargs
email=data["email"],
username=data["username"],
)
Create your OAuth app/consumer on the provider's site (GitHub, Google, etc.).
When setting it up, you'll likely need to give it a callback URL.
In development this can be http://localhost:8000/oauth/github/callback/
(if you name it "github"
like in the example below).
At the end you should get some sort of "client id" and "client secret" which you can then use in your settings.py
:
OAUTH_LOGIN_PROVIDERS = {
"github": {
"class": "yourapp.oauth.GitHubOAuthProvider",
"kwargs": {
"client_id": environ["GITHUB_CLIENT_ID"],
"client_secret": environ["GITHUB_CLIENT_SECRET"],
# "scope" is optional, defaults to ""
# You can add other fields if you have additional kwargs in your class __init__
# def __init__(self, *args, custom_arg="default", **kwargs):
# self.custom_arg = custom_arg
# super().__init__(*args, **kwargs)
},
},
}
Then add a login button (which is a form using POST rather than a basic link, for security purposes):
<h1>Login</h1>
<form action="{% url 'oauth:login' 'github' %}" method="post">
{{ csrf_input }}
<button type="submit">Login with GitHub</button>
</form>
Depending on your URL and provider names,
your OAuth callback will be something like https://example.com/oauth/{provider}/callback/
.
That's pretty much it!
Advanced usage
Handling OAuth errors
The most common error you'll run into is if an existing user clicks a login button, but they haven't yet connected that provider to their account. For security reasons, the required flow here is that the user actually logs in with another method (however they signed up) and then connects the OAuth provider from a settings page.
For this error (and a couple others),
there is an error template that is rendered.
You can customize this by copying oauth/error.html
to one of your own template directories:
{% extends "base.html" %}
{% block content %}
<h1>OAuth Error</h1>
<p>{{ oauth_error }}</p>
{% endblock %}
Connecting and disconnecting OAuth accounts
To connect and disconnect OAuth accounts, you can add a series of forms to a user/profile settings page. Here's an very basic example:
{% extends "base.html" %}
{% block content %}
Hello {{ request.user }}!
<h2>Existing connections</h2>
<ul>
{% for connection in request.user.oauth_connections.all %}
<li>
{{ connection.provider_key }} [ID: {{ connection.provider_user_id }}]
<form action="{% url 'oauth:disconnect' connection.provider_key %}" method="post">
{{ csrf_input }}
<input type="hidden" name="provider_user_id" value="{{ connection.provider_user_id }}">
<button type="submit">Disconnect</button>
</form>
</li>
{% endfor %}
</ul>
<h2>Add a connection</h2>
<ul>
{% for provider_key in oauth_provider_keys %}
<li>
{{ provider_key}}
<form action="{% url 'oauth:connect' provider_key %}" method="post">
{{ csrf_input }}
<button type="submit">Connect</button>
</form>
</li>
{% endfor %}
</ul>
{% endblock %}
The get_provider_keys
function can help populate the list of options:
from plain.oauth.providers import get_provider_keys
class ExampleView(TemplateView):
template_name = "index.html"
def get_context(self, **kwargs):
context = super().get_context(**kwargs)
context["oauth_provider_keys"] = get_provider_keys()
return context
Using a saved access token
import requests
# Get the OAuth connection for a user
connection = user.oauth_connections.get(provider_key="github")
# If the token can expire, check and refresh it
if connection.access_token_expired():
connection.refresh_access_token()
# Use the token in an API call
token = connection.access_token
response = requests.get(...)
Using the Django system check
This library comes with a Django system check to ensure you don't remove a provider from settings.py
that is still in use in your database.
You do need to specify the --database
for this to run when using the check command by itself:
python manage.py check --database default
FAQs
How is this different from Django OAuth libraries?
The short answer is that it does less.
In django-allauth
(maybe the most popular alternative)
you get all kinds of other features like managing multiple email addresses,
email verification,
a long list of supported providers,
and a whole suite of forms/urls/views/templates/signals/tags.
And in my experience,
it's too much.
It often adds more complexity to your app than you actually need (or want) and honestly it can just be a lot to wrap your head around.
Personally, I don't like the way that your OAuth settings are stored in the database vs when you use settings.py
,
and the implications for doing it one way or another.
The other popular OAuth libraries have similar issues, and I think their weight outweighs their usefulness for 80% of the use cases.
Why aren't providers included in the library itself?
One thing you'll notice is that we don't have a long list of pre-configured providers in this library. Instead, we have some examples (which you can usually just copy, paste, and use) and otherwise encourage you to wire up the provider yourself. Often times all this means is finding the two OAuth URLs ("oauth/authorize" and "oauth/token") in their docs, and writing two class methods that do the actual work of getting the user's data (which is often customized anyway).
We've written examples for the following providers:
Just copy that code and paste it in your project. Tweak as necessary!
This might sound strange at first. But in the long run we think it's actually much more maintainable for both us (as library authors) and you (as app author). If something breaks with a provider, you can fix it immediately! You don't need to try to run changes through us or wait for an upstream update. You're welcome to contribute an example to this repo, and there won't be an expectation that it "works perfectly for every use case until the end of time".
Redirect/callback URL mismatch in local development?
If you're doing local development through a proxy/tunnel like ngrok,
then the callback URL might be automatically built as http
instead of https
.
This is the Django setting you're probably looking for:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
1from typing import TYPE_CHECKING
2
3from plain import models
4from plain.auth import get_user_model
5from plain.exceptions import ValidationError
6from plain.models import transaction
7from plain.models.db import IntegrityError, OperationalError, ProgrammingError
8from plain.preflight import Error
9from plain.runtime import settings
10from plain.utils import timezone
11
12from .exceptions import OAuthUserAlreadyExistsError
13
14if TYPE_CHECKING:
15 from .providers import OAuthToken, OAuthUser
16
17
18# TODO preflight check for deploy that ensures all provider keys in db are also in settings?
19
20
21class OAuthConnection(models.Model):
22 created_at = models.DateTimeField(auto_now_add=True)
23 updated_at = models.DateTimeField(auto_now=True)
24
25 user = models.ForeignKey(
26 settings.AUTH_USER_MODEL,
27 on_delete=models.CASCADE,
28 related_name="oauth_connections",
29 )
30
31 # The key used to refer to this provider type (in settings)
32 provider_key = models.CharField(max_length=100, db_index=True)
33
34 # The unique ID of the user on the provider's system
35 provider_user_id = models.CharField(max_length=100, db_index=True)
36
37 # Token data
38 access_token = models.CharField(max_length=2000)
39 refresh_token = models.CharField(max_length=2000, blank=True)
40 access_token_expires_at = models.DateTimeField(blank=True, null=True)
41 refresh_token_expires_at = models.DateTimeField(blank=True, null=True)
42
43 class Meta:
44 constraints = [
45 models.UniqueConstraint(
46 fields=["provider_key", "provider_user_id"],
47 name="unique_oauth_provider_user_id",
48 )
49 ]
50 ordering = ("provider_key",)
51
52 def __str__(self):
53 return f"{self.provider_key}[{self.user}:{self.provider_user_id}]"
54
55 def refresh_access_token(self) -> None:
56 from .providers import OAuthToken, get_oauth_provider_instance
57
58 provider_instance = get_oauth_provider_instance(provider_key=self.provider_key)
59 oauth_token = OAuthToken(
60 access_token=self.access_token,
61 refresh_token=self.refresh_token,
62 access_token_expires_at=self.access_token_expires_at,
63 refresh_token_expires_at=self.refresh_token_expires_at,
64 )
65 refreshed_oauth_token = provider_instance.refresh_oauth_token(
66 oauth_token=oauth_token
67 )
68 self.set_token_fields(refreshed_oauth_token)
69 self.save()
70
71 def set_token_fields(self, oauth_token: "OAuthToken"):
72 self.access_token = oauth_token.access_token
73 self.refresh_token = oauth_token.refresh_token
74 self.access_token_expires_at = oauth_token.access_token_expires_at
75 self.refresh_token_expires_at = oauth_token.refresh_token_expires_at
76
77 def set_user_fields(self, oauth_user: "OAuthUser"):
78 self.provider_user_id = oauth_user.id
79
80 def access_token_expired(self) -> bool:
81 return (
82 self.access_token_expires_at is not None
83 and self.access_token_expires_at < timezone.now()
84 )
85
86 def refresh_token_expired(self) -> bool:
87 return (
88 self.refresh_token_expires_at is not None
89 and self.refresh_token_expires_at < timezone.now()
90 )
91
92 @classmethod
93 def get_or_create_user(
94 cls, *, provider_key: str, oauth_token: "OAuthToken", oauth_user: "OAuthUser"
95 ) -> "OAuthConnection":
96 try:
97 connection = cls.objects.get(
98 provider_key=provider_key,
99 provider_user_id=oauth_user.id,
100 )
101 connection.set_token_fields(oauth_token)
102 connection.save()
103 return connection
104 except cls.DoesNotExist:
105 with transaction.atomic():
106 # If email needs to be unique, then we expect
107 # that to be taken care of on the user model itself
108 try:
109 user = get_user_model()(
110 **oauth_user.user_model_fields,
111 )
112 user.save()
113 except (IntegrityError, ValidationError):
114 raise OAuthUserAlreadyExistsError()
115
116 return cls.connect(
117 user=user,
118 provider_key=provider_key,
119 oauth_token=oauth_token,
120 oauth_user=oauth_user,
121 )
122
123 @classmethod
124 def connect(
125 cls,
126 *,
127 user: settings.AUTH_USER_MODEL,
128 provider_key: str,
129 oauth_token: "OAuthToken",
130 oauth_user: "OAuthUser",
131 ) -> "OAuthConnection":
132 """
133 Connect will either create a new connection or update an existing connection
134 """
135 try:
136 connection = cls.objects.get(
137 user=user,
138 provider_key=provider_key,
139 provider_user_id=oauth_user.id,
140 )
141 except cls.DoesNotExist:
142 # Create our own instance (not using get_or_create)
143 # so that any created signals contain the token fields too
144 connection = cls(
145 user=user,
146 provider_key=provider_key,
147 provider_user_id=oauth_user.id,
148 )
149
150 connection.set_user_fields(oauth_user)
151 connection.set_token_fields(oauth_token)
152 connection.save()
153
154 return connection
155
156 @classmethod
157 def check(cls, **kwargs):
158 """
159 A system check for ensuring that provider_keys in the database are also present in settings.
160
161 Note that the --database flag is required for this to work:
162 python manage.py check --database default
163 """
164 errors = super().check(**kwargs)
165
166 databases = kwargs.get("databases", None)
167 if not databases:
168 return errors
169
170 from .providers import get_provider_keys
171
172 for database in databases:
173 try:
174 keys_in_db = set(
175 cls.objects.using(database)
176 .values_list("provider_key", flat=True)
177 .distinct()
178 )
179 except (OperationalError, ProgrammingError):
180 # Check runs on manage.py migrate, and the table may not exist yet
181 # or it may not be installed on the particular database intentionally
182 continue
183
184 keys_in_settings = set(get_provider_keys())
185
186 if keys_in_db - keys_in_settings:
187 errors.append(
188 Error(
189 "The following OAuth providers are in the database but not in the settings: {}".format(
190 ", ".join(keys_in_db - keys_in_settings)
191 ),
192 id="plain.oauth.E001",
193 )
194 )
195
196 return errors