Skip to content

Commit a25ea15

Browse files
committed
feat(oauth): Implement OAuth 2.0 Device Authorization Flow (RFC 8628)
Add support for the Device Authorization Grant, enabling headless clients (CLIs, CI/CD pipelines, containers) to obtain OAuth tokens by having users authorize on a separate device with a browser. Key components: - ApiDeviceCode model with secure device/user code generation - Device authorization endpoint (POST /oauth/device_authorization) - User verification pages (GET/POST /oauth/device) - Token endpoint support for device_code grant type - Automatic cleanup of expired device codes
1 parent 926fd81 commit a25ea15

File tree

15 files changed

+1847
-3
lines changed

15 files changed

+1847
-3
lines changed

migrations_lockfile.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ releases: 0004_cleanup_failed_safe_deletes
3131

3232
replays: 0007_organizationmember_replay_access
3333

34-
sentry: 1014_add_pkce_to_apigrant
34+
sentry: 1015_add_apidevicecode
3535

3636
social_auth: 0003_social_auth_json_field
3737

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Generated by Django 5.2.8 on 2026-01-05
2+
3+
import django.contrib.postgres.fields
4+
import django.db.models.deletion
5+
import django.utils.timezone
6+
from django.conf import settings
7+
from django.db import migrations, models
8+
9+
import sentry.db.models.fields.bounded
10+
import sentry.db.models.fields.foreignkey
11+
import sentry.db.models.fields.hybrid_cloud_foreign_key
12+
import sentry.models.apidevicecode
13+
from sentry.new_migrations.migrations import CheckedMigration
14+
15+
16+
class Migration(CheckedMigration):
17+
# This flag is used to mark that a migration shouldn't be automatically run in production.
18+
# This should only be used for operations where it's safe to run the migration after your
19+
# code has deployed. So this should not be used for most operations that alter the schema
20+
# of a table.
21+
# Here are some things that make sense to mark as post deployment:
22+
# - Large data migrations. Typically we want these to be run manually so that they can be
23+
# monitored and not block the deploy for a long period of time while they run.
24+
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
25+
# run this outside deployments so that we don't block them. Note that while adding an index
26+
# is a schema change, it's completely safe to run the operation after the code has deployed.
27+
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
28+
29+
is_post_deployment = False
30+
31+
dependencies = [
32+
("sentry", "1014_add_pkce_to_apigrant"),
33+
]
34+
35+
operations = [
36+
migrations.CreateModel(
37+
name="ApiDeviceCode",
38+
fields=[
39+
(
40+
"id",
41+
sentry.db.models.fields.bounded.BoundedBigAutoField(
42+
primary_key=True, serialize=False
43+
),
44+
),
45+
(
46+
"device_code",
47+
models.CharField(
48+
default=sentry.models.apidevicecode.generate_device_code,
49+
max_length=64,
50+
unique=True,
51+
),
52+
),
53+
(
54+
"user_code",
55+
models.CharField(
56+
unique=True,
57+
default=sentry.models.apidevicecode.generate_user_code,
58+
max_length=16,
59+
),
60+
),
61+
(
62+
"application",
63+
sentry.db.models.fields.foreignkey.FlexibleForeignKey(
64+
on_delete=django.db.models.deletion.CASCADE,
65+
to="sentry.apiapplication",
66+
),
67+
),
68+
(
69+
"user",
70+
sentry.db.models.fields.foreignkey.FlexibleForeignKey(
71+
null=True,
72+
on_delete=django.db.models.deletion.CASCADE,
73+
to=settings.AUTH_USER_MODEL,
74+
),
75+
),
76+
(
77+
"organization_id",
78+
sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey(
79+
"sentry.Organization",
80+
db_index=True,
81+
null=True,
82+
on_delete="CASCADE",
83+
),
84+
),
85+
(
86+
"scope_list",
87+
django.contrib.postgres.fields.ArrayField(
88+
base_field=models.TextField(),
89+
default=list,
90+
size=None,
91+
),
92+
),
93+
(
94+
"expires_at",
95+
models.DateTimeField(
96+
db_index=True,
97+
default=sentry.models.apidevicecode.default_expiration,
98+
),
99+
),
100+
(
101+
"status",
102+
models.CharField(
103+
default="pending",
104+
max_length=20,
105+
),
106+
),
107+
(
108+
"date_added",
109+
models.DateTimeField(default=django.utils.timezone.now),
110+
),
111+
],
112+
options={
113+
"db_table": "sentry_apidevicecode",
114+
},
115+
),
116+
]

