Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/django.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ jobs:
envdir .envdir django-admin serviceuser create -u smoke-user --username smoke-svc --password smoke123
envdir .envdir django-admin emailalias blacklist smoke-user-temp@smoke.example.com
envdir .envdir django-admin emailalias unblacklist smoke-user-temp@smoke.example.com
DCR_TOKEN="$(envdir .envdir django-admin dcrtoken create --expires-days 1)"
envdir .envdir django-admin dcrtoken list
envdir .envdir django-admin dcrtoken revoke "${DCR_TOKEN:0:12}"
envdir .envdir django-admin dockerauth registry create --client-id smoke-reg --name smoke-reg --domain smoke.example.com
envdir .envdir django-admin dockerauth registry show --name smoke-reg
envdir .envdir django-admin dockerauth registry remove --name smoke-reg -y
Expand Down
21 changes: 14 additions & 7 deletions authserver/authserver/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,22 @@
# This is here because django-oauth-toolkit checks for it in oauth2_provider.models, but we provide the keys
# from the Domain object associated with the JWT
'OIDC_RSA_PRIVATE_KEY': '<unused>',
# RFC 9207: we emit the `iss` authorization-response parameter ourselves
# (see mailauth/oauth2_backends.py) because upstream's issuer derivation
# cannot produce our per-host 'https://<host>/o2' issuers. Pinned False so
# the django-oauth-toolkit 4.0 default flip cannot turn on the upstream
# emission (which would produce a mismatching 'https://<host>' issuer and
# break RFC 9207-validating clients). Do not remove without reading
# .agent-docs/dcr-cimd-authz-iss/implementation-plan.rst section 5.
# RFC 9207: the `iss` authorization-response parameter is emitted by our own
# OAUTH2_BACKEND_CLASS below, on success and error redirects alike, using the
# per-host 'https://<host>/o2' issuer. Turning this gate on would only add a
# second, redundant emission on the success path: upstream derives the issuer
# from reverse('oauth2_provider:oauth-server-metadata') and falls back to
# 'https://<host>' when that route is missing, where our own derivation raises
# instead of quietly emitting an issuer nobody publishes. That makes the
# `check --deploy` warning oauth2_provider.W005 a false positive here.
'COMPLIANT_BCP_RFC9700_AUTHZ_RESPONSE_ISS': False,
'OAUTH2_BACKEND_CLASS': 'mailauth.oauth2_backends.MNOAuthLibCore',
'DCR_ENABLED': True,
# registration requires an operator-minted initial access token,
# see mailauth/dcr.py and the `dcrtoken` management command
'DCR_REGISTRATION_PERMISSION_CLASSES': (
'mailauth.dcr.InitialAccessTokenDCRPermission',
),
}

# we use our own modular crypt format sha256 hasher for maximum compatibility
Expand Down
46 changes: 35 additions & 11 deletions authserver/authserver/urls.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
from django.urls import path, re_path, include
from django.urls import path, re_path, include, reverse_lazy
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.views.generic.base import RedirectView

from oauth2_provider import views as oauth2_views
from authserver import base_views
from authserver import selfservice_views
from mailauth import views as mail_views
from mailauth.dcr import MNDynamicClientRegistrationManagementView, MNDynamicClientRegistrationView
from dockerauth import views as docker_views
from authserver import views as shared_views


# the path component under which the OAuth2/OpenID Connect endpoints are mounted. authserver's
# issuer is "https://<host>/" + OAUTH2_MOUNT, so this also decides where the RFC 8414 metadata
# documents live and what their "issuer" says.
OAUTH2_MOUNT = 'o2'


