Skip to content

feat(oauth): Implement OAuth 2.0 Device Authorization Flow (RFC 8628)#105675

Merged
dcramer merged 24 commits intomasterfrom
oauth-device-flow
Jan 12, 2026
Merged

feat(oauth): Implement OAuth 2.0 Device Authorization Flow (RFC 8628)#105675
dcramer merged 24 commits intomasterfrom
oauth-device-flow

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 +196
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
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
BYK pushed a commit that referenced this pull request Jan 13, 2026
…106169)

## Summary

Fixes the OAuth 2.0 Device Authorization Grant implementation to
properly support **public clients** as required by [RFC
8628](https://datatracker.ietf.org/doc/html/rfc8628).

## Problem

The current token endpoint requires `client_secret` for ALL grant types,
including `device_code`. This breaks the fundamental design of the
device flow, which is explicitly designed for public clients (CLIs,
native apps, IoT devices) that **cannot securely store secrets**.

### What the RFC Says

**[RFC 8628 §5.6 - Non-Confidential
Clients](https://datatracker.ietf.org/doc/html/rfc8628#section-5.6):**
> Device clients are generally incapable of maintaining the
confidentiality of their credentials, as users in possession of the
device can reverse-engineer it and extract the credentials. **Therefore,
unless additional measures are taken, they should be treated as public
clients** (as defined by Section 2.1 of [RFC6749]).

**[RFC 8628 §3.4 - Device Access Token
Request](https://datatracker.ietf.org/doc/html/rfc8628#section-3.4):**
> `client_id` - REQUIRED if the client is **not authenticating** with
the authorization server as described in Section 3.2.1. of [RFC6749].

**[RFC 6749 §2.1 - Client
Types](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1):**
> **public** - Clients incapable of maintaining the confidentiality of
their credentials (e.g., clients executing on the device used by the
resource owner, such as an installed native application or a web
browser-based application)

## Solution

- Move `grant_type` validation **before** credential check (needed to
determine auth requirements)
- For `device_code` grant: only require `client_id` (public client mode
per RFC 8628 §5.6)
- If `client_secret` is provided: still validate it (supports
confidential clients that choose to authenticate)
- Other grant types (`authorization_code`, `refresh_token`): unchanged,
still require `client_id` + `client_secret`

## Changes

| File | Change |
|------|--------|
| `src/sentry/web/frontend/oauth_token.py` | Allow public client auth
for device_code grant |
| `tests/sentry/web/frontend/test_oauth_token.py` | Added 5 new tests
for public client support |

## New Tests

1. `test_public_client_success` - Public client can exchange approved
device code
2. `test_public_client_invalid_client_id` - Invalid client_id rejected
with 401
3. `test_public_client_missing_client_id` - Missing client_id rejected
with 401
4. `test_public_client_authorization_pending` - Polling works for public
clients
5. `test_confidential_client_wrong_secret_rejected` - Wrong secret still
rejected when provided

## Security Considerations

This change is **RFC-compliant** and does not reduce security:

1. **Device code binding**: The `device_code` is bound to the
`application` at creation time, so a public client can only poll for
tokens for its own application
2. **User authorization**: The user must still explicitly approve the
request via the browser
3. **Confidential clients still supported**: If a client provides
`client_secret`, we validate it
4. **Rate limiting**: Existing rate limiting on device code polling
remains in place

## Related

- Original device flow implementation: #105675
- RFC 8628: https://datatracker.ietf.org/doc/html/rfc8628

---------

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
@github-actions github-actions bot locked and limited conversation to collaborators Jan 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

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.

2 participants