Skip to content

Conversation

@dcramer
Copy link
Member

@dcramer dcramer commented Jan 5, 2026

Summary

Adds support for the OAuth 2.0 Device Authorization Grant (RFC 8628), enabling headless clients (CLIs, CI/CD pipelines, Docker containers) to obtain OAuth tokens by having users authorize on a separate device with a browser.

Key Components

  • ApiDeviceCode model - Stores device authorization requests with secure device/user code generation
  • Device authorization endpoint (POST /oauth/device_authorization) - Returns device_code, user_code, and verification URLs
  • User verification pages (GET/POST /oauth/device) - Where users enter the code and approve/deny access
  • Token endpoint support - Added urn:ietf:params:oauth:grant-type:device_code grant type
  • Automatic cleanup - Expired device codes are cleaned up by the existing cleanup command

Flow

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

Refs #99002
Refs getsentry/sentry-mcp#546

@dcramer dcramer requested review from a team as code owners January 5, 2026 20:55
@github-actions github-actions bot added the Scope: Backend Automatically applied to PRs that change backend components label Jan 5, 2026
@dcramer
Copy link
Member Author

dcramer commented Jan 5, 2026

Total cost:            $23.57
Total duration (API):  41m 4s
Total duration (wall): 3h 9m 11s
Total code changes:    2374 lines added, 168 lines removed
Usage by model:
        claude-haiku:  231.1k input, 12.7k output, 411.7k cache read, 104.8k cache write ($0.4669)
     claude-opus-4-5:  14.5k input, 120.7k output, 22.4m cache read, 1.4m cache write, 1 web search ($23.10)

@github-actions
Copy link
Contributor

github-actions bot commented Jan 5, 2026

This PR has a migration; here is the generated SQL for src/sentry/migrations/1015_add_apidevicecode.py

for 1015_add_apidevicecode in sentry

--
-- Create model ApiDeviceCode
--
CREATE TABLE "sentry_apidevicecode" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "device_code" varchar(64) NOT NULL UNIQUE, "user_code" varchar(16) NOT NULL UNIQUE, "application_id" bigint NOT NULL, "user_id" bigint NULL, "organization_id" bigint NULL, "scope_list" text[] NOT NULL, "expires_at" timestamp with time zone NOT NULL, "status" varchar(20) NOT NULL, "date_added" timestamp with time zone NOT NULL);
ALTER TABLE "sentry_apidevicecode" ADD CONSTRAINT "sentry_apidevicecode_application_id_cf8361a8_fk_sentry_ap" FOREIGN KEY ("application_id") REFERENCES "sentry_apiapplication" ("id") DEFERRABLE INITIALLY DEFERRED NOT VALID;
ALTER TABLE "sentry_apidevicecode" VALIDATE CONSTRAINT "sentry_apidevicecode_application_id_cf8361a8_fk_sentry_ap";
ALTER TABLE "sentry_apidevicecode" ADD CONSTRAINT "sentry_apidevicecode_user_id_ec448031_fk_auth_user_id" FOREIGN KEY ("user_id") REFERENCES "auth_user" ("id") DEFERRABLE INITIALLY DEFERRED NOT VALID;
ALTER TABLE "sentry_apidevicecode" VALIDATE CONSTRAINT "sentry_apidevicecode_user_id_ec448031_fk_auth_user_id";
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_device_code_6d4da78d_like" ON "sentry_apidevicecode" ("device_code" varchar_pattern_ops);
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_user_code_90955a60_like" ON "sentry_apidevicecode" ("user_code" varchar_pattern_ops);
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_application_id_cf8361a8" ON "sentry_apidevicecode" ("application_id");
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_user_id_ec448031" ON "sentry_apidevicecode" ("user_id");
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_organization_id_c2717dcf" ON "sentry_apidevicecode" ("organization_id");
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_expires_at_1f1b6c16" ON "sentry_apidevicecode" ("expires_at");

name="api_device_code",
)

with lock.acquire():

This comment was marked as outdated.

Comment on lines +186 to +201
if scopes:
pending_scopes = set(scopes)
matched_sets = set()
for scope_set in settings.SENTRY_SCOPE_SETS:
for scope, description in scope_set:
if scope_set in matched_sets and scope in pending_scopes:
pending_scopes.remove(scope)
elif scope in pending_scopes:
permissions.append(description)
matched_sets.add(scope_set)
pending_scopes.remove(scope)

This comment was marked as outdated.

@dcramer
Copy link
Member Author

dcramer commented Jan 5, 2026

Total cost:            $46.37
Total duration (API):  1h 13m 21s
Total duration (wall): 5h 47m 11s
Total code changes:    3011 lines added, 701 lines removed
Usage by model:
        claude-haiku:  328.8k input, 15.4k output, 411.7k cache read, 104.8k cache write ($0.58)
     claude-opus-4-5:  26.9k input, 210.9k output, 43.9m cache read, 2.9m cache write, 1 web search ($45.79)

actually this shows almost 6 hours, so i guess the cost includes the previous cost...

