Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
80dacc8
feat(oauth): Implement OAuth 2.0 Device Authorization Flow (RFC 8628)
dcramer Jan 5, 2026
4694cba
refactor(oauth): Improve device flow security and code structure
dcramer Jan 5, 2026
40ca776
chore(oauth): Remove i18n tags from device flow templates
dcramer Jan 5, 2026
8db11e9
refactor(oauth): Remove _get_client_ip helper, use REMOTE_ADDR directly
dcramer Jan 5, 2026
705bac3
fix(oauth): Use atomic update to prevent race condition in device app…
dcramer Jan 5, 2026
45a298b
fix(oauth): Fix typing errors and handle UnableToAcquireLock
dcramer Jan 5, 2026
de2692d
fix(oauth): Add atomic status check to deny action
dcramer Jan 5, 2026
7ce56ab
Fix imports
dcramer Jan 5, 2026
eea8152
fix types
dcramer Jan 6, 2026
f7016a1
Handle invalid scopes
dcramer Jan 6, 2026
0c7bf03
fix(backup): Include ApiDeviceCode in exhaustive backup fixtures
dcramer Jan 6, 2026
2745254
fix: Add OAuth device flow URLs to control silo URL patterns
dcramer Jan 6, 2026
dcdc65d
refactor(oauth): Rename device_authorization endpoint to device/code
dcramer Jan 6, 2026
26c62ea
fix(oauth): Wrap device code approval in transaction
dcramer Jan 6, 2026
f9ecf28
fix(oauth): Wrap device code token exchange in transaction
dcramer Jan 6, 2026
85ea05b
fix(oauth): Move IntegrityError handler outside transaction block
dcramer Jan 6, 2026
25813cb
fix(oauth): Re-check device code expiration inside lock
dcramer Jan 6, 2026
2585a55
fix(migrations): Rename device code migration to 1016 after rebase
dcramer Jan 7, 2026
836a149
fix(oauth): Address code review feedback
dcramer Jan 10, 2026
5e67f69
chore: Remove unnecessary type checker comments
dcramer Jan 10, 2026
a1fffcf
fix(oauth): Simplify device code retry loop to remove unreachable code
dcramer Jan 12, 2026
df8eae2
refactor(oauth): Code simplifications
dcramer Jan 12, 2026
e6b0fa2
fix(oauth): Add trailing slash to device code endpoint URL
dcramer Jan 12, 2026
3cf0171
fix(oauth): Add trailing slash to device code endpoint URL
dcramer Jan 12, 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: 1016_remove_on_command_phrase_trigger
sentry: 1017_add_apidevicecode

social_auth: 0003_social_auth_json_field

Expand Down
116 changes: 116 additions & 0 deletions src/sentry/migrations/1017_add_apidevicecode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Generated by Django 5.2.8 on 2026-01-05

import django.contrib.postgres.fields
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models

import sentry.db.models.fields.bounded
import sentry.db.models.fields.foreignkey
import sentry.db.models.fields.hybrid_cloud_foreign_key
import sentry.models.apidevicecode
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", "1016_remove_on_command_phrase_trigger"),
]

operations = [
migrations.CreateModel(
name="ApiDeviceCode",
fields=[
(
"id",
sentry.db.models.fields.bounded.BoundedBigAutoField(
primary_key=True, serialize=False
),
),
(
"device_code",
models.CharField(
default=sentry.models.apidevicecode.generate_device_code,
max_length=64,
unique=True,
),
),
(
"user_code",
models.CharField(
unique=True,
default=sentry.models.apidevicecode.generate_user_code,
max_length=16,
),
),
(
"application",
sentry.db.models.fields.foreignkey.FlexibleForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="sentry.apiapplication",
),
),
(
"user",
sentry.db.models.fields.foreignkey.FlexibleForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"organization_id",
sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey(
"sentry.Organization",
db_index=True,
null=True,
on_delete="CASCADE",
),
),
(
"scope_list",
django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(),
default=list,
size=None,
),
),
(
"expires_at",
models.DateTimeField(
db_index=True,
default=sentry.models.apidevicecode.default_expiration,
),
),
(
"status",
models.CharField(
default="pending",
max_length=20,
),
),
(
"date_added",
models.DateTimeField(default=django.utils.timezone.now),
),
],
options={
"db_table": "sentry_apidevicecode",
},
),
]
1 change: 1 addition & 0 deletions src/sentry/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .activity import * # NOQA
from .apiapplication import * # NOQA
from .apiauthorization import * # NOQA
from .apidevicecode import * # NOQA
from .apigrant import * # NOQA
from .apikey import * # NOQA
from .apiscopes import * # NOQA
Expand Down
212 changes: 212 additions & 0 deletions src/sentry/models/apidevicecode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
from __future__ import annotations

