Skip to content

Conversation

@betegon
Copy link
Member

@betegon betegon commented Jan 15, 2026

Summary

Adds RFC-compliant public client support for refresh_token grant by tracking how each token was originally issued.

This is a follow-up to #106169 which added public client support for device_code grant.

Problem

After #106169, device flow clients can obtain tokens without client_secret. However, when these tokens expire and the client attempts to refresh them, it fails because refresh_token grant required client_secret for ALL tokens - even those issued to public clients.

What the RFCs Say

RFC 6749 §6 - Refreshing an Access Token:

Because refresh tokens are typically long-lasting credentials used to request additional access tokens, the refresh token is bound to the client to which it was issued. If the client type is confidential or the client was issued client credentials (or assigned other authentication requirements), the client MUST authenticate with the authorization server.

The inverse: public clients (without credentials) can refresh with just client_id.

RFC 8628 §5.6 - Non-Confidential Clients:

Device clients are generally incapable of maintaining the confidentiality of their credentials... Therefore, unless additional measures are taken, they should be treated as public clients.

Solution

Track issued_grant_type on each token to determine if it was issued to a public or confidential client:

issued_grant_type Client Type Can refresh without client_secret?
"device_code" Public (RFC 8628 §5.6) Yes
"authorization_code" Confidential No - must provide client_secret
null (legacy) Unknown (backward compat) No - treated as confidential

This ensures strict RFC 6749 §6 compliance: confidential client tokens still require client_secret for refresh.

Changes

File Change
src/sentry/models/apitoken.py Add issued_grant_type field; set to "authorization_code" in from_grant()
src/sentry/migrations/1019_add_issued_grant_type_to_apitoken.py Migration to add the new field
src/sentry/web/frontend/oauth_token.py Set issued_grant_type="device_code" for device flow tokens; check issued_grant_type in refresh logic
tests/sentry/web/frontend/test_oauth_token.py Add tests for RFC-compliant refresh behavior

New Tests

  1. test_device_flow_token_refresh_without_secret - Device code tokens CAN refresh without client_secret
  2. test_authorization_code_token_requires_secret_for_refresh - Auth code tokens MUST provide client_secret
  3. test_legacy_token_requires_secret_for_refresh - Legacy tokens (null) require client_secret for backward compatibility

Security

Per RFC 6749 §6, the refresh token is bound to the client. This is enforced by:

  1. Looking up the token by application (client binding)
  2. Checking issued_grant_type to determine authentication requirements
  3. Requiring client_secret for confidential client tokens

This prevents a public client from refreshing tokens that were issued to a confidential client.

Related

@betegon betegon requested a review from a team as a code owner January 15, 2026 10:55
@github-actions github-actions bot added the Scope: Backend Automatically applied to PRs that change backend components label Jan 15, 2026
@betegon betegon marked this pull request as draft January 15, 2026 11:53
@betegon betegon removed the request for review from a team January 15, 2026 11:53
…lient refresh

Per RFC 6749 §6, public clients (like CLI apps using device flow) should
not require client_secret for token refresh. This adds issued_grant_type
field to ApiToken to track how each token was issued:

- device_code: Can refresh without client_secret (public client per RFC 8628)
- authorization_code: Must provide client_secret (confidential client)
- null (legacy): Treated as confidential for backward compatibility

This ensures strict RFC compliance while maintaining security for
confidential clients.
@github-actions
Copy link
Contributor

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

for 1019_add_issued_grant_type_to_apitoken in sentry

--
-- Add field issued_grant_type to apitoken
--
ALTER TABLE "sentry_apitoken" ADD COLUMN "issued_grant_type" varchar(50) NULL;

… violation

Extract duplicated public client authentication logic into a reusable
helper method. The DEVICE_CODE and REFRESH grant handlers had ~35 lines
of nearly identical code for:
- Validating client_id presence
- Building query with optional client_secret
- Looking up ApiApplication
- Error handling with appropriate logging and metrics

This improves:
- DRY: Single source of truth for public client auth
- Maintainability: Easier to add new public client grant types
- Readability: post() method is now more concise
@betegon betegon marked this pull request as ready for review January 15, 2026 18:24
@betegon betegon requested a review from a team as a code owner January 15, 2026 18:24
Copy link
Member

@markstory markstory left a comment

Choose a reason for hiding this comment

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

Schema change looks ok.

user=grant.user,
scope_list=grant.get_scopes(),
scoping_organization_id=grant.organization_id,
issued_grant_type="authorization_code",
Copy link
Member

Choose a reason for hiding this comment

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

Could we use/expand the existing GrantType enum instead of adding magic strings?

Comment on lines +689 to +705
device_flow_token = ApiToken.objects.create(
application=self.application,
user=self.user,
expires_at=timezone.now(),
issued_grant_type="device_code",
)

# Request without client_secret (public client)
resp = self.client.post(
self.path,
{
"grant_type": "refresh_token",
"client_id": self.application.client_id,
"refresh_token": device_flow_token.refresh_token,
# No client_secret - this is a public client (device flow)
},
)
Copy link
Member

Choose a reason for hiding this comment

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

Should there also be a test covering device_code + client_secret submit?

},
)
assert resp.status_code == 400
assert json.loads(resp.content) == {"error": "invalid_client"}
Copy link
Member

Choose a reason for hiding this comment

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

I wouldn't know that I need to send my client secret as well by reading this error message.

@betegon
Copy link
Member Author

betegon commented Jan 16, 2026

thanks for the review @markstory . I'm putting this in draft because we're not sure we really need it. we'll do another pass on the RFC and I'll add your suggestions / close the PR.

@betegon betegon marked this pull request as draft January 16, 2026 16:09
@betegon
Copy link
Member Author

betegon commented Jan 16, 2026

closing this in favour of #106451

@betegon betegon closed this Jan 16, 2026
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants