From 3d9754a311a240b0dfc61d64622a43605bad5ec7 Mon Sep 17 00:00:00 2001 From: Viljen Date: Mon, 5 Jan 2026 12:56:04 +0100 Subject: [PATCH 1/6] Add fixtures and OAUTH-API for AOC --- AOC_INTEGRATION.md | 193 ++++++++++++++++++++++++++++++++++++++++++++ setup_aoc_oauth.py | 175 +++++++++++++++++++++++++++++++++++++++ update_oauth_app.py | 79 ++++++++++++++++++ 3 files changed, 447 insertions(+) create mode 100644 AOC_INTEGRATION.md create mode 100644 setup_aoc_oauth.py create mode 100644 update_oauth_app.py diff --git a/AOC_INTEGRATION.md b/AOC_INTEGRATION.md new file mode 100644 index 000000000..85e616544 --- /dev/null +++ b/AOC_INTEGRATION.md @@ -0,0 +1,193 @@ +# Lego - AOC Integration Changes + +This document describes the changes needed in lego to support the AOC leaderboard integration. + +## Summary + +The AOC leaderboard needs minimal user data (name, username, GitHub username) from Abakus users. This integration adds a new OAuth scope "aoc" that provides limited access to user data. + +## Changes Made + +### 1. New OAuth Scope: "aoc" + +**File**: `lego/settings/base.py` (line ~144) + +Added a new OAuth scope that requests minimal permissions: + +```python +"aoc": ( + "Minimal brukerinfo for Advent of Code leaderboard. " + "Gir lesetilgang til navn, brukernavn og GitHub-brukernavn" +), +``` + +**Why**: The default "user" scope requests too many permissions (email, profile picture, gender, memberships). The "aoc" scope only requests what's needed. + +### 2. MinimalUserSerializer + +**File**: `lego/apps/users/serializers/users.py` (line ~46) + +Added a new serializer that returns only essential fields: + +```python +class MinimalUserSerializer(serializers.ModelSerializer): + """ + Minimal serializer for listing users with only essential fields. + Used by Advent of Code integration + """ + + class Meta: + model = User + fields = ( + "id", + "username", + "full_name", + "github_username", + ) + read_only_fields = fields +``` + +**Why**: Returns only the fields needed for AOC matching, reducing data exposure and improving performance. + +### 3. Users API Endpoint Updates + +**File**: `lego/apps/users/views/users.py` + +#### 3a. Support for `?minimal=true` query parameter (line ~50) + +```python +def get_serializer_class(self): + # Support minimal query parameter for list actions + if self.action == "list" and self.request.query_params.get("minimal") == "true": + return MinimalUserSerializer + # ... rest of method +``` + +**Endpoint**: `GET /api/v1/users/?minimal=true` + +**Why**: Allows clients to request only minimal user data when fetching user lists. + +#### 3b. Scope-aware oauth2_userdata endpoint (line ~89) + +```python +def oauth2_userdata(self, request): + """ + Read-only endpoint used to retrieve information about the authenticated user. + Returns minimal data if the OAuth scope is 'aoc'. + """ + # Check if the request is using the 'aoc' scope + token = getattr(request.auth, 'token', None) if hasattr(request, 'auth') else None + scope = token.scope if token and hasattr(token, 'scope') else None + + if scope and 'aoc' in scope: + # Use minimal serializer for aoc scope + serializer = MinimalUserSerializer(request.user) + else: + # Use default OAuth2 serializer + serializer = self.get_serializer(request.user) + + return Response(serializer.data) +``` + +**Why**: Automatically returns minimal data when using "aoc" scope, enforcing least-privilege access. + +### 4. OAuth Authentication Updates + +**File**: `lego/apps/oauth/authentication.py` (line ~30) + +Added "aoc" scope to allowed scopes: + +```python +# Allow "aoc" scope for oauth2_userdata and users list endpoints +if token.allow_scopes(["aoc"]): + allowed_paths = [ + "/api/v1/users/oauth2_userdata/", + "/api/v1/users/" + ] + if request.path in allowed_paths: + return authentication +``` + +**Why**: Allows OAuth tokens with "aoc" scope to access the necessary endpoints. + +## API Usage + +### For AOC Integration + +**1. OAuth Flow:** +``` +GET /authorization/oauth2/authorize/?client_id=xxx&scope=aoc&redirect_uri=xxx&... +``` + +**2. Get current user (minimal):** +``` +GET /api/v1/users/oauth2_userdata/ +Authorization: Bearer + +Response: +{ + "id": 123, + "username": "viljen", + "full_name": "Viljen Jensen", + "github_username": "viljen" +} +``` + +**3. Get all users (minimal):** +``` +GET /api/v1/users/?minimal=true +Authorization: Bearer + +Response: +{ + "results": [ + { + "id": 123, + "username": "viljen", + "full_name": "Viljen Jensen", + "github_username": "viljen" + }, + ... + ] +} +``` + +## Deployment Checklist + +When deploying to staging/production: + +- [ ] Apply all 4 code changes listed above +- [ ] Restart lego server to load new scope +- [ ] Run `setup_aoc_oauth.py` script to create OAuth application +- [ ] Test OAuth flow with "aoc" scope +- [ ] Verify minimal data is returned + +## Security Considerations + +1. **Least Privilege**: The "aoc" scope only grants access to minimal user data +2. **Limited Endpoints**: OAuth authentication restricts "aoc" tokens to specific endpoints only +3. **No Write Access**: All endpoints are read-only for "aoc" scope +4. **User Consent**: Users must explicitly approve access during OAuth flow + +## Backward Compatibility + +These changes are fully backward compatible: +- Existing "user" and "all" scopes work unchanged +- Existing OAuth applications are not affected +- The MinimalUserSerializer is only used when explicitly requested +- No changes to existing API responses + +## Testing + +```bash +# Test OAuth flow with aoc scope +curl -X GET "http://localhost:8000/authorization/oauth2/authorize/?client_id=xxx&scope=aoc&..." + +# Test minimal user data endpoint +curl -X GET "http://localhost:8000/api/v1/users/oauth2_userdata/" \ + -H "Authorization: Bearer " + +# Test minimal users list +curl -X GET "http://localhost:8000/api/v1/users/?minimal=true" \ + -H "Authorization: Bearer " +``` diff --git a/setup_aoc_oauth.py b/setup_aoc_oauth.py new file mode 100644 index 000000000..355ab0810 --- /dev/null +++ b/setup_aoc_oauth.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python +""" +Setup script for AOC OAuth application in any environment (dev, staging, production) + +Usage: + python setup_aoc_oauth.py --env dev --redirect http://localhost:5173/auth/callback + python setup_aoc_oauth.py --env staging --redirect https://aoc.abakus.no/auth/callback + python setup_aoc_oauth.py --env production --redirect https://aoc.abakus.no/auth/callback +""" +import os +import sys +import django +import argparse +import secrets + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'lego.settings') +django.setup() + +from lego.apps.oauth.models import APIApplication +from lego.apps.users.models import User + + +def generate_secure_credentials(): + """Generate cryptographically secure client credentials""" + client_id = f"aoc_{secrets.token_urlsafe(32)}" + client_secret = secrets.token_urlsafe(64) + return client_id, client_secret + + +def setup_oauth_app(redirect_uri, use_existing=False): + """ + Set up or update the AOC OAuth application + + Args: + redirect_uri: The redirect URI for the OAuth callback + use_existing: If True, update existing app instead of creating new one + """ + app_name = 'Advent of Code Leaderboard' + + print("=" * 70) + print(f"Setting up OAuth Application: {app_name}") + print("=" * 70) + + # Check if app already exists + existing_app = APIApplication.objects.filter(name=app_name).first() + + if existing_app and not use_existing: + print(f"\nAn OAuth app named '{app_name}' already exists!") + print(f"\nExisting Application Details:") + print(f" Client ID: {existing_app.client_id}") + print(f" Redirect URI: {existing_app.redirect_uris}") + + response = input("\nDo you want to update it? (yes/no): ").lower() + if response != 'yes': + print("Aborted.") + return existing_app + + # Update existing app + existing_app.redirect_uris = redirect_uri + existing_app.save() + print("\n✓ Updated existing OAuth application!") + return existing_app + + elif existing_app and use_existing: + # Update existing app + existing_app.redirect_uris = redirect_uri + existing_app.save() + print("\n✓ Updated existing OAuth application!") + return existing_app + + else: + # Create new app + user = User.objects.first() + if not user: + print("ERROR: No users found in database!") + print("Please create at least one user first.") + sys.exit(1) + + # Generate secure credentials + client_id, client_secret = generate_secure_credentials() + + app = APIApplication.objects.create( + client_id=client_id, + client_secret=client_secret, + user=user, + redirect_uris=redirect_uri, + client_type='public', + authorization_grant_type='authorization-code', + name=app_name, + description='Abakus Advent of Code Leaderboard Integration', + skip_authorization=False + ) + + print("\n✓ Successfully created new OAuth application!") + return app + + +def main(): + parser = argparse.ArgumentParser( + description='Setup AOC OAuth application for different environments' + ) + parser.add_argument( + '--env', + choices=['dev', 'staging', 'production'], + default='dev', + help='Environment to configure (dev, staging, production)' + ) + parser.add_argument( + '--redirect', + required=True, + help='Redirect URI for OAuth callback (e.g., http://localhost:5173/auth/callback)' + ) + parser.add_argument( + '--update', + action='store_true', + help='Update existing app instead of creating new one' + ) + + args = parser.parse_args() + + print(f"\nEnvironment: {args.env.upper()}") + print(f"Redirect URI: {args.redirect}") + print() + + # Setup the OAuth app + app = setup_oauth_app(args.redirect, use_existing=args.update) + + # Display results + print("\n" + "=" * 70) + print("OAuth Application Details") + print("=" * 70) + print(f"\n Name: {app.name}") + print(f" Description: {app.description}") + print(f" Client ID: {app.client_id}") + print(f" Client Secret: {app.client_secret}") + print(f" Redirect URI: {app.redirect_uris}") + print(f" Grant Type: {app.authorization_grant_type}") + + # Environment-specific instructions + print("\n" + "=" * 70) + print(f"Configuration for {args.env.upper()} Environment") + print("=" * 70) + + if args.env == 'dev': + api_url = 'http://localhost:8000' + elif args.env == 'staging': + api_url = 'https://staging.abakus.no' # Replace with actual staging URL + else: # production + api_url = 'https://abakus.no' + + print(f"\nAdd these to your aoc-abakus-sv .env file:\n") + print(f"ABAKUS_API_URL={api_url}") + print(f"ABAKUS_CLIENT_ID={app.client_id}") + print(f"ABAKUS_CLIENT_SECRET={app.client_secret}") + print(f"ABAKUS_REDIRECT_URI={args.redirect}") + + print("\n" + "=" * 70) + print("Next Steps") + print("=" * 70) + print("\n1. Add the environment variables above to your .env file") + print("2. Restart your aoc-abakus-sv application") + print("3. Test the OAuth flow") + + if args.env in ['staging', 'production']: + print("\n IMPORTANT for staging/production:") + print(" - Make sure lego is running and accessible at the API URL") + print(" - Ensure the redirect URI is accessible from the internet") + print(" - The 'aoc' scope must be configured in lego settings") + print(" - OAuth authentication.py must allow 'aoc' scope") + + print("\n" + "=" * 70) + + +if __name__ == '__main__': + main() diff --git a/update_oauth_app.py b/update_oauth_app.py new file mode 100644 index 000000000..46d235314 --- /dev/null +++ b/update_oauth_app.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +""" +Quick script to create a new OAuth application for AOC +""" +import os +import sys +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'lego.settings') +django.setup() + +from lego.apps.oauth.models import APIApplication +from lego.apps.users.models import User + +# New OAuth app credentials +client_id = 'aoc_leaderboard_client_id_12345' +client_secret = 'aoc_leaderboard_secret_67890_abcdefghijklmnop' + +print("=" * 60) +print("AOC OAuth Application Setup") +print("=" * 60) + +# Check if it already exists +if APIApplication.objects.filter(client_id=client_id).exists(): + print(f"\n✓ OAuth app with client_id '{client_id}' already exists!") + app = APIApplication.objects.get(client_id=client_id) + print(f"\nApplication Details:") + print(f" Name: {app.name}") + print(f" Description: {app.description}") + print(f" Redirect URIs: {app.redirect_uris}") + print(f" Client ID: {app.client_id}") + print(f" Client Secret: {app.client_secret}") + print(f" Grant Type: {app.authorization_grant_type}") + print(f" Client Type: {app.client_type}") +else: + # Get the first user (admin) + user = User.objects.first() + if not user: + print("ERROR: No users found in database!") + print("Please run: python manage.py load_fixtures --development") + sys.exit(1) + + # Create the new OAuth application + app = APIApplication.objects.create( + client_id=client_id, + client_secret=client_secret, + user=user, + redirect_uris='http://localhost:5173/auth/callback', + client_type='public', + authorization_grant_type='authorization-code', + name='Advent of Code Leaderboard', + description='Abakus Advent of Code Leaderboard Integration', + skip_authorization=False + ) + + print("\n✓ Successfully created new OAuth application!") + print(f"\nApplication Details:") + print(f" Name: {app.name}") + print(f" Description: {app.description}") + print(f" Client ID: {app.client_id}") + print(f" Client Secret: {app.client_secret}") + print(f" Redirect URIs: {app.redirect_uris}") + +print("\n" + "=" * 60) +print("Next Steps:") +print("=" * 60) +print("\n1. Your aoc-abakus-sv/.env should have:") +print(f" ABAKUS_CLIENT_ID={app.client_id}") +print(f" ABAKUS_CLIENT_SECRET={app.client_secret}") +print("\n2. Restart your lego server to load the new 'aoc' scope") +print("\n3. Test the OAuth flow in aoc-abakus-sv") +print("\n" + "=" * 60) + +# Check for existing OAuth apps +print("\nAll OAuth Applications in Database:") +print("-" * 60) +for app in APIApplication.objects.all(): + print(f" • {app.name} (ID: {app.client_id[:20]}...)") +print("-" * 60) From be2fd532030d5e26eedc4f873d7d45ddad2293c9 Mon Sep 17 00:00:00 2001 From: Viljen Date: Sat, 7 Feb 2026 16:17:24 +0100 Subject: [PATCH 2/6] Add parsing to Redirect URLs for applications, and wildcard support, with tests --- lego/apps/oauth/tests/test_models.py | 73 ++++++++++++++++++++++++++++ lego/apps/users/serializers/users.py | 16 ++++++ 2 files changed, 89 insertions(+) diff --git a/lego/apps/oauth/tests/test_models.py b/lego/apps/oauth/tests/test_models.py index dbc396578..a13472713 100644 --- a/lego/apps/oauth/tests/test_models.py +++ b/lego/apps/oauth/tests/test_models.py @@ -11,3 +11,76 @@ def test_initial_application(self): api_app = APIApplication.objects.get(pk=1) self.assertTrue(len(api_app.description)) self.assertEqual(api_app.authorization_grant_type, Application.GRANT_PASSWORD) + + +class RedirectURIAllowedTestCase(BaseTestCase): + + def setUp(self): + self.app = APIApplication( + name="Test App", + client_type=APIApplication.CLIENT_PUBLIC, + authorization_grant_type=APIApplication.GRANT_AUTHORIZATION_CODE, + ) + + def test_exact_match(self): + self.app.redirect_uris = "https://example.com/callback" + self.assertTrue(self.app.redirect_uri_allowed("https://example.com/callback")) + self.assertFalse(self.app.redirect_uri_allowed("https://example.com/other")) + + def test_space_separated_uris(self): + self.app.redirect_uris = "https://a.com/cb https://b.com/cb" + self.assertTrue(self.app.redirect_uri_allowed("https://a.com/cb")) + self.assertTrue(self.app.redirect_uri_allowed("https://b.com/cb")) + self.assertFalse(self.app.redirect_uri_allowed("https://c.com/cb")) + + def test_comma_separated_uris(self): + self.app.redirect_uris = "https://a.com/cb, https://b.com/cb" + self.assertTrue(self.app.redirect_uri_allowed("https://a.com/cb")) + self.assertTrue(self.app.redirect_uri_allowed("https://b.com/cb")) + self.assertFalse(self.app.redirect_uri_allowed("https://c.com/cb")) + + def test_mixed_comma_space_separated(self): + self.app.redirect_uris = "https://a.com/cb, https://b.com/cb https://c.com/cb" + self.assertTrue(self.app.redirect_uri_allowed("https://a.com/cb")) + self.assertTrue(self.app.redirect_uri_allowed("https://b.com/cb")) + self.assertTrue(self.app.redirect_uri_allowed("https://c.com/cb")) + + def test_path_wildcard(self): + self.app.redirect_uris = "https://example.com/*" + self.assertTrue(self.app.redirect_uri_allowed("https://example.com/callback")) + self.assertTrue(self.app.redirect_uri_allowed("https://example.com/any/path")) + self.assertFalse(self.app.redirect_uri_allowed("https://other.com/callback")) + + def test_subdomain_wildcard(self): + self.app.redirect_uris = "https://*.example.com/callback" + self.assertTrue(self.app.redirect_uri_allowed("https://app.example.com/callback")) + self.assertTrue(self.app.redirect_uri_allowed("https://api.example.com/callback")) + self.assertFalse(self.app.redirect_uri_allowed("https://example.com/callback")) + self.assertFalse(self.app.redirect_uri_allowed("https://app.other.com/callback")) + + def test_combined_wildcards(self): + self.app.redirect_uris = "https://*.example.com/*" + self.assertTrue(self.app.redirect_uri_allowed("https://app.example.com/callback")) + self.assertTrue(self.app.redirect_uri_allowed("https://api.example.com/any/path")) + self.assertFalse(self.app.redirect_uri_allowed("https://example.com/callback")) + + def test_scheme_must_match(self): + self.app.redirect_uris = "https://example.com/callback" + self.assertFalse(self.app.redirect_uri_allowed("http://example.com/callback")) + + def test_port_matching(self): + self.app.redirect_uris = "https://example.com:8080/callback" + self.assertTrue(self.app.redirect_uri_allowed("https://example.com:8080/callback")) + self.assertFalse(self.app.redirect_uri_allowed("https://example.com:9090/callback")) + self.assertFalse(self.app.redirect_uri_allowed("https://example.com/callback")) + + def test_empty_uri(self): + self.app.redirect_uris = "https://example.com/callback" + self.assertFalse(self.app.redirect_uri_allowed("")) + self.assertFalse(self.app.redirect_uri_allowed(None)) + + def test_invalid_uri(self): + self.app.redirect_uris = "https://example.com/callback" + self.assertFalse(self.app.redirect_uri_allowed("not-a-valid-uri")) + self.assertFalse(self.app.redirect_uri_allowed("/relative/path")) + diff --git a/lego/apps/users/serializers/users.py b/lego/apps/users/serializers/users.py index 72dcc61de..07ecea297 100644 --- a/lego/apps/users/serializers/users.py +++ b/lego/apps/users/serializers/users.py @@ -290,3 +290,19 @@ class ChangeGradeSerializer(serializers.Serializer): allow_null=True, queryset=AbakusGroup.objects.all().filter(type=constants.GROUP_GRADE), ) + +class MinimalUserSerializer(serializers.ModelSerializer): + """ + Minimal serializer for listing users with only essential fields. + Used by Advent of Code integration + """ + + class Meta: + model = User + fields = ( + "id", + "username", + "full_name", + "github_username", + ) + read_only_fields = fields \ No newline at end of file From b53ee43405344f0aee02ee22cebece7713e64587 Mon Sep 17 00:00:00 2001 From: Viljen Date: Sat, 7 Feb 2026 16:32:31 +0100 Subject: [PATCH 3/6] Add correct models file, and update redirect URI --- lego/apps/oauth/models.py | 49 ++++++++++++++++++++++++++++ lego/apps/oauth/tests/test_models.py | 29 +++++++++++----- lego/apps/users/serializers/users.py | 16 --------- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/lego/apps/oauth/models.py b/lego/apps/oauth/models.py index c1e6022a3..1d5e5a2c6 100644 --- a/lego/apps/oauth/models.py +++ b/lego/apps/oauth/models.py @@ -1,3 +1,6 @@ +import fnmatch +from urllib.parse import urlparse + from django.db import models from oauth2_provider.models import AbstractApplication @@ -12,3 +15,49 @@ class APIApplication(AbstractApplication): class Meta: permission_handler = APIApplicationPermissionHandler() + + def redirect_uri_allowed(self, uri): + """ + Check if a given URI matches one of the allowed redirect URIs. + Supports: + - Space-separated URIs + - Comma-separated URIs + - Wildcards (*) in host and path using fnmatch + + Allowed patterns: + - https://example.com/callback + - https://*.example.com/callback + - https://example.com/* + - https://*.example.com/* + """ + if not uri: + return False + + # Support both space and comma separated URIs + raw_uris = self.redirect_uris.replace(",", " ") + allowed_uris = [u.strip() for u in raw_uris.split() if u.strip()] + + parsed_uri = urlparse(uri) + if not parsed_uri.scheme or not parsed_uri.netloc: + return False + + for allowed_uri in allowed_uris: + parsed_allowed = urlparse(allowed_uri) + + if parsed_allowed.scheme != parsed_uri.scheme: + continue + + if not fnmatch.fnmatch(parsed_uri.hostname or "", parsed_allowed.hostname or ""): + continue + + allowed_path = parsed_allowed.path or "/" + uri_path = parsed_uri.path or "/" + if not fnmatch.fnmatch(uri_path, allowed_path): + continue + + if parsed_allowed.port and parsed_allowed.port != parsed_uri.port: + continue + + return True + + return False diff --git a/lego/apps/oauth/tests/test_models.py b/lego/apps/oauth/tests/test_models.py index a13472713..e5432fdd0 100644 --- a/lego/apps/oauth/tests/test_models.py +++ b/lego/apps/oauth/tests/test_models.py @@ -53,15 +53,25 @@ def test_path_wildcard(self): def test_subdomain_wildcard(self): self.app.redirect_uris = "https://*.example.com/callback" - self.assertTrue(self.app.redirect_uri_allowed("https://app.example.com/callback")) - self.assertTrue(self.app.redirect_uri_allowed("https://api.example.com/callback")) + self.assertTrue( + self.app.redirect_uri_allowed("https://app.example.com/callback") + ) + self.assertTrue( + self.app.redirect_uri_allowed("https://api.example.com/callback") + ) self.assertFalse(self.app.redirect_uri_allowed("https://example.com/callback")) - self.assertFalse(self.app.redirect_uri_allowed("https://app.other.com/callback")) + self.assertFalse( + self.app.redirect_uri_allowed("https://app.other.com/callback") + ) def test_combined_wildcards(self): self.app.redirect_uris = "https://*.example.com/*" - self.assertTrue(self.app.redirect_uri_allowed("https://app.example.com/callback")) - self.assertTrue(self.app.redirect_uri_allowed("https://api.example.com/any/path")) + self.assertTrue( + self.app.redirect_uri_allowed("https://app.example.com/callback") + ) + self.assertTrue( + self.app.redirect_uri_allowed("https://api.example.com/any/path") + ) self.assertFalse(self.app.redirect_uri_allowed("https://example.com/callback")) def test_scheme_must_match(self): @@ -70,8 +80,12 @@ def test_scheme_must_match(self): def test_port_matching(self): self.app.redirect_uris = "https://example.com:8080/callback" - self.assertTrue(self.app.redirect_uri_allowed("https://example.com:8080/callback")) - self.assertFalse(self.app.redirect_uri_allowed("https://example.com:9090/callback")) + self.assertTrue( + self.app.redirect_uri_allowed("https://example.com:8080/callback") + ) + self.assertFalse( + self.app.redirect_uri_allowed("https://example.com:9090/callback") + ) self.assertFalse(self.app.redirect_uri_allowed("https://example.com/callback")) def test_empty_uri(self): @@ -83,4 +97,3 @@ def test_invalid_uri(self): self.app.redirect_uris = "https://example.com/callback" self.assertFalse(self.app.redirect_uri_allowed("not-a-valid-uri")) self.assertFalse(self.app.redirect_uri_allowed("/relative/path")) - diff --git a/lego/apps/users/serializers/users.py b/lego/apps/users/serializers/users.py index 07ecea297..72dcc61de 100644 --- a/lego/apps/users/serializers/users.py +++ b/lego/apps/users/serializers/users.py @@ -290,19 +290,3 @@ class ChangeGradeSerializer(serializers.Serializer): allow_null=True, queryset=AbakusGroup.objects.all().filter(type=constants.GROUP_GRADE), ) - -class MinimalUserSerializer(serializers.ModelSerializer): - """ - Minimal serializer for listing users with only essential fields. - Used by Advent of Code integration - """ - - class Meta: - model = User - fields = ( - "id", - "username", - "full_name", - "github_username", - ) - read_only_fields = fields \ No newline at end of file From 4aa9062d5f23d5145ee7955dd02620ad1d9888d3 Mon Sep 17 00:00:00 2001 From: Viljen Date: Sat, 7 Feb 2026 16:36:22 +0100 Subject: [PATCH 4/6] Remove local aoc scripts from PR --- AOC_INTEGRATION.md | 193 -------------------------------------------- setup_aoc_oauth.py | 175 --------------------------------------- update_oauth_app.py | 79 ------------------ 3 files changed, 447 deletions(-) delete mode 100644 AOC_INTEGRATION.md delete mode 100644 setup_aoc_oauth.py delete mode 100644 update_oauth_app.py diff --git a/AOC_INTEGRATION.md b/AOC_INTEGRATION.md deleted file mode 100644 index 85e616544..000000000 --- a/AOC_INTEGRATION.md +++ /dev/null @@ -1,193 +0,0 @@ -# Lego - AOC Integration Changes - -This document describes the changes needed in lego to support the AOC leaderboard integration. - -## Summary - -The AOC leaderboard needs minimal user data (name, username, GitHub username) from Abakus users. This integration adds a new OAuth scope "aoc" that provides limited access to user data. - -## Changes Made - -### 1. New OAuth Scope: "aoc" - -**File**: `lego/settings/base.py` (line ~144) - -Added a new OAuth scope that requests minimal permissions: - -```python -"aoc": ( - "Minimal brukerinfo for Advent of Code leaderboard. " - "Gir lesetilgang til navn, brukernavn og GitHub-brukernavn" -), -``` - -**Why**: The default "user" scope requests too many permissions (email, profile picture, gender, memberships). The "aoc" scope only requests what's needed. - -### 2. MinimalUserSerializer - -**File**: `lego/apps/users/serializers/users.py` (line ~46) - -Added a new serializer that returns only essential fields: - -```python -class MinimalUserSerializer(serializers.ModelSerializer): - """ - Minimal serializer for listing users with only essential fields. - Used by Advent of Code integration - """ - - class Meta: - model = User - fields = ( - "id", - "username", - "full_name", - "github_username", - ) - read_only_fields = fields -``` - -**Why**: Returns only the fields needed for AOC matching, reducing data exposure and improving performance. - -### 3. Users API Endpoint Updates - -**File**: `lego/apps/users/views/users.py` - -#### 3a. Support for `?minimal=true` query parameter (line ~50) - -```python -def get_serializer_class(self): - # Support minimal query parameter for list actions - if self.action == "list" and self.request.query_params.get("minimal") == "true": - return MinimalUserSerializer - # ... rest of method -``` - -**Endpoint**: `GET /api/v1/users/?minimal=true` - -**Why**: Allows clients to request only minimal user data when fetching user lists. - -#### 3b. Scope-aware oauth2_userdata endpoint (line ~89) - -```python -def oauth2_userdata(self, request): - """ - Read-only endpoint used to retrieve information about the authenticated user. - Returns minimal data if the OAuth scope is 'aoc'. - """ - # Check if the request is using the 'aoc' scope - token = getattr(request.auth, 'token', None) if hasattr(request, 'auth') else None - scope = token.scope if token and hasattr(token, 'scope') else None - - if scope and 'aoc' in scope: - # Use minimal serializer for aoc scope - serializer = MinimalUserSerializer(request.user) - else: - # Use default OAuth2 serializer - serializer = self.get_serializer(request.user) - - return Response(serializer.data) -``` - -**Why**: Automatically returns minimal data when using "aoc" scope, enforcing least-privilege access. - -### 4. OAuth Authentication Updates - -**File**: `lego/apps/oauth/authentication.py` (line ~30) - -Added "aoc" scope to allowed scopes: - -```python -# Allow "aoc" scope for oauth2_userdata and users list endpoints -if token.allow_scopes(["aoc"]): - allowed_paths = [ - "/api/v1/users/oauth2_userdata/", - "/api/v1/users/" - ] - if request.path in allowed_paths: - return authentication -``` - -**Why**: Allows OAuth tokens with "aoc" scope to access the necessary endpoints. - -## API Usage - -### For AOC Integration - -**1. OAuth Flow:** -``` -GET /authorization/oauth2/authorize/?client_id=xxx&scope=aoc&redirect_uri=xxx&... -``` - -**2. Get current user (minimal):** -``` -GET /api/v1/users/oauth2_userdata/ -Authorization: Bearer - -Response: -{ - "id": 123, - "username": "viljen", - "full_name": "Viljen Jensen", - "github_username": "viljen" -} -``` - -**3. Get all users (minimal):** -``` -GET /api/v1/users/?minimal=true -Authorization: Bearer - -Response: -{ - "results": [ - { - "id": 123, - "username": "viljen", - "full_name": "Viljen Jensen", - "github_username": "viljen" - }, - ... - ] -} -``` - -## Deployment Checklist - -When deploying to staging/production: - -- [ ] Apply all 4 code changes listed above -- [ ] Restart lego server to load new scope -- [ ] Run `setup_aoc_oauth.py` script to create OAuth application -- [ ] Test OAuth flow with "aoc" scope -- [ ] Verify minimal data is returned - -## Security Considerations - -1. **Least Privilege**: The "aoc" scope only grants access to minimal user data -2. **Limited Endpoints**: OAuth authentication restricts "aoc" tokens to specific endpoints only -3. **No Write Access**: All endpoints are read-only for "aoc" scope -4. **User Consent**: Users must explicitly approve access during OAuth flow - -## Backward Compatibility - -These changes are fully backward compatible: -- Existing "user" and "all" scopes work unchanged -- Existing OAuth applications are not affected -- The MinimalUserSerializer is only used when explicitly requested -- No changes to existing API responses - -## Testing - -```bash -# Test OAuth flow with aoc scope -curl -X GET "http://localhost:8000/authorization/oauth2/authorize/?client_id=xxx&scope=aoc&..." - -# Test minimal user data endpoint -curl -X GET "http://localhost:8000/api/v1/users/oauth2_userdata/" \ - -H "Authorization: Bearer " - -# Test minimal users list -curl -X GET "http://localhost:8000/api/v1/users/?minimal=true" \ - -H "Authorization: Bearer " -``` diff --git a/setup_aoc_oauth.py b/setup_aoc_oauth.py deleted file mode 100644 index 355ab0810..000000000 --- a/setup_aoc_oauth.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env python -""" -Setup script for AOC OAuth application in any environment (dev, staging, production) - -Usage: - python setup_aoc_oauth.py --env dev --redirect http://localhost:5173/auth/callback - python setup_aoc_oauth.py --env staging --redirect https://aoc.abakus.no/auth/callback - python setup_aoc_oauth.py --env production --redirect https://aoc.abakus.no/auth/callback -""" -import os -import sys -import django -import argparse -import secrets - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'lego.settings') -django.setup() - -from lego.apps.oauth.models import APIApplication -from lego.apps.users.models import User - - -def generate_secure_credentials(): - """Generate cryptographically secure client credentials""" - client_id = f"aoc_{secrets.token_urlsafe(32)}" - client_secret = secrets.token_urlsafe(64) - return client_id, client_secret - - -def setup_oauth_app(redirect_uri, use_existing=False): - """ - Set up or update the AOC OAuth application - - Args: - redirect_uri: The redirect URI for the OAuth callback - use_existing: If True, update existing app instead of creating new one - """ - app_name = 'Advent of Code Leaderboard' - - print("=" * 70) - print(f"Setting up OAuth Application: {app_name}") - print("=" * 70) - - # Check if app already exists - existing_app = APIApplication.objects.filter(name=app_name).first() - - if existing_app and not use_existing: - print(f"\nAn OAuth app named '{app_name}' already exists!") - print(f"\nExisting Application Details:") - print(f" Client ID: {existing_app.client_id}") - print(f" Redirect URI: {existing_app.redirect_uris}") - - response = input("\nDo you want to update it? (yes/no): ").lower() - if response != 'yes': - print("Aborted.") - return existing_app - - # Update existing app - existing_app.redirect_uris = redirect_uri - existing_app.save() - print("\n✓ Updated existing OAuth application!") - return existing_app - - elif existing_app and use_existing: - # Update existing app - existing_app.redirect_uris = redirect_uri - existing_app.save() - print("\n✓ Updated existing OAuth application!") - return existing_app - - else: - # Create new app - user = User.objects.first() - if not user: - print("ERROR: No users found in database!") - print("Please create at least one user first.") - sys.exit(1) - - # Generate secure credentials - client_id, client_secret = generate_secure_credentials() - - app = APIApplication.objects.create( - client_id=client_id, - client_secret=client_secret, - user=user, - redirect_uris=redirect_uri, - client_type='public', - authorization_grant_type='authorization-code', - name=app_name, - description='Abakus Advent of Code Leaderboard Integration', - skip_authorization=False - ) - - print("\n✓ Successfully created new OAuth application!") - return app - - -def main(): - parser = argparse.ArgumentParser( - description='Setup AOC OAuth application for different environments' - ) - parser.add_argument( - '--env', - choices=['dev', 'staging', 'production'], - default='dev', - help='Environment to configure (dev, staging, production)' - ) - parser.add_argument( - '--redirect', - required=True, - help='Redirect URI for OAuth callback (e.g., http://localhost:5173/auth/callback)' - ) - parser.add_argument( - '--update', - action='store_true', - help='Update existing app instead of creating new one' - ) - - args = parser.parse_args() - - print(f"\nEnvironment: {args.env.upper()}") - print(f"Redirect URI: {args.redirect}") - print() - - # Setup the OAuth app - app = setup_oauth_app(args.redirect, use_existing=args.update) - - # Display results - print("\n" + "=" * 70) - print("OAuth Application Details") - print("=" * 70) - print(f"\n Name: {app.name}") - print(f" Description: {app.description}") - print(f" Client ID: {app.client_id}") - print(f" Client Secret: {app.client_secret}") - print(f" Redirect URI: {app.redirect_uris}") - print(f" Grant Type: {app.authorization_grant_type}") - - # Environment-specific instructions - print("\n" + "=" * 70) - print(f"Configuration for {args.env.upper()} Environment") - print("=" * 70) - - if args.env == 'dev': - api_url = 'http://localhost:8000' - elif args.env == 'staging': - api_url = 'https://staging.abakus.no' # Replace with actual staging URL - else: # production - api_url = 'https://abakus.no' - - print(f"\nAdd these to your aoc-abakus-sv .env file:\n") - print(f"ABAKUS_API_URL={api_url}") - print(f"ABAKUS_CLIENT_ID={app.client_id}") - print(f"ABAKUS_CLIENT_SECRET={app.client_secret}") - print(f"ABAKUS_REDIRECT_URI={args.redirect}") - - print("\n" + "=" * 70) - print("Next Steps") - print("=" * 70) - print("\n1. Add the environment variables above to your .env file") - print("2. Restart your aoc-abakus-sv application") - print("3. Test the OAuth flow") - - if args.env in ['staging', 'production']: - print("\n IMPORTANT for staging/production:") - print(" - Make sure lego is running and accessible at the API URL") - print(" - Ensure the redirect URI is accessible from the internet") - print(" - The 'aoc' scope must be configured in lego settings") - print(" - OAuth authentication.py must allow 'aoc' scope") - - print("\n" + "=" * 70) - - -if __name__ == '__main__': - main() diff --git a/update_oauth_app.py b/update_oauth_app.py deleted file mode 100644 index 46d235314..000000000 --- a/update_oauth_app.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python -""" -Quick script to create a new OAuth application for AOC -""" -import os -import sys -import django - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'lego.settings') -django.setup() - -from lego.apps.oauth.models import APIApplication -from lego.apps.users.models import User - -# New OAuth app credentials -client_id = 'aoc_leaderboard_client_id_12345' -client_secret = 'aoc_leaderboard_secret_67890_abcdefghijklmnop' - -print("=" * 60) -print("AOC OAuth Application Setup") -print("=" * 60) - -# Check if it already exists -if APIApplication.objects.filter(client_id=client_id).exists(): - print(f"\n✓ OAuth app with client_id '{client_id}' already exists!") - app = APIApplication.objects.get(client_id=client_id) - print(f"\nApplication Details:") - print(f" Name: {app.name}") - print(f" Description: {app.description}") - print(f" Redirect URIs: {app.redirect_uris}") - print(f" Client ID: {app.client_id}") - print(f" Client Secret: {app.client_secret}") - print(f" Grant Type: {app.authorization_grant_type}") - print(f" Client Type: {app.client_type}") -else: - # Get the first user (admin) - user = User.objects.first() - if not user: - print("ERROR: No users found in database!") - print("Please run: python manage.py load_fixtures --development") - sys.exit(1) - - # Create the new OAuth application - app = APIApplication.objects.create( - client_id=client_id, - client_secret=client_secret, - user=user, - redirect_uris='http://localhost:5173/auth/callback', - client_type='public', - authorization_grant_type='authorization-code', - name='Advent of Code Leaderboard', - description='Abakus Advent of Code Leaderboard Integration', - skip_authorization=False - ) - - print("\n✓ Successfully created new OAuth application!") - print(f"\nApplication Details:") - print(f" Name: {app.name}") - print(f" Description: {app.description}") - print(f" Client ID: {app.client_id}") - print(f" Client Secret: {app.client_secret}") - print(f" Redirect URIs: {app.redirect_uris}") - -print("\n" + "=" * 60) -print("Next Steps:") -print("=" * 60) -print("\n1. Your aoc-abakus-sv/.env should have:") -print(f" ABAKUS_CLIENT_ID={app.client_id}") -print(f" ABAKUS_CLIENT_SECRET={app.client_secret}") -print("\n2. Restart your lego server to load the new 'aoc' scope") -print("\n3. Test the OAuth flow in aoc-abakus-sv") -print("\n" + "=" * 60) - -# Check for existing OAuth apps -print("\nAll OAuth Applications in Database:") -print("-" * 60) -for app in APIApplication.objects.all(): - print(f" • {app.name} (ID: {app.client_id[:20]}...)") -print("-" * 60) From 42ded7dcec605cf8bfe5cfe8a9340ffceaa4fab5 Mon Sep 17 00:00:00 2001 From: Viljen Date: Sat, 7 Feb 2026 16:37:19 +0100 Subject: [PATCH 5/6] fixme --- lego/apps/oauth/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lego/apps/oauth/models.py b/lego/apps/oauth/models.py index 1d5e5a2c6..6cac353da 100644 --- a/lego/apps/oauth/models.py +++ b/lego/apps/oauth/models.py @@ -47,7 +47,9 @@ def redirect_uri_allowed(self, uri): if parsed_allowed.scheme != parsed_uri.scheme: continue - if not fnmatch.fnmatch(parsed_uri.hostname or "", parsed_allowed.hostname or ""): + if not fnmatch.fnmatch( + parsed_uri.hostname or "", parsed_allowed.hostname or "" + ): continue allowed_path = parsed_allowed.path or "/" From 14347834c08c6bc3d4a92fb8a261d63737e2b51c Mon Sep 17 00:00:00 2001 From: Viljen Date: Sat, 7 Feb 2026 17:02:09 +0100 Subject: [PATCH 6/6] Add universal checks to avoid wildcard abuse --- lego/apps/oauth/models.py | 12 ++++++++++++ lego/apps/oauth/tests/test_models.py | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/lego/apps/oauth/models.py b/lego/apps/oauth/models.py index 6cac353da..e9c610333 100644 --- a/lego/apps/oauth/models.py +++ b/lego/apps/oauth/models.py @@ -29,6 +29,11 @@ def redirect_uri_allowed(self, uri): - https://*.example.com/callback - https://example.com/* - https://*.example.com/* + + Not Allowed: + - * + - https://* + - https://*.com """ if not uri: return False @@ -44,6 +49,13 @@ def redirect_uri_allowed(self, uri): for allowed_uri in allowed_uris: parsed_allowed = urlparse(allowed_uri) + # Check to avoid universal links such as https://*, to avoid wildcard abuse + allowed_host = parsed_allowed.hostname or "" + if allowed_host == "*" or ( + allowed_host.startswith("*.") and len(allowed_host.split(".")) < 3 + ): + continue + if parsed_allowed.scheme != parsed_uri.scheme: continue diff --git a/lego/apps/oauth/tests/test_models.py b/lego/apps/oauth/tests/test_models.py index e5432fdd0..8d8df58ee 100644 --- a/lego/apps/oauth/tests/test_models.py +++ b/lego/apps/oauth/tests/test_models.py @@ -88,6 +88,12 @@ def test_port_matching(self): ) self.assertFalse(self.app.redirect_uri_allowed("https://example.com/callback")) + def test_global_wildcard(self): + self.app.redirect_uris = "https://*" + self.assertFalse(self.app.redirect_uri_allowed("https://example.com/callback")) + self.assertFalse(self.app.redirect_uri_allowed("/")) + self.assertFalse(self.app.redirect_uri_allowed("https://*.com")) + def test_empty_uri(self): self.app.redirect_uris = "https://example.com/callback" self.assertFalse(self.app.redirect_uri_allowed(""))