From 71f15625afda0edc5a62dfaa7e6ab7cdd186f05d Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 20 Jan 2026 11:29:54 -0500 Subject: [PATCH 1/3] refactor(auth): device code flow Signed-off-by: Adam Setch --- src/renderer/__mocks__/account-mocks.ts | 6 +- src/renderer/constants.ts | 9 ++- src/renderer/context/App.tsx | 21 +++---- src/renderer/routes/LoginWithOAuthApp.tsx | 4 +- src/renderer/utils/auth/types.ts | 9 ++- src/renderer/utils/auth/utils.test.ts | 71 +++++++++++++++++++++-- src/renderer/utils/auth/utils.ts | 45 ++++++++++++-- src/renderer/utils/helpers.ts | 4 +- src/renderer/utils/storage.test.ts | 6 +- 9 files changed, 135 insertions(+), 40 deletions(-) diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts index 16787ffa4..3ded02c4e 100644 --- a/src/renderer/__mocks__/account-mocks.ts +++ b/src/renderer/__mocks__/account-mocks.ts @@ -14,7 +14,7 @@ export const mockGitHubAppAccount: Account = { platform: 'GitHub Cloud', method: 'GitHub App', token: 'token-987654321' as Token, - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, user: mockGitifyUser, hasRequiredScopes: true, }; @@ -23,7 +23,7 @@ export const mockPersonalAccessTokenAccount: Account = { platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, user: mockGitifyUser, hasRequiredScopes: true, }; @@ -41,7 +41,7 @@ export const mockGitHubCloudAccount: Account = { platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, user: mockGitifyUser, version: 'latest', hasRequiredScopes: true, diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index c81c24317..b4f4e89ad 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -1,5 +1,5 @@ -import type { ClientID, ClientSecret, Hostname, Link } from './types'; -import type { LoginOAuthAppOptions } from './utils/auth/types'; +import type { ClientID, Hostname, Link } from './types'; +import type { LoginOAuthDeviceOptions } from './utils/auth/types'; export const Constants = { STORAGE_KEY: 'gitify-storage', @@ -10,11 +10,10 @@ export const Constants = { ALTERNATE: ['read:user', 'notifications', 'public_repo'], }, - DEFAULT_AUTH_OPTIONS: { + OAUTH_DEVICE_FLOW: { hostname: 'github.com' as Hostname, clientId: process.env.OAUTH_CLIENT_ID as ClientID, - clientSecret: process.env.OAUTH_CLIENT_SECRET as ClientSecret, - } satisfies LoginOAuthAppOptions, + } satisfies LoginOAuthDeviceOptions, GITHUB_API_BASE_URL: 'https://api.github.com', GITHUB_API_GRAPHQL_URL: 'https://api.github.com/graphql', diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 4d37c4491..2092cd35e 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -32,7 +32,7 @@ import type { } from '../types'; import { FetchType } from '../types'; import type { - LoginOAuthAppOptions, + LoginOAuthWebOptions, LoginPersonalAccessTokenOptions, } from '../utils/auth/types'; @@ -42,7 +42,8 @@ import { exchangeAuthCodeForAccessToken, getAccountUUID, hasAccounts, - performGitHubOAuth, + performGitHubDeviceOAuth, + performGitHubWebOAuth, refreshAccount, removeAccount, } from '../utils/auth/utils'; @@ -75,7 +76,7 @@ export interface AppContextState { auth: AuthState; isLoggedIn: boolean; loginWithGitHubApp: () => Promise; - loginWithOAuthApp: (data: LoginOAuthAppOptions) => Promise; + loginWithOAuthApp: (data: LoginOAuthWebOptions) => Promise; loginWithPersonalAccessToken: ( data: LoginPersonalAccessTokenOptions, ) => Promise; @@ -396,15 +397,11 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [auth]); /** - * Login with GitHub App. - * - * Note: although we call this "Login with GitHub App", this function actually - * authenticates via a predefined "Gitify" GitHub OAuth App. + * Login with GitHub App using device flow so the client secret is never bundled or persisted. */ const loginWithGitHubApp = useCallback(async () => { - const { authCode } = await performGitHubOAuth(); - const token = await exchangeAuthCodeForAccessToken(authCode); - const hostname = Constants.DEFAULT_AUTH_OPTIONS.hostname; + const token = await performGitHubDeviceOAuth(); + const hostname = Constants.OAUTH_DEVICE_FLOW.hostname; const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); @@ -415,8 +412,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { * Login with custom GitHub OAuth App. */ const loginWithOAuthApp = useCallback( - async (data: LoginOAuthAppOptions) => { - const { authOptions, authCode } = await performGitHubOAuth(data); + async (data: LoginOAuthWebOptions) => { + const { authOptions, authCode } = await performGitHubWebOAuth(data); const token = await exchangeAuthCodeForAccessToken(authCode, authOptions); const updatedAuth = await addAccount( diff --git a/src/renderer/routes/LoginWithOAuthApp.tsx b/src/renderer/routes/LoginWithOAuthApp.tsx index ac5674194..599e53c87 100644 --- a/src/renderer/routes/LoginWithOAuthApp.tsx +++ b/src/renderer/routes/LoginWithOAuthApp.tsx @@ -28,7 +28,7 @@ import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; import type { ClientID, ClientSecret, Hostname, Token } from '../types'; -import type { LoginOAuthAppOptions } from '../utils/auth/types'; +import type { LoginOAuthWebOptions } from '../utils/auth/types'; import { getNewOAuthAppURL, @@ -116,7 +116,7 @@ export const LoginWithOAuthAppRoute: FC = () => { const verifyLoginCredentials = useCallback( async (data: IFormData) => { try { - await loginWithOAuthApp(data as LoginOAuthAppOptions); + await loginWithOAuthApp(data as LoginOAuthWebOptions); navigate(-1); } catch (err) { rendererLogError( diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index 684f73904..689f6aeda 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -10,7 +10,12 @@ export type AuthMethod = 'GitHub App' | 'Personal Access Token' | 'OAuth App'; export type PlatformType = 'GitHub Cloud' | 'GitHub Enterprise Server'; -export interface LoginOAuthAppOptions { +export interface LoginOAuthDeviceOptions { + hostname: Hostname; + clientId: ClientID; +} + +export interface LoginOAuthWebOptions { hostname: Hostname; clientId: ClientID; clientSecret: ClientSecret; @@ -24,5 +29,5 @@ export interface LoginPersonalAccessTokenOptions { export interface AuthResponse { authMethod: AuthMethod; authCode: AuthCode; - authOptions: LoginOAuthAppOptions; + authOptions: LoginOAuthWebOptions; } diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index 98fc67a8c..a4f481ef8 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -24,11 +24,23 @@ import { getNewOAuthAppURL, getNewTokenURL } from './utils'; jest.mock('@octokit/oauth-methods', () => ({ ...jest.requireActual('@octokit/oauth-methods'), + createDeviceCode: jest.fn(), + exchangeDeviceCode: jest.fn(), exchangeWebFlowCode: jest.fn(), })); -import { exchangeWebFlowCode } from '@octokit/oauth-methods'; +import { + createDeviceCode, + exchangeDeviceCode, + exchangeWebFlowCode, +} from '@octokit/oauth-methods'; +const createDeviceCodeMock = createDeviceCode as jest.MockedFunction< + typeof createDeviceCode +>; +const exchangeDeviceCodeMock = exchangeDeviceCode as jest.MockedFunction< + typeof exchangeDeviceCode +>; const exchangeWebFlowCodeMock = exchangeWebFlowCode as jest.MockedFunction< typeof exchangeWebFlowCode >; @@ -48,6 +60,47 @@ describe('renderer/utils/auth/utils.ts', () => { jest.clearAllMocks(); }); + it('should authenticate using device flow for GitHub app', async () => { + createDeviceCodeMock.mockResolvedValueOnce({ + device_code: 'device-code', + user_code: 'user-code', + verification_uri: 'https://github.com/login/device', + verification_uri_complete: + 'https://github.com/login/device?user_code=user-code', + expires_in: 900, + interval: 5, + } as any); + + exchangeDeviceCodeMock.mockResolvedValueOnce({ + authentication: { + token: 'device-token', + }, + } as any); + + const token = await authUtils.performGitHubDeviceOAuth(); + + expect(createDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + scopes: Constants.OAUTH_SCOPES.RECOMMENDED, + request: expect.any(Function), + }); + + expect(openExternalLinkSpy).toHaveBeenCalledWith( + 'https://github.com/login/device?user_code=user-code', + ); + + expect(exchangeDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + code: 'device-code', + interval: 5, + request: expect.any(Function), + }); + + expect(token).toBe('device-token'); + }); + it('should call performGitHubOAuth using gitify oauth app - success auth flow', async () => { window.gitify.onAuthCallback = jest .fn() @@ -143,10 +196,10 @@ describe('renderer/utils/auth/utils.ts', () => { }, } as any); - const res = await authUtils.exchangeAuthCodeForAccessToken( - authCode, - Constants.DEFAULT_AUTH_OPTIONS, - ); + const res = await authUtils.exchangeAuthCodeForAccessToken(authCode, { + ...Constants.OAUTH_DEVICE_FLOW, + clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, + }); expect(exchangeWebFlowCodeMock).toHaveBeenCalledWith({ clientType: 'oauth-app', @@ -157,6 +210,12 @@ describe('renderer/utils/auth/utils.ts', () => { }); expect(res).toBe('this-is-a-token'); }); + + it('should throw when client secret is missing', async () => { + await expect( + async () => await authUtils.exchangeAuthCodeForAccessToken(authCode), + ).rejects.toThrow('clientSecret is required to exchange an auth code'); + }); }); describe('addAccount', () => { @@ -475,7 +534,7 @@ describe('renderer/utils/auth/utils.ts', () => { it('should use default hostname if no accounts', () => { expect(authUtils.getPrimaryAccountHostname({ accounts: [] })).toBe( - Constants.DEFAULT_AUTH_OPTIONS.hostname, + Constants.OAUTH_DEVICE_FLOW.hostname, ); }); }); diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 6246a707e..ce4bf591c 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -1,4 +1,6 @@ import { + createDeviceCode, + exchangeDeviceCode, exchangeWebFlowCode, getWebFlowAuthorizationUrl, } from '@octokit/oauth-methods'; @@ -19,7 +21,7 @@ import type { Link, Token, } from '../../types'; -import type { AuthMethod, AuthResponse, LoginOAuthAppOptions } from './types'; +import type { AuthMethod, AuthResponse, LoginOAuthWebOptions } from './types'; import { fetchAuthenticatedUserDetails } from '../api/client'; import { getGitHubAuthBaseUrl } from '../api/utils'; @@ -27,8 +29,8 @@ import { encryptValue, openExternalLink } from '../comms'; import { getPlatformFromHostname } from '../helpers'; import { rendererLogError, rendererLogInfo, rendererLogWarn } from '../logger'; -export function performGitHubOAuth( - authOptions: LoginOAuthAppOptions = Constants.DEFAULT_AUTH_OPTIONS, +export function performGitHubWebOAuth( + authOptions: LoginOAuthWebOptions, ): Promise { return new Promise((resolve, reject) => { const { url } = getWebFlowAuthorizationUrl({ @@ -80,10 +82,43 @@ export function performGitHubOAuth( }); } +export async function performGitHubDeviceOAuth(): Promise { + const deviceCode = await createDeviceCode({ + clientType: 'oauth-app', + clientId: Constants.OAUTH_DEVICE_FLOW.clientId, + scopes: Constants.OAUTH_SCOPES.RECOMMENDED, + request: request.defaults({ + baseUrl: getGitHubAuthBaseUrl( + Constants.OAUTH_DEVICE_FLOW.hostname, + ).toString(), + }), + }); + + openExternalLink(deviceCode.data.verification_uri as Link); + + const { authentication } = await exchangeDeviceCode({ + clientType: 'oauth-app', + clientId: Constants.OAUTH_DEVICE_FLOW.clientId, + code: deviceCode.data.device_code, + interval: deviceCode.data.interval, + request: request.defaults({ + baseUrl: getGitHubAuthBaseUrl( + Constants.OAUTH_DEVICE_FLOW.hostname, + ).toString(), + }), + }); + + return authentication.token as Token; +} + export async function exchangeAuthCodeForAccessToken( authCode: AuthCode, - authOptions: LoginOAuthAppOptions = Constants.DEFAULT_AUTH_OPTIONS, + authOptions: LoginOAuthWebOptions, ): Promise { + if (!authOptions.clientSecret) { + throw new Error('clientSecret is required to exchange an auth code'); + } + const { authentication } = await exchangeWebFlowCode({ clientType: 'oauth-app', clientId: authOptions.clientId, @@ -278,7 +313,7 @@ export function getAccountUUID(account: Account): string { * Return the primary (first) account hostname */ export function getPrimaryAccountHostname(auth: AuthState) { - return auth.accounts[0]?.hostname ?? Constants.DEFAULT_AUTH_OPTIONS.hostname; + return auth.accounts[0]?.hostname ?? Constants.OAUTH_DEVICE_FLOW.hostname; } export function hasAccounts(auth: AuthState) { diff --git a/src/renderer/utils/helpers.ts b/src/renderer/utils/helpers.ts index 04788b63a..334c6def6 100644 --- a/src/renderer/utils/helpers.ts +++ b/src/renderer/utils/helpers.ts @@ -14,13 +14,13 @@ import { rendererLogError } from './logger'; import { createNotificationHandler } from './notifications/handlers'; export function getPlatformFromHostname(hostname: string): PlatformType { - return hostname.endsWith(Constants.DEFAULT_AUTH_OPTIONS.hostname) + return hostname.endsWith(Constants.OAUTH_DEVICE_FLOW.hostname) ? 'GitHub Cloud' : 'GitHub Enterprise Server'; } export function isEnterpriseServerHost(hostname: Hostname): boolean { - return !hostname.endsWith(Constants.DEFAULT_AUTH_OPTIONS.hostname); + return !hostname.endsWith(Constants.OAUTH_DEVICE_FLOW.hostname); } export function generateNotificationReferrerId( diff --git a/src/renderer/utils/storage.test.ts b/src/renderer/utils/storage.test.ts index f4e69377b..6d38df7d8 100644 --- a/src/renderer/utils/storage.test.ts +++ b/src/renderer/utils/storage.test.ts @@ -13,7 +13,7 @@ describe('renderer/utils/storage.ts', () => { auth: { accounts: [ { - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, platform: 'GitHub Cloud', method: 'Personal Access Token', token: '123-456' as Token, @@ -28,7 +28,7 @@ describe('renderer/utils/storage.ts', () => { expect(result.auth.accounts).toEqual([ { - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, platform: 'GitHub Cloud', method: 'Personal Access Token', token: '123-456' as Token, @@ -55,7 +55,7 @@ describe('renderer/utils/storage.ts', () => { auth: { accounts: [ { - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.OAUTH_DEVICE_FLOW.hostname, platform: 'GitHub Cloud', method: 'Personal Access Token', token: '123-456' as Token, From fb2cf1e5c10c5fc25306eecb7f4629dbea29ad4b Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 20 Jan 2026 11:50:38 -0500 Subject: [PATCH 2/3] refactor(auth): device code flow Signed-off-by: Adam Setch --- src/renderer/App.tsx | 5 + src/renderer/__helpers__/test-utils.tsx | 3 + src/renderer/context/App.tsx | 71 +++++- src/renderer/routes/Login.tsx | 21 +- .../routes/LoginWithDeviceFlow.test.tsx | 96 ++++++++ src/renderer/routes/LoginWithDeviceFlow.tsx | 231 ++++++++++++++++++ src/renderer/utils/auth/types.ts | 10 + src/renderer/utils/auth/utils.test.ts | 49 ++-- src/renderer/utils/auth/utils.ts | 83 +++++-- src/renderer/utils/comms.ts | 4 +- 10 files changed, 509 insertions(+), 64 deletions(-) create mode 100644 src/renderer/routes/LoginWithDeviceFlow.test.tsx create mode 100644 src/renderer/routes/LoginWithDeviceFlow.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d652819ae..541344f66 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -12,6 +12,7 @@ import { AppProvider } from './context/App'; import { AccountsRoute } from './routes/Accounts'; import { FiltersRoute } from './routes/Filters'; import { LoginRoute } from './routes/Login'; +import { LoginWithDeviceFlowRoute } from './routes/LoginWithDeviceFlow'; import { LoginWithOAuthAppRoute } from './routes/LoginWithOAuthApp'; import { LoginWithPersonalAccessTokenRoute } from './routes/LoginWithPersonalAccessToken'; import { NotificationsRoute } from './routes/Notifications'; @@ -78,6 +79,10 @@ export const App = () => { path="/accounts" /> } path="/login" /> + } + path="/login-device-flow" + /> } path="/login-personal-access-token" diff --git a/src/renderer/__helpers__/test-utils.tsx b/src/renderer/__helpers__/test-utils.tsx index 6a4a8cbdf..1a35488b2 100644 --- a/src/renderer/__helpers__/test-utils.tsx +++ b/src/renderer/__helpers__/test-utils.tsx @@ -42,6 +42,9 @@ export function AppContextProvider({ // Default mock implementations for all required methods loginWithGitHubApp: jest.fn(), + startGitHubDeviceFlow: jest.fn(), + pollGitHubDeviceFlow: jest.fn(), + completeGitHubDeviceLogin: jest.fn(), loginWithOAuthApp: jest.fn(), loginWithPersonalAccessToken: jest.fn(), logoutFromAccount: jest.fn(), diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 2092cd35e..43037dea5 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -25,6 +25,7 @@ import type { FilterSettingsValue, GitifyError, GitifyNotification, + Hostname, SettingsState, SettingsValue, Status, @@ -32,6 +33,7 @@ import type { } from '../types'; import { FetchType } from '../types'; import type { + DeviceFlowSession, LoginOAuthWebOptions, LoginPersonalAccessTokenOptions, } from '../utils/auth/types'; @@ -42,10 +44,11 @@ import { exchangeAuthCodeForAccessToken, getAccountUUID, hasAccounts, - performGitHubDeviceOAuth, performGitHubWebOAuth, + pollGitHubDeviceFlow, refreshAccount, removeAccount, + startGitHubDeviceFlow, } from '../utils/auth/utils'; import { decryptValue, @@ -76,6 +79,12 @@ export interface AppContextState { auth: AuthState; isLoggedIn: boolean; loginWithGitHubApp: () => Promise; + startGitHubDeviceFlow: () => Promise; + pollGitHubDeviceFlow: (session: DeviceFlowSession) => Promise; + completeGitHubDeviceLogin: ( + token: Token, + hostname?: Hostname, + ) => Promise; loginWithOAuthApp: (data: LoginOAuthWebOptions) => Promise; loginWithPersonalAccessToken: ( data: LoginPersonalAccessTokenOptions, @@ -396,17 +405,61 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { return hasAccounts(auth); }, [auth]); + /** + * Start a GitHub device flow session. + */ + const startGitHubDeviceFlowWithDefaults = useCallback( + async () => await startGitHubDeviceFlow(), + [], + ); + + /** + * Poll GitHub device flow session for completion. + */ + const pollGitHubDeviceFlowWithSession = useCallback( + async (session: DeviceFlowSession) => await pollGitHubDeviceFlow(session), + [], + ); + + /** + * Persist GitHub app login after device flow completes. + */ + const completeGitHubDeviceLogin = useCallback( + async ( + token: Token, + hostname: Hostname = Constants.OAUTH_DEVICE_FLOW.hostname, + ) => { + const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); + + persistAuth(updatedAuth); + }, + [auth, persistAuth], + ); + /** * Login with GitHub App using device flow so the client secret is never bundled or persisted. */ const loginWithGitHubApp = useCallback(async () => { - const token = await performGitHubDeviceOAuth(); - const hostname = Constants.OAUTH_DEVICE_FLOW.hostname; + const session = await startGitHubDeviceFlowWithDefaults(); + const intervalMs = Math.max(5000, session.intervalSeconds * 1000); - const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); + while (Date.now() < session.expiresAt) { + const token = await pollGitHubDeviceFlowWithSession(session); - persistAuth(updatedAuth); - }, [auth, persistAuth]); + if (token) { + await completeGitHubDeviceLogin(token, session.hostname); + return; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error('Device code expired before authorization completed'); + }, [ + startGitHubDeviceFlowWithDefaults, + pollGitHubDeviceFlowWithSession, + completeGitHubDeviceLogin, + ]); /** * Login with custom GitHub OAuth App. @@ -487,6 +540,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { auth, isLoggedIn, loginWithGitHubApp, + startGitHubDeviceFlow: startGitHubDeviceFlowWithDefaults, + pollGitHubDeviceFlow: pollGitHubDeviceFlowWithSession, + completeGitHubDeviceLogin, loginWithOAuthApp, loginWithPersonalAccessToken, logoutFromAccount, @@ -517,6 +573,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { auth, isLoggedIn, loginWithGitHubApp, + startGitHubDeviceFlowWithDefaults, + pollGitHubDeviceFlowWithSession, + completeGitHubDeviceLogin, loginWithOAuthApp, loginWithPersonalAccessToken, logoutFromAccount, diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx index c8dbffda6..711ce3007 100644 --- a/src/renderer/routes/Login.tsx +++ b/src/renderer/routes/Login.tsx @@ -1,4 +1,4 @@ -import { type FC, useCallback, useEffect } from 'react'; +import { type FC, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { KeyIcon, MarkGithubIcon, PersonIcon } from '@primer/octicons-react'; @@ -12,31 +12,18 @@ import { Centered } from '../components/layout/Centered'; import { Size } from '../types'; import { showWindow } from '../utils/comms'; -import { rendererLogError } from '../utils/logger'; export const LoginRoute: FC = () => { const navigate = useNavigate(); - const { loginWithGitHubApp, isLoggedIn } = useAppContext(); + const { isLoggedIn } = useAppContext(); useEffect(() => { if (isLoggedIn) { showWindow(); navigate('/', { replace: true }); } - }, [isLoggedIn]); - - const loginUser = useCallback(async () => { - try { - await loginWithGitHubApp(); - } catch (err) { - rendererLogError( - 'loginWithGitHubApp', - 'failed to login with GitHub', - err, - ); - } - }, [loginWithGitHubApp]); + }, [isLoggedIn, navigate]); return ( @@ -54,7 +41,7 @@ export const LoginRoute: FC = () => { + + {session && ( + + )} + + + ); +}; diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index 689f6aeda..71a7fa324 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -21,6 +21,16 @@ export interface LoginOAuthWebOptions { clientSecret: ClientSecret; } +export interface DeviceFlowSession { + hostname: Hostname; + clientId: ClientID; + deviceCode: string; + userCode: string; + verificationUri: string; + intervalSeconds: number; + expiresAt: number; +} + export interface LoginPersonalAccessTokenOptions { hostname: Hostname; token: Token; diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index a4f481ef8..3fc7b92ec 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -14,7 +14,7 @@ import type { Hostname, Token, } from '../../types'; -import type { AuthMethod } from './types'; +import type { AuthMethod, LoginOAuthWebOptions } from './types'; import * as comms from '../../utils/comms'; import * as apiClient from '../api/client'; @@ -50,6 +50,12 @@ describe('renderer/utils/auth/utils.ts', () => { configureAxiosHttpAdapterForNock(); }); + const webAuthOptions: LoginOAuthWebOptions = { + hostname: 'github.com' as Hostname, + clientId: 'FAKE_CLIENT_ID_123' as ClientID, + clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, + }; + describe('authGitHub', () => { jest.spyOn(logger, 'rendererLogInfo').mockImplementation(); const openExternalLinkSpy = jest @@ -62,20 +68,20 @@ describe('renderer/utils/auth/utils.ts', () => { it('should authenticate using device flow for GitHub app', async () => { createDeviceCodeMock.mockResolvedValueOnce({ - device_code: 'device-code', - user_code: 'user-code', - verification_uri: 'https://github.com/login/device', - verification_uri_complete: - 'https://github.com/login/device?user_code=user-code', - expires_in: 900, - interval: 5, - } as any); + data: { + device_code: 'device-code', + user_code: 'user-code', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + } as unknown as Awaited>); exchangeDeviceCodeMock.mockResolvedValueOnce({ authentication: { token: 'device-token', }, - } as any); + } as unknown as Awaited>); const token = await authUtils.performGitHubDeviceOAuth(); @@ -101,14 +107,14 @@ describe('renderer/utils/auth/utils.ts', () => { expect(token).toBe('device-token'); }); - it('should call performGitHubOAuth using gitify oauth app - success auth flow', async () => { + it('should call performGitHubWebOAuth using gitify oauth app - success auth flow', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { callback('gitify://auth?code=123-456'); }); - const res = await authUtils.performGitHubOAuth(); + const res = await authUtils.performGitHubWebOAuth(webAuthOptions); expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); expect(openExternalLinkSpy).toHaveBeenCalledWith( @@ -126,14 +132,14 @@ describe('renderer/utils/auth/utils.ts', () => { expect(res.authCode).toBe('123-456'); }); - it('should call performGitHubOAuth using custom oauth app - success oauth flow', async () => { + it('should call performGitHubWebOAuth using custom oauth app - success oauth flow', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { callback('gitify://oauth?code=123-456'); }); - const res = await authUtils.performGitHubOAuth({ + const res = await authUtils.performGitHubWebOAuth({ clientId: 'BYO_CLIENT_ID' as ClientID, clientSecret: 'BYO_CLIENT_SECRET' as ClientSecret, hostname: 'my.git.com' as Hostname, @@ -155,7 +161,7 @@ describe('renderer/utils/auth/utils.ts', () => { expect(res.authCode).toBe('123-456'); }); - it('should call performGitHubOAuth - failure', async () => { + it('should call performGitHubWebOAuth - failure', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { @@ -165,7 +171,7 @@ describe('renderer/utils/auth/utils.ts', () => { }); await expect( - async () => await authUtils.performGitHubOAuth(), + async () => await authUtils.performGitHubWebOAuth(webAuthOptions), ).rejects.toEqual( new Error( "Oops! Something went wrong and we couldn't log you in using GitHub. Please try again. Reason: The redirect_uri is missing or invalid. Docs: https://docs.github.com/en/developers/apps/troubleshooting-oauth-errors", @@ -194,11 +200,10 @@ describe('renderer/utils/auth/utils.ts', () => { authentication: { token: 'this-is-a-token', }, - } as any); + } as unknown as Awaited>); const res = await authUtils.exchangeAuthCodeForAccessToken(authCode, { - ...Constants.OAUTH_DEVICE_FLOW, - clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, + ...webAuthOptions, }); expect(exchangeWebFlowCodeMock).toHaveBeenCalledWith({ @@ -213,7 +218,11 @@ describe('renderer/utils/auth/utils.ts', () => { it('should throw when client secret is missing', async () => { await expect( - async () => await authUtils.exchangeAuthCodeForAccessToken(authCode), + async () => + await authUtils.exchangeAuthCodeForAccessToken(authCode, { + ...webAuthOptions, + clientSecret: undefined as unknown as ClientSecret, + }), ).rejects.toThrow('clientSecret is required to exchange an auth code'); }); }); diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index ce4bf591c..b233aafb0 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -21,7 +21,13 @@ import type { Link, Token, } from '../../types'; -import type { AuthMethod, AuthResponse, LoginOAuthWebOptions } from './types'; +import type { + AuthMethod, + AuthResponse, + DeviceFlowSession, + LoginOAuthDeviceOptions, + LoginOAuthWebOptions, +} from './types'; import { fetchAuthenticatedUserDetails } from '../api/client'; import { getGitHubAuthBaseUrl } from '../api/utils'; @@ -82,33 +88,70 @@ export function performGitHubWebOAuth( }); } -export async function performGitHubDeviceOAuth(): Promise { +export async function startGitHubDeviceFlow( + authOptions: LoginOAuthDeviceOptions = Constants.OAUTH_DEVICE_FLOW, +): Promise { const deviceCode = await createDeviceCode({ - clientType: 'oauth-app', - clientId: Constants.OAUTH_DEVICE_FLOW.clientId, + clientType: 'oauth-app' as const, + clientId: authOptions.clientId, scopes: Constants.OAUTH_SCOPES.RECOMMENDED, request: request.defaults({ - baseUrl: getGitHubAuthBaseUrl( - Constants.OAUTH_DEVICE_FLOW.hostname, - ).toString(), + baseUrl: getGitHubAuthBaseUrl(authOptions.hostname).toString(), }), }); - openExternalLink(deviceCode.data.verification_uri as Link); + return { + hostname: authOptions.hostname, + clientId: authOptions.clientId, + deviceCode: deviceCode.data.device_code, + userCode: deviceCode.data.user_code, + verificationUri: deviceCode.data.verification_uri, + intervalSeconds: deviceCode.data.interval, + expiresAt: Date.now() + deviceCode.data.expires_in * 1000, + } as DeviceFlowSession; +} - const { authentication } = await exchangeDeviceCode({ - clientType: 'oauth-app', - clientId: Constants.OAUTH_DEVICE_FLOW.clientId, - code: deviceCode.data.device_code, - interval: deviceCode.data.interval, - request: request.defaults({ - baseUrl: getGitHubAuthBaseUrl( - Constants.OAUTH_DEVICE_FLOW.hostname, - ).toString(), - }), - }); +export async function pollGitHubDeviceFlow( + session: DeviceFlowSession, +): Promise { + try { + const { authentication } = await exchangeDeviceCode({ + clientType: 'oauth-app' as const, + clientId: session.clientId, + code: session.deviceCode, + request: request.defaults({ + baseUrl: getGitHubAuthBaseUrl(session.hostname).toString(), + }), + }); - return authentication.token as Token; + return (authentication as { token: string }).token as Token; + } catch (err) { + const errorCode = (err as Record)?.response?.data?.error; + + if (errorCode === 'authorization_pending' || errorCode === 'slow_down') { + return null; + } + + throw err; + } +} + +export async function performGitHubDeviceOAuth(): Promise { + const session = await startGitHubDeviceFlow(); + + const intervalMs = Math.max(5000, session.intervalSeconds * 1000); + + while (Date.now() < session.expiresAt) { + const token = await pollGitHubDeviceFlow(session); + + if (token) { + return token; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error('Device code expired before authorization completed'); } export async function exchangeAuthCodeForAccessToken( diff --git a/src/renderer/utils/comms.ts b/src/renderer/utils/comms.ts index 5ffa3b64c..e5b91d420 100644 --- a/src/renderer/utils/comms.ts +++ b/src/renderer/utils/comms.ts @@ -50,7 +50,9 @@ export function setAutoLaunch(value: boolean): void { export function setUseAlternateIdleIcon(value: boolean): void { window.gitify.tray.useAlternateIdleIcon(value); } - +export async function copyToClipboard(text: string): Promise { + await navigator.clipboard.writeText(text); +} export function setUseUnreadActiveIcon(value: boolean): void { window.gitify.tray.useUnreadActiveIcon(value); } From a1ce6a49f8f8640a188b8ad9e9583b11782f36b1 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 20 Jan 2026 12:10:30 -0500 Subject: [PATCH 3/3] refactor(auth): device code flow Signed-off-by: Adam Setch --- src/renderer/utils/auth/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index b233aafb0..96b5d9909 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -124,7 +124,7 @@ export async function pollGitHubDeviceFlow( }), }); - return (authentication as { token: string }).token as Token; + return authentication.token as Token; } catch (err) { const errorCode = (err as Record)?.response?.data?.error;