oauth2_patterns = ([
# manually assign oauth2 views instead of importing them since we override the authorize view
re_path(r'^authorize/$', mail_views.ScopeValidationAuthView.as_view(), name='authorize'),
Expand All @@ -19,23 +27,39 @@
re_path(r'^userinfo/$', oauth2_views.UserInfoView.as_view(), name='user-info'),
re_path(r'^\.well-known/openid-configuration/?$', mail_views.MNConnectDiscoveryInfoView.as_view(),
name='oidc-connect-discovery-info'),
re_path(r"^\.well-known/jwks.json$", mail_views.JwksInfoView.as_view(), name="jwks-info")
re_path(r"^\.well-known/jwks.json$", mail_views.JwksInfoView.as_view(), name="jwks-info"),
# RFC 8414 metadata in the issuer-suffix form (<issuer>/.well-known/oauth-authorization-server),
# the way OpenID Connect Discovery builds its URL. Identical document to the path-insertion
# form mounted at the URL root below. Registering it under this name inside the
# oauth2_provider namespace also makes django-oauth-toolkit's own issuer helper
# (oauth2_settings.oauth2_authorization_server_issuer) resolve to "https://<host>/o2".
re_path(r'^\.well-known/oauth-authorization-server/?$',
mail_views.MNOAuthServerMetadataView.as_view(), name='oauth-server-metadata'),
path('register/', MNDynamicClientRegistrationView.as_view(), name='dcr-register'),
path('register/<str:client_id>/',
MNDynamicClientRegistrationManagementView.as_view(),
name='dcr-register-management'),
], 'oauth2_provider')