@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Jan 6, 2026
dcramer added 11 commits January 9, 2026 20:27
Add ApiDeviceCode to the create_exhaustive_sentry_app method so that
backup/export tests properly include the new model in scoping tests.
Add the new oauth/device/ and oauth/device_authorization/ routes to the
controlsiloUrlPatterns.ts file to fix the test_no_missing_urls test.
Align with industry conventions (GitHub, Google) by using the shorter
/oauth/device/code path instead of /oauth/device_authorization.

- /oauth/device/code - device authorization endpoint (POST)
- /oauth/device - user verification page (GET/POST)
Move the device code status check before authorization creation and wrap
both operations in a transaction. This prevents orphaned authorizations
if the device code update fails (e.g., due to race condition where
another request already processed the device code).

Previously, the authorization was created first, then the device code
status was checked. If the status check failed, the authorization would
persist even though no token could be issued.
Wrap token creation and device code deletion in a transaction to
prevent duplicate tokens if delete fails after token creation.
This follows the same pattern as grant_exchanger.py.
PostgreSQL aborts transactions on IntegrityError, preventing subsequent
DB operations. Move try/except outside atomic block to allow the
scope-merging code in the except block to run correctly.
Add expiration check after re-fetching device code inside the lock to
prevent a race condition where a code could expire during lock wait.
Migration 1015 was taken by backfill_self_hosted_sentry_app_emails on
master, so rename to 1016 and update dependencies.
- Extract magic numbers into named constants (DEVICE_CODE_BYTES, USER_CODE_GROUP_LENGTH)
- Add user_code to ApiDeviceCode.__str__ for better debugging
- Add "You can now close this tab" UX message to completion page
- Use constants in _normalize_user_code instead of hardcoded values
@github-actions
Copy link
Contributor

This PR has a migration; here is the generated SQL for src/sentry/migrations/1017_add_apidevicecode.py

for 1017_add_apidevicecode in sentry

--
-- Create model ApiDeviceCode
--
CREATE TABLE "sentry_apidevicecode" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "device_code" varchar(64) NOT NULL UNIQUE, "user_code" varchar(16) NOT NULL UNIQUE, "application_id" bigint NOT NULL, "user_id" bigint NULL, "organization_id" bigint NULL, "scope_list" text[] NOT NULL, "expires_at" timestamp with time zone NOT NULL, "status" varchar(20) NOT NULL, "date_added" timestamp with time zone NOT NULL);
ALTER TABLE "sentry_apidevicecode" ADD CONSTRAINT "sentry_apidevicecode_application_id_cf8361a8_fk_sentry_ap" FOREIGN KEY ("application_id") REFERENCES "sentry_apiapplication" ("id") DEFERRABLE INITIALLY DEFERRED NOT VALID;
ALTER TABLE "sentry_apidevicecode" VALIDATE CONSTRAINT "sentry_apidevicecode_application_id_cf8361a8_fk_sentry_ap";
ALTER TABLE "sentry_apidevicecode" ADD CONSTRAINT "sentry_apidevicecode_user_id_ec448031_fk_auth_user_id" FOREIGN KEY ("user_id") REFERENCES "auth_user" ("id") DEFERRABLE INITIALLY DEFERRED NOT VALID;
ALTER TABLE "sentry_apidevicecode" VALIDATE CONSTRAINT "sentry_apidevicecode_user_id_ec448031_fk_auth_user_id";
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_device_code_6d4da78d_like" ON "sentry_apidevicecode" ("device_code" varchar_pattern_ops);
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_user_code_90955a60_like" ON "sentry_apidevicecode" ("user_code" varchar_pattern_ops);
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_application_id_cf8361a8" ON "sentry_apidevicecode" ("application_id");
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_user_id_ec448031" ON "sentry_apidevicecode" ("user_id");
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_organization_id_c2717dcf" ON "sentry_apidevicecode" ("organization_id");
CREATE INDEX CONCURRENTLY "sentry_apidevicecode_expires_at_1f1b6c16" ON "sentry_apidevicecode" ("expires_at");

Remove `# for type checker` and similar comments that aren't used
elsewhere in the codebase. The assertions are self-explanatory.
Restructure the code generation retry loop to avoid unreachable code
after the loop. The previous structure had a final raise statement
that could never be reached since the loop always either returned
on success or raised on the last failed attempt.

The new structure:
- Tracks the last IntegrityError for debugging context
- Raises after the loop completes (which only happens when all retries fail)
- Chains the original error using `from last_error` for better traceability
- Use StrEnum for DeviceCodeStatus for better type safety
- Remove redundant pass statement in UserCodeCollisionError
- Simplify scope parsing with inline conditional
- Remove dead code assignment for device_code.organization_id
POST requests to /oauth/device/code/ were returning 404 because Django's
APPEND_SLASH only redirects GET requests. Adding trailing slash for
consistency with other OAuth endpoints.
@dcramer dcramer merged commit d4e4b74 into master Jan 12, 2026
70 checks passed
@dcramer dcramer deleted the oauth-device-flow branch January 12, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants