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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions awx/main/migrations/0213_oauth2accesstoken_resource_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Generated by Django 6.1.1 on 2026-09-06 14:04

import django.db.models.deletion
import oauth2_provider.generators
import oauth2_provider.models
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('main', '0212_inventory_allow_deletes_while_in_use'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL),
]

operations = [
migrations.AddField(
model_name='oauth2accesstoken',
name='resource',
field=oauth2_provider.models.ResourceJSONField(blank=True, default=list, verbose_name='resource'),
),
migrations.AddField(
model_name='oauth2application',
name='cimd_expires_at',
field=models.DateTimeField(
blank=True,
default=None,
help_text='When the cached Client ID Metadata Document should be re-fetched',
null=True,
verbose_name='CIMD expires at',
),
),
migrations.AddField(
model_name='oauth2application',
name='registration_source',
field=models.CharField(
choices=[('manual', 'Manual'), ('dcr', 'Dynamic Client Registration'), ('cimd', 'Client ID Metadata Document')],
default='manual',
help_text='How this application was registered (manual, DCR per RFC 7591, or CIMD)',
max_length=32,
verbose_name='registration source',
),
),
migrations.AlterField(
model_name='oauth2accesstoken',
name='application',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, verbose_name='application'
),
),
migrations.AlterField(
model_name='oauth2accesstoken',
name='created',
field=models.DateTimeField(auto_now_add=True, verbose_name='created'),
),
migrations.AlterField(
model_name='oauth2accesstoken',
name='expires',
field=models.DateTimeField(verbose_name='expires'),
),
migrations.AlterField(
model_name='oauth2accesstoken',
name='id_token',
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='access_token',
to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL,
verbose_name='ID token',
),
),
migrations.AlterField(
model_name='oauth2accesstoken',
name='source_refresh_token',
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='refreshed_access_token',
to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL,
verbose_name='source refresh token',
),
),
migrations.AlterField(
model_name='oauth2accesstoken',
name='token',
field=models.TextField(verbose_name='token'),
),
migrations.AlterField(
model_name='oauth2accesstoken',
name='token_checksum',
field=oauth2_provider.models.TokenChecksumField(db_index=True, max_length=64, unique=True, verbose_name='token checksum'),
),
migrations.AlterField(
model_name='oauth2accesstoken',
name='updated',
field=models.DateTimeField(auto_now=True, verbose_name='updated'),
),
migrations.AlterField(
model_name='oauth2application',
name='algorithm',
field=models.CharField(
blank=True,
choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')],
default='',
max_length=5,
verbose_name='algorithm',
),
),
migrations.AlterField(
model_name='oauth2application',
name='allowed_origins',
field=models.TextField(blank=True, default='', help_text='Allowed origins list to enable CORS, space separated', verbose_name='allowed origins'),
),
migrations.AlterField(
model_name='oauth2application',
name='client_id',
field=models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=255, unique=True, verbose_name='client ID'),
),
migrations.AlterField(
model_name='oauth2application',
name='created',
field=models.DateTimeField(auto_now_add=True, verbose_name='created'),
),
migrations.AlterField(
model_name='oauth2application',
name='name',
field=models.CharField(blank=True, max_length=255, verbose_name='name'),
),
migrations.AlterField(
model_name='oauth2application',
name='post_logout_redirect_uris',
field=models.TextField(
blank=True, default='', help_text='Allowed Post Logout URIs list, space separated', verbose_name='post logout redirect URIs'
),
),
migrations.AlterField(
model_name='oauth2application',
name='redirect_uris',
field=models.TextField(blank=True, help_text='Allowed URIs list, space separated', verbose_name='redirect URIs'),
),
migrations.AlterField(
model_name='oauth2application',
name='updated',
field=models.DateTimeField(auto_now=True, verbose_name='updated'),
),
migrations.AlterField(
model_name='oauth2application',
name='user',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='%(app_label)s_%(class)s',
to=settings.AUTH_USER_MODEL,
verbose_name='user',
),
),
]
51 changes: 48 additions & 3 deletions awx/main/tests/functional/api/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,8 @@ def test_refresh_accesstoken(oauth_application, post, get, delete, admin):
content_type='application/x-www-form-urlencoded',
HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))),
)
assert RefreshToken.objects.filter(token=refresh_token).exists()
original_refresh_token = RefreshToken.objects.get(token=refresh_token)
assert RefreshToken.objects.filter(token=refresh_token.token).exists()
original_refresh_token = RefreshToken.objects.get(token=refresh_token.token)
assert token not in AccessToken.objects.all()
assert AccessToken.objects.count() == 1
# the same RefreshToken remains but is marked revoked
Expand Down Expand Up @@ -309,7 +309,7 @@ def test_refresh_token_expiration_is_respected(oauth_application, post, get, del
)
assert response.status_code == 403
assert b'The refresh token has expired.' in response.content
assert RefreshToken.objects.filter(token=refresh_token).exists()
assert RefreshToken.objects.filter(token=refresh_token.token).exists()
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1