urlpatterns = [
re_path(r'^health/$', base_views.health),
re_path(r'^robots\.txt$', base_views.robots_txt),
re_path(r'^\.well-known/webfinger/?$', mail_views.WebFingerView.as_view(), name='oidc-webfinger'),
# RFC 8414 OAuth2 Authorization Server Metadata (new in django-oauth-toolkit 3.4.0). RFC 8414
# requires this document at the server root, not under the o2/ prefix. The path-component form
# is the authoritative one for our issuer "https://<host>/o2": clients must fetch
# /.well-known/oauth-authorization-server/o2 for it. The plain form (issuer "https://<host>")
# is served as well for clients that don't implement RFC 8414 issuer path handling.
re_path(r'^\.well-known/oauth-authorization-server/?$',
mail_views.MNOAuthServerMetadataView.as_view(), name='oauth-server-metadata'),
path('.well-known/oauth-authorization-server/<path:issuer_path>',
# RFC 8414 OAuth2 Authorization Server Metadata (new in django-oauth-toolkit 3.4.0) in the
# path-insertion form required by RFC 8414 section 3.1, which is the authoritative location
# for our issuer "https://<host>/o2". Only that issuer is served: a document under any other
# path would have to name an issuer this server never puts in `iss` or an ID token.
path('.well-known/oauth-authorization-server/%s' % OAUTH2_MOUNT,
mail_views.MNOAuthServerMetadataView.as_view(), name='oauth-server-metadata-issuer'),
# A request to the bare well-known URL asks about the issuer "https://<host>", which this
# server does not have. Send those clients - the ones that don't implement RFC 8414 issuer
# paths - to the real document instead of answering with a made-up issuer.
re_path(r'^\.well-known/oauth-authorization-server/?$',
RedirectView.as_view(
url=reverse_lazy('oauth-server-metadata-issuer'), permanent=False
),
name='oauth-server-metadata-root'),
re_path(r'^$', selfservice_views.HomeView.as_view(), name='selfservice-home'),
re_path(r'^dashboard/$', selfservice_views.DashboardView.as_view(), name='selfservice-dashboard'),
re_path(r'^action/login/$', selfservice_views.SelfServiceLoginView.as_view(), name='authserver-login'),
Expand All @@ -50,7 +74,7 @@
re_path(r'^admin/', admin.site.urls),

# Oauth2 and OpenIDC
path('o2/', include(oauth2_patterns)),
path('%s/' % OAUTH2_MOUNT, include(oauth2_patterns)),

# Docker auth
re_path(r'^docker/token/$', docker_views.DockerAuthView.as_view()),
Expand Down
146 changes: 146 additions & 0 deletions authserver/mailauth/dcr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""DCR (RFC 7591/7592) support: initial-access-token permission and views."""
import hashlib
from typing import Any

from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.utils.decorators import method_decorator

from django_ratelimit.decorators import ratelimit
from oauth2_provider.models import get_access_token_model, get_application_model
from oauth2_provider.settings import oauth2_settings
from oauth2_provider.utils import parse_bearer_token
from oauth2_provider.views.dynamic_client_registration import (
DynamicClientRegistrationManagementView,
DynamicClientRegistrationView,
_application_to_response,
_build_application_kwargs,
_check_permissions,
_error_response,
_issue_registration_token,
_parse_metadata,
_validation_error_description,
)

from mailauth.models import Domain

# Scope carried by operator-minted initial access tokens
# (see the `dcrtoken` management command).
INITIAL_ACCESS_TOKEN_SCOPE = "authserver:dcr-initial"


class InitialAccessTokenDCRPermission:
"""
RFC 7591 initial access token check: the request must carry a Bearer
token minted by `manage.py dcrtoken create`. Configured via
OAUTH2_PROVIDER["DCR_REGISTRATION_PERMISSION_CLASSES"].
"""

def has_permission(self, request: HttpRequest) -> bool:
raw_token = parse_bearer_token(request.META.get("HTTP_AUTHORIZATION", ""))
if raw_token is None:
return False
checksum = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
access_token_model = get_access_token_model()
try:
token = access_token_model.objects.get(token_checksum=checksum)
except access_token_model.DoesNotExist:
return False
return token.is_valid([INITIAL_ACCESS_TOKEN_SCOPE])


class RegistrationEndpointMixin:
"""
The RFC 7591 and RFC 7592 endpoints both hand out client credentials, so
they are TLS-only (RFC 7591 section 5), like the other credential-carrying
APIs in mailauth.views.
"""

def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# upstream's DCR_ENABLED gate answers first, so a disabled endpoint
# still 404s instead of commenting on the transport
if oauth2_settings.DCR_ENABLED and not request.is_secure():
return _error_response("invalid_request", "This endpoint must be called securely")
return super().dispatch(request, *args, **kwargs) # type: ignore[misc]


@method_decorator(ratelimit(key='ip', rate='20/m', group='dcr-register', block=True), name='dispatch')
class MNDynamicClientRegistrationView(RegistrationEndpointMixin, DynamicClientRegistrationView):
"""
RFC 7591 registration, binding the new application to the Domain that
signs for the request host so every dynamically registered client can be
issued ID tokens. Hosts without a signing domain are rejected before
anything is created.

``post()`` is a copy of ``DynamicClientRegistrationView.post()``
(django-oauth-toolkit 3.4.0) with the domain lookup added instead of a
call to ``super()``: ``MNApplication.domain`` is ``null=True`` but not
``blank=True``, so the ``full_clean()`` inside upstream's ``post()``
rejects an application that has no domain yet - the domain has to be on
the instance before that runs. Keep in sync when upgrading
django-oauth-toolkit.
"""

def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if not _check_permissions(request):
return _error_response(
"access_denied",
"Authentication required to register a client",
status=401,
)

hostname = request.get_host().split(":")[0]
try:
domain = Domain.objects.find_parent_domain(
hostname, require_jwt_subdomains_set=True, require_jwt_key=True
)
except Domain.DoesNotExist:
return _error_response(
"invalid_client_metadata",
"This host is not a valid registration domain",
)

data, err = _parse_metadata(request.body)
if err:
return err

app_kwargs, err = _build_application_kwargs(data)
if err:
return err

Application = get_application_model()
user = request.user if request.user.is_authenticated else None
application = Application(
user=user,
registration_source=Application.RegistrationSource.DCR,
domain=domain,
**app_kwargs,
)

# Capture the raw secret before save() hashes it
raw_secret = application.client_secret if application.client_type == "confidential" else None

try:
application.full_clean()
except ValidationError as exc:
return _error_response("invalid_client_metadata", _validation_error_description(exc))

with transaction.atomic():
application.save()
registration_token = _issue_registration_token(application, user)

response_data = _application_to_response(application, registration_token, request)
if raw_secret:
response_data["client_secret"] = raw_secret

return JsonResponse(response_data, status=201)


@method_decorator(ratelimit(key='ip', rate='60/m', group='dcr-manage', block=True), name='dispatch')
class MNDynamicClientRegistrationManagementView(RegistrationEndpointMixin,
DynamicClientRegistrationManagementView):
"""
RFC 7592 client configuration endpoint. Upstream's implementation is used
as-is, it only gains the transport rules of RegistrationEndpointMixin.
"""
Loading
Loading