From bd5fde82be4fe0c2b7f403bdf6a6e587de117618 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 26 Mar 2026 11:06:08 -0700 Subject: [PATCH] feat(oauth): Show public app device flow URLs Show the device authorization and verification URLs on the OAuth application details page for public clients. Public clients can use device flow, but the settings page only showed the authorize and token endpoints. Add the device endpoints alongside the existing OAuth URLs and normalize urlPrefix before composing them to avoid double slashes in copied values. --- .../account/apiApplications/details.spec.tsx | 12 +++++++++++ .../account/apiApplications/details.tsx | 21 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/static/app/views/settings/account/apiApplications/details.spec.tsx b/static/app/views/settings/account/apiApplications/details.spec.tsx index 083367bcf618b0..a1f788316ed308 100644 --- a/static/app/views/settings/account/apiApplications/details.spec.tsx +++ b/static/app/views/settings/account/apiApplications/details.spec.tsx @@ -8,6 +8,8 @@ import { import ApiApplicationDetails from 'sentry/views/settings/account/apiApplications/details'; describe('ApiApplicationDetails', () => { + const oauthBaseUrl = 'https://sentry-jest-tests.example.com/oauth'; + it('renders basic details for confidential client', async () => { MockApiClient.addMockResponse({ url: '/api-applications/abcd/', @@ -55,6 +57,10 @@ describe('ApiApplicationDetails', () => { expect(screen.getByLabelText('Client Secret')).toBeInTheDocument(); expect(screen.getByLabelText('Authorization URL')).toBeInTheDocument(); expect(screen.getByLabelText('Token URL')).toBeInTheDocument(); + expect(screen.getByDisplayValue(`${oauthBaseUrl}/authorize/`)).toBeInTheDocument(); + expect(screen.getByDisplayValue(`${oauthBaseUrl}/token/`)).toBeInTheDocument(); + expect(screen.queryByLabelText('Device Authorization URL')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Device Verification URL')).not.toBeInTheDocument(); }); it('handles client secret rotation', async () => { @@ -160,6 +166,10 @@ describe('ApiApplicationDetails', () => { expect(screen.getByLabelText('Client ID')).toBeInTheDocument(); expect(screen.getByDisplayValue('public-app')).toBeInTheDocument(); expect(screen.getByDisplayValue('Public CLI App')).toBeInTheDocument(); + expect(screen.getByDisplayValue(`${oauthBaseUrl}/authorize/`)).toBeInTheDocument(); + expect(screen.getByDisplayValue(`${oauthBaseUrl}/token/`)).toBeInTheDocument(); + expect(screen.getByDisplayValue(`${oauthBaseUrl}/device/code/`)).toBeInTheDocument(); + expect(screen.getByDisplayValue(`${oauthBaseUrl}/device/`)).toBeInTheDocument(); }); it('renders confidential client with client secret section', async () => { @@ -202,5 +212,7 @@ describe('ApiApplicationDetails', () => { expect( screen.queryByText(/This is a public client, designed for CLIs/) ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Device Authorization URL')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Device Verification URL')).not.toBeInTheDocument(); }); }); diff --git a/static/app/views/settings/account/apiApplications/details.tsx b/static/app/views/settings/account/apiApplications/details.tsx index 9369aea622e65b..aac78bc69d44db 100644 --- a/static/app/views/settings/account/apiApplications/details.tsx +++ b/static/app/views/settings/account/apiApplications/details.tsx @@ -1,5 +1,6 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; +import trimEnd from 'lodash/trimEnd'; import {Alert} from '@sentry/scraps/alert'; import {Tag} from '@sentry/scraps/badge'; @@ -54,6 +55,7 @@ function ApiApplicationsDetails() { const queryClient = useQueryClient(); const urlPrefix = ConfigStore.get('urlPrefix'); + const oauthBaseUrl = `${trimEnd(urlPrefix, '/')}/oauth`; const { data: app, @@ -173,12 +175,27 @@ function ApiApplicationsDetails() { )} - {`${urlPrefix}/oauth/authorize/`} + {`${oauthBaseUrl}/authorize/`} - {`${urlPrefix}/oauth/token/`} + {`${oauthBaseUrl}/token/`} + + {app.isPublic && ( + + + {`${oauthBaseUrl}/device/code/`} + + + + {`${oauthBaseUrl}/device/`} + + + )}