From d8dafeee063df17814c113f37e2ce1342219eae2 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 15 Jan 2026 11:55:13 +0100 Subject: [PATCH 1/4] =?UTF-8?q?fix(oauth):=20Support=20public=20clients=20?= =?UTF-8?q?for=20token=20refresh=20per=20RFC=206749=20=C2=A76?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/web/frontend/oauth_token.py | 8 +- tests/sentry/web/frontend/test_oauth_token.py | 112 ++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/sentry/web/frontend/oauth_token.py b/src/sentry/web/frontend/oauth_token.py index 87765d2c03263d..862a87a2ade574 100644 --- a/src/sentry/web/frontend/oauth_token.py +++ b/src/sentry/web/frontend/oauth_token.py @@ -147,10 +147,12 @@ def post(self, request: Request) -> HttpResponse: }, ) - # Device flow supports public clients per RFC 8628 §5.6. + # Device flow and refresh token support public clients: + # - Device flow: RFC 8628 §5.6 - device clients should be treated as public clients + # - Refresh token: RFC 6749 §6 - public clients can refresh without client credentials # Public clients only provide client_id to identify themselves. # If client_secret is provided, we still validate it for confidential clients. - if grant_type == GrantTypes.DEVICE_CODE: + if grant_type in (GrantTypes.DEVICE_CODE, GrantTypes.REFRESH): if not client_id: return self.error( request=request, @@ -184,7 +186,7 @@ def post(self, request: Request) -> HttpResponse: status=401, ) else: - # Other grant types require confidential client authentication + # authorization_code grant requires confidential client authentication if not client_id or not client_secret: return self.error( request=request, diff --git a/tests/sentry/web/frontend/test_oauth_token.py b/tests/sentry/web/frontend/test_oauth_token.py index 46f4aa9266a461..a604e071c3609b 100644 --- a/tests/sentry/web/frontend/test_oauth_token.py +++ b/tests/sentry/web/frontend/test_oauth_token.py @@ -675,6 +675,118 @@ def test_inactive_application_rejects_token_refresh(self) -> None: assert token_after.refresh_token == self.token.refresh_token assert token_after.expires_at == self.token.expires_at + def test_public_client_refresh_success(self) -> None: + """Public clients should be able to refresh tokens without client_secret. + + Per RFC 6749 §6: "If the client type is confidential or the client was + issued client credentials (or assigned other authentication requirements), + the client MUST authenticate" + + The inverse: public clients without credentials can refresh with just client_id. + This is essential for device flow clients (RFC 8628 §5.6) which are public clients. + """ + self.login_as(self.user) + + # Request without client_secret (public client) + resp = self.client.post( + self.path, + { + "grant_type": "refresh_token", + "client_id": self.application.client_id, + "refresh_token": self.token.refresh_token, + # No client_secret - this is a public client + }, + ) + assert resp.status_code == 200 + + data = json.loads(resp.content) + assert "access_token" in data + assert "refresh_token" in data + assert data["token_type"] == "Bearer" + + # Token should be refreshed + token2 = ApiToken.objects.get(id=self.token.id) + assert token2.token != self.token.token + assert token2.refresh_token != self.token.refresh_token + + def test_public_client_refresh_invalid_client_id(self) -> None: + """Public client with invalid client_id should return invalid_client.""" + self.login_as(self.user) + + resp = self.client.post( + self.path, + { + "grant_type": "refresh_token", + "client_id": "nonexistent_client_id", + "refresh_token": self.token.refresh_token, + # No client_secret - public client + }, + ) + assert resp.status_code == 401 + assert json.loads(resp.content) == {"error": "invalid_client"} + + def test_public_client_refresh_missing_client_id(self) -> None: + """Refresh without client_id should return invalid_client.""" + self.login_as(self.user) + + resp = self.client.post( + self.path, + { + "grant_type": "refresh_token", + "refresh_token": self.token.refresh_token, + # No client_id or client_secret + }, + ) + assert resp.status_code == 401 + assert json.loads(resp.content) == {"error": "invalid_client"} + + def test_public_client_refresh_wrong_application(self) -> None: + """Refresh token from different application should return invalid_grant. + + Per RFC 6749 §6: "the refresh token is bound to the client to which it + was issued" + """ + self.login_as(self.user) + + # Create a different application + other_app = ApiApplication.objects.create( + owner=self.user, redirect_uris="https://other.com" + ) + + # Try to refresh with token from self.application but client_id from other_app + resp = self.client.post( + self.path, + { + "grant_type": "refresh_token", + "client_id": other_app.client_id, + "refresh_token": self.token.refresh_token, + # No client_secret - public client + }, + ) + assert resp.status_code == 400 + assert json.loads(resp.content) == {"error": "invalid_grant"} + + def test_confidential_client_refresh_wrong_secret_rejected(self) -> None: + """When client_secret is provided for refresh, it must be valid. + + Even though refresh_token supports public clients, when a client provides + client_secret, we should validate it. This allows confidential clients to + use refresh with full credential validation. + """ + self.login_as(self.user) + + resp = self.client.post( + self.path, + { + "grant_type": "refresh_token", + "client_id": self.application.client_id, + "refresh_token": self.token.refresh_token, + "client_secret": "wrong_secret", + }, + ) + assert resp.status_code == 401 + assert json.loads(resp.content) == {"error": "invalid_client"} + @control_silo_test class OAuthTokenOrganizationScopedTest(TestCase): From aad7da90d76498760463d8e531959e0a97d44d6d Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 15 Jan 2026 13:09:08 +0100 Subject: [PATCH 2/4] fix(oauth): Track issued_grant_type to support RFC-compliant public client refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 6749 §6, public clients (like CLI apps using device flow) should not require client_secret for token refresh. This adds issued_grant_type field to ApiToken to track how each token was issued: - device_code: Can refresh without client_secret (public client per RFC 8628) - authorization_code: Must provide client_secret (confidential client) - null (legacy): Treated as confidential for backward compatibility This ensures strict RFC compliance while maintaining security for confidential clients. --- .../1019_add_issued_grant_type_to_apitoken.py | 33 ++++++ src/sentry/models/apitoken.py | 11 +- src/sentry/web/frontend/oauth_token.py | 76 +++++++++++- tests/sentry/web/frontend/test_oauth_token.py | 112 ++++++++++++++---- 4 files changed, 202 insertions(+), 30 deletions(-) create mode 100644 src/sentry/migrations/1019_add_issued_grant_type_to_apitoken.py diff --git a/src/sentry/migrations/1019_add_issued_grant_type_to_apitoken.py b/src/sentry/migrations/1019_add_issued_grant_type_to_apitoken.py new file mode 100644 index 00000000000000..549c72503eba2d --- /dev/null +++ b/src/sentry/migrations/1019_add_issued_grant_type_to_apitoken.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.8 on 2025-01-15 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("sentry", "1018_encrypt_integration_metadata"), + ] + + operations = [ + migrations.AddField( + model_name="apitoken", + name="issued_grant_type", + field=models.CharField(max_length=50, null=True, blank=True), + ), + ] diff --git a/src/sentry/models/apitoken.py b/src/sentry/models/apitoken.py index 4f0b9803f5b54e..da3d302836d74a 100644 --- a/src/sentry/models/apitoken.py +++ b/src/sentry/models/apitoken.py @@ -202,6 +202,12 @@ class ApiToken(ReplicatedControlModel, HasApiScopes): hashed_refresh_token = models.CharField(max_length=128, unique=True, null=True) expires_at = models.DateTimeField(null=True, default=default_expiration) date_added = models.DateTimeField(default=timezone.now) + # Track how the token was originally issued (e.g., "device_code", "authorization_code"). + # Used to determine refresh behavior per RFC 6749 §6: + # - Tokens issued via device_code (public client per RFC 8628 §5.6) can refresh without client_secret + # - Tokens issued via authorization_code (confidential client) MUST provide client_secret for refresh + # Null for tokens created before this field was added (treated as confidential for backward compatibility). + issued_grant_type = models.CharField(max_length=50, null=True, blank=True) objects: ClassVar[ApiTokenManager] = ApiTokenManager(cache_fields=("token",)) @@ -439,6 +445,7 @@ def from_grant( user=grant.user, scope_list=grant.get_scopes(), scoping_organization_id=grant.organization_id, + issued_grant_type="authorization_code", ) # Remove the ApiGrant from the database to prevent reuse of the same @@ -580,7 +587,9 @@ def default_flush(self, value: bool) -> None: self._default_flush = value -def is_api_token_auth(auth: object) -> TypeGuard[AuthenticatedToken | ApiToken | ApiTokenReplica]: +def is_api_token_auth( + auth: object, +) -> TypeGuard[AuthenticatedToken | ApiToken | ApiTokenReplica]: """:returns True when an API token is hitting the API.""" from sentry.auth.services.auth import AuthenticatedToken from sentry.hybridcloud.models.apitokenreplica import ApiTokenReplica diff --git a/src/sentry/web/frontend/oauth_token.py b/src/sentry/web/frontend/oauth_token.py index 862a87a2ade574..8700d22b382d10 100644 --- a/src/sentry/web/frontend/oauth_token.py +++ b/src/sentry/web/frontend/oauth_token.py @@ -147,12 +147,10 @@ def post(self, request: Request) -> HttpResponse: }, ) - # Device flow and refresh token support public clients: - # - Device flow: RFC 8628 §5.6 - device clients should be treated as public clients - # - Refresh token: RFC 6749 §6 - public clients can refresh without client credentials + # Device flow supports public clients per RFC 8628 §5.6. # Public clients only provide client_id to identify themselves. # If client_secret is provided, we still validate it for confidential clients. - if grant_type in (GrantTypes.DEVICE_CODE, GrantTypes.REFRESH): + if grant_type == GrantTypes.DEVICE_CODE: if not client_id: return self.error( request=request, @@ -185,6 +183,47 @@ def post(self, request: Request) -> HttpResponse: reason=reason, status=401, ) + elif grant_type == GrantTypes.REFRESH: + # Refresh token: client authentication depends on how the token was issued. + # Per RFC 6749 §6: "If the client type is confidential or the client was issued + # client credentials, the client MUST authenticate." + # We determine client type by checking the token's issued_grant_type. + if not client_id: + return self.error( + request=request, + name="invalid_client", + reason="missing client_id", + status=401, + ) + + # Build query - validate secret only if provided + query: dict[str, str] = {"client_id": client_id} + if client_secret: + query["client_secret"] = client_secret + + try: + application = ApiApplication.objects.get(**query) + except ApiApplication.DoesNotExist: + metrics.incr("oauth_token.post.invalid", sample_rate=1.0) + if client_secret: + logger.warning( + "Invalid client_id / secret pair", + extra={"client_id": client_id}, + ) + reason = "invalid client_id or client_secret" + else: + logger.warning("Invalid client_id", extra={"client_id": client_id}) + reason = "invalid client_id" + return self.error( + request=request, + name="invalid_client", + reason=reason, + status=401, + ) + + # For refresh_token, we need to verify the client authentication matches + # how the token was issued. This check happens in get_refresh_token(). + # Pass whether client_secret was provided so we can enforce RFC 6749 §6. else: # authorization_code grant requires confidential client authentication if not client_id or not client_secret: @@ -275,7 +314,11 @@ def post(self, request: Request) -> HttpResponse: elif grant_type == GrantTypes.DEVICE_CODE: return self.handle_device_code_grant(request=request, application=application) else: - token_data = self.get_refresh_token(request=request, application=application) + token_data = self.get_refresh_token( + request=request, + application=application, + client_secret_provided=bool(client_secret), + ) if "error" in token_data: return self.error( request=request, @@ -420,7 +463,12 @@ def get_access_tokens(self, request: Request, application: ApiApplication) -> di return token_data - def get_refresh_token(self, request: Request, application: ApiApplication) -> dict: + def get_refresh_token( + self, + request: Request, + application: ApiApplication, + client_secret_provided: bool = True, + ) -> dict: refresh_token_code = request.POST.get("refresh_token") scope = request.POST.get("scope") @@ -437,6 +485,21 @@ def get_refresh_token(self, request: Request, application: ApiApplication) -> di ) except ApiToken.DoesNotExist: return {"error": "invalid_grant", "reason": "invalid request"} + + # RFC 6749 §6: "If the client type is confidential or the client was issued + # client credentials, the client MUST authenticate." + # + # Per RFC 8628 §5.6, device flow clients are public clients. + # Tokens issued via device_code can be refreshed without client_secret. + # All other tokens (authorization_code, or legacy null) require client_secret. + if not client_secret_provided: + if refresh_token.issued_grant_type != "device_code": + # Confidential client token - client_secret is required + return { + "error": "invalid_client", + "reason": "client_secret required for this token", + } + refresh_token.refresh() return {"token": refresh_token} @@ -589,6 +652,7 @@ def handle_device_code_grant( user_id=device_code.user.id, scope_list=device_code.scope_list, scoping_organization_id=device_code.organization_id, + issued_grant_type="device_code", ) # Delete the device code (one-time use) diff --git a/tests/sentry/web/frontend/test_oauth_token.py b/tests/sentry/web/frontend/test_oauth_token.py index a604e071c3609b..d3fa15611c60ca 100644 --- a/tests/sentry/web/frontend/test_oauth_token.py +++ b/tests/sentry/web/frontend/test_oauth_token.py @@ -675,26 +675,32 @@ def test_inactive_application_rejects_token_refresh(self) -> None: assert token_after.refresh_token == self.token.refresh_token assert token_after.expires_at == self.token.expires_at - def test_public_client_refresh_success(self) -> None: - """Public clients should be able to refresh tokens without client_secret. + def test_device_flow_token_refresh_without_secret(self) -> None: + """Device flow tokens can be refreshed without client_secret. - Per RFC 6749 §6: "If the client type is confidential or the client was - issued client credentials (or assigned other authentication requirements), - the client MUST authenticate" + Per RFC 8628 §5.6: "device clients... should be treated as public clients" + Per RFC 6749 §6: Public clients don't need to authenticate for refresh. - The inverse: public clients without credentials can refresh with just client_id. - This is essential for device flow clients (RFC 8628 §5.6) which are public clients. + Tokens issued via device_code grant can be refreshed with just client_id. """ self.login_as(self.user) + # Create a token that was issued via device flow + device_flow_token = ApiToken.objects.create( + application=self.application, + user=self.user, + expires_at=timezone.now(), + issued_grant_type="device_code", + ) + # Request without client_secret (public client) resp = self.client.post( self.path, { "grant_type": "refresh_token", "client_id": self.application.client_id, - "refresh_token": self.token.refresh_token, - # No client_secret - this is a public client + "refresh_token": device_flow_token.refresh_token, + # No client_secret - this is a public client (device flow) }, ) assert resp.status_code == 200 @@ -705,12 +711,67 @@ def test_public_client_refresh_success(self) -> None: assert data["token_type"] == "Bearer" # Token should be refreshed - token2 = ApiToken.objects.get(id=self.token.id) - assert token2.token != self.token.token - assert token2.refresh_token != self.token.refresh_token + token2 = ApiToken.objects.get(id=device_flow_token.id) + assert token2.token != device_flow_token.token + assert token2.refresh_token != device_flow_token.refresh_token + + def test_authorization_code_token_requires_secret_for_refresh(self) -> None: + """Authorization code tokens MUST provide client_secret for refresh. + + Per RFC 6749 §6: "If the client type is confidential or the client was + issued client credentials, the client MUST authenticate." + + Tokens issued via authorization_code are confidential client tokens. + """ + self.login_as(self.user) + + # Create a token that was issued via authorization_code (confidential client) + auth_code_token = ApiToken.objects.create( + application=self.application, + user=self.user, + expires_at=timezone.now(), + issued_grant_type="authorization_code", + ) + + # Try to refresh without client_secret + resp = self.client.post( + self.path, + { + "grant_type": "refresh_token", + "client_id": self.application.client_id, + "refresh_token": auth_code_token.refresh_token, + # No client_secret - should fail for confidential token + }, + ) + assert resp.status_code == 400 + assert json.loads(resp.content) == {"error": "invalid_client"} + + def test_legacy_token_requires_secret_for_refresh(self) -> None: + """Legacy tokens (null issued_grant_type) require client_secret for refresh. + + Tokens created before the issued_grant_type field was added are treated + as confidential client tokens for backward compatibility. + """ + self.login_as(self.user) + + # self.token has issued_grant_type=None (legacy token from setUp) + assert self.token.issued_grant_type is None + + # Try to refresh without client_secret + resp = self.client.post( + self.path, + { + "grant_type": "refresh_token", + "client_id": self.application.client_id, + "refresh_token": self.token.refresh_token, + # No client_secret - should fail for legacy token + }, + ) + assert resp.status_code == 400 + assert json.loads(resp.content) == {"error": "invalid_client"} def test_public_client_refresh_invalid_client_id(self) -> None: - """Public client with invalid client_id should return invalid_client.""" + """Invalid client_id should return invalid_client.""" self.login_as(self.user) resp = self.client.post( @@ -719,13 +780,12 @@ def test_public_client_refresh_invalid_client_id(self) -> None: "grant_type": "refresh_token", "client_id": "nonexistent_client_id", "refresh_token": self.token.refresh_token, - # No client_secret - public client }, ) assert resp.status_code == 401 assert json.loads(resp.content) == {"error": "invalid_client"} - def test_public_client_refresh_missing_client_id(self) -> None: + def test_refresh_missing_client_id(self) -> None: """Refresh without client_id should return invalid_client.""" self.login_as(self.user) @@ -734,13 +794,13 @@ def test_public_client_refresh_missing_client_id(self) -> None: { "grant_type": "refresh_token", "refresh_token": self.token.refresh_token, - # No client_id or client_secret + # No client_id }, ) assert resp.status_code == 401 assert json.loads(resp.content) == {"error": "invalid_client"} - def test_public_client_refresh_wrong_application(self) -> None: + def test_device_flow_token_wrong_application(self) -> None: """Refresh token from different application should return invalid_grant. Per RFC 6749 §6: "the refresh token is bound to the client to which it @@ -748,6 +808,14 @@ def test_public_client_refresh_wrong_application(self) -> None: """ self.login_as(self.user) + # Create a device flow token + device_flow_token = ApiToken.objects.create( + application=self.application, + user=self.user, + expires_at=timezone.now(), + issued_grant_type="device_code", + ) + # Create a different application other_app = ApiApplication.objects.create( owner=self.user, redirect_uris="https://other.com" @@ -759,19 +827,17 @@ def test_public_client_refresh_wrong_application(self) -> None: { "grant_type": "refresh_token", "client_id": other_app.client_id, - "refresh_token": self.token.refresh_token, - # No client_secret - public client + "refresh_token": device_flow_token.refresh_token, }, ) assert resp.status_code == 400 assert json.loads(resp.content) == {"error": "invalid_grant"} def test_confidential_client_refresh_wrong_secret_rejected(self) -> None: - """When client_secret is provided for refresh, it must be valid. + """When client_secret is provided, it must be valid. - Even though refresh_token supports public clients, when a client provides - client_secret, we should validate it. This allows confidential clients to - use refresh with full credential validation. + Even for device flow tokens, if a client provides client_secret, + we validate it. """ self.login_as(self.user) From 7f5092fd2d23c69446776d2810bf13b28d101f91 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 15 Jan 2026 16:55:07 +0100 Subject: [PATCH 3/4] refactor(oauth): Extract _authenticate_public_client to eliminate DRY violation Extract duplicated public client authentication logic into a reusable helper method. The DEVICE_CODE and REFRESH grant handlers had ~35 lines of nearly identical code for: - Validating client_id presence - Building query with optional client_secret - Looking up ApiApplication - Error handling with appropriate logging and metrics This improves: - DRY: Single source of truth for public client auth - Maintainability: Easier to add new public client grant types - Readability: post() method is now more concise --- src/sentry/web/frontend/oauth_token.py | 133 ++++++++++++------------- 1 file changed, 61 insertions(+), 72 deletions(-) diff --git a/src/sentry/web/frontend/oauth_token.py b/src/sentry/web/frontend/oauth_token.py index 8700d22b382d10..08b482dcddbec7 100644 --- a/src/sentry/web/frontend/oauth_token.py +++ b/src/sentry/web/frontend/oauth_token.py @@ -147,80 +147,14 @@ def post(self, request: Request) -> HttpResponse: }, ) - # Device flow supports public clients per RFC 8628 §5.6. + # Device flow and refresh token support public clients per RFC 8628 §5.6. # Public clients only provide client_id to identify themselves. # If client_secret is provided, we still validate it for confidential clients. - if grant_type == GrantTypes.DEVICE_CODE: - if not client_id: - return self.error( - request=request, - name="invalid_client", - reason="missing client_id", - status=401, - ) - - # Build query - validate secret only if provided (confidential client) - query = {"client_id": client_id} - if client_secret: - query["client_secret"] = client_secret - - try: - application = ApiApplication.objects.get(**query) - except ApiApplication.DoesNotExist: - metrics.incr("oauth_token.post.invalid", sample_rate=1.0) - if client_secret: - logger.warning( - "Invalid client_id / secret pair", - extra={"client_id": client_id}, - ) - reason = "invalid client_id or client_secret" - else: - logger.warning("Invalid client_id", extra={"client_id": client_id}) - reason = "invalid client_id" - return self.error( - request=request, - name="invalid_client", - reason=reason, - status=401, - ) - elif grant_type == GrantTypes.REFRESH: - # Refresh token: client authentication depends on how the token was issued. - # Per RFC 6749 §6: "If the client type is confidential or the client was issued - # client credentials, the client MUST authenticate." - # We determine client type by checking the token's issued_grant_type. - if not client_id: - return self.error( - request=request, - name="invalid_client", - reason="missing client_id", - status=401, - ) - - # Build query - validate secret only if provided - query: dict[str, str] = {"client_id": client_id} - if client_secret: - query["client_secret"] = client_secret - - try: - application = ApiApplication.objects.get(**query) - except ApiApplication.DoesNotExist: - metrics.incr("oauth_token.post.invalid", sample_rate=1.0) - if client_secret: - logger.warning( - "Invalid client_id / secret pair", - extra={"client_id": client_id}, - ) - reason = "invalid client_id or client_secret" - else: - logger.warning("Invalid client_id", extra={"client_id": client_id}) - reason = "invalid client_id" - return self.error( - request=request, - name="invalid_client", - reason=reason, - status=401, - ) - + if grant_type in (GrantTypes.DEVICE_CODE, GrantTypes.REFRESH): + application, error = self._authenticate_public_client(request, client_id, client_secret) + if error: + return error + assert application is not None # Type narrowing: error check above guarantees this # For refresh_token, we need to verify the client authentication matches # how the token was issued. This check happens in get_refresh_token(). # Pass whether client_secret was provided so we can enforce RFC 6749 §6. @@ -413,6 +347,61 @@ def _extract_basic_auth_credentials( # Neither header nor body provided credentials. return (None, None), None + def _authenticate_public_client( + self, + request: Request, + client_id: str | None, + client_secret: str | None, + ) -> tuple[ApiApplication | None, HttpResponse | None]: + """Authenticate a public client for device flow or refresh token grants. + + Public clients (per RFC 8628 §5.6) only require client_id to identify + themselves. If client_secret is provided, it will be validated to support + confidential clients using these grant types. + + Args: + request: The HTTP request (for error responses) + client_id: The client identifier (required) + client_secret: Optional client secret (validated if provided) + + Returns: + (application, None) on success + (None, error_response) on failure + """ + if not client_id: + return None, self.error( + request=request, + name="invalid_client", + reason="missing client_id", + status=401, + ) + + # Build query - validate secret only if provided (confidential client) + query: dict[str, str] = {"client_id": client_id} + if client_secret: + query["client_secret"] = client_secret + + try: + application = ApiApplication.objects.get(**query) + return application, None + except ApiApplication.DoesNotExist: + metrics.incr("oauth_token.post.invalid", sample_rate=1.0) + if client_secret: + logger.warning( + "Invalid client_id / secret pair", + extra={"client_id": client_id}, + ) + reason = "invalid client_id or client_secret" + else: + logger.warning("Invalid client_id", extra={"client_id": client_id}) + reason = "invalid client_id" + return None, self.error( + request=request, + name="invalid_client", + reason=reason, + status=401, + ) + def get_access_tokens(self, request: Request, application: ApiApplication) -> dict: code = request.POST.get("code") try: From 637f81c5483c8dd0ac966d2edff89fbae1161ac5 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 15 Jan 2026 18:21:03 +0100 Subject: [PATCH 4/4] chore: Update migrations lockfile for 1019_add_issued_grant_type_to_apitoken --- migrations_lockfile.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index eedcc928ab1819..bf5e18bf9bb3e5 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ releases: 0004_cleanup_failed_safe_deletes replays: 0007_organizationmember_replay_access -sentry: 1018_encrypt_integration_metadata +sentry: 1019_add_issued_grant_type_to_apitoken social_auth: 0003_social_auth_json_field