Expand Down Expand Up @@ -345,3 +345,48 @@ def test_revoke_refreshtoken(oauth_application, post, get, delete, admin):
new_refresh_token = RefreshToken.objects.all().first()
assert refresh_token == new_refresh_token
assert new_refresh_token.revoked


@pytest.mark.django_db
def test_rotated_refresh_token_cannot_be_reused(oauth_application, post, admin):
"""A refresh grant rotates: the superseded token is kept with a revoked
timestamp rather than deleted, so presenting it again has to be refused."""
response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201)
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
auth = 'Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret]))))

first = post(
refresh_url,
data='grant_type=refresh_token&refresh_token=' + refresh_token.token,
content_type='application/x-www-form-urlencoded',
HTTP_AUTHORIZATION=auth,
)
assert first.status_code == 200
superseded = RefreshToken.objects.get(token=refresh_token.token)
assert superseded.revoked is not None
assert AccessToken.objects.count() == 1

replay = post(
refresh_url,
data='grant_type=refresh_token&refresh_token=' + refresh_token.token,
content_type='application/x-www-form-urlencoded',
HTTP_AUTHORIZATION=auth,
)
assert replay.status_code == 400
assert json.loads(replay.content)['error'] == 'invalid_grant'
assert AccessToken.objects.count() == 1


@pytest.mark.django_db
def test_token_values_are_not_rendered_by_str(oauth_application, post, admin):
"""django-oauth-toolkit 3.4 stopped putting the secret in __str__, which
reaches admin breadcrumbs, tracebacks and log output. Nothing in awx relies
on the old behaviour, and this keeps it that way."""
response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201)
access_token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])

for obj in (access_token, refresh_token):
assert obj.token not in str(obj)
assert obj.token not in repr(obj)
2 changes: 1 addition & 1 deletion requirements/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ django>=6.1.1 # floor was 5.2.17 for CVE-2026-15307 CVE-2026-15830, both long fi
django-auth-ldap
django-cors-headers
django-guid>=3.6.1
django-oauth-toolkit>=3.3.0
django-oauth-toolkit>=3.4.1
django-polymorphic
django-pgware
django-radius
Expand Down
3 changes: 2 additions & 1 deletion requirements/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ django-cors-headers==4.9.0
# via -r /awx_devel/requirements/requirements.in
django-guid==3.6.1
# via -r /awx_devel/requirements/requirements.in
django-oauth-toolkit==3.3.0
django-oauth-toolkit==3.4.1
# via -r /awx_devel/requirements/requirements.in
django-pgware==1.0.0
# via -r /awx_devel/requirements/requirements.in
Expand Down Expand Up @@ -443,6 +443,7 @@ urllib3==2.7.0
# via
# -r /awx_devel/requirements/requirements.in
# botocore
# django-oauth-toolkit
# kubernetes
# pygithub
# requests
Expand Down