-
-
Notifications
You must be signed in to change notification settings - Fork 4.6k
feat(oauth): Implement OAuth 2.0 Device Authorization Flow (RFC 8628) #105675
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
80dacc8
4694cba
40ca776
8db11e9
705bac3
45a298b
de2692d
7ce56ab
eea8152
f7016a1
0c7bf03
2745254
dcdc65d
26c62ea
f9ecf28
85ea05b
25813cb
2585a55
836a149
5e67f69
a1fffcf
df8eae2
e6b0fa2
3cf0171
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
| }, | ||
| ), | ||
| ] |
| 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) | ||
|
|
||
| # 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there any value in "soft deletion" for auditing purposes?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
|
||
There was a problem hiding this comment.
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:
device_codeonly needs to be unique in combination with the client id (guessingapplicationbelow is that)user_codeonly needs to be unique in combination withuserSo these fields might be overly strict with their current definitions of uniqueness.