diff --git a/src/thunderbird_accounts/authentication/api.py b/src/thunderbird_accounts/authentication/api.py index 3d075752..16a16b65 100644 --- a/src/thunderbird_accounts/authentication/api.py +++ b/src/thunderbird_accounts/authentication/api.py @@ -7,10 +7,12 @@ from urllib.parse import quote from django.conf import settings +from mozilla_django_oidc.contrib.drf import OIDCAuthentication from rest_framework.authentication import SessionAuthentication from rest_framework.throttling import UserRateThrottle -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated import sentry_sdk +from waffle.views import waffle_json from thunderbird_accounts.authentication.exceptions import ( InvalidDomainError, @@ -66,6 +68,19 @@ def get_user_profile(request: Request): return Response(UserProfileSerializer(request.user).data) +@api_view(['GET']) +@authentication_classes([OIDCAuthentication]) +@permission_classes([IsAuthenticated]) +def get_waffle_flags(request: Request): + """Return the caller's active waffle flags, switches, and samples. + + Callers authenticate with `Authorization: Bearer ` + and we resolve that to the matching local user via OIDCAuthentication. + This is just waffle's own `waffle_json` view wrapped with our token auth. + """ + return waffle_json(request) + + @api_view(['GET']) @authentication_classes([SessionAuthentication]) def get_mfa_methods(request: Request): diff --git a/src/thunderbird_accounts/authentication/tests/test_api.py b/src/thunderbird_accounts/authentication/tests/test_api.py index 57ddcca0..0b7c0636 100644 --- a/src/thunderbird_accounts/authentication/tests/test_api.py +++ b/src/thunderbird_accounts/authentication/tests/test_api.py @@ -4,9 +4,11 @@ from django.urls import reverse from json import JSONDecodeError from unittest.mock import MagicMock, patch +from waffle.models import Flag import uuid from django.conf import settings +from django.core.exceptions import SuspiciousOperation from urllib.parse import quote from django.test import Client as RequestClient, override_settings from rest_framework.test import APITestCase, APIClient @@ -353,3 +355,85 @@ def test_missing_emails_just_error_out(self): self.assertEqual('no-email', resp_data.get('type')) test_entry = AllowListEntry.objects.filter(email=email).first() self.assertIsNone(test_entry) + + +class WaffleFlagsTestcase(APITestCase): + def setUp(self): + self.client = APIClient() + self.url = reverse('api_waffle_flags') + self.user = User.objects.create( + oidc_id=str(uuid.uuid4()), + recovery_email=f'{uuid.uuid4()}@example.com', + username=f'{uuid.uuid4()}@example.org', + ) + + Flag.objects.create(name='flag-on-for-everyone', everyone=True) + Flag.objects.create(name='flag-off-for-everyone', everyone=False) + Flag.objects.create(name='flag-on-for-authenticated', authenticated=True) + + # Due to the endpoint being gated by OIDCAuthentication + patcher = patch( + 'thunderbird_accounts.authentication.middleware.AccountsOIDCBackend.get_userinfo', + side_effect=self._fake_userinfo, + ) + patcher.start() + self.addCleanup(patcher.stop) + + @staticmethod + def _fake_userinfo(access_token, id_token, payload): + # Mimic a real OIDC provider, which would reject unrecognized/invalid + # access tokens rather than happily returning userinfo for anything. + if not User.objects.filter(oidc_id=access_token).exists(): + raise SuspiciousOperation('invalid access token') + + return { + 'sub': access_token, + 'email': f'{access_token}@example.org', + 'email_verified': True, + 'preferred_username': f'{access_token}@example.org', + } + + def test_returns_active_flags_for_authenticated_user(self): + response = self.client.get(self.url, headers={'authorization': f'Bearer {self.user.oidc_id}'}) + self.assertEqual(200, response.status_code, response.content) + + flags = response.json().get('flags') + self.assertEqual( + { + 'flag-on-for-everyone', + 'flag-off-for-everyone', + 'flag-on-for-authenticated', + }, + flags.keys(), + ) + self.assertTrue(flags['flag-on-for-everyone']['is_active']) + self.assertFalse(flags['flag-off-for-everyone']['is_active']) + self.assertTrue(flags['flag-on-for-authenticated']['is_active']) + + def test_returns_active_flag_for_specific_user_only(self): + other_user = User.objects.create( + oidc_id=str(uuid.uuid4()), + recovery_email=f'{uuid.uuid4()}@example.com', + username=f'{uuid.uuid4()}@example.org', + ) + + flag = Flag.objects.create(name='flag-on-for-specific-user') + flag.users.add(self.user) + + # Authenticate as the user created in the setup step and check that the flag is active + response = self.client.get(self.url, headers={'authorization': f'Bearer {self.user.oidc_id}'}) + self.assertEqual(200, response.status_code, response.content) + self.assertTrue(response.json()['flags']['flag-on-for-specific-user']['is_active']) + + # Authenticate as the other user and check that the flag is not active + response = self.client.get(self.url, headers={'authorization': f'Bearer {other_user.oidc_id}'}) + self.assertEqual(200, response.status_code, response.content) + self.assertFalse(response.json()['flags']['flag-on-for-specific-user']['is_active']) + + def test_requires_authentication(self): + response = self.client.get(self.url) + self.assertEqual(401, response.status_code) + + def test_returns_401_for_invalid_token(self): + response = self.client.get(self.url, headers={'authorization': 'Bearer invalid-token'}) + self.assertEqual(401, response.status_code) diff --git a/src/thunderbird_accounts/urls.py b/src/thunderbird_accounts/urls.py index c468dd06..28764392 100644 --- a/src/thunderbird_accounts/urls.py +++ b/src/thunderbird_accounts/urls.py @@ -67,6 +67,7 @@ path('api/v1/auth/get-profile/', get_user_profile, name='api_get_profile'), path('api/v1/auth/sign-up/', sign_up, name='api_sign_up'), path('api/v1/auth/can-i-sign-up/', can_i_sign_up, name='api_can_i_sign_up'), + path('api/v1/auth/waffle-flags/', auth_api.get_waffle_flags, name='api_waffle_flags'), path('api/v1/auth/mfa/methods/', auth_api.get_mfa_methods, name='api_get_mfa_methods'), path('api/v1/auth/mfa/totp/setup/start/', auth_api.start_totp_setup, name='api_start_totp_setup'), path('api/v1/auth/mfa/totp/setup/confirm/', auth_api.confirm_totp_setup, name='api_confirm_totp_setup'),