src/sentry/models/apidevicecode.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
from __future__ import annotations
2+
3+
import secrets
4+
from datetime import timedelta
5+
from typing import Any
6+
7+
from django.contrib.postgres.fields.array import ArrayField
8+
from django.db import IntegrityError, models
9+
from django.utils import timezone
10+
11+
from sentry.backup.dependencies import NormalizedModelName, get_model_name
12+
from sentry.backup.sanitize import SanitizableField, Sanitizer
13+
from sentry.backup.scopes import RelocationScope
14+
from sentry.db.models import FlexibleForeignKey, Model, control_silo_model
15+
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
16+
17+
# RFC 8628 recommends short lifetimes for device codes (10-15 minutes)
18+
DEFAULT_EXPIRATION = timedelta(minutes=10)
19+
20+
# Default polling interval in seconds (RFC 8628 §3.2)
21+
DEFAULT_INTERVAL = 5
22+
23+
# Base-20 alphabet for user codes: excludes ambiguous characters (0/O, 1/I/L, etc.)
24+
# This provides ~34 bits of entropy for 8-character codes, sufficient with rate limiting.
25+
# Reference: RFC 8628 §5.1
26+
USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ"
27+
USER_CODE_LENGTH = 8
28+
29+
30+
def default_expiration():
31+
return timezone.now() + DEFAULT_EXPIRATION
32+
33+
34+
def generate_device_code():
35+
"""Generate a cryptographically secure device code (256-bit entropy)."""
36+
return secrets.token_hex(nbytes=32)
37+
38+
39+
def generate_user_code():
40+
"""
41+
Generate a human-readable user code in format "XXXX-XXXX".
42+
43+
Uses base-20 alphabet to avoid ambiguous characters, providing ~34 bits
44+
of entropy which is sufficient when combined with rate limiting.
45+
Reference: RFC 8628 §5.1
46+
"""
47+
chars = [secrets.choice(USER_CODE_ALPHABET) for _ in range(USER_CODE_LENGTH)]
48+
return f"{''.join(chars[:4])}-{''.join(chars[4:])}"
49+
50+
51+
# Maximum retries for generating unique codes
52+
MAX_CODE_GENERATION_RETRIES = 10
53+
54+
55+
class UserCodeCollisionError(Exception):
56+
"""Raised when unable to generate a unique user code after maximum retries."""
57+
58+
pass
59+
60+
61+
class DeviceCodeStatus:
62+
"""Status values for device authorization codes."""
63+
64+
PENDING = "pending"
65+
APPROVED = "approved"
66+
DENIED = "denied"
67+
68+
69+
@control_silo_model
70+
class ApiDeviceCode(Model):
71+
"""
72+
Device authorization code for OAuth 2.0 Device Flow (RFC 8628).
73+
74+
This model stores the state of a device authorization request, which allows
75+
headless devices (CLIs, Docker containers, CI/CD jobs) to obtain OAuth tokens
76+
by having users authorize on a separate device with a browser.
77+
78+
Flow:
79+
1. Device requests authorization via POST /oauth/device_authorization
80+
2. Server returns device_code (secret) and user_code (human-readable)
81+
3. Device displays user_code and verification_uri to user
82+
4. Device polls POST /oauth/token with device_code
83+
5. User visits verification_uri, enters user_code, and approves/denies
84+
6. On approval, device receives access token on next poll
85+
86+
Reference: https://datatracker.ietf.org/doc/html/rfc8628
87+
"""
88+
89+
__relocation_scope__ = RelocationScope.Global
90+
91+
# Device code: secret, high-entropy code used for token polling (RFC 8628 §3.2)
92+
device_code = models.CharField(max_length=64, unique=True, default=generate_device_code)
93+
94+
# User code: human-readable code for user entry (RFC 8628 §3.2)
95+
# Format: "XXXX-XXXX" using base-20 alphabet
96+
# Must be unique since users look up by this code
97+
user_code = models.CharField(max_length=16, unique=True, default=generate_user_code)
98+
99+
# The OAuth application requesting authorization
100+
application = FlexibleForeignKey("sentry.ApiApplication")
101+
102+
# User who approved the request (set when status changes to APPROVED)
103+
user = FlexibleForeignKey("sentry.User", null=True, on_delete=models.CASCADE)
104+
105+
# Organization selected during approval (for org-level access apps)
106+
organization_id = HybridCloudForeignKey(
107+
"sentry.Organization",
108+
db_index=True,
109+
null=True,
110+
on_delete="CASCADE",
111+
)
112+
113+
# Requested scopes (space-delimited in requests, stored as array)
114+
scope_list = ArrayField(models.TextField(), default=list)
115+
116+
# When this device code expires (RFC 8628 §3.2 expires_in)
117+
expires_at = models.DateTimeField(db_index=True, default=default_expiration)
118+
119+
# Authorization status: pending -> approved/denied
120+
status = models.CharField(max_length=20, default=DeviceCodeStatus.PENDING)
121+
122+
# Timestamps
123+
date_added = models.DateTimeField(default=timezone.now)
124+
125+
class Meta:
126+
app_label = "sentry"
127+
db_table = "sentry_apidevicecode"
128+
129+
def __str__(self) -> str:
130+
return f"device_code={self.id}, application={self.application_id}, status={self.status}"
131+
132+
def get_scopes(self) -> list[str]:
133+
"""Return the list of requested scopes."""
134+
return self.scope_list
135+
136+
def has_scope(self, scope: str) -> bool:
137+
"""Check if a specific scope was requested."""
138+
return scope in self.scope_list
139+
140+
def is_expired(self) -> bool:
141+
"""Check if the device code has expired."""
142+
return timezone.now() >= self.expires_at
143+
144+
def is_pending(self) -> bool:
145+
"""Check if the device code is still awaiting user action."""
146+
return self.status == DeviceCodeStatus.PENDING
147+
148+
def is_approved(self) -> bool:
149+
"""Check if the user has approved this device code."""
150+
return self.status == DeviceCodeStatus.APPROVED
151+
152+
def is_denied(self) -> bool:
153+
"""Check if the user has denied this device code."""
154+
return self.status == DeviceCodeStatus.DENIED
155+
156+
@classmethod
157+
def sanitize_relocation_json(
158+
cls, json: Any, sanitizer: Sanitizer, model_name: NormalizedModelName | None = None
159+
) -> None:
160+
model_name = get_model_name(cls) if model_name is None else model_name
161+
super().sanitize_relocation_json(json, sanitizer, model_name)
162+
163+
sanitizer.set_string(
164+
json, SanitizableField(model_name, "device_code"), lambda _: generate_device_code()
165+
)
166+
sanitizer.set_string(
167+
json, SanitizableField(model_name, "user_code"), lambda _: generate_user_code()
168+
)
169+
170+
@classmethod
171+
def create_with_retry(cls, application, scope_list: list[str] | None = None) -> ApiDeviceCode:
172+
"""
173+
Create a new device code with retry logic for user code collisions.
174+
175+
Since user codes have ~34 bits of entropy, collisions are rare but possible.
176+
This method retries with new codes if a collision occurs.
177+
178+
Args:
179+
application: The ApiApplication requesting authorization
180+
scope_list: Optional list of requested scopes
181+
182+
Returns:
183+
A new ApiDeviceCode instance
184+
185+
Raises:
186+
UserCodeCollisionError: If unable to generate a unique code after max retries
187+
"""
188+
if scope_list is None:
189+
scope_list = []
190+
191+
for attempt in range(MAX_CODE_GENERATION_RETRIES):
192+
try:
193+
return cls.objects.create(
194+
application=application,
195+
scope_list=scope_list,
196+
)
197+
except IntegrityError:
198+
# Collision on device_code or user_code, try again
199+
if attempt == MAX_CODE_GENERATION_RETRIES - 1:
200+
raise UserCodeCollisionError(
201+
f"Unable to generate unique device code after {MAX_CODE_GENERATION_RETRIES} attempts"
202+
)
203+
continue
204+
205+
# This should never be reached, but satisfies type checker
206+
raise UserCodeCollisionError("Unable to generate unique device code")

