Skip to content

Commit

Permalink
Feat: implement OAuth/OIDC sign on with Authlib (#414)
Browse files Browse the repository at this point in the history
  • Loading branch information
thekaveman authored Apr 15, 2022
2 parents b4d1313 + 0cbe73b commit 93e965b
Show file tree
Hide file tree
Showing 15 changed files with 112 additions and 41 deletions.
9 changes: 7 additions & 2 deletions .devcontainer/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,18 @@ DJANGO_INIT_PATH=fixtures/??_*.json
DJANGO_LOCAL_PORT=8000
DJANGO_LOG_LEVEL=DEBUG
DJANGO_RECAPTCHA_API_URL=https://www.google.com/recaptcha/api.js
DJANGO_RECAPTCHA_SITE_KEY=recaptcha-site-key
DJANGO_RECAPTCHA_SECRET_KEY=recaptcha-secret-key
DJANGO_RECAPTCHA_SITE_KEY=
DJANGO_RECAPTCHA_SECRET_KEY=
DJANGO_RECAPTCHA_VERIFY_URL=https://www.google.com/recaptcha/api/siteverify
DJANGO_SECRET_KEY=secret
DJANGO_RATE_LIMIT=0
DJANGO_RATE_LIMIT_METHODS=GET,POST,PUT,DELETE
DJANGO_RATE_LIMIT_PERIOD=0

DJANGO_OAUTH_AUTHORITY=https://example.com
DJANGO_OAUTH_CLIENT_ID=client-id-123
DJANGO_OAUTH_CLIENT_NAME=client_name
DJANGO_OAUTH_SCOPE=openid

# tests config
CYPRESS_baseUrl=http://client:8000
3 changes: 0 additions & 3 deletions benefits/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,3 @@ class CoreAppConfig(AppConfig):
name = "benefits.core"
label = "core"
verbose_name = "Core"


default_app_config = "benefits.core.CoreAppConfig"
11 changes: 0 additions & 11 deletions benefits/core/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,6 @@ class Migration(migrations.Migration):
name="AuthProvider",
fields=[
("id", models.AutoField(primary_key=True, serialize=False)),
("authority", models.TextField(help_text="Authorization server address")),
("client_id", models.TextField(help_text="The application ID registered with auth server")),
("redirect_uri", models.TextField(help_text="Post-login, the user is redirected here")),
("post_logout_redirect_uri", models.TextField(help_text="Post-logout, the user is redirected here")),
("response_type", models.TextField(help_text='Must be "code" to use the authorization-code flow')),
(
"scope",
models.TextField(
help_text='Space-delimited list. If you need refresh tokens, you must add the "offline access" scope'
),
),
("sign_in_button_label", models.TextField()),
("sign_out_button_label", models.TextField()),
],
Expand Down
8 changes: 0 additions & 8 deletions benefits/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,9 @@ def jwk(self):
class AuthProvider(models.Model):
"""An entity that provides authentication for eligibility verifiers."""

# fmt: off
id = models.AutoField(primary_key=True)
authority = models.TextField(help_text="Authorization server address")
client_id = models.TextField(help_text="The application ID registered with auth server")
redirect_uri = models.TextField(help_text="Post-login, the user is redirected here")
post_logout_redirect_uri = models.TextField(help_text="Post-logout, the user is redirected here")
response_type = models.TextField(help_text="Must be \"code\" to use the authorization-code flow")
scope = models.TextField(help_text="Space-delimited list. If you need refresh tokens, you must add the \"offline access\" scope") # noqa: 503
sign_in_button_label = models.TextField()
sign_out_button_label = models.TextField()
# fmt: on


class EligibilityType(models.Model):
Expand Down
3 changes: 0 additions & 3 deletions benefits/eligibility/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,3 @@ class EligibilityAppConfig(AppConfig):
name = "benefits.eligibility"
label = "eligibility"
verbose_name = "Eligibility Verification"


default_app_config = "benefits.eligibility.EligibilityAppConfig"
8 changes: 6 additions & 2 deletions benefits/eligibility/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from benefits.core import middleware, recaptcha, session, viewmodels
from benefits.core.models import EligibilityVerifier
from benefits.core.views import PageTemplateResponse
from benefits.settings import OAUTH_CLIENT_NAME
from . import analytics, api, forms


Expand Down Expand Up @@ -61,11 +62,14 @@ def start(request):
session.update(request, eligibility_types=[], origin=reverse("eligibility:start"))
verifier = session.verifier(request)

if verifier.requires_authentication:
if verifier.requires_authentication and not session.auth(request):
if OAUTH_CLIENT_NAME is None:
raise Exception("EligibilityVerifier requires authentication, but OAUTH_CLIENT_NAME is None")

auth_provider = verifier.auth_provider
button = viewmodels.Button.external(
text=_(auth_provider.sign_in_button_label),
url="http://calitp.org", # Placeholder URL for now, for UI specs to pass
url=reverse("oauth:login"),
id="login",
)
auth_media = dict(
Expand Down
3 changes: 0 additions & 3 deletions benefits/enrollment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,3 @@ class EnrollmentAppConfig(AppConfig):
name = "benefits.enrollment"
label = "enrollment"
verbose_name = "Benefits Enrollment"


default_app_config = "benefits.enrollment.EnrollmentAppConfig"
10 changes: 10 additions & 0 deletions benefits/oauth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""
The oauth application: Implements OAuth-based authentication
"""
from django.apps import AppConfig


class OAuthAppConfig(AppConfig):
name = "benefits.oauth"
label = "oauth"
verbose_name = "Benefits OAuth"
11 changes: 11 additions & 0 deletions benefits/oauth/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.urls import path

from . import views


app_name = "oauth"
urlpatterns = [
# /oauth
path("login", views.login, name="login"),
path("authorize", views.authorize, name="authorize"),
]
42 changes: 42 additions & 0 deletions benefits/oauth/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from django.shortcuts import redirect
from django.urls import reverse

from authlib.integrations.django_client import OAuth

from benefits.core import session
from benefits.settings import OAUTH_CLIENT_NAME


if OAUTH_CLIENT_NAME:
_oauth = OAuth()
_oauth.register(OAUTH_CLIENT_NAME)
oauth_client = _oauth.create_client(OAUTH_CLIENT_NAME)


ROUTE_AUTH = "oauth:authorize"
ROUTE_START = "eligibility:start"
ROUTE_CONFIRM = "eligibility:confirm"


def login(request):
if not oauth_client:
raise Exception("No OAuth client")

route = reverse(ROUTE_AUTH)
redirect_uri = request.build_absolute_uri(route)

return oauth_client.authorize_redirect(request, redirect_uri)


def authorize(request):
if not oauth_client:
raise Exception("No OAuth client")

token = oauth_client.authorize_access_token(request)

if token is None:
return redirect(ROUTE_START)
else:
# we are intentionally not storing anything about the user, including their token
session.update(request, auth=True)
return redirect(ROUTE_CONFIRM)
24 changes: 23 additions & 1 deletion benefits/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def _filter_empty(ls):
"benefits.core",
"benefits.enrollment",
"benefits.eligibility",
"benefits.oauth",
]

if ADMIN:
Expand Down Expand Up @@ -70,9 +71,17 @@ def _filter_empty(ls):
CSRF_COOKIE_HTTPONLY = True
CSRF_TRUSTED_ORIGINS = _filter_empty(os.environ["DJANGO_TRUSTED_ORIGINS"].split(","))

SESSION_COOKIE_SAMESITE = "Strict"
# With `Strict`, the user loses their Django session between leaving our app to
# sign in with OAuth, and coming back into our app from the OAuth redirect.
# This is because `Strict` disallows our cookie being sent from an external
# domain and so the session cookie is lost.
#
# `Lax` allows the cookie to travel with the user and be sent back to us by the
# OAuth server, as long as the request is "safe" i.e. GET
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_COOKIE_NAME = "_benefitssessionid"

if not DEBUG:
CSRF_COOKIE_SECURE = True
Expand Down Expand Up @@ -148,6 +157,19 @@ def _filter_empty(ls):
]
)

# OAuth configuration

OAUTH_CLIENT_NAME = os.environ.get("DJANGO_OAUTH_CLIENT_NAME")

if OAUTH_CLIENT_NAME:
AUTHLIB_OAUTH_CLIENTS = {
OAUTH_CLIENT_NAME: {
"client_id": os.environ.get("DJANGO_OAUTH_CLIENT_ID"),
"server_metadata_url": f"{os.environ.get('DJANGO_OAUTH_AUTHORITY')}/.well-known/openid-configuration",
"client_kwargs": {"code_challenge_method": "S256", "scope": os.environ.get("DJANGO_OAUTH_SCOPE")},
}
}

# Internationalization

LANGUAGE_CODE = "en"
Expand Down
10 changes: 8 additions & 2 deletions benefits/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from django.urls import include, path

from benefits.settings import ADMIN
from benefits.settings import ADMIN, OAUTH_CLIENT_NAME


logger = logging.getLogger(__name__)
Expand All @@ -28,7 +28,13 @@
if ADMIN:
from django.contrib import admin

logger.debug("Register admin/ urls")
logger.debug("Register admin urls")
urlpatterns.append(path("admin/", admin.site.urls))
else:
logger.debug("Skip url registrations for admin")

if OAUTH_CLIENT_NAME:
logger.info("Register oauth urls")
urlpatterns.append(path("oauth/", include("benefits.oauth.urls")))
else:
logger.debug("Skip url registrations for oauth")
6 changes: 0 additions & 6 deletions fixtures/02_authprovider.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@
"model": "core.authprovider",
"pk": 1,
"fields": {
"authority": "https://example.com",
"client_id": "b2c086d3-2e78-4e1f-8dde-f4b162743048",
"redirect_uri": "https://example.com/redirect",
"post_logout_redirect_uri": "https://example.com/logout",
"response_type": "code",
"scope": "openid",
"sign_in_button_label": "eligibility.buttons.signin",
"sign_out_button_label": "eligibility.buttons.signout"
}
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Authlib==1.0.1
cryptography==36.0.2
Django==3.2.12
django-csp==3.7
Expand Down
4 changes: 4 additions & 0 deletions tests/cypress/.env.tests
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ DJANGO_DB=django
DJANGO_DEBUG=false
DJANGO_INIT_PATH=fixtures/??_*.json
DJANGO_LOG_LEVEL=INFO
DJANGO_OAUTH_AUTHORITY=https://example.com
DJANGO_OAUTH_CLIENT_ID=client-id
DJANGO_OAUTH_CLIENT_NAME=testclient
DJANGO_OAUTH_SCOPE=openid
DJANGO_SECRET_KEY=secret
DJANGO_RATE_LIMIT=5
DJANGO_RATE_LIMIT_METHODS=POST
Expand Down

0 comments on commit 93e965b

Please sign in to comment.