import secrets
from datetime import timedelta
from enum import StrEnum
from typing import Any

from django.contrib.postgres.fields.array import ArrayField
from django.db import IntegrityError, models
from django.utils import timezone

from sentry.backup.dependencies import NormalizedModelName, get_model_name
from sentry.backup.sanitize import SanitizableField, Sanitizer
from sentry.backup.scopes import RelocationScope
from sentry.db.models import FlexibleForeignKey, Model, control_silo_model
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey

# RFC 8628 recommends short lifetimes for device codes (10-15 minutes)
DEFAULT_EXPIRATION = timedelta(minutes=10)

# Default polling interval in seconds (RFC 8628 §3.2)
DEFAULT_INTERVAL = 5

# Base-20 alphabet for user codes: excludes ambiguous characters (0/O, 1/I/L, etc.)
# This provides ~34 bits of entropy for 8-character codes, sufficient with rate limiting.
# Reference: RFC 8628 §5.1
USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ"
USER_CODE_LENGTH = 8
USER_CODE_GROUP_LENGTH = USER_CODE_LENGTH // 2 # Characters per group in "XXXX-XXXX" format

# Device code entropy: 32 bytes = 256 bits
DEVICE_CODE_BYTES = 32


def default_expiration():
return timezone.now() + DEFAULT_EXPIRATION


def generate_device_code():
"""Generate a cryptographically secure device code (256-bit entropy)."""
return secrets.token_hex(nbytes=DEVICE_CODE_BYTES)


def generate_user_code():
"""
Generate a human-readable user code in format "XXXX-XXXX".

Uses base-20 alphabet to avoid ambiguous characters, providing ~34 bits
of entropy which is sufficient when combined with rate limiting.
Reference: RFC 8628 §5.1
"""
chars = [secrets.choice(USER_CODE_ALPHABET) for _ in range(USER_CODE_LENGTH)]
return f"{''.join(chars[:USER_CODE_GROUP_LENGTH])}-{''.join(chars[USER_CODE_GROUP_LENGTH:])}"


# Maximum retries for generating unique codes
MAX_CODE_GENERATION_RETRIES = 10


class UserCodeCollisionError(Exception):
"""Raised when unable to generate a unique user code after maximum retries."""


class DeviceCodeStatus(StrEnum):
"""Status values for device authorization codes."""

PENDING = "pending"
APPROVED = "approved"
DENIED = "denied"


@control_silo_model
class ApiDeviceCode(Model):
"""
Device authorization code for OAuth 2.0 Device Flow (RFC 8628).

This model stores the state of a device authorization request, which allows
headless devices (CLIs, Docker containers, CI/CD jobs) to obtain OAuth tokens
by having users authorize on a separate device with a browser.

Flow:
1. Device requests authorization via POST /oauth/device/code
2. Server returns device_code (secret) and user_code (human-readable)
3. Device displays user_code and verification_uri to user
4. Device polls POST /oauth/token with device_code
5. User visits verification_uri, enters user_code, and approves/denies
6. On approval, device receives access token on next poll

Reference: https://datatracker.ietf.org/doc/html/rfc8628
"""

__relocation_scope__ = RelocationScope.Global

# Device code: secret, high-entropy code used for token polling (RFC 8628 §3.2)
device_code = models.CharField(max_length=64, unique=True, default=generate_device_code)

# User code: human-readable code for user entry (RFC 8628 §3.2)
# Format: "XXXX-XXXX" using base-20 alphabet
# Must be unique since users look up by this code
user_code = models.CharField(max_length=16, unique=True, default=generate_user_code)
Comment on lines +94 to +100
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some actual uniqueness needs we have:

  1. device_code only needs to be unique in combination with the client id (guessing application below is that)
  2. user_code only needs to be unique in combination with user

So these fields might be overly strict with their current definitions of uniqueness.


# The OAuth application requesting authorization
application = FlexibleForeignKey("sentry.ApiApplication")

# User who approved the request (set when status changes to APPROVED)
user = FlexibleForeignKey("sentry.User", null=True, on_delete=models.CASCADE)

# Organization selected during approval (for org-level access apps)
organization_id = HybridCloudForeignKey(
"sentry.Organization",
db_index=True,
null=True,
on_delete="CASCADE",
)

# Requested scopes (space-delimited in requests, stored as array)
scope_list = ArrayField(models.TextField(), default=list)

