Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
93d8383
feat(oauth): Add public client support and refresh token rotation
BYK Jan 16, 2026
c7047a8
fix(oauth): Fix mypy type errors for nullable client_secret
BYK Jan 19, 2026
92382bc
fix(oauth): Fix migration to use correct default for client_secret
BYK Jan 19, 2026
4829506
:hammer_and_wrench: apply pre-commit fixes
getsantry[bot] Jan 19, 2026
bd90745
chore(alerts): increase max rollup constants (#106397)
nikkikapadia Jan 16, 2026
37105c0
refactor(tests): Split PromptsActivityTest into GET/PUT test classes …
dcramer Jan 16, 2026
ed6fdc4
Uptime insights assertions (#106403)
evanpurkhiser Jan 16, 2026
7e04ec1
perf(eventstore): Use cached project lookup in BaseEvent (#106440)
beezz Jan 16, 2026
1856d09
fix(dashboards): Check query ID before moving out of loading state (#…
narsaynorath Jan 16, 2026
bdf6187
chore: logging to figure out why tasks not getting created properly (…
shruthilayaj Jan 16, 2026
d8334a7
chore(billing): add constants for emerge (#106235)
vbro Jan 16, 2026
cf6fb73
fix(alerts): Validate targetIdentifier is an int before we try to que…
kcons Jan 16, 2026
62bb5c8
fix(dashboards): Update dashboard selects to use new TypeBadge (#106376)
nsdeschenes Jan 16, 2026
9ac4821
fix(uptime): Make verification section smaller in uptime monitor edit…
jaydgoss Jan 16, 2026
5bde08b
fix(top-issues): Swap Title and Subtitle in Drawer to match Cluster C…
yuvmen Jan 16, 2026
a46e184
feat(cells): Add isCellScoped prop to ResultGrid for cell-scoped endp…
evanpurkhiser Jan 16, 2026
4b7bf9c
ref(seer): always include short id in cursor handoff (#106454)
sehr-m Jan 16, 2026
a540cda
chore: temporarily run task every hour (#106458)
shruthilayaj Jan 16, 2026
df58c19
perf(groups): Fix N+1 query on Project in qualified_short_id (#106418)
scttcper Jan 16, 2026
79f7b65
fix(top-issues): Fix TopIssuesDrawer unconstrained width on long erro…
yuvmen Jan 16, 2026
1e685f1
fix(seer): Update showNewSeer conditions to count the `code-review-be…
ryan953 Jan 16, 2026
d569af7
ref(sentry-apps): Improve RpcSentryAppError with from_exc factory met…
leeandher Jan 16, 2026
8ab3f8f
ref(spans): Add logging to SpanFlusher startup (#106446)
lvthanh03 Jan 16, 2026
14bace7
ref(cells): Use isCellScoped for user customers endpoint (#106457)
evanpurkhiser Jan 16, 2026
ca70851
fix(uptime): checker_api should not set null for body and related (#1…
klochek Jan 16, 2026
f2431e9
fix(code-review): Fix staged billing check for GA (#106146)
suejungshin Jan 16, 2026
42077a8
feat(ACI): Document organization workflow index GET and DELETE endpoi…
ceorourke Jan 16, 2026
23adfbf
chore(explorer): move panel (#106420)
roaga Jan 16, 2026
c871abe
ref(alerts): Migrate `AlertsContainer` and children views off of `dep…
shashjar Jan 16, 2026
b767779
chore(preprod): add log when artifact is updated (#106469)
trevor-e Jan 16, 2026
5d0ef97
ref(slack): Compact Slack issue alert message layout (#105994)
leeandher Jan 16, 2026
5a9dd7e
fix(eco): Fixes typing for integration debug data, reduces page size …
GabeVillalobos Jan 16, 2026
bf926e4
fix(aci): add validation for detector trigger condition results (#106…
mifu67 Jan 16, 2026
3ddb93a
fix(audit_log): Correct DetectorWorkflow audit log data (#106382)
seer-by-sentry[bot] Jan 16, 2026
dcdb217
fix(cells): Use cell endpoint for closing invoices (#106445)
evanpurkhiser Jan 16, 2026
195169a
fix(preprod): Fix settings api bug for status checks (#106480)
NicoHinderling Jan 16, 2026
400f5cb
fix(preprod): add status check retry delay (#106481)
trevor-e Jan 16, 2026
0d2f936
feat(nav): Add feature flag logic to show/hide the Prevent nav item (…
ryan953 Jan 17, 2026
5643d28
perf(issues): Fix N+1 query on repository in get_sorted_code_mapping_…
scttcper Jan 17, 2026
3e1ca72
feat: Show details when there are any for sending test notifications …
JPeer264 Jan 19, 2026
96760c3
feat(usageStats): Add new `ignored` client discard reason (#106251)
Lms24 Jan 19, 2026
62c1173
feat(ai-insights): support tool.call attribute (#106509)
obostjancic Jan 19, 2026
887b894
feat(preprod): Check quota available in preprod assemble (#106455)
chromy Jan 19, 2026
dc0426d
feat(objectstore): Try Django request.body when proxying (#106506)
lcian Jan 19, 2026
4694b2a
feat(preprod): Check quota and run only available features (#106510)
chromy Jan 19, 2026
d87f5f3
chore(overwatch): Remove Overwatch forwarding infrastructure (#106447)
armenzg Jan 19, 2026
d1bd2ea
Add some data model documentation to spans buffer (#106504)
fpacifici Jan 19, 2026
b0c03ff
Add logging for abnormally long EVALSHA in spans buffer (#106496)
fpacifici Jan 19, 2026
cb18422
Revert "feat(preprod): Check quota and run only available features" (…
chromy Jan 19, 2026
d872649
chore(feedback): re-assign sessions feedback to replay team, laravel&…
shellmayr Jan 19, 2026
b696ec0
feat(crons-detector-schedule-preview): Adding new schedule preview co…
Abdkhan14 Jan 19, 2026
a3a3bf5
Revert "feat(preprod): Check quota available in preprod assemble" (#1…
chromy Jan 19, 2026
8d3fd31
fix(uptime): validator should not set null for body and related (#106…
klochek Jan 19, 2026
57ed458
Revert "Add logging for abnormally long EVALSHA in spans buffer (#106…
getsentry-bot Jan 19, 2026
dc4eaf3
feat(project-details): link to mobile session health (#106520)
bcoe Jan 19, 2026
6ad2b36
Add logging for abnormally long EVALSHA in spans buffer (#106496) (#1…
evanh Jan 19, 2026
b25a258
chore: fix typos in comments and messages (#106495)
NAM-MAN Jan 19, 2026
3a1f328
feat(crons-detector-schedule-preview): Endpoint edge cases (#106392)
Abdkhan14 Jan 19, 2026
afed387
chore(explorer-index): remove debug code (#106516)
shruthilayaj Jan 19, 2026
9c7cf8e
fix(tracemetrics): Add timestamp to filterable fields (#106456)
narsaynorath Jan 19, 2026
3f23446
feat(oauth): Add RFC 6750 Bearer token compliance (#106274)
dcramer Jan 19, 2026
a3fc390
fix(code-review): Check if org has disabled default code review trigg…
suejungshin Jan 19, 2026
3e622d6
fix(releases): Use mobile_app_info for preprod build count query (#10…
cameroncooke Jan 19, 2026
1bd25e3
feat(dashboards/insights): allow >90 days pickable (#106529)
DominikB2014 Jan 19, 2026
ad3a4c7
fix(dashboards): clear message when dataset doesnt support >90 days (…
DominikB2014 Jan 19, 2026
6c79f66
Merge remote-tracking branch 'origin/master' into feat/oauth-public-c…
BYK Jan 19, 2026
eda118d
Merge branch 'master' into feat/oauth-public-client-refresh-token-rot…
BYK Jan 19, 2026
2f5206f
fix(oauth): Use CheckedMigration for silo-aware migration
BYK Jan 19, 2026
d8fa2f8
fix(oauth): Prevent client_id enumeration via timing oracle
BYK Jan 20, 2026
06d8d01
refactor(oauth): Simplify public client grant type check
BYK Jan 20, 2026
75b69bc
Merge branch 'master' into feat/oauth-public-client-refresh-token-rot…
BYK Jan 20, 2026
e731fb1
fix(oauth): Prevent race condition in refresh token rotation
BYK Jan 20, 2026
56e7221
fix(oauth): Handle replay detection when token has no family ID
BYK Jan 20, 2026
6781ff0
fix(oauth): Use explicit None check for is_public property
BYK Jan 20, 2026
e8d51b5
test(oauth): Add comprehensive tests for public client behavior
BYK Jan 20, 2026
5d86221
refactor(oauth): Simplify refresh token rotation to create/delete
BYK Jan 20, 2026
4b7a140
Merge branch 'master' into feat/oauth-public-client-refresh-token-rot…
BYK Jan 20, 2026
eb0ce89
:hammer_and_wrench: apply pre-commit fixes
getsantry[bot] Jan 20, 2026
f65c630
refactor(oauth): Use empty string for public clients instead of null
BYK Jan 20, 2026
e8562e7
chore: Remove verbose comments from oauth_token.py
BYK Jan 20, 2026
0665bed
fix: Revert migration lockfile to 1018
BYK Jan 20, 2026
502ef17
chore: Remove unnecessary assertion in SentryApp.build_signature
BYK Jan 20, 2026
82f62be
fix: Rename misleading test to test_public_client_with_wrong_secret_f…
BYK Jan 20, 2026
0c6552e
feat: Support is_public=True in ApiApplication constructor
BYK Jan 20, 2026
fc4edd0
test: Add confidential client refresh token tests
BYK Jan 20, 2026
2c05929
refactor: Unify refresh token handling for public and confidential cl…
BYK Jan 20, 2026
5605642
:hammer_and_wrench: apply pre-commit fixes
getsantry[bot] Jan 20, 2026
a26feaf
Merge branch 'master' into feat/oauth-public-client-refresh-token-rot…
BYK Jan 20, 2026
8f53c1d
chore: Add migration for ApiApplication.client_secret blank=True
BYK Jan 20, 2026
fde3c2e
:hammer_and_wrench: apply pre-commit fixes
getsantry[bot] Jan 20, 2026
be8239d
test: Add test for empty string client_secret bypass attempt
BYK Jan 20, 2026
5368b05
fix: Revert is_public=True parameter to fix mypy typing errors
BYK Jan 20, 2026
1f17a52
chore: Use NULL instead of empty string for public client_secret
BYK Jan 20, 2026
21f2304
fix: Add assertion for client_secret in SentryApp.build_signature
BYK Jan 21, 2026
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
2 changes: 1 addition & 1 deletion migrations_lockfile.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ releases: 0004_cleanup_failed_safe_deletes

replays: 0007_organizationmember_replay_access

sentry: 1019_add_integration_debug_json
sentry: 1020_alter_apiapplication_client_secret_nullable

social_auth: 0003_social_auth_json_field

Expand Down
7 changes: 6 additions & 1 deletion src/sentry/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,9 @@ def authenticate(self, request: Request):
except ApiApplication.DoesNotExist:
raise invalid_pair_error

if not constant_time_compare(application.client_secret, client_secret):
if application.client_secret is None or not constant_time_compare(
application.client_secret, client_secret
):
raise invalid_pair_error

try:
Expand Down Expand Up @@ -355,6 +357,9 @@ def get_payload_from_client_secret_jwt(
raise AuthenticationFailed("Application not found")

client_secret = application.client_secret
if client_secret is None:
raise AuthenticationFailed("Application does not have a client secret")
Comment thread
cursor[bot] marked this conversation as resolved.

try:
encoded_jwt = tokens[1]
except IndexError:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 5.2.8 on 2026-01-20 21:29

from django.db import migrations, models

import sentry.models.apiapplication
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", "1019_add_integration_debug_json"),
]

operations = [
migrations.AlterField(
model_name="apiapplication",
name="client_secret",
field=models.TextField(null=True, default=sentry.models.apiapplication.generate_token),
),
]
19 changes: 18 additions & 1 deletion src/sentry/models/apiapplication.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ class ApiApplication(Model):
__relocation_scope__ = RelocationScope.Global

client_id = models.CharField(max_length=64, unique=True, default=generate_token)
client_secret = models.TextField(default=generate_token)
# NULL for public clients (RFC 6749 §2.1) - CLIs, native apps, SPAs
# Public clients cannot securely store secrets, so they use PKCE and/or
# refresh token rotation instead of client authentication.
# Use client_secret=None to create a public client.
client_secret = models.TextField(null=True, default=generate_token)
owner = FlexibleForeignKey("sentry.User", null=True)
name = models.CharField(max_length=64, blank=True, default=generate_name)
status = BoundedPositiveIntegerField(
Expand Down Expand Up @@ -135,6 +139,19 @@ def outboxes_for_update(self) -> list[ControlOutbox]:
def is_active(self):
return self.status == ApiApplicationStatus.active

@property
def is_public(self) -> bool:
"""Check if this is a public client (RFC 6749 §2.1).

Public clients (native apps, CLIs, SPAs) cannot securely store
credentials, so they have no client_secret. They rely on PKCE
for authorization code flow and refresh token rotation for
token refresh (RFC 9700 §4.14.2).

Public clients are created with client_secret=None.
"""
return self.client_secret is None

def is_allowed_response_type(self, value: object) -> TypeIs[Literal["code", "token"]]:
return value in ("code", "token")

Expand Down
2 changes: 2 additions & 0 deletions src/sentry/sentry_apps/models/sentry_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ def is_installed_on(self, organization):
def build_signature(self, body):
assert self.application is not None
secret = self.application.client_secret
# SentryApps always have a client_secret (they are confidential clients)
assert secret is not None
return hmac.new(
key=secret.encode("utf-8"), msg=body.encode("utf-8"), digestmod=sha256
).hexdigest()
Expand Down
92 changes: 47 additions & 45 deletions src/sentry/web/frontend/oauth_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,73 +147,72 @@ def post(self, request: Request) -> HttpResponse:
},
)

# 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 == 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
if not client_id:
return self.error(
request=request,
name="invalid_client",
reason="missing client_id",
status=401,
)

if client_secret:
try:
application = ApiApplication.objects.get(**query)
application = ApiApplication.objects.get(
client_id=client_id,
client_secret=client_secret,
)
is_public_client = False
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"
logger.warning(
"Invalid client_id / secret pair",
extra={"client_id": client_id},
Comment thread
BYK marked this conversation as resolved.
)
return self.error(
request=request,
name="invalid_client",
reason=reason,
reason="invalid client_id or client_secret",
status=401,
)
else:
# Other grant types require confidential client authentication
if not client_id or not client_secret:
try:
application = ApiApplication.objects.get(client_id=client_id)
except ApiApplication.DoesNotExist:
metrics.incr("oauth_token.post.invalid", sample_rate=1.0)
logger.warning("Invalid client_id", extra={"client_id": client_id})
Comment thread
BYK marked this conversation as resolved.
Dismissed
return self.error(
request=request,
name="invalid_client",
reason="missing client credentials",
reason="invalid client_id or client_secret",
status=401,
)

try:
# Note: We don't filter by status here to distinguish between invalid
# credentials (unknown client) and inactive applications. This allows
# proper grant cleanup per RFC 6749 §10.5 and clearer metrics.
application = ApiApplication.objects.get(
client_id=client_id,
client_secret=client_secret,
)
except ApiApplication.DoesNotExist:
metrics.incr(
"oauth_token.post.invalid",
sample_rate=1.0,
is_public_client = application.is_public

if not is_public_client:
metrics.incr("oauth_token.post.invalid", sample_rate=1.0)
logger.warning(
"Confidential client missing secret",
extra={"client_id": client_id},
Comment thread
BYK marked this conversation as resolved.
Dismissed
Comment thread
BYK marked this conversation as resolved.
Dismissed
)
logger.warning("Invalid client_id / secret pair", extra={"client_id": client_id})
return self.error(
request=request,
name="invalid_client",
reason="invalid client_id or client_secret",
status=401,
)

if is_public_client and grant_type not in [
GrantTypes.AUTHORIZATION,
GrantTypes.DEVICE_CODE,
GrantTypes.REFRESH,
]:
return self.error(
request=request,
name="unauthorized_client",
reason="public clients cannot use this grant type",
)

# Check application status separately from credential validation.
# This preserves metric clarity and provides consistent error handling.
if application.status != ApiApplicationStatus.active:
Expand Down Expand Up @@ -272,8 +271,11 @@ def post(self, request: Request) -> HttpResponse:
token_data = self.get_access_tokens(request=request, application=application)
elif grant_type == GrantTypes.DEVICE_CODE:
return self.handle_device_code_grant(request=request, application=application)
else:
elif grant_type == GrantTypes.REFRESH:
token_data = self.get_refresh_token(request=request, application=application)
else:
# Should not reach here due to earlier grant_type validation
return self.error(request=request, name="unsupported_grant_type")
if "error" in token_data:
return self.error(
request=request,
Expand Down Expand Up @@ -581,7 +583,7 @@ def handle_device_code_grant(
# are atomic. This prevents duplicate tokens if delete fails after
# token creation succeeds.
with transaction.atomic(router.db_for_write(ApiToken)):
# Create the access token
# Create the access token for the device flow
token = ApiToken.objects.create(
application=application,
user_id=device_code.user.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def test_invalid_app_id(self) -> None:
def test_valid_call(self) -> None:
self.login_as(self.user)
old_secret = self.app.client_secret
assert old_secret is not None
response = self.client.post(self.path, data={})
new_secret = response.data["clientSecret"]
assert len(new_secret) == len(old_secret)
Expand Down
19 changes: 19 additions & 0 deletions tests/sentry/models/test_apiapplication.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@

@control_silo_test
class ApiApplicationTest(TestCase):
def test_is_public_with_null_secret(self) -> None:
"""Public clients are created with client_secret=None (NULL in DB)."""
app = ApiApplication.objects.create(
owner=self.user,
redirect_uris="http://example.com",
client_secret=None,
)
assert app.is_public is True

def test_is_public_with_secret(self) -> None:
"""Confidential clients have a client_secret."""
app = ApiApplication.objects.create(
owner=self.user,
redirect_uris="http://example.com",
# client_secret defaults to a generated token
)
assert app.client_secret is not None
assert app.is_public is False

def test_is_valid_redirect_uri(self) -> None:
app = ApiApplication.objects.create(
owner=self.user,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def test_valid_call(self) -> None:
self.login_as(self.user)
assert self.sentry_app.application is not None
old_secret = self.sentry_app.application.client_secret
assert old_secret is not None
response = self.client.post(self.url)
new_secret = response.data["clientSecret"]
assert len(new_secret) == len(old_secret)
Expand All @@ -94,6 +95,7 @@ def test_superuser_has_access(self) -> None:
self.login_as(user=superuser, superuser=True)
assert self.sentry_app.application is not None
old_secret = self.sentry_app.application.client_secret
assert old_secret is not None
response = self.client.post(self.url)
new_secret = response.data["clientSecret"]
assert len(new_secret) == len(old_secret)
Expand Down
Loading
Loading