diff --git a/.github/workflows/django.yaml b/.github/workflows/django.yaml index 3dba584a..54b5ddf8 100644 --- a/.github/workflows/django.yaml +++ b/.github/workflows/django.yaml @@ -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 diff --git a/authserver/authserver/settings.py b/authserver/authserver/settings.py index 06e02150..1e9c23aa 100644 --- a/authserver/authserver/settings.py +++ b/authserver/authserver/settings.py @@ -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': '', - # 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:///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://' 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:///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://' 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 diff --git a/authserver/authserver/urls.py b/authserver/authserver/urls.py index 8effb3bb..7c3a3b0e 100644 --- a/authserver/authserver/urls.py +++ b/authserver/authserver/urls.py @@ -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:///" + 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'), @@ -19,7 +27,18 @@ 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 (/.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:///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//', + MNDynamicClientRegistrationManagementView.as_view(), + name='dcr-register-management'), ], 'oauth2_provider') @@ -27,15 +46,20 @@ 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:///o2": clients must fetch - # /.well-known/oauth-authorization-server/o2 for it. The plain form (issuer "https://") - # 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/', + # 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:///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://", 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'), @@ -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()), diff --git a/authserver/mailauth/dcr.py b/authserver/mailauth/dcr.py new file mode 100644 index 00000000..f1ee1c5f --- /dev/null +++ b/authserver/mailauth/dcr.py @@ -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. + """ diff --git a/authserver/mailauth/management/commands/dcrtoken.py b/authserver/mailauth/management/commands/dcrtoken.py new file mode 100644 index 00000000..7fdc6d58 --- /dev/null +++ b/authserver/mailauth/management/commands/dcrtoken.py @@ -0,0 +1,136 @@ +import secrets +import sys +from datetime import datetime, timedelta +from datetime import timezone as dt_timezone +from typing import Any + +from django.core.management.base import BaseCommand, CommandParser +from django.utils import timezone +from oauth2_provider.models import get_access_token_model + +from mailauth.dcr import INITIAL_ACCESS_TOKEN_SCOPE + +AccessToken = get_access_token_model() + + +class Command(BaseCommand): + """ + Manage RFC 7591 initial access tokens: pre-shared bearer tokens minted by + an operator that allow a client to self-register through the DCR + endpoint (see mailauth/dcr.py, InitialAccessTokenDCRPermission). A token + stays usable for any number of registrations until it expires or is + revoked, so mint one per client integration and keep the lifetime short. + """ + + requires_migrations_checks = True + + def add_arguments(self, parser: CommandParser) -> None: + class SubCommandParser(CommandParser): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + subparsers = parser.add_subparsers(dest="scmd", title="subcommands", parser_class=SubCommandParser) + + create_sp = subparsers.add_parser("create", help="Create an initial access token for DCR") + create_sp.add_argument( + "--expires-days", + dest="expires_days", + type=int, + default=30, + help="Number of days until the token expires. 0 means no expiry (year 9999). " + "Default: 30", + ) + + subparsers.add_parser("list", help="List initial access tokens for DCR") + + revoke_sp = subparsers.add_parser("revoke", help="Revoke (delete) an initial access token") + revoke_sp.add_argument( + "id_or_prefix", + help="The numeric id or a token prefix (as shown by 'dcrtoken list') of the token to revoke", + ) + + def _create(self, expires_days: int = 30, **kwargs: Any) -> None: + if expires_days < 0: + self.stderr.write(self.style.ERROR("--expires-days must not be negative")) + sys.exit(1) + + if expires_days == 0: + expires = datetime(9999, 12, 31, 23, 59, 59, tzinfo=dt_timezone.utc) + else: + expires = timezone.now() + timedelta(days=expires_days) + + raw_token = secrets.token_urlsafe(32) + AccessToken.objects.create( + application=None, + user=None, + token=raw_token, + scope=INITIAL_ACCESS_TOKEN_SCOPE, + expires=expires, + ) + + self.stderr.write( + self.style.WARNING( + "Store this token now - it will not be shown again. It grants the bearer the " + "ability to register OAuth2 clients via Dynamic Client Registration." + ) + ) + self.stdout.write(raw_token) + + def _list(self, **kwargs: Any) -> None: + tokens = list( + AccessToken.objects.filter(scope=INITIAL_ACCESS_TOKEN_SCOPE).order_by("-created") + ) + if not tokens: + self.stderr.write("No initial access tokens found (yet)") + return + + fmtstr = "{:<6} {:<12} {:<26} {}" + self.stdout.write(fmtstr.format("ID", "TOKEN", "EXPIRES", "CREATED")) + for token in tokens: + self.stdout.write( + fmtstr.format( + token.id, + "%s…" % token.token[:8], + token.expires.isoformat(timespec="seconds"), + token.created.isoformat(timespec="seconds"), + ) + ) + + def _revoke(self, id_or_prefix: str, **kwargs: Any) -> None: + base_qs = AccessToken.objects.filter(scope=INITIAL_ACCESS_TOKEN_SCOPE) + + matches = None + if id_or_prefix.isdigit(): + matches = list(base_qs.filter(pk=int(id_or_prefix))) + + if not matches: + matches = list(base_qs.filter(token__startswith=id_or_prefix)) + + if not matches: + self.stderr.write(self.style.ERROR("No initial access token found matching %s" % id_or_prefix)) + sys.exit(1) + + if len(matches) > 1: + self.stderr.write( + self.style.ERROR( + "Ambiguous prefix %s matches %d tokens; use a longer prefix or the numeric id" + % (id_or_prefix, len(matches)) + ) + ) + sys.exit(1) + + token = matches[0] + token_id = token.id + token.delete() + self.stderr.write(self.style.SUCCESS("Revoked initial access token %s" % token_id)) + + def handle(self, *args: Any, **options: Any) -> None: + if options["scmd"] == "create": + self._create(**options) + elif options["scmd"] == "list": + self._list(**options) + elif options["scmd"] == "revoke": + self._revoke(**options) + else: + self.stderr.write("Please specify a command.") + self.stderr.write("Use django-admin.py dcrtoken --settings=authserver.settings --help to get help.") diff --git a/authserver/mailauth/oauth2_backends.py b/authserver/mailauth/oauth2_backends.py index ebac6dab..682c5368 100644 --- a/authserver/mailauth/oauth2_backends.py +++ b/authserver/mailauth/oauth2_backends.py @@ -1,23 +1,39 @@ """Custom OAuthLib request/response glue for authserver.""" +from typing import Any, Dict, Optional, Tuple + +from django.http import HttpRequest + from oauth2_provider.oauth2_backends import OAuthLibCore, _add_iss_to_redirect from oauth2_provider.settings import oauth2_settings +def add_iss_parameter(uri: str, request: HttpRequest) -> str: + """ + Add the RFC 9207 `iss` authorization-response parameter to a redirect URI, + using the issuer that authserver publishes for the requested host + (https:///o2), replacing any `iss` the URI already carries. + """ + return _add_iss_to_redirect(uri, oauth2_settings.oidc_issuer(request)) + + class MNOAuthLibCore(OAuthLibCore): """ - Adds the RFC 9207 `iss` parameter to successful authorization - responses using the same per-request issuer that the OIDC discovery - document advertises (https:///o2). Upstream's implementation - (gated by COMPLIANT_BCP_RFC9700_AUTHZ_RESPONSE_ISS, pinned False in - settings) cannot derive path-component multi-tenant issuers. + Adds the RFC 9207 `iss` parameter to authorization responses (both the + success redirect and the redirect produced when a user denies the request), + using the issuer of the discovery documents. Upstream's own emission stays + off, see COMPLIANT_BCP_RFC9700_AUTHZ_RESPONSE_ISS in settings. + + Authorization errors that django-oauth-toolkit turns into redirects inside + the view never reach this method; ScopeValidationAuthView.error_response() + adds `iss` to those. """ - def create_authorization_response(self, request, scopes, credentials, allow): + def create_authorization_response(self, request: HttpRequest, scopes: str, credentials: Dict[str, Any], + allow: bool) -> Tuple[Optional[str], Dict[str, str], Optional[str], int]: uri, headers, body, status = super().create_authorization_response( request, scopes, credentials, allow ) if uri is not None: - issuer = oauth2_settings.oidc_issuer(request) - uri = _add_iss_to_redirect(uri, issuer) + uri = add_iss_parameter(uri, request) headers["Location"] = uri return uri, headers, body, status diff --git a/authserver/mailauth/tests/test_authz_response_iss.py b/authserver/mailauth/tests/test_authz_response_iss.py index 6985539e..3d55873e 100644 --- a/authserver/mailauth/tests/test_authz_response_iss.py +++ b/authserver/mailauth/tests/test_authz_response_iss.py @@ -2,8 +2,10 @@ import hashlib import json import secrets +from typing import Optional from urllib.parse import parse_qs, urlsplit +from django.http import HttpResponse from django.test import TestCase from django.urls import reverse from oauth2_provider.settings import oauth2_settings @@ -59,7 +61,8 @@ def setUpTestData(cls) -> None: hash_client_secret=False, ) - def _authorize(self, app, redirect_uri, code_verifier: str, state: "str | None" = None): + def _authorize(self, app: models.MNApplication, redirect_uri: str, code_verifier: str, + state: Optional[str] = None, scope: str = "openid profile email") -> HttpResponse: code_challenge = ( base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest()) .rstrip(b"=") @@ -70,7 +73,7 @@ def _authorize(self, app, redirect_uri, code_verifier: str, state: "str | None" "response_type": "code", "client_id": app.client_id, "redirect_uri": redirect_uri, - "scope": "openid profile email", + "scope": scope, "code_challenge": code_challenge, "code_challenge_method": "S256", } @@ -129,18 +132,30 @@ def test_redirect_uri_with_preexisting_iss_param_is_overridden(self) -> None: self.assertEqual(["https://testserver/o2"], query["iss"]) self.assertIn("code", query) + def test_authorization_error_redirect_contains_iss_parameter(self) -> None: + """RFC 9207: authorization error responses carry `iss` as well.""" + code_verifier = secrets.token_urlsafe(48) + response = self._authorize(self.app, REDIRECT_URI, code_verifier, scope="not-a-real-scope") + + self.assertEqual(302, response.status_code) + query = parse_qs(urlsplit(response.url).query) + self.assertEqual(["invalid_scope"], query["error"]) + self.assertEqual(["https://testserver/o2"], query["iss"]) + def test_discovery_documents_advertise_iss_parameter_support(self) -> None: oidc_response = self.client.get("/o2/.well-known/openid-configuration", secure=True) self.assertEqual(200, oidc_response.status_code) oidc_data = json.loads(oidc_response.content) self.assertIs(True, oidc_data["authorization_response_iss_parameter_supported"]) - rfc8414_response = self.client.get( - "/.well-known/oauth-authorization-server/o2", secure=True - ) - self.assertEqual(200, rfc8414_response.status_code) - rfc8414_data = json.loads(rfc8414_response.content) - self.assertIs(True, rfc8414_data["authorization_response_iss_parameter_supported"]) + for url in ("/.well-known/oauth-authorization-server/o2", + "/o2/.well-known/oauth-authorization-server"): + rfc8414_response = self.client.get(url, secure=True) + self.assertEqual(200, rfc8414_response.status_code, url) + rfc8414_data = json.loads(rfc8414_response.content) + self.assertIs(True, rfc8414_data["authorization_response_iss_parameter_supported"], url) + # every document we serve names the issuer we put in `iss` + self.assertEqual("https://testserver/o2", rfc8414_data["issuer"], url) def test_upstream_iss_emission_gate_is_pinned_off(self) -> None: self.assertIs(False, oauth2_settings.COMPLIANT_BCP_RFC9700_AUTHZ_RESPONSE_ISS) diff --git a/authserver/mailauth/tests/test_dcr.py b/authserver/mailauth/tests/test_dcr.py new file mode 100644 index 00000000..25852a8d --- /dev/null +++ b/authserver/mailauth/tests/test_dcr.py @@ -0,0 +1,412 @@ +import base64 +import hashlib +import json +import secrets +from datetime import timedelta +from io import StringIO +from typing import Any, Optional, Tuple +from urllib.parse import parse_qs, urlsplit + +from django.core.cache import cache +from django.core.management import call_command +from django.http import HttpResponse +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from jwcrypto import jwk, jwt +from oauth2_provider.models import get_access_token_model + +from mailauth import models +from mailauth.dcr import INITIAL_ACCESS_TOKEN_SCOPE +from mailauth.utils import generate_rsa_key + +AccessToken = get_access_token_model() + +REDIRECT_URI = "https://client.example.com/callback" + + +class DCRRegistrationTests(TestCase): + """ + RFC 7591/7592 Dynamic Client Registration behind an operator-minted + initial access token, mounted at /o2/register/ (see mailauth/dcr.py). + """ + + @classmethod + def setUpTestData(cls) -> None: + cls.key = generate_rsa_key() + # named "testserver" because that's the Host header the Django test + # client sends by default; find_parent_domain() resolves the domain + # from the request Host. + cls.domain = models.Domain.objects.create(name="testserver", jwtkey=cls.key.private_key) + + cls.user = models.MNUser.objects.create_user( + identifier="testuser", fullname="Test User", password="secret" + ) + cls.alias = models.EmailAlias.objects.create( + user=cls.user, domain=cls.domain, mailprefix="testuser" + ) + cls.user.delivery_mailbox = cls.alias + cls.user.save() + + def setUp(self) -> None: + # the registration endpoints are IP rate limited and django-ratelimit + # counts in the (process-wide) default cache + cache.clear() + self.raw_initial_token = secrets.token_urlsafe(32) + self.initial_token = AccessToken.objects.create( + application=None, + user=None, + token=self.raw_initial_token, + scope=INITIAL_ACCESS_TOKEN_SCOPE, + expires=timezone.now() + timedelta(days=1), + ) + + def _register(self, metadata: dict, *, token: Optional[str] = None, **extra: Any) -> HttpResponse: + headers = {} + if token is not None: + headers["HTTP_AUTHORIZATION"] = "Bearer %s" % token + return self.client.post( + reverse("oauth2_provider:dcr-register"), + data=json.dumps(metadata), + content_type="application/json", + secure=True, + **headers, + **extra, + ) + + def _public_metadata(self, **overrides) -> dict: + metadata = { + "client_name": "Test DCR Client", + "redirect_uris": [REDIRECT_URI], + "grant_types": ["authorization_code"], + "token_endpoint_auth_method": "none", + } + metadata.update(overrides) + return metadata + + # 1. POST without token -> 401 with WWW-Authenticate header, no app created + def test_register_without_token_is_rejected(self) -> None: + response = self._register(self._public_metadata()) + self.assertEqual(401, response.status_code, response.content) + self.assertIn("WWW-Authenticate", response) + self.assertFalse(models.MNApplication.objects.exists()) + + # 2. POST with expired/unknown token -> 401 + def test_register_with_unknown_token_is_rejected(self) -> None: + response = self._register(self._public_metadata(), token="this-token-does-not-exist") + self.assertEqual(401, response.status_code, response.content) + self.assertFalse(models.MNApplication.objects.exists()) + + def test_register_with_expired_token_is_rejected(self) -> None: + raw_expired = secrets.token_urlsafe(32) + AccessToken.objects.create( + application=None, + user=None, + token=raw_expired, + scope=INITIAL_ACCESS_TOKEN_SCOPE, + expires=timezone.now() - timedelta(days=1), + ) + response = self._register(self._public_metadata(), token=raw_expired) + self.assertEqual(401, response.status_code, response.content) + self.assertFalse(models.MNApplication.objects.exists()) + + def test_register_with_token_of_another_scope_is_rejected(self) -> None: + """A plain user access token must not double as an initial access token.""" + raw_user_token = secrets.token_urlsafe(32) + AccessToken.objects.create( + application=None, + user=self.user, + token=raw_user_token, + scope="openid profile email", + expires=timezone.now() + timedelta(days=1), + ) + response = self._register(self._public_metadata(), token=raw_user_token) + self.assertEqual(401, response.status_code, response.content) + self.assertFalse(models.MNApplication.objects.exists()) + + def test_registration_endpoint_is_rate_limited_per_ip(self) -> None: + for _ in range(20): + self._register(self._public_metadata()) + response = self._register(self._public_metadata(), token=self.raw_initial_token) + self.assertEqual(403, response.status_code) + self.assertFalse(models.MNApplication.objects.exists()) + + def test_register_over_plaintext_is_rejected(self) -> None: + response = self.client.post( + reverse("oauth2_provider:dcr-register"), + data=json.dumps(self._public_metadata()), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer %s" % self.raw_initial_token, + ) + self.assertEqual(400, response.status_code, response.content) + self.assertEqual("invalid_request", json.loads(response.content)["error"]) + self.assertFalse(models.MNApplication.objects.exists()) + + # 3. POST with valid token and metadata -> 201, public app with domain set + def test_register_public_client_succeeds(self) -> None: + response = self._register(self._public_metadata(), token=self.raw_initial_token) + self.assertEqual(201, response.status_code, response.content) + data = json.loads(response.content) + + app = models.MNApplication.objects.get(client_id=data["client_id"]) + self.assertEqual(models.MNApplication.RegistrationSource.DCR, app.registration_source) + self.assertEqual(self.domain, app.domain) + self.assertEqual("public", app.client_type) + self.assertTrue(app.pkce_enforced) + self.assertNotIn("client_secret", data) + self.assertIn("registration_access_token", data) + self.assertIn("registration_client_uri", data) + + # 4. Confidential registration returns client_secret in the response body + def test_register_confidential_client_returns_secret(self) -> None: + metadata = self._public_metadata(token_endpoint_auth_method="client_secret_basic") + response = self._register(metadata, token=self.raw_initial_token) + self.assertEqual(201, response.status_code, response.content) + data = json.loads(response.content) + + app = models.MNApplication.objects.get(client_id=data["client_id"]) + self.assertEqual("confidential", app.client_type) + self.assertIn("client_secret", data) + self.assertTrue(data["client_secret"]) + + def test_registered_confidential_client_can_authenticate_at_token_endpoint(self) -> None: + """The returned secret must work against the (hashed) stored secret.""" + metadata = self._public_metadata( + token_endpoint_auth_method="client_secret_basic", + grant_types=["client_credentials"], + redirect_uris=[], + ) + response = self._register(metadata, token=self.raw_initial_token) + self.assertEqual(201, response.status_code, response.content) + data = json.loads(response.content) + + credentials = base64.b64encode( + ("%s:%s" % (data["client_id"], data["client_secret"])).encode("utf-8") + ).decode("ascii") + token_response = self.client.post( + reverse("oauth2_provider:token"), + {"grant_type": "client_credentials"}, + HTTP_AUTHORIZATION="Basic %s" % credentials, + secure=True, + ) + self.assertEqual(200, token_response.status_code, token_response.content) + self.assertIn("access_token", json.loads(token_response.content)) + + # 5. POST on a Host with no signing domain -> 400, no app created + @override_settings(ALLOWED_HOSTS=["testserver", "unknown.example"]) + def test_register_on_host_without_signing_domain_fails(self) -> None: + response = self._register( + self._public_metadata(), token=self.raw_initial_token, HTTP_HOST="unknown.example" + ) + self.assertEqual(400, response.status_code, response.content) + self.assertFalse(models.MNApplication.objects.exists()) + + @override_settings(ALLOWED_HOSTS=["testserver", "unknown.example"]) + def test_unauthenticated_request_cannot_probe_registration_domains(self) -> None: + """ + Without a valid initial access token the answer is 401 for every host, + so the endpoint doesn't reveal which vhosts can register clients. + """ + response = self._register(self._public_metadata(), HTTP_HOST="unknown.example") + self.assertEqual(401, response.status_code, response.content) + + def test_discovery_documents_advertise_the_registration_endpoint(self) -> None: + registration_endpoint = "https://testserver%s" % reverse("oauth2_provider:dcr-register") + + oidc_response = self.client.get("/o2/.well-known/openid-configuration", secure=True) + self.assertEqual(200, oidc_response.status_code) + self.assertEqual( + registration_endpoint, json.loads(oidc_response.content)["registration_endpoint"] + ) + + rfc8414_response = self.client.get( + "/.well-known/oauth-authorization-server/o2", secure=True + ) + self.assertEqual(200, rfc8414_response.status_code) + self.assertEqual( + registration_endpoint, json.loads(rfc8414_response.content)["registration_endpoint"] + ) + + # 6. RFC 7592 management endpoint + def test_management_endpoint_get_put_delete(self) -> None: + response = self._register(self._public_metadata(), token=self.raw_initial_token) + self.assertEqual(201, response.status_code, response.content) + data = json.loads(response.content) + client_id = data["client_id"] + management_token = data["registration_access_token"] + + management_url = reverse( + "oauth2_provider:dcr-register-management", kwargs={"client_id": client_id} + ) + + # the *initial* token must be rejected on the management endpoint + rejected = self.client.get( + management_url, HTTP_AUTHORIZATION="Bearer %s" % self.raw_initial_token, secure=True + ) + self.assertEqual(401, rejected.status_code, rejected.content) + + # GET with the returned registration_access_token -> 200 metadata + get_response = self.client.get( + management_url, HTTP_AUTHORIZATION="Bearer %s" % management_token, secure=True + ) + self.assertEqual(200, get_response.status_code, get_response.content) + get_data = json.loads(get_response.content) + self.assertEqual(client_id, get_data["client_id"]) + + # PUT updates client_name + put_metadata = self._public_metadata(client_name="Renamed DCR Client") + put_response = self.client.put( + management_url, + data=json.dumps(put_metadata), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer %s" % management_token, + secure=True, + ) + self.assertEqual(200, put_response.status_code, put_response.content) + app = models.MNApplication.objects.get(client_id=client_id) + self.assertEqual("Renamed DCR Client", app.name) + + # DCR_ROTATE_REGISTRATION_TOKEN_ON_UPDATE defaults to True: PUT + # issued a new registration_access_token and invalidated the old one. + put_data = json.loads(put_response.content) + management_token = put_data["registration_access_token"] + + # DELETE removes the app + delete_response = self.client.delete( + management_url, HTTP_AUTHORIZATION="Bearer %s" % management_token, secure=True + ) + self.assertEqual(204, delete_response.status_code, delete_response.content) + self.assertFalse(models.MNApplication.objects.filter(client_id=client_id).exists()) + + # 7. End-to-end: DCR-registered public client completes PKCE authorize + token flow + def test_end_to_end_pkce_flow_for_dcr_registered_client(self) -> None: + response = self._register(self._public_metadata(), token=self.raw_initial_token) + self.assertEqual(201, response.status_code, response.content) + data = json.loads(response.content) + client_id = data["client_id"] + + code_verifier = secrets.token_urlsafe(48) + code_challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest()) + .rstrip(b"=") + .decode("ascii") + ) + + self.client.force_login(self.user) + + # DCR apps keep skip_authorization=False (model default), so the + # user must explicitly consent; POST directly to the authorize + # endpoint with allow=True as the consent form would. + authorize_response = self.client.post( + reverse("oauth2_provider:authorize"), + { + "allow": True, + "response_type": "code", + "client_id": client_id, + "redirect_uri": REDIRECT_URI, + "scope": "openid profile email", + "code_challenge": code_challenge, + "code_challenge_method": "S256", + }, + secure=True, + ) + self.assertEqual(302, authorize_response.status_code, getattr(authorize_response, "content", b"")) + self.assertTrue(authorize_response.url.startswith(REDIRECT_URI)) + query = parse_qs(urlsplit(authorize_response.url).query) + self.assertIn("code", query) + code = query["code"][0] + + token_response = self.client.post( + reverse("oauth2_provider:token"), + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + "client_id": client_id, + "code_verifier": code_verifier, + }, + secure=True, + ) + self.assertEqual(200, token_response.status_code, token_response.content) + token_data = json.loads(token_response.content) + self.assertIn("id_token", token_data) + + pubkey = jwk.JWK.from_pem(self.key.public_key.encode("utf-8")) + verified = jwt.JWT(key=pubkey, jwt=token_data["id_token"]) + header = json.loads(verified.header) + claims = json.loads(verified.claims) + self.assertEqual("RS256", header["alg"]) + self.assertEqual(str(self.user.uuid), claims["sub"]) + + +class DCRTokenManagementCommandTests(TestCase): + """Tests for `manage.py dcrtoken create/list/revoke`.""" + + def _call(self, *args: str) -> Tuple[str, str]: + out, err = StringIO(), StringIO() + call_command("dcrtoken", *args, stdout=out, stderr=err) + return out.getvalue(), err.getvalue() + + def test_create_prints_token_once_and_stores_scope(self) -> None: + out, err = self._call("create", "--expires-days", "7") + + raw_token = out.strip() + self.assertTrue(raw_token) + token = AccessToken.objects.get(scope=INITIAL_ACCESS_TOKEN_SCOPE) + self.assertEqual(raw_token, token.token) + self.assertIn("Store this token now", err) + + def test_create_with_zero_expires_days_is_far_future(self) -> None: + self._call("create", "--expires-days", "0") + token = AccessToken.objects.get(scope=INITIAL_ACCESS_TOKEN_SCOPE) + self.assertEqual(9999, token.expires.year) + + def test_list_shows_created_tokens(self) -> None: + raw_token = self._call("create")[0].strip() + + listing = self._call("list")[0] + self.assertIn(raw_token[:8], listing) + + def test_list_with_no_tokens_writes_to_stderr(self) -> None: + out, err = self._call("list") + self.assertEqual("", out) + self.assertIn("No initial access tokens found", err) + + def test_revoke_by_id_deletes_token(self) -> None: + self._call("create") + token = AccessToken.objects.get(scope=INITIAL_ACCESS_TOKEN_SCOPE) + + err = self._call("revoke", str(token.id))[1] + self.assertFalse(AccessToken.objects.filter(pk=token.pk).exists()) + self.assertIn(str(token.id), err) + + def test_revoke_by_unique_prefix_deletes_token(self) -> None: + raw_token = self._call("create")[0].strip() + token = AccessToken.objects.get(scope=INITIAL_ACCESS_TOKEN_SCOPE) + + self._call("revoke", raw_token[:12]) + self.assertFalse(AccessToken.objects.filter(pk=token.pk).exists()) + + def test_create_with_negative_expires_days_exits_nonzero(self) -> None: + with self.assertRaises(SystemExit) as exit_ctx: + self._call("create", "--expires-days", "-1") + self.assertNotEqual(0, exit_ctx.exception.code) + self.assertFalse(AccessToken.objects.exists()) + + def test_revoke_unknown_id_exits_nonzero(self) -> None: + with self.assertRaises(SystemExit) as exit_ctx: + self._call("revoke", "999999") + self.assertNotEqual(0, exit_ctx.exception.code) + + def test_dcrtoken_does_not_grant_registration_management_access(self) -> None: + """ + An initial access token cannot be used against the RFC 7592 management + endpoint even if it somehow carried the DCR_REGISTRATION_SCOPE, because + it has no application to match the client_id in the URL against. + Exercised end-to-end in DCRRegistrationTests; this just confirms the + token has no application attached at creation time. + """ + self._call("create") + token = AccessToken.objects.get(scope=INITIAL_ACCESS_TOKEN_SCOPE) + self.assertIsNone(token.application) + self.assertIsNone(token.user) diff --git a/authserver/mailauth/tests/test_oauth2_oidc.py b/authserver/mailauth/tests/test_oauth2_oidc.py index 7a2000b7..731f9d28 100644 --- a/authserver/mailauth/tests/test_oauth2_oidc.py +++ b/authserver/mailauth/tests/test_oauth2_oidc.py @@ -187,9 +187,33 @@ def test_oauth_server_metadata_path_form_matches_oidc_issuer(self) -> None: ) self.assertEqual(oidc_data["token_endpoint"], rfc8414_data["token_endpoint"]) - def test_oauth_server_metadata_root_form_is_served(self) -> None: + def test_oauth_server_metadata_suffix_form_matches_path_form(self) -> None: + """ + The /.well-known/oauth-authorization-server form that OpenID + Connect Discovery style clients build serves the same document. + """ + suffix_response = self.client.get("/o2/.well-known/oauth-authorization-server", secure=True) + self.assertEqual(200, suffix_response.status_code) + + path_response = self.client.get("/.well-known/oauth-authorization-server/o2", secure=True) + self.assertEqual(200, path_response.status_code) + + self.assertEqual(json.loads(path_response.content), json.loads(suffix_response.content)) + self.assertEqual("https://testserver/o2", json.loads(suffix_response.content)["issuer"]) + + def test_oauth_server_metadata_root_form_redirects_to_the_issuer_document(self) -> None: + """ + The bare well-known URL is about the issuer "https://", which we + don't have, so it must not answer with a document of its own. + """ response = self.client.get("/.well-known/oauth-authorization-server", secure=True) - self.assertEqual(200, response.status_code) - data = json.loads(response.content) - self.assertIn("authorization_endpoint", data) - self.assertIn("token_endpoint", data) + self.assertEqual(302, response.status_code) + self.assertEqual("/.well-known/oauth-authorization-server/o2", response["Location"]) + + followed = self.client.get(response["Location"], secure=True) + self.assertEqual(200, followed.status_code) + self.assertEqual("https://testserver/o2", json.loads(followed.content)["issuer"]) + + def test_oauth_server_metadata_is_not_served_for_other_issuer_paths(self) -> None: + response = self.client.get("/.well-known/oauth-authorization-server/nope", secure=True) + self.assertEqual(404, response.status_code) diff --git a/authserver/mailauth/views.py b/authserver/mailauth/views.py index a75c9778..a4185b70 100644 --- a/authserver/mailauth/views.py +++ b/authserver/mailauth/views.py @@ -2,7 +2,7 @@ import json import logging from datetime import datetime -from typing import Any, List, NamedTuple, cast +from typing import Any, Dict, List, NamedTuple, cast from urllib.parse import urlparse from zoneinfo import ZoneInfo @@ -12,6 +12,7 @@ from django.http.request import HttpRequest from django.http.response import HttpResponse, HttpResponseBase, JsonResponse from django.shortcuts import render +from django.urls import reverse from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt @@ -26,6 +27,7 @@ from jwcrypto import jwk from dockerauth.jwtutils import JWTViewHelperMixin +from mailauth.oauth2_backends import add_iss_parameter from mailauth.models import MNApplication, UnresolvableUserException, Domain from mailauth.models import EmailAgentAuthToken, MNUser from mailauth.permissions import find_missing_permissions @@ -39,6 +41,17 @@ class ScopeValidationAuthView(AuthorizationView): def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) + def error_response(self, error: Any, application: Any, **kwargs: Any) -> HttpResponse: + """ + RFC 9207 requires the `iss` parameter in authorization error responses + as well. django-oauth-toolkit builds those redirects here in the view + instead of going through MNOAuthLibCore, so add it on the way out. + """ + response = super().error_response(error, application, **kwargs) + if response.has_header("Location"): + response["Location"] = add_iss_parameter(response["Location"], self.request) + return response + def form_valid(self, form: AllowForm) -> HttpResponse: """ use the base class' form logic, but always behave like users didn't authorize the app @@ -465,29 +478,48 @@ def get(self, request: HttpRequest) -> HttpResponse: return response -class IssAdvertisingDiscoveryMixin: +class DiscoveryDocumentMixin: """ - Advertises RFC 9207 support in discovery documents. Upstream only sets - ``authorization_response_iss_parameter_supported`` when - ``COMPLIANT_BCP_RFC9700_AUTHZ_RESPONSE_ISS`` is True; authserver pins that - gate False and emits the `iss` parameter itself (mailauth.oauth2_backends - .MNOAuthLibCore), so the flag must be advertised independently here. + Post-processes the discovery documents rendered by django-oauth-toolkit + through ``extend_discovery_document()``. + + Every document gets ``authorization_response_iss_parameter_supported``: + upstream only advertises it when ``COMPLIANT_BCP_RFC9700_AUTHZ_RESPONSE_ISS`` + is True, while authserver keeps that gate off and emits the `iss` parameter + from mailauth.oauth2_backends.MNOAuthLibCore instead. Advertising it in + every document is only correct because they all name the one issuer we + emit, ``https:///o2`` (see the metadata routes in authserver.urls). """ + def extend_discovery_document(self, data: Dict[str, Any], request: HttpRequest) -> Dict[str, Any]: + data["authorization_response_iss_parameter_supported"] = True + return data + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: response = super().get(request, *args, **kwargs) # type: ignore[misc] if response.status_code == 200: - data = json.loads(response.content) - data["authorization_response_iss_parameter_supported"] = True + data = self.extend_discovery_document(json.loads(response.content), request) new_response = JsonResponse(data) new_response["Access-Control-Allow-Origin"] = "*" return new_response return response -class MNConnectDiscoveryInfoView(IssAdvertisingDiscoveryMixin, oidc_views.ConnectDiscoveryInfoView): - pass +class MNConnectDiscoveryInfoView(DiscoveryDocumentMixin, oidc_views.ConnectDiscoveryInfoView): + """ + OpenID Connect discovery document. Also advertises the Dynamic Client + Registration endpoint, which upstream only publishes in the RFC 8414 + metadata document, so OIDC clients that read openid-configuration find it. + """ + + def extend_discovery_document(self, data: Dict[str, Any], request: HttpRequest) -> Dict[str, Any]: + data = super().extend_discovery_document(data, request) + if oauth2_settings.DCR_ENABLED: + data["registration_endpoint"] = request.build_absolute_uri( + reverse("oauth2_provider:dcr-register") + ) + return data -class MNOAuthServerMetadataView(IssAdvertisingDiscoveryMixin, oauth2_views.OAuthServerMetadataView): +class MNOAuthServerMetadataView(DiscoveryDocumentMixin, oauth2_views.OAuthServerMetadataView): pass