# When this device code expires (RFC 8628 §3.2 expires_in)
expires_at = models.DateTimeField(db_index=True, default=default_expiration)

# Authorization status: pending -> approved/denied
status = models.CharField(max_length=20, default=DeviceCodeStatus.PENDING)

# Timestamps
date_added = models.DateTimeField(default=timezone.now)

class Meta:
app_label = "sentry"
db_table = "sentry_apidevicecode"

def __str__(self) -> str:
return f"id={self.id}, user_code={self.user_code}, application={self.application_id}, status={self.status}"

def get_scopes(self) -> list[str]:
"""Return the list of requested scopes."""
return self.scope_list

def has_scope(self, scope: str) -> bool:
"""Check if a specific scope was requested."""
return scope in self.scope_list

def is_expired(self) -> bool:
"""Check if the device code has expired."""
return timezone.now() >= self.expires_at

def is_pending(self) -> bool:
"""Check if the device code is still awaiting user action."""
return self.status == DeviceCodeStatus.PENDING

def is_approved(self) -> bool:
"""Check if the user has approved this device code."""
return self.status == DeviceCodeStatus.APPROVED

def is_denied(self) -> bool:
"""Check if the user has denied this device code."""
return self.status == DeviceCodeStatus.DENIED

@classmethod
def get_lock_key(cls, device_code_id: int) -> str:
"""Return lock key for preventing race conditions during token exchange."""
return f"api_device_code:{device_code_id}"

@classmethod
def sanitize_relocation_json(
cls, json: Any, sanitizer: Sanitizer, model_name: NormalizedModelName | None = None
) -> None:
model_name = get_model_name(cls) if model_name is None else model_name
super().sanitize_relocation_json(json, sanitizer, model_name)

sanitizer.set_string(
json, SanitizableField(model_name, "device_code"), lambda _: generate_device_code()
)
sanitizer.set_string(
json, SanitizableField(model_name, "user_code"), lambda _: generate_user_code()
)

@classmethod
def create_with_retry(cls, application, scope_list: list[str] | None = None) -> ApiDeviceCode:
"""
Create a new device code with retry logic for user code collisions.

Since user codes have ~34 bits of entropy, collisions are rare but possible.
This method retries with new codes if a collision occurs.

Args:
application: The ApiApplication requesting authorization
scope_list: Optional list of requested scopes

Returns:
A new ApiDeviceCode instance

Raises:
UserCodeCollisionError: If unable to generate a unique code after max retries
"""
if scope_list is None:
scope_list = []

last_error: IntegrityError | None = None
for attempt in range(MAX_CODE_GENERATION_RETRIES):
try:
return cls.objects.create(
application=application,
scope_list=scope_list,
)
except IntegrityError as e:
# Collision on device_code or user_code, retry with new generated codes
last_error = e

raise UserCodeCollisionError(
f"Unable to generate unique device code after {MAX_CODE_GENERATION_RETRIES} attempts"
) from last_error
10 changes: 10 additions & 0 deletions src/sentry/runner/commands/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ def remove_expired_values_for_org_members(
def delete_api_models(
is_filtered: Callable[[type[BaseModel]], bool], models_attempted: set[str]
) -> None:
from sentry.models.apidevicecode import ApiDeviceCode
from sentry.models.apigrant import ApiGrant
from sentry.models.apitoken import ApiToken

Expand All @@ -576,6 +577,15 @@ def delete_api_models(

queryset.delete()

# Device codes have short expiration times (10 minutes), so clean up
# any that have expired immediately without additional TTL buffer.
if is_filtered(ApiDeviceCode):
debug_output(">> Skipping ApiDeviceCode")
else:
debug_output("Removing expired values for ApiDeviceCode")
models_attempted.add(ApiDeviceCode.__name__.lower())
ApiDeviceCode.objects.filter(expires_at__lt=timezone.now()).delete()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any value in "soft deletion" for auditing purposes?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont think we do that with grants either, so dont think we want to do it here. if it comes up we can add it later, but we have metrics and logs to some degree for abuse/diagnostics



@continue_on_error("specialized_cleanup_exported_data")
def exported_data(
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/sentry_apps/token_exchange/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
AUTHORIZATION = "authorization_code"
REFRESH = "refresh_token"
CLIENT_SECRET_JWT = "urn:sentry:params:oauth:grant-type:jwt-bearer"
DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"


class GrantTypes:
AUTHORIZATION = AUTHORIZATION
REFRESH = REFRESH
CLIENT_SECRET_JWT = CLIENT_SECRET_JWT
DEVICE_CODE = DEVICE_CODE


def token_expiration() -> datetime:
Expand Down
Loading
Loading