src/sentry/runner/commands/cleanup.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,7 @@ def remove_expired_values_for_org_members(
554554
def delete_api_models(
555555
is_filtered: Callable[[type[BaseModel]], bool], models_attempted: set[str]
556556
) -> None:
557+
from sentry.models.apidevicecode import ApiDeviceCode
557558
from sentry.models.apigrant import ApiGrant
558559
from sentry.models.apitoken import ApiToken
559560

@@ -576,6 +577,15 @@ def delete_api_models(
576577

577578
queryset.delete()
578579

580+
# Device codes have short expiration times (10 minutes), so clean up
581+
# any that have expired immediately without additional TTL buffer.
582+
if is_filtered(ApiDeviceCode):
583+
debug_output(">> Skipping ApiDeviceCode")
584+
else:
585+
debug_output("Removing expired values for ApiDeviceCode")
586+
models_attempted.add(ApiDeviceCode.__name__.lower())
587+
ApiDeviceCode.objects.filter(expires_at__lt=timezone.now()).delete()
588+
579589

580590
@continue_on_error("specialized_cleanup_exported_data")
581591
def exported_data(

src/sentry/sentry_apps/token_exchange/util.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
AUTHORIZATION = "authorization_code"
1010
REFRESH = "refresh_token"
1111
CLIENT_SECRET_JWT = "urn:sentry:params:oauth:grant-type:jwt-bearer"
12+
DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
1213

1314

1415
class GrantTypes:
1516
AUTHORIZATION = AUTHORIZATION
1617
REFRESH = REFRESH
1718
CLIENT_SECRET_JWT = CLIENT_SECRET_JWT
19+
DEVICE_CODE = DEVICE_CODE
1820

1921

2022
def token_expiration() -> datetime:

0 commit comments

Comments
 (0)